Compare commits

..

1 Commits

Author SHA1 Message Date
e35f330492 fix(deps): update glance to v1.2.0 2026-09-07 05:02:47 +00:00
57 changed files with 457 additions and 2263 deletions

View File

@@ -7,46 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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 hour lines in week and day view are now an hour grid.** The setting
that drew a faint separator line at each hour now seats every hour in its own
rounded cell, so an hour boundary reads as the seam between two surfaces
rather than a line drawn across one — the same negative space that separates
the month grid's days and the week's columns. An event that starts on the hour
fills its cell instead of overhanging the seam, and one that runs past
midnight still meets the edge of its column ([#113]).
- The calendar titles now shorten instead of being cut off. When the full month
name doesn't fit the top bar, the month and week views fall back to its
three-letter form and the day view drops the weekday, rather than trailing off
mid-word at large font sizes. The view switcher, the title and the agenda's
range bar also line up with the grid underneath them ([#165]).
- The jump-to-today button in the toolbar now shows today's date. It drew a
generic calendar icon, which told you nothing you didn't already know from
tapping it; it now carries the current day number in an outlined box, so the
bar says what day it is as well as taking you there. It rolls over at midnight
on its own ([#220]).
### Fixed
- **A long location no longer runs off the edge of its field.** The location on
the edit screen sat on one line, so a full postal address or a long meeting
link scrolled sideways out of sight as you typed it. It now wraps and the card
grows to fit, with the pin and the contacts button staying level with the first
line at any font size. A multi-line address you paste in is joined with commas
— the way one picked from your contacts always has been — instead of running
together into a single word ([#273]).
## [2.19.4] — 2026-09-01
### Fixed
@@ -1600,8 +1560,6 @@ automatically, with zero telemetry and no internet permission.
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164
[#165]: https://codeberg.org/jlmakiola/calendula/issues/165
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
@@ -1611,9 +1569,5 @@ automatically, with zero telemetry and no internet permission.
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
[#273]: https://codeberg.org/jlmakiola/calendula/issues/273
[#113]: https://codeberg.org/jlmakiola/calendula/issues/113
[#220]: https://codeberg.org/jlmakiola/calendula/issues/220

View File

@@ -31,7 +31,7 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -171,7 +171,7 @@ class MainActivity : AppCompatActivity() {
Box(modifier = Modifier.fillMaxSize()) {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalShowHourGrid provides settings.showHourGrid,
LocalShowHourLines provides settings.showHourLines,
LocalTimelineZoom provides timelineZoom,
LocalSoftenColors provides settings.softenColors,
) {

View File

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

View File

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

View File

@@ -212,15 +212,14 @@ class SettingsPrefs @Inject constructor(
}
/**
* Whether the week/day timeline seats each hour in its own cell (v2.11, a
* separator line until v2.20). Defaults to OFF — the historical flat column;
* users opt in. The stored key keeps its original name.
* Whether the week/day timeline draws a faint separator line at each hour
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
*/
val showHourGrid: Flow<Boolean> = store.data.map { prefs ->
val showHourLines: Flow<Boolean> = store.data.map { prefs ->
prefs[SHOW_HOUR_LINES_KEY] ?: false
}
suspend fun setShowHourGrid(enabled: Boolean) {
suspend fun setShowHourLines(enabled: Boolean) {
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
}

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -39,9 +40,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -50,8 +51,6 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
@@ -96,6 +95,7 @@ fun AgendaScreen(
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) }
@@ -130,16 +130,16 @@ fun AgendaScreen(
},
) {
Scaffold(
modifier = modifier,
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
AgendaTopBar(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = viewModel::goToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
@@ -159,24 +159,13 @@ fun AgendaScreen(
// One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s ->
// end lines the selector up with whatever ends the top bar:
// the view switcher's background, or — once #150 hides it —
// the search icon's glyph.
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
AppBarSpacing.Inset
} else {
AppBarSpacing.IconTrailingInset
}
Row(
verticalAlignment = Alignment.CenterVertically,
// end aligns the selector's right edge with the top-bar view
// switcher (its 8.dp margin + the app bar's 4.dp inset).
modifier = Modifier
.fillMaxWidth()
.padding(
start = RANGE_BAR_TEXT_INSET,
end = selectorEnd,
top = 8.dp,
bottom = 8.dp,
),
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
) {
AgendaRangeBanner(
range = s.range,
@@ -256,9 +245,6 @@ private fun AgendaRangePill(
}
}
/** Optical start inset for the range bar's text, set against the title above it. */
private val RANGE_BAR_TEXT_INSET = 28.dp
/**
* A header naming the concrete window currently shown under a "showing …" label,
* e.g. "27 Jun 2026" / "27 Jun 3 Jul 2026" / "June 2026". The range's name
@@ -423,22 +409,17 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
// A plain label rather than a CalendarTitleButton, so it takes that
// one's inset and one-line clamp itself.
Text(
text = stringResource(R.string.view_agenda),
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
)
},
navigationIcon = {
@@ -459,16 +440,14 @@ private fun AgendaTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
),
scrollBehavior = scrollBehavior,
)
}

View File

@@ -1,41 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.dp
/**
* Trailing-edge and title spacing shared by the four calendar app bars (#165).
*
* Only the trailing edge and the title's start are shared; the leading edge and
* the gaps between the action icons stay on M3's defaults, which derive both
* from the icon buttons' width.
*/
object AppBarSpacing {
/** Side inset shared by the bars' trailing edge and the month grid. */
val Inset = 8.dp
/**
* Start inset on the title, taking it clear of the navigation icon M3 places
* it flush against. Real padding rather than a negative offset, which would
* swallow taps meant for the menu button.
*/
val TitleInset = 8.dp
/** M3's own padding around the actions row. */
internal val BarPadding = 4.dp
private val IconButtonSize = 48.dp
/** M3's icon slot inside an icon button; the today glyph fills the same box. */
internal val IconSize = 24.dp
/**
* End padding for a container-backed control that ends the bar, measured to
* its background. Coerced because [androidx.compose.foundation.layout.padding]
* throws on a negative value.
*/
val ContainerTrailingInset = (Inset - BarPadding).coerceAtLeast(0.dp)
/** Screen edge to the glyph of an icon button that ends the bar. */
val IconTrailingInset = BarPadding + (IconButtonSize - IconSize) / 2
}

View File

@@ -10,137 +10,24 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/** Gap a timed block leaves to its neighbours in the column. */
val BLOCK_OUTER_INSET = 1.dp
/** Padding between a timed block's edge and its text. */
val BLOCK_TEXT_PADDING = 4.dp
/** The same, above and below — what a block's height has to pay before any text. */
val BLOCK_TEXT_INSET = 2.dp
/** Most lines a time label may wrap over before it is worth more than a title line. */
const val MAX_TIME_LINES = 2
/**
* Lines [text] needs to render whole at [textWidth], capped at [max].
*
* Lets a block hand out its height by what the text actually asks for rather
* than by what would fit: a title that wants one line should not be given three
* that the time label could have used, and a week column is narrower than a
* "09:3011:00" range so the range should not be assumed to want one.
*/
@Composable
fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int {
val measurer = rememberTextMeasurer()
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
return remember(text, style, widthPx, max, measurer) {
if (max <= 1 || widthPx <= 0) {
1
} else {
measurer.measure(
text = text,
style = style,
constraints = Constraints(maxWidth = widthPx),
).lineCount.coerceIn(1, max)
}
}
}
/**
* Lines the time label may take at [textWidth], out of the [spare] height left
* once the title and the label's own first line are paid for.
*
* A week column is narrower than a "09:3011:00" range, so the label takes a
* second line rather than lose its end — but only out of a line the title
* measured itself as not needing, never one it would have filled.
*/
@Composable
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
val timeLineHeight = with(LocalDensity.current) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
return if (spare >= timeLineHeight) {
blockTextLines(
text = label,
// The style it is drawn in, or the budget measures a line the label
// never uses (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
textWidth = textWidth,
max = MAX_TIME_LINES,
)
} else {
1
}
}
/**
* A timed block's title, over at most [maxLines], breaking at word boundaries.
*
* #164 clipped the last line mid-glyph so none of a narrow chip's few
* characters went on an ellipsis. On a block that can wrap, whole words read
* better than full lines do: "Farmers Market" over two lines beats "Farmer" /
* "s Marke". A single line still clips, having nowhere to wrap to.
*/
@Composable
fun BlockTitle(
title: String,
maxLines: Int,
color: Color,
modifier: Modifier = Modifier,
textDecoration: TextDecoration? = null,
fontWeight: FontWeight? = null,
) {
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
Text(
text = title,
modifier = modifier,
style = MaterialTheme.typography.labelMedium
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = color,
textDecoration = textDecoration,
)
}
/**
* A timed block's own time label, crossfaded rather than replaced — the block
* slides to its new slot, so the label shouldn't change in a single frame.
*
* Overflows like a title does (#164): the "…" costs two characters of a string
* that is nothing but characters, so the label clips at the block's edge
* instead. [maxLines] lets a narrow column spend spare height on the range
* rather than losing its end.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BlockTimeLabel(
label: String,
color: Color,
modifier: Modifier = Modifier,
maxLines: Int = 1,
) {
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
snap()
} else {
MaterialTheme.motionScheme.fastEffectsSpec()
}
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
Crossfade(
targetState = label,
animationSpec = spec,
@@ -149,11 +36,9 @@ fun BlockTimeLabel(
) { text ->
Text(
text = text,
// Regular weight against the title's medium above it (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = color,
)
}

View File

@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@@ -13,7 +12,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -21,8 +19,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate
@@ -35,9 +31,10 @@ import kotlinx.datetime.LocalDate
* [currentDate] seeds the picker with whatever the bar is currently naming (the
* visible day, week start or month anchor).
*
* [shortTitle] replaces [title] when the full one does not fit the width the app
* bar hands the title slot (#165). Either way the line clamps to one and
* ellipsises, so pass a [shortTitle] wherever there is something left to drop.
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
* start alignment: M3 places the title flush against the navigation icon's
* trailing edge, and the title is hit-tested on top of it, so a negative offset
* would swallow taps meant for the menu button.
*/
@Composable
fun CalendarTitleButton(
@@ -45,7 +42,6 @@ fun CalendarTitleButton(
currentDate: LocalDate,
onJumpToDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
shortTitle: String = title,
) {
var showDatePicker by rememberSaveable { mutableStateOf(false) }
@@ -57,27 +53,14 @@ fun CalendarTitleButton(
onClickLabel = stringResource(R.string.drawer_jump_to_date),
role = Role.Button,
) { showDatePicker = true }
.padding(horizontal = AppBarSpacing.TitleInset),
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val style = MaterialTheme.typography.titleLarge
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) {
val measurer = rememberTextMeasurer()
val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) {
titleFor(
title = title,
shortTitle = shortTitle,
titleWidth = measurer.measure(title, style).size.width,
availableWidth = constraints.maxWidth,
)
}
Text(
text = shown,
style = style,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f, fill = false),
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
@@ -96,11 +79,3 @@ fun CalendarTitleButton(
)
}
}
/** Falls back to [shortTitle] when [title] is wider than [availableWidth] (#165). */
internal fun titleFor(
title: String,
shortTitle: String,
titleWidth: Int,
availableWidth: Int,
): String = if (titleWidth <= availableWidth) title else shortTitle

View File

@@ -57,8 +57,7 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
* implemented view — the settings screen reorders the whole set — while [cycle]
* is the subset the pill actually steps through, in [order]. The navigation
* drawer keeps its own separate order and always lists every view, so a view
* disabled here stays reachable there — including when [cycle] is emptied and
* the pill disappears altogether.
* disabled here stays reachable there.
*/
data class QuickSwitchConfig(
val order: List<CalendarView>,
@@ -68,15 +67,14 @@ data class QuickSwitchConfig(
val cycle: List<CalendarView> get() = order.filter { it in enabled }
companion object {
/**
* Fewest views that keep the switch meaningful. A single target is not a
* switch, so below this the pill is hidden rather than special-cased (#150);
* the drawer still reaches every view.
*/
const val MIN_CYCLE = 2
/** All views, in default order, all enabled. */
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
/**
* Fewest views that keep the switch meaningful — a "switch" needs at
* least two targets, so the settings screen blocks disabling below this.
*/
const val MIN_ENABLED = 2
}
}

View File

@@ -1,104 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import android.content.ClipData
import android.os.Build
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.semantics
import de.jeanlucmakiola.calendula.R
import kotlinx.coroutines.launch
/** Puts one labelled field on the clipboard. */
fun interface FieldCopier {
operator fun invoke(label: String, text: String)
}
/**
* A [FieldCopier] that confirms the copy only where the system doesn't (#195).
*
* Android 13 raises its own clipboard chip for every copy, so a snackbar on top
* of it reads as the app having done the job twice. A failure is always worth a
* word, though: the clipboard can refuse a very long description outright.
*/
@Composable
fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val confirmation = stringResource(R.string.field_copied)
val failure = stringResource(R.string.field_copy_failed)
return remember(clipboard, scope, snackbarHostState, confirmation, failure) {
FieldCopier { label, text ->
scope.launch {
val message = runCatching {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, text)))
}.fold(
onSuccess = {
confirmation.takeIf {
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
}
},
onFailure = { failure },
)
if (message != null) {
// Copying twice in a row shouldn't queue two four-second
// confirmations — the newest one wins.
snackbarHostState.currentSnackbarData?.dismiss()
snackbarHostState.showSnackbar(message)
}
}
}
}
}
/**
* Long-press to copy [text] whole, filed on the clipboard under [label].
*
* [onTap] carries a field's existing tap action through; a field without one
* stays unclickable rather than growing a ripple that leads nowhere, and gets
* its long press announced through semantics instead. That branch merges the
* node it sits on, so a screen reader lands on the field itself and finds the
* action there — a bare container is never focused.
*/
@Composable
fun Modifier.copyOnLongPress(
label: String,
text: String,
copy: FieldCopier,
onTap: (() -> Unit)? = null,
): Modifier {
val actionLabel = stringResource(R.string.field_copy_action)
return if (onTap != null) {
combinedClickable(
onClick = onTap,
onLongClickLabel = actionLabel,
onLongClick = { copy(label, text) },
)
} else {
val haptics = LocalHapticFeedback.current
pointerInput(label, text, copy) {
detectTapGestures(
onLongPress = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
copy(label, text)
},
)
}.semantics(mergeDescendants = true) {
onLongClick(actionLabel) {
copy(label, text)
true
}
}
}
}

View File

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

View File

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

View File

@@ -1,37 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
/** How an event chip's title should overflow: what to pass to `Text`. */
data class EventTitleOverflow(val overflow: TextOverflow, val softWrap: Boolean)
/**
* Overflow for an event chip's title (#164). Chips are narrow enough that the
* "…" costs a couple of readable characters, so the title runs to the chip's
* edge and clips instead — mid-glyph on one line, at the last whole word it
* could fit on several.
*
* One case keeps the ellipsis: a single **[rtl]** line. With `softWrap` off
* Compose lays the line out at its full intrinsic width and clips to the node's
* left edge, which in RTL is the *end* of the string — an Arabic title would
* lose its beginning. The ellipsis truncates at the logical end in both
* directions. Wrapping needs `softWrap` on, which clips the right end either
* way, so more than one line needs no such exception.
*/
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
when {
!singleLine -> EventTitleOverflow(TextOverflow.Clip, softWrap = true)
rtl -> EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
else -> EventTitleOverflow(TextOverflow.Clip, softWrap = false)
}
/** [eventTitleOverflowFor] against the current layout direction. */
@Composable
fun eventTitleOverflow(singleLine: Boolean = true): EventTitleOverflow =
eventTitleOverflowFor(
rtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
singleLine = singleLine,
)

View File

@@ -1,83 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Whether the week/day timeline seats each hour in its own cell, from the
* `show_hour_lines` preference. Provided once at the app root (like
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
* Defaults to off — the historical flat column.
*/
val LocalShowHourGrid = staticCompositionLocalOf { false }
/** Gap between two hour cells, matching the month grid's gap between day cells. */
val HOUR_CELL_GAP = 2.dp
/** Half a gap: what a cell — and a block seated in it — gives up at each edge. */
val HOUR_CELL_INSET = HOUR_CELL_GAP / 2
/**
* The half-gap a block seated in the grid gives up at one of its ends, so an
* on-the-hour event fills its cell instead of overhanging the seam into its
* neighbours'. Zero with the grid off, and zero at an end [cut] at midnight:
* that edge is squared off against the column's own, and stopping a half-gap
* short of it would leave the block floating there rather than running off.
*/
fun hourCellBlockInset(show: Boolean, cut: Boolean): Dp =
if (show && !cut) HOUR_CELL_INSET else 0.dp
/**
* Corner radius of an hour cell: the event chip's own, so the grid never rounds
* harder than the blocks it seats. The month grid's 12dp belongs to a cell many
* times the size — on an hour cell it out-rounds its own content.
*/
private val HOUR_CELL_CORNER = EVENT_CHIP_CORNER
/**
* Radius an hour cell of [cellHeight] by [cellWidth] pixels may round to. The
* hour pitch runs from a fit-the-day sliver up to [MAX_PINCH_HOUR_HEIGHT] and a
* week column is a seventh of the screen, so the radius is held to half the
* shorter side — the point past which the corners would meet and the cell turn
* into a lozenge.
*/
internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Float): Float =
minOf(maxRadius, cellHeight / 2f, cellWidth / 2f).coerceAtLeast(0f)
/**
* Seat each of the day's 24 hours in its own rounded cell when [show] is true,
* so the hour boundary reads as a seam between two surfaces rather than a line
* drawn across one. Applied to a day column's content, so the cells sit over the
* column background but beneath the event blocks — blocks stay in a continuous
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
* height; [color] is resolved by the caller from the theme.
*/
fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
if (!show) {
this
} else {
drawBehind {
val inset = HOUR_CELL_INSET.toPx()
val cellHeight = hourHeightPx - inset * 2f
if (cellHeight <= 0f) return@drawBehind
val radius = CornerRadius(
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
)
val cellSize = Size(size.width, cellHeight)
for (hour in 0 until 24) {
drawRoundRect(
color = color,
topLeft = Offset(0f, hour * hourHeightPx + inset),
size = cellSize,
cornerRadius = radius,
)
}
}
}

View File

@@ -21,7 +21,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -73,11 +72,6 @@ fun HourGutter(
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
label = "hourLabelAlpha",
)
// Each label straddles the boundary it names, so it lines up with the seam
// the hour grid leaves there instead of hanging below it. Derived from the
// label's own line height, so it holds at any font scale.
val labelStyle = MaterialTheme.typography.labelSmall
val labelLift = with(LocalDensity.current) { labelStyle.lineHeight.toDp() } / 2
Box(
modifier = modifier
@@ -96,12 +90,12 @@ fun HourGutter(
if (h > 0) {
Text(
text = formatHourLabel(h, use24Hour, locale),
style = labelStyle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
.copy(alpha = hourAlpha),
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = -labelLift),
.offset(y = (-6).dp),
)
}
}

View File

@@ -0,0 +1,38 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
/**
* Whether the week/day timeline draws an hour separator line, from the
* `showHourLines` preference. Provided once at the app root (like
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
* Defaults to off — the historical clean look.
*/
val LocalShowHourLines = staticCompositionLocalOf { false }
/**
* Draw a faint separator line at the top of each hour (1..23) when [show] is
* true. Applied to a day column's content so each line sits over the column's
* background but beneath the event blocks. [hourHeightPx] is one hour's pixel
* height; [color] is resolved by the caller from the theme.
*/
fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
if (!show) {
this
} else {
drawBehind {
for (hour in 1 until 24) {
val y = hour * hourHeightPx
drawLine(
color = color,
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = 1f,
)
}
}
}

View File

@@ -12,7 +12,7 @@ import kotlin.time.Instant
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
* the "dim completed events" setting is on, or `null` when it is off (nothing
* dims). Provided per grid screen so only the event chips that read it recompose
* as the minute ticks, mirroring [LocalShowHourGrid] / [LocalUse24HourFormat].
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
*/
val LocalDimCutoff = compositionLocalOf<Instant?> { null }

View File

@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
@@ -25,9 +24,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
@@ -36,6 +34,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
@@ -84,13 +83,6 @@ data class TimelineDrag(
val eventSpanMin: Int,
/** The event as the grid would draw it, one piece per day column it covers. */
val pieces: List<TimelineDragPiece>,
/**
* Lines the block the finger picked up drew its title over, zero for one too
* short to have drawn it at all. The floating copy is that block's size, so
* it has to spend its height the same way or the text re-wraps under the
* finger and snaps back on drop (#267).
*/
val titleLines: Int,
/**
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
* every column this timeline shows. A hint under the finger, not where the
@@ -200,32 +192,6 @@ internal fun dragFloorMin(clipOffsetMin: Int, eventSpanMin: Int): Int =
0
}
/**
* Where one dragged piece's top edge sits below its column's top, and how tall
* it is drawn, at [hourPx] to the hour. [insetPx] is the half-gap a block seated
* in the hour grid gives up at each end, zero with the grid off — taken off the
* copy exactly as the grid takes it off the block, or the copy is a gap taller
* than the block it lifted off and spends the difference on a time label that
* block had no room for (#267). An end cut at midnight keeps none of it, as the
* block's own squared-off edge does.
*/
internal fun dragPieceBounds(
slice: DragSlice,
hourPx: Float,
insetPx: Float,
): DragPieceBounds {
val top = if (slice.continuesBefore) 0f else insetPx
val bottom = if (slice.continuesAfter) 0f else insetPx
val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
return DragPieceBounds(
top = slice.startMin / 60f * hourPx + top,
height = (full - top - bottom).coerceAtLeast(0f),
)
}
/** A piece's placement within its column — see [dragPieceBounds]. */
internal data class DragPieceBounds(val top: Float, val height: Float)
/** One day's share of a dragged event, before it is placed on screen. */
internal data class DragSlice(
val dayOffset: Int,
@@ -269,13 +235,6 @@ class TimelineGeometry {
var stepPx: Float = 0f
var days: List<LocalDate> = emptyList()
/**
* The half-gap a block gives up at each uncut end when the hour grid seats
* it in a cell, zero with the grid off. The floating copy gives up the same,
* so it is the size of the block it lifted off (#267).
*/
var blockInsetPx: Float = 0f
/**
* Whether the columns are laid out right-to-left. Pointer coordinates are
* never mirrored but the grid is, so the mapping has to flip with it.
@@ -354,9 +313,6 @@ class TimelineDragController {
*/
private var clipOffsetMin = 0
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
private var titleLines = 0
/**
* The slot the block already occupied when it was picked up — not the same
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
@@ -366,13 +322,11 @@ class TimelineDragController {
fun begin(
block: TimedBlock,
clipOffsetMin: Int,
titleLines: Int,
pointerInRoot: Offset,
blockInRoot: Offset,
) {
source = block
this.clipOffsetMin = clipOffsetMin
this.titleLines = titleLines
settling = null
isDragging = true
liftedInstanceId = block.event.instanceId
@@ -391,7 +345,6 @@ class TimelineDragController {
fun cancel() {
source = null
clipOffsetMin = 0
titleLines = 0
isDragging = false
liftedInstanceId = null
originSlot = null
@@ -514,22 +467,23 @@ class TimelineDragController {
startMin = startMin,
eventStartMin = eventStartMin,
eventSpanMin = eventSpan,
titleLines = titleLines,
// Bounded to the columns this timeline actually shows: a day it
// doesn't has no piece to draw. The write is unaffected.
pieces = slices
.map { slice ->
val day = dayIndex + slice.dayOffset
val col = if (geometry.isRtl) days.lastIndex - day else day
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
TimelineDragPiece(
topLeftInRoot = Offset(
x = origin.x + col * columnPx,
y = origin.y + bounds.top,
y = origin.y + slice.startMin / 60f * hourPx,
),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
bounds.height.roundToInt(),
maxOf(
slice.spanMin / 60f * hourPx,
MIN_EVENT_FRACTION * hourPx,
).roundToInt(),
),
continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter,
@@ -650,8 +604,8 @@ const val SETTLE_FADE_MILLIS: Int = 250
@Composable
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) }
val density = LocalDensity.current
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
@@ -701,7 +655,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "drag-handover",
)
val paint = eventPaint(drag.event, dark)
val fill = eventFill(drag.event.color, dark, soften)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
// 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
@@ -712,61 +666,22 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}" +
formatMinuteOfDay(endMin, use24Hour, locale)
// The timeline's own bounds. The copy is drawn over the whole calendar
// so no column clip or rounded corner cuts it, but it still belongs to
// the timeline: a piece a whole day tall reaches far past both ends of
// the viewport, and unclipped it paints over the headers above (#267).
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
// How much of each piece the viewport actually shows. A multi-day event
// has a piece per day, and the widest one is a full 24 hours — taller
// than the screen, its top at a midnight scrolled out of sight. Picking
// the range's piece by raw height put it there, off the top of the
// timeline; picking by *visible* height puts it where it can be read.
val shown = drag.pieces.map { piece ->
if (port == null) {
piece.sizePx.height.toFloat()
} else {
val top = maxOf(piece.topLeftInRoot.y, port.top)
val bottom = minOf(piece.topLeftInRoot.y + piece.sizePx.height, port.bottom)
bottom - top
}
}
val labelled = shown.indices.maxByOrNull { shown[it] }
Box(
modifier = if (port == null) {
Modifier
} else {
Modifier
.absoluteOffset {
IntOffset(
(port.left - origin.x).roundToInt(),
(port.top - origin.y).roundToInt(),
)
}
.size(
width = with(density) { port.width.toDp() },
height = with(density) { port.height.toDp() },
)
.clipToBounds()
},
) {
val pieceOrigin = port?.topLeft ?: origin
drag.pieces.forEachIndexed { index, piece ->
DragCopy(
topLeftInRoot = piece.topLeftInRoot,
overlayOrigin = pieceOrigin,
sizePx = piece.sizePx,
paint = paint,
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter),
lift = lift,
alpha = copyAlpha,
title = title,
titleLines = drag.titleLines,
// Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled },
)
}
// The tallest piece carries the range: on the smallest it would be
// clipped away, which is exactly the case when a short tail is held.
val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height }
drag.pieces.forEachIndexed { index, piece ->
DragCopy(
topLeftInRoot = piece.topLeftInRoot,
overlayOrigin = origin,
sizePx = piece.sizePx,
fill = fill,
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
lift = lift,
alpha = copyAlpha,
title = title,
// Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled },
)
}
}
}
@@ -777,54 +692,14 @@ private fun DragCopy(
topLeftInRoot: Offset,
overlayOrigin: Offset,
sizePx: IntSize,
paint: EventPaint,
fill: Color,
shape: RoundedCornerShape,
cuts: ChipCuts,
lift: Float,
alpha: Float,
title: String,
titleLines: Int,
label: String?,
) {
val density = LocalDensity.current
val width = with(density) { sizePx.width.toDp() }
val height = with(density) { sizePx.height.toDp() }
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// The block's size, spent the block's way — text sits at the top as it does
// on the block, so the copy hands back to the grid without shifting (#267).
// The title is served in full first and the range lives off what is left:
// the hour gutter down the side still says where the copy sits, so the
// range is the half that can afford to go.
val available = height - BLOCK_TEXT_INSET * 2
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(0)
val allowed = titleLines.coerceAtMost(titleBudget)
// Re-measured at the copy's own width rather than spent on the source's
// count: a block sharing its column with another is a lane wide where the
// copy is a whole column, so the source's second line is one the copy never
// draws — and reserving it took the range away with it (#267).
val lines = if (allowed <= 0) {
0
} else {
blockTextLines(
text = title,
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
textWidth = textWidth,
max = allowed,
)
}
val left = available - titleLineHeight * lines
val showTime = label != null && left >= timeLineHeight
val timeMaxLines = if (showTime) {
blockTimeLines(label!!, textWidth, left - timeLineHeight)
} else {
1
}
Box(
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
@@ -835,20 +710,12 @@ private fun DragCopy(
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
)
}
// Required: the parent is the viewport's size, and a plain `size`
// would let it cap a piece a whole day tall at one screen — drawn
// from a midnight scrolled off the top, that ended the column a
// scroll offset short of its own bottom (#267). The parent clips it.
.requiredSize(width = width, height = height)
.padding(horizontal = BLOCK_OUTER_INSET)
.size(
width = with(density) { sizePx.width.toDp() },
height = with(density) { sizePx.height.toDp() },
)
.padding(horizontal = 1.dp)
.graphicsLayer {
// Anchored at the top edge, not the middle: scaled about the
// centre, the 2% lift raises the top by 1% of the height — a few
// pixels on an hour, but a steady upward creep of the text as a
// dragged multi-day piece grows, and a shear against the
// timeline's clip. It snapped back when the lift animated out
// on drop (#267).
transformOrigin = TransformOrigin(0.5f, 0f)
scaleX = 1f + 0.02f * lift
scaleY = 1f + 0.02f * lift
shadowElevation = 8.dp.toPx() * lift
@@ -856,29 +723,24 @@ private fun DragCopy(
this.shape = shape
clip = false
}
.eventSurface(paint, shape, cuts)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET),
.background(fill, shape)
.padding(horizontal = 4.dp, vertical = 2.dp),
) {
Column {
if (lines > 0) {
BlockTitle(
title = title,
maxLines = lines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
)
}
if (showTime) {
val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
)
if (label != null) {
Text(
text = label,
// As the block it lifted off sets its own time (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
maxLines = timeMaxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = paint.secondaryInk,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
)
}
}

View File

@@ -96,6 +96,17 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
*/
const val MIN_EVENT_FRACTION = 26f / 60f
/**
* Narrowest an event block may be and still wrap its title over several lines.
*
* Wrapping is driven by the block's height, so a tall block on a lane-split
* column would otherwise stack two or three characters per line — "Da/ily",
* "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column
* clears this on any phone; a split one never does, while the day view's much
* wider columns keep wrapping even several lanes deep.
*/
val MIN_TITLE_WRAP_WIDTH = 36.dp
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
val FIT_DAY_MIN = 24.dp

View File

@@ -1,81 +1,26 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
private val GlyphShape = RoundedCornerShape(7.dp)
private val GlyphBorder = 1.5.dp
/**
* Height of the day number. Expressed in dp and converted to sp at draw time so
* it does not follow the font scale: this is an icon glyph sharing the bar with
* stroke icons, and a two-digit day that grew with the text would push the bar
* past the width #165 measured it to. The button's content description carries
* the meaning for screen readers, so nothing is lost by holding it still.
*/
private val GlyphTextHeight = 12.dp
/**
* The top-bar "jump to today" icon button, shared by every calendar view's app
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
* today control into the toolbar; renders nothing when [show] is false, so it
* drops straight into an app bar's `actions` slot without a wrapping condition.
*
* The glyph is the current day number in an outlined box rather than a generic
* calendar icon (#220), so the bar also says what day it is. Outlined rather
* than the month grid's filled circle, which reads as "this cell is today" and
* would sit heavier here than the stroke icons beside it.
*/
@Composable
fun TodayAction(show: Boolean, onToday: () -> Unit) {
if (!show) return
val now = rememberCurrentMinute()
// Only the day number is read, so the bar recomposes at midnight rather
// than on every minute tick.
val day by remember {
derivedStateOf { now.value.toLocalDateTime(TimeZone.currentSystemDefault()).date.day }
}
val description = stringResource(R.string.today_jump_action)
IconButton(
onClick = onToday,
modifier = Modifier.semantics { contentDescription = description },
) {
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
Box(
modifier = Modifier
.size(AppBarSpacing.IconSize)
.border(GlyphBorder, LocalContentColor.current, GlyphShape),
contentAlignment = Alignment.Center,
) {
Text(
text = day.toString(),
fontSize = textSize,
lineHeight = textSize,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = LocalContentColor.current,
)
}
IconButton(onClick = onToday) {
Icon(
imageVector = Icons.Default.Today,
contentDescription = stringResource(R.string.today_jump_action),
)
}
}

View File

@@ -1,39 +1,26 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
/**
* Top-bar pill that shows the current view and cycles to the next one on tap
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
*
* Renders nothing when [cycle] holds fewer than [QuickSwitchConfig.MIN_CYCLE]
* views (#150), so it drops straight into an app bar's `actions` slot without a
* wrapping condition — the same way [TodayAction] handles being turned off.
*
* [trailingInset] lands the pill's background on the bar's own rhythm; pass
* `0.dp` outside a top app bar, where M3's actions padding is not there to
* cancel (#165).
*/
@Composable
fun ViewSwitcherPill(
current: CalendarView,
cycle: List<CalendarView>,
onCycle: () -> Unit,
modifier: Modifier = Modifier,
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
) {
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
FilledTonalButton(
onClick = onCycle,
shape = MaterialTheme.shapes.large,
modifier = modifier.padding(end = trailingInset),
modifier = modifier,
) {
Text(stringResource(current.labelRes))
}

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -39,7 +40,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
@@ -53,8 +53,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
@@ -63,6 +65,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -70,9 +73,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
import de.jeanlucmakiola.calendula.ui.common.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
@@ -81,12 +81,7 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -99,12 +94,9 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
@@ -114,21 +106,25 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first
@@ -174,9 +170,21 @@ fun DayScreen(
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// The all-day strip shares the app bar's scrolled colour so the whole top
// region elevates together once the timeline scrolls under it.
val topSectionColor by animateColorAsState(
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surface
},
label = "day-top-section-color",
)
val isOnToday = when (val s = state) {
is DayUiState.Success -> s.date == s.today
else -> true
@@ -234,19 +242,19 @@ fun DayScreen(
},
) {
Scaffold(
modifier = modifier,
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
DayTopBar(
date = date,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
@@ -261,6 +269,7 @@ fun DayScreen(
DayContent(
state = state,
slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
@@ -278,6 +287,7 @@ fun DayScreen(
private fun DayContent(
state: DayUiState,
slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
@@ -338,6 +348,7 @@ private fun DayContent(
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is DayUiState.Success -> DaySuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
dragController = dragController,
@@ -363,6 +374,7 @@ private fun DayContent(
@Composable
internal fun DaySuccess(
state: DayUiState.Success,
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
dragController: TimelineDragController,
@@ -373,22 +385,16 @@ internal fun DaySuccess(
Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events,
// so the timeline sits directly under the app bar.
// This strip is painted on `surface`, so an outlined chip has to fill
// with that and not with the column's container (#230).
CompositionLocalProvider(
LocalChipGround provides MaterialTheme.colorScheme.surface,
) {
AllDayStrip(
state = state,
height = allDayHeight,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
)
}
// Breathing room between the top section and the scrolling timeline
// below.
AllDayStrip(
state = state,
height = allDayHeight,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.background(topSectionColor),
)
// Breathing room between the (colour-shifting) top section and the
// scrolling timeline below.
Spacer(Modifier.height(8.dp))
Timeline(
state = state,
@@ -408,25 +414,20 @@ private fun DayTopBar(
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
val locale = currentLocale()
val (title, shortTitle) = remember(date, locale, currentYear) {
formatDayTitle(date, locale, currentYear) to
formatDayTitle(date, locale, currentYear, abbreviated = true)
}
TopAppBar(
title = {
CalendarTitleButton(
title = title,
title = formatDayTitle(date, locale, currentYear),
currentDate = date,
onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
)
},
navigationIcon = {
@@ -447,17 +448,15 @@ private fun DayTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
),
scrollBehavior = scrollBehavior,
)
}
@@ -517,25 +516,23 @@ private fun AllDayBar(
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val paint = eventPaint(event, dark)
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
Box(
modifier = modifier
.eventSurface(paint, EventChipShape)
.background(fill, EventChipShape)
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = paint.titleInk,
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
}
}
@@ -553,12 +550,6 @@ private fun Timeline(
val zoom = LocalTimelineZoom.current
val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
// What the hour grid takes off a block's ends, for the floating copy to
// take off its own. Per-end, a cut edge keeps it — that much the drag
// geometry decides itself, from the slice it is drawing.
val blockInsetPx = with(density) {
hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx()
}
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
@@ -612,7 +603,6 @@ private fun Timeline(
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = 0f
it.columnWidthPx = coords.size.width.toFloat()
it.days = listOf(state.date)
@@ -639,8 +629,8 @@ private fun DayColumnCard(
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourGrid = LocalShowHourGrid.current
val zone = remember { TimeZone.currentSystemDefault() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
// Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks)
@@ -656,23 +646,16 @@ private fun DayColumnCard(
// rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape,
colors = CardDefaults.cardColors(
// With the grid on, the container colour moves onto the hour cells
// and the column behind them recedes to surface, so an hour boundary
// reads as negative space between two surfaces.
containerColor = if (showHourGrid) {
MaterialTheme.colorScheme.surface
} else {
MaterialTheme.colorScheme.surfaceContainer
},
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
modifier = modifier,
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// The hour cells sit over the column background but under the
// event blocks (drawBehind paints before the children).
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer)
// Faint hour separators sit over the column background but under
// the event blocks (drawBehind paints before the children).
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
// Tap an empty slot to create an event there. Taps on event
// blocks are consumed by their own click handler first, so this
// only fires on the column background. Snaps to the tapped hour.
@@ -704,34 +687,19 @@ private fun DayColumnCard(
width = laneWidth,
height = height,
)
// Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells.
val cuts = remember(block, date, zone) {
timedBlockCuts(
block.continuesBefore(date, zone),
block.continuesAfter(date, zone),
)
}
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
val blockHeight = (place.height - topInset - bottomInset)
.coerceAtLeast(0.dp)
EventBlock(
block = block,
dark = dark,
height = blockHeight,
width = place.width,
height = place.height,
date = date,
cuts = cuts,
topInset = topInset,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y + topInset)
.offset(x = place.x, y = place.y)
.width(place.width)
.height(blockHeight)
.padding(horizontal = BLOCK_OUTER_INSET),
.height(place.height)
.padding(horizontal = 1.dp),
)
}
}
@@ -748,10 +716,7 @@ private fun EventBlock(
block: TimedBlock,
dark: Boolean,
height: Dp,
width: Dp,
date: LocalDate,
cuts: ChipCuts,
topInset: Dp,
dragController: TimelineDragController,
onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit,
@@ -769,48 +734,29 @@ private fun EventBlock(
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// A block that cannot afford both lines spends its space on the title, and
// one too short even for that drops the title rather than serving a sliced one.
// Height alone decides: a duration threshold would keep hiding the time on a
// half-hour block the user has pinched open to three times the room it needs.
val available = height - BLOCK_TEXT_INSET * 2
val showTime = available >= titleLineHeight + timeLineHeight
// What's left for text once the 2.dp top/bottom padding is paid for. A block
// that cannot afford both lines spends its space on the title, and one too
// short even for that drops the title rather than serving a sliced one.
val available = height - 4.dp
val showTime = block.endMin - block.startMin >= 45 &&
available >= titleLineHeight + timeLineHeight
val showTitle = available >= titleLineHeight
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// Only lines the block can actually draw: a block too short for the time is
// too short for a second title line too, and asking for one served a sliced
// one — as well as handing the drag copy a count it couldn't honour, so the
// title re-wrapped the moment the block was lifted (#267).
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(1)
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
// On a day column — wide enough for "09:3011:00" several times over — the
// range never needs the second line, until lanes cut the column down.
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val paint = eventPaint(block.event, dark)
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event)
// The drop takes this offset back off, so a tail clipped at midnight lands
// where the event's own start belongs (#253).
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
val topInsetPx = with(density) { topInset.toPx() }
val shape = remember(block, date, zone) {
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
}
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ ->
dragController.begin(
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
// Measured off the block's placement rather than off where the
// hour grid seats it: the copy is drawn from that placement and
// re-seated the same half-gap down (#267).
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
)
dragController.begin(block, clipOffset, pointer, blockRoot)
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
@@ -822,12 +768,12 @@ private fun EventBlock(
modifier = modifier
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.eventSurface(paint, shape, cuts)
.background(fill, shape)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
@@ -835,19 +781,19 @@ private fun EventBlock(
) {
Column {
if (showTitle) {
BlockTitle(
title = title,
maxLines = titleMaxLines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
)
}
if (showTime) {
BlockTimeLabel(
label = timeLabel,
color = paint.secondaryInk,
maxLines = timeMaxLines,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
)
}
}
@@ -861,12 +807,7 @@ private fun DayLoading() {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's column
// doesn't resize the moment the real day arrives.
val hourHeight = scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The skeleton wears the loaded column's own ground, so the arrival of
// the real day doesn't flash a solid block into a gapped grid.
val showHourGrid = LocalShowHourGrid.current
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val totalHeight = scale.hourHeight(maxHeight) * 24
Row(
modifier = Modifier
.fillMaxSize()
@@ -879,11 +820,7 @@ private fun DayLoading() {
.weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(
if (showHourGrid) MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceContainer,
)
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer),
.background(MaterialTheme.colorScheme.surfaceContainer),
)
}
}
@@ -892,16 +829,10 @@ private fun DayLoading() {
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
formatMinuteOfDay(min, is24Hour, locale)
/** [abbreviated] drops the weekday, leaving the date itself (#165). */
private fun formatDayTitle(
date: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
locale = locale,
currentYear = currentYear,
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
skeleton = "EEEdMMM",
)

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -40,6 +41,7 @@ internal fun DayViewPreview(
ScaledViewPreview(height = height, modifier = modifier) {
DaySuccess(
state = state,
topSectionColor = MaterialTheme.colorScheme.surface,
scrollState = scrollState,
allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(),

View File

@@ -9,6 +9,7 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -97,9 +98,6 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
@@ -152,7 +150,6 @@ fun EventDetailScreen(
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
val copyField = rememberFieldCopier(snackbarHostState)
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
// an .ics in the cache and hands back a content Uri for the chooser.
@@ -303,7 +300,7 @@ fun EventDetailScreen(
reason = s.reason,
onRetry = viewModel::retry,
)
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
}
}
@@ -385,11 +382,7 @@ private fun DeleteEventDialog(
@Composable
private fun EventDetailContent(
state: EventDetailUiState.Success,
copyField: FieldCopier,
modifier: Modifier = Modifier,
) {
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
val detail = state.detail
val instance = detail.instance
val dark = isSystemInDarkTheme()
@@ -406,7 +399,6 @@ private fun EventDetailContent(
// event, so it's left implicit — only Free is worth surfacing. A
// cancelled event strikes through its title.
Row(verticalAlignment = Alignment.Top) {
val titleLabel = stringResource(R.string.event_detail_title)
Text(
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
style = MaterialTheme.typography.headlineMedium,
@@ -416,17 +408,7 @@ private fun EventDetailContent(
} else {
null
},
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
},
),
modifier = Modifier.weight(1f),
)
if (detail.availability == Availability.Free) {
Spacer(Modifier.width(12.dp))
@@ -534,11 +516,10 @@ private fun EventDetailContent(
// Location (conditional, tap → maps).
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
val context = LocalContext.current
val locationLabel = stringResource(R.string.event_detail_location)
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.Place,
iconContentDescription = locationLabel,
iconContentDescription = stringResource(R.string.event_detail_location),
) {
Text(
text = location,
@@ -546,12 +527,7 @@ private fun EventDetailContent(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.copyOnLongPress(
label = locationLabel,
text = location,
copy = copyField,
onTap = { openInMaps(context, location) },
)
.clickable { openInMaps(context, location) }
.padding(vertical = 2.dp),
)
}
@@ -559,15 +535,10 @@ private fun EventDetailContent(
// Description (conditional). URLs are auto-linked.
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
val descriptionLabel = stringResource(R.string.event_detail_description)
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.AutoMirrored.Filled.Notes,
iconContentDescription = 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),
iconContentDescription = stringResource(R.string.event_detail_description),
) {
Text(
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
@@ -640,14 +611,13 @@ private fun EventDetailContent(
private fun DetailCard(
icon: ImageVector,
iconContentDescription: String?,
modifier: Modifier = Modifier,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),

View File

@@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
@@ -91,7 +90,6 @@ import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.pluralStringResource
@@ -103,7 +101,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
@@ -791,59 +788,38 @@ private fun EventEditContent(
EditCard(
icon = Icons.Default.Place,
iconContentDescription = stringResource(R.string.event_detail_location),
iconAtTop = true,
) {
Row(verticalAlignment = Alignment.Top) {
Row(verticalAlignment = Alignment.CenterVertically) {
InlineField(
value = form.location,
onValueChange = viewModel::setLocation,
placeholder = stringResource(R.string.event_detail_location),
// A location is as often a meeting URL as an address,
// and "Zoom.us/j/123" reads wrong (#146).
capitalization = KeyboardCapitalization.None,
// Multi-line so a long address or meeting URL wraps and
// the card grows instead of scrolling off one line
// (#273). The field holds no newlines of its own, so
// the IME's action key closes the keyboard rather than
// inserting a break setLocation would only fold away.
singleLine = false,
imeAction = ImeAction.Done,
modifier = Modifier
.weight(1f)
.animateContentSizeMotion()
.padding(vertical = 4.dp),
)
// The box is exactly one text line tall, so the button
// centres on the first line without making a single-line
// card taller than its text; requiredSize keeps the
// button's own 40dp ripple.
Box(
modifier = Modifier.height(firstLineHeight()),
contentAlignment = Alignment.Center,
IconButton(
onClick = {
runCatching {
pickContactAddress.launch(
Intent(
Intent.ACTION_PICK,
ContactsContract.CommonDataKinds.StructuredPostal
.CONTENT_URI,
),
)
}
},
modifier = Modifier.size(40.dp),
) {
IconButton(
onClick = {
runCatching {
pickContactAddress.launch(
Intent(
Intent.ACTION_PICK,
ContactsContract.CommonDataKinds.StructuredPostal
.CONTENT_URI,
),
)
}
},
modifier = Modifier.requiredSize(40.dp),
) {
Icon(
imageVector = Icons.Default.Contacts,
contentDescription = stringResource(
R.string.event_edit_location_from_contacts,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
Icon(
imageVector = Icons.Default.Contacts,
contentDescription = stringResource(
R.string.event_edit_location_from_contacts,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
}
@@ -2013,8 +1989,8 @@ private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>?
/**
* Read the formatted postal address from a contact-pick result URI. No
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
* The provider's formatted address is multi-line; collapse it to one comma-
* separated line, which is what the location field holds.
* The provider's formatted address is multi-line; collapse it to one line so
* it sits cleanly in the single-line location field.
*/
private fun readContactAddress(context: Context, uri: Uri): String? =
context.contentResolver.query(
@@ -2198,19 +2174,14 @@ private fun EditCard(
modifier = Modifier.padding(16.dp),
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
) {
// A top-aligned icon centres on the first text line, whatever
// that line measures at the user's font scale.
val iconOffset = if (iconAtTop) {
((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp)
} else {
0.dp
}
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = iconTint,
// 4dp mirrors InlineField's vertical padding, so a
// top-aligned icon (24dp) centres on the ~24sp first line.
modifier = Modifier
.padding(top = iconOffset)
.padding(top = if (iconAtTop) 4.dp else 0.dp)
.size(24.dp),
)
Spacer(Modifier.width(16.dp))
@@ -2252,9 +2223,7 @@ private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Lo
/**
* Borderless text input used inside the cards (and as the headline title).
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
* the app share one input style. Sentence-case by default, as floret-kit is;
* a field holding an identifier rather than prose passes
* [KeyboardCapitalization.None].
* the app share one input style.
*/
@Composable
private fun InlineField(
@@ -2266,8 +2235,6 @@ private fun InlineField(
minLines: Int = 1,
enabled: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
imeAction: ImeAction = ImeAction.Default,
modifier: Modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
@@ -2282,25 +2249,10 @@ private fun InlineField(
minLines = minLines,
enabled = enabled,
keyboardType = keyboardType,
capitalization = capitalization,
imeAction = imeAction,
capitalization = KeyboardCapitalization.None,
)
}
/**
* Height of one [InlineField] line: the resolved [TextStyle.lineHeight] plus the
* field's 4dp vertical padding. In dp, so it tracks the user's font scale
* instead of assuming the 24sp line of a 1.0 scale (#165).
*/
@Composable
private fun firstLineHeight(
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
): Dp {
val lineHeight = textStyle.lineHeight
if (!lineHeight.isSp) return 32.dp
return with(LocalDensity.current) { lineHeight.toDp() } + 8.dp
}
/** One schedule row: label, then tappable date and (unless all-day) time. */
@Composable
private fun ScheduleRow(

View File

@@ -59,9 +59,6 @@ import javax.inject.Inject
private const val TAG = "EventEdit"
/** Any line break, with the whitespace around it, as one match. */
private val LINE_BREAK = Regex("""[ \t]*\R[ \t]*""")
/**
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
* want different reminder handling (#49), and differ in whether they own the
@@ -535,11 +532,7 @@ class EventEditViewModel @Inject constructor(
// paste would introduce, so it never reaches the provider's TITLE column.
fun setTitle(value: String) =
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
// The location wraps too (#273) but is likewise one logical line. A pasted
// multi-line address joins the way the contact picker's does (#146), so it
// never runs together into "12 Main St10115 Berlin".
fun setLocation(value: String) =
update { it.copy(location = value.replace(LINE_BREAK, ", ")) }
fun setLocation(value: String) = update { it.copy(location = value) }
fun setDescription(value: String) = update { it.copy(description = value) }
fun setAllDay(value: Boolean) {
// Going all-day drops any pinned zone: the times become bare dates that

View File

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

View File

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

View File

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

View File

@@ -88,13 +88,9 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.geometry.Offset
import de.jeanlucmakiola.calendula.ui.common.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface
import de.jeanlucmakiola.calendula.ui.common.monthBarCuts
import kotlin.math.roundToInt
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -117,6 +113,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
@@ -126,7 +123,6 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -138,7 +134,6 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
@@ -147,13 +142,14 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.monthBarShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
@@ -216,6 +212,7 @@ fun MonthScreen(
derivedStateOf { if (dimCompleted) nowState.value else null }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
@@ -301,14 +298,10 @@ fun MonthScreen(
// carries the year instead of repeating it two lines further down. Dense has
// no header to defer to, so it keeps the full title.
val locale = currentLocale()
val (topBarTitle, topBarShortTitle) = remember(viewStyle, titleMonth, locale, today.year) {
if (viewStyle == MonthViewStyle.Continuous) {
val year = titleMonth.year.toString()
year to year
} else {
formatMonthTitle(titleMonth, locale, currentYear = today.year) to
formatMonthTitle(titleMonth, locale, currentYear = today.year, abbreviated = true)
}
val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) {
titleMonth.year.toString()
} else {
formatMonthTitle(titleMonth, locale, currentYear = today.year)
}
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
@@ -393,20 +386,19 @@ fun MonthScreen(
},
) {
Scaffold(
modifier = modifier,
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MonthTopBar(
title = topBarTitle,
shortTitle = topBarShortTitle,
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
@@ -502,7 +494,6 @@ private fun MonthDragOverlay(controller: MonthDragController) {
var origin by remember { mutableStateOf(Offset.Zero) }
val dark = isSystemInDarkTheme()
val density = LocalDensity.current
val timeChipWidth = rememberMonthTimeChipWidth()
val reduceMotion = rememberReduceMotion()
val moveInFlight = moveInFlight()
// Here rather than beside the controller: this reads [drag], which changes
@@ -560,11 +551,6 @@ private fun MonthDragOverlay(controller: MonthDragController) {
dark = dark,
continuesLeft = false,
continuesRight = false,
// The time the source chip carried, drawn only if this one-column
// cut-out of it has the room — which a slice of a multi-day bar has
// not, however wide the bar was.
time = drag.time,
showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth,
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout.
@@ -577,7 +563,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
}
.width(with(density) { drag.sizePx.width.toDp() })
.height(with(density) { drag.sizePx.height.toDp() })
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
.graphicsLayer {
scaleX = 1f + 0.04f * lift
scaleY = 1f + 0.04f * lift
@@ -678,16 +664,15 @@ private fun ContinuousMonthContent(
@Composable
private fun MonthTopBar(
title: String,
shortTitle: String,
titleDate: LocalDate,
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
@@ -695,7 +680,6 @@ private fun MonthTopBar(
title = title,
currentDate = titleDate,
onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
)
},
navigationIcon = {
@@ -716,17 +700,11 @@ private fun MonthTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
scrollBehavior = scrollBehavior,
)
}
@@ -741,7 +719,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
// Reserve the gutter so the weekday labels stay over their day columns.
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
@@ -767,6 +745,7 @@ private val DAY_NUMBER_HEIGHT = 22.dp
private val WEEK_NUMBER_GUTTER = 40.dp
private val DAY_NUMBER_GAP = 4.dp
private val CELL_TOP_PADDING = 6.dp
private val CELL_GAP = 2.dp
/** Named separately because the split style's selection outline draws its own
* rounded rect and has to match this radius exactly. */
private val CELL_CORNER = 12.dp
@@ -808,22 +787,17 @@ internal fun MonthGrid(
Column(
modifier = Modifier
.fillMaxSize()
// Match the weekday header's inset so day cells sit under their
// Match the weekday header's 8dp inset so day cells sit under their
// labels, and so the week-number gutter's centre lines up with the
// top bar's hamburger (4dp bar inset + 24dp half icon button).
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
val month = state.month
// Once per grid: the value depends on the typography, the density, the
// locale and the 24-hour setting, none of which vary by row (#219).
val timeChipWidth = rememberMonthTimeChipWidth()
state.weeks.forEach { week ->
MonthWeekRow(
week = week,
today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
@@ -863,7 +837,6 @@ internal fun ContinuousMonthGrid(
) {
val monthCount = remember { continuousMonthCount() }
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) }
val timeChipWidth = rememberMonthTimeChipWidth()
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
@@ -885,8 +858,6 @@ internal fun ContinuousMonthGrid(
weeks = state.monthsByIndex[index],
weekStart = state.weekStart,
today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
@@ -907,8 +878,6 @@ private fun ContinuousMonthBlock(
weeks: List<MonthWeek>?,
weekStart: DayOfWeek,
today: LocalDate,
zone: TimeZone,
timeChipWidth: Dp,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
@@ -917,7 +886,7 @@ private fun ContinuousMonthBlock(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppBarSpacing.Inset)
.padding(horizontal = 8.dp)
.padding(bottom = CONTINUOUS_MONTH_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
@@ -928,8 +897,6 @@ private fun ContinuousMonthBlock(
MonthWeekRow(
week = week,
today = today,
zone = zone,
timeChipWidth = timeChipWidth,
inMonth = { it.month == month.month && it.year == month.year },
// The block owns its month alone: a day from either
// neighbour is left out entirely rather than dimmed.
@@ -999,7 +966,6 @@ internal fun DenseMonthGrid(
modifier: Modifier = Modifier,
) {
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
val timeChipWidth = rememberMonthTimeChipWidth()
LazyColumn(
state = listState,
modifier = modifier
@@ -1008,7 +974,7 @@ internal fun DenseMonthGrid(
// padding: a lazy list scrolls its rows *through* the before-content
// padding, so scrolling to a week landed its row that far down with
// the tail of the previous one showing above it (#191).
.padding(horizontal = AppBarSpacing.Inset)
.padding(horizontal = 8.dp)
.padding(top = DENSE_HEADER_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
// Bottom inset clears the FAB stack so the last row stays tappable.
@@ -1022,8 +988,6 @@ internal fun DenseMonthGrid(
MonthWeekRow(
week = week,
today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
// Every day in the stream belongs to a month equally — there
// is no "other month" to recede here.
inMonth = { true },
@@ -1477,7 +1441,7 @@ internal fun SplitMonthGrid(
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
state.weeks.forEach { week ->
@@ -1842,7 +1806,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = AppBarSpacing.Inset)
.padding(horizontal = 8.dp)
.clipToBounds(),
) {
if (!dense) {
@@ -1909,10 +1873,6 @@ private fun rememberSkeletonPulse(): Float {
private fun MonthWeekRow(
week: MonthWeek,
today: LocalDate,
/** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */
zone: TimeZone,
/** The narrowest chip that may carry a start time, measured once per grid (#219). */
timeChipWidth: Dp,
inMonth: (LocalDate) -> Boolean,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
@@ -1932,27 +1892,6 @@ private fun MonthWeekRow(
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
val morphing = morphInFlight()
// Every chip's start time for this row at once, and only when the row's own
// inputs change: formatting is a parsed pattern per call, and the dim cutoff
// ticks every minute while a drag recomposes every frame (#219).
val is24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val chipTimes = remember(week, zone, is24Hour, locale) {
buildMap {
week.spans.forEach { span ->
put(
span.event.instanceId,
monthChipTime(span.event, span.continuesLeft, zone, is24Hour, locale),
)
}
week.timedByDay.values.flatten().forEach { event ->
put(
event.instanceId,
monthChipTime(event, continuesLeft = false, zone, is24Hour, locale),
)
}
}
}
// Drag to reschedule (#68). The chips can take no pointer input of their own
// — the full-bleed tap layer sits on top of them — so one detector on the
@@ -2027,12 +1966,9 @@ private fun MonthWeekRow(
band = bandCoordinates,
rowHeightPx = rowHeightPx,
isRtl = isRtl,
chipTimes = chipTimes,
),
),
) {
// What a chip has to spend, against the [timeChipWidth] a start time
// costs it (#219).
val colW = maxWidth / 7
// Per-day background pills — same surfaceContainer rounded surface the
@@ -2113,8 +2049,6 @@ private fun MonthWeekRow(
continuesLeft = span.continuesLeft,
continuesRight = span.continuesRight,
days = week.days.subList(span.startCol, span.endCol + 1),
time = chipTimes[span.event.instanceId],
showTime = colW * cols >= timeChipWidth,
modifier = Modifier
.offset(
x = colW * span.startCol,
@@ -2138,7 +2072,7 @@ private fun MonthWeekRow(
)
.width(colW * cols)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
// One invisible slice of the bar per further column it
// covers. A multi-day event has a dot on every day but
@@ -2162,7 +2096,7 @@ private fun MonthWeekRow(
)
.width(colW)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
}
}
@@ -2185,8 +2119,6 @@ private fun MonthWeekRow(
continuesLeft = false,
continuesRight = false,
days = listOf(d),
time = chipTimes[ev.instanceId],
showTime = colW >= timeChipWidth,
modifier = Modifier
.offset(
x = colW * col,
@@ -2195,7 +2127,7 @@ private fun MonthWeekRow(
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
.width(colW)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
}
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
@@ -2347,9 +2279,6 @@ private fun monthChipDragModifier(
band: Array<LayoutCoordinates?>,
rowHeightPx: Float,
isRtl: Boolean,
/** The row's formatted chip times by instance id, so the copy carries the
* one its source chip had rather than deriving another (#219). */
chipTimes: Map<Long, String?>,
): Modifier = rememberDragSurface(
enabled = moveScope?.dragEnabled == true && controller != null,
key = week.days.first(),
@@ -2378,7 +2307,6 @@ private fun monthChipDragModifier(
y = requireNotNull(bandTop) + lane * rowHeightPx,
),
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
time = chipTimes[event.instanceId],
)
true
}
@@ -2486,10 +2414,7 @@ private fun shortMonthName(date: LocalDate): String {
}
}
/**
* A filled event pill/bar — softened (or raw) fill, title clipped to one line,
* with the start time before it where the chip is wide enough ([showTime], #219).
*/
/** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */
@Composable
private fun MonthBar(
event: de.jeanlucmakiola.calendula.domain.EventInstance,
@@ -2503,26 +2428,12 @@ private fun MonthBar(
* the provider hands the re-read instance a new one.
*/
days: List<LocalDate>? = null,
/** The chip's start time, or null where it has none — see `monthChipTime` (#219). */
time: String? = null,
/** Whether this chip has the width to draw [time]; a screen reader gets it either way. */
showTime: Boolean = false,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val paint = eventPaint(event, dark)
// The same title/secondary ink pairing the week and day blocks use, with
// the time on the quieter half.
val label = inlineTimeLabel(
time = time.takeIf { showTime },
title = title,
timeInk = paint.secondaryInk,
)
// Announced whether or not it is drawn, and comma-separated as the week and
// day blocks do it: what a screen reader hears shouldn't turn on how wide
// the chip happens to be (#219).
val description = if (time != null) "$title, $time" else title
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
val moveAction = eventMoveAction(event)
// The source stays put as a ghost while its floating copy travels.
val monthDrag = LocalMonthDrag.current
@@ -2533,24 +2444,21 @@ private fun MonthBar(
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.eventSurface(paint, shape, monthBarCuts(continuesLeft, continuesRight))
.padding(horizontal = MONTH_CHIP_TEXT_PADDING)
.background(fill, shape)
.padding(horizontal = 4.dp)
.semantics {
contentDescription = description
contentDescription = title
if (moveAction != null) customActions = listOf(moveAction)
},
contentAlignment = Alignment.CenterStart,
) {
val titleOverflow = eventTitleOverflow()
Text(
text = label,
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = paint.titleInk,
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
}
}
@@ -2606,7 +2514,7 @@ private fun MonthGridLoading() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
repeat(6) {
@@ -2632,16 +2540,10 @@ private fun MonthGridLoading() {
}
}
/** [abbreviated] swaps the full month name for its three-letter form (#165). */
private fun formatMonthTitle(
ym: YearMonth,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String =
formatCalendarTitle(
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
locale = locale,
currentYear = currentYear,
skeleton = if (abbreviated) "LLL" else "LLLL",
skeleton = "LLLL",
)

View File

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

View File

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

View File

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

View File

@@ -32,8 +32,8 @@ data class SettingsUiState(
val weekStart: WeekStartPref = WeekStartPref.Auto,
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
/** Whether the week/day timeline seats each hour in its own cell (v2.11). */
val showHourGrid: Boolean = false,
/** Whether the week/day timeline draws an hour separator line (v2.11). */
val showHourLines: Boolean = false,
/** How the Agenda screen treats events that already ended today. */
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
/** Whether the month/week grids fade events that have already finished. */

View File

@@ -141,7 +141,7 @@ class SettingsViewModel @Inject constructor(
// Display toggles folded into one flow so they fit this group —
// the outer combine is already at its five-arg limit.
combine(
prefs.showHourGrid,
prefs.showHourLines,
prefs.showWeekNumbers,
prefs.agendaShowToday,
prefs.softenCalendarColors,
@@ -158,7 +158,7 @@ class SettingsViewModel @Inject constructor(
) { view, screenRange, widgetRange, timeFormat, toggles ->
ViewSettings(
view, screenRange, widgetRange, timeFormat,
showHourGrid = toggles.showHourGrid,
showHourLines = toggles.showHourLines,
showWeekNumbers = toggles.showWeekNumbers,
agendaShowToday = toggles.agendaShowToday,
softenColors = toggles.softenColors,
@@ -191,7 +191,7 @@ class SettingsViewModel @Inject constructor(
agendaScreenRange = views.agendaScreenRange,
agendaWidgetRange = views.agendaWidgetRange,
timeFormat = views.timeFormat,
showHourGrid = views.showHourGrid,
showHourLines = views.showHourLines,
showWeekNumbers = views.showWeekNumbers,
agendaShowToday = views.agendaShowToday,
softenColors = views.softenColors,
@@ -292,7 +292,7 @@ class SettingsViewModel @Inject constructor(
val agendaScreenRange: AgendaRange,
val agendaWidgetRange: AgendaRange,
val timeFormat: TimeFormatPref,
val showHourGrid: Boolean,
val showHourLines: Boolean,
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
@@ -301,7 +301,7 @@ class SettingsViewModel @Inject constructor(
)
private data class DisplayToggles(
val showHourGrid: Boolean,
val showHourLines: Boolean,
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
@@ -532,8 +532,8 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setTimeFormat(pref) }
}
fun setShowHourGrid(enabled: Boolean) {
viewModelScope.launch { prefs.setShowHourGrid(enabled) }
fun setShowHourLines(enabled: Boolean) {
viewModelScope.launch { prefs.setShowHourLines(enabled) }
}
fun setShowWeekNumbers(enabled: Boolean) {
@@ -591,15 +591,15 @@ class SettingsViewModel @Inject constructor(
/**
* Enable or disable [view] in the quick-switch cycle, via an atomic
* read-modify-write so a concurrent reorder can't clobber it. Any number of
* views may be turned off; below two the pill hides itself (#150).
* read-modify-write so a concurrent reorder can't clobber it. The
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
* own guard reads an async-echoed snapshot.
*/
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
viewModelScope.launch {
prefs.updateQuickSwitch { config ->
config.copy(
enabled = if (enabled) config.enabled + view else config.enabled - view,
)
val next = if (enabled) config.enabled + view else config.enabled - view
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
}
}
}

View File

@@ -32,6 +32,7 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
@@ -54,8 +55,8 @@ import java.time.format.TextStyle as JavaTextStyle
* it belongs to, plus the two cross-view ordering lists (#24, #69).
*
* The quick-switch cycle and the drawer list are independent orders — a view
* off in the cycle is still reachable from the drawer, including when the cycle
* is emptied and the pill disappears (#150).
* off in the cycle is still reachable from the drawer. The cycle needs at least
* [QuickSwitchConfig.MIN_ENABLED] targets.
*/
@Composable
internal fun ViewsScreen(
@@ -170,11 +171,11 @@ internal fun ViewsScreen(
position = Position.Bottom,
trailing = {
Switch(
checked = state.showHourGrid,
onCheckedChange = viewModel::setShowHourGrid,
checked = state.showHourLines,
onCheckedChange = viewModel::setShowHourLines,
)
},
onClick = { viewModel.setShowHourGrid(!state.showHourGrid) },
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
)
Spacer(Modifier.height(8.dp))
@@ -220,6 +221,8 @@ internal fun ViewsScreen(
SectionHeader(stringResource(R.string.settings_quick_switch_header))
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
Spacer(Modifier.height(8.dp))
// Turning a view off is blocked once only the minimum remain enabled.
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
ReorderableColumn(
items = config.order,
keyOf = { it },
@@ -235,6 +238,8 @@ internal fun ViewsScreen(
trailing = {
Switch(
checked = checked,
// Keep the last two on: with fewer, the pill can't switch.
enabled = !checked || canDisable,
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
)
},

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -59,7 +60,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
@@ -69,6 +72,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -77,9 +81,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
import de.jeanlucmakiola.calendula.ui.common.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
@@ -89,13 +90,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.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.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -107,30 +103,31 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -138,8 +135,7 @@ import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
@@ -194,9 +190,21 @@ fun WeekScreen(
derivedStateOf { if (dimCompleted) nowState.value else null }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// The static header + all-day strip share the app bar's scrolled colour so
// the whole top region elevates together once the timeline scrolls under it.
val topSectionColor by animateColorAsState(
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surface
},
label = "week-top-section-color",
)
val isOnCurrentWeek = when (val s = state) {
// True when today falls inside the displayed week — independent of which
// weekday the user picked as the first day.
@@ -257,19 +265,19 @@ fun WeekScreen(
},
) {
Scaffold(
modifier = modifier,
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
WeekTopBar(
weekStart = weekStart,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
@@ -290,6 +298,7 @@ fun WeekScreen(
WeekContent(
state = state,
slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
@@ -309,6 +318,7 @@ fun WeekScreen(
private fun WeekContent(
state: WeekUiState,
slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
@@ -373,6 +383,7 @@ private fun WeekContent(
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is WeekUiState.Success -> WeekSuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
dragController = dragController,
@@ -399,6 +410,7 @@ private fun WeekContent(
@Composable
internal fun WeekSuccess(
state: WeekUiState.Success,
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
dragController: TimelineDragController,
@@ -411,19 +423,13 @@ internal fun WeekSuccess(
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
.background(topSectionColor),
) {
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
// This strip is painted on `surface`, so an outlined chip has to
// fill with that and not with the columns' container (#230).
CompositionLocalProvider(
LocalChipGround provides MaterialTheme.colorScheme.surface,
) {
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
}
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
}
// Breathing room between the top section and the scrolling timeline
// below.
// Breathing room between the (colour-shifting) top section and the
// scrolling timeline below.
Spacer(Modifier.height(8.dp))
Timeline(
state = state,
@@ -443,25 +449,20 @@ private fun WeekTopBar(
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
val locale = currentLocale()
val (title, shortTitle) = remember(weekStart, locale, currentYear) {
formatWeekTitle(weekStart, locale, currentYear) to
formatWeekTitle(weekStart, locale, currentYear, abbreviated = true)
}
TopAppBar(
title = {
CalendarTitleButton(
title = title,
title = formatWeekTitle(weekStart, locale, currentYear),
currentDate = weekStart,
onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
)
},
navigationIcon = {
@@ -482,17 +483,17 @@ private fun WeekTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
// Match the static top section exactly: plain surface, lifting to
// surfaceContainer once content scrolls under the bar.
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
),
scrollBehavior = scrollBehavior,
)
}
@@ -652,25 +653,23 @@ private fun AllDayBar(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val paint = eventPaint(event, dark)
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.eventSurface(paint, EventChipShape)
.background(fill, EventChipShape)
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = paint.titleInk,
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
}
}
@@ -688,12 +687,6 @@ private fun Timeline(
val zoom = LocalTimelineZoom.current
val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
// What the hour grid takes off a block's ends, for the floating copy to
// take off its own. Per-end, a cut edge keeps it — that much the drag
// geometry decides itself, from the slice it is drawing.
val blockInsetPx = with(density) {
hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx()
}
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
@@ -740,7 +733,6 @@ private fun Timeline(
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = gap
it.columnWidthPx = (coords.size.width + gap) / state.days.size
it.days = state.days
@@ -785,8 +777,8 @@ private fun DayColumnCard(
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourGrid = LocalShowHourGrid.current
val zone = remember { TimeZone.currentSystemDefault() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
// Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks)
@@ -802,24 +794,16 @@ private fun DayColumnCard(
// rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape,
colors = CardDefaults.cardColors(
// With the grid on, the container colour moves onto the hour cells
// and the column behind them recedes to surface, so the gap between
// two cells reads as the same negative space that separates the
// week's columns.
containerColor = if (showHourGrid) {
MaterialTheme.colorScheme.surface
} else {
MaterialTheme.colorScheme.surfaceContainer
},
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
modifier = modifier,
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// The hour cells sit over the column background but under the
// event blocks (drawBehind paints before the children).
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer)
// Faint hour separators sit over the column background but under
// the event blocks (drawBehind paints before the children).
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
// Tap an empty slot to create an event there; taps on event
// blocks are consumed by their own handler first. Snaps to hour.
.pointerInput(date) {
@@ -850,34 +834,20 @@ private fun DayColumnCard(
width = laneWidth,
height = height,
)
// Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells.
val cuts = remember(block, date, zone) {
timedBlockCuts(
block.continuesBefore(date, zone),
block.continuesAfter(date, zone),
)
}
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
val blockHeight = (place.height - topInset - bottomInset)
.coerceAtLeast(0.dp)
EventBlock(
block = block,
dark = dark,
height = blockHeight,
height = place.height,
width = place.width,
date = date,
cuts = cuts,
topInset = topInset,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y + topInset)
.offset(x = place.x, y = place.y)
.width(place.width)
.height(blockHeight)
.padding(horizontal = BLOCK_OUTER_INSET),
.height(place.height)
.padding(horizontal = 1.dp),
)
}
}
@@ -896,8 +866,6 @@ private fun EventBlock(
height: Dp,
width: Dp,
date: LocalDate,
cuts: ChipCuts,
topInset: Dp,
dragController: TimelineDragController,
onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit,
@@ -915,66 +883,48 @@ private fun EventBlock(
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
val available = height - BLOCK_TEXT_INSET * 2
// What's left for text once the 2.dp top/bottom padding is paid for.
val available = height - 4.dp
// Only full-width (non-overlapping) blocks that are tall enough show the
// time. On narrow overlapping columns we drop it so the title can wrap to
// fill the whole block, mirroring Google Calendar — and a block that cannot
// afford both lines spends its space on the title. Height decides that on
// its own: a duration threshold would keep hiding the time on a half-hour
// block the user has pinched open to three times the room it needs.
val showTime = block.laneCount == 1 &&
// afford both lines spends its space on the title.
val showTime = block.endMin - block.startMin >= 45 &&
block.laneCount == 1 &&
available >= titleLineHeight + timeLineHeight
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// A short block drops the title rather than serving a horizontally sliced
// one: half a letter reads as a rendering fault, while a bare colour chip
// reads as what it is — an event too brief to label. Tap still opens it, and
// the semantics description carries the full title either way.
val showTitle = available >= titleLineHeight
// The title is served first, out of everything the block has left once the
// time is down to one line — but only takes the lines it will actually use,
// and only wraps at all once a line is wide enough to hold more than a
// syllable. Below that the extra lines just stack fragments of the word.
// Wrap the title across as many lines as the block can fit — but only once a
// line is wide enough to hold more than a syllable. Below that the extra
// lines just stack fragments of the word, and one ellipsised line reads
// better.
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val paint = eventPaint(block.event, dark)
// Every line the height affords, however narrow the lane: two events side by
// side leave columns well under a word wide, and cutting the title to one
// line there lost it outright where the block had the room to wrap it.
val titleMaxLines = blockTextLines(
text = title,
// At the weight BlockTitle will set it in, or an invited block — drawn a
// weight lighter — is budgeted a line it never fills (#230).
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
textWidth = textWidth,
max = titleBudget,
)
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
1
} else {
(contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
}
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event)
// The drop takes this offset back off, so a tail clipped at midnight lands
// where the event's own start belongs (#253).
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
val topInsetPx = with(density) { topInset.toPx() }
val shape = remember(block, date, zone) {
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
}
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ ->
dragController.begin(
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
// Measured off the block's placement rather than off where the
// hour grid seats it: the copy is drawn from that placement and
// re-seated the same half-gap down (#267).
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
)
dragController.begin(block, clipOffset, pointer, blockRoot)
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
@@ -986,12 +936,12 @@ private fun EventBlock(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.eventSurface(paint, shape, cuts)
.background(fill, shape)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
@@ -999,19 +949,19 @@ private fun EventBlock(
) {
Column {
if (showTitle) {
BlockTitle(
title = title,
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = titleMaxLines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
)
}
if (showTime) {
BlockTimeLabel(
label = timeLabel,
color = paint.secondaryInk,
maxLines = timeMaxLines,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
)
}
}
@@ -1042,14 +992,7 @@ private fun WeekLoading() {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's
// columns don't resize the moment the real week arrives.
val hourHeight = scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The skeleton wears the loaded columns' own ground, so the arrival
// of the real week doesn't flash solid blocks into a gapped grid.
val showHourGrid = LocalShowHourGrid.current
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val columnGround = if (showHourGrid) MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceContainer
val totalHeight = scale.hourHeight(maxHeight) * 24
Row(
modifier = Modifier
.fillMaxSize()
@@ -1063,12 +1006,7 @@ private fun WeekLoading() {
.weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(columnGround)
.hourGridCells(
showHourGrid,
hourPx,
MaterialTheme.colorScheme.surfaceContainer,
),
.background(MaterialTheme.colorScheme.surfaceContainer),
)
}
}
@@ -1095,18 +1033,13 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
* "December" with no year would be actively misleading while the reader is
* looking straight at January dates.
*/
private fun formatWeekTitle(
weekStart: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String {
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String {
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
return formatCalendarTitle(
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
locale = locale,
currentYear = currentYear,
skeleton = if (abbreviated) "LLL" else "LLLL",
skeleton = "LLLL",
forceYear = weekEnd.year != weekStart.year,
)
}

View File

@@ -10,7 +10,6 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.isDeclined
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -145,7 +144,7 @@ class WeekViewModel @Inject constructor(
/**
* Lay out all-day events as connected horizontal spans across the visible week.
* Each event becomes one [AllDaySpan] from its first to its last covered column;
* overlapping spans are stacked on separate lanes (greedy first-fit).
* overlapping spans are stacked on separate lanes (greedy first-fit by start).
*/
internal fun layoutAllDay(
events: List<EventInstance>,
@@ -159,35 +158,21 @@ internal fun layoutAllDay(
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
}
// 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 }))
.sortedWith(compareBy({ it.startCol }, { it.endCol }))
// 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>>()
val laneEnd = ArrayList<Int>() // last occupied column per lane
return raw.map { r ->
val cols = r.startCol..r.endCol
var lane = laneCols.indexOfFirst { seated -> seated.none { it overlaps cols } }
var lane = laneEnd.indexOfFirst { it < r.startCol }
if (lane == -1) {
laneCols.add(mutableListOf(cols))
lane = laneCols.size - 1
laneEnd.add(r.endCol)
lane = laneEnd.size - 1
} else {
laneCols[lane].add(cols)
laneEnd[lane] = r.endCol
}
AllDaySpan(r.event, r.startCol, r.endCol, lane)
}
}
/** Whether two column ranges share a column, so they cannot share a lane. */
private infix fun IntRange.overlaps(other: IntRange): Boolean =
first <= other.last && other.first <= last
/** Beginning of the week (at [weekStart]) that contains this date. */
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -46,6 +47,7 @@ internal fun WeekViewPreview(
ScaledViewPreview(height = height, modifier = modifier) {
WeekSuccess(
state = state,
topSectionColor = MaterialTheme.colorScheme.surface,
scrollState = scrollState,
allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(),

View File

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

View File

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

View File

@@ -16,11 +16,6 @@
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
<string name="state_failure_provider">Could not read the calendar.</string>
<!-- Long-press a field to copy it (#195) -->
<string name="field_copy_action">Copy</string>
<string name="field_copied">Copied to clipboard</string>
<string name="field_copy_failed">Couldn\'t copy that</string>
<!-- Permission flow (F1) -->
<string name="permission_rationale_title">See all your events, beautifully</string>
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events.</string>
@@ -193,7 +188,6 @@
<string name="event_detail_all_day">All day</string>
<string name="event_detail_calendar">Calendar</string>
<string name="event_detail_calendar_unknown">Unknown calendar</string>
<string name="event_detail_title">Title</string>
<string name="event_detail_location">Location</string>
<string name="event_detail_description">Description</string>
<string name="event_detail_attendees">Attendees</string>
@@ -431,12 +425,8 @@
<string name="settings_time_format_24h">24-hour (14:00)</string>
<!-- %1$s is a sample time written the way the system currently writes it. -->
<string name="settings_time_format_auto_summary">Following the system: %1$s</string>
<!-- Reworded in v2.20 (#113): this setting no longer draws a separator line
at each hour, it seats each hour in its own cell. The key is kept, so any
translation of the old wording ("Hour lines" / "show a separator line at
each hour") describes a feature that no longer exists and needs redoing. -->
<string name="settings_hour_lines">Hour grid</string>
<string name="settings_hour_lines_summary">Seat each hour in its own cell in week and day view</string>
<string name="settings_hour_lines">Hour lines</string>
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
<string name="settings_timeline_scale">Hour height</string>
<string name="settings_timeline_scale_hint">How much vertical space one hour takes in week and day view. Both views share this setting. You can also pinch the timeline with two fingers to set any height in between.</string>
<string name="timeline_scale_fit_day">Fit whole day</string>
@@ -508,7 +498,7 @@
<string name="month_split_expand">Show the whole month</string>
<string name="month_split_collapse">Show the day\'s events</string>
<string name="settings_quick_switch_header">Quick-switch button</string>
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. With fewer than two views turned on the button is hidden. Turned-off views stay reachable from the navigation menu.</string>
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
<string name="settings_drawer_order_header">Navigation menu</string>
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
<string name="reorder_drag_handle">Drag to reorder</string>

View File

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

View File

@@ -95,9 +95,9 @@ class SettingsPrefsTest {
@Test
fun `hour lines default off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.showHourGrid.first()).isFalse()
prefs.setShowHourGrid(true)
assertThat(prefs.showHourGrid.first()).isTrue()
assertThat(prefs.showHourLines.first()).isFalse()
prefs.setShowHourLines(true)
assertThat(prefs.showHourLines.first()).isTrue()
}
@Test

View File

@@ -1,28 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class AppBarSpacingTest {
@Test
fun `the pill's background ends one inset from the screen edge`() {
// The padding is applied inside M3's actions row, so it carries that
// row's own inset on top of whatever the pill asks for (#165).
val rendered = AppBarSpacing.BarPadding + AppBarSpacing.ContainerTrailingInset
assertThat(rendered).isEqualTo(AppBarSpacing.Inset)
}
@Test
fun `trailing insets stay non-negative`() {
// Modifier.padding throws on a negative value.
assertThat(AppBarSpacing.ContainerTrailingInset.value).isAtLeast(0f)
assertThat(AppBarSpacing.IconTrailingInset.value).isAtLeast(0f)
}
@Test
fun `an icon button's glyph ends further in than a container's edge`() {
assertThat(AppBarSpacing.IconTrailingInset.value)
.isGreaterThan(AppBarSpacing.Inset.value)
}
}

View File

@@ -1,31 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class CalendarTitleButtonTest {
@Test
fun `keeps the full title when it fits`() {
assertThat(titleFor("September", "Sep", titleWidth = 200, availableWidth = 300))
.isEqualTo("September")
}
@Test
fun `keeps the full title when it fills the slot exactly`() {
assertThat(titleFor("September", "Sep", titleWidth = 300, availableWidth = 300))
.isEqualTo("September")
}
@Test
fun `falls back to the short title when the full one overflows`() {
assertThat(titleFor("September", "Sep", titleWidth = 400, availableWidth = 300))
.isEqualTo("Sep")
}
@Test
fun `a caller with nothing to shorten still gets its own title back`() {
assertThat(titleFor("Wed, 2 Sep", "Wed, 2 Sep", titleWidth = 400, availableWidth = 300))
.isEqualTo("Wed, 2 Sep")
}
}

View File

@@ -1,80 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The floating copy is the size of the block it lifted off, hour grid or not —
* a copy a half-gap taller at each end has room for a time label the block
* itself had to drop, so the label appears on long-press and goes on drop
* (#267).
*/
class DragPieceBoundsTest {
/** A Regular 56dp hour on a 3x screen. */
private val hourPx = 168f
/** The grid's half-gap, 1dp at 3x. */
private val insetPx = 3f
private fun slice(
startMin: Int,
spanMin: Int,
continuesBefore: Boolean = false,
continuesAfter: Boolean = false,
) = DragSlice(
dayOffset = 0,
startMin = startMin,
spanMin = spanMin,
continuesBefore = continuesBefore,
continuesAfter = continuesAfter,
)
@Test
fun `with the grid off a piece keeps its raw placement`() {
val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 60), hourPx, insetPx = 0f)
assertThat(bounds.top).isEqualTo(9 * hourPx)
assertThat(bounds.height).isEqualTo(hourPx)
}
@Test
fun `with the grid on a piece gives up a half-gap at each end`() {
val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 60), hourPx, insetPx)
assertThat(bounds.top).isEqualTo(9 * hourPx + insetPx)
assertThat(bounds.height).isEqualTo(hourPx - insetPx * 2)
}
@Test
fun `an end cut at midnight keeps none of the inset`() {
val head = dragPieceBounds(slice(startMin = 1200, spanMin = 240, continuesAfter = true), hourPx, insetPx)
assertThat(head.top).isEqualTo(20 * hourPx + insetPx)
assertThat(head.height).isEqualTo(4 * hourPx - insetPx)
val tail = dragPieceBounds(slice(startMin = 0, spanMin = 480, continuesBefore = true), hourPx, insetPx)
assertThat(tail.top).isEqualTo(0f)
assertThat(tail.height).isEqualTo(8 * hourPx - insetPx)
}
@Test
fun `a whole day cut at both ends fills its column`() {
val bounds = dragPieceBounds(
slice(startMin = 0, spanMin = 1440, continuesBefore = true, continuesAfter = true),
hourPx,
insetPx,
)
assertThat(bounds.top).isEqualTo(0f)
assertThat(bounds.height).isEqualTo(24 * hourPx)
}
@Test
fun `a very short piece is floored before the inset comes off`() {
val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 5), hourPx, insetPx)
assertThat(bounds.height).isEqualTo(MIN_EVENT_FRACTION * hourPx - insetPx * 2)
}
@Test
fun `an inset taller than the piece leaves no height at all`() {
val bounds = dragPieceBounds(slice(startMin = 0, spanMin = 60), hourPx = 4f, insetPx = insetPx)
assertThat(bounds.height).isEqualTo(0f)
}
}

View File

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

View File

@@ -1,40 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.text.style.TextOverflow
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class EventTitleOverflowTest {
@Test
fun `a single LTR line clips, so the title runs to the chip's edge`() {
val result = eventTitleOverflowFor(rtl = false, singleLine = true)
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
// Load-bearing: with softWrap on, a clipped line breaks at the last
// whole word and shows less title than the ellipsis did (#164).
assertThat(result.softWrap).isFalse()
}
@Test
fun `RTL keeps the ellipsis, which truncates at the logical end`() {
// Clipping with softWrap off cuts at the node's left edge, which in RTL
// is the end of the string — the title would lose its beginning.
val result = eventTitleOverflowFor(rtl = true, singleLine = true)
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
assertThat(result.softWrap).isTrue()
}
@Test
fun `a multi-line block wraps whole words and clips, spending none on a dot`() {
val result = eventTitleOverflowFor(rtl = false, singleLine = false)
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
assertThat(result.softWrap).isTrue()
}
@Test
fun `multi-line in RTL clips too, since softWrap cuts the logical end`() {
val result = eventTitleOverflowFor(rtl = true, singleLine = false)
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
assertThat(result.softWrap).isTrue()
}
}

View File

@@ -1,53 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class HourGridTest {
/** The hour cell's 4dp corner at a 3x density. */
private val maxRadius = 12f
@Test
fun `a tall cell rounds to the full radius`() {
// A Regular 56dp hour on a 3x screen, in a phone-width day column.
assertThat(hourCellRadiusPx(cellHeight = 162f, cellWidth = 900f, maxRadius = maxRadius))
.isEqualTo(maxRadius)
}
@Test
fun `a short cell rounds to half its height, never past a lozenge`() {
// Fit-the-day on a small viewport: the hour is shorter than the radius.
val radius = hourCellRadiusPx(cellHeight = 18f, cellWidth = 900f, maxRadius = maxRadius)
assertThat(radius).isEqualTo(9f)
}
@Test
fun `a narrow cell rounds to half its width`() {
// Seven columns on a small phone, so the cell is narrower than it is tall.
assertThat(hourCellRadiusPx(cellHeight = 162f, cellWidth = 18f, maxRadius = maxRadius))
.isEqualTo(9f)
}
@Test
fun `a degenerate cell asks for no radius at all`() {
assertThat(hourCellRadiusPx(cellHeight = -4f, cellWidth = 900f, maxRadius = maxRadius))
.isEqualTo(0f)
}
@Test
fun `a seated block gives up the half-gap at an end of its own`() {
assertThat(hourCellBlockInset(show = true, cut = false)).isEqualTo(HOUR_CELL_INSET)
}
@Test
fun `an end cut at midnight stays against the column edge`() {
assertThat(hourCellBlockInset(show = true, cut = true)).isEqualTo(0.dp)
}
@Test
fun `with the grid off nothing is given up`() {
assertThat(hourCellBlockInset(show = false, cut = false)).isEqualTo(0.dp)
}
}

View File

@@ -120,30 +120,4 @@ class ViewBackStackTest {
)
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
}
@Test
fun `a cycle can be emptied down to no views at all`() {
// #150: the settings screen no longer holds a floor, so every view may go.
val config = QuickSwitchConfig(order = IMPLEMENTED_VIEWS, enabled = emptySet())
assertThat(config.cycle).isEmpty()
assertThat(config.cycle.size).isLessThan(QuickSwitchConfig.MIN_CYCLE)
}
@Test
fun `one enabled view is below the cycle minimum, so the pill hides`() {
// A single target is not a switch — it hides rather than becoming a
// jump-to-one-view button, which would be dead once you were there.
val config = QuickSwitchConfig(order = IMPLEMENTED_VIEWS, enabled = setOf(CalendarView.Day))
assertThat(config.cycle).containsExactly(CalendarView.Day)
assertThat(config.cycle.size).isLessThan(QuickSwitchConfig.MIN_CYCLE)
}
@Test
fun `two enabled views are enough to show the pill`() {
val config = QuickSwitchConfig(
order = IMPLEMENTED_VIEWS,
enabled = setOf(CalendarView.Day, CalendarView.Month),
)
assertThat(config.cycle.size).isAtLeast(QuickSwitchConfig.MIN_CYCLE)
}
}

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ hiltNavigationCompose = "1.4.0"
lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0"
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
glance = "1.1.1"
glance = "1.2.0"
work = "2.11.2"
documentfile = "1.1.0"