Files
psu-schedule/android/app/src/main/kotlin/ritm/schedule/ui/Common.kt
Zakhar Timoshenko d900e038b6 feat: add Next Lesson widget and enhance existing widgets
- 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.
2026-10-18 00:39:32 +03:00

544 lines
22 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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"