- Implemented a new Next Lesson widget to display the upcoming lesson details on the home screen. - Updated Today widget to dynamically adjust the number of lessons displayed based on available space. - Enhanced the Settings screen to allow users to manage hidden lessons more effectively. - Introduced harmonized color palettes for better visual consistency across themes. - Updated the timetable API to support a new range parameter for semester-based queries. - Added tests to ensure the new semester range functionality works as expected. - Refactored widget styling for improved appearance on devices with different Android versions.
544 lines
22 KiB
Kotlin
544 lines
22 KiB
Kotlin
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||
|
||
package ritm.schedule.ui
|
||
|
||
import androidx.compose.foundation.text.InlineTextContent
|
||
import androidx.compose.foundation.text.appendInlineContent
|
||
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
|
||
import androidx.compose.material3.LocalContentColor
|
||
import androidx.compose.ui.graphics.takeOrElse
|
||
import androidx.compose.ui.text.Placeholder
|
||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||
import androidx.compose.ui.text.buildAnnotatedString
|
||
import androidx.compose.ui.unit.sp
|
||
import androidx.compose.ui.unit.takeOrElse
|
||
import ritm.schedule.ui.theme.harmonizeWith
|
||
import androidx.compose.foundation.clickable
|
||
import androidx.compose.foundation.layout.ColumnScope
|
||
import androidx.compose.material3.HorizontalDivider
|
||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||
import androidx.compose.animation.animateColorAsState
|
||
import androidx.compose.foundation.border
|
||
import androidx.compose.ui.graphics.lerp
|
||
import ritm.schedule.ui.theme.page
|
||
import ritm.schedule.ui.theme.card
|
||
import ritm.schedule.ui.theme.page
|
||
import androidx.compose.animation.core.animateDpAsState
|
||
import androidx.compose.foundation.BorderStroke
|
||
import androidx.compose.foundation.background
|
||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||
import androidx.compose.foundation.selection.selectable
|
||
import androidx.compose.ui.input.pointer.pointerInput
|
||
import androidx.compose.ui.platform.LocalDensity
|
||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||
import androidx.compose.ui.semantics.customActions
|
||
import kotlin.math.abs
|
||
import androidx.compose.foundation.layout.Arrangement
|
||
import androidx.compose.foundation.layout.Box
|
||
import androidx.compose.foundation.layout.BoxScope
|
||
import androidx.compose.foundation.layout.Column
|
||
import androidx.compose.foundation.layout.Row
|
||
import androidx.compose.foundation.layout.Spacer
|
||
import androidx.compose.foundation.layout.fillMaxSize
|
||
import androidx.compose.foundation.layout.fillMaxWidth
|
||
import androidx.compose.foundation.layout.heightIn
|
||
import androidx.compose.foundation.layout.padding
|
||
import androidx.compose.foundation.layout.size
|
||
import androidx.compose.foundation.layout.width
|
||
import androidx.compose.foundation.layout.widthIn
|
||
import androidx.compose.foundation.selection.selectableGroup
|
||
import androidx.compose.foundation.shape.CircleShape
|
||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||
import androidx.compose.material.icons.Icons
|
||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
|
||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||
import androidx.compose.material.icons.rounded.Check
|
||
import androidx.compose.material.icons.rounded.Close
|
||
import androidx.compose.material.icons.rounded.Search
|
||
import androidx.compose.material3.ButtonGroupDefaults
|
||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||
import androidx.compose.material3.Icon
|
||
import androidx.compose.material3.IconButton
|
||
import androidx.compose.material3.ListItemDefaults
|
||
import androidx.compose.material3.SegmentedListItem
|
||
import androidx.compose.material3.MaterialTheme
|
||
import androidx.compose.material3.SearchBarDefaults
|
||
import androidx.compose.material3.Surface
|
||
import androidx.compose.material3.Text
|
||
import androidx.compose.material3.ToggleButton
|
||
import androidx.compose.material3.ToggleButtonDefaults
|
||
import androidx.compose.material3.TopAppBarColors
|
||
import androidx.compose.material3.TopAppBarDefaults
|
||
import androidx.compose.foundation.layout.WindowInsets
|
||
import androidx.compose.foundation.layout.asPaddingValues
|
||
import androidx.compose.foundation.layout.navigationBars
|
||
import androidx.compose.runtime.Composable
|
||
import androidx.compose.foundation.text.input.clearText
|
||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||
import androidx.compose.material3.rememberSearchBarState
|
||
import androidx.compose.runtime.LaunchedEffect
|
||
import androidx.compose.runtime.getValue
|
||
import androidx.compose.runtime.snapshotFlow
|
||
import androidx.compose.ui.Alignment
|
||
import androidx.compose.ui.Modifier
|
||
import androidx.compose.ui.graphics.Color
|
||
import androidx.compose.ui.graphics.vector.ImageVector
|
||
import androidx.compose.ui.semantics.Role
|
||
import androidx.compose.ui.semantics.contentDescription
|
||
import androidx.compose.ui.semantics.heading
|
||
import androidx.compose.ui.semantics.role
|
||
import androidx.compose.ui.semantics.selected
|
||
import androidx.compose.ui.semantics.semantics
|
||
import androidx.compose.ui.text.TextStyle
|
||
import androidx.compose.ui.text.style.TextOverflow
|
||
import androidx.compose.ui.unit.Dp
|
||
import androidx.compose.ui.unit.dp
|
||
import ritm.schedule.data.Week
|
||
import ritm.schedule.data.shortWeekday
|
||
import ritm.schedule.data.today
|
||
import ritm.schedule.data.weekSpan
|
||
import ritm.schedule.ui.theme.WeekColors
|
||
import java.time.LocalDate
|
||
|
||
/** App bars sit on the tinted page and take a deeper tint once content scrolls under them. */
|
||
@Composable
|
||
fun appBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors(
|
||
containerColor = MaterialTheme.colorScheme.page,
|
||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||
)
|
||
|
||
/** Lists scroll under the translucent navigation bar and end above it. */
|
||
@Composable
|
||
fun navBarPadding(): Dp = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||
|
||
/** Readable line length on wide windows (M3 keeps content under 840dp). */
|
||
val MAX_CONTENT = 720.dp
|
||
|
||
/** Centres content and caps its width on tablets, foldables and landscape phones. */
|
||
@Composable
|
||
fun ContentColumn(content: @Composable BoxScope.() -> Unit) {
|
||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||
Box(Modifier.widthIn(max = MAX_CONTENT).fillMaxSize(), content = content)
|
||
}
|
||
}
|
||
|
||
/** A section or screen title that TalkBack announces as a heading. */
|
||
@Composable
|
||
fun Heading(
|
||
text: String,
|
||
modifier: Modifier = Modifier,
|
||
style: TextStyle = MaterialTheme.typography.titleMediumEmphasized,
|
||
) {
|
||
Text(text, style = style, modifier = modifier.semantics { heading() })
|
||
}
|
||
|
||
/** M3 Expressive connected button group: one choice out of a few. */
|
||
@Composable
|
||
fun <T> ConnectedChoice(
|
||
options: List<Pair<T, String>>,
|
||
selected: T,
|
||
onSelect: (T) -> Unit,
|
||
modifier: Modifier = Modifier,
|
||
) {
|
||
Row(
|
||
modifier.selectableGroup(),
|
||
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
|
||
) {
|
||
options.forEachIndexed { index, (value, label) ->
|
||
val checked = value == selected
|
||
ToggleButton(
|
||
checked = checked,
|
||
onCheckedChange = { onSelect(value) },
|
||
modifier = Modifier.weight(1f).semantics { role = Role.RadioButton },
|
||
// Unselected segments need their own tonal fill to read as one group.
|
||
colors = ToggleButtonDefaults.colors(
|
||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||
checkedContainerColor = MaterialTheme.colorScheme.primary,
|
||
checkedContentColor = MaterialTheme.colorScheme.onPrimary,
|
||
),
|
||
shapes = when (index) {
|
||
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
|
||
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
|
||
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
|
||
},
|
||
) {
|
||
if (checked) {
|
||
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp))
|
||
Spacer(Modifier.width(6.dp))
|
||
}
|
||
Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** M3 search input field on a tonal container; reports every edit through [onQueryChange]. */
|
||
@Composable
|
||
fun SearchField(query: String, onQueryChange: (String) -> Unit, placeholder: String, modifier: Modifier = Modifier) {
|
||
val text = rememberTextFieldState(query)
|
||
val bar = rememberSearchBarState()
|
||
LaunchedEffect(text) {
|
||
snapshotFlow { text.text.toString() }.collect { onQueryChange(it) }
|
||
}
|
||
Surface(
|
||
shape = SearchBarDefaults.inputFieldShape,
|
||
color = MaterialTheme.colorScheme.card,
|
||
modifier = modifier.fillMaxWidth(),
|
||
) {
|
||
SearchBarDefaults.InputField(
|
||
textFieldState = text,
|
||
searchBarState = bar,
|
||
onSearch = {},
|
||
placeholder = { Text(placeholder, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||
trailingIcon = {
|
||
if (text.text.isNotEmpty()) IconButton(onClick = { text.clearText() }) {
|
||
Icon(Icons.Rounded.Close, contentDescription = "Очистить")
|
||
}
|
||
},
|
||
)
|
||
}
|
||
}
|
||
|
||
/** Corners of an item in a segmented group: extra-large outside, small between items. */
|
||
@Composable
|
||
fun segmentShape(index: Int, count: Int): RoundedCornerShape {
|
||
val outer = MaterialTheme.shapes.extraLarge
|
||
val inner = MaterialTheme.shapes.small
|
||
val first = index == 0
|
||
val last = index == count - 1
|
||
return RoundedCornerShape(
|
||
topStart = if (first) outer.topStart else inner.topStart,
|
||
topEnd = if (first) outer.topEnd else inner.topEnd,
|
||
bottomEnd = if (last) outer.bottomEnd else inner.bottomEnd,
|
||
bottomStart = if (last) outer.bottomStart else inner.bottomStart,
|
||
)
|
||
}
|
||
|
||
/** An M3 Expressive list item in a segmented list: shapes and colours from ListItemDefaults. */
|
||
@Composable
|
||
fun SegmentedItem(
|
||
index: Int,
|
||
count: Int,
|
||
headline: String,
|
||
supporting: String?,
|
||
onClick: () -> Unit,
|
||
enabled: Boolean = true,
|
||
leading: (@Composable () -> Unit)? = null,
|
||
trailing: (@Composable () -> Unit)? = null,
|
||
) {
|
||
SegmentedListItem(
|
||
onClick = onClick,
|
||
enabled = enabled,
|
||
leadingContent = leading,
|
||
trailingContent = trailing,
|
||
supportingContent = supporting?.takeIf { it.isNotEmpty() }?.let { text -> { Text(text) } },
|
||
shapes = ListItemDefaults.segmentedShapes(index, count),
|
||
// Segments are cards on the tinted page.
|
||
colors = ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.card),
|
||
) {
|
||
Text(headline, style = MaterialTheme.typography.titleMediumEmphasized)
|
||
}
|
||
}
|
||
|
||
/** Items of a segmented list sit the M3 segmented gap apart. */
|
||
@Composable
|
||
fun SegmentedList(content: @Composable () -> Unit) {
|
||
Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { content() }
|
||
}
|
||
|
||
/** A tonal circular container for a list item's leading icon. */
|
||
@Composable
|
||
fun LeadingIcon(icon: ImageVector) {
|
||
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.size(40.dp)) {
|
||
Icon(icon, contentDescription = null, modifier = Modifier.padding(8.dp))
|
||
}
|
||
}
|
||
|
||
/** "Неделя 7 [белая] 12–18 октября" with arrows to the neighbouring weeks. */
|
||
@Composable
|
||
fun WeekHeader(
|
||
week: Week?,
|
||
monday: LocalDate,
|
||
onPrevious: (() -> Unit)?,
|
||
onNext: (() -> Unit)?,
|
||
trailing: @Composable () -> Unit = {},
|
||
) {
|
||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||
IconButton(onClick = { onPrevious?.invoke() }, enabled = onPrevious != null) {
|
||
Icon(Icons.AutoMirrored.Rounded.KeyboardArrowLeft, contentDescription = "Предыдущая неделя")
|
||
}
|
||
Column(Modifier.weight(1f)) {
|
||
Heading(week?.let { "Неделя ${it.n}" } ?: "Вне таблицы недель")
|
||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||
Text(
|
||
weekSpan(monday, monday.plusDays(6)),
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||
)
|
||
week?.let {
|
||
Spacer(Modifier.width(12.dp))
|
||
WeekMark(it)
|
||
}
|
||
}
|
||
}
|
||
trailing()
|
||
IconButton(onClick = { onNext?.invoke() }, enabled = onNext != null) {
|
||
Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = "Следующая неделя")
|
||
}
|
||
}
|
||
}
|
||
|
||
/** "● зелёная неделя": a small mark in the week's own colour next to a calm label. */
|
||
@Composable
|
||
fun WeekMark(week: Week, modifier: Modifier = Modifier, short: Boolean = false) {
|
||
val dark = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||
Box(
|
||
Modifier
|
||
.size(10.dp)
|
||
.background(if (week.white) Color.White else WeekColors.mark(dark).harmonizeWith(MaterialTheme.colorScheme.primary), CircleShape)
|
||
.then(if (week.white) Modifier.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) else Modifier),
|
||
)
|
||
Spacer(Modifier.width(6.dp))
|
||
Text(
|
||
(if (week.white) "белая" else "зелёная") + if (short) "" else " неделя",
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||
maxLines = 1,
|
||
overflow = TextOverflow.Ellipsis,
|
||
)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The schedule header's background: green weeks tint it softly, white weeks keep the page.
|
||
* Swiping between weeks fades one into the other.
|
||
*/
|
||
@Composable
|
||
fun weekTint(week: Week?): Color =
|
||
animateColorAsState(weekTintOf(week), MaterialTheme.motionScheme.defaultEffectsSpec(), label = "week-tint").value
|
||
|
||
/** [weekTint] without the fade: each page of a week pager carries its own week's colour. */
|
||
@Composable
|
||
fun weekTintOf(week: Week?): Color {
|
||
val scheme = MaterialTheme.colorScheme
|
||
val dark = scheme.surface.luminance() < 0.5f
|
||
return if (week == null || week.white) scheme.page
|
||
else lerp(scheme.page, WeekColors.container(false, dark).harmonizeWith(scheme.primary), if (dark) 0.55f else 0.75f)
|
||
}
|
||
|
||
/**
|
||
* Seven days of a week in School Planner's calm style: the weekday, the date in a circle,
|
||
* a filled circle for the selected day and primary text for today. Swiping between weeks
|
||
* is the caller's pager; TalkBack gets the same moves as custom actions.
|
||
*/
|
||
@Composable
|
||
fun DayStrip(
|
||
monday: LocalDate,
|
||
selected: LocalDate?,
|
||
hasLessons: (LocalDate) -> Boolean,
|
||
enabled: (LocalDate) -> Boolean = { true },
|
||
onSelect: (LocalDate) -> Unit,
|
||
onWeek: ((Int) -> Unit)? = null,
|
||
modifier: Modifier = Modifier,
|
||
) {
|
||
Row(
|
||
modifier
|
||
.fillMaxWidth()
|
||
.selectableGroup()
|
||
.then(
|
||
if (onWeek == null) Modifier
|
||
else Modifier.semantics {
|
||
customActions = listOf(
|
||
CustomAccessibilityAction("Предыдущая неделя") { onWeek(-1); true },
|
||
CustomAccessibilityAction("Следующая неделя") { onWeek(1); true },
|
||
)
|
||
},
|
||
),
|
||
) {
|
||
repeat(7) { i ->
|
||
val date = monday.plusDays(i.toLong())
|
||
val isSelected = date == selected
|
||
val isToday = date == today()
|
||
val busy = hasLessons(date)
|
||
val scheme = MaterialTheme.colorScheme
|
||
Column(
|
||
horizontalAlignment = Alignment.CenterHorizontally,
|
||
modifier = Modifier
|
||
.weight(1f)
|
||
// No pressed ripple: the filled circle already answers the tap.
|
||
.selectable(
|
||
selected = isSelected,
|
||
enabled = enabled(date),
|
||
role = Role.Tab,
|
||
interactionSource = null,
|
||
indication = null,
|
||
onClick = { onSelect(date) },
|
||
)
|
||
.padding(vertical = 6.dp)
|
||
.semantics(mergeDescendants = true) {
|
||
contentDescription = "${date.shortWeekday()} ${date.dayOfMonth}" +
|
||
(if (isToday) ", сегодня" else "") + (if (busy) "" else ", нет занятий")
|
||
},
|
||
) {
|
||
Text(
|
||
date.shortWeekday(),
|
||
style = MaterialTheme.typography.labelMedium,
|
||
color = if (isToday) scheme.primary else scheme.onSurfaceVariant,
|
||
)
|
||
Box(
|
||
contentAlignment = Alignment.Center,
|
||
modifier = Modifier
|
||
.padding(top = 4.dp)
|
||
.size(40.dp)
|
||
.background(if (isSelected) scheme.primary else Color.Transparent, CircleShape),
|
||
) {
|
||
Text(
|
||
"${date.dayOfMonth}",
|
||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||
color = when {
|
||
isSelected -> scheme.onPrimary
|
||
isToday -> scheme.primary
|
||
busy -> scheme.onSurface
|
||
else -> scheme.onSurfaceVariant
|
||
},
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A School Planner style list card: the primary icon-and-title header, then full-width rows
|
||
* (see [CardRow]) separated by inset dividers, and an optional footer action.
|
||
*/
|
||
@Composable
|
||
fun ListCard(
|
||
icon: ImageVector,
|
||
title: String,
|
||
modifier: Modifier = Modifier,
|
||
trailing: (@Composable () -> Unit)? = null,
|
||
content: @Composable ColumnScope.() -> Unit,
|
||
) {
|
||
Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.card, modifier = modifier.fillMaxWidth()) {
|
||
Column(Modifier.padding(bottom = 8.dp)) {
|
||
Row(
|
||
verticalAlignment = Alignment.CenterVertically,
|
||
modifier = Modifier.padding(start = 20.dp, end = 12.dp, top = 16.dp, bottom = 8.dp),
|
||
) {
|
||
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primary, modifier = Modifier.size(32.dp)) {
|
||
Icon(icon, contentDescription = null, modifier = Modifier.padding(6.dp))
|
||
}
|
||
Spacer(Modifier.width(12.dp))
|
||
Heading(
|
||
title,
|
||
style = MaterialTheme.typography.titleMediumEmphasized.copy(color = MaterialTheme.colorScheme.primary),
|
||
modifier = Modifier.weight(1f),
|
||
)
|
||
trailing?.invoke()
|
||
}
|
||
content()
|
||
}
|
||
}
|
||
}
|
||
|
||
/** A row of a [ListCard]: a coloured avatar, a title with a supporting line and a trailing slot. */
|
||
@Composable
|
||
fun CardRow(
|
||
avatar: String,
|
||
color: SubjectColor,
|
||
title: String,
|
||
subtitle: String?,
|
||
onClick: (() -> Unit)?,
|
||
enabled: Boolean = true,
|
||
trailing: (@Composable () -> Unit)? = null,
|
||
) {
|
||
Row(
|
||
verticalAlignment = Alignment.CenterVertically,
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.then(if (onClick != null) Modifier.clickable(enabled = enabled, role = Role.Button, onClick = onClick) else Modifier)
|
||
.padding(horizontal = 20.dp, vertical = 10.dp),
|
||
) {
|
||
Box(
|
||
contentAlignment = Alignment.Center,
|
||
modifier = Modifier.size(44.dp).background(color.container, CircleShape).clearAndSetSemantics {},
|
||
) {
|
||
Text(avatar, style = MaterialTheme.typography.titleSmallEmphasized, color = color.content, maxLines = 1)
|
||
}
|
||
Spacer(Modifier.width(16.dp))
|
||
Column(Modifier.weight(1f)) {
|
||
Text(title, style = MaterialTheme.typography.bodyLargeEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||
subtitle?.takeIf { it.isNotEmpty() }?.let {
|
||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||
}
|
||
}
|
||
trailing?.let {
|
||
Spacer(Modifier.width(8.dp))
|
||
it()
|
||
}
|
||
}
|
||
}
|
||
|
||
/** The divider between [CardRow]s: it starts under the text, as in School Planner's lists. */
|
||
@Composable
|
||
fun RowDivider() {
|
||
HorizontalDivider(Modifier.padding(start = 80.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f))
|
||
}
|
||
|
||
/** "Скопюк В.А." → "СВ", "23-БА" → "23": a short avatar label. */
|
||
fun avatarLabel(text: String): String {
|
||
val digits = text.takeWhile { it.isDigit() }
|
||
if (digits.length >= 2) return digits.take(2)
|
||
val parts = text.split(' ', '.', '-').filter { it.isNotEmpty() }
|
||
return parts.take(2).joinToString("") { it.first().uppercase() }.ifEmpty { "?" }
|
||
}
|
||
|
||
/**
|
||
* Text with time ranges like "10:05 → 11:30": each "→" is drawn as the Material arrow icon,
|
||
* sized to the text and in its colour, instead of the font's arrow glyph.
|
||
*/
|
||
@Composable
|
||
fun TimeText(
|
||
text: String,
|
||
style: TextStyle,
|
||
modifier: Modifier = Modifier,
|
||
color: Color = Color.Unspecified,
|
||
maxLines: Int = Int.MAX_VALUE,
|
||
) {
|
||
val tint = color.takeOrElse { style.color.takeOrElse { LocalContentColor.current } }
|
||
val parts = text.split("→")
|
||
val annotated = buildAnnotatedString {
|
||
parts.forEachIndexed { i, part ->
|
||
if (i > 0) appendInlineContent(ARROW, "→")
|
||
append(part)
|
||
}
|
||
}
|
||
val size = style.fontSize.takeOrElse { 14.sp }
|
||
val inline = mapOf(
|
||
ARROW to InlineTextContent(Placeholder(size, size, PlaceholderVerticalAlign.TextCenter)) {
|
||
Icon(
|
||
Icons.AutoMirrored.Rounded.ArrowForward,
|
||
contentDescription = "до",
|
||
tint = tint,
|
||
modifier = Modifier.fillMaxSize(),
|
||
)
|
||
},
|
||
)
|
||
Text(
|
||
annotated,
|
||
style = style,
|
||
color = color,
|
||
modifier = modifier,
|
||
maxLines = maxLines,
|
||
overflow = TextOverflow.Ellipsis,
|
||
inlineContent = inline,
|
||
)
|
||
}
|
||
|
||
private const val ARROW = "arrow"
|