Long-press to copy title, location and description (#195) (#263)

Long-pressing one of the three free-text fields in the event view puts the whole field on the clipboard.

| Field | Tap | Long-press |
| --- | --- | --- |
| Title | — | copy |
| Location | opens maps (unchanged) | copy |
| Description | links still open (unchanged) | copy |

Times, calendar, recurrence and attendees stay uncopyable. The confirmation snackbar only shows below API 33 — Android 13+ raises its own clipboard chip. A failed copy is reported on every API level.

First clipboard use in the app, so the helper lives in `ui/common` as a `FieldCopier` plus a `copyOnLongPress` modifier. A field with no tap action gets the gesture through `pointerInput` and merged `onLongClick` semantics rather than a `clickable` that would announce a tap it doesn't have.

Deviation worth naming: the description takes the gesture on its card rather than its text. A linkified URL consumes the press over its own glyphs, so long-pressing a URL copies nothing either way — the card at least adds the icon and padding as target. A description that is nothing but a link stays awkward to copy.

Closes #195

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/263
This commit is contained in:
Jean-Luc Makiola
2026-09-02 19:04:57 +02:00
parent dd3988c5bf
commit ddf508e350
11 changed files with 397 additions and 116 deletions

View File

@@ -30,7 +30,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -40,7 +39,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -98,7 +96,6 @@ fun AgendaScreen(
val showToday by viewModel.showToday.collectAsStateWithLifecycle() val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) } var showRangePicker by remember { mutableStateOf(false) }
@@ -133,7 +130,7 @@ fun AgendaScreen(
}, },
) { ) {
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier,
topBar = { topBar = {
AgendaTopBar( AgendaTopBar(
selectedView = selectedView, selectedView = selectedView,
@@ -143,7 +140,6 @@ fun AgendaScreen(
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar, showTodayButton = todayInToolbar,
onToday = viewModel::goToToday, onToday = viewModel::goToToday,
scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -432,7 +428,6 @@ private fun AgendaTopBar(
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
showTodayButton: Boolean, showTodayButton: Boolean,
onToday: () -> Unit, onToday: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) { ) {
TopAppBar( TopAppBar(
title = { title = {
@@ -468,10 +463,12 @@ private fun AgendaTopBar(
onCycle = onCycleView, onCycle = onCycleView,
) )
}, },
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, scrolledContainerColor = MaterialTheme.colorScheme.surface,
), ),
scrollBehavior = scrollBehavior,
) )
} }

View File

@@ -4,30 +4,166 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap import androidx.compose.animation.core.snap
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/** Gap a timed block leaves to its neighbours in the column. */
val BLOCK_OUTER_INSET = 1.dp
/** Padding between a timed block's edge and its text. */
val BLOCK_TEXT_PADDING = 4.dp
/** 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)
}
}
}
/**
* A timed block's title, over at most [maxLines].
*
* Wrapping and clipping pull against each other, which is why #164 left the
* ellipsis on multi-line chips: with `softWrap` on, the last visible line ends
* at a word boundary, so "Farmers Market" in a six-character column would clip
* to "Farmer" / "s" where the ellipsis at least reached "s Mar…".
*
* So the block wraps every line but the last through one `Text` and hands the
* remainder to a second that clips mid-glyph the way a single-line chip does.
* Every line is then full and none of them spends two of its few characters on
* a "…". RTL keeps the ellipsis for the reason [eventTitleOverflowFor] gives.
*/
@Composable
fun BlockTitle(
title: String,
maxLines: Int,
textWidth: Dp,
color: Color,
modifier: Modifier = Modifier,
textDecoration: TextDecoration? = null,
) {
val style = MaterialTheme.typography.labelMedium
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val measurer = rememberTextMeasurer()
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
// Where the wrapped lines stop and the clipped tail starts — null when the
// title fits, and nothing needs splitting.
val headEnd = remember(title, style, widthPx, maxLines, rtl, measurer) {
if (rtl || maxLines < 2 || widthPx <= 0) {
null
} else {
val layout = measurer.measure(
text = title,
style = style,
constraints = Constraints(maxWidth = widthPx),
)
if (layout.lineCount <= maxLines) {
null
} else {
layout.getLineEnd(maxLines - 2, visibleEnd = true)
}
}
}
if (headEnd == null) {
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
Text(
text = title,
modifier = modifier,
style = style,
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = color,
textDecoration = textDecoration,
)
} else {
val tail = eventTitleOverflow(singleLine = true)
Column(modifier = modifier) {
Text(
text = title.substring(0, headEnd),
style = style,
maxLines = maxLines - 1,
overflow = TextOverflow.Clip,
softWrap = true,
color = color,
textDecoration = textDecoration,
)
Text(
text = title.substring(headEnd).trimStart(),
style = style,
maxLines = 1,
overflow = tail.overflow,
softWrap = tail.softWrap,
color = color,
textDecoration = textDecoration,
)
}
}
}
/** /**
* A timed block's own time label, crossfaded rather than replaced — the block * A timed block's own time label, crossfaded rather than replaced — the block
* slides to its new slot, so the label shouldn't change in a single frame. * slides to its new slot, so the label shouldn't change in a single frame.
*
* Overflows like a title does (#164): the "…" costs two characters of a string
* that is nothing but characters, so the label clips at the block's edge
* instead. [maxLines] lets a narrow column spend spare height on the range
* rather than losing its end.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) { fun BlockTimeLabel(
label: String,
color: Color,
modifier: Modifier = Modifier,
maxLines: Int = 1,
) {
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) { val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
snap() snap()
} else { } else {
MaterialTheme.motionScheme.fastEffectsSpec() MaterialTheme.motionScheme.fastEffectsSpec()
} }
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
Crossfade( Crossfade(
targetState = label, targetState = label,
animationSpec = spec, animationSpec = spec,
@@ -37,8 +173,9 @@ fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = maxLines,
overflow = TextOverflow.Ellipsis, overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = color, color = color,
) )
} }

View File

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

View File

@@ -34,7 +34,6 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -714,7 +713,7 @@ private fun DragCopy(
width = with(density) { sizePx.width.toDp() }, width = with(density) { sizePx.width.toDp() },
height = with(density) { sizePx.height.toDp() }, height = with(density) { sizePx.height.toDp() },
) )
.padding(horizontal = 1.dp) .padding(horizontal = BLOCK_OUTER_INSET)
.graphicsLayer { .graphicsLayer {
scaleX = 1f + 0.02f * lift scaleX = 1f + 0.02f * lift
scaleY = 1f + 0.02f * lift scaleY = 1f + 0.02f * lift
@@ -724,7 +723,7 @@ private fun DragCopy(
clip = false clip = false
} }
.background(fill, shape) .background(fill, shape)
.padding(horizontal = 4.dp, vertical = 2.dp), .padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp),
) { ) {
Column { Column {
val titleOverflow = eventTitleOverflow() val titleOverflow = eventTitleOverflow()
@@ -741,7 +740,8 @@ private fun DragCopy(
text = label, text = label,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
) )
} }

View File

@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.day package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -53,10 +52,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -80,7 +77,12 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.MAX_TIME_LINES
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -170,21 +172,9 @@ fun DayScreen(
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) } initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
} }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// The all-day strip shares the app bar's scrolled colour so the whole top
// region elevates together once the timeline scrolls under it.
val topSectionColor by animateColorAsState(
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surface
},
label = "day-top-section-color",
)
val isOnToday = when (val s = state) { val isOnToday = when (val s = state) {
is DayUiState.Success -> s.date == s.today is DayUiState.Success -> s.date == s.today
else -> true else -> true
@@ -242,7 +232,7 @@ fun DayScreen(
}, },
) { ) {
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier,
topBar = { topBar = {
DayTopBar( DayTopBar(
date = date, date = date,
@@ -255,7 +245,6 @@ fun DayScreen(
onJumpToDate = jumpToDate, onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar, showTodayButton = todayInToolbar,
onToday = jumpToToday, onToday = jumpToToday,
scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -270,7 +259,6 @@ fun DayScreen(
DayContent( DayContent(
state = state, state = state,
slideDir = slideDir, slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext, onSwipeNext = goNext,
onSwipePrev = goPrev, onSwipePrev = goPrev,
onRetry = jumpToToday, onRetry = jumpToToday,
@@ -288,7 +276,6 @@ fun DayScreen(
private fun DayContent( private fun DayContent(
state: DayUiState, state: DayUiState,
slideDir: Int, slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit, onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit, onSwipePrev: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
@@ -349,7 +336,6 @@ private fun DayContent(
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is DayUiState.Success -> DaySuccess( is DayUiState.Success -> DaySuccess(
state = s, state = s,
topSectionColor = topSectionColor,
scrollState = scrollState, scrollState = scrollState,
allDayHeight = allDayHeight, allDayHeight = allDayHeight,
dragController = dragController, dragController = dragController,
@@ -375,7 +361,6 @@ private fun DayContent(
@Composable @Composable
internal fun DaySuccess( internal fun DaySuccess(
state: DayUiState.Success, state: DayUiState.Success,
topSectionColor: Color,
scrollState: ScrollState, scrollState: ScrollState,
allDayHeight: Dp, allDayHeight: Dp,
dragController: TimelineDragController, dragController: TimelineDragController,
@@ -392,10 +377,10 @@ internal fun DaySuccess(
onEventClick = onEventClick, onEventClick = onEventClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(topSectionColor), .background(MaterialTheme.colorScheme.surface),
) )
// Breathing room between the (colour-shifting) top section and the // Breathing room between the top section and the scrolling timeline
// scrolling timeline below. // below.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Timeline( Timeline(
state = state, state = state,
@@ -421,7 +406,6 @@ private fun DayTopBar(
onJumpToDate: (LocalDate) -> Unit, onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean, showTodayButton: Boolean,
onToday: () -> Unit, onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) { ) {
val locale = currentLocale() val locale = currentLocale()
val (title, shortTitle) = remember(date, locale, currentYear) { val (title, shortTitle) = remember(date, locale, currentYear) {
@@ -459,11 +443,13 @@ private fun DayTopBar(
onCycle = onCycleView, onCycle = onCycleView,
) )
}, },
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, scrolledContainerColor = MaterialTheme.colorScheme.surface,
), ),
scrollBehavior = scrollBehavior,
) )
} }
@@ -700,6 +686,7 @@ private fun DayColumnCard(
block = block, block = block,
dark = dark, dark = dark,
height = place.height, height = place.height,
width = place.width,
date = date, date = date,
dragController = dragController, dragController = dragController,
onClick = { onEventClick(block.event) }, onClick = { onEventClick(block.event) },
@@ -708,7 +695,7 @@ private fun DayColumnCard(
.offset(x = place.x, y = place.y) .offset(x = place.x, y = place.y)
.width(place.width) .width(place.width)
.height(place.height) .height(place.height)
.padding(horizontal = 1.dp), .padding(horizontal = BLOCK_OUTER_INSET),
) )
} }
} }
@@ -725,6 +712,7 @@ private fun EventBlock(
block: TimedBlock, block: TimedBlock,
dark: Boolean, dark: Boolean,
height: Dp, height: Dp,
width: Dp,
date: LocalDate, date: LocalDate,
dragController: TimelineDragController, dragController: TimelineDragController,
onClick: () -> Unit, onClick: () -> Unit,
@@ -746,10 +734,28 @@ private fun EventBlock(
// What's left for text once the 2.dp top/bottom padding is paid for. A block // 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 // 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. // 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 - 4.dp val available = height - 4.dp
val showTime = block.endMin - block.startMin >= 45 && val showTime = available >= titleLineHeight + timeLineHeight
available >= titleLineHeight + timeLineHeight
val showTitle = available >= titleLineHeight val showTitle = available >= titleLineHeight
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
val titleMaxLines = if (showTime) 1 else 2
// The range only wraps out of a line the title has not claimed, which on a
// day column — wide enough for "09:3011:00" several times over — means it
// never does, until lanes cut the column down.
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val timeMaxLines = if (showTime && spare >= timeLineHeight) {
blockTextLines(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
textWidth = textWidth,
max = MAX_TIME_LINES,
)
} else {
1
}
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften) val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
@@ -782,7 +788,7 @@ private fun EventBlock(
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up. // the tap still works, since a drag consumes the up.
.then(dragModifier) .then(dragModifier)
.padding(horizontal = 4.dp, vertical = 2.dp) .padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp)
.semantics { .semantics {
contentDescription = "$title, $timeLabel" contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
@@ -790,13 +796,10 @@ private fun EventBlock(
) { ) {
Column { Column {
if (showTitle) { if (showTitle) {
val titleOverflow = eventTitleOverflow(singleLine = showTime) BlockTitle(
Text( title = title,
text = title, maxLines = titleMaxLines,
style = MaterialTheme.typography.labelMedium, textWidth = textWidth,
maxLines = if (showTime) 1 else 2,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = 0.85f), color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined), textDecoration = declinedDecoration(block.event.isDeclined),
) )
@@ -805,6 +808,7 @@ private fun EventBlock(
BlockTimeLabel( BlockTimeLabel(
label = timeLabel, label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
maxLines = timeMaxLines,
) )
} }
} }

View File

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

View File

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

View File

@@ -114,7 +114,6 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -214,7 +213,6 @@ fun MonthScreen(
derivedStateOf { if (dimCompleted) nowState.value else null } derivedStateOf { if (dimCompleted) nowState.value else null }
} }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -392,7 +390,7 @@ fun MonthScreen(
}, },
) { ) {
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier,
topBar = { topBar = {
MonthTopBar( MonthTopBar(
title = topBarTitle, title = topBarTitle,
@@ -406,7 +404,6 @@ fun MonthScreen(
onJumpToDate = jumpToDate, onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar, showTodayButton = todayInToolbar,
onToday = jumpToToday, onToday = jumpToToday,
scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -682,7 +679,6 @@ private fun MonthTopBar(
onJumpToDate: (LocalDate) -> Unit, onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean, showTodayButton: Boolean,
onToday: () -> Unit, onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) { ) {
TopAppBar( TopAppBar(
title = { title = {
@@ -715,7 +711,13 @@ private fun MonthTopBar(
onCycle = onCycleView, onCycle = onCycleView,
) )
}, },
scrollBehavior = scrollBehavior, // Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
) )
} }

View File

@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.week package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -60,9 +59,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -90,7 +87,12 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.MAX_TIME_LINES
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -190,21 +192,9 @@ fun WeekScreen(
derivedStateOf { if (dimCompleted) nowState.value else null } derivedStateOf { if (dimCompleted) nowState.value else null }
} }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// The static header + all-day strip share the app bar's scrolled colour so
// the whole top region elevates together once the timeline scrolls under it.
val topSectionColor by animateColorAsState(
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surface
},
label = "week-top-section-color",
)
val isOnCurrentWeek = when (val s = state) { val isOnCurrentWeek = when (val s = state) {
// True when today falls inside the displayed week — independent of which // True when today falls inside the displayed week — independent of which
// weekday the user picked as the first day. // weekday the user picked as the first day.
@@ -265,7 +255,7 @@ fun WeekScreen(
}, },
) { ) {
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier,
topBar = { topBar = {
WeekTopBar( WeekTopBar(
weekStart = weekStart, weekStart = weekStart,
@@ -278,7 +268,6 @@ fun WeekScreen(
onJumpToDate = jumpToDate, onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar, showTodayButton = todayInToolbar,
onToday = jumpToToday, onToday = jumpToToday,
scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -299,7 +288,6 @@ fun WeekScreen(
WeekContent( WeekContent(
state = state, state = state,
slideDir = slideDir, slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext, onSwipeNext = goNext,
onSwipePrev = goPrev, onSwipePrev = goPrev,
onRetry = jumpToToday, onRetry = jumpToToday,
@@ -319,7 +307,6 @@ fun WeekScreen(
private fun WeekContent( private fun WeekContent(
state: WeekUiState, state: WeekUiState,
slideDir: Int, slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit, onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit, onSwipePrev: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
@@ -384,7 +371,6 @@ private fun WeekContent(
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is WeekUiState.Success -> WeekSuccess( is WeekUiState.Success -> WeekSuccess(
state = s, state = s,
topSectionColor = topSectionColor,
scrollState = scrollState, scrollState = scrollState,
allDayHeight = allDayHeight, allDayHeight = allDayHeight,
dragController = dragController, dragController = dragController,
@@ -411,7 +397,6 @@ private fun WeekContent(
@Composable @Composable
internal fun WeekSuccess( internal fun WeekSuccess(
state: WeekUiState.Success, state: WeekUiState.Success,
topSectionColor: Color,
scrollState: ScrollState, scrollState: ScrollState,
allDayHeight: Dp, allDayHeight: Dp,
dragController: TimelineDragController, dragController: TimelineDragController,
@@ -424,13 +409,13 @@ internal fun WeekSuccess(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(topSectionColor), .background(MaterialTheme.colorScheme.surface),
) { ) {
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay) WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick) AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
} }
// Breathing room between the (colour-shifting) top section and the // Breathing room between the top section and the scrolling timeline
// scrolling timeline below. // below.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Timeline( Timeline(
state = state, state = state,
@@ -456,7 +441,6 @@ private fun WeekTopBar(
onJumpToDate: (LocalDate) -> Unit, onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean, showTodayButton: Boolean,
onToday: () -> Unit, onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) { ) {
val locale = currentLocale() val locale = currentLocale()
val (title, shortTitle) = remember(weekStart, locale, currentYear) { val (title, shortTitle) = remember(weekStart, locale, currentYear) {
@@ -494,13 +478,13 @@ private fun WeekTopBar(
onCycle = onCycleView, onCycle = onCycleView,
) )
}, },
// Match the static top section exactly: plain surface, lifting to // Deliberately flat: M3 lifts the bar to mark content scrolling under
// surfaceContainer once content scrolls under the bar. // it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, scrolledContainerColor = MaterialTheme.colorScheme.surface,
), ),
scrollBehavior = scrollBehavior,
) )
} }
@@ -856,7 +840,7 @@ private fun DayColumnCard(
.offset(x = place.x, y = place.y) .offset(x = place.x, y = place.y)
.width(place.width) .width(place.width)
.height(place.height) .height(place.height)
.padding(horizontal = 1.dp), .padding(horizontal = BLOCK_OUTER_INSET),
) )
} }
} }
@@ -897,24 +881,47 @@ private fun EventBlock(
// Only full-width (non-overlapping) blocks that are tall enough show the // Only full-width (non-overlapping) blocks that are tall enough show the
// time. On narrow overlapping columns we drop it so the title can wrap to // time. On narrow overlapping columns we drop it so the title can wrap to
// fill the whole block, mirroring Google Calendar — and a block that cannot // fill the whole block, mirroring Google Calendar — and a block that cannot
// afford both lines spends its space on the title. // afford both lines spends its space on the title. Height decides that on
val showTime = block.endMin - block.startMin >= 45 && // its own: a duration threshold would keep hiding the time on a half-hour
block.laneCount == 1 && // block the user has pinched open to three times the room it needs.
val showTime = block.laneCount == 1 &&
available >= titleLineHeight + timeLineHeight available >= titleLineHeight + timeLineHeight
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// A short block drops the title rather than serving a horizontally sliced // A short block drops the title rather than serving a horizontally sliced
// one: half a letter reads as a rendering fault, while a bare colour chip // one: half a letter reads as a rendering fault, while a bare colour chip
// reads as what it is — an event too brief to label. Tap still opens it, and // reads as what it is — an event too brief to label. Tap still opens it, and
// the semantics description carries the full title either way. // the semantics description carries the full title either way.
val showTitle = available >= titleLineHeight val showTitle = available >= titleLineHeight
// Wrap the title across as many lines as the block can fit — but only once a // The title is served first, out of everything the block has left once the
// line is wide enough to hold more than a syllable. Below that the extra // time is down to one line — but only takes the lines it will actually use,
// lines just stack fragments of the word, and one ellipsised line reads // and only wraps at all once a line is wide enough to hold more than a
// better. // syllable. Below that the extra lines just stack fragments of the word.
val contentHeight = available - if (showTime) timeLineHeight else 0.dp val contentHeight = available - if (showTime) timeLineHeight else 0.dp
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) { val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
1 1
} else { } else {
(contentHeight / titleLineHeight).toInt().coerceAtLeast(1) blockTextLines(
text = title,
style = MaterialTheme.typography.labelMedium,
textWidth = textWidth,
max = titleBudget,
)
}
// 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.
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val timeMaxLines = if (showTime && spare >= timeLineHeight) {
blockTextLines(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
textWidth = textWidth,
max = MAX_TIME_LINES,
)
} else {
1
} }
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
@@ -950,7 +957,7 @@ private fun EventBlock(
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up. // the tap still works, since a drag consumes the up.
.then(dragModifier) .then(dragModifier)
.padding(horizontal = 4.dp, vertical = 2.dp) .padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp)
.semantics { .semantics {
contentDescription = "$title, $timeLabel" contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
@@ -958,13 +965,10 @@ private fun EventBlock(
) { ) {
Column { Column {
if (showTitle) { if (showTitle) {
val titleOverflow = eventTitleOverflow(singleLine = titleMaxLines == 1) BlockTitle(
Text( title = title,
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = titleMaxLines, maxLines = titleMaxLines,
overflow = titleOverflow.overflow, textWidth = textWidth,
softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = 0.85f), color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined), textDecoration = declinedDecoration(block.event.isDeclined),
) )
@@ -973,6 +977,7 @@ private fun EventBlock(
BlockTimeLabel( BlockTimeLabel(
label = timeLabel, label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
maxLines = timeMaxLines,
) )
} }
} }

View File

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

View File

@@ -16,6 +16,11 @@
<string name="state_failure_no_calendars_action">Open system calendar settings</string> <string name="state_failure_no_calendars_action">Open system calendar settings</string>
<string name="state_failure_provider">Could not read the calendar.</string> <string name="state_failure_provider">Could not read the calendar.</string>
<!-- Long-press a field to copy it (#195) -->
<string name="field_copy_action">Copy</string>
<string name="field_copied">Copied to clipboard</string>
<string name="field_copy_failed">Couldn\'t copy that</string>
<!-- Permission flow (F1) --> <!-- Permission flow (F1) -->
<string name="permission_rationale_title">See all your events, beautifully</string> <string name="permission_rationale_title">See all your events, beautifully</string>
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events.</string> <string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events.</string>
@@ -188,6 +193,7 @@
<string name="event_detail_all_day">All day</string> <string name="event_detail_all_day">All day</string>
<string name="event_detail_calendar">Calendar</string> <string name="event_detail_calendar">Calendar</string>
<string name="event_detail_calendar_unknown">Unknown calendar</string> <string name="event_detail_calendar_unknown">Unknown calendar</string>
<string name="event_detail_title">Title</string>
<string name="event_detail_location">Location</string> <string name="event_detail_location">Location</string>
<string name="event_detail_description">Description</string> <string name="event_detail_description">Description</string>
<string name="event_detail_attendees">Attendees</string> <string name="event_detail_attendees">Attendees</string>