diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 7f07d0b..76e340b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -40,7 +39,6 @@ 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 @@ -98,7 +96,6 @@ fun AgendaScreen( val showToday by viewModel.showToday.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() var showRangePicker by remember { mutableStateOf(false) } @@ -133,7 +130,7 @@ fun AgendaScreen( }, ) { Scaffold( - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + modifier = modifier, topBar = { AgendaTopBar( selectedView = selectedView, @@ -143,7 +140,6 @@ fun AgendaScreen( onOpenSearch = onOpenSearch, showTodayButton = todayInToolbar, onToday = viewModel::goToToday, - scrollBehavior = scrollBehavior, ) }, floatingActionButton = { @@ -432,7 +428,6 @@ private fun AgendaTopBar( onOpenSearch: () -> Unit, showTodayButton: Boolean, onToday: () -> Unit, - scrollBehavior: TopAppBarScrollBehavior, ) { TopAppBar( title = { @@ -468,10 +463,12 @@ private fun AgendaTopBar( 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( containerColor = MaterialTheme.colorScheme.surface, - scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + scrolledContainerColor = MaterialTheme.colorScheme.surface, ), - scrollBehavior = scrollBehavior, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt index 24db4bf..265a042 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt @@ -4,30 +4,166 @@ import androidx.compose.animation.Crossfade import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.snap +import androidx.compose.foundation.layout.Column import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier 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.unit.Constraints +import androidx.compose.ui.unit.LayoutDirection 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 + +/** Most lines a time label may wrap over before it is worth more than a title line. */ +const val MAX_TIME_LINES = 2 + +/** + * Lines [text] needs to render whole at [textWidth], capped at [max]. + * + * Lets a block hand out its height by what the text actually asks for rather + * than by what would fit: a title that wants one line should not be given three + * that the time label could have used, and a week column is narrower than a + * "09:30–11:00" range so the range should not be assumed to want one. + */ +@Composable +fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int { + val measurer = rememberTextMeasurer() + val widthPx = with(LocalDensity.current) { textWidth.roundToPx() } + return remember(text, style, widthPx, max, measurer) { + if (max <= 1 || widthPx <= 0) { + 1 + } else { + measurer.measure( + text = text, + style = style, + constraints = Constraints(maxWidth = widthPx), + ).lineCount.coerceIn(1, max) + } + } +} + +/** + * 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 * slides to its new slot, so the label shouldn't change in a single frame. + * + * Overflows like a title does (#164): the "…" costs two characters of a string + * that is nothing but characters, so the label clips at the block's edge + * instead. [maxLines] lets a narrow column spend spare height on the range + * rather than losing its end. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) { +fun BlockTimeLabel( + label: String, + color: Color, + modifier: Modifier = Modifier, + maxLines: Int = 1, +) { val spec: FiniteAnimationSpec = if (rememberReduceMotion()) { snap() } else { MaterialTheme.motionScheme.fastEffectsSpec() } + val overflow = eventTitleOverflow(singleLine = maxLines == 1) Crossfade( targetState = label, animationSpec = spec, @@ -37,8 +173,9 @@ fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) { Text( text = text, style = MaterialTheme.typography.labelSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + maxLines = maxLines, + overflow = overflow.overflow, + softWrap = overflow.softWrap, color = color, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CopyToClipboard.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CopyToClipboard.kt new file mode 100644 index 0000000..14dcdfc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CopyToClipboard.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index c05c976..18b0ad1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -34,7 +34,6 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -714,7 +713,7 @@ private fun DragCopy( width = with(density) { sizePx.width.toDp() }, height = with(density) { sizePx.height.toDp() }, ) - .padding(horizontal = 1.dp) + .padding(horizontal = BLOCK_OUTER_INSET) .graphicsLayer { scaleX = 1f + 0.02f * lift scaleY = 1f + 0.02f * lift @@ -724,7 +723,7 @@ private fun DragCopy( clip = false } .background(fill, shape) - .padding(horizontal = 4.dp, vertical = 2.dp), + .padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp), ) { Column { val titleOverflow = eventTitleOverflow() @@ -741,7 +740,8 @@ private fun DragCopy( text = label, style = MaterialTheme.typography.labelSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = titleOverflow.overflow, + softWrap = titleOverflow.softWrap, color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index c2ab537..8ee12d5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -1,7 +1,6 @@ package de.jeanlucmakiola.calendula.ui.day import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -53,10 +52,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.draw.alpha -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity @@ -80,7 +77,12 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS +import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET +import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING import de.jeanlucmakiola.calendula.ui.common.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.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.LocalEventMove @@ -170,21 +172,9 @@ fun DayScreen( initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) } } - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() - // The all-day strip shares the app bar's scrolled colour so the whole top - // region elevates together once the timeline scrolls under it. - val topSectionColor by animateColorAsState( - targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) { - MaterialTheme.colorScheme.surfaceContainer - } else { - MaterialTheme.colorScheme.surface - }, - label = "day-top-section-color", - ) - val isOnToday = when (val s = state) { is DayUiState.Success -> s.date == s.today else -> true @@ -242,7 +232,7 @@ fun DayScreen( }, ) { Scaffold( - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + modifier = modifier, topBar = { DayTopBar( date = date, @@ -255,7 +245,6 @@ fun DayScreen( onJumpToDate = jumpToDate, showTodayButton = todayInToolbar, onToday = jumpToToday, - scrollBehavior = scrollBehavior, ) }, floatingActionButton = { @@ -270,7 +259,6 @@ fun DayScreen( DayContent( state = state, slideDir = slideDir, - topSectionColor = topSectionColor, onSwipeNext = goNext, onSwipePrev = goPrev, onRetry = jumpToToday, @@ -288,7 +276,6 @@ fun DayScreen( private fun DayContent( state: DayUiState, slideDir: Int, - topSectionColor: Color, onSwipeNext: () -> Unit, onSwipePrev: () -> Unit, onRetry: () -> Unit, @@ -349,7 +336,6 @@ private fun DayContent( is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) is DayUiState.Success -> DaySuccess( state = s, - topSectionColor = topSectionColor, scrollState = scrollState, allDayHeight = allDayHeight, dragController = dragController, @@ -375,7 +361,6 @@ private fun DayContent( @Composable internal fun DaySuccess( state: DayUiState.Success, - topSectionColor: Color, scrollState: ScrollState, allDayHeight: Dp, dragController: TimelineDragController, @@ -392,10 +377,10 @@ internal fun DaySuccess( onEventClick = onEventClick, modifier = Modifier .fillMaxWidth() - .background(topSectionColor), + .background(MaterialTheme.colorScheme.surface), ) - // Breathing room between the (colour-shifting) top section and the - // scrolling timeline below. + // Breathing room between the top section and the scrolling timeline + // below. Spacer(Modifier.height(8.dp)) Timeline( state = state, @@ -421,7 +406,6 @@ private fun DayTopBar( onJumpToDate: (LocalDate) -> Unit, showTodayButton: Boolean, onToday: () -> Unit, - scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { val locale = currentLocale() val (title, shortTitle) = remember(date, locale, currentYear) { @@ -459,11 +443,13 @@ private fun DayTopBar( 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( containerColor = MaterialTheme.colorScheme.surface, - scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + scrolledContainerColor = MaterialTheme.colorScheme.surface, ), - scrollBehavior = scrollBehavior, ) } @@ -700,6 +686,7 @@ private fun DayColumnCard( block = block, dark = dark, height = place.height, + width = place.width, date = date, dragController = dragController, onClick = { onEventClick(block.event) }, @@ -708,7 +695,7 @@ private fun DayColumnCard( .offset(x = place.x, y = place.y) .width(place.width) .height(place.height) - .padding(horizontal = 1.dp), + .padding(horizontal = BLOCK_OUTER_INSET), ) } } @@ -725,6 +712,7 @@ private fun EventBlock( block: TimedBlock, dark: Boolean, height: Dp, + width: Dp, date: LocalDate, dragController: TimelineDragController, 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 // 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 - 4.dp - val showTime = block.endMin - block.startMin >= 45 && - available >= titleLineHeight + timeLineHeight + val showTime = available >= titleLineHeight + timeLineHeight 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:30–11: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 fill = eventFill(block.event.color, dark, soften) 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; // the tap still works, since a drag consumes the up. .then(dragModifier) - .padding(horizontal = 4.dp, vertical = 2.dp) + .padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp) .semantics { contentDescription = "$title, $timeLabel" if (moveAction != null) customActions = listOf(moveAction) @@ -790,13 +796,10 @@ private fun EventBlock( ) { Column { if (showTitle) { - val titleOverflow = eventTitleOverflow(singleLine = showTime) - Text( - text = title, - style = MaterialTheme.typography.labelMedium, - maxLines = if (showTime) 1 else 2, - overflow = titleOverflow.overflow, - softWrap = titleOverflow.softWrap, + BlockTitle( + title = title, + maxLines = titleMaxLines, + textWidth = textWidth, color = eventInk(fill, alpha = 0.85f), textDecoration = declinedDecoration(block.event.isDeclined), ) @@ -805,6 +808,7 @@ private fun EventBlock( BlockTimeLabel( label = timeLabel, color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), + maxLines = timeMaxLines, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt index 27eeda2..90303d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt @@ -1,7 +1,6 @@ package de.jeanlucmakiola.calendula.ui.day import androidx.compose.foundation.rememberScrollState -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -41,7 +40,6 @@ internal fun DayViewPreview( ScaledViewPreview(height = height, modifier = modifier) { DaySuccess( state = state, - topSectionColor = MaterialTheme.colorScheme.surface, scrollState = scrollState, allDayHeight = state.allDayStripHeight(), dragController = rememberTimelineDragController(), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 67709b8..12a32ee 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -9,7 +9,6 @@ import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -98,6 +97,9 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CalendarFailure +import de.jeanlucmakiola.calendula.ui.common.FieldCopier +import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress +import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventAccent @@ -150,6 +152,7 @@ fun EventDetailScreen( val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } var showDeleteDialog by rememberSaveable { mutableStateOf(false) } + val copyField = rememberFieldCopier(snackbarHostState) // Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages // an .ics in the cache and hands back a content Uri for the chooser. @@ -300,7 +303,7 @@ fun EventDetailScreen( reason = s.reason, onRetry = viewModel::retry, ) - is EventDetailUiState.Success -> EventDetailContent(s, contentModifier) + is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier) } } @@ -382,7 +385,11 @@ private fun DeleteEventDialog( @Composable -private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) { +private fun EventDetailContent( + state: EventDetailUiState.Success, + copyField: FieldCopier, + modifier: Modifier = Modifier, +) { val detail = state.detail val instance = detail.instance val dark = isSystemInDarkTheme() @@ -399,6 +406,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi // event, so it's left implicit — only Free is worth surfacing. A // cancelled event strikes through its title. Row(verticalAlignment = Alignment.Top) { + val titleLabel = stringResource(R.string.event_detail_title) Text( text = instance.title.ifBlank { stringResource(R.string.event_untitled) }, style = MaterialTheme.typography.headlineMedium, @@ -408,7 +416,17 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi } else { null }, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .then( + // Nothing to copy off an untitled event — the placeholder + // isn't the event's own text. + if (instance.title.isNotBlank()) { + Modifier.copyOnLongPress(titleLabel, instance.title, copyField) + } else { + Modifier + }, + ), ) if (detail.availability == Availability.Free) { Spacer(Modifier.width(12.dp)) @@ -516,10 +534,11 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi // Location (conditional, tap → maps). instance.location?.takeIf { it.isNotBlank() }?.let { location -> val context = LocalContext.current + val locationLabel = stringResource(R.string.event_detail_location) Spacer(Modifier.height(gap)) DetailCard( icon = Icons.Default.Place, - iconContentDescription = stringResource(R.string.event_detail_location), + iconContentDescription = locationLabel, ) { Text( text = location, @@ -527,7 +546,12 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi color = MaterialTheme.colorScheme.primary, modifier = Modifier .fillMaxWidth() - .clickable { openInMaps(context, location) } + .copyOnLongPress( + label = locationLabel, + text = location, + copy = copyField, + onTap = { openInMaps(context, location) }, + ) .padding(vertical = 2.dp), ) } @@ -535,10 +559,15 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi // Description (conditional). URLs are auto-linked. detail.description?.takeIf { it.isNotBlank() }?.let { description -> + val descriptionLabel = stringResource(R.string.event_detail_description) Spacer(Modifier.height(gap)) DetailCard( icon = Icons.AutoMirrored.Filled.Notes, - iconContentDescription = stringResource(R.string.event_detail_description), + iconContentDescription = descriptionLabel, + // The gesture sits on the card so the icon and padding answer + // it too: every linkified URL owns the pointer over its own + // glyphs, which leaves the text itself a patchy target. + modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField), ) { Text( text = linkifyUrls(description, MaterialTheme.colorScheme.primary), @@ -611,13 +640,14 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi private fun DetailCard( icon: ImageVector, iconContentDescription: String?, + modifier: Modifier = Modifier, iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant, content: @Composable ColumnScope.() -> Unit, ) { Surface( color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), ) { Row( modifier = Modifier.padding(16.dp), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index ae4f177..a4dce26 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -114,7 +114,6 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity @@ -214,7 +213,6 @@ fun MonthScreen( derivedStateOf { if (dimCompleted) nowState.value else null } } - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() @@ -392,7 +390,7 @@ fun MonthScreen( }, ) { Scaffold( - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + modifier = modifier, topBar = { MonthTopBar( title = topBarTitle, @@ -406,7 +404,6 @@ fun MonthScreen( onJumpToDate = jumpToDate, showTodayButton = todayInToolbar, onToday = jumpToToday, - scrollBehavior = scrollBehavior, ) }, floatingActionButton = { @@ -682,7 +679,6 @@ private fun MonthTopBar( onJumpToDate: (LocalDate) -> Unit, showTodayButton: Boolean, onToday: () -> Unit, - scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { TopAppBar( title = { @@ -715,7 +711,13 @@ private fun MonthTopBar( 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, + ), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 5c34de6..9cc9609 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -1,7 +1,6 @@ package de.jeanlucmakiola.calendula.ui.week import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -60,9 +59,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity @@ -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.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.declinedDecoration +import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET +import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING import de.jeanlucmakiola.calendula.ui.common.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.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.LocalEventMove @@ -190,21 +192,9 @@ fun WeekScreen( derivedStateOf { if (dimCompleted) nowState.value else null } } - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() - // The static header + all-day strip share the app bar's scrolled colour so - // the whole top region elevates together once the timeline scrolls under it. - val topSectionColor by animateColorAsState( - targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) { - MaterialTheme.colorScheme.surfaceContainer - } else { - MaterialTheme.colorScheme.surface - }, - label = "week-top-section-color", - ) - val isOnCurrentWeek = when (val s = state) { // True when today falls inside the displayed week — independent of which // weekday the user picked as the first day. @@ -265,7 +255,7 @@ fun WeekScreen( }, ) { Scaffold( - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + modifier = modifier, topBar = { WeekTopBar( weekStart = weekStart, @@ -278,7 +268,6 @@ fun WeekScreen( onJumpToDate = jumpToDate, showTodayButton = todayInToolbar, onToday = jumpToToday, - scrollBehavior = scrollBehavior, ) }, floatingActionButton = { @@ -299,7 +288,6 @@ fun WeekScreen( WeekContent( state = state, slideDir = slideDir, - topSectionColor = topSectionColor, onSwipeNext = goNext, onSwipePrev = goPrev, onRetry = jumpToToday, @@ -319,7 +307,6 @@ fun WeekScreen( private fun WeekContent( state: WeekUiState, slideDir: Int, - topSectionColor: Color, onSwipeNext: () -> Unit, onSwipePrev: () -> Unit, onRetry: () -> Unit, @@ -384,7 +371,6 @@ private fun WeekContent( is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) is WeekUiState.Success -> WeekSuccess( state = s, - topSectionColor = topSectionColor, scrollState = scrollState, allDayHeight = allDayHeight, dragController = dragController, @@ -411,7 +397,6 @@ private fun WeekContent( @Composable internal fun WeekSuccess( state: WeekUiState.Success, - topSectionColor: Color, scrollState: ScrollState, allDayHeight: Dp, dragController: TimelineDragController, @@ -424,13 +409,13 @@ internal fun WeekSuccess( Column( modifier = Modifier .fillMaxWidth() - .background(topSectionColor), + .background(MaterialTheme.colorScheme.surface), ) { WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay) AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick) } - // Breathing room between the (colour-shifting) top section and the - // scrolling timeline below. + // Breathing room between the top section and the scrolling timeline + // below. Spacer(Modifier.height(8.dp)) Timeline( state = state, @@ -456,7 +441,6 @@ private fun WeekTopBar( onJumpToDate: (LocalDate) -> Unit, showTodayButton: Boolean, onToday: () -> Unit, - scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { val locale = currentLocale() val (title, shortTitle) = remember(weekStart, locale, currentYear) { @@ -494,13 +478,13 @@ private fun WeekTopBar( onCycle = onCycleView, ) }, - // Match the static top section exactly: plain surface, lifting to - // surfaceContainer once content scrolls under the bar. + // Deliberately flat: M3 lifts the bar to mark content scrolling under + // it, but here the bar meets the header on the same surface and the + // tint is what makes that seam look like a separate block (#186). colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surface, - scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + scrolledContainerColor = MaterialTheme.colorScheme.surface, ), - scrollBehavior = scrollBehavior, ) } @@ -856,7 +840,7 @@ private fun DayColumnCard( .offset(x = place.x, y = place.y) .width(place.width) .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 // time. On narrow overlapping columns we drop it so the title can wrap to // fill the whole block, mirroring Google Calendar — and a block that cannot - // afford both lines spends its space on the title. - val showTime = block.endMin - block.startMin >= 45 && - block.laneCount == 1 && + // afford both lines spends its space on the title. Height decides that on + // its own: a duration threshold would keep hiding the time on a half-hour + // block the user has pinched open to three times the room it needs. + val showTime = block.laneCount == 1 && available >= titleLineHeight + timeLineHeight + val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 // A short block drops the title rather than serving a horizontally sliced // one: half a letter reads as a rendering fault, while a bare colour chip // reads as what it is — an event too brief to label. Tap still opens it, and // the semantics description carries the full title either way. val showTitle = available >= titleLineHeight - // Wrap the title across as many lines as the block can fit — but only once a - // line is wide enough to hold more than a syllable. Below that the extra - // lines just stack fragments of the word, and one ellipsised line reads - // better. + // The title is served first, out of everything the block has left once the + // time is down to one line — but only takes the lines it will actually use, + // and only wraps at all once a line is wide enough to hold more than a + // syllable. Below that the extra lines just stack fragments of the word. val contentHeight = available - if (showTime) timeLineHeight else 0.dp + val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) { 1 } 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:30–11:00" range, so the label takes a + // second line rather than lose its end — but only out of a line the title + // measured itself as not needing, never one it would have filled. + 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 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; // the tap still works, since a drag consumes the up. .then(dragModifier) - .padding(horizontal = 4.dp, vertical = 2.dp) + .padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp) .semantics { contentDescription = "$title, $timeLabel" if (moveAction != null) customActions = listOf(moveAction) @@ -958,13 +965,10 @@ private fun EventBlock( ) { Column { if (showTitle) { - val titleOverflow = eventTitleOverflow(singleLine = titleMaxLines == 1) - Text( - text = title, - style = MaterialTheme.typography.labelMedium, + BlockTitle( + title = title, maxLines = titleMaxLines, - overflow = titleOverflow.overflow, - softWrap = titleOverflow.softWrap, + textWidth = textWidth, color = eventInk(fill, alpha = 0.85f), textDecoration = declinedDecoration(block.event.isDeclined), ) @@ -973,6 +977,7 @@ private fun EventBlock( BlockTimeLabel( label = timeLabel, color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), + maxLines = timeMaxLines, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt index 83b1400..977ac1f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt @@ -1,7 +1,6 @@ package de.jeanlucmakiola.calendula.ui.week import androidx.compose.foundation.rememberScrollState -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -47,7 +46,6 @@ internal fun WeekViewPreview( ScaledViewPreview(height = height, modifier = modifier) { WeekSuccess( state = state, - topSectionColor = MaterialTheme.colorScheme.surface, scrollState = scrollState, allDayHeight = state.allDayStripHeight(), dragController = rememberTimelineDragController(), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f405e00..add7a77 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,11 @@ Open system calendar settings Could not read the calendar. + + Copy + Copied to clipboard + Couldn\'t copy that + See all your events, beautifully Calendula needs access to your calendar to show and manage your events. @@ -188,6 +193,7 @@ All day Calendar Unknown calendar + Title Location Description Attendees