made android client
This commit is contained in:
7
android/.gitignore
vendored
Normal file
7
android/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
.kotlin/
|
||||
captures/
|
||||
70
android/app/build.gradle.kts
Normal file
70
android/app/build.gradle.kts
Normal 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
4
android/app/proguard-rules.pro
vendored
Normal 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; }
|
||||
55
android/app/src/main/AndroidManifest.xml
Normal file
55
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
315
android/app/src/main/kotlin/ritm/schedule/MainActivity.kt
Normal file
315
android/app/src/main/kotlin/ritm/schedule/MainActivity.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
25
android/app/src/main/kotlin/ritm/schedule/ScheduleApp.kt
Normal file
25
android/app/src/main/kotlin/ritm/schedule/ScheduleApp.kt
Normal 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
|
||||
82
android/app/src/main/kotlin/ritm/schedule/data/Api.kt
Normal file
82
android/app/src/main/kotlin/ritm/schedule/data/Api.kt
Normal 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)
|
||||
}
|
||||
48
android/app/src/main/kotlin/ritm/schedule/data/Dates.kt
Normal file
48
android/app/src/main/kotlin/ritm/schedule/data/Dates.kt
Normal 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 ""
|
||||
154
android/app/src/main/kotlin/ritm/schedule/data/Models.kt
Normal file
154
android/app/src/main/kotlin/ritm/schedule/data/Models.kt
Normal 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 = "")
|
||||
55
android/app/src/main/kotlin/ritm/schedule/data/Repository.kt
Normal file
55
android/app/src/main/kotlin/ritm/schedule/data/Repository.kt
Normal 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() }
|
||||
}
|
||||
121
android/app/src/main/kotlin/ritm/schedule/data/Settings.kt
Normal file
121
android/app/src/main/kotlin/ritm/schedule/data/Settings.kt
Normal 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
|
||||
}
|
||||
}
|
||||
155
android/app/src/main/kotlin/ritm/schedule/notify/Reminders.kt
Normal file
155
android/app/src/main/kotlin/ritm/schedule/notify/Reminders.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
485
android/app/src/main/kotlin/ritm/schedule/ui/Common.kt
Normal file
485
android/app/src/main/kotlin/ritm/schedule/ui/Common.kt
Normal 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 { "?" }
|
||||
}
|
||||
276
android/app/src/main/kotlin/ritm/schedule/ui/GroupsScreen.kt
Normal file
276
android/app/src/main/kotlin/ritm/schedule/ui/GroupsScreen.kt
Normal 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
390
android/app/src/main/kotlin/ritm/schedule/ui/HomeScreen.kt
Normal file
390
android/app/src/main/kotlin/ritm/schedule/ui/HomeScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
31
android/app/src/main/kotlin/ritm/schedule/ui/Nav.kt
Normal file
31
android/app/src/main/kotlin/ritm/schedule/ui/Nav.kt
Normal 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 = "Открыть меню") }
|
||||
}
|
||||
}
|
||||
631
android/app/src/main/kotlin/ritm/schedule/ui/ScheduleScreen.kt
Normal file
631
android/app/src/main/kotlin/ritm/schedule/ui/ScheduleScreen.kt
Normal 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 }
|
||||
427
android/app/src/main/kotlin/ritm/schedule/ui/SearchScreen.kt
Normal file
427
android/app/src/main/kotlin/ritm/schedule/ui/SearchScreen.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
190
android/app/src/main/kotlin/ritm/schedule/ui/SettingsScreen.kt
Normal file
190
android/app/src/main/kotlin/ritm/schedule/ui/SettingsScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
102
android/app/src/main/kotlin/ritm/schedule/ui/Subjects.kt
Normal file
102
android/app/src/main/kotlin/ritm/schedule/ui/Subjects.kt
Normal 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
|
||||
362
android/app/src/main/kotlin/ritm/schedule/ui/SubjectsScreen.kt
Normal file
362
android/app/src/main/kotlin/ritm/schedule/ui/SubjectsScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
206
android/app/src/main/kotlin/ritm/schedule/ui/WeekGrid.kt
Normal file
206
android/app/src/main/kotlin/ritm/schedule/ui/WeekGrid.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
126
android/app/src/main/kotlin/ritm/schedule/ui/theme/Theme.kt
Normal file
126
android/app/src/main/kotlin/ritm/schedule/ui/theme/Theme.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
154
android/app/src/main/kotlin/ritm/schedule/widget/TodayWidget.kt
Normal file
154
android/app/src/main/kotlin/ritm/schedule/widget/TodayWidget.kt
Normal 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()
|
||||
}
|
||||
56
android/app/src/main/kotlin/ritm/schedule/work/SyncWorker.kt
Normal file
56
android/app/src/main/kotlin/ritm/schedule/work/SyncWorker.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
18
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
18
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
12
android/app/src/main/res/drawable/ic_notification.xml
Normal file
12
android/app/src/main/res/drawable/ic_notification.xml
Normal 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>
|
||||
@@ -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>
|
||||
4
android/app/src/main/res/values-night/colors.xml
Normal file
4
android/app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="surface">#141218</color>
|
||||
</resources>
|
||||
8
android/app/src/main/res/values-night/themes.xml
Normal file
8
android/app/src/main/res/values-night/themes.xml
Normal 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>
|
||||
6
android/app/src/main/res/values/colors.xml
Normal file
6
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
7
android/app/src/main/res/values/strings.xml
Normal file
7
android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
9
android/app/src/main/res/values/themes.xml
Normal file
9
android/app/src/main/res/values/themes.xml
Normal 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>
|
||||
10
android/app/src/main/res/xml/network_security_config.xml
Normal file
10
android/app/src/main/res/xml/network_security_config.xml
Normal 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>
|
||||
11
android/app/src/main/res/xml/today_widget_info.xml
Normal file
11
android/app/src/main/res/xml/today_widget_info.xml
Normal 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
5
android/build.gradle.kts
Normal 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
11
android/gradle.properties
Normal 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/
|
||||
41
android/gradle/libs.versions.toml
Normal file
41
android/gradle/libs.versions.toml
Normal 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" }
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
9
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
9
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
android/gradlew
vendored
Executable 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
82
android/gradlew.bat
vendored
Normal 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%
|
||||
16
android/settings.gradle.kts
Normal file
16
android/settings.gradle.kts
Normal 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")
|
||||
Reference in New Issue
Block a user