Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e10fae0710 |
@@ -37,6 +37,12 @@ jobs:
|
||||
- name: Reproducible-release invariant
|
||||
run: bash scripts/check_reproducible_release.sh
|
||||
|
||||
# Play rejects a "What's New" over 500 characters, which would fail the
|
||||
# upload after the release had already shipped everywhere else. Cheap, so
|
||||
# it runs on every PR rather than only on the release merge.
|
||||
- name: Changelog length invariant
|
||||
run: bash scripts/check_changelog_lengths.sh
|
||||
|
||||
# Decide whether anything that affects the app build changed. Docs, store
|
||||
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
||||
# the SDK + Gradle work below but still report a green `ci`.
|
||||
|
||||
+84
-1
@@ -5,7 +5,78 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [2.20.0] — 2026-09-07
|
||||
|
||||
### Added
|
||||
- **Long-press to copy.** Holding down the title, the location or the
|
||||
description on an event puts that whole field on the clipboard. Tapping still
|
||||
does what it always did — the location opens maps, a link in the description
|
||||
opens — and the times, calendar, repeat rule and guests stay uncopyable. Below
|
||||
Android 13 a short confirmation appears; from Android 13 on the system shows
|
||||
its own clipboard chip ([#195]).
|
||||
- **The open-source projects Calendula ships** are now listed under Settings →
|
||||
About, each with its copyright holder and licence, and each row tapping
|
||||
through to that project's source ([#281]).
|
||||
|
||||
### Changed
|
||||
- Event titles no longer end in an ellipsis. The "…" took the width of a couple
|
||||
of characters and told you nothing you couldn't already see, so the title now
|
||||
simply runs to the edge of its chip in the month, week and day views — a few
|
||||
more letters per event, which is often the difference between two events you
|
||||
can tell apart and two you can't. Right-to-left languages keep the ellipsis,
|
||||
where clipping would cut the beginning of a title rather than its end
|
||||
([#164]).
|
||||
- Month-view events now show their start time before the title, where the chip
|
||||
is wide enough to keep the title readable — in landscape, on a foldable and on
|
||||
a tablet, and never at the cost of a narrow phone column. All-day events show
|
||||
no time, and neither does a bar carried in from the previous week, whose start
|
||||
is not in that row. Event times across the month, week and day views are also
|
||||
set in a regular weight against the title's, so a time reads as a time rather
|
||||
than as part of the name next to it ([#219]).
|
||||
- **The hour lines in week and day view are now an hour grid.** The setting
|
||||
that drew a faint separator line at each hour now seats every hour in its own
|
||||
rounded cell, so an hour boundary reads as the seam between two surfaces
|
||||
rather than a line drawn across one — the same negative space that separates
|
||||
the month grid's days and the week's columns. An event that starts on the hour
|
||||
fills its cell instead of overhanging the seam, and one that runs past
|
||||
midnight still meets the edge of its column ([#113]).
|
||||
- **An invitation you haven't answered now reads as an outline.** An event still
|
||||
waiting on your reply is drawn with the calendar's colour on its border and
|
||||
its title and nothing behind it, so it stands apart from the ones you have
|
||||
accepted — on the month chips, the all-day bars and the week and day blocks
|
||||
alike. Declined events keep their filled, struck-through chip and now sort
|
||||
last in the month view as well, so a day with more events than fit drops the
|
||||
ones you said no to first ([#230]).
|
||||
- The calendar titles now shorten instead of being cut off. When the full month
|
||||
name doesn't fit the top bar, the month and week views fall back to its
|
||||
three-letter form and the day view drops the weekday, rather than trailing off
|
||||
mid-word at large font sizes. The view switcher, the title and the agenda's
|
||||
range bar also line up with the grid underneath them ([#165]).
|
||||
- The jump-to-today button in the toolbar now shows today's date. It drew a
|
||||
generic calendar icon, which told you nothing you didn't already know from
|
||||
tapping it; it now carries the current day number in an outlined box, so the
|
||||
bar says what day it is as well as taking you there. It rolls over at midnight
|
||||
on its own ([#220]).
|
||||
- The quick-switch pill disappears when there is nothing left to switch between,
|
||||
and you can now turn calendar views off all the way down to a single one — the
|
||||
setting used to hold two open purely to keep the pill fed ([#150]).
|
||||
|
||||
### Fixed
|
||||
- **A long location no longer runs off the edge of its field.** The location on
|
||||
the edit screen sat on one line, so a full postal address or a long meeting
|
||||
link scrolled sideways out of sight as you typed it. It now wraps and the card
|
||||
grows to fit, with the pin and the contacts button staying level with the first
|
||||
line at any font size. A multi-line address you paste in is joined with commas
|
||||
— the way one picked from your contacts always has been — instead of running
|
||||
together into a single word ([#273]).
|
||||
- **The event form capitalizes sentences again.** The title, description and
|
||||
guest fields had stopped starting a sentence with a capital letter; they
|
||||
follow the keyboard's usual behaviour once more. The location is deliberately
|
||||
left alone, since it is as often a URL as an address ([#146]).
|
||||
- **A block you pick up no longer loses its second line.** Dragging an event
|
||||
whose time or title wrapped to two lines clipped the floating copy to one line
|
||||
for as long as you held it, and it snapped back on drop. The copy now measures
|
||||
its text exactly as the block does ([#267]).
|
||||
|
||||
## [2.19.4] — 2026-09-01
|
||||
|
||||
@@ -1560,6 +1631,8 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
||||
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164
|
||||
[#165]: https://codeberg.org/jlmakiola/calendula/issues/165
|
||||
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
||||
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
||||
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
||||
@@ -1569,5 +1642,15 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
|
||||
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
||||
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
||||
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
|
||||
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
||||
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
|
||||
[#273]: https://codeberg.org/jlmakiola/calendula/issues/273
|
||||
[#113]: https://codeberg.org/jlmakiola/calendula/issues/113
|
||||
[#220]: https://codeberg.org/jlmakiola/calendula/issues/220
|
||||
[#195]: https://codeberg.org/jlmakiola/calendula/issues/195
|
||||
[#281]: https://codeberg.org/jlmakiola/calendula/issues/281
|
||||
[#230]: https://codeberg.org/jlmakiola/calendula/issues/230
|
||||
[#150]: https://codeberg.org/jlmakiola/calendula/issues/150
|
||||
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
|
||||
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
|
||||
|
||||
@@ -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 = 21904
|
||||
versionName = "2.19.4"
|
||||
versionCode = 22000
|
||||
versionName = "2.20.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
@@ -171,7 +171,7 @@ class MainActivity : AppCompatActivity() {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
LocalShowHourGrid provides settings.showHourGrid,
|
||||
LocalTimelineZoom provides timelineZoom,
|
||||
LocalSoftenColors provides settings.softenColors,
|
||||
) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||
|
||||
private const val TAG = "InstanceMapper"
|
||||
|
||||
@@ -39,7 +40,17 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
||||
color = color,
|
||||
location = getString(InstanceProjection.IDX_LOCATION),
|
||||
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
||||
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe"
|
||||
* counts as going, and everything that is not an open or refused invitation —
|
||||
* your own events included — falls through to [EventResponse.Going].
|
||||
*/
|
||||
internal fun mapEventResponse(raw: Int): EventResponse = when (raw) {
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> EventResponse.Declined
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> EventResponse.Invited
|
||||
else -> EventResponse.Going
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
@@ -43,7 +42,6 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
location = getString(SearchProjection.IDX_LOCATION),
|
||||
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
||||
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -212,14 +212,15 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the week/day timeline draws a faint separator line at each hour
|
||||
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
|
||||
* Whether the week/day timeline seats each hour in its own cell (v2.11, a
|
||||
* separator line until v2.20). Defaults to OFF — the historical flat column;
|
||||
* users opt in. The stored key keeps its original name.
|
||||
*/
|
||||
val showHourLines: Flow<Boolean> = store.data.map { prefs ->
|
||||
val showHourGrid: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[SHOW_HOUR_LINES_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setShowHourLines(enabled: Boolean) {
|
||||
suspend fun setShowHourGrid(enabled: Boolean) {
|
||||
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
||||
}
|
||||
|
||||
|
||||
@@ -68,14 +68,35 @@ data class EventInstance(
|
||||
*/
|
||||
val isRecurring: Boolean = false,
|
||||
/**
|
||||
* This device user answered "no" to the invitation
|
||||
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is
|
||||
* still an appointment someone expects an answer about — but every surface
|
||||
* strikes it through, and it plans no reminders (#180).
|
||||
* This device user's own answer to the invitation, as far as the grids care
|
||||
* (#180, #230).
|
||||
*/
|
||||
val isDeclined: Boolean = false,
|
||||
val response: EventResponse = EventResponse.Going,
|
||||
)
|
||||
|
||||
/**
|
||||
* How this device user stands towards an event's invitation
|
||||
* (`Events.SELF_ATTENDEE_STATUS`), reduced to the three cases the calendar
|
||||
* surfaces draw differently (#230).
|
||||
*/
|
||||
enum class EventResponse {
|
||||
/** Your own event, or one you accepted — including a tentative "maybe". */
|
||||
Going,
|
||||
|
||||
/** Invited, no answer given yet: drawn as an outline so it reads as still open. */
|
||||
Invited,
|
||||
|
||||
/**
|
||||
* You answered "no". The event stays on the calendar — it is still an
|
||||
* appointment someone expects an answer about — but every surface strikes it
|
||||
* through, and it plans no reminders (#180).
|
||||
*/
|
||||
Declined,
|
||||
}
|
||||
|
||||
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
|
||||
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
|
||||
|
||||
/**
|
||||
* Whether this event has finished relative to [now] — its end is at or before
|
||||
* the current instant. An in-progress event (already started but not yet ended)
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
|
||||
@@ -30,7 +30,6 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -40,9 +39,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -51,6 +50,8 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
@@ -95,7 +96,6 @@ fun AgendaScreen(
|
||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRangePicker by remember { mutableStateOf(false) }
|
||||
@@ -130,16 +130,16 @@ fun AgendaScreen(
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
AgendaTopBar(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = viewModel::goToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
@@ -159,13 +159,24 @@ fun AgendaScreen(
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
// end lines the selector up with whatever ends the top bar:
|
||||
// the view switcher's background, or — once #150 hides it —
|
||||
// the search icon's glyph.
|
||||
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
|
||||
AppBarSpacing.Inset
|
||||
} else {
|
||||
AppBarSpacing.IconTrailingInset
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// end aligns the selector's right edge with the top-bar view
|
||||
// switcher (its 8.dp margin + the app bar's 4.dp inset).
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
.padding(
|
||||
start = RANGE_BAR_TEXT_INSET,
|
||||
end = selectorEnd,
|
||||
top = 8.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
@@ -245,6 +256,9 @@ private fun AgendaRangePill(
|
||||
}
|
||||
}
|
||||
|
||||
/** Optical start inset for the range bar's text, set against the title above it. */
|
||||
private val RANGE_BAR_TEXT_INSET = 28.dp
|
||||
|
||||
/**
|
||||
* A header naming the concrete window currently shown under a "showing …" label,
|
||||
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
||||
@@ -409,17 +423,22 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
private fun AgendaTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
// A plain label rather than a CalendarTitleButton, so it takes that
|
||||
// one's inset and one-line clamp itself.
|
||||
Text(
|
||||
text = stringResource(R.string.view_agenda),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@@ -440,14 +459,16 @@ private fun AgendaTopBar(
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
cycle = quickSwitchViews,
|
||||
onCycle = onCycleView,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||
// it, but here the bar meets the header on the same surface and the
|
||||
// tint is what makes that seam look like a separate block (#186).
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Trailing-edge and title spacing shared by the four calendar app bars (#165).
|
||||
*
|
||||
* Only the trailing edge and the title's start are shared; the leading edge and
|
||||
* the gaps between the action icons stay on M3's defaults, which derive both
|
||||
* from the icon buttons' width.
|
||||
*/
|
||||
object AppBarSpacing {
|
||||
|
||||
/** Side inset shared by the bars' trailing edge and the month grid. */
|
||||
val Inset = 8.dp
|
||||
|
||||
/**
|
||||
* Start inset on the title, taking it clear of the navigation icon M3 places
|
||||
* it flush against. Real padding rather than a negative offset, which would
|
||||
* swallow taps meant for the menu button.
|
||||
*/
|
||||
val TitleInset = 8.dp
|
||||
|
||||
/** M3's own padding around the actions row. */
|
||||
internal val BarPadding = 4.dp
|
||||
|
||||
private val IconButtonSize = 48.dp
|
||||
|
||||
/** M3's icon slot inside an icon button; the today glyph fills the same box. */
|
||||
internal val IconSize = 24.dp
|
||||
|
||||
/**
|
||||
* End padding for a container-backed control that ends the bar, measured to
|
||||
* its background. Coerced because [androidx.compose.foundation.layout.padding]
|
||||
* throws on a negative value.
|
||||
*/
|
||||
val ContainerTrailingInset = (Inset - BarPadding).coerceAtLeast(0.dp)
|
||||
|
||||
/** Screen edge to the glyph of an icon button that ends the bar. */
|
||||
val IconTrailingInset = BarPadding + (IconButtonSize - IconSize) / 2
|
||||
}
|
||||
@@ -10,24 +10,137 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
|
||||
/** Gap a timed block leaves to its neighbours in the column. */
|
||||
val BLOCK_OUTER_INSET = 1.dp
|
||||
|
||||
/** Padding between a timed block's edge and its text. */
|
||||
val BLOCK_TEXT_PADDING = 4.dp
|
||||
|
||||
/** The same, above and below — what a block's height has to pay before any text. */
|
||||
val BLOCK_TEXT_INSET = 2.dp
|
||||
|
||||
/** Most lines a time label may wrap over before it is worth more than a title line. */
|
||||
const val MAX_TIME_LINES = 2
|
||||
|
||||
/**
|
||||
* Lines [text] needs to render whole at [textWidth], capped at [max].
|
||||
*
|
||||
* Lets a block hand out its height by what the text actually asks for rather
|
||||
* than by what would fit: a title that wants one line should not be given three
|
||||
* that the time label could have used, and a week column is narrower than a
|
||||
* "09:30–11:00" range so the range should not be assumed to want one.
|
||||
*/
|
||||
@Composable
|
||||
fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
|
||||
return remember(text, style, widthPx, max, measurer) {
|
||||
if (max <= 1 || widthPx <= 0) {
|
||||
1
|
||||
} else {
|
||||
measurer.measure(
|
||||
text = text,
|
||||
style = style,
|
||||
constraints = Constraints(maxWidth = widthPx),
|
||||
).lineCount.coerceIn(1, max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lines the time label may take at [textWidth], out of the [spare] height left
|
||||
* once the title and the label's own first line are paid for.
|
||||
*
|
||||
* A week column is narrower than a "09:30–11:00" range, so the label takes a
|
||||
* second line rather than lose its end — but only out of a line the title
|
||||
* measured itself as not needing, never one it would have filled.
|
||||
*/
|
||||
@Composable
|
||||
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
|
||||
val timeLineHeight = with(LocalDensity.current) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
return if (spare >= timeLineHeight) {
|
||||
blockTextLines(
|
||||
text = label,
|
||||
// The style it is drawn in, or the budget measures a line the label
|
||||
// never uses (#219).
|
||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
textWidth = textWidth,
|
||||
max = MAX_TIME_LINES,
|
||||
)
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A timed block's title, over at most [maxLines], breaking at word boundaries.
|
||||
*
|
||||
* #164 clipped the last line mid-glyph so none of a narrow chip's few
|
||||
* characters went on an ellipsis. On a block that can wrap, whole words read
|
||||
* better than full lines do: "Farmers Market" over two lines beats "Farmer" /
|
||||
* "s Marke". A single line still clips, having nowhere to wrap to.
|
||||
*/
|
||||
@Composable
|
||||
fun BlockTitle(
|
||||
title: String,
|
||||
maxLines: Int,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
textDecoration: TextDecoration? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
) {
|
||||
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||
Text(
|
||||
text = title,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
|
||||
maxLines = maxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
color = color,
|
||||
textDecoration = textDecoration,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A timed block's own time label, crossfaded rather than replaced — the block
|
||||
* slides to its new slot, so the label shouldn't change in a single frame.
|
||||
*
|
||||
* Overflows like a title does (#164): the "…" costs two characters of a string
|
||||
* that is nothing but characters, so the label clips at the block's edge
|
||||
* instead. [maxLines] lets a narrow column spend spare height on the range
|
||||
* rather than losing its end.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
||||
fun BlockTimeLabel(
|
||||
label: String,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
maxLines: Int = 1,
|
||||
) {
|
||||
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
||||
snap()
|
||||
} else {
|
||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
}
|
||||
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||
Crossfade(
|
||||
targetState = label,
|
||||
animationSpec = spec,
|
||||
@@ -36,9 +149,11 @@ fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
// Regular weight against the title's medium above it (#219).
|
||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
maxLines = maxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -12,6 +13,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -19,6 +21,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -31,10 +35,9 @@ import kotlinx.datetime.LocalDate
|
||||
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
||||
* visible day, week start or month anchor).
|
||||
*
|
||||
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
|
||||
* start alignment: M3 places the title flush against the navigation icon's
|
||||
* trailing edge, and the title is hit-tested on top of it, so a negative offset
|
||||
* would swallow taps meant for the menu button.
|
||||
* [shortTitle] replaces [title] when the full one does not fit the width the app
|
||||
* bar hands the title slot (#165). Either way the line clamps to one and
|
||||
* ellipsises, so pass a [shortTitle] wherever there is something left to drop.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarTitleButton(
|
||||
@@ -42,6 +45,7 @@ fun CalendarTitleButton(
|
||||
currentDate: LocalDate,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
shortTitle: String = title,
|
||||
) {
|
||||
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
@@ -53,14 +57,27 @@ fun CalendarTitleButton(
|
||||
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
||||
role = Role.Button,
|
||||
) { showDatePicker = true }
|
||||
.padding(horizontal = 8.dp),
|
||||
.padding(horizontal = AppBarSpacing.TitleInset),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
val style = MaterialTheme.typography.titleLarge
|
||||
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) {
|
||||
titleFor(
|
||||
title = title,
|
||||
shortTitle = shortTitle,
|
||||
titleWidth = measurer.measure(title, style).size.width,
|
||||
availableWidth = constraints.maxWidth,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = shown,
|
||||
style = style,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
@@ -79,3 +96,11 @@ fun CalendarTitleButton(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Falls back to [shortTitle] when [title] is wider than [availableWidth] (#165). */
|
||||
internal fun titleFor(
|
||||
title: String,
|
||||
shortTitle: String,
|
||||
titleWidth: Int,
|
||||
availableWidth: Int,
|
||||
): String = if (titleWidth <= availableWidth) title else shortTitle
|
||||
|
||||
@@ -57,7 +57,8 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
|
||||
* implemented view — the settings screen reorders the whole set — while [cycle]
|
||||
* is the subset the pill actually steps through, in [order]. The navigation
|
||||
* drawer keeps its own separate order and always lists every view, so a view
|
||||
* disabled here stays reachable there.
|
||||
* disabled here stays reachable there — including when [cycle] is emptied and
|
||||
* the pill disappears altogether.
|
||||
*/
|
||||
data class QuickSwitchConfig(
|
||||
val order: List<CalendarView>,
|
||||
@@ -67,14 +68,15 @@ data class QuickSwitchConfig(
|
||||
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Fewest views that keep the switch meaningful. A single target is not a
|
||||
* switch, so below this the pill is hidden rather than special-cased (#150);
|
||||
* the drawer still reaches every view.
|
||||
*/
|
||||
const val MIN_CYCLE = 2
|
||||
|
||||
/** All views, in default order, all enabled. */
|
||||
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
||||
|
||||
/**
|
||||
* Fewest views that keep the switch meaningful — a "switch" needs at
|
||||
* least two targets, so the settings screen blocks disabling below this.
|
||||
*/
|
||||
const val MIN_ENABLED = 2
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.content.ClipData
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Puts one labelled field on the clipboard. */
|
||||
fun interface FieldCopier {
|
||||
operator fun invoke(label: String, text: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* A [FieldCopier] that confirms the copy only where the system doesn't (#195).
|
||||
*
|
||||
* Android 13 raises its own clipboard chip for every copy, so a snackbar on top
|
||||
* of it reads as the app having done the job twice. A failure is always worth a
|
||||
* word, though: the clipboard can refuse a very long description outright.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val confirmation = stringResource(R.string.field_copied)
|
||||
val failure = stringResource(R.string.field_copy_failed)
|
||||
return remember(clipboard, scope, snackbarHostState, confirmation, failure) {
|
||||
FieldCopier { label, text ->
|
||||
scope.launch {
|
||||
val message = runCatching {
|
||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, text)))
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
confirmation.takeIf {
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
|
||||
}
|
||||
},
|
||||
onFailure = { failure },
|
||||
)
|
||||
if (message != null) {
|
||||
// Copying twice in a row shouldn't queue two four-second
|
||||
// confirmations — the newest one wins.
|
||||
snackbarHostState.currentSnackbarData?.dismiss()
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Long-press to copy [text] whole, filed on the clipboard under [label].
|
||||
*
|
||||
* [onTap] carries a field's existing tap action through; a field without one
|
||||
* stays unclickable rather than growing a ripple that leads nowhere, and gets
|
||||
* its long press announced through semantics instead. That branch merges the
|
||||
* node it sits on, so a screen reader lands on the field itself and finds the
|
||||
* action there — a bare container is never focused.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.copyOnLongPress(
|
||||
label: String,
|
||||
text: String,
|
||||
copy: FieldCopier,
|
||||
onTap: (() -> Unit)? = null,
|
||||
): Modifier {
|
||||
val actionLabel = stringResource(R.string.field_copy_action)
|
||||
return if (onTap != null) {
|
||||
combinedClickable(
|
||||
onClick = onTap,
|
||||
onLongClickLabel = actionLabel,
|
||||
onLongClick = { copy(label, text) },
|
||||
)
|
||||
} else {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
pointerInput(label, text, copy) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
copy(label, text)
|
||||
},
|
||||
)
|
||||
}.semantics(mergeDescendants = true) {
|
||||
onLongClick(actionLabel) {
|
||||
copy(label, text)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
|
||||
/** Stroke an invitation you have not answered is outlined with. */
|
||||
val EVENT_OUTLINE_WIDTH = 1.dp
|
||||
|
||||
/**
|
||||
* The surface an event chip is drawn on, which an outlined one fills itself with
|
||||
* so it reads as the background showing through rather than as a pale block laid
|
||||
* over it. Defaults to the `surfaceContainer` the month cells and the timeline
|
||||
* columns carry; the all-day strips sit on plain `surface` and a floating drag
|
||||
* copy wants a lifted one, so both provide their own.
|
||||
*/
|
||||
val LocalChipGround = compositionLocalOf { Color.Unspecified }
|
||||
|
||||
/**
|
||||
* Which edges of a chip the event runs past. Those edges are squared by
|
||||
* [monthBarShape] / [timedBlockShape] so the cut reads as "this carries on", and
|
||||
* an outline has to leave them open for the same reason — a stroke all the way
|
||||
* round would close a bar carried across a week boundary into two boxes.
|
||||
*/
|
||||
@Immutable
|
||||
data class ChipCuts(
|
||||
val start: Boolean = false,
|
||||
val end: Boolean = false,
|
||||
val top: Boolean = false,
|
||||
val bottom: Boolean = false,
|
||||
)
|
||||
|
||||
/** [ChipCuts] for a month or all-day bar, which is only ever cut left and right. */
|
||||
fun monthBarCuts(continuesLeft: Boolean, continuesRight: Boolean): ChipCuts =
|
||||
ChipCuts(start = continuesLeft, end = continuesRight)
|
||||
|
||||
/** [ChipCuts] for a timed block, which is only ever cut top and bottom. */
|
||||
fun timedBlockCuts(continuesBefore: Boolean, continuesAfter: Boolean): ChipCuts =
|
||||
ChipCuts(top = continuesBefore, bottom = continuesAfter)
|
||||
|
||||
/**
|
||||
* How one event's chip or block is painted, which turns on your answer to its
|
||||
* invitation (#180, #230).
|
||||
*
|
||||
* An invitation you have not answered is drawn as an outline: its calendar's
|
||||
* colour on the border and on the title, over the surface the chip sits on. So
|
||||
* it holds a chip's shape and a chip's weight without claiming the filled
|
||||
* container an event you are going to gets. A declined one keeps the fill and
|
||||
* its strike-through.
|
||||
*/
|
||||
@Immutable
|
||||
data class EventPaint(
|
||||
val fill: Color,
|
||||
/** Null for a filled chip; the border colour when the chip is outlined. */
|
||||
val outline: Color?,
|
||||
val titleInk: Color,
|
||||
val secondaryInk: Color,
|
||||
val decoration: TextDecoration?,
|
||||
/**
|
||||
* Weight to set the title at, or null to keep whatever the surface's own
|
||||
* text style carries.
|
||||
*/
|
||||
val titleWeight: FontWeight?,
|
||||
)
|
||||
|
||||
/** [EventPaint] for [event] on a [dark] scheme. */
|
||||
@Composable
|
||||
fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
|
||||
val soften = LocalSoftenColors.current
|
||||
if (event.response == EventResponse.Invited) {
|
||||
// Harmonised even when the setting is off. Raw mode exists so a filled
|
||||
// container matches what the sync source paints, and an outlined chip
|
||||
// has no container — what it has is coloured *text*, which needs the
|
||||
// lightness pinned against the surface or a pale calendar goes
|
||||
// unreadable (eventTone returns the raw colour verbatim otherwise).
|
||||
val accent = eventAccent(event.color, dark, soften = true)
|
||||
val ground = LocalChipGround.current.takeIf { it != Color.Unspecified }
|
||||
?: MaterialTheme.colorScheme.surfaceContainer
|
||||
return EventPaint(
|
||||
fill = ground,
|
||||
outline = accent,
|
||||
titleInk = accent,
|
||||
// A neutral token rather than the accent faded: the time is the
|
||||
// smallest text on the chip, and an alpha step off an accent that is
|
||||
// itself only just clear of the surface is where legibility goes.
|
||||
secondaryInk = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
decoration = null,
|
||||
// The label styles' medium weight is set to carry ink on a filled
|
||||
// container. In a calendar colour on a plain one it thickens into
|
||||
// something harder to read, so step it back to regular.
|
||||
titleWeight = FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
val fill = eventFill(event.color, dark, soften)
|
||||
return EventPaint(
|
||||
fill = fill,
|
||||
outline = null,
|
||||
titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA),
|
||||
secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
decoration = declinedDecoration(event.isDeclined),
|
||||
titleWeight = null,
|
||||
)
|
||||
}
|
||||
|
||||
/** [this] set at the weight [paint] asks a title for, for measuring and for drawing alike. */
|
||||
fun TextStyle.withTitleWeight(paint: EventPaint): TextStyle =
|
||||
if (paint.titleWeight == null) this else copy(fontWeight = paint.titleWeight)
|
||||
|
||||
/** Seats a chip or block on [shape]: its fill, plus the border when it has one. */
|
||||
fun Modifier.eventSurface(
|
||||
paint: EventPaint,
|
||||
shape: Shape,
|
||||
cuts: ChipCuts = ChipCuts(),
|
||||
): Modifier {
|
||||
val filled = background(paint.fill, shape)
|
||||
val outline = paint.outline ?: return filled
|
||||
return filled.clip(shape).drawBehind { drawChipOutline(outline, cuts) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The border, stroked as one round-rect whose edges run past the chip on every
|
||||
* side the event continues over. Clipped to the chip's own shape, so those
|
||||
* strokes — and the corners that would have turned back in — fall outside and
|
||||
* the edge stays open.
|
||||
*/
|
||||
private fun DrawScope.drawChipOutline(color: Color, cuts: ChipCuts) {
|
||||
val stroke = EVENT_OUTLINE_WIDTH.toPx()
|
||||
val radius = EVENT_CHIP_CORNER.toPx()
|
||||
// Far enough out that the corner arc clears the clip too, not just the edge.
|
||||
val bleed = radius + stroke
|
||||
val rtl = layoutDirection == LayoutDirection.Rtl
|
||||
val leftCut = if (rtl) cuts.end else cuts.start
|
||||
val rightCut = if (rtl) cuts.start else cuts.end
|
||||
val left = if (leftCut) -bleed else stroke / 2f
|
||||
val top = if (cuts.top) -bleed else stroke / 2f
|
||||
val right = if (rightCut) size.width + bleed else size.width - stroke / 2f
|
||||
val bottom = if (cuts.bottom) size.height + bleed else size.height - stroke / 2f
|
||||
drawRoundRect(
|
||||
color = color,
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(right - left, bottom - top),
|
||||
cornerRadius = CornerRadius(radius),
|
||||
style = Stroke(width = stroke),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
private val TIME_WEIGHT = FontWeight.Normal
|
||||
private val TIME_TRACKING = 0.sp
|
||||
private val TIME_SPAN = SpanStyle(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
|
||||
|
||||
/**
|
||||
* Ink for an event's title, against [SECONDARY_INK_ALPHA] for the time beside or
|
||||
* beneath it. Full, so the pair separates on a typeface with no medium weight to
|
||||
* step down from — Atkinson Hyperlegible and JetBrains Mono ship regular and bold
|
||||
* only, and a user's imported font a single face, so [asEventTime] is a no-op
|
||||
* there and the ink is the whole difference (#219).
|
||||
*/
|
||||
const val TITLE_INK_ALPHA = 1f
|
||||
|
||||
/**
|
||||
* [this] set as an event's time rather than as its title: the label styles' medium
|
||||
* weight and 0.5sp of tracking left a time reading as part of the title next to it.
|
||||
*/
|
||||
fun TextStyle.asEventTime(): TextStyle =
|
||||
copy(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
|
||||
|
||||
/** Text reading [time], set apart in [timeInk], before [title]. */
|
||||
fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedString =
|
||||
buildAnnotatedString {
|
||||
if (time != null) {
|
||||
withStyle(TIME_SPAN.copy(color = timeInk)) { append(time) }
|
||||
append(" ")
|
||||
}
|
||||
append(title)
|
||||
}
|
||||
|
||||
/**
|
||||
* The characters of title that have to survive a time prefix for it to be worth
|
||||
* its place — lowercase Latin of average advance, priced at the surface's own
|
||||
* text style rather than guessed in dp.
|
||||
*/
|
||||
private const val TITLE_SAMPLE = "notepad"
|
||||
|
||||
/** The widest wall-clock time in either convention: two-digit hour, and a meridiem in 12-hour. */
|
||||
private const val SAMPLE_HOUR = 12
|
||||
private const val SAMPLE_MINUTE = 45
|
||||
|
||||
/**
|
||||
* The narrowest run of [style] text that may carry a time in front of a title:
|
||||
* wide enough for the widest time in the current convention plus [TITLE_SAMPLE].
|
||||
*
|
||||
* Measured rather than a device breakpoint, so it follows the font scale, the
|
||||
* 12/24-hour setting, the locale's own time format and whatever else has
|
||||
* already been taken off the width.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberInlineTimeWidth(style: TextStyle): Dp {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val locale = currentLocale()
|
||||
val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale)
|
||||
return remember(sample, style, density, measurer) {
|
||||
val label = inlineTimeLabel(sample, TITLE_SAMPLE, Color.Unspecified)
|
||||
with(density) { measurer.measure(label, style).size.width.toDp() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
|
||||
/** How an event chip's title should overflow: what to pass to `Text`. */
|
||||
data class EventTitleOverflow(val overflow: TextOverflow, val softWrap: Boolean)
|
||||
|
||||
/**
|
||||
* Overflow for an event chip's title (#164). Chips are narrow enough that the
|
||||
* "…" costs a couple of readable characters, so the title runs to the chip's
|
||||
* edge and clips instead — mid-glyph on one line, at the last whole word it
|
||||
* could fit on several.
|
||||
*
|
||||
* One case keeps the ellipsis: a single **[rtl]** line. With `softWrap` off
|
||||
* Compose lays the line out at its full intrinsic width and clips to the node's
|
||||
* left edge, which in RTL is the *end* of the string — an Arabic title would
|
||||
* lose its beginning. The ellipsis truncates at the logical end in both
|
||||
* directions. Wrapping needs `softWrap` on, which clips the right end either
|
||||
* way, so more than one line needs no such exception.
|
||||
*/
|
||||
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
|
||||
when {
|
||||
!singleLine -> EventTitleOverflow(TextOverflow.Clip, softWrap = true)
|
||||
rtl -> EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
|
||||
else -> EventTitleOverflow(TextOverflow.Clip, softWrap = false)
|
||||
}
|
||||
|
||||
/** [eventTitleOverflowFor] against the current layout direction. */
|
||||
@Composable
|
||||
fun eventTitleOverflow(singleLine: Boolean = true): EventTitleOverflow =
|
||||
eventTitleOverflowFor(
|
||||
rtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
|
||||
singleLine = singleLine,
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Whether the week/day timeline seats each hour in its own cell, from the
|
||||
* `show_hour_lines` preference. Provided once at the app root (like
|
||||
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
|
||||
* Defaults to off — the historical flat column.
|
||||
*/
|
||||
val LocalShowHourGrid = staticCompositionLocalOf { false }
|
||||
|
||||
/** Gap between two hour cells, matching the month grid's gap between day cells. */
|
||||
val HOUR_CELL_GAP = 2.dp
|
||||
|
||||
/** Half a gap: what a cell — and a block seated in it — gives up at each edge. */
|
||||
val HOUR_CELL_INSET = HOUR_CELL_GAP / 2
|
||||
|
||||
/**
|
||||
* The half-gap a block seated in the grid gives up at one of its ends, so an
|
||||
* on-the-hour event fills its cell instead of overhanging the seam into its
|
||||
* neighbours'. Zero with the grid off, and zero at an end [cut] at midnight:
|
||||
* that edge is squared off against the column's own, and stopping a half-gap
|
||||
* short of it would leave the block floating there rather than running off.
|
||||
*/
|
||||
fun hourCellBlockInset(show: Boolean, cut: Boolean): Dp =
|
||||
if (show && !cut) HOUR_CELL_INSET else 0.dp
|
||||
|
||||
/**
|
||||
* Corner radius of an hour cell: the event chip's own, so the grid never rounds
|
||||
* harder than the blocks it seats. The month grid's 12dp belongs to a cell many
|
||||
* times the size — on an hour cell it out-rounds its own content.
|
||||
*/
|
||||
private val HOUR_CELL_CORNER = EVENT_CHIP_CORNER
|
||||
|
||||
/**
|
||||
* Radius an hour cell of [cellHeight] by [cellWidth] pixels may round to. The
|
||||
* hour pitch runs from a fit-the-day sliver up to [MAX_PINCH_HOUR_HEIGHT] and a
|
||||
* week column is a seventh of the screen, so the radius is held to half the
|
||||
* shorter side — the point past which the corners would meet and the cell turn
|
||||
* into a lozenge.
|
||||
*/
|
||||
internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Float): Float =
|
||||
minOf(maxRadius, cellHeight / 2f, cellWidth / 2f).coerceAtLeast(0f)
|
||||
|
||||
/**
|
||||
* Seat each of the day's 24 hours in its own rounded cell when [show] is true,
|
||||
* so the hour boundary reads as a seam between two surfaces rather than a line
|
||||
* drawn across one. Applied to a day column's content, so the cells sit over the
|
||||
* column background but beneath the event blocks — blocks stay in a continuous
|
||||
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
|
||||
* height; [color] is resolved by the caller from the theme.
|
||||
*/
|
||||
fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
|
||||
if (!show) {
|
||||
this
|
||||
} else {
|
||||
drawBehind {
|
||||
val inset = HOUR_CELL_INSET.toPx()
|
||||
val cellHeight = hourHeightPx - inset * 2f
|
||||
if (cellHeight <= 0f) return@drawBehind
|
||||
val radius = CornerRadius(
|
||||
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
|
||||
)
|
||||
val cellSize = Size(size.width, cellHeight)
|
||||
for (hour in 0 until 24) {
|
||||
drawRoundRect(
|
||||
color = color,
|
||||
topLeft = Offset(0f, hour * hourHeightPx + inset),
|
||||
size = cellSize,
|
||||
cornerRadius = radius,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -72,6 +73,11 @@ fun HourGutter(
|
||||
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
||||
label = "hourLabelAlpha",
|
||||
)
|
||||
// Each label straddles the boundary it names, so it lines up with the seam
|
||||
// the hour grid leaves there instead of hanging below it. Derived from the
|
||||
// label's own line height, so it holds at any font scale.
|
||||
val labelStyle = MaterialTheme.typography.labelSmall
|
||||
val labelLift = with(LocalDensity.current) { labelStyle.lineHeight.toDp() } / 2
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -90,12 +96,12 @@ fun HourGutter(
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = labelStyle,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
.copy(alpha = hourAlpha),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
.offset(y = -labelLift),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Whether the week/day timeline draws an hour separator line, from the
|
||||
* `showHourLines` preference. Provided once at the app root (like
|
||||
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
|
||||
* Defaults to off — the historical clean look.
|
||||
*/
|
||||
val LocalShowHourLines = staticCompositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* Draw a faint separator line at the top of each hour (1..23) when [show] is
|
||||
* true. Applied to a day column's content so each line sits over the column's
|
||||
* background but beneath the event blocks. [hourHeightPx] is one hour's pixel
|
||||
* height; [color] is resolved by the caller from the theme.
|
||||
*/
|
||||
fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
|
||||
if (!show) {
|
||||
this
|
||||
} else {
|
||||
drawBehind {
|
||||
for (hour in 1 until 24) {
|
||||
val y = hour * hourHeightPx
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(0f, y),
|
||||
end = Offset(size.width, y),
|
||||
strokeWidth = 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import kotlin.time.Instant
|
||||
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
|
||||
* the "dim completed events" setting is on, or `null` when it is off (nothing
|
||||
* dims). Provided per grid screen so only the event chips that read it recompose
|
||||
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
|
||||
* as the minute ticks, mirroring [LocalShowHourGrid] / [LocalUse24HourFormat].
|
||||
*/
|
||||
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -24,8 +25,9 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
@@ -34,7 +36,6 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -83,6 +84,13 @@ data class TimelineDrag(
|
||||
val eventSpanMin: Int,
|
||||
/** The event as the grid would draw it, one piece per day column it covers. */
|
||||
val pieces: List<TimelineDragPiece>,
|
||||
/**
|
||||
* Lines the block the finger picked up drew its title over, zero for one too
|
||||
* short to have drawn it at all. The floating copy is that block's size, so
|
||||
* it has to spend its height the same way or the text re-wraps under the
|
||||
* finger and snaps back on drop (#267).
|
||||
*/
|
||||
val titleLines: Int,
|
||||
/**
|
||||
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
|
||||
* every column this timeline shows. A hint under the finger, not where the
|
||||
@@ -192,6 +200,32 @@ internal fun dragFloorMin(clipOffsetMin: Int, eventSpanMin: Int): Int =
|
||||
0
|
||||
}
|
||||
|
||||
/**
|
||||
* Where one dragged piece's top edge sits below its column's top, and how tall
|
||||
* it is drawn, at [hourPx] to the hour. [insetPx] is the half-gap a block seated
|
||||
* in the hour grid gives up at each end, zero with the grid off — taken off the
|
||||
* copy exactly as the grid takes it off the block, or the copy is a gap taller
|
||||
* than the block it lifted off and spends the difference on a time label that
|
||||
* block had no room for (#267). An end cut at midnight keeps none of it, as the
|
||||
* block's own squared-off edge does.
|
||||
*/
|
||||
internal fun dragPieceBounds(
|
||||
slice: DragSlice,
|
||||
hourPx: Float,
|
||||
insetPx: Float,
|
||||
): DragPieceBounds {
|
||||
val top = if (slice.continuesBefore) 0f else insetPx
|
||||
val bottom = if (slice.continuesAfter) 0f else insetPx
|
||||
val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
||||
return DragPieceBounds(
|
||||
top = slice.startMin / 60f * hourPx + top,
|
||||
height = (full - top - bottom).coerceAtLeast(0f),
|
||||
)
|
||||
}
|
||||
|
||||
/** A piece's placement within its column — see [dragPieceBounds]. */
|
||||
internal data class DragPieceBounds(val top: Float, val height: Float)
|
||||
|
||||
/** One day's share of a dragged event, before it is placed on screen. */
|
||||
internal data class DragSlice(
|
||||
val dayOffset: Int,
|
||||
@@ -235,6 +269,13 @@ class TimelineGeometry {
|
||||
var stepPx: Float = 0f
|
||||
var days: List<LocalDate> = emptyList()
|
||||
|
||||
/**
|
||||
* The half-gap a block gives up at each uncut end when the hour grid seats
|
||||
* it in a cell, zero with the grid off. The floating copy gives up the same,
|
||||
* so it is the size of the block it lifted off (#267).
|
||||
*/
|
||||
var blockInsetPx: Float = 0f
|
||||
|
||||
/**
|
||||
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
||||
* never mirrored but the grid is, so the mapping has to flip with it.
|
||||
@@ -313,6 +354,9 @@ class TimelineDragController {
|
||||
*/
|
||||
private var clipOffsetMin = 0
|
||||
|
||||
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
|
||||
private var titleLines = 0
|
||||
|
||||
/**
|
||||
* The slot the block already occupied when it was picked up — not the same
|
||||
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
||||
@@ -322,11 +366,13 @@ class TimelineDragController {
|
||||
fun begin(
|
||||
block: TimedBlock,
|
||||
clipOffsetMin: Int,
|
||||
titleLines: Int,
|
||||
pointerInRoot: Offset,
|
||||
blockInRoot: Offset,
|
||||
) {
|
||||
source = block
|
||||
this.clipOffsetMin = clipOffsetMin
|
||||
this.titleLines = titleLines
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = block.event.instanceId
|
||||
@@ -345,6 +391,7 @@ class TimelineDragController {
|
||||
fun cancel() {
|
||||
source = null
|
||||
clipOffsetMin = 0
|
||||
titleLines = 0
|
||||
isDragging = false
|
||||
liftedInstanceId = null
|
||||
originSlot = null
|
||||
@@ -467,23 +514,22 @@ class TimelineDragController {
|
||||
startMin = startMin,
|
||||
eventStartMin = eventStartMin,
|
||||
eventSpanMin = eventSpan,
|
||||
titleLines = titleLines,
|
||||
// Bounded to the columns this timeline actually shows: a day it
|
||||
// doesn't has no piece to draw. The write is unaffected.
|
||||
pieces = slices
|
||||
.map { slice ->
|
||||
val day = dayIndex + slice.dayOffset
|
||||
val col = if (geometry.isRtl) days.lastIndex - day else day
|
||||
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
|
||||
TimelineDragPiece(
|
||||
topLeftInRoot = Offset(
|
||||
x = origin.x + col * columnPx,
|
||||
y = origin.y + slice.startMin / 60f * hourPx,
|
||||
y = origin.y + bounds.top,
|
||||
),
|
||||
sizePx = IntSize(
|
||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||
maxOf(
|
||||
slice.spanMin / 60f * hourPx,
|
||||
MIN_EVENT_FRACTION * hourPx,
|
||||
).roundToInt(),
|
||||
bounds.height.roundToInt(),
|
||||
),
|
||||
continuesBefore = slice.continuesBefore,
|
||||
continuesAfter = slice.continuesAfter,
|
||||
@@ -604,8 +650,8 @@ const val SETTLE_FADE_MILLIS: Int = 250
|
||||
@Composable
|
||||
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val density = LocalDensity.current
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
@@ -655,7 +701,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "drag-handover",
|
||||
)
|
||||
val fill = eventFill(drag.event.color, dark, soften)
|
||||
val paint = eventPaint(drag.event, dark)
|
||||
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
// Read off the event rather than the held block, so grabbing either half
|
||||
// of an event that crosses midnight names the same hours. An end past
|
||||
@@ -666,22 +712,61 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
|
||||
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}–" +
|
||||
formatMinuteOfDay(endMin, use24Hour, locale)
|
||||
// The tallest piece carries the range: on the smallest it would be
|
||||
// clipped away, which is exactly the case when a short tail is held.
|
||||
val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height }
|
||||
drag.pieces.forEachIndexed { index, piece ->
|
||||
DragCopy(
|
||||
topLeftInRoot = piece.topLeftInRoot,
|
||||
overlayOrigin = origin,
|
||||
sizePx = piece.sizePx,
|
||||
fill = fill,
|
||||
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
|
||||
lift = lift,
|
||||
alpha = copyAlpha,
|
||||
title = title,
|
||||
// Once for the whole event: repeated, it would name it per day.
|
||||
label = label.takeIf { index == labelled },
|
||||
)
|
||||
// The timeline's own bounds. The copy is drawn over the whole calendar
|
||||
// so no column clip or rounded corner cuts it, but it still belongs to
|
||||
// the timeline: a piece a whole day tall reaches far past both ends of
|
||||
// the viewport, and unclipped it paints over the headers above (#267).
|
||||
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
|
||||
// How much of each piece the viewport actually shows. A multi-day event
|
||||
// has a piece per day, and the widest one is a full 24 hours — taller
|
||||
// than the screen, its top at a midnight scrolled out of sight. Picking
|
||||
// the range's piece by raw height put it there, off the top of the
|
||||
// timeline; picking by *visible* height puts it where it can be read.
|
||||
val shown = drag.pieces.map { piece ->
|
||||
if (port == null) {
|
||||
piece.sizePx.height.toFloat()
|
||||
} else {
|
||||
val top = maxOf(piece.topLeftInRoot.y, port.top)
|
||||
val bottom = minOf(piece.topLeftInRoot.y + piece.sizePx.height, port.bottom)
|
||||
bottom - top
|
||||
}
|
||||
}
|
||||
val labelled = shown.indices.maxByOrNull { shown[it] }
|
||||
Box(
|
||||
modifier = if (port == null) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier
|
||||
.absoluteOffset {
|
||||
IntOffset(
|
||||
(port.left - origin.x).roundToInt(),
|
||||
(port.top - origin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.size(
|
||||
width = with(density) { port.width.toDp() },
|
||||
height = with(density) { port.height.toDp() },
|
||||
)
|
||||
.clipToBounds()
|
||||
},
|
||||
) {
|
||||
val pieceOrigin = port?.topLeft ?: origin
|
||||
drag.pieces.forEachIndexed { index, piece ->
|
||||
DragCopy(
|
||||
topLeftInRoot = piece.topLeftInRoot,
|
||||
overlayOrigin = pieceOrigin,
|
||||
sizePx = piece.sizePx,
|
||||
paint = paint,
|
||||
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
|
||||
cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter),
|
||||
lift = lift,
|
||||
alpha = copyAlpha,
|
||||
title = title,
|
||||
titleLines = drag.titleLines,
|
||||
// Once for the whole event: repeated, it would name it per day.
|
||||
label = label.takeIf { index == labelled },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -692,14 +777,54 @@ private fun DragCopy(
|
||||
topLeftInRoot: Offset,
|
||||
overlayOrigin: Offset,
|
||||
sizePx: IntSize,
|
||||
fill: Color,
|
||||
paint: EventPaint,
|
||||
shape: RoundedCornerShape,
|
||||
cuts: ChipCuts,
|
||||
lift: Float,
|
||||
alpha: Float,
|
||||
title: String,
|
||||
titleLines: Int,
|
||||
label: String?,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val width = with(density) { sizePx.width.toDp() }
|
||||
val height = with(density) { sizePx.height.toDp() }
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// The block's size, spent the block's way — text sits at the top as it does
|
||||
// on the block, so the copy hands back to the grid without shifting (#267).
|
||||
// The title is served in full first and the range lives off what is left:
|
||||
// the hour gutter down the side still says where the copy sits, so the
|
||||
// range is the half that can afford to go.
|
||||
val available = height - BLOCK_TEXT_INSET * 2
|
||||
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(0)
|
||||
val allowed = titleLines.coerceAtMost(titleBudget)
|
||||
// Re-measured at the copy's own width rather than spent on the source's
|
||||
// count: a block sharing its column with another is a lane wide where the
|
||||
// copy is a whole column, so the source's second line is one the copy never
|
||||
// draws — and reserving it took the range away with it (#267).
|
||||
val lines = if (allowed <= 0) {
|
||||
0
|
||||
} else {
|
||||
blockTextLines(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
|
||||
textWidth = textWidth,
|
||||
max = allowed,
|
||||
)
|
||||
}
|
||||
val left = available - titleLineHeight * lines
|
||||
val showTime = label != null && left >= timeLineHeight
|
||||
val timeMaxLines = if (showTime) {
|
||||
blockTimeLines(label!!, textWidth, left - timeLineHeight)
|
||||
} else {
|
||||
1
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
@@ -710,12 +835,20 @@ private fun DragCopy(
|
||||
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.size(
|
||||
width = with(density) { sizePx.width.toDp() },
|
||||
height = with(density) { sizePx.height.toDp() },
|
||||
)
|
||||
.padding(horizontal = 1.dp)
|
||||
// Required: the parent is the viewport's size, and a plain `size`
|
||||
// would let it cap a piece a whole day tall at one screen — drawn
|
||||
// from a midnight scrolled off the top, that ended the column a
|
||||
// scroll offset short of its own bottom (#267). The parent clips it.
|
||||
.requiredSize(width = width, height = height)
|
||||
.padding(horizontal = BLOCK_OUTER_INSET)
|
||||
.graphicsLayer {
|
||||
// Anchored at the top edge, not the middle: scaled about the
|
||||
// centre, the 2% lift raises the top by 1% of the height — a few
|
||||
// pixels on an hour, but a steady upward creep of the text as a
|
||||
// dragged multi-day piece grows, and a shear against the
|
||||
// timeline's clip. It snapped back when the lift animated out
|
||||
// on drop (#267).
|
||||
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||
scaleX = 1f + 0.02f * lift
|
||||
scaleY = 1f + 0.02f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
@@ -723,24 +856,29 @@ private fun DragCopy(
|
||||
this.shape = shape
|
||||
clip = false
|
||||
}
|
||||
.background(fill, shape)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
.eventSurface(paint, shape, cuts)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
if (label != null) {
|
||||
if (lines > 0) {
|
||||
BlockTitle(
|
||||
title = title,
|
||||
maxLines = lines,
|
||||
color = paint.titleInk,
|
||||
textDecoration = paint.decoration,
|
||||
fontWeight = paint.titleWeight,
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
// As the block it lifted off sets its own time (#219).
|
||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
maxLines = timeMaxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
color = paint.secondaryInk,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,17 +96,6 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
|
||||
*/
|
||||
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
|
||||
|
||||
|
||||
@@ -1,26 +1,81 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Today
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
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.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
|
||||
private val GlyphShape = RoundedCornerShape(7.dp)
|
||||
private val GlyphBorder = 1.5.dp
|
||||
|
||||
/**
|
||||
* Height of the day number. Expressed in dp and converted to sp at draw time so
|
||||
* it does not follow the font scale: this is an icon glyph sharing the bar with
|
||||
* stroke icons, and a two-digit day that grew with the text would push the bar
|
||||
* past the width #165 measured it to. The button's content description carries
|
||||
* the meaning for screen readers, so nothing is lost by holding it still.
|
||||
*/
|
||||
private val GlyphTextHeight = 12.dp
|
||||
|
||||
/**
|
||||
* The top-bar "jump to today" icon button, shared by every calendar view's app
|
||||
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
|
||||
* today control into the toolbar; renders nothing when [show] is false, so it
|
||||
* drops straight into an app bar's `actions` slot without a wrapping condition.
|
||||
*
|
||||
* The glyph is the current day number in an outlined box rather than a generic
|
||||
* calendar icon (#220), so the bar also says what day it is. Outlined rather
|
||||
* than the month grid's filled circle, which reads as "this cell is today" and
|
||||
* would sit heavier here than the stroke icons beside it.
|
||||
*/
|
||||
@Composable
|
||||
fun TodayAction(show: Boolean, onToday: () -> Unit) {
|
||||
if (!show) return
|
||||
IconButton(onClick = onToday) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Today,
|
||||
contentDescription = stringResource(R.string.today_jump_action),
|
||||
)
|
||||
val now = rememberCurrentMinute()
|
||||
// Only the day number is read, so the bar recomposes at midnight rather
|
||||
// than on every minute tick.
|
||||
val day by remember {
|
||||
derivedStateOf { now.value.toLocalDateTime(TimeZone.currentSystemDefault()).date.day }
|
||||
}
|
||||
val description = stringResource(R.string.today_jump_action)
|
||||
IconButton(
|
||||
onClick = onToday,
|
||||
modifier = Modifier.semantics { contentDescription = description },
|
||||
) {
|
||||
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(AppBarSpacing.IconSize)
|
||||
.border(GlyphBorder, LocalContentColor.current, GlyphShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = day.toString(),
|
||||
fontSize = textSize,
|
||||
lineHeight = textSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = LocalContentColor.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/**
|
||||
* Top-bar pill that shows the current view and cycles to the next one on tap
|
||||
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
|
||||
*
|
||||
* Renders nothing when [cycle] holds fewer than [QuickSwitchConfig.MIN_CYCLE]
|
||||
* views (#150), so it drops straight into an app bar's `actions` slot without a
|
||||
* wrapping condition — the same way [TodayAction] handles being turned off.
|
||||
*
|
||||
* [trailingInset] lands the pill's background on the bar's own rhythm; pass
|
||||
* `0.dp` outside a top app bar, where M3's actions padding is not there to
|
||||
* cancel (#165).
|
||||
*/
|
||||
@Composable
|
||||
fun ViewSwitcherPill(
|
||||
current: CalendarView,
|
||||
cycle: List<CalendarView>,
|
||||
onCycle: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
|
||||
) {
|
||||
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
|
||||
FilledTonalButton(
|
||||
onClick = onCycle,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = modifier,
|
||||
modifier = modifier.padding(end = trailingInset),
|
||||
) {
|
||||
Text(stringResource(current.labelRes))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -40,6 +39,7 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
@@ -53,10 +53,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -65,7 +63,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -73,6 +70,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||
@@ -81,7 +81,12 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
@@ -94,9 +99,12 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
|
||||
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
|
||||
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
||||
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
|
||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
|
||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||
@@ -106,25 +114,21 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||
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.TIMELINE_CONTENT_END_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -170,21 +174,9 @@ fun DayScreen(
|
||||
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// The all-day strip shares the app bar's scrolled colour so the whole top
|
||||
// region elevates together once the timeline scrolls under it.
|
||||
val topSectionColor by animateColorAsState(
|
||||
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
label = "day-top-section-color",
|
||||
)
|
||||
|
||||
val isOnToday = when (val s = state) {
|
||||
is DayUiState.Success -> s.date == s.today
|
||||
else -> true
|
||||
@@ -242,19 +234,19 @@ fun DayScreen(
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
DayTopBar(
|
||||
date = date,
|
||||
currentYear = currentYear,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
onJumpToDate = jumpToDate,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
@@ -269,7 +261,6 @@ fun DayScreen(
|
||||
DayContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
@@ -287,7 +278,6 @@ fun DayScreen(
|
||||
private fun DayContent(
|
||||
state: DayUiState,
|
||||
slideDir: Int,
|
||||
topSectionColor: Color,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
@@ -348,7 +338,6 @@ private fun DayContent(
|
||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is DayUiState.Success -> DaySuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
dragController = dragController,
|
||||
@@ -374,7 +363,6 @@ private fun DayContent(
|
||||
@Composable
|
||||
internal fun DaySuccess(
|
||||
state: DayUiState.Success,
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
@@ -385,16 +373,22 @@ internal fun DaySuccess(
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// All-day strip collapses to nothing when the day has no all-day events,
|
||||
// so the timeline sits directly under the app bar.
|
||||
AllDayStrip(
|
||||
state = state,
|
||||
height = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(topSectionColor),
|
||||
)
|
||||
// Breathing room between the (colour-shifting) top section and the
|
||||
// scrolling timeline below.
|
||||
// This strip is painted on `surface`, so an outlined chip has to fill
|
||||
// with that and not with the column's container (#230).
|
||||
CompositionLocalProvider(
|
||||
LocalChipGround provides MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
AllDayStrip(
|
||||
state = state,
|
||||
height = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
}
|
||||
// Breathing room between the top section and the scrolling timeline
|
||||
// below.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Timeline(
|
||||
state = state,
|
||||
@@ -414,20 +408,25 @@ private fun DayTopBar(
|
||||
currentYear: Int,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val (title, shortTitle) = remember(date, locale, currentYear) {
|
||||
formatDayTitle(date, locale, currentYear) to
|
||||
formatDayTitle(date, locale, currentYear, abbreviated = true)
|
||||
}
|
||||
TopAppBar(
|
||||
title = {
|
||||
CalendarTitleButton(
|
||||
title = formatDayTitle(date, locale, currentYear),
|
||||
title = title,
|
||||
currentDate = date,
|
||||
onJumpToDate = onJumpToDate,
|
||||
shortTitle = shortTitle,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@@ -448,15 +447,17 @@ private fun DayTopBar(
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
cycle = quickSwitchViews,
|
||||
onCycle = onCycleView,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||
// it, but here the bar meets the header on the same surface and the
|
||||
// tint is what makes that seam look like a separate block (#186).
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -516,23 +517,25 @@ private fun AllDayBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(event.color, dark, soften)
|
||||
val paint = eventPaint(event, dark)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(fill, EventChipShape)
|
||||
.eventSurface(paint, EventChipShape)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = title },
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val titleOverflow = eventTitleOverflow()
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill),
|
||||
textDecoration = declinedDecoration(event.isDeclined),
|
||||
overflow = titleOverflow.overflow,
|
||||
softWrap = titleOverflow.softWrap,
|
||||
color = paint.titleInk,
|
||||
fontWeight = paint.titleWeight,
|
||||
textDecoration = paint.decoration,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -550,6 +553,12 @@ private fun Timeline(
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val density = LocalDensity.current
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
// What the hour grid takes off a block's ends, for the floating copy to
|
||||
// take off its own. Per-end, a cut edge keeps it — that much the drag
|
||||
// geometry decides itself, from the slice it is drawing.
|
||||
val blockInsetPx = with(density) {
|
||||
hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx()
|
||||
}
|
||||
|
||||
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
||||
// timeline's own viewport height, which is only known here — below the top
|
||||
@@ -603,6 +612,7 @@ private fun Timeline(
|
||||
it.grid = coords
|
||||
it.scroll = scrollState
|
||||
it.hourPx = with(density) { hourHeight.toPx() }
|
||||
it.blockInsetPx = blockInsetPx
|
||||
it.columnGapPx = 0f
|
||||
it.columnWidthPx = coords.size.width.toFloat()
|
||||
it.days = listOf(state.date)
|
||||
@@ -629,8 +639,8 @@ private fun DayColumnCard(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
val showHourGrid = LocalShowHourGrid.current
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
// Tells a settled drop when this column has caught up with it.
|
||||
LaunchedEffect(blocks, dragController.settling) {
|
||||
dragController.noteGrid(date, blocks)
|
||||
@@ -646,16 +656,23 @@ private fun DayColumnCard(
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
shape = RectangleShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
// With the grid on, the container colour moves onto the hour cells
|
||||
// and the column behind them recedes to surface, so an hour boundary
|
||||
// reads as negative space between two surfaces.
|
||||
containerColor = if (showHourGrid) {
|
||||
MaterialTheme.colorScheme.surface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
},
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Faint hour separators sit over the column background but under
|
||||
// the event blocks (drawBehind paints before the children).
|
||||
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
|
||||
// The hour cells sit over the column background but under the
|
||||
// event blocks (drawBehind paints before the children).
|
||||
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer)
|
||||
// Tap an empty slot to create an event there. Taps on event
|
||||
// blocks are consumed by their own click handler first, so this
|
||||
// only fires on the column background. Snaps to the tapped hour.
|
||||
@@ -687,19 +704,34 @@ private fun DayColumnCard(
|
||||
width = laneWidth,
|
||||
height = height,
|
||||
)
|
||||
// Where this block is cut at midnight, which decides both
|
||||
// its shape and the half-gap it gives up to the hour cells.
|
||||
val cuts = remember(block, date, zone) {
|
||||
timedBlockCuts(
|
||||
block.continuesBefore(date, zone),
|
||||
block.continuesAfter(date, zone),
|
||||
)
|
||||
}
|
||||
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
|
||||
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
|
||||
val blockHeight = (place.height - topInset - bottomInset)
|
||||
.coerceAtLeast(0.dp)
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = place.height,
|
||||
height = blockHeight,
|
||||
width = place.width,
|
||||
date = date,
|
||||
cuts = cuts,
|
||||
topInset = topInset,
|
||||
dragController = dragController,
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.offset(x = place.x, y = place.y)
|
||||
.offset(x = place.x, y = place.y + topInset)
|
||||
.width(place.width)
|
||||
.height(place.height)
|
||||
.padding(horizontal = 1.dp),
|
||||
.height(blockHeight)
|
||||
.padding(horizontal = BLOCK_OUTER_INSET),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -716,7 +748,10 @@ private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
width: Dp,
|
||||
date: LocalDate,
|
||||
cuts: ChipCuts,
|
||||
topInset: Dp,
|
||||
dragController: TimelineDragController,
|
||||
onClick: () -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
@@ -734,29 +769,48 @@ private fun EventBlock(
|
||||
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
|
||||
// A block that cannot afford both lines spends its space on the title, and
|
||||
// one too short even for that drops the title rather than serving a sliced one.
|
||||
// Height alone decides: a duration threshold would keep hiding the time on a
|
||||
// half-hour block the user has pinched open to three times the room it needs.
|
||||
val available = height - BLOCK_TEXT_INSET * 2
|
||||
val showTime = available >= titleLineHeight + timeLineHeight
|
||||
val showTitle = available >= titleLineHeight
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(block.event.color, dark, soften)
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
// Only lines the block can actually draw: a block too short for the time is
|
||||
// too short for a second title line too, and asking for one served a sliced
|
||||
// one — as well as handing the drag copy a count it couldn't honour, so the
|
||||
// title re-wrapped the moment the block was lifted (#267).
|
||||
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
|
||||
// On a day column — wide enough for "09:30–11:00" several times over — the
|
||||
// range never needs the second line, until lanes cut the column down.
|
||||
val spare = available - titleLineHeight * titleMaxLines -
|
||||
if (showTime) timeLineHeight else 0.dp
|
||||
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
||||
val paint = eventPaint(block.event, dark)
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val moveAction = eventMoveAction(block.event)
|
||||
val draggable = eventDragAllowed(block.event)
|
||||
// The drop takes this offset back off, so a tail clipped at midnight lands
|
||||
// where the event's own start belongs (#253).
|
||||
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
||||
val shape = remember(block, date, zone) {
|
||||
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
|
||||
}
|
||||
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
|
||||
val topInsetPx = with(density) { topInset.toPx() }
|
||||
val dragModifier = rememberEventDragSource(
|
||||
enabled = draggable,
|
||||
key = block.event.instanceId,
|
||||
onPickUp = { pointer, blockRoot, _ ->
|
||||
dragController.begin(block, clipOffset, pointer, blockRoot)
|
||||
dragController.begin(
|
||||
block = block,
|
||||
clipOffsetMin = clipOffset,
|
||||
titleLines = if (showTitle) titleMaxLines else 0,
|
||||
pointerInRoot = pointer,
|
||||
// Measured off the block's placement rather than off where the
|
||||
// hour grid seats it: the copy is drawn from that placement and
|
||||
// re-seated the same half-gap down (#267).
|
||||
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
|
||||
)
|
||||
},
|
||||
onMove = dragController::move,
|
||||
onDrop = { dragController.finish()?.let(onDrop) },
|
||||
@@ -768,12 +822,12 @@ private fun EventBlock(
|
||||
modifier = modifier
|
||||
// The source stays put as a ghost while its floating copy travels.
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||
.background(fill, shape)
|
||||
.eventSurface(paint, shape, cuts)
|
||||
.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)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
@@ -781,19 +835,19 @@ private fun EventBlock(
|
||||
) {
|
||||
Column {
|
||||
if (showTitle) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||
BlockTitle(
|
||||
title = title,
|
||||
maxLines = titleMaxLines,
|
||||
color = paint.titleInk,
|
||||
textDecoration = paint.decoration,
|
||||
fontWeight = paint.titleWeight,
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
BlockTimeLabel(
|
||||
label = timeLabel,
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
color = paint.secondaryInk,
|
||||
maxLines = timeMaxLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -807,7 +861,12 @@ private fun DayLoading() {
|
||||
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
|
||||
val hourHeight = scale.hourHeight(maxHeight)
|
||||
val totalHeight = hourHeight * 24
|
||||
// The skeleton wears the loaded column's own ground, so the arrival of
|
||||
// the real day doesn't flash a solid block into a gapped grid.
|
||||
val showHourGrid = LocalShowHourGrid.current
|
||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -820,7 +879,11 @@ private fun DayLoading() {
|
||||
.weight(1f)
|
||||
.height(totalHeight)
|
||||
.padding(horizontal = 2.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
.background(
|
||||
if (showHourGrid) MaterialTheme.colorScheme.surface
|
||||
else MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -829,10 +892,16 @@ private fun DayLoading() {
|
||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
|
||||
/** [abbreviated] drops the weekday, leaving the date itself (#165). */
|
||||
private fun formatDayTitle(
|
||||
date: LocalDate,
|
||||
locale: Locale,
|
||||
currentYear: Int,
|
||||
abbreviated: Boolean = false,
|
||||
): String =
|
||||
formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = "EEEdMMM",
|
||||
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -41,7 +40,6 @@ internal fun DayViewPreview(
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
DaySuccess(
|
||||
state = state,
|
||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = state.allDayStripHeight(),
|
||||
dragController = rememberTimelineDragController(),
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -98,6 +97,9 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
||||
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
|
||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
@@ -150,6 +152,7 @@ fun EventDetailScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val copyField = rememberFieldCopier(snackbarHostState)
|
||||
|
||||
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
|
||||
// an .ics in the cache and hands back a content Uri for the chooser.
|
||||
@@ -300,7 +303,7 @@ fun EventDetailScreen(
|
||||
reason = s.reason,
|
||||
onRetry = viewModel::retry,
|
||||
)
|
||||
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
||||
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +385,11 @@ private fun DeleteEventDialog(
|
||||
|
||||
|
||||
@Composable
|
||||
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
|
||||
private fun EventDetailContent(
|
||||
state: EventDetailUiState.Success,
|
||||
copyField: FieldCopier,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val detail = state.detail
|
||||
val instance = detail.instance
|
||||
val dark = isSystemInDarkTheme()
|
||||
@@ -399,6 +406,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
// event, so it's left implicit — only Free is worth surfacing. A
|
||||
// cancelled event strikes through its title.
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
val titleLabel = stringResource(R.string.event_detail_title)
|
||||
Text(
|
||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
@@ -408,7 +416,17 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
} else {
|
||||
null
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.then(
|
||||
// Nothing to copy off an untitled event — the placeholder
|
||||
// isn't the event's own text.
|
||||
if (instance.title.isNotBlank()) {
|
||||
Modifier.copyOnLongPress(titleLabel, instance.title, copyField)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
if (detail.availability == Availability.Free) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
@@ -516,10 +534,11 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
// Location (conditional, tap → maps).
|
||||
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||
val context = LocalContext.current
|
||||
val locationLabel = stringResource(R.string.event_detail_location)
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.Place,
|
||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||
iconContentDescription = locationLabel,
|
||||
) {
|
||||
Text(
|
||||
text = location,
|
||||
@@ -527,7 +546,12 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { openInMaps(context, location) }
|
||||
.copyOnLongPress(
|
||||
label = locationLabel,
|
||||
text = location,
|
||||
copy = copyField,
|
||||
onTap = { openInMaps(context, location) },
|
||||
)
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -535,10 +559,15 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
|
||||
// Description (conditional). URLs are auto-linked.
|
||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
val descriptionLabel = stringResource(R.string.event_detail_description)
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.AutoMirrored.Filled.Notes,
|
||||
iconContentDescription = stringResource(R.string.event_detail_description),
|
||||
iconContentDescription = descriptionLabel,
|
||||
// The gesture sits on the card so the icon and padding answer
|
||||
// it too: every linkified URL owns the pointer over its own
|
||||
// glyphs, which leaves the text itself a patchy target.
|
||||
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
|
||||
) {
|
||||
Text(
|
||||
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||
@@ -611,13 +640,14 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
private fun DetailCard(
|
||||
icon: ImageVector,
|
||||
iconContentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -90,6 +91,7 @@ import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
@@ -101,6 +103,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -788,38 +791,59 @@ private fun EventEditContent(
|
||||
EditCard(
|
||||
icon = Icons.Default.Place,
|
||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||
iconAtTop = true,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
InlineField(
|
||||
value = form.location,
|
||||
onValueChange = viewModel::setLocation,
|
||||
placeholder = stringResource(R.string.event_detail_location),
|
||||
// A location is as often a meeting URL as an address,
|
||||
// and "Zoom.us/j/123" reads wrong (#146).
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
// Multi-line so a long address or meeting URL wraps and
|
||||
// the card grows instead of scrolling off one line
|
||||
// (#273). The field holds no newlines of its own, so
|
||||
// the IME's action key closes the keyboard rather than
|
||||
// inserting a break setLocation would only fold away.
|
||||
singleLine = false,
|
||||
imeAction = ImeAction.Done,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.animateContentSizeMotion()
|
||||
.padding(vertical = 4.dp),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
pickContactAddress.launch(
|
||||
Intent(
|
||||
Intent.ACTION_PICK,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal
|
||||
.CONTENT_URI,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(40.dp),
|
||||
// The box is exactly one text line tall, so the button
|
||||
// centres on the first line without making a single-line
|
||||
// card taller than its text; requiredSize keeps the
|
||||
// button's own 40dp ripple.
|
||||
Box(
|
||||
modifier = Modifier.height(firstLineHeight()),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Contacts,
|
||||
contentDescription = stringResource(
|
||||
R.string.event_edit_location_from_contacts,
|
||||
),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
pickContactAddress.launch(
|
||||
Intent(
|
||||
Intent.ACTION_PICK,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal
|
||||
.CONTENT_URI,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.requiredSize(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Contacts,
|
||||
contentDescription = stringResource(
|
||||
R.string.event_edit_location_from_contacts,
|
||||
),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1989,8 +2013,8 @@ private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>?
|
||||
/**
|
||||
* Read the formatted postal address from a contact-pick result URI. No
|
||||
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
|
||||
* The provider's formatted address is multi-line; collapse it to one line so
|
||||
* it sits cleanly in the single-line location field.
|
||||
* The provider's formatted address is multi-line; collapse it to one comma-
|
||||
* separated line, which is what the location field holds.
|
||||
*/
|
||||
private fun readContactAddress(context: Context, uri: Uri): String? =
|
||||
context.contentResolver.query(
|
||||
@@ -2174,14 +2198,19 @@ private fun EditCard(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
|
||||
) {
|
||||
// A top-aligned icon centres on the first text line, whatever
|
||||
// that line measures at the user's font scale.
|
||||
val iconOffset = if (iconAtTop) {
|
||||
((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp)
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = iconContentDescription,
|
||||
tint = iconTint,
|
||||
// 4dp mirrors InlineField's vertical padding, so a
|
||||
// top-aligned icon (24dp) centres on the ~24sp first line.
|
||||
modifier = Modifier
|
||||
.padding(top = if (iconAtTop) 4.dp else 0.dp)
|
||||
.padding(top = iconOffset)
|
||||
.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
@@ -2223,7 +2252,9 @@ private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Lo
|
||||
/**
|
||||
* Borderless text input used inside the cards (and as the headline title).
|
||||
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
||||
* the app share one input style.
|
||||
* the app share one input style. Sentence-case by default, as floret-kit is;
|
||||
* a field holding an identifier rather than prose passes
|
||||
* [KeyboardCapitalization.None].
|
||||
*/
|
||||
@Composable
|
||||
private fun InlineField(
|
||||
@@ -2235,6 +2266,8 @@ private fun InlineField(
|
||||
minLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction: ImeAction = ImeAction.Default,
|
||||
modifier: Modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
@@ -2249,10 +2282,25 @@ private fun InlineField(
|
||||
minLines = minLines,
|
||||
enabled = enabled,
|
||||
keyboardType = keyboardType,
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
capitalization = capitalization,
|
||||
imeAction = imeAction,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Height of one [InlineField] line: the resolved [TextStyle.lineHeight] plus the
|
||||
* field's 4dp vertical padding. In dp, so it tracks the user's font scale
|
||||
* instead of assuming the 24sp line of a 1.0 scale (#165).
|
||||
*/
|
||||
@Composable
|
||||
private fun firstLineHeight(
|
||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||
): Dp {
|
||||
val lineHeight = textStyle.lineHeight
|
||||
if (!lineHeight.isSp) return 32.dp
|
||||
return with(LocalDensity.current) { lineHeight.toDp() } + 8.dp
|
||||
}
|
||||
|
||||
/** One schedule row: label, then tappable date and (unless all-day) time. */
|
||||
@Composable
|
||||
private fun ScheduleRow(
|
||||
|
||||
@@ -59,6 +59,9 @@ import javax.inject.Inject
|
||||
|
||||
private const val TAG = "EventEdit"
|
||||
|
||||
/** Any line break, with the whitespace around it, as one match. */
|
||||
private val LINE_BREAK = Regex("""[ \t]*\R[ \t]*""")
|
||||
|
||||
/**
|
||||
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||
* want different reminder handling (#49), and differ in whether they own the
|
||||
@@ -532,7 +535,11 @@ class EventEditViewModel @Inject constructor(
|
||||
// paste would introduce, so it never reaches the provider's TITLE column.
|
||||
fun setTitle(value: String) =
|
||||
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
|
||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||
// The location wraps too (#273) but is likewise one logical line. A pasted
|
||||
// multi-line address joins the way the contact picker's does (#146), so it
|
||||
// never runs together into "12 Main St10115 Berlin".
|
||||
fun setLocation(value: String) =
|
||||
update { it.copy(location = value.replace(LINE_BREAK, ", ")) }
|
||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||
fun setAllDay(value: Boolean) {
|
||||
// Going all-day drops any pinned zone: the times become bare dates that
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.jeanlucmakiola.calendula.ui.licences
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/**
|
||||
* Third-party attribution.
|
||||
*
|
||||
* Reachable from Settings rather than buried, because Apache-2.0 §4(a) is about
|
||||
* what the *recipient of the binary* is told. Each row opens the project's source.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LicencesScreen(onBack: () -> Unit) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.licences_title),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.licences_intro),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OpenSourceLicences.ALL.forEachIndexed { index, attribution ->
|
||||
GroupedRow(
|
||||
title = attribution.name,
|
||||
summary = "${attribution.copyright} · ${attribution.licence.spdxId}",
|
||||
position = positionOf(index, OpenSourceLicences.ALL.size),
|
||||
onClick = { uriHandler.openUri(attribution.sourceUrl) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(R.string.licences_footer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.jeanlucmakiola.calendula.ui.licences
|
||||
|
||||
/**
|
||||
* The third-party code Calendula ships, and what each licence obliges us to say.
|
||||
*
|
||||
* This screen is a **licence obligation, not an about-page nicety**:
|
||||
* **Apache-2.0 §4(a)** requires the licence to travel with the work and **§4(d)**
|
||||
* propagates any `NOTICE` file. What discharges that is what the recipient of the
|
||||
* APK is shown — a notice only visible to someone reading the repository does not
|
||||
* count. Nearly everything below is Apache-2.0.
|
||||
*
|
||||
* Hand-maintained on purpose. Generators read POM metadata, and POM metadata is
|
||||
* routinely wrong — the licence name is often not an SPDX id and the licence URL
|
||||
* frequently 404s, which produces confidently incomplete output. A short honest
|
||||
* list beats a long generated one that omits the entry that mattered.
|
||||
*
|
||||
* **When adding a dependency, add it here.** There is no automated check that can
|
||||
* tell you that you forgot. Test-only dependencies (JUnit, Truth, Turbine) are not
|
||||
* shipped in the APK and stay off the list.
|
||||
*/
|
||||
data class Attribution(
|
||||
val name: String,
|
||||
val copyright: String,
|
||||
val licence: Licence,
|
||||
/** Where the source can actually be obtained; what each row links to. */
|
||||
val sourceUrl: String,
|
||||
)
|
||||
|
||||
enum class Licence(val spdxId: String, val url: String) {
|
||||
APACHE_2("Apache-2.0", "https://www.apache.org/licenses/LICENSE-2.0"),
|
||||
MIT("MIT", "https://opensource.org/license/mit"),
|
||||
}
|
||||
|
||||
object OpenSourceLicences {
|
||||
|
||||
val ALL: List<Attribution> = listOf(
|
||||
Attribution(
|
||||
name = "AndroidX, Jetpack Compose, Glance, WorkManager, DataStore, DocumentFile",
|
||||
copyright = "© The Android Open Source Project",
|
||||
licence = Licence.APACHE_2,
|
||||
sourceUrl = "https://cs.android.com/androidx/platform/frameworks/support",
|
||||
),
|
||||
Attribution(
|
||||
name = "Kotlin, kotlinx.coroutines, kotlinx.datetime",
|
||||
copyright = "© JetBrains s.r.o. and Kotlin Programming Language contributors",
|
||||
licence = Licence.APACHE_2,
|
||||
sourceUrl = "https://github.com/JetBrains/kotlin",
|
||||
),
|
||||
Attribution(
|
||||
name = "Dagger and Hilt",
|
||||
copyright = "© Google LLC and The Dagger Authors",
|
||||
licence = Licence.APACHE_2,
|
||||
sourceUrl = "https://github.com/google/dagger",
|
||||
),
|
||||
Attribution(
|
||||
name = "Material Design icons",
|
||||
copyright = "© Google LLC",
|
||||
licence = Licence.APACHE_2,
|
||||
sourceUrl = "https://github.com/google/material-design-icons",
|
||||
),
|
||||
Attribution(
|
||||
name = "floret-kit",
|
||||
copyright = "© Jean-Luc Makiola",
|
||||
licence = Licence.MIT,
|
||||
sourceUrl = "https://codeberg.org/jlmakiola/floret-kit",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// The month grid's chip geometry, together in one file: these derive from each
|
||||
// other, and a top-level val reading one across a file boundary would resolve
|
||||
// against whichever class the JVM happened to initialise first.
|
||||
|
||||
/** Gap between a day cell and its neighbours. */
|
||||
internal val CELL_GAP = 2.dp
|
||||
|
||||
/** Padding between a month chip's edge and its text. */
|
||||
internal val MONTH_CHIP_TEXT_PADDING = 4.dp
|
||||
|
||||
/** A chip's own inset inside its day cell, on top of the cell's gap. */
|
||||
internal val MONTH_CHIP_INSET = CELL_GAP + 1.dp
|
||||
|
||||
/** Horizontal space a chip spends on chrome rather than on text, both sides. */
|
||||
internal val MONTH_CHIP_CHROME = (MONTH_CHIP_INSET + MONTH_CHIP_TEXT_PADDING) * 2f
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The start time a chip shows before its title (#219), or null when it has none
|
||||
* to show: an all-day chip never carries a time, and neither does a bar carried
|
||||
* in from an earlier week, whose start is not in this segment.
|
||||
*/
|
||||
internal fun monthChipTime(
|
||||
event: EventInstance,
|
||||
continuesLeft: Boolean,
|
||||
zone: TimeZone,
|
||||
is24Hour: Boolean,
|
||||
locale: Locale,
|
||||
): String? {
|
||||
if (event.isAllDay || continuesLeft) return null
|
||||
val start = event.start.toLocalDateTime(zone).time
|
||||
return formatTimeOfDay(start.hour, start.minute, is24Hour, locale)
|
||||
}
|
||||
|
||||
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */
|
||||
@Composable
|
||||
internal fun rememberMonthTimeChipWidth(): Dp =
|
||||
rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + MONTH_CHIP_CHROME
|
||||
@@ -97,6 +97,13 @@ data class MonthChipDrag(
|
||||
val targetDate: LocalDate?,
|
||||
val topLeftInRoot: Offset,
|
||||
val sizePx: IntSize,
|
||||
/**
|
||||
* The start time of the chip this lifted off, already formatted (#219).
|
||||
* Carried rather than re-derived: the overlay has no row to ask whether the
|
||||
* bar was carried in from the previous week, and no zone the grid laid out
|
||||
* in. Null where the source chip had no time to show.
|
||||
*/
|
||||
val time: String? = null,
|
||||
)
|
||||
|
||||
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
||||
@@ -172,6 +179,7 @@ class MonthDragController {
|
||||
|
||||
private var event: EventInstance? = null
|
||||
private var grabDate: LocalDate? = null
|
||||
private var time: String? = null
|
||||
private var grab = Offset.Zero
|
||||
private var pointer = Offset.Zero
|
||||
private var sizePx = IntSize.Zero
|
||||
@@ -190,9 +198,11 @@ class MonthDragController {
|
||||
pointerInRoot: Offset,
|
||||
chipInRoot: Offset,
|
||||
size: IntSize,
|
||||
time: String? = null,
|
||||
) {
|
||||
this.event = event
|
||||
this.grabDate = grabDate
|
||||
this.time = time
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = event.instanceId
|
||||
@@ -210,6 +220,7 @@ class MonthDragController {
|
||||
fun cancel() {
|
||||
event = null
|
||||
grabDate = null
|
||||
time = null
|
||||
isDragging = false
|
||||
liftedInstanceId = null
|
||||
drag = null
|
||||
@@ -331,6 +342,7 @@ class MonthDragController {
|
||||
targetDate = resolved ?: drag?.targetDate,
|
||||
topLeftInRoot = pointer - grab,
|
||||
sizePx = sizePx,
|
||||
time = time,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,9 +88,13 @@ 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 de.jeanlucmakiola.calendula.ui.common.eventPaint
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.monthBarCuts
|
||||
import kotlin.math.roundToInt
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
@@ -113,7 +117,6 @@ import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -123,6 +126,7 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -134,6 +138,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||
@@ -142,14 +147,13 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.monthBarShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
@@ -212,7 +216,6 @@ fun MonthScreen(
|
||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -298,10 +301,14 @@ fun MonthScreen(
|
||||
// carries the year instead of repeating it two lines further down. Dense has
|
||||
// no header to defer to, so it keeps the full title.
|
||||
val locale = currentLocale()
|
||||
val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) {
|
||||
titleMonth.year.toString()
|
||||
} else {
|
||||
formatMonthTitle(titleMonth, locale, currentYear = today.year)
|
||||
val (topBarTitle, topBarShortTitle) = remember(viewStyle, titleMonth, locale, today.year) {
|
||||
if (viewStyle == MonthViewStyle.Continuous) {
|
||||
val year = titleMonth.year.toString()
|
||||
year to year
|
||||
} else {
|
||||
formatMonthTitle(titleMonth, locale, currentYear = today.year) to
|
||||
formatMonthTitle(titleMonth, locale, currentYear = today.year, abbreviated = true)
|
||||
}
|
||||
}
|
||||
|
||||
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
||||
@@ -386,19 +393,20 @@ fun MonthScreen(
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
MonthTopBar(
|
||||
title = topBarTitle,
|
||||
shortTitle = topBarShortTitle,
|
||||
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
onJumpToDate = jumpToDate,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
@@ -494,6 +502,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
val density = LocalDensity.current
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val moveInFlight = moveInFlight()
|
||||
// Here rather than beside the controller: this reads [drag], which changes
|
||||
@@ -551,6 +560,11 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
// The time the source chip carried, drawn only if this one-column
|
||||
// cut-out of it has the room — which a slice of a multi-day bar has
|
||||
// not, however wide the bar was.
|
||||
time = drag.time,
|
||||
showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth,
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
@@ -563,7 +577,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
}
|
||||
.width(with(density) { drag.sizePx.width.toDp() })
|
||||
.height(with(density) { drag.sizePx.height.toDp() })
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.04f * lift
|
||||
scaleY = 1f + 0.04f * lift
|
||||
@@ -664,15 +678,16 @@ private fun ContinuousMonthContent(
|
||||
@Composable
|
||||
private fun MonthTopBar(
|
||||
title: String,
|
||||
shortTitle: String,
|
||||
titleDate: LocalDate,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
@@ -680,6 +695,7 @@ private fun MonthTopBar(
|
||||
title = title,
|
||||
currentDate = titleDate,
|
||||
onJumpToDate = onJumpToDate,
|
||||
shortTitle = shortTitle,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@@ -700,11 +716,17 @@ private fun MonthTopBar(
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
cycle = quickSwitchViews,
|
||||
onCycle = onCycleView,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||
// it, but here the bar meets the header on the same surface and the
|
||||
// tint is what makes that seam look like a separate block (#186).
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -719,7 +741,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||
) {
|
||||
// Reserve the gutter so the weekday labels stay over their day columns.
|
||||
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
|
||||
@@ -745,7 +767,6 @@ private val DAY_NUMBER_HEIGHT = 22.dp
|
||||
private val WEEK_NUMBER_GUTTER = 40.dp
|
||||
private val DAY_NUMBER_GAP = 4.dp
|
||||
private val CELL_TOP_PADDING = 6.dp
|
||||
private val CELL_GAP = 2.dp
|
||||
/** Named separately because the split style's selection outline draws its own
|
||||
* rounded rect and has to match this radius exactly. */
|
||||
private val CELL_CORNER = 12.dp
|
||||
@@ -787,17 +808,22 @@ internal fun MonthGrid(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Match the weekday header's 8dp inset so day cells sit under their
|
||||
// Match the weekday header's inset so day cells sit under their
|
||||
// labels, and so the week-number gutter's centre lines up with the
|
||||
// top bar's hamburger (4dp bar inset + 24dp half icon button).
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
val month = state.month
|
||||
// Once per grid: the value depends on the typography, the density, the
|
||||
// locale and the 24-hour setting, none of which vary by row (#219).
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
state.weeks.forEach { week ->
|
||||
MonthWeekRow(
|
||||
week = week,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
timeChipWidth = timeChipWidth,
|
||||
inMonth = { it.month == month.month && it.year == month.year },
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
@@ -837,6 +863,7 @@ internal fun ContinuousMonthGrid(
|
||||
) {
|
||||
val monthCount = remember { continuousMonthCount() }
|
||||
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) }
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
@@ -858,6 +885,8 @@ internal fun ContinuousMonthGrid(
|
||||
weeks = state.monthsByIndex[index],
|
||||
weekStart = state.weekStart,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
timeChipWidth = timeChipWidth,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
@@ -878,6 +907,8 @@ private fun ContinuousMonthBlock(
|
||||
weeks: List<MonthWeek>?,
|
||||
weekStart: DayOfWeek,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
timeChipWidth: Dp,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
@@ -886,7 +917,7 @@ private fun ContinuousMonthBlock(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(horizontal = AppBarSpacing.Inset)
|
||||
.padding(bottom = CONTINUOUS_MONTH_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
@@ -897,6 +928,8 @@ private fun ContinuousMonthBlock(
|
||||
MonthWeekRow(
|
||||
week = week,
|
||||
today = today,
|
||||
zone = zone,
|
||||
timeChipWidth = timeChipWidth,
|
||||
inMonth = { it.month == month.month && it.year == month.year },
|
||||
// The block owns its month alone: a day from either
|
||||
// neighbour is left out entirely rather than dimmed.
|
||||
@@ -966,6 +999,7 @@ internal fun DenseMonthGrid(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
@@ -974,7 +1008,7 @@ internal fun DenseMonthGrid(
|
||||
// padding: a lazy list scrolls its rows *through* the before-content
|
||||
// padding, so scrolling to a week landed its row that far down with
|
||||
// the tail of the previous one showing above it (#191).
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(horizontal = AppBarSpacing.Inset)
|
||||
.padding(top = DENSE_HEADER_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
@@ -988,6 +1022,8 @@ internal fun DenseMonthGrid(
|
||||
MonthWeekRow(
|
||||
week = week,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
timeChipWidth = timeChipWidth,
|
||||
// Every day in the stream belongs to a month equally — there
|
||||
// is no "other month" to recede here.
|
||||
inMonth = { true },
|
||||
@@ -1441,7 +1477,7 @@ internal fun SplitMonthGrid(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
state.weeks.forEach { week ->
|
||||
@@ -1806,7 +1842,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(horizontal = AppBarSpacing.Inset)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
if (!dense) {
|
||||
@@ -1873,6 +1909,10 @@ private fun rememberSkeletonPulse(): Float {
|
||||
private fun MonthWeekRow(
|
||||
week: MonthWeek,
|
||||
today: LocalDate,
|
||||
/** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */
|
||||
zone: TimeZone,
|
||||
/** The narrowest chip that may carry a start time, measured once per grid (#219). */
|
||||
timeChipWidth: Dp,
|
||||
inMonth: (LocalDate) -> Boolean,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
@@ -1892,6 +1932,27 @@ private fun MonthWeekRow(
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||
val morphing = morphInFlight()
|
||||
// Every chip's start time for this row at once, and only when the row's own
|
||||
// inputs change: formatting is a parsed pattern per call, and the dim cutoff
|
||||
// ticks every minute while a drag recomposes every frame (#219).
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val chipTimes = remember(week, zone, is24Hour, locale) {
|
||||
buildMap {
|
||||
week.spans.forEach { span ->
|
||||
put(
|
||||
span.event.instanceId,
|
||||
monthChipTime(span.event, span.continuesLeft, zone, is24Hour, locale),
|
||||
)
|
||||
}
|
||||
week.timedByDay.values.flatten().forEach { event ->
|
||||
put(
|
||||
event.instanceId,
|
||||
monthChipTime(event, continuesLeft = false, zone, is24Hour, locale),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drag to reschedule (#68). The chips can take no pointer input of their own
|
||||
// — the full-bleed tap layer sits on top of them — so one detector on the
|
||||
@@ -1966,9 +2027,12 @@ private fun MonthWeekRow(
|
||||
band = bandCoordinates,
|
||||
rowHeightPx = rowHeightPx,
|
||||
isRtl = isRtl,
|
||||
chipTimes = chipTimes,
|
||||
),
|
||||
),
|
||||
) {
|
||||
// What a chip has to spend, against the [timeChipWidth] a start time
|
||||
// costs it (#219).
|
||||
val colW = maxWidth / 7
|
||||
|
||||
// Per-day background pills — same surfaceContainer rounded surface the
|
||||
@@ -2049,6 +2113,8 @@ private fun MonthWeekRow(
|
||||
continuesLeft = span.continuesLeft,
|
||||
continuesRight = span.continuesRight,
|
||||
days = week.days.subList(span.startCol, span.endCol + 1),
|
||||
time = chipTimes[span.event.instanceId],
|
||||
showTime = colW * cols >= timeChipWidth,
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * span.startCol,
|
||||
@@ -2072,7 +2138,7 @@ private fun MonthWeekRow(
|
||||
)
|
||||
.width(colW * cols)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||
)
|
||||
// One invisible slice of the bar per further column it
|
||||
// covers. A multi-day event has a dot on every day but
|
||||
@@ -2096,7 +2162,7 @@ private fun MonthWeekRow(
|
||||
)
|
||||
.width(colW)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2119,6 +2185,8 @@ private fun MonthWeekRow(
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
days = listOf(d),
|
||||
time = chipTimes[ev.instanceId],
|
||||
showTime = colW >= timeChipWidth,
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * col,
|
||||
@@ -2127,7 +2195,7 @@ private fun MonthWeekRow(
|
||||
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
||||
.width(colW)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||
@@ -2279,6 +2347,9 @@ private fun monthChipDragModifier(
|
||||
band: Array<LayoutCoordinates?>,
|
||||
rowHeightPx: Float,
|
||||
isRtl: Boolean,
|
||||
/** The row's formatted chip times by instance id, so the copy carries the
|
||||
* one its source chip had rather than deriving another (#219). */
|
||||
chipTimes: Map<Long, String?>,
|
||||
): Modifier = rememberDragSurface(
|
||||
enabled = moveScope?.dragEnabled == true && controller != null,
|
||||
key = week.days.first(),
|
||||
@@ -2307,6 +2378,7 @@ private fun monthChipDragModifier(
|
||||
y = requireNotNull(bandTop) + lane * rowHeightPx,
|
||||
),
|
||||
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
|
||||
time = chipTimes[event.instanceId],
|
||||
)
|
||||
true
|
||||
}
|
||||
@@ -2414,7 +2486,10 @@ private fun shortMonthName(date: LocalDate): String {
|
||||
}
|
||||
}
|
||||
|
||||
/** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */
|
||||
/**
|
||||
* A filled event pill/bar — softened (or raw) fill, title clipped to one line,
|
||||
* with the start time before it where the chip is wide enough ([showTime], #219).
|
||||
*/
|
||||
@Composable
|
||||
private fun MonthBar(
|
||||
event: de.jeanlucmakiola.calendula.domain.EventInstance,
|
||||
@@ -2428,12 +2503,26 @@ private fun MonthBar(
|
||||
* the provider hands the re-read instance a new one.
|
||||
*/
|
||||
days: List<LocalDate>? = null,
|
||||
/** The chip's start time, or null where it has none — see `monthChipTime` (#219). */
|
||||
time: String? = null,
|
||||
/** Whether this chip has the width to draw [time]; a screen reader gets it either way. */
|
||||
showTime: Boolean = false,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(event.color, dark, soften)
|
||||
val paint = eventPaint(event, dark)
|
||||
// The same title/secondary ink pairing the week and day blocks use, with
|
||||
// the time on the quieter half.
|
||||
val label = inlineTimeLabel(
|
||||
time = time.takeIf { showTime },
|
||||
title = title,
|
||||
timeInk = paint.secondaryInk,
|
||||
)
|
||||
// Announced whether or not it is drawn, and comma-separated as the week and
|
||||
// day blocks do it: what a screen reader hears shouldn't turn on how wide
|
||||
// the chip happens to be (#219).
|
||||
val description = if (time != null) "$title, $time" else title
|
||||
val moveAction = eventMoveAction(event)
|
||||
// The source stays put as a ghost while its floating copy travels.
|
||||
val monthDrag = LocalMonthDrag.current
|
||||
@@ -2444,21 +2533,24 @@ 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)
|
||||
.eventSurface(paint, shape, monthBarCuts(continuesLeft, continuesRight))
|
||||
.padding(horizontal = MONTH_CHIP_TEXT_PADDING)
|
||||
.semantics {
|
||||
contentDescription = title
|
||||
contentDescription = description
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
},
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val titleOverflow = eventTitleOverflow()
|
||||
Text(
|
||||
text = title,
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill),
|
||||
textDecoration = declinedDecoration(event.isDeclined),
|
||||
overflow = titleOverflow.overflow,
|
||||
softWrap = titleOverflow.softWrap,
|
||||
color = paint.titleInk,
|
||||
fontWeight = paint.titleWeight,
|
||||
textDecoration = paint.decoration,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2514,7 +2606,7 @@ private fun MonthGridLoading() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
repeat(6) {
|
||||
@@ -2540,10 +2632,16 @@ private fun MonthGridLoading() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String =
|
||||
/** [abbreviated] swaps the full month name for its three-letter form (#165). */
|
||||
private fun formatMonthTitle(
|
||||
ym: YearMonth,
|
||||
locale: Locale,
|
||||
currentYear: Int,
|
||||
abbreviated: Boolean = false,
|
||||
): String =
|
||||
formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = "LLLL",
|
||||
skeleton = if (abbreviated) "LLL" else "LLLL",
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
||||
*/
|
||||
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
||||
if (col !in days.indices || lane !in 0 until laneCap) return null
|
||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
|
||||
spanAt(col, lane)?.let { return it.event }
|
||||
val occupied = spans
|
||||
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
@@ -91,8 +91,11 @@ fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
||||
* [col] for a single-day chip, and for an empty slot no one should be asking
|
||||
* about.
|
||||
*/
|
||||
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int =
|
||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.startCol ?: col
|
||||
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int = spanAt(col, lane)?.startCol ?: col
|
||||
|
||||
/** The bar covering lane [lane] of column [col], or null where a pill sits there. */
|
||||
fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? =
|
||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
|
||||
/**
|
||||
* The events on [day] that [laneEvents] had no lane left for — its exact
|
||||
@@ -136,6 +139,8 @@ sealed interface ContinuousMonthUiState {
|
||||
val monthsByIndex: Map<Int, List<MonthWeek>>,
|
||||
val weeksByIndex: Map<Int, MonthWeek>,
|
||||
val weekStart: DayOfWeek,
|
||||
/** As [MonthUiState.Success.zone], and for the same reason. */
|
||||
val zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||
) : ContinuousMonthUiState
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
||||
@@ -238,6 +239,7 @@ class MonthViewModel @Inject constructor(
|
||||
monthsByIndex = months,
|
||||
weeksByIndex = weeks,
|
||||
weekStart = weekStart,
|
||||
zone = zone,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -438,14 +440,18 @@ internal fun layoutCalendarWeek(
|
||||
days = days,
|
||||
spans = spans,
|
||||
timedByDay = days.associateWith { d ->
|
||||
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
|
||||
// Declined last, so a day that overflows drops the events you said
|
||||
// no to before the ones you are actually going to (#230).
|
||||
singles.filter { it.coversDay(d, zone) }
|
||||
.sortedWith(compareBy<EventInstance> { it.isDeclined }.thenBy { it.start })
|
||||
},
|
||||
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every event touching each of [days], all-day first then by start time. Unlike
|
||||
* Every event touching each of [days], declined ones last (#230), then all-day
|
||||
* first and by start time within each group. Unlike
|
||||
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
|
||||
* they cover and applies no display cap, so the split style's day pane can list a
|
||||
* date in full without querying the provider again.
|
||||
@@ -458,7 +464,11 @@ internal fun instancesByDay(
|
||||
days.associateWith { day ->
|
||||
instances
|
||||
.filter { it.coversDay(day, zone) }
|
||||
.sortedWith(compareByDescending<EventInstance> { it.isAllDay }.thenBy { it.start })
|
||||
.sortedWith(
|
||||
compareBy<EventInstance> { it.isDeclined }
|
||||
.thenByDescending { it.isAllDay }
|
||||
.thenBy { it.start },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,6 +82,7 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.material.icons.filled.Backup
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Cake
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Gavel
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
@@ -54,6 +55,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.licences.LicencesScreen
|
||||
import de.jeanlucmakiola.floret.components.AboutCard
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
@@ -67,7 +69,9 @@ import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
|
||||
/** The settings sub-screens reached from the hub's category rows. */
|
||||
private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates, Widgets }
|
||||
private enum class SettingsSection {
|
||||
Appearance, Views, EventForm, Notifications, SpecialDates, Widgets, Licences
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings hub: three labelled groups of category rows, each opening a
|
||||
@@ -146,6 +150,13 @@ fun SettingsScreen(
|
||||
) {
|
||||
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Licences,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
LicencesScreen(onBack = { section = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +264,13 @@ private fun SettingsHub(
|
||||
icon = Icons.Default.Gavel,
|
||||
position = Position.Middle,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_licences),
|
||||
summary = stringResource(R.string.settings_licences_subtitle),
|
||||
position = Position.Middle,
|
||||
leading = { CategoryIcon(Icons.Default.Code, ChipAccent.Neutral) },
|
||||
onClick = { onOpenSection(SettingsSection.Licences) },
|
||||
)
|
||||
AboutLinkRow(
|
||||
label = stringResource(R.string.settings_about_privacy),
|
||||
url = stringResource(R.string.about_privacy_url),
|
||||
|
||||
@@ -32,8 +32,8 @@ data class SettingsUiState(
|
||||
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
||||
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
||||
val showHourLines: Boolean = false,
|
||||
/** Whether the week/day timeline seats each hour in its own cell (v2.11). */
|
||||
val showHourGrid: Boolean = false,
|
||||
/** How the Agenda screen treats events that already ended today. */
|
||||
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||
/** Whether the month/week grids fade events that have already finished. */
|
||||
|
||||
@@ -141,7 +141,7 @@ class SettingsViewModel @Inject constructor(
|
||||
// Display toggles folded into one flow so they fit this group —
|
||||
// the outer combine is already at its five-arg limit.
|
||||
combine(
|
||||
prefs.showHourLines,
|
||||
prefs.showHourGrid,
|
||||
prefs.showWeekNumbers,
|
||||
prefs.agendaShowToday,
|
||||
prefs.softenCalendarColors,
|
||||
@@ -158,7 +158,7 @@ class SettingsViewModel @Inject constructor(
|
||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||
ViewSettings(
|
||||
view, screenRange, widgetRange, timeFormat,
|
||||
showHourLines = toggles.showHourLines,
|
||||
showHourGrid = toggles.showHourGrid,
|
||||
showWeekNumbers = toggles.showWeekNumbers,
|
||||
agendaShowToday = toggles.agendaShowToday,
|
||||
softenColors = toggles.softenColors,
|
||||
@@ -191,7 +191,7 @@ class SettingsViewModel @Inject constructor(
|
||||
agendaScreenRange = views.agendaScreenRange,
|
||||
agendaWidgetRange = views.agendaWidgetRange,
|
||||
timeFormat = views.timeFormat,
|
||||
showHourLines = views.showHourLines,
|
||||
showHourGrid = views.showHourGrid,
|
||||
showWeekNumbers = views.showWeekNumbers,
|
||||
agendaShowToday = views.agendaShowToday,
|
||||
softenColors = views.softenColors,
|
||||
@@ -292,7 +292,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val agendaScreenRange: AgendaRange,
|
||||
val agendaWidgetRange: AgendaRange,
|
||||
val timeFormat: TimeFormatPref,
|
||||
val showHourLines: Boolean,
|
||||
val showHourGrid: Boolean,
|
||||
val showWeekNumbers: Boolean,
|
||||
val agendaShowToday: Boolean,
|
||||
val softenColors: Boolean,
|
||||
@@ -301,7 +301,7 @@ class SettingsViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
private data class DisplayToggles(
|
||||
val showHourLines: Boolean,
|
||||
val showHourGrid: Boolean,
|
||||
val showWeekNumbers: Boolean,
|
||||
val agendaShowToday: Boolean,
|
||||
val softenColors: Boolean,
|
||||
@@ -532,8 +532,8 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||
}
|
||||
|
||||
fun setShowHourLines(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
||||
fun setShowHourGrid(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setShowHourGrid(enabled) }
|
||||
}
|
||||
|
||||
fun setShowWeekNumbers(enabled: Boolean) {
|
||||
@@ -591,15 +591,15 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
/**
|
||||
* Enable or disable [view] in the quick-switch cycle, via an atomic
|
||||
* read-modify-write so a concurrent reorder can't clobber it. The
|
||||
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
|
||||
* own guard reads an async-echoed snapshot.
|
||||
* read-modify-write so a concurrent reorder can't clobber it. Any number of
|
||||
* views may be turned off; below two the pill hides itself (#150).
|
||||
*/
|
||||
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.updateQuickSwitch { config ->
|
||||
val next = if (enabled) config.enabled + view else config.enabled - view
|
||||
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
|
||||
config.copy(
|
||||
enabled = if (enabled) config.enabled + view else config.enabled - view,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
||||
@@ -55,8 +54,8 @@ import java.time.format.TextStyle as JavaTextStyle
|
||||
* it belongs to, plus the two cross-view ordering lists (#24, #69).
|
||||
*
|
||||
* The quick-switch cycle and the drawer list are independent orders — a view
|
||||
* off in the cycle is still reachable from the drawer. The cycle needs at least
|
||||
* [QuickSwitchConfig.MIN_ENABLED] targets.
|
||||
* off in the cycle is still reachable from the drawer, including when the cycle
|
||||
* is emptied and the pill disappears (#150).
|
||||
*/
|
||||
@Composable
|
||||
internal fun ViewsScreen(
|
||||
@@ -171,11 +170,11 @@ internal fun ViewsScreen(
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showHourLines,
|
||||
onCheckedChange = viewModel::setShowHourLines,
|
||||
checked = state.showHourGrid,
|
||||
onCheckedChange = viewModel::setShowHourGrid,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
||||
onClick = { viewModel.setShowHourGrid(!state.showHourGrid) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -221,8 +220,6 @@ internal fun ViewsScreen(
|
||||
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// Turning a view off is blocked once only the minimum remain enabled.
|
||||
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
|
||||
ReorderableColumn(
|
||||
items = config.order,
|
||||
keyOf = { it },
|
||||
@@ -238,8 +235,6 @@ internal fun ViewsScreen(
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = checked,
|
||||
// Keep the last two on: with fewer, the pill can't switch.
|
||||
enabled = !checked || canDisable,
|
||||
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -60,9 +59,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -72,7 +69,6 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -81,6 +77,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||
@@ -90,8 +89,13 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
@@ -103,31 +107,30 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
|
||||
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
|
||||
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
||||
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
|
||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
|
||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
|
||||
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.LocalShowHourGrid
|
||||
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
|
||||
@@ -135,7 +138,8 @@ 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.TIMELINE_CONTENT_END_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
@@ -190,21 +194,9 @@ fun WeekScreen(
|
||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// The static header + all-day strip share the app bar's scrolled colour so
|
||||
// the whole top region elevates together once the timeline scrolls under it.
|
||||
val topSectionColor by animateColorAsState(
|
||||
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
label = "week-top-section-color",
|
||||
)
|
||||
|
||||
val isOnCurrentWeek = when (val s = state) {
|
||||
// True when today falls inside the displayed week — independent of which
|
||||
// weekday the user picked as the first day.
|
||||
@@ -265,19 +257,19 @@ fun WeekScreen(
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
WeekTopBar(
|
||||
weekStart = weekStart,
|
||||
currentYear = currentYear,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
onJumpToDate = jumpToDate,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
@@ -298,7 +290,6 @@ fun WeekScreen(
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
@@ -318,7 +309,6 @@ fun WeekScreen(
|
||||
private fun WeekContent(
|
||||
state: WeekUiState,
|
||||
slideDir: Int,
|
||||
topSectionColor: Color,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
@@ -383,7 +373,6 @@ private fun WeekContent(
|
||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is WeekUiState.Success -> WeekSuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
dragController = dragController,
|
||||
@@ -410,7 +399,6 @@ private fun WeekContent(
|
||||
@Composable
|
||||
internal fun WeekSuccess(
|
||||
state: WeekUiState.Success,
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
@@ -423,13 +411,19 @@ internal fun WeekSuccess(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(topSectionColor),
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
|
||||
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||
// This strip is painted on `surface`, so an outlined chip has to
|
||||
// fill with that and not with the columns' container (#230).
|
||||
CompositionLocalProvider(
|
||||
LocalChipGround provides MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||
}
|
||||
}
|
||||
// Breathing room between the (colour-shifting) top section and the
|
||||
// scrolling timeline below.
|
||||
// Breathing room between the top section and the scrolling timeline
|
||||
// below.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Timeline(
|
||||
state = state,
|
||||
@@ -449,20 +443,25 @@ private fun WeekTopBar(
|
||||
currentYear: Int,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val (title, shortTitle) = remember(weekStart, locale, currentYear) {
|
||||
formatWeekTitle(weekStart, locale, currentYear) to
|
||||
formatWeekTitle(weekStart, locale, currentYear, abbreviated = true)
|
||||
}
|
||||
TopAppBar(
|
||||
title = {
|
||||
CalendarTitleButton(
|
||||
title = formatWeekTitle(weekStart, locale, currentYear),
|
||||
title = title,
|
||||
currentDate = weekStart,
|
||||
onJumpToDate = onJumpToDate,
|
||||
shortTitle = shortTitle,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@@ -483,17 +482,17 @@ private fun WeekTopBar(
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
cycle = quickSwitchViews,
|
||||
onCycle = onCycleView,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
// Match the static top section exactly: plain surface, lifting to
|
||||
// surfaceContainer once content scrolls under the bar.
|
||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||
// it, but here the bar meets the header on the same surface and the
|
||||
// tint is what makes that seam look like a separate block (#186).
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -653,23 +652,25 @@ private fun AllDayBar(
|
||||
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 paint = eventPaint(event, dark)
|
||||
Box(
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(fill, EventChipShape)
|
||||
.eventSurface(paint, EventChipShape)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = title },
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val titleOverflow = eventTitleOverflow()
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill),
|
||||
textDecoration = declinedDecoration(event.isDeclined),
|
||||
overflow = titleOverflow.overflow,
|
||||
softWrap = titleOverflow.softWrap,
|
||||
color = paint.titleInk,
|
||||
fontWeight = paint.titleWeight,
|
||||
textDecoration = paint.decoration,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -687,6 +688,12 @@ private fun Timeline(
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val density = LocalDensity.current
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
// What the hour grid takes off a block's ends, for the floating copy to
|
||||
// take off its own. Per-end, a cut edge keeps it — that much the drag
|
||||
// geometry decides itself, from the slice it is drawing.
|
||||
val blockInsetPx = with(density) {
|
||||
hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx()
|
||||
}
|
||||
|
||||
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
||||
// timeline's own viewport height, which is only known here — below the top
|
||||
@@ -733,6 +740,7 @@ private fun Timeline(
|
||||
it.grid = coords
|
||||
it.scroll = scrollState
|
||||
it.hourPx = with(density) { hourHeight.toPx() }
|
||||
it.blockInsetPx = blockInsetPx
|
||||
it.columnGapPx = gap
|
||||
it.columnWidthPx = (coords.size.width + gap) / state.days.size
|
||||
it.days = state.days
|
||||
@@ -777,8 +785,8 @@ private fun DayColumnCard(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
val showHourGrid = LocalShowHourGrid.current
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
// Tells a settled drop when this column has caught up with it.
|
||||
LaunchedEffect(blocks, dragController.settling) {
|
||||
dragController.noteGrid(date, blocks)
|
||||
@@ -794,16 +802,24 @@ private fun DayColumnCard(
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
shape = RectangleShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
// With the grid on, the container colour moves onto the hour cells
|
||||
// and the column behind them recedes to surface, so the gap between
|
||||
// two cells reads as the same negative space that separates the
|
||||
// week's columns.
|
||||
containerColor = if (showHourGrid) {
|
||||
MaterialTheme.colorScheme.surface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
},
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Faint hour separators sit over the column background but under
|
||||
// the event blocks (drawBehind paints before the children).
|
||||
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
|
||||
// The hour cells sit over the column background but under the
|
||||
// event blocks (drawBehind paints before the children).
|
||||
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer)
|
||||
// Tap an empty slot to create an event there; taps on event
|
||||
// blocks are consumed by their own handler first. Snaps to hour.
|
||||
.pointerInput(date) {
|
||||
@@ -834,20 +850,34 @@ private fun DayColumnCard(
|
||||
width = laneWidth,
|
||||
height = height,
|
||||
)
|
||||
// Where this block is cut at midnight, which decides both
|
||||
// its shape and the half-gap it gives up to the hour cells.
|
||||
val cuts = remember(block, date, zone) {
|
||||
timedBlockCuts(
|
||||
block.continuesBefore(date, zone),
|
||||
block.continuesAfter(date, zone),
|
||||
)
|
||||
}
|
||||
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
|
||||
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
|
||||
val blockHeight = (place.height - topInset - bottomInset)
|
||||
.coerceAtLeast(0.dp)
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = place.height,
|
||||
height = blockHeight,
|
||||
width = place.width,
|
||||
date = date,
|
||||
cuts = cuts,
|
||||
topInset = topInset,
|
||||
dragController = dragController,
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.offset(x = place.x, y = place.y)
|
||||
.offset(x = place.x, y = place.y + topInset)
|
||||
.width(place.width)
|
||||
.height(place.height)
|
||||
.padding(horizontal = 1.dp),
|
||||
.height(blockHeight)
|
||||
.padding(horizontal = BLOCK_OUTER_INSET),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -866,6 +896,8 @@ private fun EventBlock(
|
||||
height: Dp,
|
||||
width: Dp,
|
||||
date: LocalDate,
|
||||
cuts: ChipCuts,
|
||||
topInset: Dp,
|
||||
dragController: TimelineDragController,
|
||||
onClick: () -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
@@ -883,48 +915,66 @@ private fun EventBlock(
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// What's left for text once the 2.dp top/bottom padding is paid for.
|
||||
val available = height - 4.dp
|
||||
val available = height - BLOCK_TEXT_INSET * 2
|
||||
// Only full-width (non-overlapping) blocks that are tall enough show the
|
||||
// time. On narrow overlapping columns we drop it so the title can wrap to
|
||||
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
||||
// afford both lines spends its space on the title.
|
||||
val showTime = block.endMin - block.startMin >= 45 &&
|
||||
block.laneCount == 1 &&
|
||||
// afford both lines spends its space on the title. Height decides that on
|
||||
// its own: a duration threshold would keep hiding the time on a half-hour
|
||||
// block the user has pinched open to three times the room it needs.
|
||||
val showTime = block.laneCount == 1 &&
|
||||
available >= titleLineHeight + timeLineHeight
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
// A short block drops the title rather than serving a horizontally sliced
|
||||
// one: half a letter reads as a rendering fault, while a bare colour chip
|
||||
// reads as what it is — an event too brief to label. Tap still opens it, and
|
||||
// the semantics description carries the full title either way.
|
||||
val showTitle = available >= titleLineHeight
|
||||
// Wrap the title across as many lines as the block can fit — but only once a
|
||||
// line is wide enough to hold more than a syllable. Below that the extra
|
||||
// lines just stack fragments of the word, and one ellipsised line reads
|
||||
// better.
|
||||
// The title is served first, out of everything the block has left once the
|
||||
// time is down to one line — but only takes the lines it will actually use,
|
||||
// and only wraps at all once a line is wide enough to hold more than a
|
||||
// syllable. Below that the extra lines just stack fragments of the word.
|
||||
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
||||
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
|
||||
1
|
||||
} else {
|
||||
(contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
}
|
||||
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val paint = eventPaint(block.event, dark)
|
||||
// Every line the height affords, however narrow the lane: two events side by
|
||||
// side leave columns well under a word wide, and cutting the title to one
|
||||
// line there lost it outright where the block had the room to wrap it.
|
||||
val titleMaxLines = blockTextLines(
|
||||
text = title,
|
||||
// At the weight BlockTitle will set it in, or an invited block — drawn a
|
||||
// weight lighter — is budgeted a line it never fills (#230).
|
||||
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
|
||||
textWidth = textWidth,
|
||||
max = titleBudget,
|
||||
)
|
||||
val spare = available - titleLineHeight * titleMaxLines -
|
||||
if (showTime) timeLineHeight else 0.dp
|
||||
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val 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)
|
||||
val draggable = eventDragAllowed(block.event)
|
||||
// The drop takes this offset back off, so a tail clipped at midnight lands
|
||||
// where the event's own start belongs (#253).
|
||||
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
||||
val shape = remember(block, date, zone) {
|
||||
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
|
||||
}
|
||||
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
|
||||
val topInsetPx = with(density) { topInset.toPx() }
|
||||
val dragModifier = rememberEventDragSource(
|
||||
enabled = draggable,
|
||||
key = block.event.instanceId,
|
||||
onPickUp = { pointer, blockRoot, _ ->
|
||||
dragController.begin(block, clipOffset, pointer, blockRoot)
|
||||
dragController.begin(
|
||||
block = block,
|
||||
clipOffsetMin = clipOffset,
|
||||
titleLines = if (showTitle) titleMaxLines else 0,
|
||||
pointerInRoot = pointer,
|
||||
// Measured off the block's placement rather than off where the
|
||||
// hour grid seats it: the copy is drawn from that placement and
|
||||
// re-seated the same half-gap down (#267).
|
||||
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
|
||||
)
|
||||
},
|
||||
onMove = dragController::move,
|
||||
onDrop = { dragController.finish()?.let(onDrop) },
|
||||
@@ -936,12 +986,12 @@ private fun EventBlock(
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
// The source stays put as a ghost while its floating copy travels.
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||
.background(fill, shape)
|
||||
.eventSurface(paint, shape, cuts)
|
||||
.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)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
@@ -949,19 +999,19 @@ private fun EventBlock(
|
||||
) {
|
||||
Column {
|
||||
if (showTitle) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
BlockTitle(
|
||||
title = title,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||
color = paint.titleInk,
|
||||
textDecoration = paint.decoration,
|
||||
fontWeight = paint.titleWeight,
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
BlockTimeLabel(
|
||||
label = timeLabel,
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
color = paint.secondaryInk,
|
||||
maxLines = timeMaxLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -992,7 +1042,14 @@ private fun WeekLoading() {
|
||||
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
|
||||
val hourHeight = scale.hourHeight(maxHeight)
|
||||
val totalHeight = hourHeight * 24
|
||||
// The skeleton wears the loaded columns' own ground, so the arrival
|
||||
// of the real week doesn't flash solid blocks into a gapped grid.
|
||||
val showHourGrid = LocalShowHourGrid.current
|
||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||
val columnGround = if (showHourGrid) MaterialTheme.colorScheme.surface
|
||||
else MaterialTheme.colorScheme.surfaceContainer
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -1006,7 +1063,12 @@ private fun WeekLoading() {
|
||||
.weight(1f)
|
||||
.height(totalHeight)
|
||||
.padding(horizontal = 2.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
.background(columnGround)
|
||||
.hourGridCells(
|
||||
showHourGrid,
|
||||
hourPx,
|
||||
MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1033,13 +1095,18 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
|
||||
* "December" with no year would be actively misleading while the reader is
|
||||
* looking straight at January dates.
|
||||
*/
|
||||
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String {
|
||||
private fun formatWeekTitle(
|
||||
weekStart: LocalDate,
|
||||
locale: Locale,
|
||||
currentYear: Int,
|
||||
abbreviated: Boolean = false,
|
||||
): String {
|
||||
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||
return formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = "LLLL",
|
||||
skeleton = if (abbreviated) "LLL" else "LLLL",
|
||||
forceYear = weekEnd.year != weekStart.year,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -144,7 +145,7 @@ class WeekViewModel @Inject constructor(
|
||||
/**
|
||||
* Lay out all-day events as connected horizontal spans across the visible week.
|
||||
* Each event becomes one [AllDaySpan] from its first to its last covered column;
|
||||
* overlapping spans are stacked on separate lanes (greedy first-fit by start).
|
||||
* overlapping spans are stacked on separate lanes (greedy first-fit).
|
||||
*/
|
||||
internal fun layoutAllDay(
|
||||
events: List<EventInstance>,
|
||||
@@ -158,21 +159,35 @@ internal fun layoutAllDay(
|
||||
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
|
||||
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
|
||||
}
|
||||
.sortedWith(compareBy({ it.startCol }, { it.endCol }))
|
||||
// Declined bars are packed after every other one, so on any day they
|
||||
// cover they land in a lane below it (#230).
|
||||
.sortedWith(compareBy({ it.event.isDeclined }, { it.startCol }, { it.endCol }))
|
||||
|
||||
val laneEnd = ArrayList<Int>() // last occupied column per lane
|
||||
// What each lane already holds, rather than just how far right it reaches.
|
||||
// A single "last occupied column" only answers correctly while spans arrive
|
||||
// in non-decreasing start order, which the declined-last rule above breaks:
|
||||
// a Monday bar seated after a Wednesday one would be refused a lane it is
|
||||
// nowhere near, and each wasted lane costs the all-day strip a whole row and
|
||||
// pushes a bar closer to the month grid's MAX_EVENT_ROWS cap. Seven columns
|
||||
// and a handful of bars, so the scan is cheaper than the sort above it.
|
||||
val laneCols = ArrayList<MutableList<IntRange>>()
|
||||
return raw.map { r ->
|
||||
var lane = laneEnd.indexOfFirst { it < r.startCol }
|
||||
val cols = r.startCol..r.endCol
|
||||
var lane = laneCols.indexOfFirst { seated -> seated.none { it overlaps cols } }
|
||||
if (lane == -1) {
|
||||
laneEnd.add(r.endCol)
|
||||
lane = laneEnd.size - 1
|
||||
laneCols.add(mutableListOf(cols))
|
||||
lane = laneCols.size - 1
|
||||
} else {
|
||||
laneEnd[lane] = r.endCol
|
||||
laneCols[lane].add(cols)
|
||||
}
|
||||
AllDaySpan(r.event, r.startCol, r.endCol, lane)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether two column ranges share a column, so they cannot share a lane. */
|
||||
private infix fun IntRange.overlaps(other: IntRange): Boolean =
|
||||
first <= other.last && other.first <= last
|
||||
|
||||
/** Beginning of the week (at [weekStart]) that contains this date. */
|
||||
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
|
||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -47,7 +46,6 @@ internal fun WeekViewPreview(
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
WeekSuccess(
|
||||
state = state,
|
||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = state.allDayStripHeight(),
|
||||
dragController = rememberTimelineDragController(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
|
||||
<string name="state_failure_provider">Could not read the calendar.</string>
|
||||
|
||||
<!-- Long-press a field to copy it (#195) -->
|
||||
<string name="field_copy_action">Copy</string>
|
||||
<string name="field_copied">Copied to clipboard</string>
|
||||
<string name="field_copy_failed">Couldn\'t copy that</string>
|
||||
|
||||
<!-- Permission flow (F1) -->
|
||||
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events.</string>
|
||||
@@ -188,6 +193,7 @@
|
||||
<string name="event_detail_all_day">All day</string>
|
||||
<string name="event_detail_calendar">Calendar</string>
|
||||
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
||||
<string name="event_detail_title">Title</string>
|
||||
<string name="event_detail_location">Location</string>
|
||||
<string name="event_detail_description">Description</string>
|
||||
<string name="event_detail_attendees">Attendees</string>
|
||||
@@ -425,8 +431,12 @@
|
||||
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
||||
<!-- %1$s is a sample time written the way the system currently writes it. -->
|
||||
<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>
|
||||
<!-- Reworded in v2.20 (#113): this setting no longer draws a separator line
|
||||
at each hour, it seats each hour in its own cell. The key is kept, so any
|
||||
translation of the old wording ("Hour lines" / "show a separator line at
|
||||
each hour") describes a feature that no longer exists and needs redoing. -->
|
||||
<string name="settings_hour_lines">Hour grid</string>
|
||||
<string name="settings_hour_lines_summary">Seat each hour in its own cell in week and day view</string>
|
||||
<string name="settings_timeline_scale">Hour height</string>
|
||||
<string name="settings_timeline_scale_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>
|
||||
@@ -498,7 +508,7 @@
|
||||
<string name="month_split_expand">Show the whole month</string>
|
||||
<string name="month_split_collapse">Show the day\'s events</string>
|
||||
<string name="settings_quick_switch_header">Quick-switch button</string>
|
||||
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
|
||||
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. With fewer than two views turned on the button is hidden. Turned-off views stay reachable from the navigation menu.</string>
|
||||
<string name="settings_drawer_order_header">Navigation menu</string>
|
||||
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
|
||||
<string name="reorder_drag_handle">Drag to reorder</string>
|
||||
@@ -610,6 +620,8 @@
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Source</string>
|
||||
<string name="settings_licences">Open source licenses</string>
|
||||
<string name="settings_licences_subtitle">The projects Calendula is built on</string>
|
||||
<string name="settings_about_privacy">Privacy policy</string>
|
||||
<string name="settings_about_support">Support development</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
@@ -617,6 +629,11 @@
|
||||
<string name="settings_report_problem">Report a problem</string>
|
||||
<string name="settings_report_problem_hint">Send a crash report or open the issue tracker</string>
|
||||
|
||||
<!-- Open source licences -->
|
||||
<string name="licences_title">Open source licenses</string>
|
||||
<string name="licences_intro">Calendula includes the work below. Tap any entry to get its source code.</string>
|
||||
<string name="licences_footer">Every project here is included unmodified, under the license shown.</string>
|
||||
|
||||
<!-- Calendar manager -->
|
||||
<string name="calendars_title">Calendars</string>
|
||||
<string name="calendars_local_header">Your calendars</string>
|
||||
|
||||
@@ -2,6 +2,8 @@ package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@@ -109,4 +111,22 @@ class InstanceMapperTest {
|
||||
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unanswered invitation reads as invited, an answered one does not`() {
|
||||
assertThat(
|
||||
reader(selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_INVITED)
|
||||
.toEventInstance()!!.response,
|
||||
).isEqualTo(EventResponse.Invited)
|
||||
mapOf(
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_NONE to EventResponse.Going,
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED to EventResponse.Going,
|
||||
// "Maybe" is still an answer, so it keeps the solid chip (#230).
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE to EventResponse.Going,
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED to EventResponse.Declined,
|
||||
).forEach { (status, expected) ->
|
||||
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.response)
|
||||
.isEqualTo(expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,9 +95,9 @@ class SettingsPrefsTest {
|
||||
@Test
|
||||
fun `hour lines default off and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.showHourLines.first()).isFalse()
|
||||
prefs.setShowHourLines(true)
|
||||
assertThat(prefs.showHourLines.first()).isTrue()
|
||||
assertThat(prefs.showHourGrid.first()).isFalse()
|
||||
prefs.setShowHourGrid(true)
|
||||
assertThat(prefs.showHourGrid.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AppBarSpacingTest {
|
||||
|
||||
@Test
|
||||
fun `the pill's background ends one inset from the screen edge`() {
|
||||
// The padding is applied inside M3's actions row, so it carries that
|
||||
// row's own inset on top of whatever the pill asks for (#165).
|
||||
val rendered = AppBarSpacing.BarPadding + AppBarSpacing.ContainerTrailingInset
|
||||
assertThat(rendered).isEqualTo(AppBarSpacing.Inset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trailing insets stay non-negative`() {
|
||||
// Modifier.padding throws on a negative value.
|
||||
assertThat(AppBarSpacing.ContainerTrailingInset.value).isAtLeast(0f)
|
||||
assertThat(AppBarSpacing.IconTrailingInset.value).isAtLeast(0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an icon button's glyph ends further in than a container's edge`() {
|
||||
assertThat(AppBarSpacing.IconTrailingInset.value)
|
||||
.isGreaterThan(AppBarSpacing.Inset.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class CalendarTitleButtonTest {
|
||||
|
||||
@Test
|
||||
fun `keeps the full title when it fits`() {
|
||||
assertThat(titleFor("September", "Sep", titleWidth = 200, availableWidth = 300))
|
||||
.isEqualTo("September")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps the full title when it fills the slot exactly`() {
|
||||
assertThat(titleFor("September", "Sep", titleWidth = 300, availableWidth = 300))
|
||||
.isEqualTo("September")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to the short title when the full one overflows`() {
|
||||
assertThat(titleFor("September", "Sep", titleWidth = 400, availableWidth = 300))
|
||||
.isEqualTo("Sep")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a caller with nothing to shorten still gets its own title back`() {
|
||||
assertThat(titleFor("Wed, 2 Sep", "Wed, 2 Sep", titleWidth = 400, availableWidth = 300))
|
||||
.isEqualTo("Wed, 2 Sep")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The floating copy is the size of the block it lifted off, hour grid or not —
|
||||
* a copy a half-gap taller at each end has room for a time label the block
|
||||
* itself had to drop, so the label appears on long-press and goes on drop
|
||||
* (#267).
|
||||
*/
|
||||
class DragPieceBoundsTest {
|
||||
|
||||
/** A Regular 56dp hour on a 3x screen. */
|
||||
private val hourPx = 168f
|
||||
|
||||
/** The grid's half-gap, 1dp at 3x. */
|
||||
private val insetPx = 3f
|
||||
|
||||
private fun slice(
|
||||
startMin: Int,
|
||||
spanMin: Int,
|
||||
continuesBefore: Boolean = false,
|
||||
continuesAfter: Boolean = false,
|
||||
) = DragSlice(
|
||||
dayOffset = 0,
|
||||
startMin = startMin,
|
||||
spanMin = spanMin,
|
||||
continuesBefore = continuesBefore,
|
||||
continuesAfter = continuesAfter,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `with the grid off a piece keeps its raw placement`() {
|
||||
val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 60), hourPx, insetPx = 0f)
|
||||
assertThat(bounds.top).isEqualTo(9 * hourPx)
|
||||
assertThat(bounds.height).isEqualTo(hourPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with the grid on a piece gives up a half-gap at each end`() {
|
||||
val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 60), hourPx, insetPx)
|
||||
assertThat(bounds.top).isEqualTo(9 * hourPx + insetPx)
|
||||
assertThat(bounds.height).isEqualTo(hourPx - insetPx * 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an end cut at midnight keeps none of the inset`() {
|
||||
val head = dragPieceBounds(slice(startMin = 1200, spanMin = 240, continuesAfter = true), hourPx, insetPx)
|
||||
assertThat(head.top).isEqualTo(20 * hourPx + insetPx)
|
||||
assertThat(head.height).isEqualTo(4 * hourPx - insetPx)
|
||||
|
||||
val tail = dragPieceBounds(slice(startMin = 0, spanMin = 480, continuesBefore = true), hourPx, insetPx)
|
||||
assertThat(tail.top).isEqualTo(0f)
|
||||
assertThat(tail.height).isEqualTo(8 * hourPx - insetPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a whole day cut at both ends fills its column`() {
|
||||
val bounds = dragPieceBounds(
|
||||
slice(startMin = 0, spanMin = 1440, continuesBefore = true, continuesAfter = true),
|
||||
hourPx,
|
||||
insetPx,
|
||||
)
|
||||
assertThat(bounds.top).isEqualTo(0f)
|
||||
assertThat(bounds.height).isEqualTo(24 * hourPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a very short piece is floored before the inset comes off`() {
|
||||
val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 5), hourPx, insetPx)
|
||||
assertThat(bounds.height).isEqualTo(MIN_EVENT_FRACTION * hourPx - insetPx * 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an inset taller than the piece leaves no height at all`() {
|
||||
val bounds = dragPieceBounds(slice(startMin = 0, spanMin = 60), hourPx = 4f, insetPx = insetPx)
|
||||
assertThat(bounds.height).isEqualTo(0f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** How an event's time is set against its title (#219). */
|
||||
class EventTimeStyleTest {
|
||||
|
||||
@Test
|
||||
fun `a time steps down to regular and closes its tracking up`() {
|
||||
val label = TextStyle(fontWeight = FontWeight.Medium, letterSpacing = 0.5.sp)
|
||||
val time = label.asEventTime()
|
||||
assertThat(time.fontWeight).isEqualTo(FontWeight.Normal)
|
||||
assertThat(time.letterSpacing).isEqualTo(0.sp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing else of the style is touched`() {
|
||||
val label = TextStyle(
|
||||
fontSize = 11.sp,
|
||||
color = Color.Red,
|
||||
textDecoration = TextDecoration.LineThrough,
|
||||
)
|
||||
val time = label.asEventTime()
|
||||
assertThat(time.fontSize).isEqualTo(11.sp)
|
||||
assertThat(time.color).isEqualTo(Color.Red)
|
||||
assertThat(time.textDecoration).isEqualTo(TextDecoration.LineThrough)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the title is inked above the time beside it`() {
|
||||
assertThat(TITLE_INK_ALPHA).isGreaterThan(SECONDARY_INK_ALPHA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the time is set apart from the title it precedes`() {
|
||||
val label = inlineTimeLabel("09:05", "Standup", Color.Black)
|
||||
assertThat(label.text).isEqualTo("09:05 Standup")
|
||||
// Only the time is restyled: the title keeps the surface's own label
|
||||
// style, so the two read as separate things on one line (#219).
|
||||
val spans = label.spanStyles
|
||||
assertThat(spans).hasSize(1)
|
||||
assertThat(spans.single().start).isEqualTo(0)
|
||||
assertThat(spans.single().end).isEqualTo("09:05".length)
|
||||
assertThat(spans.single().item.fontWeight).isEqualTo(FontWeight.Normal)
|
||||
assertThat(spans.single().item.letterSpacing).isEqualTo(0.sp)
|
||||
assertThat(spans.single().item.color).isEqualTo(Color.Black)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chip without a time is styled title and nothing else`() {
|
||||
val label = inlineTimeLabel(null, "Standup", Color.Black)
|
||||
assertThat(label.text).isEqualTo("Standup")
|
||||
assertThat(label.spanStyles).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class EventTitleOverflowTest {
|
||||
|
||||
@Test
|
||||
fun `a single LTR line clips, so the title runs to the chip's edge`() {
|
||||
val result = eventTitleOverflowFor(rtl = false, singleLine = true)
|
||||
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
|
||||
// Load-bearing: with softWrap on, a clipped line breaks at the last
|
||||
// whole word and shows less title than the ellipsis did (#164).
|
||||
assertThat(result.softWrap).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RTL keeps the ellipsis, which truncates at the logical end`() {
|
||||
// Clipping with softWrap off cuts at the node's left edge, which in RTL
|
||||
// is the end of the string — the title would lose its beginning.
|
||||
val result = eventTitleOverflowFor(rtl = true, singleLine = true)
|
||||
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
|
||||
assertThat(result.softWrap).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-line block wraps whole words and clips, spending none on a dot`() {
|
||||
val result = eventTitleOverflowFor(rtl = false, singleLine = false)
|
||||
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
|
||||
assertThat(result.softWrap).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multi-line in RTL clips too, since softWrap cuts the logical end`() {
|
||||
val result = eventTitleOverflowFor(rtl = true, singleLine = false)
|
||||
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
|
||||
assertThat(result.softWrap).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class HourGridTest {
|
||||
|
||||
/** The hour cell's 4dp corner at a 3x density. */
|
||||
private val maxRadius = 12f
|
||||
|
||||
@Test
|
||||
fun `a tall cell rounds to the full radius`() {
|
||||
// A Regular 56dp hour on a 3x screen, in a phone-width day column.
|
||||
assertThat(hourCellRadiusPx(cellHeight = 162f, cellWidth = 900f, maxRadius = maxRadius))
|
||||
.isEqualTo(maxRadius)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short cell rounds to half its height, never past a lozenge`() {
|
||||
// Fit-the-day on a small viewport: the hour is shorter than the radius.
|
||||
val radius = hourCellRadiusPx(cellHeight = 18f, cellWidth = 900f, maxRadius = maxRadius)
|
||||
assertThat(radius).isEqualTo(9f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a narrow cell rounds to half its width`() {
|
||||
// Seven columns on a small phone, so the cell is narrower than it is tall.
|
||||
assertThat(hourCellRadiusPx(cellHeight = 162f, cellWidth = 18f, maxRadius = maxRadius))
|
||||
.isEqualTo(9f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a degenerate cell asks for no radius at all`() {
|
||||
assertThat(hourCellRadiusPx(cellHeight = -4f, cellWidth = 900f, maxRadius = maxRadius))
|
||||
.isEqualTo(0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a seated block gives up the half-gap at an end of its own`() {
|
||||
assertThat(hourCellBlockInset(show = true, cut = false)).isEqualTo(HOUR_CELL_INSET)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an end cut at midnight stays against the column edge`() {
|
||||
assertThat(hourCellBlockInset(show = true, cut = true)).isEqualTo(0.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with the grid off nothing is given up`() {
|
||||
assertThat(hourCellBlockInset(show = false, cut = false)).isEqualTo(0.dp)
|
||||
}
|
||||
}
|
||||
@@ -120,4 +120,30 @@ class ViewBackStackTest {
|
||||
)
|
||||
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cycle can be emptied down to no views at all`() {
|
||||
// #150: the settings screen no longer holds a floor, so every view may go.
|
||||
val config = QuickSwitchConfig(order = IMPLEMENTED_VIEWS, enabled = emptySet())
|
||||
assertThat(config.cycle).isEmpty()
|
||||
assertThat(config.cycle.size).isLessThan(QuickSwitchConfig.MIN_CYCLE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one enabled view is below the cycle minimum, so the pill hides`() {
|
||||
// A single target is not a switch — it hides rather than becoming a
|
||||
// jump-to-one-view button, which would be dead once you were there.
|
||||
val config = QuickSwitchConfig(order = IMPLEMENTED_VIEWS, enabled = setOf(CalendarView.Day))
|
||||
assertThat(config.cycle).containsExactly(CalendarView.Day)
|
||||
assertThat(config.cycle.size).isLessThan(QuickSwitchConfig.MIN_CYCLE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two enabled views are enough to show the pill`() {
|
||||
val config = QuickSwitchConfig(
|
||||
order = IMPLEMENTED_VIEWS,
|
||||
enabled = setOf(CalendarView.Day, CalendarView.Month),
|
||||
)
|
||||
assertThat(config.cycle.size).isAtLeast(QuickSwitchConfig.MIN_CYCLE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.Locale
|
||||
|
||||
/** Which month chips carry a start time, and how it reads (#219).
|
||||
* How it is *set* against the title lives in `EventTimeStyleTest`. */
|
||||
class MonthChipTimeTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
private val locale = Locale.UK
|
||||
private val day = LocalDate(2026, Month.SEPTEMBER, 2)
|
||||
|
||||
private fun timed(hour: Int, minute: Int) = EventInstance(
|
||||
instanceId = 1L,
|
||||
eventId = 1L,
|
||||
calendarId = 1L,
|
||||
title = "Standup",
|
||||
start = day.atTime(hour, minute).toInstant(zone),
|
||||
end = day.atTime(hour + 1, minute).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = 0,
|
||||
location = null,
|
||||
)
|
||||
|
||||
private fun allDay() = EventInstance(
|
||||
instanceId = 2L,
|
||||
eventId = 2L,
|
||||
calendarId = 1L,
|
||||
title = "Holiday",
|
||||
start = day.atTime(0, 0).toInstant(zone),
|
||||
end = day.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
|
||||
isAllDay = true,
|
||||
color = 0,
|
||||
location = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a timed chip shows its start in the 24-hour convention`() {
|
||||
val time = monthChipTime(timed(9, 5), continuesLeft = false, zone, is24Hour = true, locale)
|
||||
assertThat(time).isEqualTo("09:05")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the 12-hour setting is honoured`() {
|
||||
val time = monthChipTime(timed(14, 30), continuesLeft = false, zone, is24Hour = false, locale)
|
||||
assertThat(time).isEqualTo("2:30 pm")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day chip never shows a time`() {
|
||||
val time = monthChipTime(allDay(), continuesLeft = false, zone, is24Hour = true, locale)
|
||||
assertThat(time).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bar carried in from the previous week shows none either`() {
|
||||
// Its start is not in this segment, so printing it would put a time on a
|
||||
// row the event does not begin on.
|
||||
val time = monthChipTime(timed(9, 5), continuesLeft = true, zone, is24Hour = true, locale)
|
||||
assertThat(time).isNull()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -267,4 +268,70 @@ class MonthLayoutTest {
|
||||
assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th)
|
||||
assertThat(byDay.values.flatten()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a declined event sorts behind every answered one on its day`() {
|
||||
val day = LocalDate(2026, 6, 10)
|
||||
// Earliest of the three, and all-day on top of that, so only the
|
||||
// declined ordering can put it last.
|
||||
val declined = allDay(day, id = 1L, title = "Declined")
|
||||
.copy(response = EventResponse.Declined)
|
||||
val morning = timed(day, 9, 10, id = 2L, title = "Morning")
|
||||
val evening = timed(day, 18, 19, id = 3L, title = "Evening")
|
||||
val events = listOf(declined, morning, evening)
|
||||
|
||||
assertThat(instancesByDay(listOf(day), events, zone).getValue(day).map { it.title })
|
||||
.containsExactly("Morning", "Evening", "Declined").inOrder()
|
||||
|
||||
val timedOnly =
|
||||
layoutCalendarWeek(weekOf8th, listOf(evening, morning, declinedTimed(day)), zone)
|
||||
assertThat(timedOnly.timedByDay.getValue(day).map { it.title })
|
||||
.containsExactly("Morning", "Evening", "Declined early").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a declined bar packed last still shares a lane it does not overlap`() {
|
||||
// Sorting declined last means this bar is seated after one that reaches
|
||||
// further right than it starts. A lane must still be offered on the
|
||||
// columns it is actually free on, or the row grows a wasted rank.
|
||||
val mon = weekOf8th[0]
|
||||
val declined = allDay(mon, id = 1L, title = "Declined")
|
||||
.copy(response = EventResponse.Declined)
|
||||
val going = allDay(weekOf8th[2], weekOf8th[3], id = 2L, title = "Going")
|
||||
val week = layoutCalendarWeek(weekOf8th, listOf(declined, going), zone)
|
||||
|
||||
assertThat(week.spans.map { it.lane }).containsExactly(0, 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a declined all-day bar takes a lane below the ones you answered`() {
|
||||
val day = LocalDate(2026, 6, 10)
|
||||
val declined =
|
||||
allDay(day, id = 1L, title = "Declined").copy(response = EventResponse.Declined)
|
||||
val going = allDay(day, id = 2L, title = "Going")
|
||||
// Declined listed first: only the sort, not the input order, may decide.
|
||||
val week = layoutCalendarWeek(weekOf8th, listOf(declined, going), zone)
|
||||
|
||||
val lanes = week.spans.associate { it.event.title to it.lane }
|
||||
assertThat(lanes.getValue("Going")).isLessThan(lanes.getValue("Declined"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overlapping bars never share a lane, whatever order they arrive in`() {
|
||||
val a = allDay(weekOf8th[0], weekOf8th[3], id = 1L, title = "A")
|
||||
val b = allDay(weekOf8th[2], weekOf8th[5], id = 2L, title = "B")
|
||||
val c = allDay(weekOf8th[3], weekOf8th[4], id = 3L, title = "C")
|
||||
.copy(response = EventResponse.Declined)
|
||||
val lanes = layoutCalendarWeek(weekOf8th, listOf(c, b, a), zone)
|
||||
.spans.associate { it.event.title to it.lane }
|
||||
|
||||
assertThat(lanes.getValue("A")).isNotEqualTo(lanes.getValue("B"))
|
||||
assertThat(lanes.getValue("B")).isNotEqualTo(lanes.getValue("C"))
|
||||
assertThat(lanes.getValue("A")).isNotEqualTo(lanes.getValue("C"))
|
||||
}
|
||||
|
||||
/** A declined timed event starting before every other one in its test. */
|
||||
private fun declinedTimed(date: LocalDate) =
|
||||
timed(date, 7, 8, id = 4L, title = "Declined early")
|
||||
.copy(response = EventResponse.Declined)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,15 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`
|
||||
solely when the file is missing, so the self-hosted pipeline always has
|
||||
something to publish. The pipeline runs the same script, so forgetting to
|
||||
write the file yields a long auto-generated changelog rather than none.
|
||||
|
||||
To check every locale rather than just en-US, run
|
||||
```bash
|
||||
scripts/check_changelog_lengths.sh
|
||||
```
|
||||
It fails on any file for the current `versionCode` that is missing, empty or
|
||||
over the limit, and only warns about older ones (already published; Play
|
||||
never re-reads them). CI runs it on every pull request. `--strict` fails on
|
||||
the older files too.
|
||||
4. **Verify the release build on a real device** — the mandatory gate. The
|
||||
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
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
New
|
||||
• Long-press an event's title, location or description to copy.
|
||||
• Settings → About lists the open-source projects we ship.
|
||||
|
||||
Changed
|
||||
• Week & day: each hour sits in its own cell, not a line.
|
||||
• Event titles run to the chip's edge — no more "…".
|
||||
• Month events show their start time on wide chips.
|
||||
• The today button shows today's date.
|
||||
• Unanswered invitations are drawn as outlines.
|
||||
|
||||
Fixed
|
||||
• A long location wraps instead of scrolling out of view.
|
||||
• The event form capitalizes sentences again.
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Length guard for the per-version "What's New" files under
|
||||
# fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt.
|
||||
#
|
||||
# Google Play rejects an upload whose "What's New" exceeds 500 characters, and
|
||||
# F-Droid truncates a long entry in-client. Play reads only the version being
|
||||
# uploaded, so the file for the CURRENT versionCode is release-blocking in every
|
||||
# locale it exists in — that is a hard failure here. Older files are already
|
||||
# published and Play never looks at them again; they are reported as warnings so
|
||||
# a legacy overrun (the pipeline used to auto-generate these from CHANGELOG.md)
|
||||
# does not turn every PR red.
|
||||
#
|
||||
# scripts/check_changelog_lengths.sh current version fails, rest warn
|
||||
# scripts/check_changelog_lengths.sh --strict every file must fit
|
||||
#
|
||||
# The companion scripts/sync_changelog_to_fastlane.sh checks only en-US for the
|
||||
# current version, and only as a side effect of ensuring the file exists.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.." # repo root
|
||||
|
||||
# wc -m counts bytes, not characters, under a C locale — force UTF-8 so "•" and
|
||||
# "—" count as the single characters Play sees.
|
||||
if locale -a 2>/dev/null | grep -qix 'C.utf8\|C.UTF-8'; then
|
||||
export LC_ALL=C.UTF-8
|
||||
fi
|
||||
|
||||
LIMIT=500
|
||||
STRICT=0
|
||||
[ "${1:-}" = "--strict" ] && STRICT=1
|
||||
|
||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; }
|
||||
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
|
||||
VERSION_CODE=$(( ${MAJOR:-0} * 10000 + ${MINOR:-0} * 100 + ${PATCH:-0} ))
|
||||
|
||||
fail=0
|
||||
warned=0
|
||||
current=0
|
||||
|
||||
shopt -s nullglob
|
||||
for f in fastlane/metadata/android/*/changelogs/*.txt; do
|
||||
locale_dir=$(basename "$(dirname "$(dirname "$f")")")
|
||||
code=$(basename "$f" .txt)
|
||||
chars=$(wc -m < "$f" | tr -d ' ')
|
||||
|
||||
if [ "$code" = "$VERSION_CODE" ]; then
|
||||
current=$((current + 1))
|
||||
if [ ! -s "$f" ]; then
|
||||
echo "ERROR: $f is empty." >&2
|
||||
fail=1
|
||||
elif [ "$chars" -gt "$LIMIT" ]; then
|
||||
echo "ERROR: $f is $chars chars (limit $LIMIT) — Play would reject this release." >&2
|
||||
fail=1
|
||||
else
|
||||
printf 'OK: %-6s %s — %s chars\n' "$locale_dir" "$code" "$chars"
|
||||
fi
|
||||
elif [ "$chars" -gt "$LIMIT" ]; then
|
||||
if [ "$STRICT" -eq 1 ]; then
|
||||
echo "ERROR: $f is $chars chars (limit $LIMIT)." >&2
|
||||
fail=1
|
||||
else
|
||||
echo "warning: $f is $chars chars (limit $LIMIT) — already published, left as is." >&2
|
||||
warned=$((warned + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$current" -eq 0 ]; then
|
||||
echo "ERROR: no changelog for version $VERSION (code $VERSION_CODE)." >&2
|
||||
echo " Write fastlane/metadata/android/en-US/changelogs/$VERSION_CODE.txt" >&2
|
||||
echo " before releasing — see docs/RELEASING.md step 3." >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo >&2
|
||||
echo "Fix the changelog(s) above before releasing." >&2
|
||||
exit 1
|
||||
fi
|
||||
[ "$warned" -gt 0 ] && echo "$warned older changelog(s) over the limit — warnings only."
|
||||
echo "All release-blocking changelogs fit in $LIMIT characters."
|
||||
Reference in New Issue
Block a user