feat: Implement support for foldable devices with enhanced UI layout

- Added handling for foldable devices in MainActivity, including hinge detection and layout adjustments.
- Introduced new UI components for managing two-pane layouts on tablets and foldables.
- Updated CalendarScreen, HomeScreen, PlansScreen, and SearchScreen to utilize the new foldable layout logic.
- Created Fold.kt and Tabletop.kt to manage fold-related UI behavior and display.
- Enhanced animations for menu visibility on foldable devices.
- Updated Motion.kt to include new motion duration constants.
- Added dependency for androidx.window to support foldable features.
This commit is contained in:
2026-10-20 01:12:28 +03:00
parent 9b5062f0ed
commit 3055cbaf3c
13 changed files with 202986 additions and 194083 deletions

View File

@@ -67,6 +67,7 @@ dependencies {
implementation(libs.glance.material3)
implementation(libs.work.runtime)
implementation(libs.material)
implementation(libs.window)
coreLibraryDesugaring(libs.desugar)
testImplementation(libs.junit)
}

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,15 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import org.xtimms.psu.schedule.ui.LocalHinge
import org.xtimms.psu.schedule.ui.EmphasizedDecelerate
import org.xtimms.psu.schedule.ui.EmphasizedAccelerate
import org.xtimms.psu.schedule.ui.LocalFoldTablet
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
@@ -157,7 +166,10 @@ class MainActivity : ComponentActivity() {
)
onDispose {}
}
// The fold of a foldable phone, for screens that split their panes around it.
val hinge = org.xtimms.psu.schedule.ui.rememberHinge(this)
ScheduleTheme(mode = s?.theme ?: ThemeMode.System, dynamicColor = s?.dynamicColor ?: false) {
androidx.compose.runtime.CompositionLocalProvider(org.xtimms.psu.schedule.ui.LocalHinge provides hinge) {
Surface(color = MaterialTheme.colorScheme.page, modifier = Modifier.fillMaxSize()) {
// Wait for the stored settings so a returning student never sees the onboarding.
if (s != null) {
@@ -173,6 +185,7 @@ class MainActivity : ComponentActivity() {
}
}
}
}
}
}
}
@@ -197,6 +210,7 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
val hasGroup = settings?.current != null
val rail = rememberWideNavigationRailState()
val foldRail = rememberWideNavigationRailState()
// Landscape tablets start with the menu open and keep it beside the screen either way.
val deskRail = rememberWideNavigationRailState(WideNavigationRailValue.Expanded)
val scope = rememberCoroutineScope()
@@ -239,8 +253,12 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
// Behind the pushed-aside screen: the menu's own card-white background.
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.card)) {
val wide = maxWidth >= MEDIUM_WIDTH
val permanent = maxWidth >= EXPANDED_WIDTH
val weekView = weekChoice ?: permanent
// An open book-style foldable gets the tablet layout; a flip phone keeps the phone one.
val foldTablet = LocalHinge.current != null && minOf(maxWidth, maxHeight) >= MEDIUM_WIDTH
// On a foldable the menu stays a narrow rail and opens over the screen, so the page
// beside it is never squeezed; a tablet in landscape keeps it open beside the screen.
val permanent = maxWidth >= EXPANDED_WIDTH && !foldTablet
val weekView = weekChoice ?: (permanent || foldTablet)
BackHandler(enabled = wide && !permanent && (rail.targetValue == WideNavigationRailValue.Expanded)) { scope.launch { rail.collapse() } }
val nav = remember(wide) {
AppNav(
@@ -334,8 +352,9 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
onPicked = {},
)
} else if (wide) MenuRail(
state = rail,
expanded = (rail.targetValue == WideNavigationRailValue.Expanded),
// The foldable's rail in the layout never widens: its menu opens over the screen.
state = if (foldTablet) foldRail else rail,
expanded = !foldTablet && (rail.targetValue == WideNavigationRailValue.Expanded),
items = menuItems,
color = MaterialTheme.colorScheme.page,
onToggle = { scope.launch { rail.toggle() } },
@@ -363,6 +382,7 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
),
) {
CompositionLocalProvider(
LocalFoldTablet provides foldTablet,
LocalMenuProgress provides openState,
LocalSubjectSlots provides slots,
LocalDisplay provides (settings?.display ?: Display()),
@@ -430,6 +450,43 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
)
}
}
// The foldable's menu, opened over the screen with a scrim, as the M3 modal expanded rail.
if (foldTablet) {
val shown = rail.targetValue == WideNavigationRailValue.Expanded
AnimatedVisibility(shown, enter = fadeIn(), exit = fadeOut()) {
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f))
.clickable(interactionSource = null, indication = null, onClickLabel = "Закрыть меню") {
scope.launch { rail.collapse() }
},
)
}
AnimatedVisibility(
shown,
// Enters decelerating, leaves accelerating: md.sys.motion emphasized decelerate/accelerate.
enter = slideInHorizontally(tween(MotionDuration.Medium4, easing = EmphasizedDecelerate)) { -it } + fadeIn(),
exit = slideOutHorizontally(tween(MotionDuration.Short4, easing = EmphasizedAccelerate)) { -it } + fadeOut(),
) {
Surface(
shape = RoundedCornerShape(topEnd = 28.dp, bottomEnd = 28.dp),
color = MaterialTheme.colorScheme.card,
shadowElevation = 6.dp,
modifier = Modifier.fillMaxHeight(),
) {
MenuRail(
state = rememberWideNavigationRailState(WideNavigationRailValue.Expanded),
expanded = true,
items = menuItems,
color = MaterialTheme.colorScheme.card,
modifier = Modifier.widthIn(min = 280.dp),
onToggle = { scope.launch { rail.collapse() } },
onPicked = { scope.launch { rail.collapse() } },
)
}
}
}
}
}

View File

@@ -260,18 +260,19 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
}
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.page)) {
if (maxWidth >= TWO_PANE) {
// Tablets: the month on the left, the chosen day in its own pane, as in School Planner.
if (twoPane(maxWidth)) {
// Tablets: the month on the left, the chosen day in its own pane, as in School Planner;
// a foldable half opened like a book puts one on each side of the fold.
Column(Modifier.fillMaxSize()) {
appBar()
Row(Modifier.fillMaxSize().padding(end = 16.dp)) {
PaneRow(Modifier.fillMaxSize().padding(end = 16.dp), first = { m ->
// Rows stretch to fill the height, as School Planner's month does on a tablet.
BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) {
BoxWithConstraints(m) {
val row = ((maxHeight - 40.dp - GRID_BOTTOM - navBarPadding()) / 6).coerceIn(ROW, 112.dp)
monthBlock(false, row)
}
Spacer(Modifier.width(16.dp))
DetailPane(Modifier.weight(1f)) { agenda(Modifier.fillMaxSize().padding(top = 12.dp)) }
}) {
DetailPane(it) { agenda(Modifier.fillMaxSize().padding(top = 12.dp)) }
}
}
} else Column(Modifier.fillMaxSize().nestedScroll(connection)) {

View File

@@ -0,0 +1,135 @@
package org.xtimms.psu.schedule.ui
import android.app.Activity
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.toComposeRect
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
/**
* The fold of a foldable phone, in window pixels. A fold that separates the screen (half
* opened, or the gap between two screens) is never covered by content: panes end on each side
* of it. A flat fold is just glass, and layouts ignore it.
*/
@Immutable
data class Hinge(val bounds: Rect, val vertical: Boolean, val separating: Boolean, val halfOpened: Boolean) {
/** Half opened like a book: list on one side, detail on the other. */
val book get() = vertical && separating
/** Half opened on a table: content above the fold, controls below it. */
val tabletop get() = !vertical && halfOpened
}
val LocalHinge = staticCompositionLocalOf<Hinge?> { null }
/**
* An open book-style foldable (Galaxy Z Fold, Pixel Fold): a window across a fold whose short
* side is still 600dp or more. It gets the tablet layout, panes aligned to the fold. A flip
* phone opened (Galaxy Z Flip, Razr) is a phone-sized window and keeps the phone layout.
*/
val LocalFoldTablet = staticCompositionLocalOf { false }
/** The window's fold as the phone reports it; null on phones and tablets without one. */
@Composable
fun rememberHinge(activity: Activity): Hinge? {
val hinge by produceState<Hinge?>(null, activity) {
WindowInfoTracker.getOrCreate(activity).windowLayoutInfo(activity).collect { info ->
value = info.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()?.let {
Hinge(
bounds = it.bounds.toComposeRect(),
vertical = it.orientation == FoldingFeature.Orientation.VERTICAL,
separating = it.isSeparating,
halfOpened = it.state == FoldingFeature.State.HALF_OPENED,
)
}
}
}
return hinge
}
/** Where a layout sits in the window, to find the fold inside it. */
@Stable
class FoldSplit {
var origin by mutableStateOf<Offset?>(null)
var size by mutableStateOf(IntSize.Zero)
/** The fold in this layout's own pixels, or null when it does not cross the layout. */
fun hingeIn(hinge: Hinge?): Rect? {
val o = origin ?: return null
val r = hinge?.bounds?.translate(-o.x, -o.y) ?: return null
val inside = r.right > 0f && r.left < size.width && r.bottom > 0f && r.top < size.height
return if (inside) r else null
}
}
fun Modifier.trackFold(split: FoldSplit) = onGloballyPositioned {
split.origin = it.positionInWindow()
split.size = it.size
}
/** Whether a screen of [width] shows two panes: tablets from 840dp, and open book-style foldables. */
@Composable
fun twoPane(width: Dp): Boolean = width >= TWO_PANE || LocalFoldTablet.current
/** Narrower than this, a pane next to the fold is not worth having: the panes ignore the fold. */
private val MIN_PANE = 280.dp
/**
* Two panes side by side. With a vertical fold across them (a book-style foldable, flat or half
* opened) the first ends just before the fold and the second starts just after it, [foldGap]
* apart, as two pages. Otherwise the first is [firstWidth] wide (half of a narrow area) and a
* [gap] separates them.
*/
@Composable
fun PaneRow(
modifier: Modifier = Modifier,
firstWidth: Dp? = null,
gap: Dp = 16.dp,
foldGap: Dp = 24.dp,
first: @Composable RowScope.(Modifier) -> Unit,
second: @Composable RowScope.(Modifier) -> Unit,
) {
val split = remember { FoldSplit() }
val hinge = split.hingeIn(LocalHinge.current?.takeIf { it.vertical })
val density = LocalDensity.current
BoxWithConstraints(modifier.trackFold(split)) {
val total = maxWidth
val start = hinge?.let { with(density) { it.left.toDp() } - foldGap / 2 }
val end = hinge?.let { total - with(density) { it.right.toDp() } - foldGap / 2 }
Row(Modifier.fillMaxSize()) {
if (start != null && end != null && start >= MIN_PANE && end >= MIN_PANE) {
first(Modifier.width(start).fillMaxHeight())
Spacer(Modifier.width(total - start - end))
second(Modifier.weight(1f).fillMaxHeight())
} else {
val fixed = firstWidth?.takeIf { total >= TWO_PANE }
first(if (fixed != null) Modifier.width(fixed).fillMaxHeight() else Modifier.weight(1f).fillMaxHeight())
Spacer(Modifier.width(gap))
second(Modifier.weight(1f).fillMaxHeight())
}
}
}
}

View File

@@ -195,7 +195,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
}
}
else -> BoxWithConstraints(Modifier.fillMaxSize()) {
val twoPane = maxWidth >= TWO_PANE
val twoPane = twoPane(maxWidth)
val overview: @Composable (Modifier) -> Unit = { listModifier ->
val todaySlots = vm.slotsOn(t, date, subgroup)
val next = (1..7).asSequence()
@@ -241,10 +241,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
}
}
}
if (twoPane) Row(Modifier.fillMaxSize()) {
// Tablets: the overview beside this week's grid, as School Planner pairs them.
overview(Modifier.width(440.dp).fillMaxHeight())
DetailPane(Modifier.weight(1f).padding(end = 16.dp)) {
// Tablets: the overview beside this week's grid, as School Planner pairs them;
// a foldable half opened like a book puts one on each side of the fold.
if (twoPane) PaneRow(Modifier.fillMaxSize().padding(end = 16.dp), firstWidth = 440.dp, gap = 0.dp, first = { overview(it) }) {
DetailPane(it) {
WeekPane(vm, t, subgroup, onOpen = { lesson, day -> details = lesson to day })
}
} else ContentColumn { overview(Modifier.fillMaxSize()) }

View File

@@ -72,6 +72,7 @@ val Emphasized: Easing = run {
/** md.sys.motion.duration tokens used here, in milliseconds. */
object MotionDuration {
const val Short4 = 200
const val Medium4 = 400
const val Long2 = 500
}

View File

@@ -203,7 +203,7 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val wide = maxWidth >= TWO_PANE
val wide = twoPane(maxWidth)
// On a phone the agenda's header wears the week's colour; the change fades.
val tinted = !wide && agenda
val headerColor by animateColorAsState(
@@ -232,9 +232,9 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
Board(days, byDay, overdue, lessonsOf, vm::toggleNote, onEdit = { editing = it }, wide = wide)
// Tablets: the plans on the left, the day at the list's top on the right — its
// lessons and plans together, as School Planner's agenda keeps a detail pane.
wide -> Row(Modifier.fillMaxSize().padding(end = 16.dp)) {
wide -> PaneRow(Modifier.fillMaxSize().padding(end = 16.dp), firstWidth = 440.dp, first = { m ->
// The week strip stays on the calm page; the list below sits on a rounded card.
Column(Modifier.width(440.dp).fillMaxHeight().padding(start = 16.dp)) {
Column(m.padding(start = 16.dp)) {
if (layout == PlansLayout.Agenda) strip(false)
Box(
Modifier
@@ -243,8 +243,8 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
.clip(MaterialTheme.shapes.extraLarge.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp))),
) { planList(layout, true, MaterialTheme.colorScheme.card) }
}
Spacer(Modifier.width(16.dp))
DetailPane(Modifier.weight(1f)) {
}) {
DetailPane(it) {
DayAgenda(
vm, timetable, settings?.subgroup, date, all,
onLesson = { details = it to date },

View File

@@ -31,6 +31,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.widthIn
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material.icons.rounded.Today
import androidx.compose.runtime.mutableLongStateOf
import java.time.format.DateTimeFormatter
@@ -258,10 +262,21 @@ private fun Planner(
// Tablets keep the calm page behind the header and put the timetable on a card, as School
// Planner does; the week's colour on the header stays a phone touch.
BoxWithConstraints(Modifier.fillMaxSize()) {
val wide = maxWidth >= TWO_PANE
Column(Modifier.fillMaxSize().background(if (wide) MaterialTheme.colorScheme.page else Color.Transparent)) {
// Tablets and open book-style foldables.
val wide = maxWidth >= TWO_PANE || LocalFoldTablet.current
// A foldable half opened on a table: the day's lessons above the fold, where they face the
// student, and the bar with the day strip below it, under the thumb.
val split = remember { FoldSplit() }
val fold = split.hingeIn(LocalHinge.current?.takeIf { it.tabletop })
val density = LocalDensity.current
// The lesson picked below the fold for the stand above it, per day.
var stand by remember { mutableStateOf<Pair<LocalDate, java.time.LocalTime>?>(null) }
val clock = rememberClock()
val header: @Composable () -> Unit = {
Column(Modifier.background(if (wide) MaterialTheme.colorScheme.page else tint)) {
TopAppBar(
// Below the fold of a phone on the table the bar has no status bar above it.
windowInsets = if (fold != null) WindowInsets(0) else TopAppBarDefaults.windowInsets,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
@@ -325,11 +340,12 @@ private fun Planner(
status.message?.let {
Box(Modifier.background(MaterialTheme.colorScheme.page)) { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null) }
}
}
val timetable: @Composable (Modifier) -> Unit = {
Surface(
color = if (wide) MaterialTheme.colorScheme.card else MaterialTheme.colorScheme.surface,
shape = if (wide) MaterialTheme.shapes.extraLarge else RectangleShape,
modifier = Modifier
.fillMaxSize()
modifier = it
.then(if (wide) Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp + navBarPadding()) else Modifier),
) {
// Day and week views cross-fade without scaling: the grid's tinted day row must meet
@@ -375,6 +391,42 @@ private fun Planner(
}
}
}
Column(Modifier.fillMaxSize().background(if (wide) MaterialTheme.colorScheme.page else Color.Transparent).trackFold(split)) {
if (fold != null) with(density) {
val upper = Modifier.fillMaxWidth().height(fold.top.toDp()).windowInsetsPadding(WindowInsets.statusBars)
// The day view becomes a desk stand: one lesson large above the fold, still paged by day.
if (weekView) timetable(upper)
else HorizontalPager(state = pager, modifier = upper) { page ->
val pageDate = dates[page]
StandDay(
date = pageDate,
week = t.days.firstOrNull { it.date == pageDate.toString() }?.week,
slots = vm.slotsOn(t, pageDate, subgroup),
chosen = stand?.takeIf { it.first == pageDate }?.second,
clock = clock,
onOpen = { onOpen(it, pageDate) },
modifier = Modifier.fillMaxSize(),
)
}
Spacer(Modifier.height(fold.height.toDp()))
// The week's colour fills the whole lower half, not only the bar.
Column(Modifier.fillMaxSize().background(if (wide) MaterialTheme.colorScheme.page else tint)) {
header()
if (!weekView) StandLessons(
date = date,
slots = vm.slotsOn(t, date, subgroup),
chosen = stand?.takeIf { it.first == date }?.second,
clock = clock,
onChoose = { stand = date to it },
onOpen = { onOpen(it, date) },
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
)
}
} else {
header()
timetable(Modifier.fillMaxSize())
}
}
}
}

View File

@@ -280,17 +280,15 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
}
BoxWithConstraints(Modifier.fillMaxSize()) {
if (maxWidth >= TWO_PANE) Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.page)) {
if (twoPane(maxWidth)) Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.page)) {
// Tablets: the search on the left, the chosen teacher's or room's day on the right.
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
navigationIcon = { MenuButton(nav) },
title = { Text("Поиск", style = MaterialTheme.typography.titleLargeEmphasized) },
)
Row(Modifier.fillMaxSize().padding(end = 16.dp)) {
results(Modifier.width(440.dp).fillMaxHeight(), true)
Spacer(Modifier.width(16.dp))
DetailPane(Modifier.weight(1f)) {
PaneRow(Modifier.fillMaxSize().padding(end = 16.dp), firstWidth = 440.dp, first = { results(it, true) }) {
DetailPane(it) {
val t = target
if (t == null) CenteredEmpty {
Empty(

View File

@@ -0,0 +1,280 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.EventAvailable
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.contentDescription
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.Week
import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.minutesLabel
import org.xtimms.psu.schedule.data.plural
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekdayName
import java.time.Duration
import java.time.LocalDate
import java.time.LocalTime
// The schedule of a foldable half opened on a table, as a desk stand: above the fold, facing the
// student, one lesson large enough to read from a distance; below it, under the thumb, the bar,
// the day strip and the day's lessons as cards to pick from.
/** The lesson the stand shows by itself: today the running or next one, another day the first. */
fun standSlot(slots: List<Slot>, date: LocalDate, clock: LocalTime): Slot? =
if (date == today()) slots.firstOrNull { it.end.isAfter(clock) } else slots.firstOrNull()
/**
* The upper half: the day, then the chosen lesson in the subject's colour with its time, a
* countdown or progress, room and teacher. Wide enough, the rest of the day follows beside it.
*/
@Composable
fun StandDay(
date: LocalDate,
week: Week?,
slots: List<Slot>,
chosen: LocalTime?,
clock: LocalTime,
onOpen: (Lesson) -> Unit,
modifier: Modifier = Modifier,
) {
val isToday = date == today()
val slot = slots.firstOrNull { it.start == chosen } ?: standSlot(slots, date, clock)
Column(modifier.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Heading(
(if (isToday) "Сегодня, " else "${date.weekdayName()}, ") + date.longLabel(),
style = MaterialTheme.typography.titleLargeEmphasized,
modifier = Modifier.weight(1f, fill = false),
)
week?.let {
Spacer(Modifier.width(12.dp))
WeekMark(it)
}
}
Text(
if (slots.isEmpty()) "Пар нет"
else "${slots.size} ${plural(slots.size, "пара", "пары", "пар")} · ${slots.first().start}–${slots.last().end}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp),
)
when {
slots.isEmpty() -> StandQuiet("Пар нет", "В этот день занятий нет — выберите другой день внизу.")
slot == null -> StandQuiet(
"Пары на сегодня закончились",
"Сегодня было ${slots.size} ${plural(slots.size, "пара", "пары", "пар")}. Завтрашний день — в ленте внизу.",
)
else -> BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
val later = slots.filter { it.start.isAfter(slot.start) }
val side = maxWidth >= 640.dp && later.isNotEmpty()
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
StandLesson(slot, clock, isToday, onOpen, Modifier.weight(1f).fillMaxHeight())
if (side) Column(Modifier.width(260.dp).padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Дальше", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
later.take(3).forEach { s -> s.lessons.forEach { CompactLesson(it, s, onClick = { onOpen(it) }) } }
}
}
}
}
}
}
@Composable
private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen: (Lesson) -> Unit, modifier: Modifier) {
val lesson = slot.lessons.first()
val color = subjectColor(lesson.subject)
val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end)
val upcoming = isToday && clock.isBefore(slot.start)
val show = LocalDisplay.current
Surface(
onClick = { onOpen(lesson) },
shape = MaterialTheme.shapes.extraLarge,
color = color.soft,
contentColor = color.onSoft,
modifier = modifier,
) {
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
TimeText(
"${slot.start} → ${slot.end}" + (lesson.pair?.let { " · $it пара" } ?: ""),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
if (running || upcoming) {
val minutes = Duration.between(clock, if (running) slot.end else slot.start).toMinutes()
Surface(
shape = MaterialTheme.shapes.small,
color = if (running) color.container else MaterialTheme.colorScheme.surface,
contentColor = if (running) color.content else MaterialTheme.colorScheme.onSurface,
) {
Text(
if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
}
// The subject sits low, like a poster's title, where the eye lands from across a desk.
Spacer(Modifier.weight(1f))
lesson.session?.let { SessionBadge(it) }
Text(
lesson.subject,
style = MaterialTheme.typography.headlineLargeEmphasized,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
val room = if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }
room?.let { StandMeta(Icons.Rounded.LocationOn, it) }
if (show.teachers && lesson.teachers.isNotEmpty()) StandMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
}
// Parallel subgroups share the slot: the others in a line.
slot.lessons.drop(1).forEach { other ->
Text(
listOfNotNull(other.subgroup, other.subject, other.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" })
.joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (running) {
val total = Duration.between(slot.start, slot.end).toMinutes().coerceAtLeast(1)
val passed = Duration.between(slot.start, clock).toMinutes()
LinearProgressIndicator(
progress = { (passed.toFloat() / total).coerceIn(0f, 1f) },
color = color.container,
trackColor = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth().height(8.dp),
)
}
}
}
}
@Composable
private fun StandMeta(icon: ImageVector, text: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(text, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
private fun StandQuiet(title: String, text: String) {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(Icons.Rounded.EventAvailable, contentDescription = null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(12.dp))
Text(title, style = MaterialTheme.typography.titleLargeEmphasized)
Text(text, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
/**
* The lower half's row of the day's lessons: a tap shows one on the stand above, a tap on the
* one already shown opens its details.
*/
@Composable
fun StandLessons(
date: LocalDate,
slots: List<Slot>,
chosen: LocalTime?,
clock: LocalTime,
onChoose: (LocalTime) -> Unit,
onOpen: (Lesson) -> Unit,
modifier: Modifier = Modifier,
) {
val shown = slots.firstOrNull { it.start == chosen } ?: standSlot(slots, date, clock)
val state = rememberLazyListState()
LaunchedEffect(date, shown?.start) {
val index = slots.indexOf(shown)
if (index >= 0) state.animateScrollToItem(index)
}
LazyRow(
state = state,
modifier = modifier,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
items(slots, key = { it.start.toString() }) { slot ->
val lesson = slot.lessons.first()
val selected = slot == shown
val color = subjectColor(lesson.subject)
val past = date == today() && !slot.end.isAfter(clock)
Surface(
onClick = { if (selected) onOpen(lesson) else onChoose(slot.start) },
shape = MaterialTheme.shapes.large,
color = if (selected) color.soft else MaterialTheme.colorScheme.surfaceContainerLow,
contentColor = if (selected) color.onSoft else MaterialTheme.colorScheme.onSurface,
border = if (selected) BorderStroke(2.dp, color.container) else null,
modifier = Modifier
.width(196.dp)
.semantics {
this.selected = selected
contentDescription = "${slot.start}–${slot.end}, ${slot.lessons.joinToString(", ") { it.subject }}"
},
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
SubjectDot(lesson.subject, 10.dp)
Spacer(Modifier.width(8.dp))
Text(
"${slot.start}–${slot.end}",
style = MaterialTheme.typography.labelLarge,
color = if (past && !selected) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified,
)
}
Text(
slot.lessons.joinToString(" / ") { it.subject },
style = MaterialTheme.typography.titleSmallEmphasized,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = if (past && !selected) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified,
)
val room = slot.lessons.flatMap { it.rooms }.distinct().joinToString(", ")
if (room.isNotEmpty()) Text("ауд. $room", style = MaterialTheme.typography.bodySmall, maxLines = 1)
}
}
}
}
}

View File

@@ -13,6 +13,7 @@ glance = "1.2.0"
work = "2.12.0"
desugar = "2.1.5"
material = "1.13.0"
window = "1.5.1"
junit = "4.13.2"
[libraries]
@@ -37,6 +38,8 @@ desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desuga
# Only for MaterialColors.harmonize; R8 drops the rest from release builds.
material = { module = "com.google.android.material:material", version.ref = "material" }
junit = { module = "junit:junit", version.ref = "junit" }
# Folds and hinges of foldable phones (FoldingFeature).
window = { module = "androidx.window:window", version.ref = "window" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }