Compare commits
19 Commits
main
...
fix/267-dr
| Author | SHA1 | Date | |
|---|---|---|---|
| c2fbfb0bef | |||
| 8db99868af | |||
| 3a29e6f676 | |||
|
|
76603be0b3 | ||
|
|
a4d77109fb | ||
| 72be175fca | |||
| 5be8884e9f | |||
| 065afdb2b8 | |||
| f7e9990c4e | |||
|
|
27ce9a29c9 | ||
| 5f28a8b333 | |||
|
|
ddf508e350 | ||
| dd3988c5bf | |||
|
|
03287ae25d | ||
|
|
7e8119be02 | ||
|
|
7e843aa740 | ||
| 9775e0a652 | |||
| 2cf7d590bd | |||
|
|
8c76cbdf5e |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Month-view event titles no longer end in an ellipsis. The "…" took the width
|
||||||
|
of a couple of characters and told you nothing you couldn't already see, so
|
||||||
|
the title now simply runs to the edge of its chip — a few more letters per
|
||||||
|
cell, which is often the difference between two events you can tell apart and
|
||||||
|
two you can't ([#164]).
|
||||||
|
- Month-view events now show their start time before the title, where the chip
|
||||||
|
is wide enough to keep the title readable — in landscape, on a foldable and on
|
||||||
|
a tablet, and never at the cost of a narrow phone column. All-day events show
|
||||||
|
no time, and neither does a bar carried in from the previous week, whose start
|
||||||
|
is not in that row. Event times across the month, week and day views are also
|
||||||
|
set in a regular weight against the title's, so a time reads as a time rather
|
||||||
|
than as part of the name next to it ([#219]).
|
||||||
|
- The calendar titles now shorten instead of being cut off. When the full month
|
||||||
|
name doesn't fit the top bar, the month and week views fall back to its
|
||||||
|
three-letter form and the day view drops the weekday, rather than trailing off
|
||||||
|
mid-word at large font sizes. The view switcher, the title and the agenda's
|
||||||
|
range bar also line up with the grid underneath them ([#165]).
|
||||||
|
|
||||||
## [2.19.4] — 2026-09-01
|
## [2.19.4] — 2026-09-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -1560,6 +1579,8 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||||
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
||||||
|
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164
|
||||||
|
[#165]: https://codeberg.org/jlmakiola/calendula/issues/165
|
||||||
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
||||||
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
||||||
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
||||||
@@ -1569,5 +1590,6 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
|
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
|
||||||
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
||||||
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
||||||
|
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
|
||||||
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
||||||
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
|
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
|||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||||
|
|
||||||
private const val TAG = "InstanceMapper"
|
private const val TAG = "InstanceMapper"
|
||||||
|
|
||||||
@@ -39,7 +40,17 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
|||||||
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
||||||
color = color,
|
color = color,
|
||||||
location = getString(InstanceProjection.IDX_LOCATION),
|
location = getString(InstanceProjection.IDX_LOCATION),
|
||||||
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe"
|
||||||
|
* counts as going, and everything that is not an open or refused invitation —
|
||||||
|
* your own events included — falls through to [EventResponse.Going].
|
||||||
|
*/
|
||||||
|
internal fun mapEventResponse(raw: Int): EventResponse = when (raw) {
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> EventResponse.Declined
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> EventResponse.Invited
|
||||||
|
else -> EventResponse.Going
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import android.provider.CalendarContract
|
|
||||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
@@ -43,7 +42,6 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
|||||||
location = getString(SearchProjection.IDX_LOCATION),
|
location = getString(SearchProjection.IDX_LOCATION),
|
||||||
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||||
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,14 +68,35 @@ data class EventInstance(
|
|||||||
*/
|
*/
|
||||||
val isRecurring: Boolean = false,
|
val isRecurring: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* This device user answered "no" to the invitation
|
* This device user's own answer to the invitation, as far as the grids care
|
||||||
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is
|
* (#180, #230).
|
||||||
* still an appointment someone expects an answer about — but every surface
|
|
||||||
* strikes it through, and it plans no reminders (#180).
|
|
||||||
*/
|
*/
|
||||||
val isDeclined: Boolean = false,
|
val response: EventResponse = EventResponse.Going,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How this device user stands towards an event's invitation
|
||||||
|
* (`Events.SELF_ATTENDEE_STATUS`), reduced to the three cases the calendar
|
||||||
|
* surfaces draw differently (#230).
|
||||||
|
*/
|
||||||
|
enum class EventResponse {
|
||||||
|
/** Your own event, or one you accepted — including a tentative "maybe". */
|
||||||
|
Going,
|
||||||
|
|
||||||
|
/** Invited, no answer given yet: drawn as an outline so it reads as still open. */
|
||||||
|
Invited,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You answered "no". The event stays on the calendar — it is still an
|
||||||
|
* appointment someone expects an answer about — but every surface strikes it
|
||||||
|
* through, and it plans no reminders (#180).
|
||||||
|
*/
|
||||||
|
Declined,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
|
||||||
|
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this event has finished relative to [now] — its end is at or before
|
* Whether this event has finished relative to [now] — its end is at or before
|
||||||
* the current instant. An in-progress event (already started but not yet ended)
|
* the current instant. An in-progress event (already started but not yet ended)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -40,9 +39,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -51,6 +50,8 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
@@ -95,7 +96,6 @@ fun AgendaScreen(
|
|||||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showRangePicker by remember { mutableStateOf(false) }
|
var showRangePicker by remember { mutableStateOf(false) }
|
||||||
@@ -130,16 +130,16 @@ fun AgendaScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
AgendaTopBar(
|
AgendaTopBar(
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = viewModel::goToToday,
|
onToday = viewModel::goToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -159,13 +159,24 @@ fun AgendaScreen(
|
|||||||
// One bar at the top: the "showing …" header on the left and the
|
// One bar at the top: the "showing …" header on the left and the
|
||||||
// session range switcher on the right (one settings toggle).
|
// session range switcher on the right (one settings toggle).
|
||||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||||
|
// end lines the selector up with whatever ends the top bar:
|
||||||
|
// the view switcher's background, or — once #150 hides it —
|
||||||
|
// the search icon's glyph.
|
||||||
|
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
|
||||||
|
AppBarSpacing.Inset
|
||||||
|
} else {
|
||||||
|
AppBarSpacing.IconTrailingInset
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
// end aligns the selector's right edge with the top-bar view
|
|
||||||
// switcher (its 8.dp margin + the app bar's 4.dp inset).
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
.padding(
|
||||||
|
start = RANGE_BAR_TEXT_INSET,
|
||||||
|
end = selectorEnd,
|
||||||
|
top = 8.dp,
|
||||||
|
bottom = 8.dp,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
AgendaRangeBanner(
|
AgendaRangeBanner(
|
||||||
range = s.range,
|
range = s.range,
|
||||||
@@ -245,6 +256,9 @@ private fun AgendaRangePill(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Optical start inset for the range bar's text, set against the title above it. */
|
||||||
|
private val RANGE_BAR_TEXT_INSET = 28.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A header naming the concrete window currently shown under a "showing …" label,
|
* A header naming the concrete window currently shown under a "showing …" label,
|
||||||
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
||||||
@@ -409,17 +423,22 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
|||||||
private fun AgendaTopBar(
|
private fun AgendaTopBar(
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView>,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
|
// A plain label rather than a CalendarTitleButton, so it takes that
|
||||||
|
// one's inset and one-line clamp itself.
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.view_agenda),
|
text = stringResource(R.string.view_agenda),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -440,14 +459,16 @@ private fun AgendaTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
|
cycle = quickSwitchViews,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||||
|
// it, but here the bar meets the header on the same surface and the
|
||||||
|
// tint is what makes that seam look like a separate block (#186).
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trailing-edge and title spacing shared by the four calendar app bars (#165).
|
||||||
|
*
|
||||||
|
* Only the trailing edge and the title's start are shared; the leading edge and
|
||||||
|
* the gaps between the action icons stay on M3's defaults, which derive both
|
||||||
|
* from the icon buttons' width.
|
||||||
|
*/
|
||||||
|
object AppBarSpacing {
|
||||||
|
|
||||||
|
/** Side inset shared by the bars' trailing edge and the month grid. */
|
||||||
|
val Inset = 8.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start inset on the title, taking it clear of the navigation icon M3 places
|
||||||
|
* it flush against. Real padding rather than a negative offset, which would
|
||||||
|
* swallow taps meant for the menu button.
|
||||||
|
*/
|
||||||
|
val TitleInset = 8.dp
|
||||||
|
|
||||||
|
/** M3's own padding around the actions row. */
|
||||||
|
internal val BarPadding = 4.dp
|
||||||
|
|
||||||
|
private val IconButtonSize = 48.dp
|
||||||
|
|
||||||
|
private val IconSize = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End padding for a container-backed control that ends the bar, measured to
|
||||||
|
* its background. Coerced because [androidx.compose.foundation.layout.padding]
|
||||||
|
* throws on a negative value.
|
||||||
|
*/
|
||||||
|
val ContainerTrailingInset = (Inset - BarPadding).coerceAtLeast(0.dp)
|
||||||
|
|
||||||
|
/** Screen edge to the glyph of an icon button that ends the bar. */
|
||||||
|
val IconTrailingInset = BarPadding + (IconButtonSize - IconSize) / 2
|
||||||
|
}
|
||||||
@@ -4,30 +4,201 @@ import androidx.compose.animation.Crossfade
|
|||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.core.snap
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
|
||||||
|
/** Gap a timed block leaves to its neighbours in the column. */
|
||||||
|
val BLOCK_OUTER_INSET = 1.dp
|
||||||
|
|
||||||
|
/** Padding between a timed block's edge and its text. */
|
||||||
|
val BLOCK_TEXT_PADDING = 4.dp
|
||||||
|
|
||||||
|
/** The same, above and below — what a block's height has to pay before any text. */
|
||||||
|
val BLOCK_TEXT_INSET = 2.dp
|
||||||
|
|
||||||
|
/** Most lines a time label may wrap over before it is worth more than a title line. */
|
||||||
|
const val MAX_TIME_LINES = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lines [text] needs to render whole at [textWidth], capped at [max].
|
||||||
|
*
|
||||||
|
* Lets a block hand out its height by what the text actually asks for rather
|
||||||
|
* than by what would fit: a title that wants one line should not be given three
|
||||||
|
* that the time label could have used, and a week column is narrower than a
|
||||||
|
* "09: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].
|
||||||
|
*
|
||||||
|
* Wrapping and clipping pull against each other, which is why #164 left the
|
||||||
|
* ellipsis on multi-line chips: with `softWrap` on, the last visible line ends
|
||||||
|
* at a word boundary, so "Farmers Market" in a six-character column would clip
|
||||||
|
* to "Farmer" / "s" where the ellipsis at least reached "s Mar…".
|
||||||
|
*
|
||||||
|
* So the block wraps every line but the last through one `Text` and hands the
|
||||||
|
* remainder to a second that clips mid-glyph the way a single-line chip does.
|
||||||
|
* Every line is then full and none of them spends two of its few characters on
|
||||||
|
* a "…". RTL keeps the ellipsis for the reason [eventTitleOverflowFor] gives.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BlockTitle(
|
||||||
|
title: String,
|
||||||
|
maxLines: Int,
|
||||||
|
textWidth: Dp,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
textDecoration: TextDecoration? = null,
|
||||||
|
fontWeight: FontWeight? = null,
|
||||||
|
) {
|
||||||
|
// Folded into the style rather than passed to the Text, so the wrap measured
|
||||||
|
// below is the wrap that gets drawn.
|
||||||
|
val style = MaterialTheme.typography.labelMedium
|
||||||
|
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) }
|
||||||
|
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
|
||||||
|
// Where the wrapped lines stop and the clipped tail starts — null when the
|
||||||
|
// title fits, and nothing needs splitting.
|
||||||
|
val headEnd = remember(title, style, widthPx, maxLines, rtl, measurer) {
|
||||||
|
if (rtl || maxLines < 2 || widthPx <= 0) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val layout = measurer.measure(
|
||||||
|
text = title,
|
||||||
|
style = style,
|
||||||
|
constraints = Constraints(maxWidth = widthPx),
|
||||||
|
)
|
||||||
|
if (layout.lineCount <= maxLines) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
layout.getLineEnd(maxLines - 2, visibleEnd = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (headEnd == null) {
|
||||||
|
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
modifier = modifier,
|
||||||
|
style = style,
|
||||||
|
maxLines = maxLines,
|
||||||
|
overflow = overflow.overflow,
|
||||||
|
softWrap = overflow.softWrap,
|
||||||
|
color = color,
|
||||||
|
textDecoration = textDecoration,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val tail = eventTitleOverflow(singleLine = true)
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = title.substring(0, headEnd),
|
||||||
|
style = style,
|
||||||
|
maxLines = maxLines - 1,
|
||||||
|
overflow = TextOverflow.Clip,
|
||||||
|
softWrap = true,
|
||||||
|
color = color,
|
||||||
|
textDecoration = textDecoration,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = title.substring(headEnd).trimStart(),
|
||||||
|
style = style,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = tail.overflow,
|
||||||
|
softWrap = tail.softWrap,
|
||||||
|
color = color,
|
||||||
|
textDecoration = textDecoration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A timed block's own time label, crossfaded rather than replaced — the block
|
* A timed block's own time label, crossfaded rather than replaced — the block
|
||||||
* slides to its new slot, so the label shouldn't change in a single frame.
|
* slides to its new slot, so the label shouldn't change in a single frame.
|
||||||
|
*
|
||||||
|
* Overflows like a title does (#164): the "…" costs two characters of a string
|
||||||
|
* that is nothing but characters, so the label clips at the block's edge
|
||||||
|
* instead. [maxLines] lets a narrow column spend spare height on the range
|
||||||
|
* rather than losing its end.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
fun BlockTimeLabel(
|
||||||
|
label: String,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
maxLines: Int = 1,
|
||||||
|
) {
|
||||||
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
||||||
snap()
|
snap()
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||||
}
|
}
|
||||||
|
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||||
Crossfade(
|
Crossfade(
|
||||||
targetState = label,
|
targetState = label,
|
||||||
animationSpec = spec,
|
animationSpec = spec,
|
||||||
@@ -36,9 +207,11 @@ fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
|||||||
) { text ->
|
) { text ->
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
// Regular weight against the title's medium above it (#219).
|
||||||
maxLines = 1,
|
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||||
overflow = TextOverflow.Ellipsis,
|
maxLines = maxLines,
|
||||||
|
overflow = overflow.overflow,
|
||||||
|
softWrap = overflow.softWrap,
|
||||||
color = color,
|
color = color,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -12,6 +13,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -19,6 +21,8 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -31,10 +35,9 @@ import kotlinx.datetime.LocalDate
|
|||||||
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
||||||
* visible day, week start or month anchor).
|
* visible day, week start or month anchor).
|
||||||
*
|
*
|
||||||
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
|
* [shortTitle] replaces [title] when the full one does not fit the width the app
|
||||||
* start alignment: M3 places the title flush against the navigation icon's
|
* bar hands the title slot (#165). Either way the line clamps to one and
|
||||||
* trailing edge, and the title is hit-tested on top of it, so a negative offset
|
* ellipsises, so pass a [shortTitle] wherever there is something left to drop.
|
||||||
* would swallow taps meant for the menu button.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarTitleButton(
|
fun CalendarTitleButton(
|
||||||
@@ -42,6 +45,7 @@ fun CalendarTitleButton(
|
|||||||
currentDate: LocalDate,
|
currentDate: LocalDate,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
shortTitle: String = title,
|
||||||
) {
|
) {
|
||||||
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -53,14 +57,27 @@ fun CalendarTitleButton(
|
|||||||
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
||||||
role = Role.Button,
|
role = Role.Button,
|
||||||
) { showDatePicker = true }
|
) { showDatePicker = true }
|
||||||
.padding(horizontal = 8.dp),
|
.padding(horizontal = AppBarSpacing.TitleInset),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
val style = MaterialTheme.typography.titleLarge
|
||||||
text = title,
|
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) {
|
||||||
style = MaterialTheme.typography.titleLarge,
|
val measurer = rememberTextMeasurer()
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) {
|
||||||
)
|
titleFor(
|
||||||
|
title = title,
|
||||||
|
shortTitle = shortTitle,
|
||||||
|
titleWidth = measurer.measure(title, style).size.width,
|
||||||
|
availableWidth = constraints.maxWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = shown,
|
||||||
|
style = style,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.ArrowDropDown,
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -79,3 +96,11 @@ fun CalendarTitleButton(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Falls back to [shortTitle] when [title] is wider than [availableWidth] (#165). */
|
||||||
|
internal fun titleFor(
|
||||||
|
title: String,
|
||||||
|
shortTitle: String,
|
||||||
|
titleWidth: Int,
|
||||||
|
availableWidth: Int,
|
||||||
|
): String = if (titleWidth <= availableWidth) title else shortTitle
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
|
|||||||
* implemented view — the settings screen reorders the whole set — while [cycle]
|
* implemented view — the settings screen reorders the whole set — while [cycle]
|
||||||
* is the subset the pill actually steps through, in [order]. The navigation
|
* is the subset the pill actually steps through, in [order]. The navigation
|
||||||
* drawer keeps its own separate order and always lists every view, so a view
|
* drawer keeps its own separate order and always lists every view, so a view
|
||||||
* disabled here stays reachable there.
|
* disabled here stays reachable there — including when [cycle] is emptied and
|
||||||
|
* the pill disappears altogether.
|
||||||
*/
|
*/
|
||||||
data class QuickSwitchConfig(
|
data class QuickSwitchConfig(
|
||||||
val order: List<CalendarView>,
|
val order: List<CalendarView>,
|
||||||
@@ -67,14 +68,15 @@ data class QuickSwitchConfig(
|
|||||||
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
/**
|
||||||
|
* Fewest views that keep the switch meaningful. A single target is not a
|
||||||
|
* switch, so below this the pill is hidden rather than special-cased (#150);
|
||||||
|
* the drawer still reaches every view.
|
||||||
|
*/
|
||||||
|
const val MIN_CYCLE = 2
|
||||||
|
|
||||||
/** All views, in default order, all enabled. */
|
/** All views, in default order, all enabled. */
|
||||||
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
||||||
|
|
||||||
/**
|
|
||||||
* Fewest views that keep the switch meaningful — a "switch" needs at
|
|
||||||
* least two targets, so the settings screen blocks disabling below this.
|
|
||||||
*/
|
|
||||||
const val MIN_ENABLED = 2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,39 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
|
||||||
|
/** How an event chip's title should overflow: what to pass to `Text`. */
|
||||||
|
data class EventTitleOverflow(val overflow: TextOverflow, val softWrap: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overflow for an event chip's title (#164). Chips are narrow enough that the
|
||||||
|
* "…" costs a couple of readable characters, so the title runs to the chip's
|
||||||
|
* edge and clips mid-glyph instead.
|
||||||
|
*
|
||||||
|
* Two cases keep the ellipsis:
|
||||||
|
*
|
||||||
|
* - **[rtl].** With `softWrap` off Compose lays the line out at its full
|
||||||
|
* intrinsic width and clips to the node's left edge, which in RTL is the *end*
|
||||||
|
* of the string — an Arabic title would lose its beginning. The ellipsis
|
||||||
|
* truncates at the logical end in both directions.
|
||||||
|
* - **More than one line** ([singleLine] false). Wrapping needs `softWrap` on,
|
||||||
|
* and clipping with it on breaks the last line at the last whole word — less
|
||||||
|
* title than the ellipsis showed, not more.
|
||||||
|
*/
|
||||||
|
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
|
||||||
|
if (rtl || !singleLine) {
|
||||||
|
EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
|
||||||
|
} else {
|
||||||
|
EventTitleOverflow(TextOverflow.Clip, softWrap = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [eventTitleOverflowFor] against the current layout direction. */
|
||||||
|
@Composable
|
||||||
|
fun eventTitleOverflow(singleLine: Boolean = true): EventTitleOverflow =
|
||||||
|
eventTitleOverflowFor(
|
||||||
|
rtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
|
||||||
|
singleLine = singleLine,
|
||||||
|
)
|
||||||
@@ -25,7 +25,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
@@ -34,7 +33,6 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
|||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -83,6 +81,13 @@ data class TimelineDrag(
|
|||||||
val eventSpanMin: Int,
|
val eventSpanMin: Int,
|
||||||
/** The event as the grid would draw it, one piece per day column it covers. */
|
/** The event as the grid would draw it, one piece per day column it covers. */
|
||||||
val pieces: List<TimelineDragPiece>,
|
val pieces: List<TimelineDragPiece>,
|
||||||
|
/**
|
||||||
|
* Lines the block the finger picked up drew its title over, zero for one too
|
||||||
|
* short to have drawn it at all. The floating copy is that block's size, so
|
||||||
|
* it has to spend its height the same way or the text re-wraps under the
|
||||||
|
* finger and snaps back on drop (#267).
|
||||||
|
*/
|
||||||
|
val titleLines: Int,
|
||||||
/**
|
/**
|
||||||
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
|
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
|
||||||
* every column this timeline shows. A hint under the finger, not where the
|
* every column this timeline shows. A hint under the finger, not where the
|
||||||
@@ -313,6 +318,9 @@ class TimelineDragController {
|
|||||||
*/
|
*/
|
||||||
private var clipOffsetMin = 0
|
private var clipOffsetMin = 0
|
||||||
|
|
||||||
|
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
|
||||||
|
private var titleLines = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The slot the block already occupied when it was picked up — not the same
|
* The slot the block already occupied when it was picked up — not the same
|
||||||
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
||||||
@@ -322,11 +330,13 @@ class TimelineDragController {
|
|||||||
fun begin(
|
fun begin(
|
||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
clipOffsetMin: Int,
|
clipOffsetMin: Int,
|
||||||
|
titleLines: Int,
|
||||||
pointerInRoot: Offset,
|
pointerInRoot: Offset,
|
||||||
blockInRoot: Offset,
|
blockInRoot: Offset,
|
||||||
) {
|
) {
|
||||||
source = block
|
source = block
|
||||||
this.clipOffsetMin = clipOffsetMin
|
this.clipOffsetMin = clipOffsetMin
|
||||||
|
this.titleLines = titleLines
|
||||||
settling = null
|
settling = null
|
||||||
isDragging = true
|
isDragging = true
|
||||||
liftedInstanceId = block.event.instanceId
|
liftedInstanceId = block.event.instanceId
|
||||||
@@ -345,6 +355,7 @@ class TimelineDragController {
|
|||||||
fun cancel() {
|
fun cancel() {
|
||||||
source = null
|
source = null
|
||||||
clipOffsetMin = 0
|
clipOffsetMin = 0
|
||||||
|
titleLines = 0
|
||||||
isDragging = false
|
isDragging = false
|
||||||
liftedInstanceId = null
|
liftedInstanceId = null
|
||||||
originSlot = null
|
originSlot = null
|
||||||
@@ -467,6 +478,7 @@ class TimelineDragController {
|
|||||||
startMin = startMin,
|
startMin = startMin,
|
||||||
eventStartMin = eventStartMin,
|
eventStartMin = eventStartMin,
|
||||||
eventSpanMin = eventSpan,
|
eventSpanMin = eventSpan,
|
||||||
|
titleLines = titleLines,
|
||||||
// Bounded to the columns this timeline actually shows: a day it
|
// Bounded to the columns this timeline actually shows: a day it
|
||||||
// doesn't has no piece to draw. The write is unaffected.
|
// doesn't has no piece to draw. The write is unaffected.
|
||||||
pieces = slices
|
pieces = slices
|
||||||
@@ -605,7 +617,6 @@ const val SETTLE_FADE_MILLIS: Int = 250
|
|||||||
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
||||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val soften = LocalSoftenColors.current
|
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -655,7 +666,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||||
label = "drag-handover",
|
label = "drag-handover",
|
||||||
)
|
)
|
||||||
val fill = eventFill(drag.event.color, dark, soften)
|
val paint = eventPaint(drag.event, dark)
|
||||||
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
// Read off the event rather than the held block, so grabbing either half
|
// Read off the event rather than the held block, so grabbing either half
|
||||||
// of an event that crosses midnight names the same hours. An end past
|
// of an event that crosses midnight names the same hours. An end past
|
||||||
@@ -674,11 +685,13 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
topLeftInRoot = piece.topLeftInRoot,
|
topLeftInRoot = piece.topLeftInRoot,
|
||||||
overlayOrigin = origin,
|
overlayOrigin = origin,
|
||||||
sizePx = piece.sizePx,
|
sizePx = piece.sizePx,
|
||||||
fill = fill,
|
paint = paint,
|
||||||
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
|
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
|
||||||
|
cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter),
|
||||||
lift = lift,
|
lift = lift,
|
||||||
alpha = copyAlpha,
|
alpha = copyAlpha,
|
||||||
title = title,
|
title = title,
|
||||||
|
titleLines = drag.titleLines,
|
||||||
// Once for the whole event: repeated, it would name it per day.
|
// Once for the whole event: repeated, it would name it per day.
|
||||||
label = label.takeIf { index == labelled },
|
label = label.takeIf { index == labelled },
|
||||||
)
|
)
|
||||||
@@ -692,14 +705,34 @@ private fun DragCopy(
|
|||||||
topLeftInRoot: Offset,
|
topLeftInRoot: Offset,
|
||||||
overlayOrigin: Offset,
|
overlayOrigin: Offset,
|
||||||
sizePx: IntSize,
|
sizePx: IntSize,
|
||||||
fill: Color,
|
paint: EventPaint,
|
||||||
shape: RoundedCornerShape,
|
shape: RoundedCornerShape,
|
||||||
|
cuts: ChipCuts,
|
||||||
lift: Float,
|
lift: Float,
|
||||||
alpha: Float,
|
alpha: Float,
|
||||||
title: String,
|
title: String,
|
||||||
|
titleLines: Int,
|
||||||
label: String?,
|
label: String?,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val width = with(density) { sizePx.width.toDp() }
|
||||||
|
val height = with(density) { sizePx.height.toDp() }
|
||||||
|
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||||
|
val titleLineHeight = with(density) {
|
||||||
|
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||||
|
}
|
||||||
|
val timeLineHeight = with(density) {
|
||||||
|
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||||
|
}
|
||||||
|
// The block's size, spent the block's way, so nothing reflows under the
|
||||||
|
// finger (#267) — but the range is paid for first, since it may be showing
|
||||||
|
// where a block too short for it will land, and nothing clips the overrun.
|
||||||
|
val available = height - BLOCK_TEXT_INSET * 2
|
||||||
|
val reserved = if (label == null) 0.dp else timeLineHeight
|
||||||
|
val titleBudget = ((available - reserved) / titleLineHeight).toInt().coerceAtLeast(0)
|
||||||
|
val lines = titleLines.coerceAtMost(titleBudget)
|
||||||
|
val spare = available - titleLineHeight * lines - reserved
|
||||||
|
val timeMaxLines = if (label == null) 1 else blockTimeLines(label, textWidth, spare)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
// Absolute: these are root coordinates, and the direction-aware
|
// Absolute: these are root coordinates, and the direction-aware
|
||||||
@@ -710,11 +743,8 @@ private fun DragCopy(
|
|||||||
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
|
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.size(
|
.size(width = width, height = height)
|
||||||
width = with(density) { sizePx.width.toDp() },
|
.padding(horizontal = BLOCK_OUTER_INSET)
|
||||||
height = with(density) { sizePx.height.toDp() },
|
|
||||||
)
|
|
||||||
.padding(horizontal = 1.dp)
|
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
scaleX = 1f + 0.02f * lift
|
scaleX = 1f + 0.02f * lift
|
||||||
scaleY = 1f + 0.02f * lift
|
scaleY = 1f + 0.02f * lift
|
||||||
@@ -723,24 +753,30 @@ private fun DragCopy(
|
|||||||
this.shape = shape
|
this.shape = shape
|
||||||
clip = false
|
clip = false
|
||||||
}
|
}
|
||||||
.background(fill, shape)
|
.eventSurface(paint, shape, cuts)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
if (lines > 0) {
|
||||||
text = title,
|
BlockTitle(
|
||||||
style = MaterialTheme.typography.labelMedium,
|
title = title,
|
||||||
maxLines = 1,
|
maxLines = lines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
textWidth = textWidth,
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
color = paint.titleInk,
|
||||||
)
|
textDecoration = paint.decoration,
|
||||||
|
fontWeight = paint.titleWeight,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (label != null) {
|
if (label != null) {
|
||||||
|
val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
// As the block it lifted off sets its own time (#219).
|
||||||
maxLines = 1,
|
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||||
overflow = TextOverflow.Ellipsis,
|
maxLines = timeMaxLines,
|
||||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
overflow = overflow.overflow,
|
||||||
|
softWrap = overflow.softWrap,
|
||||||
|
color = paint.secondaryInk,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,39 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-bar pill that shows the current view and cycles to the next one on tap
|
* Top-bar pill that shows the current view and cycles to the next one on tap
|
||||||
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
|
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
|
||||||
|
*
|
||||||
|
* Renders nothing when [cycle] holds fewer than [QuickSwitchConfig.MIN_CYCLE]
|
||||||
|
* views (#150), so it drops straight into an app bar's `actions` slot without a
|
||||||
|
* wrapping condition — the same way [TodayAction] handles being turned off.
|
||||||
|
*
|
||||||
|
* [trailingInset] lands the pill's background on the bar's own rhythm; pass
|
||||||
|
* `0.dp` outside a top app bar, where M3's actions padding is not there to
|
||||||
|
* cancel (#165).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ViewSwitcherPill(
|
fun ViewSwitcherPill(
|
||||||
current: CalendarView,
|
current: CalendarView,
|
||||||
|
cycle: List<CalendarView>,
|
||||||
onCycle: () -> Unit,
|
onCycle: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
|
||||||
) {
|
) {
|
||||||
|
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = onCycle,
|
onClick = onCycle,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
modifier = modifier,
|
modifier = modifier.padding(end = trailingInset),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(current.labelRes))
|
Text(stringResource(current.labelRes))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -40,6 +39,7 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
@@ -53,10 +53,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -65,7 +63,6 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.customActions
|
import androidx.compose.ui.semantics.customActions
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -73,6 +70,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||||
@@ -81,7 +81,12 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
@@ -94,9 +99,11 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
|
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
|
||||||
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
|
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||||
@@ -106,18 +113,13 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
@@ -170,21 +172,9 @@ fun DayScreen(
|
|||||||
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
|
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// The all-day strip shares the app bar's scrolled colour so the whole top
|
|
||||||
// region elevates together once the timeline scrolls under it.
|
|
||||||
val topSectionColor by animateColorAsState(
|
|
||||||
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surface
|
|
||||||
},
|
|
||||||
label = "day-top-section-color",
|
|
||||||
)
|
|
||||||
|
|
||||||
val isOnToday = when (val s = state) {
|
val isOnToday = when (val s = state) {
|
||||||
is DayUiState.Success -> s.date == s.today
|
is DayUiState.Success -> s.date == s.today
|
||||||
else -> true
|
else -> true
|
||||||
@@ -242,19 +232,19 @@ fun DayScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
DayTopBar(
|
DayTopBar(
|
||||||
date = date,
|
date = date,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onJumpToDate = jumpToDate,
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -269,7 +259,6 @@ fun DayScreen(
|
|||||||
DayContent(
|
DayContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
topSectionColor = topSectionColor,
|
|
||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
@@ -287,7 +276,6 @@ fun DayScreen(
|
|||||||
private fun DayContent(
|
private fun DayContent(
|
||||||
state: DayUiState,
|
state: DayUiState,
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
topSectionColor: Color,
|
|
||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
@@ -348,7 +336,6 @@ private fun DayContent(
|
|||||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is DayUiState.Success -> DaySuccess(
|
is DayUiState.Success -> DaySuccess(
|
||||||
state = s,
|
state = s,
|
||||||
topSectionColor = topSectionColor,
|
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
@@ -374,7 +361,6 @@ private fun DayContent(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun DaySuccess(
|
internal fun DaySuccess(
|
||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
topSectionColor: Color,
|
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
@@ -385,16 +371,22 @@ internal fun DaySuccess(
|
|||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// All-day strip collapses to nothing when the day has no all-day events,
|
// All-day strip collapses to nothing when the day has no all-day events,
|
||||||
// so the timeline sits directly under the app bar.
|
// so the timeline sits directly under the app bar.
|
||||||
AllDayStrip(
|
// This strip is painted on `surface`, so an outlined chip has to fill
|
||||||
state = state,
|
// with that and not with the column's container (#230).
|
||||||
height = allDayHeight,
|
CompositionLocalProvider(
|
||||||
onEventClick = onEventClick,
|
LocalChipGround provides MaterialTheme.colorScheme.surface,
|
||||||
modifier = Modifier
|
) {
|
||||||
.fillMaxWidth()
|
AllDayStrip(
|
||||||
.background(topSectionColor),
|
state = state,
|
||||||
)
|
height = allDayHeight,
|
||||||
// Breathing room between the (colour-shifting) top section and the
|
onEventClick = onEventClick,
|
||||||
// scrolling timeline below.
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Breathing room between the top section and the scrolling timeline
|
||||||
|
// below.
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
@@ -414,20 +406,25 @@ private fun DayTopBar(
|
|||||||
currentYear: Int,
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView>,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
|
||||||
) {
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
|
val (title, shortTitle) = remember(date, locale, currentYear) {
|
||||||
|
formatDayTitle(date, locale, currentYear) to
|
||||||
|
formatDayTitle(date, locale, currentYear, abbreviated = true)
|
||||||
|
}
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
CalendarTitleButton(
|
CalendarTitleButton(
|
||||||
title = formatDayTitle(date, locale, currentYear),
|
title = title,
|
||||||
currentDate = date,
|
currentDate = date,
|
||||||
onJumpToDate = onJumpToDate,
|
onJumpToDate = onJumpToDate,
|
||||||
|
shortTitle = shortTitle,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -448,15 +445,17 @@ private fun DayTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
|
cycle = quickSwitchViews,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||||
|
// it, but here the bar meets the header on the same surface and the
|
||||||
|
// tint is what makes that seam look like a separate block (#186).
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,23 +515,25 @@ private fun AllDayBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val soften = LocalSoftenColors.current
|
val paint = eventPaint(event, dark)
|
||||||
val fill = eventFill(event.color, dark, soften)
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(fill, EventChipShape)
|
.eventSurface(paint, EventChipShape)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
|
val titleOverflow = eventTitleOverflow()
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = titleOverflow.overflow,
|
||||||
color = eventInk(fill),
|
softWrap = titleOverflow.softWrap,
|
||||||
textDecoration = declinedDecoration(event.isDeclined),
|
color = paint.titleInk,
|
||||||
|
fontWeight = paint.titleWeight,
|
||||||
|
textDecoration = paint.decoration,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -691,6 +692,7 @@ private fun DayColumnCard(
|
|||||||
block = block,
|
block = block,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
height = place.height,
|
height = place.height,
|
||||||
|
width = place.width,
|
||||||
date = date,
|
date = date,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
@@ -699,7 +701,7 @@ private fun DayColumnCard(
|
|||||||
.offset(x = place.x, y = place.y)
|
.offset(x = place.x, y = place.y)
|
||||||
.width(place.width)
|
.width(place.width)
|
||||||
.height(place.height)
|
.height(place.height)
|
||||||
.padding(horizontal = 1.dp),
|
.padding(horizontal = BLOCK_OUTER_INSET),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -716,6 +718,7 @@ private fun EventBlock(
|
|||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
height: Dp,
|
height: Dp,
|
||||||
|
width: Dp,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
@@ -734,29 +737,42 @@ private fun EventBlock(
|
|||||||
val timeLineHeight = with(density) {
|
val timeLineHeight = with(density) {
|
||||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||||
}
|
}
|
||||||
// What's left for text once the 2.dp top/bottom padding is paid for. A block
|
// A block that cannot afford both lines spends its space on the title, and
|
||||||
// that cannot afford both lines spends its space on the title, and one too
|
// one too short even for that drops the title rather than serving a sliced one.
|
||||||
// short even for that drops the title rather than serving a sliced one.
|
// Height alone decides: a duration threshold would keep hiding the time on a
|
||||||
val available = height - 4.dp
|
// half-hour block the user has pinched open to three times the room it needs.
|
||||||
val showTime = block.endMin - block.startMin >= 45 &&
|
val available = height - BLOCK_TEXT_INSET * 2
|
||||||
available >= titleLineHeight + timeLineHeight
|
val showTime = available >= titleLineHeight + timeLineHeight
|
||||||
val showTitle = available >= titleLineHeight
|
val showTitle = available >= titleLineHeight
|
||||||
val soften = LocalSoftenColors.current
|
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||||
val fill = eventFill(block.event.color, dark, soften)
|
val titleMaxLines = if (showTime) 1 else 2
|
||||||
|
// On a day column — wide enough for "09: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 zone = remember { TimeZone.currentSystemDefault() }
|
||||||
val moveAction = eventMoveAction(block.event)
|
val moveAction = eventMoveAction(block.event)
|
||||||
val draggable = eventDragAllowed(block.event)
|
val draggable = eventDragAllowed(block.event)
|
||||||
// The drop takes this offset back off, so a tail clipped at midnight lands
|
// The drop takes this offset back off, so a tail clipped at midnight lands
|
||||||
// where the event's own start belongs (#253).
|
// where the event's own start belongs (#253).
|
||||||
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
||||||
val shape = remember(block, date, zone) {
|
val cuts = remember(block, date, zone) {
|
||||||
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
|
timedBlockCuts(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
|
||||||
}
|
}
|
||||||
|
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
|
||||||
val dragModifier = rememberEventDragSource(
|
val dragModifier = rememberEventDragSource(
|
||||||
enabled = draggable,
|
enabled = draggable,
|
||||||
key = block.event.instanceId,
|
key = block.event.instanceId,
|
||||||
onPickUp = { pointer, blockRoot, _ ->
|
onPickUp = { pointer, blockRoot, _ ->
|
||||||
dragController.begin(block, clipOffset, pointer, blockRoot)
|
dragController.begin(
|
||||||
|
block = block,
|
||||||
|
clipOffsetMin = clipOffset,
|
||||||
|
titleLines = if (showTitle) titleMaxLines else 0,
|
||||||
|
pointerInRoot = pointer,
|
||||||
|
blockInRoot = blockRoot,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onMove = dragController::move,
|
onMove = dragController::move,
|
||||||
onDrop = { dragController.finish()?.let(onDrop) },
|
onDrop = { dragController.finish()?.let(onDrop) },
|
||||||
@@ -768,12 +784,12 @@ private fun EventBlock(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.background(fill, shape)
|
.eventSurface(paint, shape, cuts)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
// After clickable, so it is the inner node and wins the main pass;
|
// After clickable, so it is the inner node and wins the main pass;
|
||||||
// the tap still works, since a drag consumes the up.
|
// the tap still works, since a drag consumes the up.
|
||||||
.then(dragModifier)
|
.then(dragModifier)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = "$title, $timeLabel"
|
contentDescription = "$title, $timeLabel"
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
@@ -781,19 +797,20 @@ private fun EventBlock(
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (showTitle) {
|
if (showTitle) {
|
||||||
Text(
|
BlockTitle(
|
||||||
text = title,
|
title = title,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
maxLines = titleMaxLines,
|
||||||
maxLines = if (showTime) 1 else 2,
|
textWidth = textWidth,
|
||||||
overflow = TextOverflow.Ellipsis,
|
color = paint.titleInk,
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
textDecoration = paint.decoration,
|
||||||
textDecoration = declinedDecoration(block.event.isDeclined),
|
fontWeight = paint.titleWeight,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
BlockTimeLabel(
|
BlockTimeLabel(
|
||||||
label = timeLabel,
|
label = timeLabel,
|
||||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
color = paint.secondaryInk,
|
||||||
|
maxLines = timeMaxLines,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -829,10 +846,16 @@ private fun DayLoading() {
|
|||||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||||
formatMinuteOfDay(min, is24Hour, locale)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
|
/** [abbreviated] drops the weekday, leaving the date itself (#165). */
|
||||||
|
private fun formatDayTitle(
|
||||||
|
date: LocalDate,
|
||||||
|
locale: Locale,
|
||||||
|
currentYear: Int,
|
||||||
|
abbreviated: Boolean = false,
|
||||||
|
): String =
|
||||||
formatCalendarTitle(
|
formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
skeleton = "EEEdMMM",
|
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -41,7 +40,6 @@ internal fun DayViewPreview(
|
|||||||
ScaledViewPreview(height = height, modifier = modifier) {
|
ScaledViewPreview(height = height, modifier = modifier) {
|
||||||
DaySuccess(
|
DaySuccess(
|
||||||
state = state,
|
state = state,
|
||||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = state.allDayStripHeight(),
|
allDayHeight = state.allDayStripHeight(),
|
||||||
dragController = rememberTimelineDragController(),
|
dragController = rememberTimelineDragController(),
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import android.net.Uri
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -98,6 +97,9 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
|||||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
@@ -150,6 +152,7 @@ fun EventDetailScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val copyField = rememberFieldCopier(snackbarHostState)
|
||||||
|
|
||||||
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
|
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
|
||||||
// an .ics in the cache and hands back a content Uri for the chooser.
|
// an .ics in the cache and hands back a content Uri for the chooser.
|
||||||
@@ -300,7 +303,7 @@ fun EventDetailScreen(
|
|||||||
reason = s.reason,
|
reason = s.reason,
|
||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
)
|
)
|
||||||
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +385,11 @@ private fun DeleteEventDialog(
|
|||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
|
private fun EventDetailContent(
|
||||||
|
state: EventDetailUiState.Success,
|
||||||
|
copyField: FieldCopier,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
val detail = state.detail
|
val detail = state.detail
|
||||||
val instance = detail.instance
|
val instance = detail.instance
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -399,6 +406,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
// event, so it's left implicit — only Free is worth surfacing. A
|
// event, so it's left implicit — only Free is worth surfacing. A
|
||||||
// cancelled event strikes through its title.
|
// cancelled event strikes through its title.
|
||||||
Row(verticalAlignment = Alignment.Top) {
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
val titleLabel = stringResource(R.string.event_detail_title)
|
||||||
Text(
|
Text(
|
||||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
@@ -408,7 +416,17 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.then(
|
||||||
|
// Nothing to copy off an untitled event — the placeholder
|
||||||
|
// isn't the event's own text.
|
||||||
|
if (instance.title.isNotBlank()) {
|
||||||
|
Modifier.copyOnLongPress(titleLabel, instance.title, copyField)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (detail.availability == Availability.Free) {
|
if (detail.availability == Availability.Free) {
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
@@ -516,10 +534,11 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
// Location (conditional, tap → maps).
|
// Location (conditional, tap → maps).
|
||||||
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val locationLabel = stringResource(R.string.event_detail_location)
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Default.Place,
|
icon = Icons.Default.Place,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
iconContentDescription = locationLabel,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = location,
|
text = location,
|
||||||
@@ -527,7 +546,12 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { openInMaps(context, location) }
|
.copyOnLongPress(
|
||||||
|
label = locationLabel,
|
||||||
|
text = location,
|
||||||
|
copy = copyField,
|
||||||
|
onTap = { openInMaps(context, location) },
|
||||||
|
)
|
||||||
.padding(vertical = 2.dp),
|
.padding(vertical = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -535,10 +559,15 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
|
|
||||||
// Description (conditional). URLs are auto-linked.
|
// Description (conditional). URLs are auto-linked.
|
||||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||||
|
val descriptionLabel = stringResource(R.string.event_detail_description)
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.AutoMirrored.Filled.Notes,
|
icon = Icons.AutoMirrored.Filled.Notes,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_description),
|
iconContentDescription = descriptionLabel,
|
||||||
|
// The gesture sits on the card so the icon and padding answer
|
||||||
|
// it too: every linkified URL owns the pointer over its own
|
||||||
|
// glyphs, which leaves the text itself a patchy target.
|
||||||
|
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||||
@@ -611,13 +640,14 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
private fun DetailCard(
|
private fun DetailCard(
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
iconContentDescription: String?,
|
iconContentDescription: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
|
|||||||
@@ -794,6 +794,9 @@ private fun EventEditContent(
|
|||||||
value = form.location,
|
value = form.location,
|
||||||
onValueChange = viewModel::setLocation,
|
onValueChange = viewModel::setLocation,
|
||||||
placeholder = stringResource(R.string.event_detail_location),
|
placeholder = stringResource(R.string.event_detail_location),
|
||||||
|
// A location is as often a meeting URL as an address,
|
||||||
|
// and "Zoom.us/j/123" reads wrong (#146).
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
@@ -2223,7 +2226,9 @@ private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Lo
|
|||||||
/**
|
/**
|
||||||
* Borderless text input used inside the cards (and as the headline title).
|
* Borderless text input used inside the cards (and as the headline title).
|
||||||
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
||||||
* the app share one input style.
|
* the app share one input style. Sentence-case by default, as floret-kit is;
|
||||||
|
* a field holding an identifier rather than prose passes
|
||||||
|
* [KeyboardCapitalization.None].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun InlineField(
|
private fun InlineField(
|
||||||
@@ -2235,6 +2240,7 @@ private fun InlineField(
|
|||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
|
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
@@ -2249,7 +2255,7 @@ private fun InlineField(
|
|||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
capitalization = KeyboardCapitalization.None,
|
capitalization = capitalization,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 targetDate: LocalDate?,
|
||||||
val topLeftInRoot: Offset,
|
val topLeftInRoot: Offset,
|
||||||
val sizePx: IntSize,
|
val sizePx: IntSize,
|
||||||
|
/**
|
||||||
|
* The start time of the chip this lifted off, already formatted (#219).
|
||||||
|
* Carried rather than re-derived: the overlay has no row to ask whether the
|
||||||
|
* bar was carried in from the previous week, and no zone the grid laid out
|
||||||
|
* in. Null where the source chip had no time to show.
|
||||||
|
*/
|
||||||
|
val time: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
||||||
@@ -172,6 +179,7 @@ class MonthDragController {
|
|||||||
|
|
||||||
private var event: EventInstance? = null
|
private var event: EventInstance? = null
|
||||||
private var grabDate: LocalDate? = null
|
private var grabDate: LocalDate? = null
|
||||||
|
private var time: String? = null
|
||||||
private var grab = Offset.Zero
|
private var grab = Offset.Zero
|
||||||
private var pointer = Offset.Zero
|
private var pointer = Offset.Zero
|
||||||
private var sizePx = IntSize.Zero
|
private var sizePx = IntSize.Zero
|
||||||
@@ -190,9 +198,11 @@ class MonthDragController {
|
|||||||
pointerInRoot: Offset,
|
pointerInRoot: Offset,
|
||||||
chipInRoot: Offset,
|
chipInRoot: Offset,
|
||||||
size: IntSize,
|
size: IntSize,
|
||||||
|
time: String? = null,
|
||||||
) {
|
) {
|
||||||
this.event = event
|
this.event = event
|
||||||
this.grabDate = grabDate
|
this.grabDate = grabDate
|
||||||
|
this.time = time
|
||||||
settling = null
|
settling = null
|
||||||
isDragging = true
|
isDragging = true
|
||||||
liftedInstanceId = event.instanceId
|
liftedInstanceId = event.instanceId
|
||||||
@@ -210,6 +220,7 @@ class MonthDragController {
|
|||||||
fun cancel() {
|
fun cancel() {
|
||||||
event = null
|
event = null
|
||||||
grabDate = null
|
grabDate = null
|
||||||
|
time = null
|
||||||
isDragging = false
|
isDragging = false
|
||||||
liftedInstanceId = null
|
liftedInstanceId = null
|
||||||
drag = null
|
drag = null
|
||||||
@@ -331,6 +342,7 @@ class MonthDragController {
|
|||||||
targetDate = resolved ?: drag?.targetDate,
|
targetDate = resolved ?: drag?.targetDate,
|
||||||
topLeftInRoot = pointer - grab,
|
topLeftInRoot = pointer - grab,
|
||||||
sizePx = sizePx,
|
sizePx = sizePx,
|
||||||
|
time = time,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,9 +88,13 @@ import androidx.compose.ui.platform.LocalLayoutDirection
|
|||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.monthBarCuts
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
@@ -113,7 +117,6 @@ import androidx.compose.ui.graphics.graphicsLayer
|
|||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -123,6 +126,7 @@ import androidx.compose.ui.semantics.semantics
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -134,6 +138,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||||
@@ -142,14 +147,13 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.monthBarShape
|
import de.jeanlucmakiola.calendula.ui.common.monthBarShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
@@ -212,7 +216,6 @@ fun MonthScreen(
|
|||||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||||
}
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -298,10 +301,14 @@ fun MonthScreen(
|
|||||||
// carries the year instead of repeating it two lines further down. Dense has
|
// carries the year instead of repeating it two lines further down. Dense has
|
||||||
// no header to defer to, so it keeps the full title.
|
// no header to defer to, so it keeps the full title.
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) {
|
val (topBarTitle, topBarShortTitle) = remember(viewStyle, titleMonth, locale, today.year) {
|
||||||
titleMonth.year.toString()
|
if (viewStyle == MonthViewStyle.Continuous) {
|
||||||
} else {
|
val year = titleMonth.year.toString()
|
||||||
formatMonthTitle(titleMonth, locale, currentYear = today.year)
|
year to year
|
||||||
|
} else {
|
||||||
|
formatMonthTitle(titleMonth, locale, currentYear = today.year) to
|
||||||
|
formatMonthTitle(titleMonth, locale, currentYear = today.year, abbreviated = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
||||||
@@ -386,19 +393,20 @@ fun MonthScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
MonthTopBar(
|
MonthTopBar(
|
||||||
title = topBarTitle,
|
title = topBarTitle,
|
||||||
|
shortTitle = topBarShortTitle,
|
||||||
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onJumpToDate = jumpToDate,
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -494,6 +502,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
|||||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
val moveInFlight = moveInFlight()
|
val moveInFlight = moveInFlight()
|
||||||
// Here rather than beside the controller: this reads [drag], which changes
|
// Here rather than beside the controller: this reads [drag], which changes
|
||||||
@@ -551,6 +560,11 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
continuesLeft = false,
|
continuesLeft = false,
|
||||||
continuesRight = false,
|
continuesRight = false,
|
||||||
|
// The time the source chip carried, drawn only if this one-column
|
||||||
|
// cut-out of it has the room — which a slice of a multi-day bar has
|
||||||
|
// not, however wide the bar was.
|
||||||
|
time = drag.time,
|
||||||
|
showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
// Absolute: these are root coordinates, and the direction-aware
|
// Absolute: these are root coordinates, and the direction-aware
|
||||||
// offset would mirror them across the screen in an RTL layout.
|
// offset would mirror them across the screen in an RTL layout.
|
||||||
@@ -563,7 +577,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
|||||||
}
|
}
|
||||||
.width(with(density) { drag.sizePx.width.toDp() })
|
.width(with(density) { drag.sizePx.width.toDp() })
|
||||||
.height(with(density) { drag.sizePx.height.toDp() })
|
.height(with(density) { drag.sizePx.height.toDp() })
|
||||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
scaleX = 1f + 0.04f * lift
|
scaleX = 1f + 0.04f * lift
|
||||||
scaleY = 1f + 0.04f * lift
|
scaleY = 1f + 0.04f * lift
|
||||||
@@ -664,15 +678,16 @@ private fun ContinuousMonthContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MonthTopBar(
|
private fun MonthTopBar(
|
||||||
title: String,
|
title: String,
|
||||||
|
shortTitle: String,
|
||||||
titleDate: LocalDate,
|
titleDate: LocalDate,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView>,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
@@ -680,6 +695,7 @@ private fun MonthTopBar(
|
|||||||
title = title,
|
title = title,
|
||||||
currentDate = titleDate,
|
currentDate = titleDate,
|
||||||
onJumpToDate = onJumpToDate,
|
onJumpToDate = onJumpToDate,
|
||||||
|
shortTitle = shortTitle,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -700,11 +716,17 @@ private fun MonthTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
|
cycle = quickSwitchViews,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior,
|
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||||
|
// it, but here the bar meets the header on the same surface and the
|
||||||
|
// tint is what makes that seam look like a separate block (#186).
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,7 +741,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
// Reserve the gutter so the weekday labels stay over their day columns.
|
// Reserve the gutter so the weekday labels stay over their day columns.
|
||||||
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
|
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
|
||||||
@@ -745,7 +767,6 @@ private val DAY_NUMBER_HEIGHT = 22.dp
|
|||||||
private val WEEK_NUMBER_GUTTER = 40.dp
|
private val WEEK_NUMBER_GUTTER = 40.dp
|
||||||
private val DAY_NUMBER_GAP = 4.dp
|
private val DAY_NUMBER_GAP = 4.dp
|
||||||
private val CELL_TOP_PADDING = 6.dp
|
private val CELL_TOP_PADDING = 6.dp
|
||||||
private val CELL_GAP = 2.dp
|
|
||||||
/** Named separately because the split style's selection outline draws its own
|
/** Named separately because the split style's selection outline draws its own
|
||||||
* rounded rect and has to match this radius exactly. */
|
* rounded rect and has to match this radius exactly. */
|
||||||
private val CELL_CORNER = 12.dp
|
private val CELL_CORNER = 12.dp
|
||||||
@@ -787,17 +808,22 @@ internal fun MonthGrid(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
// Match the weekday header's 8dp inset so day cells sit under their
|
// Match the weekday header's inset so day cells sit under their
|
||||||
// labels, and so the week-number gutter's centre lines up with the
|
// labels, and so the week-number gutter's centre lines up with the
|
||||||
// top bar's hamburger (4dp bar inset + 24dp half icon button).
|
// top bar's hamburger (4dp bar inset + 24dp half icon button).
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
val month = state.month
|
val month = state.month
|
||||||
|
// Once per grid: the value depends on the typography, the density, the
|
||||||
|
// locale and the 24-hour setting, none of which vary by row (#219).
|
||||||
|
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||||
state.weeks.forEach { week ->
|
state.weeks.forEach { week ->
|
||||||
MonthWeekRow(
|
MonthWeekRow(
|
||||||
week = week,
|
week = week,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
|
zone = state.zone,
|
||||||
|
timeChipWidth = timeChipWidth,
|
||||||
inMonth = { it.month == month.month && it.year == month.year },
|
inMonth = { it.month == month.month && it.year == month.year },
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
@@ -837,6 +863,7 @@ internal fun ContinuousMonthGrid(
|
|||||||
) {
|
) {
|
||||||
val monthCount = remember { continuousMonthCount() }
|
val monthCount = remember { continuousMonthCount() }
|
||||||
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) }
|
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) }
|
||||||
|
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
@@ -858,6 +885,8 @@ internal fun ContinuousMonthGrid(
|
|||||||
weeks = state.monthsByIndex[index],
|
weeks = state.monthsByIndex[index],
|
||||||
weekStart = state.weekStart,
|
weekStart = state.weekStart,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
|
zone = state.zone,
|
||||||
|
timeChipWidth = timeChipWidth,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
@@ -878,6 +907,8 @@ private fun ContinuousMonthBlock(
|
|||||||
weeks: List<MonthWeek>?,
|
weeks: List<MonthWeek>?,
|
||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
timeChipWidth: Dp,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
@@ -886,7 +917,7 @@ private fun ContinuousMonthBlock(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp)
|
.padding(horizontal = AppBarSpacing.Inset)
|
||||||
.padding(bottom = CONTINUOUS_MONTH_GAP),
|
.padding(bottom = CONTINUOUS_MONTH_GAP),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
@@ -897,6 +928,8 @@ private fun ContinuousMonthBlock(
|
|||||||
MonthWeekRow(
|
MonthWeekRow(
|
||||||
week = week,
|
week = week,
|
||||||
today = today,
|
today = today,
|
||||||
|
zone = zone,
|
||||||
|
timeChipWidth = timeChipWidth,
|
||||||
inMonth = { it.month == month.month && it.year == month.year },
|
inMonth = { it.month == month.month && it.year == month.year },
|
||||||
// The block owns its month alone: a day from either
|
// The block owns its month alone: a day from either
|
||||||
// neighbour is left out entirely rather than dimmed.
|
// neighbour is left out entirely rather than dimmed.
|
||||||
@@ -966,6 +999,7 @@ internal fun DenseMonthGrid(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
|
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
|
||||||
|
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -974,7 +1008,7 @@ internal fun DenseMonthGrid(
|
|||||||
// padding: a lazy list scrolls its rows *through* the before-content
|
// padding: a lazy list scrolls its rows *through* the before-content
|
||||||
// padding, so scrolling to a week landed its row that far down with
|
// padding, so scrolling to a week landed its row that far down with
|
||||||
// the tail of the previous one showing above it (#191).
|
// the tail of the previous one showing above it (#191).
|
||||||
.padding(horizontal = 8.dp)
|
.padding(horizontal = AppBarSpacing.Inset)
|
||||||
.padding(top = DENSE_HEADER_GAP),
|
.padding(top = DENSE_HEADER_GAP),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
@@ -988,6 +1022,8 @@ internal fun DenseMonthGrid(
|
|||||||
MonthWeekRow(
|
MonthWeekRow(
|
||||||
week = week,
|
week = week,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
|
zone = state.zone,
|
||||||
|
timeChipWidth = timeChipWidth,
|
||||||
// Every day in the stream belongs to a month equally — there
|
// Every day in the stream belongs to a month equally — there
|
||||||
// is no "other month" to recede here.
|
// is no "other month" to recede here.
|
||||||
inMonth = { true },
|
inMonth = { true },
|
||||||
@@ -1441,7 +1477,7 @@ internal fun SplitMonthGrid(
|
|||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
state.weeks.forEach { week ->
|
state.weeks.forEach { week ->
|
||||||
@@ -1806,7 +1842,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 8.dp)
|
.padding(horizontal = AppBarSpacing.Inset)
|
||||||
.clipToBounds(),
|
.clipToBounds(),
|
||||||
) {
|
) {
|
||||||
if (!dense) {
|
if (!dense) {
|
||||||
@@ -1873,6 +1909,10 @@ private fun rememberSkeletonPulse(): Float {
|
|||||||
private fun MonthWeekRow(
|
private fun MonthWeekRow(
|
||||||
week: MonthWeek,
|
week: MonthWeek,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
|
/** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */
|
||||||
|
zone: TimeZone,
|
||||||
|
/** The narrowest chip that may carry a start time, measured once per grid (#219). */
|
||||||
|
timeChipWidth: Dp,
|
||||||
inMonth: (LocalDate) -> Boolean,
|
inMonth: (LocalDate) -> Boolean,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
@@ -1892,6 +1932,27 @@ private fun MonthWeekRow(
|
|||||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||||
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||||
val morphing = morphInFlight()
|
val morphing = morphInFlight()
|
||||||
|
// Every chip's start time for this row at once, and only when the row's own
|
||||||
|
// inputs change: formatting is a parsed pattern per call, and the dim cutoff
|
||||||
|
// ticks every minute while a drag recomposes every frame (#219).
|
||||||
|
val is24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
val chipTimes = remember(week, zone, is24Hour, locale) {
|
||||||
|
buildMap {
|
||||||
|
week.spans.forEach { span ->
|
||||||
|
put(
|
||||||
|
span.event.instanceId,
|
||||||
|
monthChipTime(span.event, span.continuesLeft, zone, is24Hour, locale),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
week.timedByDay.values.flatten().forEach { event ->
|
||||||
|
put(
|
||||||
|
event.instanceId,
|
||||||
|
monthChipTime(event, continuesLeft = false, zone, is24Hour, locale),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Drag to reschedule (#68). The chips can take no pointer input of their own
|
// Drag to reschedule (#68). The chips can take no pointer input of their own
|
||||||
// — the full-bleed tap layer sits on top of them — so one detector on the
|
// — the full-bleed tap layer sits on top of them — so one detector on the
|
||||||
@@ -1966,9 +2027,12 @@ private fun MonthWeekRow(
|
|||||||
band = bandCoordinates,
|
band = bandCoordinates,
|
||||||
rowHeightPx = rowHeightPx,
|
rowHeightPx = rowHeightPx,
|
||||||
isRtl = isRtl,
|
isRtl = isRtl,
|
||||||
|
chipTimes = chipTimes,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
// What a chip has to spend, against the [timeChipWidth] a start time
|
||||||
|
// costs it (#219).
|
||||||
val colW = maxWidth / 7
|
val colW = maxWidth / 7
|
||||||
|
|
||||||
// Per-day background pills — same surfaceContainer rounded surface the
|
// Per-day background pills — same surfaceContainer rounded surface the
|
||||||
@@ -2049,6 +2113,8 @@ private fun MonthWeekRow(
|
|||||||
continuesLeft = span.continuesLeft,
|
continuesLeft = span.continuesLeft,
|
||||||
continuesRight = span.continuesRight,
|
continuesRight = span.continuesRight,
|
||||||
days = week.days.subList(span.startCol, span.endCol + 1),
|
days = week.days.subList(span.startCol, span.endCol + 1),
|
||||||
|
time = chipTimes[span.event.instanceId],
|
||||||
|
showTime = colW * cols >= timeChipWidth,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * span.startCol,
|
x = colW * span.startCol,
|
||||||
@@ -2072,7 +2138,7 @@ private fun MonthWeekRow(
|
|||||||
)
|
)
|
||||||
.width(colW * cols)
|
.width(colW * cols)
|
||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||||
)
|
)
|
||||||
// One invisible slice of the bar per further column it
|
// One invisible slice of the bar per further column it
|
||||||
// covers. A multi-day event has a dot on every day but
|
// covers. A multi-day event has a dot on every day but
|
||||||
@@ -2096,7 +2162,7 @@ private fun MonthWeekRow(
|
|||||||
)
|
)
|
||||||
.width(colW)
|
.width(colW)
|
||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2119,6 +2185,8 @@ private fun MonthWeekRow(
|
|||||||
continuesLeft = false,
|
continuesLeft = false,
|
||||||
continuesRight = false,
|
continuesRight = false,
|
||||||
days = listOf(d),
|
days = listOf(d),
|
||||||
|
time = chipTimes[ev.instanceId],
|
||||||
|
showTime = colW >= timeChipWidth,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * col,
|
x = colW * col,
|
||||||
@@ -2127,7 +2195,7 @@ private fun MonthWeekRow(
|
|||||||
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
||||||
.width(colW)
|
.width(colW)
|
||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||||
@@ -2279,6 +2347,9 @@ private fun monthChipDragModifier(
|
|||||||
band: Array<LayoutCoordinates?>,
|
band: Array<LayoutCoordinates?>,
|
||||||
rowHeightPx: Float,
|
rowHeightPx: Float,
|
||||||
isRtl: Boolean,
|
isRtl: Boolean,
|
||||||
|
/** The row's formatted chip times by instance id, so the copy carries the
|
||||||
|
* one its source chip had rather than deriving another (#219). */
|
||||||
|
chipTimes: Map<Long, String?>,
|
||||||
): Modifier = rememberDragSurface(
|
): Modifier = rememberDragSurface(
|
||||||
enabled = moveScope?.dragEnabled == true && controller != null,
|
enabled = moveScope?.dragEnabled == true && controller != null,
|
||||||
key = week.days.first(),
|
key = week.days.first(),
|
||||||
@@ -2307,6 +2378,7 @@ private fun monthChipDragModifier(
|
|||||||
y = requireNotNull(bandTop) + lane * rowHeightPx,
|
y = requireNotNull(bandTop) + lane * rowHeightPx,
|
||||||
),
|
),
|
||||||
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
|
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
|
||||||
|
time = chipTimes[event.instanceId],
|
||||||
)
|
)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -2414,7 +2486,10 @@ private fun shortMonthName(date: LocalDate): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */
|
/**
|
||||||
|
* A filled event pill/bar — softened (or raw) fill, title clipped to one line,
|
||||||
|
* with the start time before it where the chip is wide enough ([showTime], #219).
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthBar(
|
private fun MonthBar(
|
||||||
event: de.jeanlucmakiola.calendula.domain.EventInstance,
|
event: de.jeanlucmakiola.calendula.domain.EventInstance,
|
||||||
@@ -2428,12 +2503,26 @@ private fun MonthBar(
|
|||||||
* the provider hands the re-read instance a new one.
|
* the provider hands the re-read instance a new one.
|
||||||
*/
|
*/
|
||||||
days: List<LocalDate>? = null,
|
days: List<LocalDate>? = null,
|
||||||
|
/** The chip's start time, or null where it has none — see `monthChipTime` (#219). */
|
||||||
|
time: String? = null,
|
||||||
|
/** Whether this chip has the width to draw [time]; a screen reader gets it either way. */
|
||||||
|
showTime: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
val soften = LocalSoftenColors.current
|
val paint = eventPaint(event, dark)
|
||||||
val fill = eventFill(event.color, dark, soften)
|
// The same title/secondary ink pairing the week and day blocks use, with
|
||||||
|
// the time on the quieter half.
|
||||||
|
val label = inlineTimeLabel(
|
||||||
|
time = time.takeIf { showTime },
|
||||||
|
title = title,
|
||||||
|
timeInk = paint.secondaryInk,
|
||||||
|
)
|
||||||
|
// Announced whether or not it is drawn, and comma-separated as the week and
|
||||||
|
// day blocks do it: what a screen reader hears shouldn't turn on how wide
|
||||||
|
// the chip happens to be (#219).
|
||||||
|
val description = if (time != null) "$title, $time" else title
|
||||||
val moveAction = eventMoveAction(event)
|
val moveAction = eventMoveAction(event)
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
val monthDrag = LocalMonthDrag.current
|
val monthDrag = LocalMonthDrag.current
|
||||||
@@ -2444,21 +2533,24 @@ private fun MonthBar(
|
|||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.background(fill, shape)
|
.eventSurface(paint, shape, monthBarCuts(continuesLeft, continuesRight))
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = MONTH_CHIP_TEXT_PADDING)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = title
|
contentDescription = description
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
|
val titleOverflow = eventTitleOverflow()
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = label,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = titleOverflow.overflow,
|
||||||
color = eventInk(fill),
|
softWrap = titleOverflow.softWrap,
|
||||||
textDecoration = declinedDecoration(event.isDeclined),
|
color = paint.titleInk,
|
||||||
|
fontWeight = paint.titleWeight,
|
||||||
|
textDecoration = paint.decoration,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2514,7 +2606,7 @@ private fun MonthGridLoading() {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
repeat(6) {
|
repeat(6) {
|
||||||
@@ -2540,10 +2632,16 @@ private fun MonthGridLoading() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String =
|
/** [abbreviated] swaps the full month name for its three-letter form (#165). */
|
||||||
|
private fun formatMonthTitle(
|
||||||
|
ym: YearMonth,
|
||||||
|
locale: Locale,
|
||||||
|
currentYear: Int,
|
||||||
|
abbreviated: Boolean = false,
|
||||||
|
): String =
|
||||||
formatCalendarTitle(
|
formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
|
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
skeleton = "LLLL",
|
skeleton = if (abbreviated) "LLL" else "LLLL",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
|||||||
*/
|
*/
|
||||||
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
||||||
if (col !in days.indices || lane !in 0 until laneCap) return null
|
if (col !in days.indices || lane !in 0 until laneCap) return null
|
||||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
|
spanAt(col, lane)?.let { return it.event }
|
||||||
val occupied = spans
|
val occupied = spans
|
||||||
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||||
.map { it.lane }
|
.map { it.lane }
|
||||||
@@ -91,8 +91,11 @@ fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
|||||||
* [col] for a single-day chip, and for an empty slot no one should be asking
|
* [col] for a single-day chip, and for an empty slot no one should be asking
|
||||||
* about.
|
* about.
|
||||||
*/
|
*/
|
||||||
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int =
|
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int = spanAt(col, lane)?.startCol ?: col
|
||||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.startCol ?: col
|
|
||||||
|
/** The bar covering lane [lane] of column [col], or null where a pill sits there. */
|
||||||
|
fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? =
|
||||||
|
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The events on [day] that [laneEvents] had no lane left for — its exact
|
* The events on [day] that [laneEvents] had no lane left for — its exact
|
||||||
@@ -136,6 +139,8 @@ sealed interface ContinuousMonthUiState {
|
|||||||
val monthsByIndex: Map<Int, List<MonthWeek>>,
|
val monthsByIndex: Map<Int, List<MonthWeek>>,
|
||||||
val weeksByIndex: Map<Int, MonthWeek>,
|
val weeksByIndex: Map<Int, MonthWeek>,
|
||||||
val weekStart: DayOfWeek,
|
val weekStart: DayOfWeek,
|
||||||
|
/** As [MonthUiState.Success.zone], and for the same reason. */
|
||||||
|
val zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||||
) : ContinuousMonthUiState
|
) : ContinuousMonthUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
||||||
@@ -238,6 +239,7 @@ class MonthViewModel @Inject constructor(
|
|||||||
monthsByIndex = months,
|
monthsByIndex = months,
|
||||||
weeksByIndex = weeks,
|
weeksByIndex = weeks,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
|
zone = zone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,14 +440,18 @@ internal fun layoutCalendarWeek(
|
|||||||
days = days,
|
days = days,
|
||||||
spans = spans,
|
spans = spans,
|
||||||
timedByDay = days.associateWith { d ->
|
timedByDay = days.associateWith { d ->
|
||||||
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
|
// Declined last, so a day that overflows drops the events you said
|
||||||
|
// no to before the ones you are actually going to (#230).
|
||||||
|
singles.filter { it.coversDay(d, zone) }
|
||||||
|
.sortedWith(compareBy<EventInstance> { it.isDeclined }.thenBy { it.start })
|
||||||
},
|
},
|
||||||
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every event touching each of [days], all-day first then by start time. Unlike
|
* Every event touching each of [days], declined ones last (#230), then all-day
|
||||||
|
* first and by start time within each group. Unlike
|
||||||
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
|
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
|
||||||
* they cover and applies no display cap, so the split style's day pane can list a
|
* they cover and applies no display cap, so the split style's day pane can list a
|
||||||
* date in full without querying the provider again.
|
* date in full without querying the provider again.
|
||||||
@@ -458,7 +464,11 @@ internal fun instancesByDay(
|
|||||||
days.associateWith { day ->
|
days.associateWith { day ->
|
||||||
instances
|
instances
|
||||||
.filter { it.coversDay(day, zone) }
|
.filter { it.coversDay(day, zone) }
|
||||||
.sortedWith(compareByDescending<EventInstance> { it.isAllDay }.thenBy { it.start })
|
.sortedWith(
|
||||||
|
compareBy<EventInstance> { it.isDeclined }
|
||||||
|
.thenByDescending { it.isAllDay }
|
||||||
|
.thenBy { it.start },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan
|
|||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
|
|||||||
@@ -591,15 +591,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable or disable [view] in the quick-switch cycle, via an atomic
|
* Enable or disable [view] in the quick-switch cycle, via an atomic
|
||||||
* read-modify-write so a concurrent reorder can't clobber it. The
|
* read-modify-write so a concurrent reorder can't clobber it. Any number of
|
||||||
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
|
* views may be turned off; below two the pill hides itself (#150).
|
||||||
* own guard reads an async-echoed snapshot.
|
|
||||||
*/
|
*/
|
||||||
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.updateQuickSwitch { config ->
|
prefs.updateQuickSwitch { config ->
|
||||||
val next = if (enabled) config.enabled + view else config.enabled - view
|
config.copy(
|
||||||
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
|
enabled = if (enabled) config.enabled + view else config.enabled - view,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
||||||
@@ -55,8 +54,8 @@ import java.time.format.TextStyle as JavaTextStyle
|
|||||||
* it belongs to, plus the two cross-view ordering lists (#24, #69).
|
* it belongs to, plus the two cross-view ordering lists (#24, #69).
|
||||||
*
|
*
|
||||||
* The quick-switch cycle and the drawer list are independent orders — a view
|
* The quick-switch cycle and the drawer list are independent orders — a view
|
||||||
* off in the cycle is still reachable from the drawer. The cycle needs at least
|
* off in the cycle is still reachable from the drawer, including when the cycle
|
||||||
* [QuickSwitchConfig.MIN_ENABLED] targets.
|
* is emptied and the pill disappears (#150).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ViewsScreen(
|
internal fun ViewsScreen(
|
||||||
@@ -221,8 +220,6 @@ internal fun ViewsScreen(
|
|||||||
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
// Turning a view off is blocked once only the minimum remain enabled.
|
|
||||||
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
|
|
||||||
ReorderableColumn(
|
ReorderableColumn(
|
||||||
items = config.order,
|
items = config.order,
|
||||||
keyOf = { it },
|
keyOf = { it },
|
||||||
@@ -238,8 +235,6 @@ internal fun ViewsScreen(
|
|||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = checked,
|
checked = checked,
|
||||||
// Keep the last two on: with fewer, the pill can't switch.
|
|
||||||
enabled = !checked || canDisable,
|
|
||||||
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -60,9 +59,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -72,7 +69,6 @@ import androidx.compose.ui.semantics.contentDescription
|
|||||||
import androidx.compose.ui.semantics.customActions
|
import androidx.compose.ui.semantics.customActions
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -81,6 +77,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||||
@@ -90,8 +89,13 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
@@ -103,23 +107,23 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
|
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
|
||||||
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
|
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
@@ -127,7 +131,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
|
import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
@@ -190,21 +193,9 @@ fun WeekScreen(
|
|||||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||||
}
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// The static header + all-day strip share the app bar's scrolled colour so
|
|
||||||
// the whole top region elevates together once the timeline scrolls under it.
|
|
||||||
val topSectionColor by animateColorAsState(
|
|
||||||
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surface
|
|
||||||
},
|
|
||||||
label = "week-top-section-color",
|
|
||||||
)
|
|
||||||
|
|
||||||
val isOnCurrentWeek = when (val s = state) {
|
val isOnCurrentWeek = when (val s = state) {
|
||||||
// True when today falls inside the displayed week — independent of which
|
// True when today falls inside the displayed week — independent of which
|
||||||
// weekday the user picked as the first day.
|
// weekday the user picked as the first day.
|
||||||
@@ -265,19 +256,19 @@ fun WeekScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
WeekTopBar(
|
WeekTopBar(
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onJumpToDate = jumpToDate,
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -298,7 +289,6 @@ fun WeekScreen(
|
|||||||
WeekContent(
|
WeekContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
topSectionColor = topSectionColor,
|
|
||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
@@ -318,7 +308,6 @@ fun WeekScreen(
|
|||||||
private fun WeekContent(
|
private fun WeekContent(
|
||||||
state: WeekUiState,
|
state: WeekUiState,
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
topSectionColor: Color,
|
|
||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
@@ -383,7 +372,6 @@ private fun WeekContent(
|
|||||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is WeekUiState.Success -> WeekSuccess(
|
is WeekUiState.Success -> WeekSuccess(
|
||||||
state = s,
|
state = s,
|
||||||
topSectionColor = topSectionColor,
|
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
@@ -410,7 +398,6 @@ private fun WeekContent(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun WeekSuccess(
|
internal fun WeekSuccess(
|
||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
topSectionColor: Color,
|
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
@@ -423,13 +410,19 @@ internal fun WeekSuccess(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(topSectionColor),
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
) {
|
) {
|
||||||
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
|
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
|
||||||
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
|
// Breathing room between the top section and the scrolling timeline
|
||||||
// scrolling timeline below.
|
// below.
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
@@ -449,20 +442,25 @@ private fun WeekTopBar(
|
|||||||
currentYear: Int,
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView>,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
|
||||||
) {
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
|
val (title, shortTitle) = remember(weekStart, locale, currentYear) {
|
||||||
|
formatWeekTitle(weekStart, locale, currentYear) to
|
||||||
|
formatWeekTitle(weekStart, locale, currentYear, abbreviated = true)
|
||||||
|
}
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
CalendarTitleButton(
|
CalendarTitleButton(
|
||||||
title = formatWeekTitle(weekStart, locale, currentYear),
|
title = title,
|
||||||
currentDate = weekStart,
|
currentDate = weekStart,
|
||||||
onJumpToDate = onJumpToDate,
|
onJumpToDate = onJumpToDate,
|
||||||
|
shortTitle = shortTitle,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -483,17 +481,17 @@ private fun WeekTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
|
cycle = quickSwitchViews,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
// Match the static top section exactly: plain surface, lifting to
|
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||||
// surfaceContainer once content scrolls under the bar.
|
// it, but here the bar meets the header on the same surface and the
|
||||||
|
// tint is what makes that seam look like a separate block (#186).
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,23 +651,25 @@ private fun AllDayBar(
|
|||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
val soften = LocalSoftenColors.current
|
val paint = eventPaint(event, dark)
|
||||||
val fill = eventFill(event.color, dark, soften)
|
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.background(fill, EventChipShape)
|
.eventSurface(paint, EventChipShape)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
|
val titleOverflow = eventTitleOverflow()
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = titleOverflow.overflow,
|
||||||
color = eventInk(fill),
|
softWrap = titleOverflow.softWrap,
|
||||||
textDecoration = declinedDecoration(event.isDeclined),
|
color = paint.titleInk,
|
||||||
|
fontWeight = paint.titleWeight,
|
||||||
|
textDecoration = paint.decoration,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -847,7 +847,7 @@ private fun DayColumnCard(
|
|||||||
.offset(x = place.x, y = place.y)
|
.offset(x = place.x, y = place.y)
|
||||||
.width(place.width)
|
.width(place.width)
|
||||||
.height(place.height)
|
.height(place.height)
|
||||||
.padding(horizontal = 1.dp),
|
.padding(horizontal = BLOCK_OUTER_INSET),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -883,48 +883,66 @@ private fun EventBlock(
|
|||||||
val timeLineHeight = with(density) {
|
val timeLineHeight = with(density) {
|
||||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||||
}
|
}
|
||||||
// What's left for text once the 2.dp top/bottom padding is paid for.
|
val available = height - BLOCK_TEXT_INSET * 2
|
||||||
val available = height - 4.dp
|
|
||||||
// Only full-width (non-overlapping) blocks that are tall enough show the
|
// Only full-width (non-overlapping) blocks that are tall enough show the
|
||||||
// time. On narrow overlapping columns we drop it so the title can wrap to
|
// time. On narrow overlapping columns we drop it so the title can wrap to
|
||||||
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
||||||
// afford both lines spends its space on the title.
|
// afford both lines spends its space on the title. Height decides that on
|
||||||
val showTime = block.endMin - block.startMin >= 45 &&
|
// its own: a duration threshold would keep hiding the time on a half-hour
|
||||||
block.laneCount == 1 &&
|
// block the user has pinched open to three times the room it needs.
|
||||||
|
val showTime = block.laneCount == 1 &&
|
||||||
available >= titleLineHeight + timeLineHeight
|
available >= titleLineHeight + timeLineHeight
|
||||||
|
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||||
// A short block drops the title rather than serving a horizontally sliced
|
// A short block drops the title rather than serving a horizontally sliced
|
||||||
// one: half a letter reads as a rendering fault, while a bare colour chip
|
// one: half a letter reads as a rendering fault, while a bare colour chip
|
||||||
// reads as what it is — an event too brief to label. Tap still opens it, and
|
// reads as what it is — an event too brief to label. Tap still opens it, and
|
||||||
// the semantics description carries the full title either way.
|
// the semantics description carries the full title either way.
|
||||||
val showTitle = available >= titleLineHeight
|
val showTitle = available >= titleLineHeight
|
||||||
// Wrap the title across as many lines as the block can fit — but only once a
|
// The title is served first, out of everything the block has left once the
|
||||||
// line is wide enough to hold more than a syllable. Below that the extra
|
// time is down to one line — but only takes the lines it will actually use,
|
||||||
// lines just stack fragments of the word, and one ellipsised line reads
|
// and only wraps at all once a line is wide enough to hold more than a
|
||||||
// better.
|
// syllable. Below that the extra lines just stack fragments of the word.
|
||||||
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
||||||
|
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||||
|
val paint = eventPaint(block.event, dark)
|
||||||
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
|
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
|
||||||
1
|
1
|
||||||
} else {
|
} else {
|
||||||
(contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
blockTextLines(
|
||||||
|
text = title,
|
||||||
|
// At the weight BlockTitle will set it in, or an invited block —
|
||||||
|
// drawn a weight lighter — is budgeted a line it never fills (#230).
|
||||||
|
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
|
||||||
|
textWidth = textWidth,
|
||||||
|
max = titleBudget,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
val spare = available - titleLineHeight * titleMaxLines -
|
||||||
|
if (showTime) timeLineHeight else 0.dp
|
||||||
|
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||||
val soften = LocalSoftenColors.current
|
|
||||||
val fill = eventFill(block.event.color, dark, soften)
|
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
val moveAction = eventMoveAction(block.event)
|
val moveAction = eventMoveAction(block.event)
|
||||||
val draggable = eventDragAllowed(block.event)
|
val draggable = eventDragAllowed(block.event)
|
||||||
// The drop takes this offset back off, so a tail clipped at midnight lands
|
// The drop takes this offset back off, so a tail clipped at midnight lands
|
||||||
// where the event's own start belongs (#253).
|
// where the event's own start belongs (#253).
|
||||||
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
||||||
val shape = remember(block, date, zone) {
|
val cuts = remember(block, date, zone) {
|
||||||
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
|
timedBlockCuts(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
|
||||||
}
|
}
|
||||||
|
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
|
||||||
val dragModifier = rememberEventDragSource(
|
val dragModifier = rememberEventDragSource(
|
||||||
enabled = draggable,
|
enabled = draggable,
|
||||||
key = block.event.instanceId,
|
key = block.event.instanceId,
|
||||||
onPickUp = { pointer, blockRoot, _ ->
|
onPickUp = { pointer, blockRoot, _ ->
|
||||||
dragController.begin(block, clipOffset, pointer, blockRoot)
|
dragController.begin(
|
||||||
|
block = block,
|
||||||
|
clipOffsetMin = clipOffset,
|
||||||
|
titleLines = if (showTitle) titleMaxLines else 0,
|
||||||
|
pointerInRoot = pointer,
|
||||||
|
blockInRoot = blockRoot,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onMove = dragController::move,
|
onMove = dragController::move,
|
||||||
onDrop = { dragController.finish()?.let(onDrop) },
|
onDrop = { dragController.finish()?.let(onDrop) },
|
||||||
@@ -936,12 +954,12 @@ private fun EventBlock(
|
|||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.background(fill, shape)
|
.eventSurface(paint, shape, cuts)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
// After clickable, so it is the inner node and wins the main pass;
|
// After clickable, so it is the inner node and wins the main pass;
|
||||||
// the tap still works, since a drag consumes the up.
|
// the tap still works, since a drag consumes the up.
|
||||||
.then(dragModifier)
|
.then(dragModifier)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = "$title, $timeLabel"
|
contentDescription = "$title, $timeLabel"
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
@@ -949,19 +967,20 @@ private fun EventBlock(
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (showTitle) {
|
if (showTitle) {
|
||||||
Text(
|
BlockTitle(
|
||||||
text = title,
|
title = title,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
maxLines = titleMaxLines,
|
maxLines = titleMaxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
textWidth = textWidth,
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
color = paint.titleInk,
|
||||||
textDecoration = declinedDecoration(block.event.isDeclined),
|
textDecoration = paint.decoration,
|
||||||
|
fontWeight = paint.titleWeight,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
BlockTimeLabel(
|
BlockTimeLabel(
|
||||||
label = timeLabel,
|
label = timeLabel,
|
||||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
color = paint.secondaryInk,
|
||||||
|
maxLines = timeMaxLines,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1033,13 +1052,18 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
|
|||||||
* "December" with no year would be actively misleading while the reader is
|
* "December" with no year would be actively misleading while the reader is
|
||||||
* looking straight at January dates.
|
* looking straight at January dates.
|
||||||
*/
|
*/
|
||||||
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String {
|
private fun formatWeekTitle(
|
||||||
|
weekStart: LocalDate,
|
||||||
|
locale: Locale,
|
||||||
|
currentYear: Int,
|
||||||
|
abbreviated: Boolean = false,
|
||||||
|
): String {
|
||||||
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||||
return formatCalendarTitle(
|
return formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
skeleton = "LLLL",
|
skeleton = if (abbreviated) "LLL" else "LLLL",
|
||||||
forceYear = weekEnd.year != weekStart.year,
|
forceYear = weekEnd.year != weekStart.year,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -144,7 +145,7 @@ class WeekViewModel @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Lay out all-day events as connected horizontal spans across the visible week.
|
* Lay out all-day events as connected horizontal spans across the visible week.
|
||||||
* Each event becomes one [AllDaySpan] from its first to its last covered column;
|
* Each event becomes one [AllDaySpan] from its first to its last covered column;
|
||||||
* overlapping spans are stacked on separate lanes (greedy first-fit by start).
|
* overlapping spans are stacked on separate lanes (greedy first-fit).
|
||||||
*/
|
*/
|
||||||
internal fun layoutAllDay(
|
internal fun layoutAllDay(
|
||||||
events: List<EventInstance>,
|
events: List<EventInstance>,
|
||||||
@@ -158,21 +159,35 @@ internal fun layoutAllDay(
|
|||||||
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
|
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
|
||||||
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
|
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
|
||||||
}
|
}
|
||||||
.sortedWith(compareBy({ it.startCol }, { it.endCol }))
|
// Declined bars are packed after every other one, so on any day they
|
||||||
|
// cover they land in a lane below it (#230).
|
||||||
|
.sortedWith(compareBy({ it.event.isDeclined }, { it.startCol }, { it.endCol }))
|
||||||
|
|
||||||
val laneEnd = ArrayList<Int>() // last occupied column per lane
|
// What each lane already holds, rather than just how far right it reaches.
|
||||||
|
// A single "last occupied column" only answers correctly while spans arrive
|
||||||
|
// in non-decreasing start order, which the declined-last rule above breaks:
|
||||||
|
// a Monday bar seated after a Wednesday one would be refused a lane it is
|
||||||
|
// nowhere near, and each wasted lane costs the all-day strip a whole row and
|
||||||
|
// pushes a bar closer to the month grid's MAX_EVENT_ROWS cap. Seven columns
|
||||||
|
// and a handful of bars, so the scan is cheaper than the sort above it.
|
||||||
|
val laneCols = ArrayList<MutableList<IntRange>>()
|
||||||
return raw.map { r ->
|
return raw.map { r ->
|
||||||
var lane = laneEnd.indexOfFirst { it < r.startCol }
|
val cols = r.startCol..r.endCol
|
||||||
|
var lane = laneCols.indexOfFirst { seated -> seated.none { it overlaps cols } }
|
||||||
if (lane == -1) {
|
if (lane == -1) {
|
||||||
laneEnd.add(r.endCol)
|
laneCols.add(mutableListOf(cols))
|
||||||
lane = laneEnd.size - 1
|
lane = laneCols.size - 1
|
||||||
} else {
|
} else {
|
||||||
laneEnd[lane] = r.endCol
|
laneCols[lane].add(cols)
|
||||||
}
|
}
|
||||||
AllDaySpan(r.event, r.startCol, r.endCol, lane)
|
AllDaySpan(r.event, r.startCol, r.endCol, lane)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether two column ranges share a column, so they cannot share a lane. */
|
||||||
|
private infix fun IntRange.overlaps(other: IntRange): Boolean =
|
||||||
|
first <= other.last && other.first <= last
|
||||||
|
|
||||||
/** Beginning of the week (at [weekStart]) that contains this date. */
|
/** Beginning of the week (at [weekStart]) that contains this date. */
|
||||||
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
|
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
|
||||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -47,7 +46,6 @@ internal fun WeekViewPreview(
|
|||||||
ScaledViewPreview(height = height, modifier = modifier) {
|
ScaledViewPreview(height = height, modifier = modifier) {
|
||||||
WeekSuccess(
|
WeekSuccess(
|
||||||
state = state,
|
state = state,
|
||||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = state.allDayStripHeight(),
|
allDayHeight = state.allDayStripHeight(),
|
||||||
dragController = rememberTimelineDragController(),
|
dragController = rememberTimelineDragController(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.agenda
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.month
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
|
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
|
||||||
<string name="state_failure_provider">Could not read the calendar.</string>
|
<string name="state_failure_provider">Could not read the calendar.</string>
|
||||||
|
|
||||||
|
<!-- Long-press a field to copy it (#195) -->
|
||||||
|
<string name="field_copy_action">Copy</string>
|
||||||
|
<string name="field_copied">Copied to clipboard</string>
|
||||||
|
<string name="field_copy_failed">Couldn\'t copy that</string>
|
||||||
|
|
||||||
<!-- Permission flow (F1) -->
|
<!-- Permission flow (F1) -->
|
||||||
<string name="permission_rationale_title">See all your events, beautifully</string>
|
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events.</string>
|
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events.</string>
|
||||||
@@ -188,6 +193,7 @@
|
|||||||
<string name="event_detail_all_day">All day</string>
|
<string name="event_detail_all_day">All day</string>
|
||||||
<string name="event_detail_calendar">Calendar</string>
|
<string name="event_detail_calendar">Calendar</string>
|
||||||
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
||||||
|
<string name="event_detail_title">Title</string>
|
||||||
<string name="event_detail_location">Location</string>
|
<string name="event_detail_location">Location</string>
|
||||||
<string name="event_detail_description">Description</string>
|
<string name="event_detail_description">Description</string>
|
||||||
<string name="event_detail_attendees">Attendees</string>
|
<string name="event_detail_attendees">Attendees</string>
|
||||||
@@ -498,7 +504,7 @@
|
|||||||
<string name="month_split_expand">Show the whole month</string>
|
<string name="month_split_expand">Show the whole month</string>
|
||||||
<string name="month_split_collapse">Show the day\'s events</string>
|
<string name="month_split_collapse">Show the day\'s events</string>
|
||||||
<string name="settings_quick_switch_header">Quick-switch button</string>
|
<string name="settings_quick_switch_header">Quick-switch button</string>
|
||||||
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
|
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. With fewer than two views turned on the button is hidden. Turned-off views stay reachable from the navigation menu.</string>
|
||||||
<string name="settings_drawer_order_header">Navigation menu</string>
|
<string name="settings_drawer_order_header">Navigation menu</string>
|
||||||
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
|
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
|
||||||
<string name="reorder_drag_handle">Drag to reorder</string>
|
<string name="reorder_drag_handle">Drag to reorder</string>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
@@ -109,4 +111,22 @@ class InstanceMapperTest {
|
|||||||
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse()
|
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unanswered invitation reads as invited, an answered one does not`() {
|
||||||
|
assertThat(
|
||||||
|
reader(selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_INVITED)
|
||||||
|
.toEventInstance()!!.response,
|
||||||
|
).isEqualTo(EventResponse.Invited)
|
||||||
|
mapOf(
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_NONE to EventResponse.Going,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED to EventResponse.Going,
|
||||||
|
// "Maybe" is still an answer, so it keeps the solid chip (#230).
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE to EventResponse.Going,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED to EventResponse.Declined,
|
||||||
|
).forEach { (status, expected) ->
|
||||||
|
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.response)
|
||||||
|
.isEqualTo(expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 keeps the ellipsis, since wrapping needs softWrap`() {
|
||||||
|
val result = eventTitleOverflowFor(rtl = false, singleLine = false)
|
||||||
|
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
|
||||||
|
assertThat(result.softWrap).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multi-line in RTL keeps the ellipsis too`() {
|
||||||
|
val result = eventTitleOverflowFor(rtl = true, singleLine = false)
|
||||||
|
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
|
||||||
|
assertThat(result.softWrap).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,4 +120,30 @@ class ViewBackStackTest {
|
|||||||
)
|
)
|
||||||
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a cycle can be emptied down to no views at all`() {
|
||||||
|
// #150: the settings screen no longer holds a floor, so every view may go.
|
||||||
|
val config = QuickSwitchConfig(order = IMPLEMENTED_VIEWS, enabled = emptySet())
|
||||||
|
assertThat(config.cycle).isEmpty()
|
||||||
|
assertThat(config.cycle.size).isLessThan(QuickSwitchConfig.MIN_CYCLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one enabled view is below the cycle minimum, so the pill hides`() {
|
||||||
|
// A single target is not a switch — it hides rather than becoming a
|
||||||
|
// jump-to-one-view button, which would be dead once you were there.
|
||||||
|
val config = QuickSwitchConfig(order = IMPLEMENTED_VIEWS, enabled = setOf(CalendarView.Day))
|
||||||
|
assertThat(config.cycle).containsExactly(CalendarView.Day)
|
||||||
|
assertThat(config.cycle.size).isLessThan(QuickSwitchConfig.MIN_CYCLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two enabled views are enough to show the pill`() {
|
||||||
|
val config = QuickSwitchConfig(
|
||||||
|
order = IMPLEMENTED_VIEWS,
|
||||||
|
enabled = setOf(CalendarView.Day, CalendarView.Month),
|
||||||
|
)
|
||||||
|
assertThat(config.cycle.size).isAtLeast(QuickSwitchConfig.MIN_CYCLE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -267,4 +268,70 @@ class MonthLayoutTest {
|
|||||||
assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th)
|
assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th)
|
||||||
assertThat(byDay.values.flatten()).isEmpty()
|
assertThat(byDay.values.flatten()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a declined event sorts behind every answered one on its day`() {
|
||||||
|
val day = LocalDate(2026, 6, 10)
|
||||||
|
// Earliest of the three, and all-day on top of that, so only the
|
||||||
|
// declined ordering can put it last.
|
||||||
|
val declined = allDay(day, id = 1L, title = "Declined")
|
||||||
|
.copy(response = EventResponse.Declined)
|
||||||
|
val morning = timed(day, 9, 10, id = 2L, title = "Morning")
|
||||||
|
val evening = timed(day, 18, 19, id = 3L, title = "Evening")
|
||||||
|
val events = listOf(declined, morning, evening)
|
||||||
|
|
||||||
|
assertThat(instancesByDay(listOf(day), events, zone).getValue(day).map { it.title })
|
||||||
|
.containsExactly("Morning", "Evening", "Declined").inOrder()
|
||||||
|
|
||||||
|
val timedOnly =
|
||||||
|
layoutCalendarWeek(weekOf8th, listOf(evening, morning, declinedTimed(day)), zone)
|
||||||
|
assertThat(timedOnly.timedByDay.getValue(day).map { it.title })
|
||||||
|
.containsExactly("Morning", "Evening", "Declined early").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a declined bar packed last still shares a lane it does not overlap`() {
|
||||||
|
// Sorting declined last means this bar is seated after one that reaches
|
||||||
|
// further right than it starts. A lane must still be offered on the
|
||||||
|
// columns it is actually free on, or the row grows a wasted rank.
|
||||||
|
val mon = weekOf8th[0]
|
||||||
|
val declined = allDay(mon, id = 1L, title = "Declined")
|
||||||
|
.copy(response = EventResponse.Declined)
|
||||||
|
val going = allDay(weekOf8th[2], weekOf8th[3], id = 2L, title = "Going")
|
||||||
|
val week = layoutCalendarWeek(weekOf8th, listOf(declined, going), zone)
|
||||||
|
|
||||||
|
assertThat(week.spans.map { it.lane }).containsExactly(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a declined all-day bar takes a lane below the ones you answered`() {
|
||||||
|
val day = LocalDate(2026, 6, 10)
|
||||||
|
val declined =
|
||||||
|
allDay(day, id = 1L, title = "Declined").copy(response = EventResponse.Declined)
|
||||||
|
val going = allDay(day, id = 2L, title = "Going")
|
||||||
|
// Declined listed first: only the sort, not the input order, may decide.
|
||||||
|
val week = layoutCalendarWeek(weekOf8th, listOf(declined, going), zone)
|
||||||
|
|
||||||
|
val lanes = week.spans.associate { it.event.title to it.lane }
|
||||||
|
assertThat(lanes.getValue("Going")).isLessThan(lanes.getValue("Declined"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `overlapping bars never share a lane, whatever order they arrive in`() {
|
||||||
|
val a = allDay(weekOf8th[0], weekOf8th[3], id = 1L, title = "A")
|
||||||
|
val b = allDay(weekOf8th[2], weekOf8th[5], id = 2L, title = "B")
|
||||||
|
val c = allDay(weekOf8th[3], weekOf8th[4], id = 3L, title = "C")
|
||||||
|
.copy(response = EventResponse.Declined)
|
||||||
|
val lanes = layoutCalendarWeek(weekOf8th, listOf(c, b, a), zone)
|
||||||
|
.spans.associate { it.event.title to it.lane }
|
||||||
|
|
||||||
|
assertThat(lanes.getValue("A")).isNotEqualTo(lanes.getValue("B"))
|
||||||
|
assertThat(lanes.getValue("B")).isNotEqualTo(lanes.getValue("C"))
|
||||||
|
assertThat(lanes.getValue("A")).isNotEqualTo(lanes.getValue("C"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A declined timed event starting before every other one in its test. */
|
||||||
|
private fun declinedTimed(date: LocalDate) =
|
||||||
|
timed(date, 7, 8, id = 4L, title = "Declined early")
|
||||||
|
.copy(response = EventResponse.Declined)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user