@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 ConnectedChoice( options: List>, 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"