made android client

This commit is contained in:
2026-10-17 04:01:58 +03:00
parent 19ee41411b
commit e1a787c1dd
59 changed files with 9379 additions and 1 deletions

7
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.gradle/
build/
local.properties
.idea/
*.iml
.kotlin/
captures/

View File

@@ -0,0 +1,70 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
val apiUrl = providers.gradleProperty("PSU_API_URL")
.orElse(providers.environmentVariable("PSU_API_URL"))
.orElse("http://10.0.2.2:3010/")
.map { if (it.endsWith("/")) it else "$it/" }
android {
namespace = "ritm.schedule"
compileSdk {
version = release(37) { minorApiLevel = 1 }
}
defaultConfig {
applicationId = "ritm.schedule"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
buildConfigField("String", "API_URL", "\"${apiUrl.get()}\"")
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
// A release build must talk to a public HTTPS server.
require(apiUrl.get().startsWith("https://") || !gradle.startParameter.taskNames.any { "Release" in it }) {
"Для релиза укажите HTTPS-адрес: -PPSU_API_URL=https://…"
}
}
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
}
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.material3)
implementation(libs.compose.icons)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.activity.compose)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.core.ktx)
implementation(libs.coroutines.android)
implementation(libs.serialization.json)
implementation(libs.okhttp)
implementation(libs.datastore.preferences)
implementation(libs.glance.appwidget)
implementation(libs.glance.material3)
implementation(libs.work.runtime)
coreLibraryDesugaring(libs.desugar)
testImplementation(libs.junit)
}

4
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,4 @@
# kotlinx.serialization keeps generated serializers; models are plain data classes.
-keepattributes *Annotation*, InnerClasses
-keep,includedescriptorclasses class ritm.schedule.data.**$$serializer { *; }
-keepclassmembers class ritm.schedule.data.** { *** Companion; }

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Optional: the student can allow exact reminder times in system settings. -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application
android:name=".ScheduleApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true"
android:supportsRtl="true"
android:theme="@style/Theme.PsuSchedule">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".widget.TodayWidgetReceiver"
android:exported="true"
android:label="@string/widget_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/today_widget_info" />
</receiver>
<receiver
android:name=".notify.ReminderReceiver"
android:exported="false" />
<receiver
android:name=".notify.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,315 @@
package ritm.schedule
import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.School
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.TravelExplore
import androidx.compose.material.icons.rounded.CalendarMonth
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.rounded.School
import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material.icons.rounded.TravelExplore
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalWideNavigationRail
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.WideNavigationRail
import androidx.compose.material3.WideNavigationRailDefaults
import androidx.compose.material3.WideNavigationRailItem
import androidx.compose.material3.rememberWideNavigationRailState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import ritm.schedule.data.ThemeMode
import ritm.schedule.data.today
import ritm.schedule.ui.AppNav
import ritm.schedule.ui.GroupsScreen
import ritm.schedule.ui.HomeScreen
import ritm.schedule.ui.LocalDisplay
import ritm.schedule.ui.LocalSubjectSlots
import ritm.schedule.data.Display
import ritm.schedule.ui.MainViewModel
import ritm.schedule.ui.ScheduleScreen
import ritm.schedule.ui.SearchScreen
import ritm.schedule.ui.SettingsScreen
import ritm.schedule.ui.SubjectsScreen
import ritm.schedule.ui.subjectSlots
import ritm.schedule.ui.theme.ScheduleTheme
import ritm.schedule.ui.theme.isDark
import ritm.schedule.ui.theme.page
import java.time.LocalDate
private enum class Tab(val label: String, val icon: ImageVector, val selectedIcon: ImageVector) {
Home("Обзор", Icons.Outlined.Home, Icons.Rounded.Home),
Schedule("Расписание", Icons.Outlined.CalendarMonth, Icons.Rounded.CalendarMonth),
Subjects("Предметы", Icons.Outlined.School, Icons.Rounded.School),
Search("Поиск", Icons.Outlined.TravelExplore, Icons.Rounded.TravelExplore),
}
/** Pages opened from the menu or the app bar; they cover the tabs until the student goes back. */
private enum class Page { Groups, Settings }
// The translucent scrims Android uses behind three-button navigation.
private val LIGHT_SCRIM = Color.argb(0xe6, 0xff, 0xff, 0xff)
private val DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
class MainActivity : ComponentActivity() {
private val vm: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val settings by vm.settings.collectAsStateWithLifecycle()
val s = settings
val dark = isDark(s?.theme ?: ThemeMode.System)
// System bar icons follow the app's own theme, which may differ from the phone's.
DisposableEffect(dark) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
navigationBarStyle = SystemBarStyle.auto(LIGHT_SCRIM, DARK_SCRIM) { dark },
)
onDispose {}
}
ScheduleTheme(mode = s?.theme ?: ThemeMode.System, dynamicColor = s?.dynamicColor ?: false) {
Surface(color = MaterialTheme.colorScheme.page, modifier = Modifier.fillMaxSize()) {
// Wait for the stored settings so a returning student never sees the first-run screen.
if (s != null) App(vm, firstRun = s.current == null)
}
}
}
}
}
/** Window width from which the menu turns into a permanent side rail (M3 medium window class). */
private val MEDIUM_WIDTH = 600.dp
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun App(vm: MainViewModel, firstRun: Boolean) {
var tab by rememberSaveable { mutableStateOf(Tab.Home) }
var page by rememberSaveable { mutableStateOf(if (firstRun) Page.Groups else null) }
var weekView by rememberSaveable { mutableStateOf(false) }
var focus by remember { mutableStateOf<LocalDate?>(null) }
val settings by vm.settings.collectAsStateWithLifecycle()
val timetable by vm.timetable.collectAsStateWithLifecycle()
val hasGroup = settings?.current != null
val menu = rememberWideNavigationRailState()
val scope = rememberCoroutineScope()
// Back closes a page first, then returns to the overview instead of closing the app.
BackHandler(enabled = page != null && hasGroup) { page = null }
BackHandler(enabled = page == null && tab != Tab.Home) { tab = Tab.Home }
val slots = remember(timetable) { subjectSlots(timetable) }
val motion = MaterialTheme.motionScheme
BoxWithConstraints(Modifier.fillMaxSize()) {
val wide = maxWidth >= MEDIUM_WIDTH
val nav = remember(wide) {
AppNav(
openGroups = { page = Page.Groups },
openSettings = { page = Page.Settings },
openDay = { focus = it; weekView = false; tab = Tab.Schedule },
openWeek = { focus = today(); weekView = true; tab = Tab.Schedule },
openSubjects = { tab = Tab.Subjects },
openSearch = { tab = Tab.Search },
openMenu = if (wide) null else ({ scope.launch { menu.expand() } }),
)
}
CompositionLocalProvider(LocalSubjectSlots provides slots, LocalDisplay provides (settings?.display ?: Display())) {
AnimatedContent(
targetState = page,
transitionSpec = {
(slideInHorizontally(motion.defaultSpatialSpec()) { it / 5 } + fadeIn(motion.defaultEffectsSpec())) togetherWith
fadeOut(motion.fastEffectsSpec())
},
label = "page",
) { current ->
// Pages pad for side insets; their lists end above the navigation bar themselves.
val pageInsets = Modifier.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal))
when (current) {
Page.Groups -> Box(pageInsets) {
GroupsScreen(
vm,
onOpened = { page = null; tab = Tab.Home },
onBack = if (hasGroup) ({ page = null }) else null,
)
}
Page.Settings -> Box(pageInsets) { SettingsScreen(vm, onBack = { page = null }) }
null -> Tabs(tab, wide, onTab = { tab = it }, onMenu = { scope.launch { menu.expand() } }) { shown ->
when (shown) {
Tab.Home -> HomeScreen(vm, nav)
Tab.Schedule -> ScheduleScreen(
vm, nav,
weekView = weekView,
onWeekView = { weekView = it },
focus = focus,
onFocusHandled = { focus = null },
)
Tab.Subjects -> SubjectsScreen(vm, nav)
Tab.Search -> SearchScreen(vm, nav)
}
}
}
}
}
// The side menu slides over the screen, as in School Planner: from the app bar's
// hamburger on phones, from the rail's menu button on wider windows. It opens in its
// own window that styles the system bars by the night mode it reads, so it gets the app's.
CompositionLocalProvider(LocalConfiguration provides themedConfiguration(isDark(settings?.theme ?: ThemeMode.System))) {
ModalWideNavigationRail(
state = menu,
hideOnCollapse = true,
colors = WideNavigationRailDefaults.colors(modalContainerColor = MaterialTheme.colorScheme.page),
header = {
Text(
stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLargeEmphasized,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, top = 8.dp, bottom = 16.dp).semantics { heading() },
)
},
) {
fun close(action: () -> Unit) {
action()
scope.launch { menu.collapse() }
}
Tab.entries.forEach { item ->
val selected = page == null && tab == item
WideNavigationRailItem(
selected = selected,
onClick = { close { page = null; tab = item } },
icon = { Icon(if (selected) item.selectedIcon else item.icon, contentDescription = null) },
label = { Text(item.label) },
railExpanded = true,
)
}
HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 8.dp))
WideNavigationRailItem(
selected = page == Page.Groups,
onClick = { close { page = Page.Groups } },
icon = { Icon(Icons.Rounded.SwapHoriz, contentDescription = null) },
label = { Text("Сменить группу") },
railExpanded = true,
)
WideNavigationRailItem(
selected = page == Page.Settings,
onClick = { close { page = Page.Settings } },
icon = { Icon(Icons.Outlined.Settings, contentDescription = null) },
label = { Text("Настройки") },
railExpanded = true,
)
}
}
}
}
/** The current configuration with its night mode set to the app's theme. */
@Composable
private fun themedConfiguration(dark: Boolean): Configuration {
val base = LocalConfiguration.current
return remember(base, dark) {
Configuration(base).apply {
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or
if (dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
}
}
}
/** On medium and wider windows a permanent rail on the tinted page; on phones the screen alone. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun Tabs(tab: Tab, wide: Boolean, onTab: (Tab) -> Unit, onMenu: () -> Unit, content: @Composable (Tab) -> Unit) {
val motion = MaterialTheme.motionScheme
// The screen keeps one place in the tree whether the rail is shown or not,
// so rotating the phone keeps its saved state (selected day, week view).
Row(Modifier.fillMaxSize()) {
if (wide) WideNavigationRail(
colors = WideNavigationRailDefaults.colors(containerColor = MaterialTheme.colorScheme.page),
header = {
IconButton(onClick = onMenu, modifier = Modifier.padding(start = 24.dp)) {
Icon(Icons.Rounded.Menu, contentDescription = "Открыть меню")
}
},
) {
Tab.entries.forEach { item ->
WideNavigationRailItem(
selected = tab == item,
onClick = { onTab(item) },
icon = { Icon(if (tab == item) item.selectedIcon else item.icon, contentDescription = null) },
label = { Text(item.label) },
railExpanded = false,
)
}
}
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.then(
// The rail already sits over the start inset (cutout in landscape),
// so the screens' top app bars must not pad for it again.
if (wide) Modifier
.consumeWindowInsets(WindowInsets.safeDrawing.only(WindowInsetsSides.Start))
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.End))
else Modifier,
),
) {
AnimatedContent(
targetState = tab,
transitionSpec = { fadeIn(motion.defaultEffectsSpec()) togetherWith fadeOut(motion.fastEffectsSpec()) },
label = "tab",
) { content(it) }
}
}
}

View File

@@ -0,0 +1,25 @@
package ritm.schedule
import android.app.Application
import ritm.schedule.data.Api
import ritm.schedule.data.Repository
import ritm.schedule.data.Settings
import ritm.schedule.notify.Reminders
import ritm.schedule.work.SyncWorker
class ScheduleApp : Application() {
lateinit var settings: Settings
private set
lateinit var repository: Repository
private set
override fun onCreate() {
super.onCreate()
settings = Settings(this)
repository = Repository(this, Api(BuildConfig.API_URL), settings)
Reminders.createChannel(this)
SyncWorker.schedule(this)
}
}
val android.content.Context.app: ScheduleApp get() = applicationContext as ScheduleApp

View File

@@ -0,0 +1,82 @@
package ritm.schedule.data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.util.concurrent.TimeUnit
class ApiException(message: String, val offline: Boolean = false) : Exception(message)
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
/** The schedule server's HTTP API. Every error message is ready to show. */
class Api(baseUrl: String) {
private val base: HttpUrl = baseUrl.toHttpUrl()
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
// The first request after a server start can wait for the catalog index.
.readTimeout(60, TimeUnit.SECONDS)
.build()
private suspend inline fun <reified T> get(path: String, vararg query: Pair<String, String?>): T =
withContext(Dispatchers.IO) {
val url = base.newBuilder().addPathSegments(path.trimStart('/')).apply {
for ((name, value) in query) if (!value.isNullOrEmpty()) addQueryParameter(name, value)
}.build()
val body = try {
client.newCall(Request.Builder().url(url).build()).execute().use { response ->
val text = response.body?.string().orEmpty()
if (!response.isSuccessful) {
val message = runCatching {
json.parseToJsonElement(text).jsonObject["error"]?.jsonPrimitive?.content
}.getOrNull()
throw ApiException(message ?: "Сервер ответил ошибкой ${response.code}.")
}
text
}
} catch (e: IOException) {
throw ApiException("Нет связи с сервером расписания. Показана сохранённая копия.", offline = true)
}
json.decodeFromString<T>(body)
}
suspend fun timetable(docId: String, group: String, from: String, days: Int) =
get<Timetable>("api/timetable", "doc" to docId, "group" to group, "from" to from, "days" to days.toString())
suspend fun groups(query: String = "", faculty: String = "", course: String = "", form: String = "") =
get<GroupsResponse>("api/groups", "q" to query, "faculty" to faculty, "course" to course, "form" to form)
suspend fun searchDirectory(query: String) = get<DirectorySearch>("api/directory/search", "q" to query)
suspend fun teacherDay(name: String, date: String) =
get<DirectoryDay>("api/directory/day", "teacher" to name, "date" to date)
suspend fun roomDay(room: String, date: String) =
get<DirectoryDay>("api/directory/day", "room" to room, "date" to date)
suspend fun freeRooms(date: String, pair: Int) =
get<FreeRooms>("api/directory/free", "date" to date, "pair" to pair.toString())
suspend fun calendar() = get<CalendarInfo>("api/calendar")
}
/** runCatching that lets coroutine cancellation through: a cancelled request is not an error. */
suspend inline fun <T> attempt(crossinline block: suspend () -> T): Result<T> =
try {
Result.success(block())
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}

View File

@@ -0,0 +1,48 @@
package ritm.schedule.data
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
import java.util.Locale
/** The university's time zone: dates and "now" never follow the phone's zone. */
val MINSK: ZoneId = ZoneId.of("Europe/Minsk")
private val RU = Locale.forLanguageTag("ru")
fun today(): LocalDate = LocalDate.now(MINSK)
fun nowTime(): LocalTime = LocalTime.now(MINSK)
fun nowDateTime(): LocalDateTime = LocalDateTime.now(MINSK)
fun LocalDate.monday(): LocalDate = with(DayOfWeek.MONDAY)
fun parseDate(iso: String): LocalDate = LocalDate.parse(iso)
fun parseTime(hhmm: String): LocalTime = LocalTime.parse(hhmm)
fun LocalDate.longLabel(): String = format(DateTimeFormatter.ofPattern("d MMMM", RU))
fun LocalDate.shortWeekday(): String =
dayOfWeek.getDisplayName(TextStyle.SHORT_STANDALONE, RU).replaceFirstChar { it.titlecase(RU) }
fun LocalDate.weekdayName(): String =
dayOfWeek.getDisplayName(TextStyle.FULL_STANDALONE, RU).replaceFirstChar { it.titlecase(RU) }
fun dayName(day: Int): String =
DayOfWeek.of(day).getDisplayName(TextStyle.FULL_STANDALONE, RU).replaceFirstChar { it.titlecase(RU) }
/** "12–18 октября" or "28 сентября – 4 октября". */
fun weekSpan(start: LocalDate, end: LocalDate): String =
if (start.month == end.month) "${start.dayOfMonth}–${end.longLabel()}"
else "${start.longLabel()} – ${end.longLabel()}"
fun plural(n: Int, one: String, few: String, many: String): String {
val tail = n % 100
return when {
tail in 11..14 -> many
n % 10 == 1 -> one
n % 10 in 2..4 -> few
else -> many
}
}
fun minutesLabel(m: Long): String =
if (m < 60) "$m мин" else "${m / 60} ч" + if (m % 60 > 0) " ${m % 60} мин" else ""

View File

@@ -0,0 +1,154 @@
package ritm.schedule.data
import kotlinx.serialization.Serializable
// Shapes of the server JSON (see server/timetable.js and server/directory.js).
@Serializable
data class Timetable(
val group: String,
val document: DocumentRef,
val period: String? = null,
val semester: Semester? = null,
val note: String? = null,
val subgroups: List<String> = emptyList(),
val bells: List<Bell> = emptyList(),
val from: String,
val days: List<Day> = emptyList(),
val weekly: List<WeeklyDay> = emptyList(),
val notes: List<String> = emptyList(),
val generatedAt: String? = null,
)
@Serializable
data class DocumentRef(
val id: String,
val name: String,
val faculty: String = "",
val course: String = "",
val modified: String? = null,
)
@Serializable
data class Semester(val id: String, val label: String, val start: String, val end: String)
@Serializable
data class Bell(val pair: Int, val start: String, val end: String, val halves: List<List<String>> = emptyList())
@Serializable
data class Week(val n: Int, val color: String) {
val white get() = color == "white"
}
@Serializable
data class Day(val date: String, val weekday: Int, val week: Week? = null, val lessons: List<Lesson> = emptyList())
@Serializable
data class WeeklyDay(val day: Int, val lessons: List<Lesson> = emptyList())
@Serializable
data class Lesson(
val key: String,
val start: String,
val end: String? = null,
val pair: Int? = null,
val subject: String,
val type: String = "",
val teachers: List<String> = emptyList(),
val rooms: List<String> = emptyList(),
val roomMoved: Boolean = false,
val distance: Boolean = false,
val condition: String = "",
val variant: String = "all",
val subgroup: String? = null,
val groups: List<String> = emptyList(),
val uncertain: Boolean = false,
val raw: String = "",
)
@Serializable
data class GroupsResponse(
val results: List<GroupResult> = emptyList(),
val facets: Facets = Facets(),
val total: Int = 0,
val year: String = "",
)
@Serializable
data class GroupResult(
val group: String,
val fileId: String,
val file: String = "",
val faculty: String = "",
val course: String = "",
val form: String = "",
val period: String = "",
)
@Serializable
data class Facets(
val faculties: List<Facet> = emptyList(),
val courses: List<Facet> = emptyList(),
val forms: List<Facet> = emptyList(),
)
@Serializable
data class Facet(val name: String, val count: Int)
@Serializable
data class DirectorySearch(val teachers: List<TeacherHit> = emptyList(), val rooms: List<RoomHit> = emptyList())
@Serializable
data class TeacherHit(val name: String, val count: Int = 0, val faculties: List<String> = emptyList())
@Serializable
data class RoomHit(val room: String, val count: Int = 0)
@Serializable
data class DirectoryDay(val date: String, val lessons: List<DirectoryLesson> = emptyList(), val coverage: Coverage)
@Serializable
data class DirectoryLesson(
val start: String,
val end: String? = null,
val subject: String,
val type: String = "",
val teachers: List<String> = emptyList(),
val rooms: List<String> = emptyList(),
val moved: Boolean = false,
val distance: Boolean = false,
val groups: List<String> = emptyList(),
val condition: String = "",
val uncertain: Boolean = false,
)
@Serializable
data class Coverage(
val documents: Int,
val total: Int,
val failed: List<String> = emptyList(),
val withoutSemester: List<String> = emptyList(),
val year: String = "",
)
@Serializable
data class FreeRooms(
val date: String,
val pair: Int,
val bell: Bell,
val free: List<RoomGroup> = emptyList(),
val freeCount: Int = 0,
val busyCount: Int = 0,
val knownRooms: Int = 0,
val coverage: Coverage,
)
@Serializable
data class RoomGroup(val letter: String, val rooms: List<String>)
@Serializable
data class CalendarInfo(val bells: List<Bell> = emptyList())
/** A group the student opened; kept on the device for quick switching. */
@Serializable
data class SavedGroup(val docId: String, val group: String, val faculty: String = "", val course: String = "")

View File

@@ -0,0 +1,55 @@
package ritm.schedule.data
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import java.io.File
import java.time.LocalDate
/**
* The open group's timetable: the server computes dates, the app keeps the last
* answer on disk so the schedule, widget and reminders work without a network.
*/
class Repository(context: Context, val api: Api, private val settings: Settings) {
private val cache = File(context.filesDir, "timetable.json")
private val _timetable = MutableStateFlow(readCache())
val timetable: StateFlow<Timetable?> = _timetable
/** Five weeks from this Monday: enough to browse ahead and to plan reminders. */
val span = 35
private fun readCache(): Timetable? =
runCatching { json.decodeFromString<Timetable>(cache.readText()) }.getOrNull()
suspend fun refresh(): Result<Timetable> = withContext(Dispatchers.IO) {
val group = settings.now().current
?: return@withContext Result.failure(ApiException("Группа не выбрана."))
attempt {
val from = today().monday()
val fresh = api.timetable(group.docId, group.group, from.toString(), span)
cache.writeText(json.encodeToString(Timetable.serializer(), fresh))
_timetable.value = fresh
fresh
}
}
suspend fun open(result: GroupResult): Result<Timetable> {
settings.openGroup(SavedGroup(result.fileId, result.group, result.faculty, result.course))
return refresh()
}
suspend fun open(saved: SavedGroup): Result<Timetable> {
settings.openGroup(saved)
return refresh()
}
/** Lessons of a date after the student's subgroup filter. */
fun lessonsOn(timetable: Timetable, date: LocalDate, subgroup: String?): List<Lesson> =
timetable.days.firstOrNull { it.date == date.toString() }?.lessons.orEmpty()
.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
fun dayOf(timetable: Timetable, date: LocalDate): Day? =
timetable.days.firstOrNull { it.date == date.toString() }
}

View File

@@ -0,0 +1,121 @@
package ritm.schedule.data
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
enum class ThemeMode { System, Light, Dark }
/** What lesson cards, the week grid and the overview show; everything is on by default. */
data class Display(
val colors: Boolean = true,
val teachers: Boolean = true,
val rooms: Boolean = true,
val pairs: Boolean = true,
val tags: Boolean = true,
val notes: Boolean = true,
)
data class UserSettings(
val current: SavedGroup? = null,
val subgroup: String? = null,
val theme: ThemeMode = ThemeMode.System,
val dynamicColor: Boolean = false,
val reminders: Boolean = false,
val reminderMinutes: Int = 10,
val recent: List<SavedGroup> = emptyList(),
val display: Display = Display(),
)
private val Context.store by preferencesDataStore("settings")
/** Everything the student chose; stays on the device. */
class Settings(private val context: Context) {
private object Keys {
val current = stringPreferencesKey("current")
val subgroups = stringPreferencesKey("subgroups")
val theme = stringPreferencesKey("theme")
val dynamic = booleanPreferencesKey("dynamic_color")
val reminders = booleanPreferencesKey("reminders")
val minutes = intPreferencesKey("reminder_minutes")
val recent = stringPreferencesKey("recent")
val colors = booleanPreferencesKey("show_colors")
val teachers = booleanPreferencesKey("show_teachers")
val rooms = booleanPreferencesKey("show_rooms")
val pairs = booleanPreferencesKey("show_pairs")
val tags = booleanPreferencesKey("show_tags")
val notes = booleanPreferencesKey("show_notes")
}
val flow: Flow<UserSettings> = context.store.data.map { it.toSettings() }
suspend fun now(): UserSettings = flow.first()
private fun Preferences.toSettings(): UserSettings {
val current = this[Keys.current]?.let { runCatching { json.decodeFromString<SavedGroup>(it) }.getOrNull() }
val subgroups = this[Keys.subgroups]?.let {
runCatching { json.decodeFromString<Map<String, String>>(it) }.getOrNull()
}.orEmpty()
return UserSettings(
current = current,
subgroup = current?.let { subgroups[subgroupKey(it)] },
theme = this[Keys.theme]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() } ?: ThemeMode.System,
dynamicColor = this[Keys.dynamic] ?: false,
reminders = this[Keys.reminders] ?: false,
reminderMinutes = this[Keys.minutes] ?: 10,
recent = this[Keys.recent]?.let {
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
}.orEmpty(),
display = Display(
colors = this[Keys.colors] ?: true,
teachers = this[Keys.teachers] ?: true,
rooms = this[Keys.rooms] ?: true,
pairs = this[Keys.pairs] ?: true,
tags = this[Keys.tags] ?: true,
notes = this[Keys.notes] ?: true,
),
)
}
private fun subgroupKey(g: SavedGroup) = "${g.docId}|${g.group}"
suspend fun openGroup(group: SavedGroup) = context.store.edit { prefs ->
prefs[Keys.current] = json.encodeToString(group)
val recent = prefs[Keys.recent]?.let {
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
}.orEmpty()
val next = (listOf(group) + recent.filterNot { it.docId == group.docId && it.group == group.group }).take(8)
prefs[Keys.recent] = json.encodeToString(next)
}
suspend fun setSubgroup(label: String?) = context.store.edit { prefs ->
val current = prefs[Keys.current]?.let { runCatching { json.decodeFromString<SavedGroup>(it) }.getOrNull() }
?: return@edit
val map = prefs[Keys.subgroups]?.let {
runCatching { json.decodeFromString<Map<String, String>>(it) }.getOrNull()
}.orEmpty().toMutableMap()
if (label == null) map.remove(subgroupKey(current)) else map[subgroupKey(current)] = label
prefs[Keys.subgroups] = json.encodeToString(map.toMap())
}
suspend fun setTheme(mode: ThemeMode) = context.store.edit { it[Keys.theme] = mode.name }
suspend fun setDynamicColor(on: Boolean) = context.store.edit { it[Keys.dynamic] = on }
suspend fun setReminders(on: Boolean) = context.store.edit { it[Keys.reminders] = on }
suspend fun setReminderMinutes(minutes: Int) = context.store.edit { it[Keys.minutes] = minutes }
suspend fun setDisplay(d: Display) = context.store.edit {
it[Keys.colors] = d.colors
it[Keys.teachers] = d.teachers
it[Keys.rooms] = d.rooms
it[Keys.pairs] = d.pairs
it[Keys.tags] = d.tags
it[Keys.notes] = d.notes
}
}

View File

@@ -0,0 +1,155 @@
package ritm.schedule.notify
import android.Manifest
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import ritm.schedule.MainActivity
import ritm.schedule.R
import ritm.schedule.app
import ritm.schedule.data.MINSK
import ritm.schedule.data.Timetable
import ritm.schedule.data.UserSettings
import ritm.schedule.data.parseDate
import ritm.schedule.data.parseTime
import java.time.Instant
/**
* Local reminders before each lesson of the coming week. They come from the
* cached timetable, so they work offline; no push server is involved.
*/
object Reminders {
private const val CHANNEL = "before_lesson"
private const val MAX = 24
private const val ACTION = "ritm.schedule.REMIND"
private const val WINDOW = 10 * 60 * 1000L
/** Exact times need the student's consent on Android 12+ ("Будильники и напоминания"). */
fun canBeExact(context: Context) =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
fun createChannel(context: Context) {
val channel = NotificationChannel(
CHANNEL,
context.getString(R.string.channel_reminders),
NotificationManager.IMPORTANCE_DEFAULT,
).apply { description = "Напоминание за несколько минут до начала пары" }
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
fun canNotify(context: Context) =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
private fun intent(context: Context, slot: Int, extras: Intent.() -> Unit = {}): PendingIntent =
PendingIntent.getBroadcast(
context,
slot,
Intent(context, ReminderReceiver::class.java).setAction(ACTION).apply(extras),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Replaces every scheduled reminder with the ones the timetable needs now. */
fun reschedule(context: Context, timetable: Timetable?, settings: UserSettings) {
val alarms = context.getSystemService(AlarmManager::class.java)
repeat(MAX) { slot -> alarms.cancel(intent(context, slot)) }
if (!settings.reminders || timetable == null) return
val now = Instant.now()
// A week ahead, so a Friday evening already holds Monday's reminders.
val horizon = now.plusSeconds(7 * 24 * 3600)
var slot = 0
for (day in timetable.days) {
val date = parseDate(day.date)
// One reminder per start time: parallel subgroups share the notification.
for ((start, lessons) in day.lessons
.filter { settings.subgroup == null || it.subgroup == null || it.subgroup == settings.subgroup }
.groupBy { it.start }) {
val begins = date.atTime(parseTime(start)).atZone(MINSK).toInstant()
val at = begins.minusSeconds(settings.reminderMinutes * 60L)
if (at.isBefore(now) || begins.isAfter(horizon) || slot >= MAX) continue
val first = lessons.first()
val title = if (lessons.size == 1) first.subject else lessons.joinToString(" / ") { it.subject }
val place = lessons.flatMap { it.rooms }.distinct().joinToString(", ")
val text = buildString {
append(start)
first.pair?.let { append(" · $it пара") }
if (place.isNotEmpty()) append(" · ауд. $place")
if (lessons.any { it.uncertain }) append(" · сверьте недели")
}
val pending = intent(context, slot) {
putExtra("title", title)
putExtra("text", text)
putExtra("id", slot)
}
if (canBeExact(context)) {
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at.toEpochMilli(), pending)
} else {
// Without exact alarms Android may deliver up to an hour late; a
// 10-minute window (the shortest allowed) centred on the time keeps it useful.
alarms.setWindow(AlarmManager.RTC_WAKEUP, at.toEpochMilli() - WINDOW / 2, WINDOW, pending)
}
slot++
}
}
}
fun show(context: Context, id: Int, title: String, text: String) {
if (!canNotify(context)) return
val open = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setContentIntent(open)
.setAutoCancel(true)
.build()
@Suppress("MissingPermission")
NotificationManagerCompat.from(context).notify(1000 + id, notification)
}
}
class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Reminders.show(
context,
intent.getIntExtra("id", 0),
intent.getStringExtra("title") ?: return,
intent.getStringExtra("text").orEmpty(),
)
}
}
/** Alarms do not survive a reboot or an app update; set them again from the cache. */
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
CoroutineScope(Dispatchers.Default).launch {
try {
val app = context.app
Reminders.reschedule(context, app.repository.timetable.value, app.settings.now())
} finally {
pending.finish()
}
}
}
}

View File

@@ -0,0 +1,485 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui
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), 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), 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)
.selectable(
selected = isSelected,
enabled = enabled(date),
role = Role.Tab,
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 { "?" }
}

View File

@@ -0,0 +1,276 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui
import androidx.compose.material.icons.rounded.AccountBalance
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.History
import androidx.compose.material.icons.rounded.SearchOff
import ritm.schedule.ui.theme.card
import ritm.schedule.ui.theme.page
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
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.Groups
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import ritm.schedule.data.GroupResult
import ritm.schedule.data.attempt
import ritm.schedule.data.GroupsResponse
import ritm.schedule.data.SavedGroup
private val PLAIN_PERIOD = Regex("^(осенний|весенний) семестр$|^расписание из word$", RegexOption.IGNORE_CASE)
private fun describe(faculty: String, course: String, form: String = "", period: String = "") =
listOfNotNull(
facultyShort(faculty),
course.ifEmpty { null },
form.removeSuffix(" форма").lowercase().ifEmpty { null },
period.takeIf { it.isNotEmpty() && !PLAIN_PERIOD.matches(it) }?.lowercase(),
).joinToString(" · ")
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?) {
val settings by vm.settings.collectAsStateWithLifecycle()
val timetable by vm.timetable.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle()
val current = settings?.current
var query by rememberSaveable { mutableStateOf("") }
var faculty by rememberSaveable { mutableStateOf(current?.faculty.orEmpty()) }
var course by rememberSaveable { mutableStateOf("") }
var data by remember { mutableStateOf<GroupsResponse?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var opening by remember { mutableStateOf<String?>(null) }
LaunchedEffect(query, faculty, course) {
if (query.isNotEmpty()) delay(250)
attempt { vm.api.groups(query.trim(), faculty, course) }
.onSuccess { data = it; error = null }
.onFailure { error = it.message }
}
val searching = query.trim().length >= 2
fun open(result: GroupResult) {
opening = "${result.fileId}|${result.group}"
vm.open(result) { ok ->
opening = null
if (ok) onOpened()
}
}
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
TopAppBar(
colors = appBarColors(),
title = { Text("Группы", style = MaterialTheme.typography.titleLargeEmphasized) },
navigationIcon = {
if (onBack != null) IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Назад")
}
},
scrollBehavior = scroll,
)
ContentColumn {
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
) {
if (current == null) item {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(vertical = 8.dp)) {
Heading("Найдите свою группу", style = MaterialTheme.typography.headlineMediumEmphasized)
Text(
"Расписание откроется сразу и будет доступно без интернета.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item { SearchField(query, { query = it }, "Номер группы, например 23-БА") }
error?.let { item { Banner(it, offline = true, onClose = null) } }
status.message?.let { message ->
item { Banner(message, offline = status.offline, onClose = vm::dismissMessage) }
}
if (searching) {
val results = data?.results.orEmpty()
if (results.isNotEmpty()) item {
ListCard(Icons.Rounded.Search, "Найдено: ${results.size}") { GroupRows(results, opening, ::open) }
}
if (results.isEmpty() && data != null) item {
Empty(
"Группа не найдена",
"Группы «${query.trim()}» нет в каталоге ${data?.year}. Проверьте номер.",
icon = Icons.Rounded.SearchOff,
)
}
} else {
if (current != null) item {
CurrentGroup(
current = current,
subgroups = timetable?.subgroups.orEmpty(),
subgroup = settings?.subgroup,
onSubgroup = vm::setSubgroup,
)
}
val recent = settings?.recent.orEmpty().filterNot { it.docId == current?.docId && it.group == current?.group }
if (recent.isNotEmpty()) item {
ListCard(Icons.Rounded.History, "Недавние") {
recent.forEachIndexed { i, g ->
if (i > 0) RowDivider()
CardRow(
avatar = avatarLabel(g.group),
color = subjectColor(avatarLabel(g.group)),
title = g.group,
subtitle = describe(g.faculty, g.course),
onClick = { vm.open(g, onOpened) },
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
)
}
}
}
data?.let { d ->
item {
ListCard(Icons.Rounded.AccountBalance, "Каталог ${d.year}") {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 8.dp),
) {
Text("Факультет", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
d.facets.faculties.forEach { f ->
val on = faculty == f.name
FilterChip(
selected = on,
onClick = { faculty = if (on) "" else f.name; course = "" },
label = { Text(facultyShort(f.name) ?: f.name) },
leadingIcon = if (on) ({ Icon(Icons.Rounded.Check, null, Modifier.size(FilterChipDefaults.IconSize)) }) else null,
)
}
}
if (faculty.isNotEmpty()) {
Text("Курс", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
d.facets.courses.forEach { c ->
val on = course == c.name
FilterChip(
selected = on,
onClick = { course = if (on) "" else c.name },
label = { Text(c.name) },
leadingIcon = if (on) ({ Icon(Icons.Rounded.Check, null, Modifier.size(FilterChipDefaults.IconSize)) }) else null,
)
}
}
} else Text(
"Выберите факультет или введите номер группы. Всего в каталоге ${d.total} групп.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
if (faculty.isNotEmpty() && d.results.isNotEmpty()) {
RowDivider()
GroupRows(d.results, opening, ::open)
}
}
}
}
if (data == null && error == null) item {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() }
}
}
}
}
}
}
@Composable
private fun GroupRows(results: List<GroupResult>, opening: String?, onOpen: (GroupResult) -> Unit) {
results.forEachIndexed { index, r ->
if (index > 0) RowDivider()
val busy = opening == "${r.fileId}|${r.group}"
CardRow(
avatar = avatarLabel(r.group),
color = subjectColor(avatarLabel(r.group)),
title = r.group,
subtitle = describe(r.faculty, r.course, r.form, r.period),
onClick = { onOpen(r) },
enabled = opening == null,
trailing = {
if (busy) LoadingIndicator(Modifier.size(32.dp))
else Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null)
},
)
}
}
@Composable
private fun CurrentGroup(current: SavedGroup, subgroups: List<String>, subgroup: String?, onSubgroup: (String?) -> Unit) {
ListCard(Icons.Rounded.CheckCircle, "Сейчас открыта") {
CardRow(
avatar = avatarLabel(current.group),
color = subjectColor(avatarLabel(current.group)),
title = current.group,
subtitle = describe(current.faculty, current.course),
onClick = null,
)
if (subgroups.isNotEmpty()) Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 12.dp),
) {
Text("Подгруппа", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
ConnectedChoice(
options = listOf<Pair<String?, String>>(null to "Все") + subgroups.map { it to it.removePrefix("Подгруппа ") },
selected = subgroup,
onSelect = onSubgroup,
modifier = Modifier.fillMaxWidth(),
)
}
}
}

View File

@@ -0,0 +1,390 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui
import ritm.schedule.ui.theme.card
import ritm.schedule.ui.theme.page
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.automirrored.rounded.MenuBook
import androidx.compose.material.icons.rounded.CalendarViewWeek
import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.TravelExplore
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.ui.layout.layout
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.EventAvailable
import androidx.compose.material.icons.rounded.Insights
import androidx.compose.material.icons.rounded.Description
import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.ViewDay
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import ritm.schedule.data.Lesson
import ritm.schedule.data.Timetable
import ritm.schedule.data.longLabel
import ritm.schedule.data.minutesLabel
import ritm.schedule.data.monday
import ritm.schedule.data.nowTime
import ritm.schedule.data.parseTime
import ritm.schedule.data.plural
import ritm.schedule.data.today
import ritm.schedule.data.weekSpan
import ritm.schedule.data.weekdayName
import java.time.Duration
import java.time.LocalDate
import java.time.LocalTime
/** One time slot of a day: parallel lessons of subgroups share it. */
data class Slot(val date: LocalDate, val start: LocalTime, val end: LocalTime, val lessons: List<Lesson>)
fun MainViewModel.slotsOn(t: Timetable, date: LocalDate, subgroup: String?): List<Slot> =
repository.lessonsOn(t, date, subgroup)
.groupBy { it.start }
.map { (start, lessons) -> Slot(date, parseTime(start), lessonEnd(lessons.first(), t), lessons) }
.sortedBy { it.start }
/** The current time, refreshed every half minute while the screen is shown. */
@Composable
fun rememberClock(): LocalTime {
var clock by remember { mutableStateOf(nowTime()) }
LaunchedEffect(Unit) {
while (true) {
delay(30_000)
clock = nowTime()
}
}
return clock
}
@Composable
fun HomeScreen(vm: MainViewModel, nav: AppNav) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle()
var details by remember { mutableStateOf<Lesson?>(null) }
val subgroup = settings?.subgroup
val clock = rememberClock()
val date = today()
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
TopAppBar(
colors = appBarColors(),
scrollBehavior = scroll,
title = {
Column {
Text("Обзор", style = MaterialTheme.typography.titleLargeEmphasized)
(timetable?.group ?: settings?.current?.group)?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
},
navigationIcon = { MenuButton(nav) },
)
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage) }
val t = timetable
when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
t == null -> Empty(
"Расписание не загружено",
"Выберите группу — обзор дня и недели появится здесь.",
icon = Icons.Rounded.Groups,
action = "Выбрать группу" to nav.openGroups,
)
t.semester == null -> Column(Modifier.padding(top = 8.dp)) {
Banner(t.note ?: "Даты для этого расписания не рассчитаны.", offline = false, onClose = null)
Empty("Обзор недоступен", "Строки Word по дням недели — во вкладке «Расписание».", icon = Icons.Rounded.Description, action = "Открыть" to { nav.openDay(date) })
}
else -> ContentColumn {
val todaySlots = vm.slotsOn(t, date, subgroup)
val next = (1..7).asSequence()
.map { date.plusDays(it.toLong()) }
.map { it to vm.slotsOn(t, it, subgroup) }
.firstOrNull { it.second.isNotEmpty() }
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
item { QuickActions(nav, Modifier.bleed(16.dp)) }
item { WeekReport(vm, t, subgroup, date, clock) }
item { DayTitle("Сегодня", date) }
item { TodayCard(todaySlots, clock, onOpen = { details = it }, onDay = { nav.openDay(date) }) }
if (next != null) {
val (day, slots) = next
item { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) }
item {
SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) {
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it }) } }
DayButton { nav.openDay(day) }
}
}
}
}
}
}
}
details?.let { lesson ->
ModalBottomSheet(onDismissRequest = { details = null }) { LessonDetails(lesson, timetable?.bells.orEmpty()) }
}
}
/** Shortcuts under the app bar, as in School Planner's overview. */
@Composable
private fun QuickActions(nav: AppNav, modifier: Modifier = Modifier) {
val actions = listOf(
Triple("Сегодня", Icons.Rounded.Today) { nav.openDay(today()) },
Triple("Неделя", Icons.Rounded.CalendarViewWeek, nav.openWeek),
Triple("Предметы", Icons.AutoMirrored.Rounded.MenuBook, nav.openSubjects),
Triple("Кто где", Icons.Rounded.TravelExplore, nav.openSearch),
Triple("Группа", Icons.Rounded.SwapHoriz, nav.openGroups),
)
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
items(actions, key = { it.first }) { (label, icon, onClick) ->
FilledTonalButton(onClick = onClick, contentPadding = ButtonDefaults.ButtonWithIconContentPadding) {
Icon(icon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize))
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
Text(label)
}
}
}
}
/** Lets a row scroll edge to edge inside a padded column. */
private fun Modifier.bleed(horizontal: Dp): Modifier = layout { measurable, constraints ->
val extra = horizontal.roundToPx() * 2
val placeable = measurable.measure(
constraints.copy(minWidth = constraints.minWidth + extra, maxWidth = constraints.maxWidth + extra),
)
layout(placeable.width - extra, placeable.height) { placeable.place(-extra / 2, 0) }
}
/** "Прошло 12 из 18 пар" with a wavy progress line over the current week. */
@Composable
private fun WeekReport(vm: MainViewModel, t: Timetable, subgroup: String?, date: LocalDate, clock: LocalTime) {
val monday = date.monday()
val slots = (0..6L).flatMap { vm.slotsOn(t, monday.plusDays(it), subgroup) }
val done = slots.count { it.date < date || (it.date == date && !it.end.isAfter(clock)) }
val week = t.days.firstOrNull { it.date == date.toString() }?.week
SectionCard(Icons.Rounded.Insights, "Отчёт за неделю") {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
if (slots.isEmpty()) "На этой неделе занятий нет"
else "Прошло $done из ${slots.size} ${plural(slots.size, "пары", "пар", "пар")}",
style = MaterialTheme.typography.titleLargeEmphasized,
)
Text(
(week?.let { "Неделя ${it.n} · " } ?: "") + weekSpan(monday, monday.plusDays(6)),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
week?.let { WeekMark(it, Modifier.padding(top = 2.dp)) }
}
}
if (slots.isNotEmpty()) LinearWavyProgressIndicator(
progress = { done.toFloat() / slots.size },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
)
}
}
@Composable
private fun DayTitle(label: String, date: LocalDate) {
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 8.dp)) {
Heading(label, style = MaterialTheme.typography.headlineMediumEmphasized, modifier = Modifier.weight(1f))
Text(date.longLabel(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun TodayCard(slots: List<Slot>, clock: LocalTime, onOpen: (Lesson) -> Unit, onDay: () -> Unit) {
val left = slots.filter { it.end.isAfter(clock) }
SectionCard(Icons.Rounded.Schedule, "Следующие занятия") {
when {
slots.isEmpty() -> Quiet("Сегодня занятий нет", "Отдохните — ближайшие пары ниже.")
left.isEmpty() -> Quiet(
"Пары на сегодня закончились",
"Сегодня было ${slots.size} ${plural(slots.size, "пара", "пары", "пар")}.",
)
else -> {
val first = left.first()
first.lessons.forEach { FeaturedLesson(it, first, clock, onClick = { onOpen(it) }) }
if (left.size > 1) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
left.drop(1).forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { onOpen(it) }) } }
}
}
if (slots.isNotEmpty()) DayButton(onDay)
}
}
/** A titled tonal card: the dashboard's building block. */
@Composable
fun SectionCard(icon: ImageVector, title: String, content: @Composable ColumnScope.() -> Unit) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.card,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
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))
}
content()
}
}
}
@Composable
private fun Quiet(title: String, text: String) {
Column {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun DayButton(onClick: () -> Unit) {
TextButton(onClick = onClick, contentPadding = PaddingValues(horizontal = 8.dp)) {
Icon(Icons.Rounded.ViewDay, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Открыть день")
}
}
@Composable
fun SubjectDot(subject: String, size: Dp = 12.dp, modifier: Modifier = Modifier) {
Box(modifier.size(size).background(subjectColor(subject).container, CircleShape))
}
private fun Slot.timeLabel() = "$start → $end"
/** The next lesson in full: subject, time with a countdown, room and teacher. */
@Composable
private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick: () -> Unit) {
val running = !clock.isBefore(slot.start)
Row(
Modifier
.fillMaxWidth()
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
) {
SubjectDot(lesson.subject, 14.dp, Modifier.padding(top = 8.dp))
Spacer(Modifier.width(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(lesson.subject, style = MaterialTheme.typography.titleLargeEmphasized, maxLines = 3, overflow = TextOverflow.Ellipsis)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(slot.timeLabel(), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(10.dp))
val minutes = Duration.between(clock, if (running) slot.end else slot.start).toMinutes()
Surface(
shape = MaterialTheme.shapes.small,
color = if (running) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondaryContainer,
contentColor = if (running) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer,
) {
Text(
if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
}
val show = LocalDisplay.current
val room = if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }
room?.let { HomeMeta(Icons.Rounded.LocationOn, it) }
if (show.teachers && lesson.teachers.isNotEmpty()) HomeMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
}
}
}
/** Later lessons: a coloured dot, the subject and "13:40 → 15:05 · ауд. 254В". */
@Composable
fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
val show = LocalDisplay.current
Row(
Modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick) else Modifier),
verticalAlignment = Alignment.CenterVertically,
) {
SubjectDot(lesson.subject, 10.dp)
Spacer(Modifier.width(18.dp))
Column {
Text(lesson.subject, style = MaterialTheme.typography.titleSmallEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(
listOfNotNull(
slot.timeLabel(),
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. $it" },
lesson.type.takeIf { show.tags && it.isNotEmpty() }?.lowercase(),
).joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun HomeMeta(icon: ImageVector, text: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(8.dp))
Text(text, style = MaterialTheme.typography.bodyMedium)
}
}

View File

@@ -0,0 +1,91 @@
package ritm.schedule.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import ritm.schedule.app
import ritm.schedule.data.ApiException
import ritm.schedule.data.Display
import ritm.schedule.data.GroupResult
import ritm.schedule.data.SavedGroup
import ritm.schedule.data.ThemeMode
import ritm.schedule.data.Timetable
import ritm.schedule.data.UserSettings
import ritm.schedule.work.SyncWorker
data class Status(val loading: Boolean = false, val message: String? = null, val offline: Boolean = false)
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val app = application.app
val api = app.repository.api
val repository = app.repository
/** null until DataStore answers, so the first frame never shows the wrong screen. */
val settings: StateFlow<UserSettings?> =
app.settings.flow.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val timetable: StateFlow<Timetable?> = app.repository.timetable
private val _status = MutableStateFlow(Status())
val status = _status.asStateFlow()
init {
viewModelScope.launch {
if (app.settings.now().current != null) refresh()
}
}
fun refresh() = viewModelScope.launch { perform { repository.refresh() } }
/** done(true) when the timetable loaded; on failure the status carries the message. */
fun open(result: GroupResult, done: (Boolean) -> Unit) = viewModelScope.launch {
done(perform { repository.open(result) })
}
fun open(saved: SavedGroup, done: () -> Unit) = viewModelScope.launch {
// A saved group opens from the cache too when the network is down.
perform { repository.open(saved) }
done()
}
private suspend fun perform(block: suspend () -> Result<Timetable>): Boolean {
_status.value = _status.value.copy(loading = true)
val result = block()
val error = result.exceptionOrNull()
_status.value = Status(
loading = false,
message = error?.message,
offline = (error as? ApiException)?.offline == true,
)
SyncWorker.afterChange(getApplication())
return result.isSuccess
}
fun dismissMessage() {
_status.value = _status.value.copy(message = null)
}
fun setSubgroup(label: String?) = viewModelScope.launch {
app.settings.setSubgroup(label)
SyncWorker.afterChange(getApplication())
}
fun setTheme(mode: ThemeMode) = viewModelScope.launch { app.settings.setTheme(mode) }
fun setDynamicColor(on: Boolean) = viewModelScope.launch { app.settings.setDynamicColor(on) }
fun setDisplay(display: Display) = viewModelScope.launch { app.settings.setDisplay(display) }
fun setReminders(on: Boolean) = viewModelScope.launch {
app.settings.setReminders(on)
SyncWorker.afterChange(getApplication())
}
fun setReminderMinutes(minutes: Int) = viewModelScope.launch {
app.settings.setReminderMinutes(minutes)
SyncWorker.afterChange(getApplication())
}
}

View File

@@ -0,0 +1,31 @@
package ritm.schedule.ui
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import java.time.LocalDate
/** Ways a screen can send the student elsewhere in the app. */
class AppNav(
val openGroups: () -> Unit,
val openSettings: () -> Unit,
val openDay: (LocalDate) -> Unit,
val openWeek: () -> Unit,
val openSubjects: () -> Unit,
val openSearch: () -> Unit,
/** Opens the side menu on phones; null when the rail is always on screen. */
val openMenu: (() -> Unit)?,
)
/** The hamburger of a top-level screen's app bar. */
@Composable
fun MenuButton(nav: AppNav) {
nav.openMenu?.let { open ->
IconButton(onClick = open) { Icon(Icons.Rounded.Menu, contentDescription = "Открыть меню") }
}
}

View File

@@ -0,0 +1,631 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import kotlin.math.abs
import androidx.compose.material.icons.rounded.Weekend
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.toShape
import androidx.compose.ui.graphics.vector.ImageVector
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.material.icons.rounded.Today
import androidx.compose.runtime.mutableLongStateOf
import java.time.format.DateTimeFormatter
import java.util.Locale
import ritm.schedule.ui.theme.card
import ritm.schedule.ui.theme.page
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.EventBusy
import androidx.compose.material.icons.rounded.EventRepeat
import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.CalendarViewWeek
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material.icons.rounded.ViewDay
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import ritm.schedule.data.Bell
import ritm.schedule.data.Lesson
import ritm.schedule.data.Timetable
import ritm.schedule.data.dayName
import ritm.schedule.data.longLabel
import ritm.schedule.data.minutesLabel
import ritm.schedule.data.monday
import ritm.schedule.data.nowTime
import ritm.schedule.data.parseDate
import ritm.schedule.data.parseTime
import ritm.schedule.data.today
import ritm.schedule.data.weekdayName
import java.time.Duration
import java.time.LocalDate
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ScheduleScreen(
vm: MainViewModel,
nav: AppNav,
weekView: Boolean,
onWeekView: (Boolean) -> Unit,
focus: LocalDate?,
onFocusHandled: () -> Unit,
) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle()
var details by remember { mutableStateOf<Lesson?>(null) }
val subgroup = settings?.subgroup
val t = timetable
if (t?.semester != null) {
Planner(vm, nav, t, subgroup, weekView, onWeekView, focus, onFocusHandled, onOpen = { details = it })
} else Column(Modifier.fillMaxSize()) {
TopAppBar(
colors = appBarColors(),
navigationIcon = { MenuButton(nav) },
title = { Text(t?.group ?: settings?.current?.group ?: "Расписание", style = MaterialTheme.typography.titleLargeEmphasized) },
actions = { ScheduleMenu(vm, nav) },
)
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage) }
when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
t == null -> Empty(
"Расписание не загружено",
"Проверьте подключение или выберите группу заново.",
icon = Icons.Rounded.CloudOff,
action = "Выбрать группу" to nav.openGroups,
)
else -> WordRows(t, subgroup, onOpen = { details = it })
}
}
details?.let { lesson ->
ModalBottomSheet(onDismissRequest = { details = null }) {
LessonDetails(lesson, timetable?.bells.orEmpty())
}
}
}
/** Refresh is the only schedule action left in the app bar; groups and settings live in the menu. */
@Composable
private fun ScheduleMenu(vm: MainViewModel, nav: AppNav) {
val status by vm.status.collectAsStateWithLifecycle()
IconButton(onClick = { vm.refresh() }, enabled = !status.loading) {
Icon(Icons.Rounded.Refresh, contentDescription = "Обновить расписание")
}
}
private val MONTH_SHORT = DateTimeFormatter.ofPattern("LLL", Locale.forLanguageTag("ru"))
/** "Окт. · Неделя 7" — the School Planner style title of the shown week. */
internal fun weekTitle(date: LocalDate, n: Int?): String {
val month = date.format(MONTH_SHORT).replaceFirstChar { it.titlecase() }
return if (n != null) "$month · Неделя $n" else month
}
/**
* The dated timetable: a tinted header (app bar and calm day strip) over the lighter
* content, as in School Planner. Days page horizontally; the week view is a time grid.
*/
@Composable
private fun Planner(
vm: MainViewModel,
nav: AppNav,
t: Timetable,
subgroup: String?,
weekView: Boolean,
onWeekView: (Boolean) -> Unit,
focus: LocalDate?,
onFocusHandled: () -> Unit,
onOpen: (Lesson) -> Unit,
) {
val status by vm.status.collectAsStateWithLifecycle()
val dates = remember(t) { t.days.map { parseDate(it.date) } }
// On Sunday the next study day is more useful than an empty page.
val start = remember(t) {
val target = if (today().dayOfWeek.value == 7) today().plusDays(1) else today()
dates.indexOf(target).coerceAtLeast(0)
}
val pager = rememberPagerState(initialPage = start) { dates.size }
val scope = rememberCoroutineScope()
val date = dates.getOrElse(pager.currentPage) { today() }
var gridMonday by rememberSaveable { mutableLongStateOf(date.monday().toEpochDay()) }
val grid = LocalDate.ofEpochDay(gridMonday)
val mondays = remember(dates) { dates.map { it.monday() }.distinct() }
fun weekOf(monday: LocalDate) = t.days.firstOrNull { it.date == monday.toString() }?.week
fun go(target: LocalDate) {
val index = dates.indexOf(target)
if (index >= 0) scope.launch { pager.animateScrollToPage(index) }
}
fun week(step: Int) {
if (weekView) {
val next = grid.plusDays(7L * step)
if (dates.contains(next)) gridMonday = next.toEpochDay()
} else {
val next = date.plusDays(7L * step)
go(if (dates.contains(next)) next else if (step > 0) dates.last() else dates.first())
}
}
// A day or week opened from elsewhere (overview shortcuts, week grid headers).
LaunchedEffect(focus) {
if (focus != null) {
dates.indexOf(focus).takeIf { it >= 0 }?.let { pager.requestScrollToPage(it) }
gridMonday = focus.monday().toEpochDay()
onFocusHandled()
}
}
// The strip is a pager of weeks: it follows the day pager, and swiping it moves the days.
val strip = rememberPagerState(initialPage = mondays.indexOf(date.monday()).coerceAtLeast(0)) { mondays.size }
LaunchedEffect(date.monday()) {
val index = mondays.indexOf(date.monday())
if (index >= 0 && index != strip.currentPage) strip.animateScrollToPage(index)
}
LaunchedEffect(strip) {
snapshotFlow { strip.settledPage }.collect { page ->
val shown = dates.getOrElse(pager.currentPage) { today() }
val monday = mondays[page]
if (monday != shown.monday()) {
// The same weekday in the swiped-to week, or the nearest loaded day.
val target = monday.plusDays(shown.dayOfWeek.value - 1L)
go(dates.minBy { abs(it.toEpochDay() - target.toEpochDay()) })
}
}
}
val shownMonday = if (weekView) grid else date.monday()
val week = weekOf(shownMonday)
val tint = weekTint(week)
val onToday = if (weekView) grid == today().monday() else date == today()
val doc = t.document
Column(Modifier.fillMaxSize()) {
Column(Modifier.background(tint)) {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
navigationIcon = { MenuButton(nav) },
title = {
Column {
Text(weekTitle(shownMonday.plusDays(3), week?.n), style = MaterialTheme.typography.titleLargeEmphasized, maxLines = 1)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
listOfNotNull(t.group, subgroup?.lowercase()?.replace("подгруппа", "подгр.")).joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
week?.let {
Spacer(Modifier.width(10.dp))
WeekMark(it, short = true)
}
}
}
},
actions = {
if (!onToday) IconButton(onClick = {
if (weekView) gridMonday = today().monday().toEpochDay() else go(today())
}) { Icon(Icons.Rounded.Today, contentDescription = "Сегодня") }
IconButton(onClick = {
if (!weekView) gridMonday = date.monday().toEpochDay()
onWeekView(!weekView)
}) {
Icon(
if (weekView) Icons.Rounded.ViewDay else Icons.Rounded.CalendarViewWeek,
contentDescription = if (weekView) "Показать день" else "Показать неделю",
)
}
ScheduleMenu(vm, nav)
},
)
if (!weekView) HorizontalPager(state = strip, beyondViewportPageCount = 1) { page ->
val monday = mondays[page]
Box(Modifier.fillMaxWidth().background(weekTintOf(weekOf(monday))), contentAlignment = Alignment.Center) {
DayStrip(
monday = monday,
selected = date,
hasLessons = { d -> vm.repository.lessonsOn(t, d, subgroup).isNotEmpty() },
enabled = { d -> dates.contains(d) },
onSelect = ::go,
onWeek = ::week,
modifier = Modifier.widthIn(max = MAX_CONTENT).padding(horizontal = 8.dp),
)
}
}
}
status.message?.let {
Box(Modifier.background(MaterialTheme.colorScheme.page)) { Banner(it, offline = status.offline, onClose = vm::dismissMessage) }
}
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) {
if (weekView) {
// The week grid pages through weeks under the finger as well.
val weeks = rememberPagerState(initialPage = mondays.indexOf(grid).coerceAtLeast(0)) { mondays.size }
LaunchedEffect(gridMonday) {
val index = mondays.indexOf(grid)
if (index >= 0 && index != weeks.currentPage) weeks.animateScrollToPage(index)
}
LaunchedEffect(weeks) {
snapshotFlow { weeks.settledPage }.collect { gridMonday = mondays[it].toEpochDay() }
}
HorizontalPager(state = weeks, verticalAlignment = Alignment.Top) { page ->
val monday = mondays[page]
WeekGrid(
vm, t, subgroup, monday,
onOpen = onOpen,
onWeek = ::week,
headerColor = weekTintOf(weekOf(monday)),
)
}
} else PullToRefreshBox(isRefreshing = status.loading, onRefresh = { vm.refresh() }) {
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
val pageDate = dates[page]
DayPage(
date = pageDate,
slots = vm.slotsOn(t, pageDate, subgroup),
onOpen = onOpen,
)
}
}
}
}
}
@Composable
private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson) -> Unit) {
val clock = rememberClock()
val isToday = date == today()
val next = if (isToday) slots.firstOrNull { it.start.isAfter(clock) } else null
ContentColumn {
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
) {
if (slots.isEmpty()) item {
Empty("Занятий нет", "По таблице недель в этот день свободно — можно отдохнуть.", icon = Icons.Rounded.Weekend)
}
slots.forEach { slot ->
val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end)
val status = when {
running -> "Идёт · ещё ${minutesLabel(Duration.between(clock, slot.end).toMinutes())}"
slot == next && Duration.between(clock, slot.start).toMinutes() <= 120 ->
"Через ${minutesLabel(Duration.between(clock, slot.start).toMinutes())}"
else -> null
}
items(slot.lessons, key = { "${slot.start}|${it.key}" }) { lesson ->
LessonCard(
lesson,
time = "${slot.start} → ${slot.end}",
pair = lesson.pair,
status = status,
running = running,
onClick = { onOpen(lesson) },
)
}
}
}
}
}
/** A lesson as a soft card in its subject's colour, like School Planner's day list. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LessonCard(
lesson: Lesson,
time: String?,
pair: Int? = null,
status: String? = null,
running: Boolean = false,
onClick: (() -> Unit)?,
) {
val color = subjectColor(lesson.subject)
val show = LocalDisplay.current
val content: @Composable () -> Unit = {
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.Top) {
Text(lesson.subject, style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f))
status?.let {
Spacer(Modifier.width(8.dp))
Surface(
shape = MaterialTheme.shapes.small,
color = if (running) color.onSoft else color.onSoft.copy(alpha = 0.12f),
contentColor = if (running) color.soft else color.onSoft,
) {
Text(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
}
}
}
time?.let { Text(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно")
else if (show.rooms && lesson.rooms.isNotEmpty()) Meta(
Icons.Rounded.LocationOn,
lesson.rooms.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "",
)
if (show.teachers && lesson.teachers.isNotEmpty()) Meta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) }
}
val tags = if (!show.tags) emptyList() else listOfNotNull(
lesson.type.ifEmpty { null },
if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null,
if (lesson.groups.size > 1) "общее занятие" else null,
)
if (tags.isNotEmpty() || lesson.uncertain) FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 4.dp),
) {
tags.forEach { SoftTag(it, color) }
if (lesson.uncertain) Tag("Сверьте недели", warning = true)
}
if (show.notes && lesson.condition.isNotEmpty()) Meta(Icons.Rounded.EventRepeat, "Пометка в Word: ${lesson.condition}")
}
}
val border = if (running) BorderStroke(2.dp, color.onSoft) else null
if (onClick != null) Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = color.soft,
contentColor = color.onSoft,
border = border,
modifier = Modifier.fillMaxWidth(),
content = content,
) else Surface(
shape = MaterialTheme.shapes.large,
color = color.soft,
contentColor = color.onSoft,
border = border,
modifier = Modifier.fillMaxWidth(),
content = content,
)
}
@Composable
private fun SoftTag(text: String, color: SubjectColor) {
Surface(shape = MaterialTheme.shapes.small, color = color.onSoft.copy(alpha = 0.12f), contentColor = color.onSoft) {
Text(text.replaceFirstChar { it.titlecase() }, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
}
}
@Composable
private fun Meta(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(text, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun TypeTag(type: String) {
val scheme = MaterialTheme.colorScheme
val (bg, fg) = when (type) {
"Лекция" -> scheme.primaryContainer to scheme.onPrimaryContainer
"Практика" -> scheme.tertiaryContainer to scheme.onTertiaryContainer
"Экзамен", "Зачёт" -> scheme.errorContainer to scheme.onErrorContainer
else -> scheme.secondaryContainer to scheme.onSecondaryContainer
}
Surface(shape = MaterialTheme.shapes.small, color = bg, contentColor = fg) {
Text(type.ifEmpty { "Занятие" }, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
}
}
@Composable
private fun Tag(text: String, warning: Boolean = false) {
Surface(
shape = MaterialTheme.shapes.small,
color = if (warning) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = if (warning) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant,
) {
Text(text, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
}
}
@Composable
internal fun LessonDetails(lesson: Lesson, bells: List<Bell>) {
val bell = bells.firstOrNull { it.start == lesson.start }
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TypeTag(lesson.type)
Row(verticalAlignment = Alignment.CenterVertically) {
SubjectDot(lesson.subject, 16.dp)
Spacer(Modifier.width(14.dp))
Heading(lesson.subject, style = MaterialTheme.typography.headlineSmallEmphasized)
}
Detail(
Icons.Rounded.Schedule,
bell?.let { "${it.pair} пара" } ?: "Время",
lesson.start + (lesson.end?.let { " — $it" } ?: ""),
bell?.halves?.takeIf { it.size > 1 }?.joinToString(", ") { it.joinToString("–") },
)
Detail(Icons.Rounded.Person, "Преподаватель", lesson.teachers.joinToString(", ").ifEmpty { "Не указан" })
Detail(
Icons.Rounded.LocationOn,
"Аудитория",
if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { "Не указана" },
if (lesson.roomMoved) "В этот день — перенос аудитории" else null,
)
Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup)
if (lesson.uncertain) Banner(
"Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.",
offline = false,
onClose = null,
)
Heading("Исходная запись", style = MaterialTheme.typography.titleSmallEmphasized)
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())
}
}
}
@Composable
private fun Detail(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String, extra: String? = null) {
Row {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
Spacer(Modifier.width(16.dp))
Column {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyLargeEmphasized)
extra?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
}
/** When the week table has no semester for this schedule: the Word rows by weekday. */
@Composable
private fun WordRows(t: Timetable, subgroup: String?, onOpen: (Lesson) -> Unit) {
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 16.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
item { Banner(t.note ?: "Даты не рассчитаны: показаны строки Word.", offline = false, onClose = null) }
for (day in t.weekly) {
item(key = "h${day.day}") {
Heading(dayName(day.day), style = MaterialTheme.typography.headlineSmallEmphasized)
}
val lessons = day.lessons.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
items(lessons, key = { "${day.day}-${it.key}" }) { lesson ->
LessonCard(lesson, time = lesson.start + (lesson.end?.let { " → $it" } ?: ""), onClick = { onOpen(lesson) })
}
}
}
}
@Composable
fun Banner(text: String, offline: Boolean, onClose: (() -> Unit)?) {
Surface(
shape = MaterialTheme.shapes.large,
color = if (offline) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer,
contentColor = if (offline) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
) {
Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(if (offline) Icons.Rounded.CloudOff else Icons.Rounded.ErrorOutline, contentDescription = null)
Spacer(Modifier.width(12.dp))
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(vertical = 8.dp))
if (onClose != null) TextButton(onClick = onClose) { Text("OK") }
}
}
}
/**
* Empty state in the RideBus style: an icon on a seven-sided cookie shape, a title,
* a calm explanation and an optional tonal action.
*/
@Composable
fun Empty(
title: String,
text: String,
icon: ImageVector = Icons.Rounded.EventBusy,
action: Pair<String, () -> Unit>? = null,
) {
Column(
Modifier.fillMaxWidth().padding(vertical = 48.dp, horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(112.dp)
.background(MaterialTheme.colorScheme.primaryContainer, MaterialShapes.Cookie7Sided.toShape()),
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp))
}
Spacer(Modifier.height(20.dp))
Text(title, style = MaterialTheme.typography.titleLargeEmphasized, textAlign = TextAlign.Center)
Spacer(Modifier.height(6.dp))
Text(
text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
action?.let { (label, onClick) ->
Spacer(Modifier.height(20.dp))
FilledTonalButton(onClick = onClick) { Text(label) }
}
}
}
private val FACULTIES = mapOf(
"Финансово-экономический факультет" to "ФЭФ",
"Факультет информационных технологий" to "ФИТ",
"Факультет компьютерных наук и электроники" to "ФКНЭ",
"Гуманитарный факультет" to "ГФ",
"Инженерно-строительный факультет" to "ИСФ",
"Механико-технологический факультет" to "МТФ",
"Юридический факультет" to "ЮФ",
)
fun facultyShort(name: String): String? = name.takeIf { it.isNotEmpty() }?.let { FACULTIES[it] ?: it }

View File

@@ -0,0 +1,427 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.rounded.PersonSearch
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Today
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import java.time.temporal.ChronoUnit
import androidx.compose.material.icons.rounded.SearchOff
import androidx.compose.material.icons.rounded.EventAvailable
import ritm.schedule.ui.theme.card
import ritm.schedule.ui.theme.page
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.MeetingRoom
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import ritm.schedule.data.Bell
import ritm.schedule.data.attempt
import ritm.schedule.data.Coverage
import ritm.schedule.data.DirectoryDay
import ritm.schedule.data.DirectorySearch
import ritm.schedule.data.FreeRooms
import ritm.schedule.data.Lesson
import ritm.schedule.data.longLabel
import ritm.schedule.data.monday
import ritm.schedule.data.nowTime
import ritm.schedule.data.parseTime
import ritm.schedule.data.today
import ritm.schedule.data.weekdayName
import java.time.LocalDate
private sealed interface Target {
data class Teacher(val name: String) : Target
data class Room(val room: String) : Target
}
private fun startDate(): LocalDate = if (today().dayOfWeek.value == 7) today().plusDays(1) else today()
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class)
@Composable
fun SearchScreen(vm: MainViewModel, nav: AppNav) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
var tab by rememberSaveable { mutableIntStateOf(0) }
var query by rememberSaveable { mutableStateOf("") }
var target by remember { mutableStateOf<Target?>(null) }
var date by remember { mutableStateOf(startDate()) }
var bells by remember { mutableStateOf(timetable?.bells.orEmpty()) }
var pair by remember { mutableIntStateOf(1) }
var hits by remember { mutableStateOf<DirectorySearch?>(null) }
var day by remember { mutableStateOf<DirectoryDay?>(null) }
var free by remember { mutableStateOf<FreeRooms?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (bells.isEmpty()) attempt { vm.api.calendar() }.onSuccess { bells = it.bells }
val now = nowTime()
pair = bells.firstOrNull { parseTime(it.end).isAfter(now) }?.pair ?: bells.firstOrNull()?.pair ?: 1
}
LaunchedEffect(query) {
if (query.trim().length < 2) { hits = null; return@LaunchedEffect }
delay(250)
attempt { vm.api.searchDirectory(query.trim()) }.onSuccess { hits = it; error = null }.onFailure { error = it.message }
}
LaunchedEffect(target, date) {
val t = target ?: return@LaunchedEffect
loading = true
attempt {
when (t) {
is Target.Teacher -> vm.api.teacherDay(t.name, date.toString())
is Target.Room -> vm.api.roomDay(t.room, date.toString())
}
}.onSuccess { day = it; error = null }.onFailure { error = it.message }
loading = false
}
LaunchedEffect(tab, date, pair) {
if (tab != 1) return@LaunchedEffect
loading = true
attempt { vm.api.freeRooms(date.toString(), pair) }
.onSuccess { free = it; error = null }.onFailure { error = it.message }
loading = false
}
// Back from a teacher's or room's day returns to the search results.
BackHandler(enabled = target != null) { target = null; day = null }
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
val dated = target != null || tab == 1
val week = timetable?.days?.firstOrNull { it.date == date.monday().toString() }?.week
// Undated search tints its header once results scroll under it; dated views carry the week's colour.
val scrolledTint by animateColorAsState(
if (scroll.state.overlappedFraction > 0.01f) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.page,
MaterialTheme.motionScheme.defaultEffectsSpec(),
label = "search-header",
)
val weekColor = weekTint(week)
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
Column(Modifier.background(if (dated) weekColor else scrolledTint)) {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent, scrolledContainerColor = Color.Transparent),
scrollBehavior = scroll,
title = {
Column {
Text(
when (val t = target) {
is Target.Teacher -> t.name
is Target.Room -> "Аудитория ${t.room}"
null -> "Поиск"
},
style = MaterialTheme.typography.titleLargeEmphasized,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (dated) Row(verticalAlignment = Alignment.CenterVertically) {
Text(
weekTitle(date.monday().plusDays(3), week?.n),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
week?.let {
Spacer(Modifier.width(10.dp))
WeekMark(it, short = true)
}
}
}
},
navigationIcon = {
if (target != null) IconButton(onClick = { target = null; day = null }) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Назад к поиску")
} else MenuButton(nav)
},
actions = {
if (dated && date != startDate()) IconButton(onClick = { date = startDate() }) {
Icon(Icons.Rounded.Today, contentDescription = "Сегодня")
}
},
)
if (target == null) ConnectedChoice(
options = listOf(0 to "Кто где", 1 to "Свободные"),
selected = tab,
onSelect = { tab = it },
modifier = Modifier.widthIn(max = MAX_CONTENT).fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
.align(Alignment.CenterHorizontally),
)
if (dated) SearchWeekStrip(timetable, date) { date = it }
}
Box(Modifier.fillMaxSize().background(if (target != null) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.page)) {
ContentColumn {
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
) {
error?.let { item { Banner(it, offline = true, onClose = null) } }
if (tab == 0 && target == null) {
item { SearchField(query, { query = it }, "Фамилия или номер аудитории") }
val h = hits
if (h == null) item {
Empty(
"Кто где",
"Найдите преподавателя или аудиторию — покажем занятия на выбранный день по всем расписаниям университета.",
icon = Icons.Rounded.PersonSearch,
)
} else {
if (h.teachers.isNotEmpty()) item {
ListCard(Icons.Rounded.Person, "Преподаватели") {
h.teachers.forEachIndexed { index, t ->
if (index > 0) RowDivider()
CardRow(
avatar = avatarLabel(t.name),
color = subjectColor(t.name),
title = t.name,
subtitle = t.faculties.joinToString(", ") { facultyShort(it) ?: it },
onClick = { target = Target.Teacher(t.name) },
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
)
}
}
}
if (h.rooms.isNotEmpty()) item {
ListCard(Icons.Rounded.MeetingRoom, "Аудитории") {
Box(Modifier.padding(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 12.dp)) {
RoomChips(h.rooms.map { it.room }) { target = Target.Room(it) }
}
}
}
if (h.teachers.isEmpty() && h.rooms.isEmpty()) item {
Empty("Никого не нашли", "Ни преподавателя, ни аудитории с таким названием в расписаниях каталога.", icon = Icons.Rounded.SearchOff)
}
}
}
if (target != null) {
val d = day
if (loading && d == null) item { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() } }
if (d != null) {
if (d.lessons.isEmpty()) item { Empty("Занятий нет", "По расписаниям каталога в этот день здесь пусто.", icon = Icons.Rounded.EventAvailable) }
items(d.lessons, key = { "${it.start}|${it.subject}|${it.rooms}" }) { l ->
LessonCard(
Lesson(
key = l.start + l.subject,
start = l.start,
end = l.end,
subject = l.subject,
type = l.type,
teachers = if (target is Target.Room) l.teachers else emptyList(),
rooms = if (target is Target.Teacher) l.rooms else emptyList(),
roomMoved = l.moved,
distance = l.distance,
condition = l.condition,
groups = l.groups,
subgroup = l.groups.joinToString(", "),
uncertain = l.uncertain,
),
time = l.start + (l.end?.let { " → $it" } ?: ""),
pair = bells.firstOrNull { it.start == l.start }?.pair,
onClick = null,
)
}
item { CoverageNote(d.coverage, free = false) }
}
}
if (tab == 1 && target == null) {
item {
ListCard(Icons.Rounded.Schedule, "Пара") {
Box(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp)) { PairPicker(bells, pair) { pair = it } }
}
}
if (loading && free == null) item { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() } }
free?.let { f ->
item {
ListCard(Icons.Rounded.MeetingRoom, "Свободно ${f.freeCount} из ${f.knownRooms}") {
Text(
"${f.bell.pair} пара, ${f.bell.start}–${f.bell.end} · занято ${f.busyCount}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 64.dp, end = 20.dp, bottom = 8.dp),
)
f.free.forEachIndexed { index, g ->
if (index > 0) HorizontalDivider(
Modifier.padding(horizontal = 20.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
) {
Text(
if (g.letter.isEmpty()) "Номера без буквы" else "Номера с «${g.letter}»",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
RoomChips(g.rooms) { room -> tab = 0; target = Target.Room(room) }
}
}
}
}
item { CoverageNote(f.coverage, free = true) }
}
}
}
}
}
}
}
/** The calm day strip as a pager of weeks around today; each page wears its week's colour. */
@Composable
private fun SearchWeekStrip(timetable: ritm.schedule.data.Timetable?, date: LocalDate, onDate: (LocalDate) -> Unit) {
val base = remember { startDate().monday() }
fun mondayAt(page: Int): LocalDate = base.plusWeeks((page - SPAN).toLong())
val pager = rememberPagerState(initialPage = SPAN + ChronoUnit.WEEKS.between(base, date.monday()).toInt()) { SPAN * 2 + 1 }
val current by rememberUpdatedState(date)
LaunchedEffect(date.monday()) {
val index = SPAN + ChronoUnit.WEEKS.between(base, date.monday()).toInt()
if (index != pager.currentPage && index in 0 until pager.pageCount) pager.animateScrollToPage(index)
}
LaunchedEffect(pager) {
snapshotFlow { pager.settledPage }.collect { page ->
val monday = mondayAt(page)
if (monday != current.monday()) onDate(monday.plusDays(current.dayOfWeek.value - 1L))
}
}
HorizontalPager(state = pager, beyondViewportPageCount = 1) { page ->
val monday = mondayAt(page)
Box(
Modifier.fillMaxWidth().background(weekTintOf(timetable?.days?.firstOrNull { it.date == monday.toString() }?.week)),
contentAlignment = Alignment.Center,
) {
DayStrip(
monday = monday,
selected = date,
hasLessons = { true },
onSelect = onDate,
onWeek = { onDate(date.plusDays(7L * it)) },
modifier = Modifier.widthIn(max = MAX_CONTENT).padding(horizontal = 8.dp),
)
}
}
}
/** Weeks the search strip can page through on either side of today. */
private const val SPAN = 52
@Composable
private fun SectionTitle(text: String) = Heading(text)
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RoomChips(rooms: List<String>, onClick: (String) -> Unit) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
rooms.forEach { room ->
Surface(
onClick = { onClick(room) },
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
) {
Row(Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.MeetingRoom, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(room, style = MaterialTheme.typography.titleSmall)
}
}
}
}
}
@Composable
private fun PairPicker(bells: List<Bell>, pair: Int, onSelect: (Int) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
bells.chunked(4).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
row.forEach { b ->
val on = b.pair == pair
val corner by animateDpAsState(
if (on) 12.dp else 20.dp,
MaterialTheme.motionScheme.fastSpatialSpec(),
label = "pair-shape",
)
Surface(
onClick = { onSelect(b.pair) },
shape = RoundedCornerShape(corner),
color = if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.page,
contentColor = if (on) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
) {
Column(Modifier.padding(vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("${b.pair}", style = MaterialTheme.typography.titleMediumEmphasized)
Text(b.start, style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
}
}
@Composable
private fun CoverageNote(c: Coverage, free: Boolean) {
Text(
"По расписаниям из Word: учтено ${c.documents} из ${c.total} документов каталога ${c.year}." +
(if (free) " Аудитория может быть занята мероприятием или закрыта — расписание этого не знает." else ""),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}

View File

@@ -0,0 +1,190 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui
import androidx.compose.foundation.selection.toggleable
import androidx.compose.ui.semantics.Role
import ritm.schedule.ui.theme.card
import ritm.schedule.ui.theme.page
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ritm.schedule.BuildConfig
import ritm.schedule.data.ThemeMode
import ritm.schedule.notify.Reminders
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
val settings by vm.settings.collectAsStateWithLifecycle()
val timetable by vm.timetable.collectAsStateWithLifecycle()
val s = settings ?: return
val context = LocalContext.current
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
vm.setReminders(granted)
}
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
TopAppBar(
colors = appBarColors(),
title = { Text("Настройки", style = MaterialTheme.typography.titleLargeEmphasized) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Назад")
}
},
scrollBehavior = scroll,
)
ContentColumn {
Column(
Modifier.verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Section("Тема") {
ConnectedChoice(
options = listOf(ThemeMode.System to "Авто", ThemeMode.Light to "Светлая", ThemeMode.Dark to "Тёмная"),
selected = s.theme,
onSelect = vm::setTheme,
modifier = Modifier.fillMaxWidth(),
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) SwitchRow(
"Цвета обоев",
"Цвета приложения подстраиваются под обои телефона",
s.dynamicColor,
vm::setDynamicColor,
)
}
Section("Расписание") {
val d = s.display
SwitchRow("Цвета предметов", "Каждый предмет своим цветом в карточках, сетке и обзоре", d.colors) {
vm.setDisplay(d.copy(colors = it))
}
SwitchRow("Преподаватели", "Фамилии в карточках занятий", d.teachers) { vm.setDisplay(d.copy(teachers = it)) }
SwitchRow("Аудитории", "Номера аудиторий в карточках и в сетке", d.rooms) { vm.setDisplay(d.copy(rooms = it)) }
SwitchRow("Номера пар", "«2 пара» рядом со временем", d.pairs) { vm.setDisplay(d.copy(pairs = it)) }
SwitchRow("Метки занятий", "Лекция или практика, белые и зелёные недели, общее занятие", d.tags) {
vm.setDisplay(d.copy(tags = it))
}
SwitchRow("Пометки из Word", "Например, «3-13н.» — даты по ним уже рассчитаны", d.notes) {
vm.setDisplay(d.copy(notes = it))
}
Text(
"Полные сведения о занятии всегда открываются по нажатию на карточку.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Section("Напоминания") {
SwitchRow(
"Перед парой",
"Уведомление с предметом и аудиторией. Работает без интернета, по сохранённому расписанию.",
s.reminders,
) { on ->
if (on && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !Reminders.canNotify(context))
permission.launch(Manifest.permission.POST_NOTIFICATIONS)
else vm.setReminders(on)
}
if (s.reminders && !Reminders.canBeExact(context)) {
Text(
"Без разрешения на точные будильники Android может прислать напоминание на пару минут раньше или позже.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
androidx.compose.material3.OutlinedButton(onClick = {
context.startActivity(
android.content.Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
.setData(android.net.Uri.parse("package:" + context.packageName)),
)
}) { Text("Разрешить точное время") }
}
if (s.reminders) {
Text("За сколько минут", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
ConnectedChoice(
options = listOf(5 to "5", 10 to "10", 15 to "15", 30 to "30"),
selected = s.reminderMinutes,
onSelect = vm::setReminderMinutes,
modifier = Modifier.fillMaxWidth(),
)
}
}
Section("О расписании") {
val doc = timetable?.document
Info("Документ", doc?.name ?: "Группа не выбрана")
timetable?.semester?.let { Info("Семестр", it.label) }
Info("Сервер", BuildConfig.API_URL)
Text(
"Расписание берётся из публичного каталога ПГУ и пересчитывается по таблице белых и зелёных недель. " +
"Приложение неофициальное: при сомнениях сверяйтесь с документом Word.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun Section(title: String, content: @Composable () -> Unit) {
Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.card, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Heading(title)
content()
}
}
}
@Composable
private fun SwitchRow(title: String, text: String, checked: Boolean, onChange: (Boolean) -> Unit) {
// The whole row is the touch target, not only the switch.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().toggleable(value = checked, role = Role.Switch, onValueChange = onChange),
) {
Column(Modifier.weight(1f).padding(end = 16.dp)) {
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(checked = checked, onCheckedChange = null)
}
}
@Composable
private fun Info(label: String, value: String) {
Column {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyLarge)
}
}

View File

@@ -0,0 +1,102 @@
package ritm.schedule.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import ritm.schedule.data.Display
import ritm.schedule.data.Lesson
import ritm.schedule.data.Timetable
import ritm.schedule.data.parseTime
import java.time.LocalTime
/**
* A subject's colours: [container]/[content] for small strong fills (week grid, dots),
* [soft]/[onSoft] for School Planner style lesson cards. Every pair keeps 4.5:1 contrast.
*/
data class SubjectColor(val container: Color, val content: Color, val soft: Color, val onSoft: Color)
// Material 700 tones (strong in light theme) and 200 tones (strong in dark theme).
private val TONE_700 = listOf(
0xFF1565C0, 0xFF7B1FA2, 0xFFC62828, 0xFF2E7D32, 0xFFD84315, 0xFF00695C,
0xFF283593, 0xFFAD1457, 0xFF5D4037, 0xFF00838F, 0xFF4527A0, 0xFF455A64,
).map { Color(it) }
private val TONE_200 = listOf(
0xFF90CAF9, 0xFFCE93D8, 0xFFEF9A9A, 0xFFA5D6A7, 0xFFFFAB91, 0xFF80CBC4,
0xFF9FA8DA, 0xFFF48FB1, 0xFFBCAAA4, 0xFF80DEEA, 0xFFB39DDB, 0xFFB0BEC5,
).map { Color(it) }
private val LIGHT = TONE_700.indices.map { i ->
SubjectColor(
container = TONE_700[i],
content = Color.White,
soft = lerp(TONE_200[i], Color.White, 0.35f),
onSoft = lerp(TONE_700[i], Color.Black, 0.35f),
)
}
private val DARK = TONE_700.indices.map { i ->
SubjectColor(
container = TONE_200[i],
content = Color(0xFF1D1B20),
soft = lerp(Color(0xFF211F26), TONE_700[i], 0.5f),
onSoft = lerp(TONE_200[i], Color.White, 0.4f),
)
}
fun subjectKey(subject: String) = subject.trim().lowercase()
/**
* Palette slot of every subject of the open timetable. Slots go in alphabetical order,
* so a subject keeps its colour across refreshes and two subjects rarely share one.
*/
fun subjectSlots(t: Timetable?): Map<String, Int> {
if (t == null) return emptyMap()
val subjects = (t.days.flatMap { it.lessons } + t.weekly.flatMap { it.lessons })
.map { subjectKey(it.subject) }
.distinct()
.sorted()
return subjects.withIndex().associate { (i, key) -> key to i % LIGHT.size }
}
val LocalSubjectSlots = staticCompositionLocalOf<Map<String, Int>> { emptyMap() }
/** The student's display choices from the settings. */
val LocalDisplay = staticCompositionLocalOf { Display() }
@Composable
fun subjectColor(subject: String): SubjectColor {
if (!LocalDisplay.current.colors) {
// Colours off: every lesson in the calm theme tones.
val scheme = MaterialTheme.colorScheme
return SubjectColor(scheme.primaryContainer, scheme.onPrimaryContainer, scheme.surfaceContainerHigh, scheme.onSurface)
}
val key = subjectKey(subject)
// Subjects of other groups (search results) still get a stable colour.
val slot = LocalSubjectSlots.current[key] ?: key.hashCode().mod(LIGHT.size)
val dark = MaterialTheme.colorScheme.surface.luminance() < 0.5f
return (if (dark) DARK else LIGHT)[slot]
}
private val SHORT_WORDS = setOf("и", "в", "во", "на", "по", "с", "со", "к", "о", "об", "для", "из", "от")
/** "Экономика предприятия в машиностроении" → "ЭПМ"; short names stay as they are. */
fun abbreviate(subject: String): String {
val name = subject.substringBefore(':').trim()
if (name.length <= 10) return name
val letters = name.split(' ', '-', '(', ')', ',')
.filter { it.isNotEmpty() && it.lowercase() !in SHORT_WORDS && it.first().isLetter() }
.joinToString("") { it.first().uppercase() }
// Four letters still fit a phone's week column at the default font size.
return letters.take(4).takeIf { it.length >= 2 } ?: name.take(8)
}
/** End of a lesson: its own end, else the bell table's, else 80 minutes after the start. */
fun lessonEnd(lesson: Lesson, t: Timetable): LocalTime =
lesson.end?.let(::parseTime)
?: t.bells.firstOrNull { it.start == lesson.start }?.end?.let(::parseTime)
?: parseTime(lesson.start).plusMinutes(80)
fun Color.luminance(): Float = 0.2126f * red + 0.7152f * green + 0.0722f * blue

View File

@@ -0,0 +1,362 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui
import androidx.compose.foundation.background
import androidx.compose.material.icons.rounded.DateRange
import androidx.compose.material.icons.rounded.Event
import ritm.schedule.ui.theme.page
import ritm.schedule.ui.theme.card
import ritm.schedule.ui.theme.page
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.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.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BarChart
import androidx.compose.material.icons.rounded.EventBusy
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.automirrored.rounded.MenuBook
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ritm.schedule.data.Lesson
import ritm.schedule.data.Timetable
import ritm.schedule.data.longLabel
import ritm.schedule.data.monday
import ritm.schedule.data.parseDate
import ritm.schedule.data.plural
import ritm.schedule.data.shortWeekday
import ritm.schedule.data.today
import ritm.schedule.data.weekdayName
import java.time.LocalTime
/** Everything the loaded weeks say about one subject. */
private class SubjectSummary(
val name: String,
val types: List<String>,
val teachers: List<String>,
val rooms: List<String>,
val upcoming: List<Pair<Slot, Lesson>>,
val weekDone: Int,
val weekTotal: Int,
)
private fun MainViewModel.summaries(t: Timetable, subgroup: String?, clock: LocalTime): List<SubjectSummary> {
val date = today()
val monday = date.monday()
val entries = t.days.flatMap { day -> slotsOn(t, parseDate(day.date), subgroup) }
.flatMap { slot -> slot.lessons.map { slot to it } }
return entries.groupBy { subjectKey(it.second.subject) }.values.map { items ->
val lessons = items.map { it.second }
val week = items.filter { it.first.date.monday() == monday }
SubjectSummary(
name = lessons.first().subject,
types = lessons.map { it.type.ifEmpty { "Занятие" } }.distinct(),
teachers = lessons.flatMap { it.teachers }.distinct(),
rooms = lessons.flatMap { it.rooms }.distinct(),
upcoming = items.filter { (slot, _) -> slot.date > date || (slot.date == date && slot.end.isAfter(clock)) },
weekDone = week.count { (slot, _) -> slot.date < date || (slot.date == date && !slot.end.isAfter(clock)) },
weekTotal = week.size,
)
}.sortedWith(
// Soonest first; subjects with nothing ahead go to the end.
compareBy<SubjectSummary, java.time.LocalDate?>(nullsLast()) { it.upcoming.firstOrNull()?.first?.date }
.thenBy(nullsLast()) { it.upcoming.firstOrNull()?.first?.start }
.thenBy { it.name },
)
}
@Composable
fun SubjectsScreen(vm: MainViewModel, nav: AppNav) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle()
var open by remember { mutableStateOf<SubjectSummary?>(null) }
val clock = rememberClock()
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
TopAppBar(
colors = appBarColors(),
scrollBehavior = scroll,
title = { Text("Предметы", style = MaterialTheme.typography.titleLargeEmphasized) },
navigationIcon = { MenuButton(nav) },
)
val t = timetable
when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
t == null -> Empty("Предметов пока нет", "Выберите группу — предметы появятся из её расписания.", icon = Icons.AutoMirrored.Rounded.MenuBook, action = "Выбрать группу" to nav.openGroups)
t.semester == null -> Empty("Предметы недоступны", "Для этого расписания даты не рассчитаны: откройте вкладку «Расписание».", icon = Icons.Rounded.EventBusy)
else -> ContentColumn {
val subgroup = settings?.subgroup
val subjects = vm.summaries(t, subgroup, clock)
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
) {
item { WeekLoad(vm, t, subgroup, subjects.size) }
item {
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 8.dp)) {
Heading("Все предметы", style = MaterialTheme.typography.titleLargeEmphasized, modifier = Modifier.weight(1f))
Text("по ближайшему занятию", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
items(subjects, key = { subjectKey(it.name) }) { s -> SubjectCard(s, onClick = { open = s }) }
}
}
}
}
open?.let { s ->
ModalBottomSheet(onDismissRequest = { open = null }) { SubjectDetails(s) }
}
}
/** Pairs per weekday of the current week: a count over each bar, today's bar in the strong tone. */
@Composable
private fun WeekLoad(vm: MainViewModel, t: Timetable, subgroup: String?, subjects: Int) {
val date = today()
val monday = date.monday()
val days = (0..6L).map { monday.plusDays(it) }.map { it to vm.slotsOn(t, it, subgroup).size }
.filter { (d, n) -> d.dayOfWeek.value < 7 || n > 0 }
val total = days.sumOf { it.second }
val most = days.maxOf { it.second }.coerceAtLeast(1)
val busiest = days.filter { it.second == most && most > 0 }.map { it.first.weekdayName().lowercase() }
SectionCard(Icons.Rounded.BarChart, "Эта неделя") {
Column {
Text(
"$total ${plural(total, "пара", "пары", "пар")} · $subjects ${plural(subjects, "предмет", "предмета", "предметов")}",
style = MaterialTheme.typography.titleLargeEmphasized,
)
if (busiest.isNotEmpty()) Text(
"Больше всего — ${busiest.joinToString(" и ")}, $most ${plural(most, "пара", "пары", "пар")}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {
contentDescription = days.joinToString(", ") { (d, n) -> "${d.weekdayName()}: $n" }
},
) {
days.forEach { (d, n) ->
val isToday = d == date
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f).clearAndSetSemantics {}) {
Text(
if (n > 0) "$n" else "—",
style = MaterialTheme.typography.labelLarge,
color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Surface(
shape = MaterialTheme.shapes.small,
color = when {
isToday -> MaterialTheme.colorScheme.primary
d < date -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
else -> MaterialTheme.colorScheme.primaryContainer
},
modifier = Modifier.width(28.dp).height((4 + 60 * n / most).dp),
) {}
Spacer(Modifier.height(6.dp))
Text(
d.shortWeekday(),
style = MaterialTheme.typography.labelMedium,
color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/** "Сегодня, 11:45", "Завтра, 10:05" or "Пн, 19 октября, 10:05". */
private fun nextLabel(slot: Slot): String {
val date = today()
val day = when (slot.date) {
date -> "Сегодня"
date.plusDays(1) -> "Завтра"
else -> slot.date.shortWeekday() + ", " + slot.date.longLabel()
}
return "$day, ${slot.start}"
}
/** The subject's short label: the same letters the week grid shows. */
fun subjectBadge(name: String): String {
val short = abbreviate(name)
return if (short.length <= 4) short
else short.split(' ').filter { it.isNotEmpty() }.take(2).joinToString("") { it.first().uppercase() }
.let { if (it.length >= 2) it else short.take(2).replaceFirstChar { c -> c.uppercase() } }
}
/** A tinted square with the subject's grid letters, so a block in the week grid is easy to find here. */
@Composable
private fun SubjectBadge(name: String, size: androidx.compose.ui.unit.Dp = 48.dp) {
val color = subjectColor(name)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(size)
.background(color.container, MaterialTheme.shapes.large)
.clearAndSetSemantics {},
) {
Text(subjectBadge(name), style = MaterialTheme.typography.titleSmallEmphasized, color = color.content, maxLines = 1)
}
}
@Composable
private fun SubjectCard(s: SubjectSummary, onClick: () -> Unit) {
val show = LocalDisplay.current
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.card,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
SubjectBadge(s.name)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(s.name, style = MaterialTheme.typography.titleMediumEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (show.tags) Text(
s.types.joinToString(" · ").lowercase().replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.page, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
val next = s.upcoming.firstOrNull()
Fact(
Icons.Rounded.Event,
next?.let { (slot, lesson) ->
nextLabel(slot) + (lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { ", ауд. ${it.joinToString(", ")}" } ?: "")
} ?: "В загруженных неделях больше нет",
strong = next != null,
)
if (show.teachers && s.teachers.isNotEmpty()) Fact(Icons.Rounded.Person, s.teachers.joinToString(", "))
Fact(
Icons.Rounded.DateRange,
if (s.weekTotal == 0) "На этой неделе пар нет"
else "На этой неделе ${s.weekTotal} ${plural(s.weekTotal, "пара", "пары", "пар")}" +
when (s.weekDone) {
0 -> ""
s.weekTotal -> ", все прошли"
else -> ", прошло ${s.weekDone}"
},
)
}
}
}
}
}
@Composable
private fun Fact(icon: ImageVector, text: String, strong: Boolean = false) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(10.dp))
Text(text, style = if (strong) MaterialTheme.typography.bodyLargeEmphasized else MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun SubjectDetails(s: SubjectSummary) {
val color = subjectColor(s.name)
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
SubjectBadge(s.name, 56.dp)
Spacer(Modifier.width(16.dp))
Heading(s.name, style = MaterialTheme.typography.headlineSmallEmphasized)
}
Text(s.types.joinToString(" · "), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
DetailLine(Icons.Rounded.Person, "Преподаватели", s.teachers.joinToString(", ").ifEmpty { "Не указаны" })
DetailLine(Icons.Rounded.LocationOn, "Аудитории", s.rooms.joinToString(", ").ifEmpty { "Не указаны" })
Heading("Ближайшие занятия", style = MaterialTheme.typography.titleMediumEmphasized)
if (s.upcoming.isEmpty()) Text("В загруженных неделях занятий больше нет.", color = MaterialTheme.colorScheme.onSurfaceVariant)
s.upcoming.take(12).groupBy { it.first.date }.forEach { (date, items) ->
Text(
date.weekdayName() + ", " + date.longLabel(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
// The subject is the sheet's title, so each row keeps only when, where and what.
items.forEach { (slot, lesson) ->
Row(verticalAlignment = Alignment.CenterVertically) {
SubjectDot(lesson.subject, 10.dp)
Spacer(Modifier.width(18.dp))
Column {
Text("${slot.start} → ${slot.end}", style = MaterialTheme.typography.titleSmallEmphasized)
Text(
listOfNotNull(
lesson.type.ifEmpty { null },
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" },
lesson.teachers.joinToString(", ").ifEmpty { null },
).joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun DetailLine(icon: ImageVector, label: String, value: String) {
Row {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
Spacer(Modifier.width(16.dp))
Column {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyLargeEmphasized)
}
}
}

View File

@@ -0,0 +1,206 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package ritm.schedule.ui
import ritm.schedule.ui.theme.card
import ritm.schedule.ui.theme.page
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
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.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ritm.schedule.data.Lesson
import ritm.schedule.data.Timetable
import ritm.schedule.data.longLabel
import ritm.schedule.data.shortWeekday
import ritm.schedule.data.today
import ritm.schedule.data.weekdayName
import java.time.LocalDate
import java.time.LocalTime
private val HOUR = 60.dp
private val GUTTER = 36.dp
/** The week as a time grid: one column per day, a coloured block per lesson. */
@Composable
fun WeekGrid(
vm: MainViewModel,
t: Timetable,
subgroup: String?,
monday: LocalDate,
onOpen: (Lesson) -> Unit,
onWeek: (Int) -> Unit,
headerColor: Color,
modifier: Modifier = Modifier,
) {
val clock = rememberClock()
val all = (0..6L).map { monday.plusDays(it) }.map { it to vm.slotsOn(t, it, subgroup) }
// Sunday only takes a column when something happens on it.
val days = all.filter { (d, slots) -> d.dayOfWeek.value < 7 || slots.isNotEmpty() }
val slots = days.flatMap { it.second }
val first = minOf(8, slots.minOfOrNull { it.start.hour } ?: 8)
val last = maxOf(18, slots.maxOfOrNull { it.end.hour + if (it.end.minute > 0) 1 else 0 } ?: 18)
val hours = last - first
fun top(time: LocalTime) = HOUR * ((time.hour - first) * 60 + time.minute) / 60f
val line = MaterialTheme.colorScheme.outlineVariant
Column(modifier) {
Row(
Modifier
.background(headerColor)
.padding(start = GUTTER, bottom = 8.dp)
.semantics {
customActions = listOf(
CustomAccessibilityAction("Предыдущая неделя") { onWeek(-1); true },
CustomAccessibilityAction("Следующая неделя") { onWeek(1); true },
)
},
) {
days.forEach { (d, _) ->
val isToday = d == today()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
.semantics(mergeDescendants = true) {
contentDescription = "${d.weekdayName()}, ${d.longLabel()}"
},
) {
Text(
d.shortWeekday(),
style = MaterialTheme.typography.labelMedium,
color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.background(if (isToday) MaterialTheme.colorScheme.primary else Color.Transparent, CircleShape),
) {
Text(
"${d.dayOfMonth}",
style = MaterialTheme.typography.titleMediumEmphasized,
color = if (isToday) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
Row(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
// Room above the first hour label, which sits on its line.
.padding(top = 8.dp, bottom = 24.dp + navBarPadding())
.height(HOUR * hours),
) {
Box(Modifier.width(GUTTER).fillMaxHeight()) {
for (h in first until last) Text(
"$h",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.offset(x = 12.dp, y = HOUR * (h - first) - 6.dp).clearAndSetSemantics {},
)
}
days.forEach { (d, daySlots) ->
BoxWithConstraints(
Modifier
.weight(1f)
.fillMaxHeight()
.drawBehind {
val step = size.height / hours
for (h in 0..hours) drawLine(line, Offset(0f, step * h), Offset(size.width, step * h), 1f)
drawLine(line, Offset(0f, 0f), Offset(0f, size.height), 1f)
},
) {
val wide = maxWidth >= 96.dp
daySlots.forEach { slot ->
val y = top(slot.start)
val height = top(slot.end) - y
val width = maxWidth / slot.lessons.size
slot.lessons.forEachIndexed { i, lesson ->
LessonBlock(
lesson, slot, wide,
onClick = { onOpen(lesson) },
modifier = Modifier
.offset(x = width * i, y = y)
.size(width, height)
.padding(horizontal = 2.dp, vertical = 1.dp),
)
}
}
if (d == today() && clock.hour in first until last) {
val y = top(clock)
Box(Modifier.offset(y = y - 1.dp).fillMaxWidth().height(2.dp).background(MaterialTheme.colorScheme.primary))
Box(Modifier.offset(x = (-5).dp, y = y - 5.dp).size(10.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
}
}
}
}
}
}
@Composable
private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, onClick: () -> Unit, modifier: Modifier) {
val color = subjectColor(lesson.subject)
val room = when {
lesson.distance -> "дист."
LocalDisplay.current.rooms -> lesson.rooms.joinToString(", ")
else -> ""
}
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.small,
color = color.container,
contentColor = color.content,
modifier = modifier.semantics {
contentDescription = "${lesson.subject}, ${slot.start}–${slot.end}" +
(if (room.isNotEmpty()) ", ауд. $room" else "") + (lesson.type.takeIf { it.isNotEmpty() }?.let { ", $it" } ?: "")
},
) {
Column(Modifier.padding(horizontal = 6.dp, vertical = 4.dp).clearAndSetSemantics {}, verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
if (wide) lesson.subject else abbreviate(lesson.subject),
style = MaterialTheme.typography.labelLargeEmphasized,
maxLines = if (wide) 3 else 2,
overflow = TextOverflow.Ellipsis,
)
if (room.isNotEmpty()) Text(room, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}

View File

@@ -0,0 +1,126 @@
package ritm.schedule.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import ritm.schedule.data.ThemeMode
// The same tokens as the web app (src/styles.css), so both clients look alike.
private val Light = lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color.White,
primaryContainer = Color(0xFFEADDFF),
onPrimaryContainer = Color(0xFF21005D),
secondary = Color(0xFF625B71),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE8DEF8),
onSecondaryContainer = Color(0xFF1D192B),
tertiary = Color(0xFF7D5260),
onTertiary = Color.White,
tertiaryContainer = Color(0xFFFFD8E4),
onTertiaryContainer = Color(0xFF31111D),
surface = Color(0xFFFEF7FF),
onSurface = Color(0xFF1D1B20),
onSurfaceVariant = Color(0xFF49454F),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFF7F2FA),
surfaceContainer = Color(0xFFF3EDF7),
surfaceContainerHigh = Color(0xFFECE6F0),
surfaceContainerHighest = Color(0xFFE6E0E9),
outline = Color(0xFF79747E),
outlineVariant = Color(0xFFCAC4D0),
error = Color(0xFFB3261E),
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF410E0B),
background = Color(0xFFFEF7FF),
onBackground = Color(0xFF1D1B20),
)
private val Dark = darkColorScheme(
primary = Color(0xFFD0BCFF),
onPrimary = Color(0xFF381E72),
primaryContainer = Color(0xFF4F378B),
onPrimaryContainer = Color(0xFFEADDFF),
secondary = Color(0xFFCCC2DC),
onSecondary = Color(0xFF332D41),
secondaryContainer = Color(0xFF4A4458),
onSecondaryContainer = Color(0xFFE8DEF8),
tertiary = Color(0xFFEFB8C8),
onTertiary = Color(0xFF492532),
tertiaryContainer = Color(0xFF633B48),
onTertiaryContainer = Color(0xFFFFD8E4),
surface = Color(0xFF141218),
onSurface = Color(0xFFE6E0E9),
onSurfaceVariant = Color(0xFFCAC4D0),
surfaceContainerLowest = Color(0xFF0F0D13),
surfaceContainerLow = Color(0xFF1D1B20),
surfaceContainer = Color(0xFF211F26),
surfaceContainerHigh = Color(0xFF2B2930),
surfaceContainerHighest = Color(0xFF36343B),
outline = Color(0xFF938F99),
outlineVariant = Color(0xFF49454F),
error = Color(0xFFF2B8B5),
errorContainer = Color(0xFF8C1D18),
onErrorContainer = Color(0xFFF9DEDC),
background = Color(0xFF141218),
onBackground = Color(0xFFE6E0E9),
)
/** The tinted page behind cards, app bars and the navigation rail. */
val ColorScheme.page: Color get() = surfaceContainer
/** Cards lift off the page: white in the light theme, a lighter tone in the dark one. */
val ColorScheme.card: Color
get() = if (surface.red + surface.green + surface.blue < 1.5f) surfaceContainerHigh else surfaceContainerLowest
/** Week colours from the university table: white and green weeks. */
object WeekColors {
fun container(white: Boolean, dark: Boolean) = when {
white && dark -> Color(0xFFE6E0E9)
white -> Color.White
dark -> Color(0xFF3D6A3A)
else -> Color(0xFFC8EFB8)
}
/** A strong green for small marks, readable on both themes. */
fun mark(dark: Boolean) = if (dark) Color(0xFF81C784) else Color(0xFF2E7D32)
fun content(white: Boolean, dark: Boolean) = when {
white -> Color(0xFF1D1B20)
dark -> Color(0xFFD5E8D4)
else -> Color(0xFF284F34)
}
}
@Composable
fun isDark(mode: ThemeMode): Boolean = when (mode) {
ThemeMode.System -> isSystemInDarkTheme()
ThemeMode.Light -> false
ThemeMode.Dark -> true
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ScheduleTheme(mode: ThemeMode, dynamicColor: Boolean, content: @Composable () -> Unit) {
val dark = isDark(mode)
val context = LocalContext.current
val scheme: ColorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
dark -> Dark
else -> Light
}
MaterialExpressiveTheme(
colorScheme = scheme,
motionScheme = MotionScheme.expressive(),
content = content,
)
}

View File

@@ -0,0 +1,154 @@
package ritm.schedule.widget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import ritm.schedule.MainActivity
import ritm.schedule.app
import ritm.schedule.data.Lesson
import ritm.schedule.data.longLabel
import ritm.schedule.data.minutesLabel
import ritm.schedule.data.nowTime
import ritm.schedule.data.parseTime
import ritm.schedule.data.today
import ritm.schedule.data.weekdayName
import java.time.Duration
import java.time.LocalDate
/** What the widget shows: the lesson in progress and what comes next, today or tomorrow. */
private data class WidgetState(
val group: String?,
val title: String,
val lines: List<Pair<String, String>>,
val empty: String?,
)
private suspend fun state(context: Context): WidgetState {
val app = context.app
val timetable = app.repository.timetable.value
?: return WidgetState(null, "Расписание", emptyList(), "Откройте приложение и выберите группу")
val subgroup = app.settings.now().subgroup
val now = nowTime()
val todayLessons = app.repository.lessonsOn(timetable, today(), subgroup)
val left = todayLessons.filter { parseTime(it.end ?: it.start).isAfter(now) }
val (date: LocalDate, list: List<Lesson>) =
if (left.isNotEmpty()) today() to left
else today().plusDays(1) to app.repository.lessonsOn(timetable, today().plusDays(1), subgroup)
val title = if (date == today()) "Сегодня, ${date.longLabel()}" else "Завтра, ${date.weekdayName().lowercase()}"
val lines = list.groupBy { it.start }.entries.take(3).map { (start, lessons) ->
val l = lessons.first()
val begins = parseTime(start)
val ends = l.end?.let(::parseTime)
val label = when {
date == today() && !now.isBefore(begins) && ends != null && now.isBefore(ends) ->
"Идёт · ещё ${minutesLabel(Duration.between(now, ends).toMinutes())}"
date == today() && begins.isAfter(now) &&
Duration.between(now, begins).toMinutes() <= 120 ->
"$start · через ${minutesLabel(Duration.between(now, begins).toMinutes())}"
else -> start + (l.pair?.let { " · $it пара" } ?: "")
}
val place = lessons.flatMap { it.rooms }.distinct().joinToString(", ")
label to (lessons.joinToString(" / ") { it.subject } + if (place.isNotEmpty()) " · ауд. $place" else "")
}
return WidgetState(
timetable.group,
title,
lines,
if (lines.isEmpty()) "Занятий нет" else null,
)
}
class TodayWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = state(context)
provideContent { GlanceTheme { Content(data) } }
}
@Composable
private fun Content(data: WidgetState) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.widgetBackground)
.cornerRadius(24.dp)
.padding(16.dp)
.clickable(actionStartActivity<MainActivity>()),
) {
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
data.group ?: "Расписание",
style = TextStyle(
color = GlanceTheme.colors.primary,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
),
)
Spacer(GlanceModifier.defaultWeight())
Text(data.title, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp))
}
Spacer(GlanceModifier.height(8.dp))
data.empty?.let {
Text(it, style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp))
}
data.lines.forEachIndexed { index, (label, text) ->
Column(
modifier = GlanceModifier
.fillMaxWidth()
.background(
if (index == 0) GlanceTheme.colors.primaryContainer
else GlanceTheme.colors.secondaryContainer,
)
.cornerRadius(16.dp)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
label,
style = TextStyle(
color = if (index == 0) GlanceTheme.colors.onPrimaryContainer
else GlanceTheme.colors.onSecondaryContainer,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
),
)
Text(
text,
maxLines = 2,
style = TextStyle(
color = if (index == 0) GlanceTheme.colors.onPrimaryContainer
else GlanceTheme.colors.onSecondaryContainer,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
),
)
}
Spacer(GlanceModifier.height(6.dp))
}
}
}
}
class TodayWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = TodayWidget()
}

View File

@@ -0,0 +1,56 @@
package ritm.schedule.work
import android.content.Context
import androidx.glance.appwidget.updateAll
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import ritm.schedule.app
import ritm.schedule.notify.Reminders
import ritm.schedule.widget.TodayWidget
import java.util.concurrent.TimeUnit
/**
* Every few hours: fetch the timetable (the university republishes files),
* redraw the widget and plan the next reminders. Offline runs reuse the cache.
*/
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val app = applicationContext.app
app.repository.refresh()
afterChange(applicationContext)
return Result.success()
}
companion object {
fun schedule(context: Context) {
val request = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
val tick = PeriodicWorkRequestBuilder<WidgetTick>(15, TimeUnit.MINUTES).build()
WorkManager.getInstance(context).apply {
enqueueUniquePeriodicWork("sync", ExistingPeriodicWorkPolicy.KEEP, request)
enqueueUniquePeriodicWork("widget", ExistingPeriodicWorkPolicy.KEEP, tick)
}
}
/** Widget and reminders follow the cached timetable and settings. */
suspend fun afterChange(context: Context) {
val app = context.app
Reminders.reschedule(context, app.repository.timetable.value, app.settings.now())
TodayWidget().updateAll(context)
}
}
}
/** Keeps "now / next lesson" on the widget current; no network needed. */
class WidgetTick(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
TodayWidget().updateAll(applicationContext)
return Result.success()
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "calendar_month" in the adaptive icon safe zone. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="960"
android:viewportHeight="960">
<group
android:pivotX="480"
android:pivotY="480"
android:scaleX="0.5"
android:scaleY="0.5"
android:translateY="480">
<path
android:fillColor="#4F378B"
android:pathData="M200,-80Q167,-80 143.5,-103.5Q120,-127 120,-160L120,-720Q120,-753 143.5,-776.5Q167,-800 200,-800L240,-800L240,-840Q240,-857 251.5,-868.5Q263,-880 280,-880Q297,-880 308.5,-868.5Q320,-857 320,-840L320,-800L640,-800L640,-840Q640,-857 651.5,-868.5Q663,-880 680,-880Q697,-880 708.5,-868.5Q720,-857 720,-840L720,-800L760,-800Q793,-800 816.5,-776.5Q840,-753 840,-720L840,-160Q840,-127 816.5,-103.5Q793,-80 760,-80L200,-80ZM200,-160L760,-160Q760,-160 760,-160Q760,-160 760,-160L760,-560L200,-560L200,-160Q200,-160 200,-160Q200,-160 200,-160ZM480,-400Q463,-400 451.5,-411.5Q440,-423 440,-440Q440,-457 451.5,-468.5Q463,-480 480,-480Q497,-480 508.5,-468.5Q520,-457 520,-440Q520,-423 508.5,-411.5Q497,-400 480,-400ZM320,-400Q303,-400 291.5,-411.5Q280,-423 280,-440Q280,-457 291.5,-468.5Q303,-480 320,-480Q337,-480 348.5,-468.5Q360,-457 360,-440Q360,-423 348.5,-411.5Q337,-400 320,-400ZM640,-400Q623,-400 611.5,-411.5Q600,-423 600,-440Q600,-457 611.5,-468.5Q623,-480 640,-480Q657,-480 668.5,-468.5Q680,-457 680,-440Q680,-423 668.5,-411.5Q657,-400 640,-400ZM480,-240Q463,-240 451.5,-251.5Q440,-263 440,-280Q440,-297 451.5,-308.5Q463,-320 480,-320Q497,-320 508.5,-308.5Q520,-297 520,-280Q520,-263 508.5,-251.5Q497,-240 480,-240ZM320,-240Q303,-240 291.5,-251.5Q280,-263 280,-280Q280,-297 291.5,-308.5Q303,-320 320,-320Q337,-320 348.5,-308.5Q360,-297 360,-280Q360,-263 348.5,-251.5Q337,-240 320,-240ZM640,-240Q623,-240 611.5,-251.5Q600,-263 600,-280Q600,-297 611.5,-308.5Q623,-320 640,-320Q657,-320 668.5,-308.5Q680,-297 680,-280Q680,-263 668.5,-251.5Q657,-240 640,-240Z" />
</group>
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<group android:translateY="960">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M200,-80Q167,-80 143.5,-103.5Q120,-127 120,-160L120,-720Q120,-753 143.5,-776.5Q167,-800 200,-800L240,-800L240,-840Q240,-857 251.5,-868.5Q263,-880 280,-880Q297,-880 308.5,-868.5Q320,-857 320,-840L320,-800L640,-800L640,-840Q640,-857 651.5,-868.5Q663,-880 680,-880Q697,-880 708.5,-868.5Q720,-857 720,-840L720,-800L760,-800Q793,-800 816.5,-776.5Q840,-753 840,-720L840,-160Q840,-127 816.5,-103.5Q793,-80 760,-80L200,-80ZM200,-160L760,-160L760,-560L200,-560L200,-160Z" />
</group>
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="surface">#141218</color>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.PsuSchedule" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/surface</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="surface">#FEF7FF</color>
<color name="primary">#6750A4</color>
<color name="launcher_background">#EADDFF</color>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Расписание ПГУ</string>
<string name="widget_name">Сегодня</string>
<string name="widget_description">Текущая и следующая пара вашей группы</string>
<string name="channel_reminders">Перед парой</string>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Only the window before Compose draws; colours match the app surface. -->
<style name="Theme.PsuSchedule" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/surface</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- HTTPS everywhere; plain HTTP only for a local development server
(10.0.2.2 is the host computer as seen from the emulator). -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="180dp"
android:minHeight="110dp"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />

5
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
}

11
android/gradle.properties Normal file
View File

@@ -0,0 +1,11 @@
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.configuration-cache=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
# Адрес API. По умолчанию — сервер из docker-compose на этом компьютере,
# видимый из эмулятора как 10.0.2.2. Для релиза нужен публичный HTTPS-адрес:
# ./gradlew assembleRelease -PPSU_API_URL=https://schedule.example.by/
PSU_API_URL=http://10.0.2.2:3010/

View File

@@ -0,0 +1,41 @@
[versions]
agp = "9.4.1"
kotlin = "2.4.20"
composeBom = "2026.09.01"
activity = "1.13.0"
lifecycle = "2.11.0"
coreKtx = "1.19.1"
coroutines = "1.11.0"
serialization = "1.11.0"
okhttp = "4.12.0"
datastore = "1.2.1"
glance = "1.2.0"
work = "2.12.0"
desugar = "2.1.5"
junit = "4.13.2"
[libraries]
compose-bom = { module = "androidx.compose:compose-bom-alpha", version.ref = "composeBom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-icons = { module = "androidx.compose.material:material-icons-extended" }
activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" }
glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" }
work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar" }
junit = { module = "junit:junit", version.ref = "junit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Binary file not shown.

View File

@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
android/gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

82
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%

View File

@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "PsuSchedule"
include(":app")