Compare commits
3 Commits
release/v2
...
feat/219-m
| Author | SHA1 | Date | |
|---|---|---|---|
| 5be8884e9f | |||
| 065afdb2b8 | |||
| f7e9990c4e |
@@ -172,7 +172,8 @@ fun BlockTimeLabel(
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
// Regular weight against the title's medium above it (#219).
|
||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
maxLines = maxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/**
|
||||
* How an event's time is set against its title (#219).
|
||||
*
|
||||
* The label styles carry a medium weight and 0.5sp of tracking, which left a
|
||||
* time reading as part of the title next to it. The time steps down to regular
|
||||
* and closes the tracking up, so the two read as a time and a title — whether
|
||||
* they sit on one line, as they do on a wide month chip, or on two, as they do
|
||||
* in a timed block.
|
||||
*
|
||||
* The ink is not dimmed further to do it: at this size a fainter grey would
|
||||
* give up the contrast [SECONDARY_INK_ALPHA] exists to hold, so the weight
|
||||
* carries the difference instead.
|
||||
*/
|
||||
private val TIME_WEIGHT = FontWeight.Normal
|
||||
private val TIME_TRACKING = 0.sp
|
||||
|
||||
/** Ink for a title, against [SECONDARY_INK_ALPHA] for the time beside it. */
|
||||
const val TITLE_INK_ALPHA = 0.85f
|
||||
|
||||
/** [this] set as an event's time rather than as its title. */
|
||||
fun TextStyle.asEventTime(): TextStyle =
|
||||
copy(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
|
||||
|
||||
/** The same, for a time that shares one line with the title after it. */
|
||||
private val TIME_SPAN = SpanStyle(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
|
||||
|
||||
/** Text reading [time], set apart in [timeInk], before [title]. */
|
||||
fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedString =
|
||||
buildAnnotatedString {
|
||||
if (time != null) {
|
||||
withStyle(TIME_SPAN.copy(color = timeInk)) { append(time) }
|
||||
append(" ")
|
||||
}
|
||||
append(title)
|
||||
}
|
||||
|
||||
/**
|
||||
* The characters of title that have to survive a time prefix for it to be worth
|
||||
* its place — the threshold is a readable title, so it is stated in title
|
||||
* characters and priced at the surface's own text style rather than guessed in
|
||||
* dp. Lowercase Latin of average advance: not "iii", which would let the time
|
||||
* in where nothing else fits, nor "WWW", which would keep it out of a column
|
||||
* with room to spare.
|
||||
*
|
||||
* Seven rather than a rounder eight because the 12-hour convention spends a
|
||||
* meridiem the 24-hour one does not, and eight would have cost a landscape
|
||||
* phone the time in 12-hour while granting it in 24-hour. A narrow portrait
|
||||
* column is nowhere near either figure, so the gate is unchanged where it
|
||||
* matters most.
|
||||
*/
|
||||
private const val TITLE_SAMPLE = "notepad"
|
||||
|
||||
/** The widest wall-clock time in either convention: two-digit hour, and a meridiem in 12-hour. */
|
||||
private const val SAMPLE_HOUR = 12
|
||||
private const val SAMPLE_MINUTE = 45
|
||||
|
||||
/**
|
||||
* The narrowest run of [style] text that may carry a time in front of a title:
|
||||
* wide enough for the widest time in the current convention plus [TITLE_SAMPLE].
|
||||
*
|
||||
* Measured rather than a device breakpoint, so it follows the font scale, the
|
||||
* 12/24-hour setting, the locale's own time format and whatever else has
|
||||
* already been taken off the width — landscape, an unfolded foldable and a
|
||||
* tablet all come out wide enough without any of them being named.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberInlineTimeWidth(style: TextStyle): Dp {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val locale = currentLocale()
|
||||
val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale)
|
||||
return remember(sample, style, density, measurer) {
|
||||
val label = inlineTimeLabel(sample, TITLE_SAMPLE, Color.Unspecified)
|
||||
with(density) { measurer.measure(label, style).size.width.toDp() }
|
||||
}
|
||||
}
|
||||
@@ -738,7 +738,8 @@ private fun DragCopy(
|
||||
if (label != null) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
// As the block it lifted off sets its own time (#219).
|
||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
maxLines = 1,
|
||||
overflow = titleOverflow.overflow,
|
||||
softWrap = titleOverflow.softWrap,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
|
||||
/** Padding between a month chip's edge and its text. */
|
||||
internal val MONTH_CHIP_TEXT_PADDING = 4.dp
|
||||
|
||||
/** A chip's own inset inside its day cell, on top of the cell's gap. */
|
||||
internal val MONTH_CHIP_INSET = CELL_GAP + 1.dp
|
||||
|
||||
/** Horizontal space a chip spends on chrome rather than on text, both sides. */
|
||||
private val CHIP_CHROME = (MONTH_CHIP_INSET + MONTH_CHIP_TEXT_PADDING) * 2f
|
||||
|
||||
/**
|
||||
* The start time a chip shows before its title (#219), or null when it has none
|
||||
* to show: an all-day chip never carries a time, and neither does a bar carried
|
||||
* in from an earlier week, whose start is not in this segment.
|
||||
*/
|
||||
internal fun monthChipTime(
|
||||
event: EventInstance,
|
||||
continuesLeft: Boolean,
|
||||
zone: TimeZone,
|
||||
is24Hour: Boolean,
|
||||
locale: Locale,
|
||||
): String? {
|
||||
if (event.isAllDay || continuesLeft) return null
|
||||
val start = event.start.toLocalDateTime(zone).time
|
||||
return formatTimeOfDay(start.hour, start.minute, is24Hour, locale)
|
||||
}
|
||||
|
||||
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */
|
||||
@Composable
|
||||
internal fun rememberMonthTimeChipWidth(): Dp =
|
||||
rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + CHIP_CHROME
|
||||
@@ -146,6 +146,10 @@ import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.ui.common.TITLE_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.monthBarShape
|
||||
@@ -499,6 +503,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
val density = LocalDensity.current
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val moveInFlight = moveInFlight()
|
||||
// Here rather than beside the controller: this reads [drag], which changes
|
||||
@@ -556,6 +561,9 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
// The copy is a cut-out of the chip it lifted off, so it answers the
|
||||
// gate at the same width the grid did.
|
||||
showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth,
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
@@ -568,7 +576,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
}
|
||||
.width(with(density) { drag.sizePx.width.toDp() })
|
||||
.height(with(density) { drag.sizePx.height.toDp() })
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.04f * lift
|
||||
scaleY = 1f + 0.04f * lift
|
||||
@@ -758,7 +766,7 @@ private val DAY_NUMBER_HEIGHT = 22.dp
|
||||
private val WEEK_NUMBER_GUTTER = 40.dp
|
||||
private val DAY_NUMBER_GAP = 4.dp
|
||||
private val CELL_TOP_PADDING = 6.dp
|
||||
private val CELL_GAP = 2.dp
|
||||
internal val CELL_GAP = 2.dp
|
||||
/** Named separately because the split style's selection outline draws its own
|
||||
* rounded rect and has to match this radius exactly. */
|
||||
private val CELL_CORNER = 12.dp
|
||||
@@ -1983,6 +1991,10 @@ private fun MonthWeekRow(
|
||||
),
|
||||
) {
|
||||
val colW = maxWidth / 7
|
||||
// The width a chip needs before its start time is worth the title
|
||||
// characters it costs (#219) — measured here, so the same column in
|
||||
// landscape or on a foldable answers differently on its own.
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
|
||||
// Per-day background pills — same surfaceContainer rounded surface the
|
||||
// week/day views use, so the three views share one visual language.
|
||||
@@ -2062,6 +2074,7 @@ private fun MonthWeekRow(
|
||||
continuesLeft = span.continuesLeft,
|
||||
continuesRight = span.continuesRight,
|
||||
days = week.days.subList(span.startCol, span.endCol + 1),
|
||||
showTime = colW * cols >= timeChipWidth,
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * span.startCol,
|
||||
@@ -2085,7 +2098,7 @@ private fun MonthWeekRow(
|
||||
)
|
||||
.width(colW * cols)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||
)
|
||||
// One invisible slice of the bar per further column it
|
||||
// covers. A multi-day event has a dot on every day but
|
||||
@@ -2109,7 +2122,7 @@ private fun MonthWeekRow(
|
||||
)
|
||||
.width(colW)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2132,6 +2145,7 @@ private fun MonthWeekRow(
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
days = listOf(d),
|
||||
showTime = colW >= timeChipWidth,
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * col,
|
||||
@@ -2140,7 +2154,7 @@ private fun MonthWeekRow(
|
||||
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
||||
.width(colW)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||
@@ -2427,7 +2441,10 @@ private fun shortMonthName(date: LocalDate): String {
|
||||
}
|
||||
}
|
||||
|
||||
/** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */
|
||||
/**
|
||||
* A filled event pill/bar — softened (or raw) fill, title clipped to one line,
|
||||
* with the start time before it where the chip is wide enough ([showTime], #219).
|
||||
*/
|
||||
@Composable
|
||||
private fun MonthBar(
|
||||
event: de.jeanlucmakiola.calendula.domain.EventInstance,
|
||||
@@ -2441,12 +2458,29 @@ private fun MonthBar(
|
||||
* the provider hands the re-read instance a new one.
|
||||
*/
|
||||
days: List<LocalDate>? = null,
|
||||
/** Whether this chip has the width to carry its start time (#219). */
|
||||
showTime: Boolean = false,
|
||||
) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val time = if (showTime) {
|
||||
monthChipTime(
|
||||
event = event,
|
||||
continuesLeft = continuesLeft,
|
||||
zone = zone,
|
||||
is24Hour = LocalUse24HourFormat.current,
|
||||
locale = currentLocale(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(event.color, dark, soften)
|
||||
// The same title/secondary ink pairing the week and day blocks use, with
|
||||
// the time on the quieter half.
|
||||
val label = inlineTimeLabel(time, title, eventInk(fill, alpha = SECONDARY_INK_ALPHA))
|
||||
val moveAction = eventMoveAction(event)
|
||||
// The source stays put as a ghost while its floating copy travels.
|
||||
val monthDrag = LocalMonthDrag.current
|
||||
@@ -2458,21 +2492,21 @@ private fun MonthBar(
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||
.background(fill, shape)
|
||||
.padding(horizontal = 4.dp)
|
||||
.padding(horizontal = MONTH_CHIP_TEXT_PADDING)
|
||||
.semantics {
|
||||
contentDescription = title
|
||||
contentDescription = label.text
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
},
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val titleOverflow = eventTitleOverflow()
|
||||
Text(
|
||||
text = title,
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = titleOverflow.overflow,
|
||||
softWrap = titleOverflow.softWrap,
|
||||
color = eventInk(fill),
|
||||
color = eventInk(fill, alpha = TITLE_INK_ALPHA),
|
||||
textDecoration = declinedDecoration(event.isDeclined),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.Locale
|
||||
|
||||
/** Which month chips carry a start time, and how it reads (#219). */
|
||||
class MonthChipTimeTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
private val locale = Locale.UK
|
||||
private val day = LocalDate(2026, Month.SEPTEMBER, 2)
|
||||
|
||||
private fun timed(hour: Int, minute: Int) = EventInstance(
|
||||
instanceId = 1L,
|
||||
eventId = 1L,
|
||||
calendarId = 1L,
|
||||
title = "Standup",
|
||||
start = day.atTime(hour, minute).toInstant(zone),
|
||||
end = day.atTime(hour + 1, minute).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = 0,
|
||||
location = null,
|
||||
)
|
||||
|
||||
private fun allDay() = EventInstance(
|
||||
instanceId = 2L,
|
||||
eventId = 2L,
|
||||
calendarId = 1L,
|
||||
title = "Holiday",
|
||||
start = day.atTime(0, 0).toInstant(zone),
|
||||
end = day.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
|
||||
isAllDay = true,
|
||||
color = 0,
|
||||
location = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a timed chip shows its start in the 24-hour convention`() {
|
||||
val time = monthChipTime(timed(9, 5), continuesLeft = false, zone, is24Hour = true, locale)
|
||||
assertThat(time).isEqualTo("09:05")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the 12-hour setting is honoured`() {
|
||||
val time = monthChipTime(timed(14, 30), continuesLeft = false, zone, is24Hour = false, locale)
|
||||
assertThat(time).isEqualTo("2:30 pm")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the time is set apart from the title it precedes`() {
|
||||
val label = inlineTimeLabel("09:05", "Standup", Color.Black)
|
||||
assertThat(label.text).isEqualTo("09:05 Standup")
|
||||
// Only the time is restyled: the title keeps labelSmall as the theme
|
||||
// sets it, so the two read as separate things on one line (#219).
|
||||
val spans = label.spanStyles
|
||||
assertThat(spans).hasSize(1)
|
||||
assertThat(spans.single().start).isEqualTo(0)
|
||||
assertThat(spans.single().end).isEqualTo("09:05".length)
|
||||
assertThat(spans.single().item.fontWeight).isEqualTo(FontWeight.Normal)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chip without a time is styled title and nothing else`() {
|
||||
val label = inlineTimeLabel(null, "Standup", Color.Black)
|
||||
assertThat(label.text).isEqualTo("Standup")
|
||||
assertThat(label.spanStyles).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day chip never shows a time`() {
|
||||
val time = monthChipTime(allDay(), continuesLeft = false, zone, is24Hour = true, locale)
|
||||
assertThat(time).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bar carried in from the previous week shows none either`() {
|
||||
// Its start is not in this segment, so printing it would put a time on a
|
||||
// row the event does not begin on.
|
||||
val time = monthChipTime(timed(9, 5), continuesLeft = true, zone, is24Hour = true, locale)
|
||||
assertThat(time).isNull()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user