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