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:
@@ -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
@@ -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() } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
135
android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Fold.kt
Normal file
135
android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Fold.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user