feat: add Next Lesson widget and enhance existing widgets

- Implemented a new Next Lesson widget to display the upcoming lesson details on the home screen.
- Updated Today widget to dynamically adjust the number of lessons displayed based on available space.
- Enhanced the Settings screen to allow users to manage hidden lessons more effectively.
- Introduced harmonized color palettes for better visual consistency across themes.
- Updated the timetable API to support a new range parameter for semester-based queries.
- Added tests to ensure the new semester range functionality works as expected.
- Refactored widget styling for improved appearance on devices with different Android versions.
This commit is contained in:
2026-10-18 00:39:32 +03:00
parent 29df0aa61f
commit d900e038b6
52 changed files with 278058 additions and 183859 deletions

View File

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

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +0,0 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "ritm.schedule",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "0.1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 26
}

View File

@@ -39,6 +39,29 @@
android:resource="@xml/today_widget_info" />
</receiver>
<receiver
android:name=".widget.NextLessonWidgetReceiver"
android:exported="true"
android:label="@string/next_widget_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/next_widget_info" />
</receiver>
<service
android:name=".tile.LessonTileService"
android:exported="true"
android:icon="@drawable/ic_tile_lesson"
android:label="@string/tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<receiver
android:name=".notify.ReminderReceiver"
android:exported="false" />

View File

@@ -1,5 +1,41 @@
package ritm.schedule
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.outlined.CalendarViewWeek
import androidx.compose.material.icons.outlined.Checklist
import androidx.compose.material.icons.rounded.CalendarViewWeek
import androidx.compose.material.icons.rounded.Checklist
import ritm.schedule.ui.CalendarScreen
import ritm.schedule.ui.PlansScreen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.Alignment
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.WideNavigationRailState
import androidx.compose.material3.WideNavigationRailValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.automirrored.rounded.MenuOpen
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.ui.semantics.contentDescription
import ritm.schedule.ui.materialFadeThrough
import ritm.schedule.ui.Emphasized
import ritm.schedule.ui.MotionDuration
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.animation.core.tween
import ritm.schedule.ui.materialSharedAxisX
import ritm.schedule.ui.rememberSlideDistance
import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
@@ -74,6 +110,10 @@ import ritm.schedule.ui.AppNav
import ritm.schedule.ui.GroupsScreen
import ritm.schedule.ui.HomeScreen
import ritm.schedule.ui.LocalDisplay
import ritm.schedule.ui.LocalMarks
import ritm.schedule.ui.LocalMenuProgress
import ritm.schedule.ui.theme.card
import androidx.compose.foundation.layout.widthIn
import ritm.schedule.ui.LocalNotes
import ritm.schedule.ui.LocalSubjectSlots
import ritm.schedule.data.Display
@@ -90,7 +130,9 @@ 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),
Plans("Планы", Icons.Outlined.Checklist, Icons.Rounded.Checklist),
Calendar("Календарь", Icons.Outlined.CalendarMonth, Icons.Rounded.CalendarMonth),
Schedule("Расписание", Icons.Outlined.CalendarViewWeek, Icons.Rounded.CalendarViewWeek),
Subjects("Предметы", Icons.Outlined.School, Icons.Rounded.School),
Search("Поиск", Icons.Outlined.TravelExplore, Icons.Rounded.TravelExplore),
}
@@ -140,20 +182,25 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
var page by rememberSaveable { mutableStateOf(if (firstRun) Page.Groups else null) }
var weekView by rememberSaveable { mutableStateOf(false) }
var focus by remember { mutableStateOf<LocalDate?>(null) }
var menuOpen by rememberSaveable { mutableStateOf(false) }
val settings by vm.settings.collectAsStateWithLifecycle()
val timetable by vm.timetable.collectAsStateWithLifecycle()
val hasGroup = settings?.current != null
val menu = rememberWideNavigationRailState()
val rail = 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 }
// Declared last, so an open menu closes before anything else.
BackHandler(enabled = menuOpen) { menuOpen = false }
val slots = remember(timetable) { subjectSlots(timetable) }
val motion = MaterialTheme.motionScheme
val slide = rememberSlideDistance()
BoxWithConstraints(Modifier.fillMaxSize()) {
// Behind the pushed-aside screen: the menu's own card-white background.
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.card)) {
val wide = maxWidth >= MEDIUM_WIDTH
BackHandler(enabled = wide && (rail.targetValue == WideNavigationRailValue.Expanded)) { scope.launch { rail.collapse() } }
val nav = remember(wide) {
AppNav(
openGroups = { page = Page.Groups },
@@ -162,160 +209,237 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
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() } }),
openPlans = { tab = Tab.Plans; page = null },
openCalendar = { tab = Tab.Calendar; page = null },
openMenu = if (wide) null else ({ menuOpen = true }),
)
}
val notes by vm.notes.collectAsStateWithLifecycle()
CompositionLocalProvider(
LocalSubjectSlots provides slots,
LocalDisplay provides (settings?.display ?: Display()),
LocalNotes provides notes,
val menuItems = MenuItems(
tab = tab,
page = page,
group = settings?.current?.group,
onTab = { tab = it; page = null },
onPage = { page = it },
)
// Phones: the menu sits under the screen, and the screen slides right to show it,
// as in the M3 expanded navigation rail that appears from the menu icon.
// The screen begins and ends on screen, so both ways use the M3 emphasized easing
// (md.sys.motion.easing.emphasized): long2 500 ms to open, medium4 400 ms to close.
val opening = menuOpen && !wide
val openState = animateFloatAsState(
if (opening) 1f else 0f,
tween(if (opening) MotionDuration.Long2 else MotionDuration.Medium4, easing = Emphasized),
label = "menu",
)
// Read only in layout and draw lambdas, so a running animation never recomposes the app.
val open by openState
var menuWidth by remember { mutableIntStateOf(0) }
// Composed ahead of time: the first frame of opening has nothing to build or measure.
if (!wide) Box(
Modifier
.onSizeChanged { menuWidth = it.width }
.graphicsLayer { alpha = open.coerceIn(0f, 1f) }
.then(if (menuOpen) Modifier else Modifier.clearAndSetSemantics {}),
) {
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,
)
MenuRail(
state = rememberWideNavigationRailState(WideNavigationRailValue.Expanded),
expanded = true,
items = menuItems,
color = MaterialTheme.colorScheme.card,
modifier = Modifier.widthIn(min = 256.dp),
onToggle = { menuOpen = false },
onPicked = { menuOpen = false },
)
}
val notes by vm.notes.collectAsStateWithLifecycle()
val marks by vm.marks.collectAsStateWithLifecycle()
val corner = with(LocalDensity.current) { 28.dp.toPx() }
// 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) MenuRail(
state = rail,
expanded = (rail.targetValue == WideNavigationRailValue.Expanded),
items = menuItems,
color = MaterialTheme.colorScheme.page,
onToggle = { scope.launch { rail.toggle() } },
onPicked = { scope.launch { rail.collapse() } },
)
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.graphicsLayer {
translationX = menuWidth * open
if (open > 0f) {
shape = RoundedCornerShape(corner * open)
clip = true
}
}
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)
.background(MaterialTheme.colorScheme.page)
.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,
),
) {
CompositionLocalProvider(
LocalMenuProgress provides openState,
LocalSubjectSlots provides slots,
LocalDisplay provides (settings?.display ?: Display()),
LocalNotes provides notes,
LocalMarks provides marks,
) {
AnimatedContent(
targetState = page,
// Shared axis X: forward into a page, back out of it, as in RideBus.
transitionSpec = { materialSharedAxisX(forward = targetState != null, distance = slide) },
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 -> AnimatedContent(
targetState = tab,
transitionSpec = { materialFadeThrough() },
label = "tab",
) { shown ->
when (shown) {
Tab.Home -> HomeScreen(vm, nav)
Tab.Plans -> PlansScreen(vm, nav)
Tab.Calendar -> CalendarScreen(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,
// While the menu is open the pushed-aside screen only closes it.
if (menuOpen && !wide) Box(
Modifier
.fillMaxSize()
.clickable(
interactionSource = null,
indication = null,
onClickLabel = "Закрыть меню",
onClick = { menuOpen = false },
),
)
}
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
}
}
}
/** What the menu shows and where its items lead. */
private class MenuItems(
val tab: Tab,
val page: Page?,
val group: String?,
val onTab: (Tab) -> Unit,
val onPage: (Page) -> Unit,
)
/** On medium and wider windows a permanent rail on the tinted page; on phones the screen alone. */
/**
* The navigation rail: collapsed it holds the four sections; expanded it adds the group
* button, the shared gaps and the settings, as the M3 expanded navigation rail does.
*/
@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,
private fun MenuRail(
state: WideNavigationRailState,
expanded: Boolean,
items: MenuItems,
color: androidx.compose.ui.graphics.Color,
onToggle: () -> Unit,
onPicked: () -> Unit,
modifier: Modifier = Modifier,
) {
WideNavigationRail(
modifier = modifier,
state = state,
colors = WideNavigationRailDefaults.colors(containerColor = color),
// On a landscape phone the items need every bit of height; they scroll when it runs out.
contentPadding = PaddingValues(0.dp),
header = {
IconButton(
onClick = onToggle,
modifier = Modifier.padding(start = if (expanded) 12.dp else 24.dp, bottom = if (expanded) 12.dp else 0.dp),
) {
Icon(
if (expanded) Icons.AutoMirrored.Rounded.MenuOpen else Icons.Rounded.Menu,
contentDescription = if (expanded) "Закрыть меню" else "Открыть меню",
)
}
}
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,
),
},
) {
Column(
Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = if (expanded) Alignment.Start else Alignment.CenterHorizontally,
) {
AnimatedContent(
targetState = tab,
transitionSpec = { fadeIn(motion.defaultEffectsSpec()) togetherWith fadeOut(motion.fastEffectsSpec()) },
label = "tab",
) { content(it) }
@Composable
fun TabItem(item: Tab) {
val selected = items.page == null && items.tab == item
WideNavigationRailItem(
selected = selected,
onClick = { items.onTab(item); onPicked() },
icon = { Icon(if (selected) item.selectedIcon else item.icon, contentDescription = null) },
label = { Text(item.label) },
railExpanded = expanded,
)
}
@Composable
fun PageItem(page: Page, icon: ImageVector, label: String) = WideNavigationRailItem(
selected = items.page == page,
onClick = { items.onPage(page); onPicked() },
icon = { Icon(icon, contentDescription = null) },
label = { Text(label) },
railExpanded = true,
)
// Groups as in School Planner: the day and its plans, then study tools, then the app.
MENU_DAY.forEach { TabItem(it) }
if (expanded) MenuDivider()
MENU_STUDY.forEach { TabItem(it) }
if (expanded) {
MenuDivider()
PageItem(Page.Settings, Icons.Outlined.Settings, "Настройки")
}
}
}
}
private val MENU_DAY = listOf(Tab.Home, Tab.Plans, Tab.Calendar, Tab.Schedule)
private val MENU_STUDY = listOf(Tab.Subjects, Tab.Search)
/** A divider of fixed width: a full-width line would stretch the rail to its widest. */
@Composable
private fun MenuDivider() {
HorizontalDivider(
Modifier.padding(start = 28.dp, top = 8.dp, bottom = 8.dp).width(200.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}

View File

@@ -2,6 +2,7 @@ package ritm.schedule
import android.app.Application
import ritm.schedule.data.Api
import ritm.schedule.data.AttendanceStore
import ritm.schedule.data.NotesStore
import ritm.schedule.data.Repository
import ritm.schedule.data.Settings
@@ -15,12 +16,15 @@ class ScheduleApp : Application() {
private set
lateinit var notes: NotesStore
private set
lateinit var attendance: AttendanceStore
private set
override fun onCreate() {
super.onCreate()
settings = Settings(this)
repository = Repository(this, Api(BuildConfig.API_URL), settings)
notes = NotesStore(this)
attendance = AttendanceStore(this)
Reminders.createChannel(this)
SyncWorker.schedule(this)
}

View File

@@ -55,8 +55,12 @@ class Api(baseUrl: String) {
json.decodeFromString<T>(body)
}
/** The whole current semester; a server without range=semester answers the from/days window. */
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())
get<Timetable>(
"api/timetable",
"doc" to docId, "group" to group, "from" to from, "days" to days.toString(), "range" to "semester",
)
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)
@@ -69,10 +73,11 @@ class Api(baseUrl: String) {
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")
suspend fun exams(group: String, faculty: String) =
get<ExamsResponse>("api/exams", "group" to group, "faculty" to faculty)
}
/** runCatching that lets coroutine cancellation through: a cancelled request is not an error. */

View File

@@ -0,0 +1,44 @@
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.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import java.io.File
/** Whether the student was at one lesson: the group, subject, date and start time. */
@Serializable
data class Mark(
val group: String,
val subject: String,
val date: String,
val start: String,
val present: Boolean,
) {
val id get() = listOf(group, date, start, subject.trim().lowercase()).joinToString("|")
}
/** Attendance marks live only on the device, in one small JSON file. */
class AttendanceStore(context: Context) {
private val file = File(context.filesDir, "attendance.json")
private val lock = Mutex()
private val _marks = MutableStateFlow(read())
val marks: StateFlow<List<Mark>> = _marks
private fun read(): List<Mark> =
runCatching { json.decodeFromString<List<Mark>>(file.readText()) }.getOrDefault(emptyList())
/** Sets a mark, or clears it when [present] is null. */
suspend fun set(mark: Mark, present: Boolean?) = withContext(Dispatchers.IO) {
lock.withLock {
val rest = _marks.value.filterNot { it.id == mark.id }
val next = if (present == null) rest else rest + mark.copy(present = present)
file.writeText(json.encodeToString(next))
_marks.value = next
}
}
}

View File

@@ -66,6 +66,9 @@ data class Lesson(
val raw: String = "",
)
/** A recurring lesson the student may hide: the subject with its kind, in every week. */
val Lesson.hideKey: String get() = subject.trim().lowercase() + "|" + type.trim().lowercase()
@Serializable
data class GroupsResponse(
val results: List<GroupResult> = emptyList(),
@@ -131,24 +134,28 @@ data class Coverage(
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())
/** An exam, credit or consultation of the group, from any document of the catalog (/api/exams). */
@Serializable
data class Exam(
val date: String,
val start: String,
val end: String? = null,
val type: String,
val subject: String,
val teachers: List<String> = emptyList(),
val rooms: List<String> = emptyList(),
) {
/** Consultations go into the list but not into the countdown. */
val counts get() = type != "Консультация"
}
@Serializable
data class ExamsResponse(val group: String = "", val exams: List<Exam> = emptyList())
/** A group the student opened; kept on the device for quick switching. */
@Serializable
data class SavedGroup(val docId: String, val group: String, val faculty: String = "", val course: String = "")

View File

@@ -17,7 +17,18 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
private val _timetable = MutableStateFlow(readCache())
val timetable: StateFlow<Timetable?> = _timetable
/** Five weeks from this Monday: enough to browse ahead and to plan reminders. */
// The group's session, kept like the timetable so the countdown works offline.
private val examsCache = File(context.filesDir, "exams.json")
private val _exams = MutableStateFlow(readExams())
val exams: StateFlow<ExamsResponse?> = _exams
private fun readExams(): ExamsResponse? =
runCatching { json.decodeFromString<ExamsResponse>(examsCache.readText()) }.getOrNull()
/**
* The fallback window, five weeks from this Monday, for a server that cannot send the
* whole semester (or a schedule without dates); otherwise the semester comes whole.
*/
val span = 35
private fun readCache(): Timetable? =
@@ -31,6 +42,11 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
val fresh = api.timetable(group.docId, group.group, from.toString(), span)
cache.writeText(json.encodeToString(Timetable.serializer(), fresh))
_timetable.value = fresh
// Exams come from the whole catalog index; their failure never fails the timetable.
attempt { api.exams(group.group, fresh.document.faculty.ifEmpty { group.faculty }) }.onSuccess {
examsCache.writeText(json.encodeToString(ExamsResponse.serializer(), it))
_exams.value = it
}
fresh
}
}
@@ -45,10 +61,11 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
return refresh()
}
/** Lessons of a date after the student's subgroup filter. */
fun lessonsOn(timetable: Timetable, date: LocalDate, subgroup: String?): List<Lesson> =
/** Lessons of a date after the student's subgroup filter and the lessons they hid. */
fun lessonsOn(timetable: Timetable, date: LocalDate, subgroup: String?, hidden: Set<String> = emptySet()): List<Lesson> =
timetable.days.firstOrNull { it.date == date.toString() }?.lessons.orEmpty()
.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
.filter { it.hideKey !in hidden }
fun dayOf(timetable: Timetable, date: LocalDate): Day? =
timetable.days.firstOrNull { it.date == date.toString() }

View File

@@ -13,6 +13,17 @@ import kotlinx.coroutines.flow.map
enum class ThemeMode { System, Light, Dark }
enum class PlansLayout { Agenda, List, Board }
/** How the Plans screen lays out tasks, as in School Planner's plan settings. */
data class PlansPrefs(
val layout: PlansLayout = PlansLayout.Agenda,
/** The day's lessons above its plans; off by default, so plans come first. */
val lessons: Boolean = false,
/** Ticked-off tasks stay in the list. */
val done: Boolean = true,
)
/** What lesson cards, the week grid and the overview show; everything is on by default. */
data class Display(
val colors: Boolean = true,
@@ -32,6 +43,9 @@ data class UserSettings(
val reminderMinutes: Int = 10,
val recent: List<SavedGroup> = emptyList(),
val display: Display = Display(),
/** Lessons hidden in the open group, as [Lesson.hideKey]s. */
val hidden: Set<String> = emptySet(),
val plans: PlansPrefs = PlansPrefs(),
)
private val Context.store by preferencesDataStore("settings")
@@ -52,8 +66,15 @@ class Settings(private val context: Context) {
val pairs = booleanPreferencesKey("show_pairs")
val tags = booleanPreferencesKey("show_tags")
val notes = booleanPreferencesKey("show_notes")
val hidden = stringPreferencesKey("hidden")
val plansLayout = stringPreferencesKey("plans_layout")
val plansLessons = booleanPreferencesKey("plans_lessons")
val plansDone = booleanPreferencesKey("plans_done")
}
private fun Preferences.hiddenMap(): Map<String, List<String>> =
this[Keys.hidden]?.let { runCatching { json.decodeFromString<Map<String, List<String>>>(it) }.getOrNull() }.orEmpty()
val flow: Flow<UserSettings> = context.store.data.map { it.toSettings() }
suspend fun now(): UserSettings = flow.first()
@@ -73,6 +94,12 @@ class Settings(private val context: Context) {
recent = this[Keys.recent]?.let {
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
}.orEmpty(),
hidden = current?.let { hiddenMap()[subgroupKey(it)] }.orEmpty().toSet(),
plans = PlansPrefs(
layout = this[Keys.plansLayout]?.let { runCatching { PlansLayout.valueOf(it) }.getOrNull() } ?: PlansLayout.Agenda,
lessons = this[Keys.plansLessons] ?: false,
done = this[Keys.plansDone] ?: true,
),
display = Display(
colors = this[Keys.colors] ?: true,
teachers = this[Keys.teachers] ?: true,
@@ -110,6 +137,23 @@ class Settings(private val context: Context) {
suspend fun setReminders(on: Boolean) = context.store.edit { it[Keys.reminders] = on }
suspend fun setReminderMinutes(minutes: Int) = context.store.edit { it[Keys.minutes] = minutes }
/** Hides or shows again a recurring lesson of the open group. */
suspend fun setHidden(key: String, hidden: Boolean) = context.store.edit { prefs ->
val current = prefs[Keys.current]?.let { runCatching { json.decodeFromString<SavedGroup>(it) }.getOrNull() }
?: return@edit
val map = prefs.hiddenMap().toMutableMap()
val list = map[subgroupKey(current)].orEmpty()
map[subgroupKey(current)] = if (hidden) (list + key).distinct() else list - key
prefs[Keys.hidden] = json.encodeToString(map.toMap())
}
suspend fun setPlans(p: PlansPrefs) = context.store.edit {
it[Keys.plansLayout] = p.layout.name
it[Keys.plansLessons] = p.lessons
it[Keys.plansDone] = p.done
}
suspend fun setDisplay(d: Display) = context.store.edit {
it[Keys.colors] = d.colors
it[Keys.teachers] = d.teachers

View File

@@ -1,5 +1,6 @@
package ritm.schedule.notify
import ritm.schedule.data.hideKey
import android.Manifest
import android.app.AlarmManager
import android.app.NotificationChannel
@@ -77,6 +78,7 @@ object Reminders {
// 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 }
.filter { it.hideKey !in settings.hidden }
.groupBy { it.start }) {
val begins = date.atTime(parseTime(start)).atZone(MINSK).toInstant()
val at = begins.minusSeconds(settings.reminderMinutes * 60L)

View File

@@ -0,0 +1,68 @@
package ritm.schedule.tile
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import ritm.schedule.MainActivity
import ritm.schedule.widget.upcoming
/**
* A Quick Settings tile with the lesson in progress or the next one: when, which room and
* the subject. It reads the cached timetable, so it works without a network; a tap opens the app.
*/
class LessonTileService : TileService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
override fun onStartListening() {
super.onStartListening()
scope.launch {
val next = upcoming(applicationContext)
val tile = qsTile ?: return@launch
if (next == null) {
tile.label = "Пар нет"
tile.state = Tile.STATE_INACTIVE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = "на ближайшую неделю"
} else {
val room = next.rooms.takeIf { it.isNotEmpty() }?.let { "ауд. $it" }
tile.label = listOfNotNull(next.whenLabel(), room).joinToString(" · ")
tile.state = if (next.running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = next.subject
}
tile.contentDescription = listOfNotNull(tile.label, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle else null)
.joinToString(", ")
tile.updateTile()
}
}
override fun onClick() {
super.onClick()
val intent = Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE))
} else {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
companion object {
/** Asks the system to refresh the tile after the timetable or settings changed. */
fun refresh(context: Context) {
runCatching { requestListeningState(context, ComponentName(context, LessonTileService::class.java)) }
}
}
}

View File

@@ -0,0 +1,121 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package ritm.schedule.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.HowToReg
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.Mark
import ritm.schedule.data.nowTime
import ritm.schedule.data.today
import java.time.LocalDate
/** Attendance marks of the open group, for cards deep in the tree. */
val LocalMarks = staticCompositionLocalOf<List<Mark>> { emptyList() }
fun List<Mark>.forLesson(subject: String, date: LocalDate, start: String): Mark? {
val key = subjectKey(subject)
val day = date.toString()
return firstOrNull { it.date == day && it.start == start && subjectKey(it.subject) == key }
}
/** Attendance makes sense once a lesson has begun. */
fun lessonStarted(date: LocalDate, start: String): Boolean =
date < today() || (date == today() && start <= nowTime().toString())
/** Present and marked lessons of a subject. */
fun List<Mark>.statsFor(subject: String): Pair<Int, Int> {
val key = subjectKey(subject)
val own = filter { subjectKey(it.subject) == key }
return own.count { it.present } to own.size
}
/** "Был" / "Пропустил" in the lesson sheet, with "Не отмечено" to take a mark back. */
@Composable
fun LessonAttendance(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
if (!lessonStarted(date, lesson.start) || vm.currentGroupKey() == null) return
val marks by vm.marks.collectAsStateWithLifecycle()
val mark = marks.forLesson(lesson.subject, date, lesson.start)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Heading("Посещаемость", style = MaterialTheme.typography.titleSmallEmphasized)
// Tapping the chosen answer again takes the mark back.
ConnectedChoice(
options = listOf<Pair<Boolean?, String>>(true to "Был", false to "Пропустил"),
selected = mark?.present,
onSelect = { vm.mark(lesson, date, if (it == mark?.present) null else it) },
modifier = Modifier.fillMaxWidth(),
)
if (mark != null) Text(
"Нажмите на отметку ещё раз, чтобы снять её.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* One-tap attendance for today's finished lessons that have no mark yet; the card leaves
* the overview once every lesson is marked.
*/
@Composable
fun AttendanceCard(vm: MainViewModel, slots: List<Slot>) {
val marks by vm.marks.collectAsStateWithLifecycle()
if (vm.currentGroupKey() == null) return
val now = nowTime()
val date = today()
val open = slots.filter { !it.start.isAfter(now) }
.flatMap { slot -> slot.lessons.map { slot to it } }
.filter { (_, lesson) -> marks.forLesson(lesson.subject, date, lesson.start) == null }
if (open.isEmpty()) return
ListCard(Icons.Rounded.HowToReg, "Отметьте посещаемость") {
open.forEachIndexed { index, (slot, lesson) ->
if (index > 0) RowDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 12.dp, top = 6.dp, bottom = 6.dp),
) {
SubjectDot(lesson.subject, 10.dp)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(lesson.subject, style = MaterialTheme.typography.bodyLargeEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
TimeText("${slot.start} → ${slot.end}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
FilledTonalIconButton(onClick = { vm.mark(lesson, date, true) }) {
Icon(Icons.Rounded.Check, contentDescription = "Был на паре «${lesson.subject}»")
}
FilledTonalIconButton(
onClick = { vm.mark(lesson, date, false) },
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Icon(Icons.Rounded.Close, contentDescription = "Пропустил пару «${lesson.subject}»")
}
}
}
}
}

View File

@@ -0,0 +1,602 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.ui.draw.clip
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.EventBusy
import androidx.compose.material.icons.rounded.Weekend
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.rounded.CalendarToday
import androidx.compose.material.icons.rounded.Check
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.Surface
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.unit.Velocity
import kotlin.math.roundToInt
import androidx.compose.foundation.background
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.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Assignment
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Today
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.semantics.Role
import androidx.compose.ui.semantics.contentDescription
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.Lesson
import ritm.schedule.data.Note
import ritm.schedule.data.Timetable
import ritm.schedule.data.Week
import ritm.schedule.data.longLabel
import ritm.schedule.data.monday
import ritm.schedule.data.today
import ritm.schedule.data.weekdayName
import ritm.schedule.ui.theme.page
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.Locale
private val MONTH = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru"))
private const val MONTHS_BEFORE = 2
private const val MONTHS = 9
private val WEEKDAYS = listOf("П", "В", "С", "Ч", "П", "С", "В")
/** One row of the month grid with its gap. */
private val ROW = 58.dp
private val GRID_BOTTOM = 12.dp
private fun mondays(month: YearMonth): List<LocalDate> =
generateSequence(month.atDay(1).monday()) { it.plusDays(7) }.takeWhile { it <= month.atEndOfMonth() }.toList()
private fun rowsOf(month: YearMonth) = mondays(month).size
private fun weekRow(month: YearMonth, d: LocalDate) = mondays(month).indexOf(d.monday()).coerceAtLeast(0)
/**
* Calendar: when things happen. A month at a glance with marks for days with lessons and
* with plans, green weeks tinted; below, the chosen day's plans and lessons.
*/
@Composable
fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val notes by vm.notes.collectAsStateWithLifecycle()
var selected by remember { mutableStateOf(today()) }
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
var editing by remember { mutableStateOf<Note?>(null) }
var adding by remember { mutableStateOf(false) }
var target by remember { mutableStateOf<NoteTarget?>(null) }
val base = remember { YearMonth.from(today()).minusMonths(MONTHS_BEFORE.toLong()) }
val pager = rememberPagerState(initialPage = MONTHS_BEFORE) { MONTHS }
val scope = rememberCoroutineScope()
val month = base.plusMonths(pager.currentPage.toLong())
// Swiping to another month picks its first day, or today in the current month.
LaunchedEffect(pager) {
snapshotFlow { pager.settledPage }.collect { page ->
val m = base.plusMonths(page.toLong())
if (YearMonth.from(selected) != m) selected = if (m == YearMonth.from(today())) today() else m.atDay(1)
}
}
val t = timetable
val subgroup = settings?.subgroup
// 0 — the whole month; 1 — the day panel covers it and only the chosen week stays.
var collapse by remember { mutableFloatStateOf(0f) }
// Whole pixels, exactly what each row is laid out at, so the folded grid never shows
// a sliver of the next (green) week.
val rowPx = with(LocalDensity.current) { ROW.roundToPx().toFloat() }
val range = (rowsOf(month) - 1).coerceAtLeast(1) * rowPx
val snap = remember { tween<Float>(MotionDuration.Medium4, easing = Emphasized) }
suspend fun settle(velocity: Float) {
val to = when {
velocity < -300f -> 1f
velocity > 300f -> 0f
else -> if (collapse > 0.5f) 1f else 0f
}
animate(collapse, to, animationSpec = snap) { v, _ -> collapse = v }
}
// Scrolling the day up first slides the panel over the month; pulling down at the top
// brings the month back, as in School Planner's calendar.
val connection = remember(range) {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y >= 0f || collapse >= 1f) return Offset.Zero
val next = (collapse - available.y / range).coerceAtMost(1f)
val used = (next - collapse) * range
collapse = next
return Offset(0f, -used)
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
if (available.y <= 0f || collapse <= 0f) return Offset.Zero
val next = (collapse - available.y / range).coerceAtLeast(0f)
val used = (collapse - next) * range
collapse = next
return Offset(0f, used)
}
override suspend fun onPreFling(available: Velocity): Velocity {
if (collapse <= 0f || collapse >= 1f) return Velocity.Zero
settle(available.y)
return available
}
}
}
val drag = rememberDraggableState { delta -> collapse = (collapse - delta / range).coerceIn(0f, 1f) }
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.page).nestedScroll(connection)) {
Column {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
navigationIcon = { MenuButton(nav) },
title = {
Text(
month.format(MONTH).replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleLargeEmphasized,
)
},
actions = {
if (selected != today()) IconButton(onClick = {
selected = today()
scope.launch { pager.animateScrollToPage(MONTHS_BEFORE) }
}) { Icon(Icons.Rounded.Today, contentDescription = "Сегодня") }
},
)
Row(Modifier.widthIn(max = MAX_CONTENT).fillMaxWidth().padding(horizontal = 12.dp).align(Alignment.CenterHorizontally)) {
WEEKDAYS.forEach {
Text(
it,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f).padding(vertical = 4.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
HorizontalPager(state = pager, verticalAlignment = Alignment.Top) { page ->
val m = base.plusMonths(page.toLong())
// The chosen week's row stays in view as the grid folds up under the panel.
val keep = if (YearMonth.from(selected) == m) weekRow(m, selected) else 0
MonthGrid(
modifier = Modifier
.clipToBounds()
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
// Folded, exactly one row stays: no strip of the next (tinted) week peeks out.
val hidden = (placeable.height - rowPx) * collapse
layout(placeable.width, (placeable.height - hidden).roundToInt()) {
placeable.place(0, -(keep * rowPx * collapse).roundToInt())
}
},
month = m,
selected = selected,
hasLessons = { d -> t != null && vm.slotsOn(t, d, subgroup).isNotEmpty() },
hasPlans = { d -> notes.any { it.date == d.toString() && (!it.task || !it.done) } },
weekOf = { m -> t?.days?.firstOrNull { it.date == m.toString() }?.week },
onSelect = { selected = it },
)
}
}
// The day panel: a sheet with a handle, so its rounded top reads as something that moves.
Surface(
shape = MaterialTheme.shapes.extraLarge.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxSize(),
) {
Column {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.draggable(drag, Orientation.Vertical, onDragStopped = { v -> settle(v) })
.semantics {
customActions = listOf(
CustomAccessibilityAction(if (collapse < 0.5f) "Свернуть календарь до недели" else "Развернуть месяц") {
scope.launch { animate(collapse, if (collapse < 0.5f) 1f else 0f, animationSpec = snap) { v, _ -> collapse = v } }
true
},
)
},
) { BottomSheetDefaults.DragHandle() }
DayAgenda(
vm, t, subgroup, selected, notes,
onLesson = { details = it to selected },
onNote = { editing = it },
modifier = Modifier.fillMaxSize(),
)
}
}
}
FloatingActionButton(
onClick = { adding = true },
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp + navBarPadding()),
) { Icon(Icons.Rounded.Add, contentDescription = "Новый план") }
}
if (adding) ModalBottomSheet(onDismissRequest = { adding = false }) {
AddPlanSheet(vm, t, subgroup, day = selected) { adding = false; target = it }
}
target?.let { tg ->
vm.currentGroupKey()?.let { group ->
NoteEditor(
target = tg,
editing = null,
groupKey = group,
onSave = { vm.saveNote(it); target = null },
onDelete = {},
onDismiss = { target = null },
)
}
}
details?.let { (lesson, date) ->
ModalBottomSheet(onDismissRequest = { details = null }) {
LessonDetails(lesson, t?.bells.orEmpty(), date, vm, onClose = { details = null })
}
}
editing?.let { note ->
NoteEditor(
target = null,
editing = note,
groupKey = note.group,
onSave = { vm.saveNote(it); editing = null },
onDelete = { vm.deleteNote(it.id); editing = null },
onDismiss = { editing = null },
)
}
}
/** Six week rows of a month; a green week's row is tinted the way the schedule header is. */
@Composable
private fun MonthGrid(
modifier: Modifier,
month: YearMonth,
selected: LocalDate,
hasLessons: (LocalDate) -> Boolean,
hasPlans: (LocalDate) -> Boolean,
weekOf: (LocalDate) -> Week?,
onSelect: (LocalDate) -> Unit,
) {
val rows = mondays(month)
Column(modifier.widthIn(max = MAX_CONTENT).fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = GRID_BOTTOM)) {
rows.forEach { monday ->
val week = weekOf(monday)
Row(
Modifier
.fillMaxWidth()
.height(ROW)
.padding(bottom = 2.dp)
.background(
if (week != null && !week.white) weekTintOf(week) else Color.Transparent,
MaterialTheme.shapes.large,
),
) {
repeat(7) { i ->
val d = monday.plusDays(i.toLong())
val inMonth = d.month == month.month
val isSelected = d == selected
val isToday = d == today()
val lessons = inMonth && hasLessons(d)
val plans = inMonth && hasPlans(d)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
// No pressed ripple: the filled circle already answers the tap.
.selectable(
selected = isSelected,
enabled = inMonth,
role = Role.Tab,
interactionSource = null,
indication = null,
) { onSelect(d) }
.padding(vertical = 4.dp)
.semantics(mergeDescendants = true) {
contentDescription = "${d.weekdayName()}, ${d.longLabel()}" +
(if (isToday) ", сегодня" else "") + (if (lessons) ", есть пары" else "") +
(if (plans) ", есть планы" else "")
},
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(40.dp)
.background(if (isSelected && inMonth) MaterialTheme.colorScheme.primary else Color.Transparent, CircleShape),
) {
Text(
"${d.dayOfMonth}",
style = MaterialTheme.typography.titleMediumEmphasized,
color = when {
!inMonth -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
isSelected -> MaterialTheme.colorScheme.onPrimary
isToday -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface
},
)
}
// Two small marks: lessons in the calm tone, plans in the accent.
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.height(8.dp).padding(top = 2.dp)) {
if (lessons) Box(Modifier.size(5.dp).background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape))
if (plans) Box(Modifier.size(5.dp).background(MaterialTheme.colorScheme.tertiary, CircleShape))
}
}
}
}
}
}
}
private val DATE = DateTimeFormatter.ofPattern("dd.MM.yyyy")
/**
* The chosen day as a timeline, as School Planner's calendar shows it: numbered sections for
* the day's plans and lessons, each item on a line with its marker — a ring to tick a task off,
* a dot in the subject's colour for a lesson. Only sections with something in them are shown;
* a day with nothing gets an empty state.
*/
@Composable
private fun DayAgenda(
vm: MainViewModel,
t: Timetable?,
subgroup: String?,
date: LocalDate,
notes: List<Note>,
onLesson: (Lesson) -> Unit,
onNote: (Note) -> Unit,
modifier: Modifier,
) {
val plans = notes.filter { it.date == date.toString() }.sortedWith(compareBy({ it.task && it.done }, { it.start }))
val known = t?.days?.any { it.date == date.toString() } == true
val slots = if (t != null && known) vm.slotsOn(t, date, subgroup) else emptyList()
val lessons = slots.flatMap { slot -> slot.lessons.map { slot to it } }
if (plans.isEmpty() && lessons.isEmpty()) {
Box(modifier) {
ContentColumn {
// Scrolls, so a low sheet never squeezes the illustration out of shape.
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 80.dp + navBarPadding())) {
if (known) Empty(
"Свободный день",
"${date.weekdayName()}, ${date.longLabel()}: ни пар, ни планов.",
icon = Icons.Rounded.Weekend,
) else Empty(
"Расписание не загружено",
"Эта дата вне текущего семестра. Планов на ${date.longLabel()} нет.",
icon = Icons.Rounded.EventBusy,
)
}
}
}
return
}
ContentColumn {
LazyColumn(
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 24.dp + navBarPadding()),
modifier = modifier,
) {
var n = 0
if (plans.isNotEmpty()) {
n++
val number = n
item(key = "plans") { Section(number, "Ваши планы", trailing = date.format(DATE)) }
plans.forEachIndexed { i, note ->
item(key = note.id) {
TimelineItem(
marker = { PlanMarker(note, onToggle = { vm.toggleNote(note.id) }) },
over = note.subject.ifBlank { null },
title = note.text,
done = note.task && note.done,
last = i == plans.lastIndex,
onClick = { onNote(note) },
)
}
}
}
if (lessons.isNotEmpty()) {
n++
val number = n
item(key = "lessons") {
Section(number, "Ваши занятия", trailing = if (number == 1) date.format(DATE) else null, gap = number > 1)
}
lessons.forEachIndexed { i, (slot, l) ->
item(key = "${slot.start}|${l.key}") {
val color = subjectColor(l.subject)
TimelineItem(
marker = { Box(Modifier.size(20.dp).background(color.container, CircleShape)) },
over = "${slot.start} → ${slot.end}",
title = l.subject,
under = listOfNotNull(
if (l.distance) "дистанционно" else l.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" },
l.type.ifEmpty { null }?.lowercase(),
).joinToString(" · ").ifEmpty { null },
last = i == lessons.lastIndex,
onClick = { onLesson(l) },
)
}
}
}
}
}
}
/** "① Ваши планы" — a numbered pill, as in School Planner; the timeline starts under its circle. */
@Composable
private fun Section(n: Int, title: String, trailing: String? = null, gap: Boolean = false) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(top = if (gap) 24.dp else 8.dp)) {
Box(Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(RAIL).background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
) {
Text("$n", style = MaterialTheme.typography.titleMediumEmphasized, color = MaterialTheme.colorScheme.onPrimaryContainer)
}
Heading(
title,
style = MaterialTheme.typography.titleSmallEmphasized,
modifier = Modifier.padding(start = 12.dp, end = 16.dp),
)
}
}
Spacer(Modifier.weight(1f))
trailing?.let {
Icon(Icons.Rounded.CalendarToday, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(8.dp))
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
private val RAIL = 40.dp
private val MARKER = 24.dp
/**
* One entry on the timeline. The line runs from the item above (or the section circle) down
* through the marker; on the last item it stops at the marker instead of trailing off.
*/
@Composable
private fun TimelineItem(
marker: @Composable () -> Unit,
over: String?,
title: String,
under: String? = null,
done: Boolean = false,
last: Boolean,
onClick: () -> Unit,
) {
val line = MaterialTheme.colorScheme.outlineVariant
// Level with the first line of the title: 14dp padding, a 20dp line above it, a 24dp title line.
val markerTop = if (over != null) 34.dp else 14.dp
Row(
Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.clip(MaterialTheme.shapes.large)
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
) {
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.width(RAIL)
.fillMaxHeight()
.drawBehind {
val end = if (last) (markerTop + MARKER / 2).toPx() else size.height
drawLine(line, Offset(size.width / 2, 0f), Offset(size.width / 2, end), 2.dp.toPx())
},
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(top = markerTop)
.size(MARKER)
.background(MaterialTheme.colorScheme.surface, CircleShape),
) { marker() }
}
Column(Modifier.weight(1f).padding(start = 16.dp, top = 14.dp, bottom = 10.dp)) {
over?.let {
TimeText(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
Text(
title,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textDecoration = if (done) TextDecoration.LineThrough else null,
color = if (done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
)
under?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) }
}
}
}
/** A task's ring in its subject's colour, filled with a tick once done; a note gets a small square. */
@Composable
private fun PlanMarker(n: Note, onToggle: () -> Unit) {
val color = if (n.general) MaterialTheme.colorScheme.primary else subjectColor(n.subject).container
if (!n.task) {
Box(Modifier.size(18.dp).border(2.dp, color, MaterialTheme.shapes.extraSmall))
return
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(24.dp)
.background(if (n.done) color else Color.Transparent, CircleShape)
.border(2.dp, color, CircleShape)
.toggleable(value = n.done, role = Role.Checkbox, onValueChange = { onToggle() })
.semantics { contentDescription = if (n.done) "Выполнено" else "Не выполнено" },
) {
if (n.done) Icon(
Icons.Rounded.Check,
contentDescription = null,
tint = if (n.general) MaterialTheme.colorScheme.onPrimary else subjectColor(n.subject).content,
modifier = Modifier.size(16.dp),
)
}
}

View File

@@ -2,6 +2,17 @@
package ritm.schedule.ui
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.takeOrElse
import ritm.schedule.ui.theme.harmonizeWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.HorizontalDivider
@@ -288,7 +299,7 @@ fun WeekMark(week: Week, modifier: Modifier = Modifier, short: Boolean = false)
Box(
Modifier
.size(10.dp)
.background(if (week.white) Color.White else WeekColors.mark(dark), CircleShape)
.background(if (week.white) Color.White else WeekColors.mark(dark).harmonizeWith(MaterialTheme.colorScheme.primary), CircleShape)
.then(if (week.white) Modifier.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) else Modifier),
)
Spacer(Modifier.width(6.dp))
@@ -316,7 +327,7 @@ 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)
else lerp(scheme.page, WeekColors.container(false, dark).harmonizeWith(scheme.primary), if (dark) 0.55f else 0.75f)
}
/**
@@ -358,10 +369,13 @@ fun DayStrip(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
// No pressed ripple: the filled circle already answers the tap.
.selectable(
selected = isSelected,
enabled = enabled(date),
role = Role.Tab,
interactionSource = null,
indication = null,
onClick = { onSelect(date) },
)
.padding(vertical = 6.dp)
@@ -483,3 +497,47 @@ fun avatarLabel(text: String): String {
val parts = text.split(' ', '.', '-').filter { it.isNotEmpty() }
return parts.take(2).joinToString("") { it.first().uppercase() }.ifEmpty { "?" }
}
/**
* Text with time ranges like "10:05 → 11:30": each "→" is drawn as the Material arrow icon,
* sized to the text and in its colour, instead of the font's arrow glyph.
*/
@Composable
fun TimeText(
text: String,
style: TextStyle,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
maxLines: Int = Int.MAX_VALUE,
) {
val tint = color.takeOrElse { style.color.takeOrElse { LocalContentColor.current } }
val parts = text.split("→")
val annotated = buildAnnotatedString {
parts.forEachIndexed { i, part ->
if (i > 0) appendInlineContent(ARROW, "→")
append(part)
}
}
val size = style.fontSize.takeOrElse { 14.sp }
val inline = mapOf(
ARROW to InlineTextContent(Placeholder(size, size, PlaceholderVerticalAlign.TextCenter)) {
Icon(
Icons.AutoMirrored.Rounded.ArrowForward,
contentDescription = "до",
tint = tint,
modifier = Modifier.fillMaxSize(),
)
},
)
Text(
annotated,
style = style,
color = color,
modifier = modifier,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
inlineContent = inline,
)
}
private const val ARROW = "arrow"

View File

@@ -2,6 +2,9 @@
package ritm.schedule.ui
import androidx.compose.foundation.layout.height
import androidx.compose.material3.TextButton
import androidx.compose.material3.AlertDialog
import androidx.compose.material.icons.rounded.AccountBalance
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.History
@@ -96,11 +99,22 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
}
val searching = query.trim().length >= 2
// Before going back to the overview, a group with subgroups asks which one is yours,
// unless you already chose it for this group.
var askSubgroup by remember { mutableStateOf<List<String>?>(null) }
fun opened() {
val t = vm.timetable.value
val subgroups = t?.subgroups.orEmpty()
val s = vm.settings.value
val chosen = s?.current?.group == t?.group && s?.subgroup != null
if (subgroups.isNotEmpty() && !chosen) askSubgroup = subgroups else onOpened()
}
fun open(result: GroupResult) {
opening = "${result.fileId}|${result.group}"
vm.open(result) { ok ->
opening = null
if (ok) onOpened()
if (ok) opened()
}
}
@@ -169,7 +183,7 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
color = subjectColor(avatarLabel(g.group)),
title = g.group,
subtitle = describe(g.faculty, g.course),
onClick = { vm.open(g, onOpened) },
onClick = { vm.open(g) { opened() } },
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
)
}
@@ -228,6 +242,13 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
}
}
}
askSubgroup?.let { subgroups ->
SubgroupDialog(
group = vm.timetable.value?.group.orEmpty(),
subgroups = subgroups,
onPick = { vm.setSubgroup(it); askSubgroup = null; onOpened() },
)
}
}
@Composable
@@ -274,3 +295,40 @@ private fun CurrentGroup(current: SavedGroup, subgroups: List<String>, subgroup:
}
}
}
/** "Ваша подгруппа": all of them, or one; the choice is remembered for this group. */
@Composable
private fun SubgroupDialog(group: String, subgroups: List<String>, onPick: (String?) -> Unit) {
AlertDialog(
onDismissRequest = { onPick(null) },
icon = { Icon(Icons.Rounded.Groups, contentDescription = null) },
title = { Text("Ваша подгруппа") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"В группе $group занятия делятся на подгруппы. Выберите свою — чужие пары скроются из расписания, " +
"виджета и напоминаний. Изменить можно в разделе «Группы».",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(8.dp))
(listOf<String?>(null) + subgroups).forEach { option ->
Surface(
onClick = { onPick(option) },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
Text(
option ?: "Все подгруппы",
style = MaterialTheme.typography.titleSmallEmphasized,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = { onPick(null) }) { Text("Позже") } },
)
}

View File

@@ -2,6 +2,19 @@
package ritm.schedule.ui
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.material.icons.rounded.ArrowDropDown
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.rounded.CalendarMonth
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.DateRange
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import ritm.schedule.data.shortWeekday
import androidx.compose.material.icons.automirrored.rounded.Assignment
import androidx.compose.runtime.saveable.rememberSaveable
import ritm.schedule.data.Note
@@ -91,7 +104,7 @@ import java.time.LocalTime
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)
repository.lessonsOn(t, date, subgroup, hidden)
.groupBy { it.start }
.map { (start, lessons) -> Slot(date, parseTime(start), lessonEnd(lessons.first(), t), lessons) }
.sortedBy { it.start }
@@ -116,6 +129,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
val status by vm.status.collectAsStateWithLifecycle()
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
var editing by remember { mutableStateOf<Note?>(null) }
var session by remember { mutableStateOf(false) }
val subgroup = settings?.subgroup
val clock = rememberClock()
val date = today()
@@ -125,15 +139,38 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
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)
title = { Text("Обзор", style = MaterialTheme.typography.titleLargeEmphasized) },
navigationIcon = { MenuButton(nav) },
actions = {
// The open group, one tap from switching: it used to live in the side menu.
val group = timetable?.group ?: settings?.current?.group
// An outlined pill with the group's avatar: unlike the tonal shortcuts below,
// it reads as "who I am", not as one more action.
Surface(
onClick = nav.openGroups,
shape = CircleShape,
color = Color.Transparent,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
modifier = Modifier.padding(end = 8.dp).semantics {
contentDescription = group?.let { "Группа $it, сменить" } ?: "Выбрать группу"
},
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 8.dp, top = 4.dp, bottom = 4.dp).clearAndSetSemantics {},
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(32.dp).background(MaterialTheme.colorScheme.primary, CircleShape),
) {
Icon(Icons.Rounded.Groups, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(20.dp))
}
Spacer(Modifier.width(8.dp))
Text(group ?: "Группа", style = MaterialTheme.typography.labelLarge)
Icon(Icons.Rounded.ArrowDropDown, contentDescription = null, modifier = Modifier.size(20.dp))
}
}
},
navigationIcon = { MenuButton(nav) },
)
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage) }
val t = timetable
@@ -162,9 +199,11 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
) {
item { QuickActions(nav, Modifier.bleed(16.dp)) }
item { WeekReport(vm, t, subgroup, date, clock) }
item { SessionCard(vm, t, onOpen = { session = true }) }
item { DayTitle("Сегодня", date) }
item { TodayCard(todaySlots, clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) }
item { TasksCard(vm, onEdit = { editing = it }) }
item { AttendanceCard(vm, todaySlots) }
item { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) }
if (next != null) {
val (day, slots) = next
item { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) }
@@ -179,6 +218,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
}
}
}
if (session) timetable?.let { t ->
val exams = upcomingExams(vm, t)
ModalBottomSheet(onDismissRequest = { session = false }) { SessionSheet(exams) }
}
editing?.let { note ->
NoteEditor(
target = null,
@@ -190,7 +233,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
)
}
details?.let { lesson ->
ModalBottomSheet(onDismissRequest = { details = null }) { LessonDetails(lesson.first, timetable?.bells.orEmpty(), lesson.second, vm) }
ModalBottomSheet(onDismissRequest = { details = null }) { LessonDetails(lesson.first, timetable?.bells.orEmpty(), lesson.second, vm, onClose = { details = null }) }
}
}
@@ -200,9 +243,9 @@ 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.Checklist, nav.openPlans),
Triple("Календарь", Icons.Rounded.CalendarMonth, nav.openCalendar),
Triple("Кто где", Icons.Rounded.TravelExplore, nav.openSearch),
Triple("Группа", Icons.Rounded.SwapHoriz, nav.openGroups),
)
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
@@ -233,7 +276,7 @@ private fun Modifier.bleed(horizontal: Dp): Modifier = layout { measurable, cons
* soonest first. Ticked tasks stay struck through until their day has passed.
*/
@Composable
private fun TasksCard(vm: MainViewModel, onEdit: (Note) -> Unit) {
private fun TasksCard(vm: MainViewModel, onEdit: (Note) -> Unit, onAll: () -> Unit) {
val notes by vm.notes.collectAsStateWithLifecycle()
val date = today()
val pending = notes
@@ -252,40 +295,107 @@ private fun TasksCard(vm: MainViewModel, onEdit: (Note) -> Unit) {
(if (expanded) pending else pending.take(4)).forEach { note ->
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { onEdit(note) }, showSubject = true)
}
if (pending.size > 4) TextButton(
onClick = { expanded = !expanded },
modifier = Modifier.padding(start = 12.dp),
) { Text(if (expanded) "Свернуть" else "Показать все (${pending.size})") }
Row(Modifier.padding(start = 12.dp)) {
if (pending.size > 4) TextButton(onClick = { expanded = !expanded }) {
Text(if (expanded) "Свернуть" else "Показать все (${pending.size})")
}
TextButton(onClick = onAll) { Text("Все планы") }
}
}
}
/** "Прошло 12 из 18 пар" with a wavy progress line over the current week. */
/**
* The current week at a glance: how many lessons are still ahead, a chip per day with its
* count (past days ticked off, today highlighted) and the homework due this week. Once the
* week is over it looks ahead to the next one.
*/
@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)) {
val days = (0..6L).map { monday.plusDays(it) }.map { it to vm.slotsOn(t, it, subgroup) }
.filter { (d, slots) -> d.dayOfWeek.value < 7 || slots.isNotEmpty() }
val total = days.sumOf { it.second.size }
val left = days.sumOf { (_, slots) -> slots.count { it.date > date || (it.date == date && it.end.isAfter(clock)) } }
val week = t.days.firstOrNull { it.date == monday.toString() }?.week
val due = LocalNotes.current.count { it.task && !it.done && parseDate(it.date) in date..monday.plusDays(6) }
SectionCard(Icons.Rounded.DateRange, week?.let { "Неделя ${it.n}" } ?: "Эта неделя") {
Column {
Text(
when {
total == 0 -> "На этой неделе пар нет"
left == 0 -> "Пары этой недели закончились"
left == total -> "Впереди $total ${plural(total, "пара", "пары", "пар")}"
else -> "Осталось $left ${plural(left, "пара", "пары", "пар")} из $total"
},
style = MaterialTheme.typography.titleLargeEmphasized,
)
Row(verticalAlignment = Alignment.CenterVertically) {
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)),
weekSpan(monday, monday.plusDays(6)),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
week?.let { WeekMark(it, Modifier.padding(top = 2.dp)) }
week?.let {
Spacer(Modifier.width(12.dp))
WeekMark(it)
}
}
}
if (slots.isNotEmpty()) LinearWavyProgressIndicator(
progress = { done.toFloat() / slots.size },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
if (total > 0) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
days.forEach { (d, slots) -> DayChip(d, slots.size, date, Modifier.weight(1f)) }
}
if (due > 0) HomeMeta(
Icons.AutoMirrored.Rounded.Assignment,
"Сдать до воскресенья: $due ${plural(due, "задание", "задания", "заданий")}",
)
if (left == 0) {
val next = monday.plusDays(7)
val nextTotal = (0..6L).sumOf { vm.slotsOn(t, next.plusDays(it), subgroup).size }
val nextWeek = t.days.firstOrNull { it.date == next.toString() }?.week
if (nextTotal > 0) HomeMeta(
Icons.Rounded.EventAvailable,
"Дальше " + (nextWeek?.let { "неделя ${it.n}, ${if (it.white) "белая" else "зелёная"}: " } ?: "") +
"$nextTotal ${plural(nextTotal, "пара", "пары", "пар")}",
)
}
}
}
/** A weekday with its number of lessons: ticked off once past, strong today. */
@Composable
private fun DayChip(d: LocalDate, count: Int, date: LocalDate, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
val past = d < date
val (bg, fg) = when {
count == 0 -> Color.Transparent to scheme.onSurfaceVariant
d == date -> scheme.primary to scheme.onPrimary
past -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant
else -> scheme.primaryContainer to scheme.onPrimaryContainer
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.semantics(mergeDescendants = true) {
contentDescription = "${d.weekdayName()}: " + if (count == 0) "пар нет" else
"$count ${plural(count, "пара", "пары", "пар")}" + if (past) ", прошли" else ""
},
) {
Text(
d.shortWeekday(),
style = MaterialTheme.typography.labelMedium,
color = if (d == date) scheme.primary else scheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().height(36.dp).background(bg, MaterialTheme.shapes.medium),
) {
when {
count == 0 -> Text("—", style = MaterialTheme.typography.labelLarge, color = fg)
past -> Icon(Icons.Rounded.Check, contentDescription = null, tint = fg, modifier = Modifier.size(18.dp))
else -> Text("$count", style = MaterialTheme.typography.titleSmallEmphasized, color = fg)
}
}
}
}
@@ -370,6 +480,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
Row(
Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
) {
SubjectDot(lesson.subject, 14.dp, Modifier.padding(top = 8.dp))
@@ -377,7 +488,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
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)
TimeText(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(
@@ -407,14 +518,17 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
Row(
Modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick) else Modifier),
.then(
if (onClick != null) Modifier.clip(MaterialTheme.shapes.medium).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(
TimeText(
listOfNotNull(
slot.timeLabel(),
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. $it" },

View File

@@ -1,5 +1,7 @@
package ritm.schedule.ui
import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
@@ -34,12 +36,27 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
app.settings.flow.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val timetable: StateFlow<Timetable?> = app.repository.timetable
/** The open group's exams; null until the first answer, then cached on the device. */
val exams: StateFlow<ritm.schedule.data.ExamsResponse?> = app.repository.exams
/** Tasks and notes of the open group. */
val notes: StateFlow<List<Note>> = combine(app.notes.notes, app.settings.flow) { list, s ->
val key = s.current?.let(::groupKey)
list.filter { it.group == key }
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
/** Attendance marks of the open group. */
val marks: StateFlow<List<ritm.schedule.data.Mark>> = combine(app.attendance.marks, app.settings.flow) { list, s ->
val key = s.current?.let(::groupKey)
list.filter { it.group == key }
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
/** Marks a lesson as attended (true), missed (false) or clears the mark (null). */
fun mark(lesson: ritm.schedule.data.Lesson, date: java.time.LocalDate, present: Boolean?) = viewModelScope.launch {
val group = currentGroupKey() ?: return@launch
app.attendance.set(ritm.schedule.data.Mark(group, lesson.subject, date.toString(), lesson.start, present ?: false), present)
}
fun saveNote(note: Note) = viewModelScope.launch { app.notes.save(note) }
fun deleteNote(id: String) = viewModelScope.launch { app.notes.delete(id) }
fun toggleNote(id: String) = viewModelScope.launch { app.notes.toggle(id) }
@@ -50,7 +67,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private val _status = MutableStateFlow(Status())
val status = _status.asStateFlow()
/**
* Hidden lessons as Compose state: screens that read it through [slotsOn] redraw
* as soon as a lesson is hidden or shown again.
*/
var hidden by androidx.compose.runtime.mutableStateOf(emptySet<String>())
private set
init {
viewModelScope.launch { settings.collect { hidden = it?.hidden.orEmpty() } }
viewModelScope.launch {
if (app.settings.now().current != null) refresh()
}
@@ -94,6 +119,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
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 setPlans(prefs: ritm.schedule.data.PlansPrefs) = viewModelScope.launch { app.settings.setPlans(prefs) }
fun setHidden(key: String, hidden: Boolean) = viewModelScope.launch {
app.settings.setHidden(key, hidden)
SyncWorker.afterChange(getApplication())
}
fun setReminders(on: Boolean) = viewModelScope.launch {
app.settings.setReminders(on)

View File

@@ -0,0 +1,76 @@
package ritm.schedule.ui
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import android.graphics.Path
import android.view.animation.PathInterpolator
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
// Material motion patterns, as in RideBus: shared axis X between a screen and the page it
// opens, fade through between top-level destinations.
/** How far a shared axis transition slides: 30dp in pixels. */
@Composable
fun rememberSlideDistance(): Int {
val density = LocalDensity.current
return remember(density) { with(density) { 30.dp.roundToPx() } }
}
private fun sharedAxisXIn(forward: Boolean, distance: Int): EnterTransition =
slideInHorizontally(tween(300, easing = FastOutSlowInEasing)) { if (forward) distance else -distance } +
fadeIn(tween(durationMillis = 195, delayMillis = 105, easing = LinearOutSlowInEasing))
private fun sharedAxisXOut(forward: Boolean, distance: Int): ExitTransition =
slideOutHorizontally(tween(300, easing = FastOutSlowInEasing)) { if (forward) -distance else distance } +
fadeOut(tween(durationMillis = 105, easing = FastOutLinearInEasing))
/** Forward into a page slides in from the end; back slides the other way. */
fun materialSharedAxisX(forward: Boolean, distance: Int): ContentTransform =
sharedAxisXIn(forward, distance) togetherWith sharedAxisXOut(forward, distance)
/**
* md.sys.motion.easing.emphasized: the two-segment path M3 uses for large transitions that
* begin and end on screen. A single cubic only approximates it, so this follows the path itself.
*/
val Emphasized: Easing = run {
val path = Path().apply {
moveTo(0f, 0f)
cubicTo(0.05f, 0f, 0.133333f, 0.06f, 0.166666f, 0.4f)
cubicTo(0.208333f, 0.82f, 0.25f, 1f, 1f, 1f)
}
val interpolator = PathInterpolator(path)
Easing { interpolator.getInterpolation(it) }
}
/** md.sys.motion.duration tokens used here, in milliseconds. */
object MotionDuration {
const val Medium4 = 400
const val Long2 = 500
}
/** M3 emphasized easing for components entering the screen: fast start, long gentle stop. */
val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
/** M3 emphasized easing for components leaving: they speed up and come to rest just off screen. */
val EmphasizedAccelerate = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f)
/** Between sibling destinations: the old one fades out, the new one fades and grows in. */
fun materialFadeThrough(): ContentTransform =
(fadeIn(tween(400, easing = EmphasizedDecelerate)) + scaleIn(tween(400, easing = EmphasizedDecelerate), initialScale = 0.92f)) togetherWith
fadeOut(tween(200, easing = EmphasizedAccelerate))

View File

@@ -8,6 +8,16 @@ import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import kotlin.math.roundToInt
import java.time.LocalDate
/** Ways a screen can send the student elsewhere in the app. */
@@ -18,14 +28,35 @@ class AppNav(
val openWeek: () -> Unit,
val openSubjects: () -> Unit,
val openSearch: () -> Unit,
val openPlans: () -> Unit,
val openCalendar: () -> 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. */
/** How far the side menu is open, 0 to 1; read in the draw phase only. */
val LocalMenuProgress = staticCompositionLocalOf<State<Float>> { mutableFloatStateOf(0f) }
/** The hamburger of a top-level screen's app bar; it fades away and the title takes its place while the menu is open. */
@Composable
fun MenuButton(nav: AppNav) {
val progress = LocalMenuProgress.current
// Recomposes when the menu starts or stops moving, not on every frame.
val resting by remember(progress) { derivedStateOf { progress.value == 0f } }
nav.openMenu?.let { open ->
IconButton(onClick = open) { Icon(Icons.Rounded.Menu, contentDescription = "Открыть меню") }
// Taps are ignored while the menu moves, but the icon keeps its colour: a greyed-out
// flash on every close would read as a broken button.
IconButton(
onClick = { if (resting) open() },
// Fades and gives its width back, so the title slides into its place.
modifier = Modifier
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
// The expressive spring overshoots past 1; a width must never go negative.
val width = (placeable.width * (1f - progress.value.coerceIn(0f, 1f))).roundToInt()
layout(width, placeable.height) { placeable.place(0, 0) }
}
.graphicsLayer { alpha = 1f - progress.value.coerceIn(0f, 1f) },
) { Icon(Icons.Rounded.Menu, contentDescription = "Открыть меню") }
}
}

View File

@@ -1,7 +1,14 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui
import androidx.compose.ui.draw.clip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material.icons.rounded.CalendarToday
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DatePicker
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -53,6 +60,7 @@ import ritm.schedule.data.nowTime
import ritm.schedule.data.parseDate
import ritm.schedule.data.shortWeekday
import ritm.schedule.data.today
import ritm.schedule.data.weekdayName
import java.time.LocalDate
/** Tasks and notes of the open group, for cards deep in the tree. */
@@ -70,11 +78,15 @@ fun List<Note>.open(): List<Note> = filter { !it.task || !it.done }
fun Note.overdue(): Boolean = task && !done && parseDate(date) < today()
/** "к сегодня, 11:45", "к завтра, 10:05", "к пн, 19 октября". */
/** A plan not pinned to any lesson: just a day. */
val Note.general: Boolean get() = subject.isBlank()
fun Note.dueLabel(): String {
val d = parseDate(date)
val at = if (start.isNotBlank()) ", $start" else ""
val day = when (d) {
today() -> "сегодня, $start"
today().plusDays(1) -> "завтра, $start"
today() -> "сегодня$at"
today().plusDays(1) -> "завтра$at"
else -> d.shortWeekday().lowercase() + ", " + d.longLabel()
}
return if (task) "к $day" else day
@@ -87,7 +99,7 @@ fun MainViewModel.nextOccurrence(t: Timetable, subject: String, after: LocalDate
.map { parseDate(it.date) }
.filter { it >= after }
.flatMap { d ->
repository.lessonsOn(t, d, subgroup)
repository.lessonsOn(t, d, subgroup, hidden)
.filter { subjectKey(it.subject) == key && (d > after || it.start > start) }
.sortedBy { it.start }
.map { d to it }
@@ -97,12 +109,13 @@ fun MainViewModel.nextOccurrence(t: Timetable, subject: String, after: LocalDate
/** A task with its checkbox or a note with its icon; tap opens the editor. */
@Composable
fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject: Boolean) {
fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject: Boolean, showDue: Boolean = true) {
val overdue = note.overdue()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.clickable(onClickLabel = "Изменить", role = Role.Button, onClick = onClick)
.padding(start = 8.dp, end = 16.dp, top = 2.dp, bottom = 2.dp),
) {
@@ -121,14 +134,19 @@ fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject:
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (showSubject) {
if (showSubject && !note.general) {
SubjectDot(note.subject, 8.dp)
Spacer(Modifier.width(6.dp))
}
Text(
listOfNotNull(
if (showSubject) note.subject else null,
if (overdue) "просрочено" else note.dueLabel(),
if (showSubject) note.subject.ifBlank { "Общий план" } else null,
when {
overdue -> "просрочено"
showDue -> note.dueLabel()
note.start.isNotBlank() -> "к ${note.start}"
else -> null
},
).joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
color = if (overdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -160,13 +178,25 @@ fun NoteEditor(
var task by rememberSaveable { mutableStateOf(editing?.task ?: true) }
val started = target != null && (target.date < today() || (target.date == today() && target.start <= nowTime().toString()))
var toNext by rememberSaveable { mutableStateOf(editing == null && started && target?.next != null) }
// A general plan (not pinned to a lesson) picks its own day.
val general = editing?.general ?: (target?.subject?.isBlank() == true)
var day by rememberSaveable { mutableStateOf((editing?.date?.let(::parseDate) ?: target?.date ?: today()).toEpochDay()) }
var picking by remember { mutableStateOf(false) }
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(if (task) Icons.AutoMirrored.Rounded.Assignment else Icons.Rounded.StickyNote2, contentDescription = null) },
title = { Text(if (editing == null) (if (task) "Новое задание" else "Новая заметка") else (if (task) "Задание" else "Заметка")) },
title = {
Text(
when {
editing != null -> if (task) "Задание" else "Заметка"
general -> if (task) "Новый общий план" else "Новая заметка на день"
else -> if (task) "Новое задание" else "Новая заметка"
},
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
ConnectedChoice(
@@ -182,6 +212,15 @@ fun NoteEditor(
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp).focusRequester(focus),
)
if (general) {
Text("День", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedButton(onClick = { picking = true }, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Rounded.CalendarToday, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
val d = LocalDate.ofEpochDay(day)
Text(d.weekdayName() + ", " + d.longLabel())
}
}
if (editing == null && target?.next != null) {
Text("К какому занятию", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
val (nextDate, nextLesson) = target.next
@@ -209,7 +248,8 @@ fun NoteEditor(
val (date, start) = if (toNext && t.next != null) t.next.first to t.next.second.start else t.date to t.start
Note(group = groupKey, subject = t.subject, date = date.toString(), start = start, text = "")
}
onSave(base.copy(text = text.trim(), task = task))
val dated = if (general) base.copy(date = LocalDate.ofEpochDay(day).toString()) else base
onSave(dated.copy(text = text.trim(), task = task))
},
) { Text("Сохранить") }
},
@@ -222,6 +262,19 @@ fun NoteEditor(
}
},
)
if (picking) {
val state = rememberDatePickerState(initialSelectedDateMillis = LocalDate.ofEpochDay(day).toEpochDay() * 86_400_000L)
DatePickerDialog(
onDismissRequest = { picking = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let { day = it / 86_400_000L }
picking = false
}) { Text("Выбрать") }
},
dismissButton = { TextButton(onClick = { picking = false }) { Text("Отмена") } },
) { DatePicker(state) }
}
}
/** The lesson sheet's section: this lesson's tasks and notes and a button to add one. */

View File

@@ -0,0 +1,485 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
package ritm.schedule.ui
import androidx.compose.ui.draw.clip
import androidx.compose.material.icons.rounded.EventNote
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material.icons.rounded.ViewAgenda
import androidx.compose.material.icons.rounded.ViewColumn
import androidx.compose.material.icons.rounded.ViewList
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.heading
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 kotlinx.coroutines.launch
import ritm.schedule.data.Lesson
import ritm.schedule.data.Note
import ritm.schedule.data.PlansLayout
import ritm.schedule.data.PlansPrefs
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.today
import ritm.schedule.data.weekdayName
import ritm.schedule.ui.theme.card
import ritm.schedule.ui.theme.page
import java.time.LocalDate
/** "Сегодня • Суббота • 17 октября", "Завтра • …" or "Понедельник • 19 октября". */
private fun dayTitle(d: LocalDate): String = when (d) {
today() -> "Сегодня • ${d.weekdayName()} • ${d.longLabel()}"
today().plusDays(1) -> "Завтра • ${d.weekdayName()} • ${d.longLabel()}"
else -> "${d.weekdayName()} • ${d.longLabel()}"
}
/** One line of the plans list: sticky day headers, the day's lessons, its tasks and notes. */
private sealed interface Row {
val date: LocalDate?
data class Header(override val date: LocalDate?, val title: String, val empty: Boolean, val overdue: Boolean) : Row
data class Lessons(override val date: LocalDate, val slots: List<Slot>) : Row
data class Item(override val date: LocalDate?, val note: Note) : Row
data class Divider(override val date: LocalDate?) : Row
}
/**
* Plans: what has to be done, day by day — homework and notes pinned to lessons, overdue
* tasks first. Three layouts as in School Planner: an agenda with a week strip, a compact
* list of busy days, and a board of day columns.
*/
@Composable
fun PlansScreen(vm: MainViewModel, nav: AppNav) {
val all by vm.notes.collectAsStateWithLifecycle()
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val prefs = settings?.plans ?: PlansPrefs()
var editing by remember { mutableStateOf<Note?>(null) }
var adding by remember { mutableStateOf(false) }
var tuning by remember { mutableStateOf(false) }
var target by remember { mutableStateOf<NoteTarget?>(null) }
var date by remember { mutableStateOf(today()) }
val list = rememberLazyListState()
val scope = rememberCoroutineScope()
val notes = if (prefs.done) all else all.filterNot { it.task && it.done }
val first = today()
val last = timetable?.days?.lastOrNull()?.date?.let(::parseDate)?.takeIf { it >= first } ?: first.plusDays(13)
val days = generateSequence(first) { it.plusDays(1) }.takeWhile { it <= last }.toList()
val overdue = notes.filter { it.overdue() }.sortedWith(compareBy({ it.date }, { it.start }))
val byDay = notes.filter { parseDate(it.date) >= first }.groupBy { it.date }
.mapValues { (_, v) -> v.sortedWith(compareBy({ it.task && it.done }, { it.start })) }
val open = all.count { (it.task && !it.done) || (!it.task && parseDate(it.date) >= first) }
val lessonsOf: (LocalDate) -> List<Slot> = { d ->
val t = timetable
if (prefs.lessons && t != null) vm.slotsOn(t, d, settings?.subgroup) else emptyList()
}
val agenda = prefs.layout == PlansLayout.Agenda
val week = timetable?.days?.firstOrNull { it.date == date.monday().toString() }?.week
// Built once per data change, not on every day the strip passes while the list scrolls.
val rows = remember(all, timetable, prefs, settings?.subgroup, vm.hidden) {
if (prefs.layout == PlansLayout.Board) emptyList() else rows(days, byDay, overdue, lessonsOf, agenda)
}
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
Column(Modifier.background(if (agenda) weekTint(week) else MaterialTheme.colorScheme.page)) {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
navigationIcon = { MenuButton(nav) },
title = {
Column {
Text("Планы", style = MaterialTheme.typography.titleLargeEmphasized)
Text(
if (open == 0) "Всё сделано" else "$open ${plural(open, "дело", "дела", "дел")} впереди",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
actions = {
if (agenda && date != today()) IconButton(onClick = {
date = today()
scope.launch { list.scrollToItem(0) }
}) { Icon(Icons.Rounded.Today, contentDescription = "Сегодня") }
IconButton(onClick = { tuning = true }) { Icon(Icons.Rounded.Tune, contentDescription = "Вид планов") }
},
)
if (agenda) SearchWeekStrip(timetable, date) { d ->
date = d
val index = rows.indexOfFirst { it is Row.Header && it.date == d }
if (index >= 0) scope.launch { list.scrollToItem(index) }
}
}
when (prefs.layout) {
PlansLayout.Board -> Board(days, byDay, overdue, lessonsOf, vm::toggleNote, onEdit = { editing = it })
else -> {
// The strip follows the list: the day at the top of the list is the selected one.
if (agenda) LaunchedEffect(rows) {
snapshotFlow { list.firstVisibleItemIndex }.collect { i ->
rows.getOrNull(i)?.date?.let { if (it != date) date = it }
}
}
PlanList(list, rows, vm::toggleNote, onEdit = { editing = it })
}
}
}
FloatingActionButton(
onClick = { adding = true },
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp + navBarPadding()),
) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание") }
}
if (tuning) ModalBottomSheet(onDismissRequest = { tuning = false }) { PlansSettings(prefs, vm::setPlans) }
val group = vm.currentGroupKey()
if (adding) ModalBottomSheet(onDismissRequest = { adding = false }) {
AddPlanSheet(vm, timetable, settings?.subgroup, day = null) {
adding = false
target = it
}
}
if (group != null && (target != null || editing != null)) NoteEditor(
target = target,
editing = editing,
groupKey = editing?.group ?: group,
onSave = { vm.saveNote(it); target = null; editing = null },
onDelete = { vm.deleteNote(it.id); editing = null },
onDismiss = { target = null; editing = null },
)
}
/** Agenda: every day, empty ones as a grey header. List: only days with plans or lessons. */
private fun rows(
days: List<LocalDate>,
byDay: Map<String, List<Note>>,
overdue: List<Note>,
lessonsOf: (LocalDate) -> List<Slot>,
agenda: Boolean,
): List<Row> = buildList {
if (overdue.isNotEmpty()) {
add(Row.Header(null, "Просрочено", empty = false, overdue = true))
overdue.forEach { add(Row.Item(null, it)) }
add(Row.Divider(null))
}
days.forEach { d ->
val notes = byDay[d.toString()].orEmpty()
// Every layout shows the day's lessons alike; the list only drops days with nothing at all.
val slots = lessonsOf(d)
if (!agenda && notes.isEmpty() && slots.isEmpty()) return@forEach
add(Row.Header(d, dayTitle(d), empty = notes.isEmpty() && slots.isEmpty(), overdue = false))
if (slots.isNotEmpty()) add(Row.Lessons(d, slots))
notes.forEach { add(Row.Item(d, it)) }
add(Row.Divider(d))
}
}
@Composable
private fun PlanList(list: LazyListState, rows: List<Row>, onToggle: (String) -> Unit, onEdit: (Note) -> Unit) {
if (rows.isEmpty()) {
Empty("Планов нет", "Задания и заметки к парам появятся здесь. Добавьте первое кнопкой «+».", icon = Icons.Rounded.Checklist)
return
}
LazyColumn(
state = list,
contentPadding = PaddingValues(bottom = 96.dp + navBarPadding()),
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
) {
rows.forEachIndexed { i, row ->
when (row) {
is Row.Header -> stickyHeader(key = "h$i") { DayHeader(row) }
is Row.Lessons -> item(key = "l$i") { LessonsSummary(row.slots, Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) }
is Row.Item -> item(key = "n${row.note.id}") {
NoteRow(row.note, onToggle = { onToggle(row.note.id) }, onClick = { onEdit(row.note) }, showSubject = true, showDue = row.date == null)
}
is Row.Divider -> item(key = "d$i") {
HorizontalDivider(Modifier.padding(horizontal = 20.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
}
}
}
}
}
/** A sticky day header: opaque, so the list scrolls under it. */
@Composable
private fun DayHeader(h: Row.Header) {
Text(
h.title,
style = MaterialTheme.typography.titleSmallEmphasized,
color = when {
h.overdue -> MaterialTheme.colorScheme.error
h.empty -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
else -> MaterialTheme.colorScheme.onSurface
},
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(start = 20.dp, end = 20.dp, top = 14.dp, bottom = 10.dp)
.semantics { heading() },
)
}
/** The day's lessons in a compact block: "9:00–10:30 ▍Programming". */
@Composable
private fun LessonsSummary(slots: List<Slot>, modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.surfaceContainer) {
Surface(shape = MaterialTheme.shapes.large, color = color, modifier = modifier.fillMaxWidth()) {
Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
slots.forEach { slot ->
slot.lessons.forEach { l ->
androidx.compose.foundation.layout.Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.width(3.dp).height(14.dp).background(subjectColor(l.subject).container, CircleShape))
Spacer(Modifier.width(8.dp))
Text(
"${slot.start}–${slot.end}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(10.dp))
Text(l.subject, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
}
/** Board: a column per day, swiped sideways; the next day's column peeks in from the end. */
@Composable
private fun Board(
days: List<LocalDate>,
byDay: Map<String, List<Note>>,
overdue: List<Note>,
lessonsOf: (LocalDate) -> List<Slot>,
onToggle: (String) -> Unit,
onEdit: (Note) -> Unit,
) {
val columns: List<LocalDate?> = (if (overdue.isNotEmpty()) listOf<LocalDate?>(null) else emptyList()) + days
val pager: PagerState = rememberPagerState { columns.size }
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.page)) {
HorizontalPager(
state = pager,
contentPadding = PaddingValues(start = 16.dp, end = 48.dp),
pageSpacing = 12.dp,
verticalAlignment = Alignment.Top,
modifier = Modifier.weight(1f),
) { page ->
val d = columns[page]
val notes = if (d == null) overdue else byDay[d.toString()].orEmpty()
val slots = d?.let(lessonsOf).orEmpty()
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = 8.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
if (d == null) "Просрочено" else "${d.longLabel()} • ${d.weekdayName()}" + if (d == today()) " • Сегодня" else "",
style = MaterialTheme.typography.titleMediumEmphasized,
color = if (d == null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 4.dp).semantics { heading() },
)
if (slots.isNotEmpty()) LessonsSummary(slots, color = MaterialTheme.colorScheme.card)
if (notes.isEmpty()) Text(
"Ничего не запланировано",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp, top = 4.dp),
)
notes.forEach { n ->
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.card,
modifier = Modifier.fillMaxWidth().border(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.large),
) {
NoteRow(n, onToggle = { onToggle(n.id) }, onClick = { onEdit(n) }, showSubject = true, showDue = d == null)
}
}
}
}
// Page dots, as School Planner shows under its board.
androidx.compose.foundation.layout.Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp, bottom = 16.dp + navBarPadding()),
) {
val shown = pager.currentPage
val range = (shown - 3).coerceAtLeast(0)..(shown + 3).coerceAtMost(columns.lastIndex)
range.forEach { i ->
Box(
Modifier
.size(width = if (i == shown) 16.dp else 6.dp, height = 6.dp)
.background(
if (i == shown) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
CircleShape,
),
)
}
}
}
}
/** School Planner's plan settings: layout, the day's lessons, ticked-off tasks. */
@Composable
private fun PlansSettings(prefs: PlansPrefs, onChange: (PlansPrefs) -> Unit) {
Column(
Modifier.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Heading("Вид планов", style = MaterialTheme.typography.headlineSmallEmphasized)
Text("Макет", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
androidx.compose.foundation.layout.Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
LayoutTile(Icons.Rounded.ViewAgenda, "Повестка дня", prefs.layout == PlansLayout.Agenda, Modifier.weight(1f)) {
onChange(prefs.copy(layout = PlansLayout.Agenda))
}
LayoutTile(Icons.Rounded.ViewList, "Список", prefs.layout == PlansLayout.List, Modifier.weight(1f)) {
onChange(prefs.copy(layout = PlansLayout.List))
}
LayoutTile(Icons.Rounded.ViewColumn, "Доска", prefs.layout == PlansLayout.Board, Modifier.weight(1f)) {
onChange(prefs.copy(layout = PlansLayout.Board))
}
}
Text(
when (prefs.layout) {
PlansLayout.Agenda -> "Все дни подряд с полосой недели — видно и занятые, и свободные дни."
PlansLayout.List -> "Без полосы недели и без пустых дней — только дни с парами или планами."
PlansLayout.Board -> "Колонка на каждый день, листаются в сторону."
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
SettingSwitch("Пары дня", "Короткий список пар над планами дня", prefs.lessons) { onChange(prefs.copy(lessons = it)) }
SettingSwitch("Выполненные", "Отмеченные задания остаются в списке зачёркнутыми", prefs.done) { onChange(prefs.copy(done = it)) }
}
}
@Composable
private fun LayoutTile(icon: ImageVector, label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
Surface(
shape = MaterialTheme.shapes.large,
color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainer,
contentColor = if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
border = if (selected) androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
modifier = modifier.clip(MaterialTheme.shapes.large).selectable(selected = selected, role = Role.RadioButton, onClick = onClick),
) {
Column(Modifier.padding(vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp))
Spacer(Modifier.height(8.dp))
Text(label, style = MaterialTheme.typography.labelLarge, maxLines = 1)
}
}
}
@Composable
private fun SettingSwitch(title: String, text: String, checked: Boolean, onChange: (Boolean) -> Unit) {
androidx.compose.foundation.layout.Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).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)
}
}
/**
* What a new plan belongs to: a general plan for a day, or one of the coming lessons —
* of [day] only when given (the calendar), otherwise of the next two weeks.
*/
@Composable
internal fun AddPlanSheet(vm: MainViewModel, t: Timetable?, subgroup: String?, day: LocalDate?, onPick: (NoteTarget) -> Unit) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Heading("Новый план", style = MaterialTheme.typography.headlineSmallEmphasized)
Surface(
onClick = { onPick(NoteTarget("", day ?: today(), "", next = null)) },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
androidx.compose.foundation.layout.Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.EventNote, contentDescription = null)
Spacer(Modifier.width(16.dp))
Column {
Text("Общий план", style = MaterialTheme.typography.titleSmallEmphasized)
Text("Без привязки к паре — на любой день", style = MaterialTheme.typography.bodyMedium)
}
}
}
val days = if (t == null) emptyList() else (if (day != null) listOf(day) else (0..13L).map { today().plusDays(it) })
.map { it to vm.slotsOn(t, it, subgroup) }.filter { it.second.isNotEmpty() }
if (days.isNotEmpty()) Text(
"Или к занятию",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
days.forEach { (d, slots) ->
Text(dayTitle(d), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp))
slots.forEach { slot ->
slot.lessons.forEach { l -> CompactLesson(l, slot, onClick = { onPick(NoteTarget(l.subject, d, l.start, next = null)) }) }
}
}
}
}

View File

@@ -2,6 +2,12 @@
package ritm.schedule.ui
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Check
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.OutlinedButton
import ritm.schedule.data.hideKey
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import kotlin.math.abs
@@ -139,12 +145,12 @@ fun ScheduleScreen(
icon = Icons.Rounded.CloudOff,
action = "Выбрать группу" to nav.openGroups,
)
else -> WordRows(t, subgroup, onOpen = { details = it to null })
else -> WordRows(t, subgroup, settings?.hidden.orEmpty(), onOpen = { details = it to null })
}
}
details?.let { (lesson, date) ->
ModalBottomSheet(onDismissRequest = { details = null }) {
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm)
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm, onClose = { details = null })
}
}
}
@@ -225,8 +231,14 @@ private fun Planner(
val index = mondays.indexOf(date.monday())
if (index >= 0 && index != strip.currentPage) strip.animateScrollToPage(index)
}
// Only a finger swipe on the strip moves the days; following the day pager must not.
var swiped by remember { mutableStateOf(false) }
val dragged by strip.interactionSource.collectIsDraggedAsState()
LaunchedEffect(dragged) { if (dragged) swiped = true }
LaunchedEffect(strip) {
snapshotFlow { strip.settledPage }.collect { page ->
if (!swiped) return@collect
swiped = false
val shown = dates.getOrElse(pager.currentPage) { today() }
val monday = mondays[page]
if (monday != shown.monday()) {
@@ -290,7 +302,7 @@ private fun Planner(
DayStrip(
monday = monday,
selected = date,
hasLessons = { d -> vm.repository.lessonsOn(t, d, subgroup).isNotEmpty() },
hasLessons = { d -> vm.slotsOn(t, d, subgroup).isNotEmpty() },
enabled = { d -> dates.contains(d) },
onSelect = ::go,
onWeek = ::week,
@@ -403,7 +415,7 @@ fun LessonCard(
}
}
}
time?.let { Text(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
time?.let { TimeText(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(
@@ -418,11 +430,15 @@ fun LessonCard(
if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null,
if (lesson.groups.size > 1) "общее занятие" else null,
)
if (tags.isNotEmpty() || lesson.uncertain) FlowRow(
val mark = date?.let { LocalMarks.current.forLesson(lesson.subject, it, lesson.start) }
if (tags.isNotEmpty() || lesson.uncertain || mark != null) FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 4.dp),
) {
mark?.let {
SoftTag(if (it.present) "был" else "пропуск", color, icon = if (it.present) Icons.Rounded.Check else Icons.Rounded.Close)
}
tags.forEach { SoftTag(it, color) }
if (lesson.uncertain) Tag("Сверьте недели", warning = true)
}
@@ -450,9 +466,18 @@ fun LessonCard(
}
@Composable
private fun SoftTag(text: String, color: SubjectColor) {
private fun SoftTag(text: String, color: SubjectColor, icon: ImageVector? = null) {
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))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = if (icon != null) 6.dp else 8.dp, end = 8.dp, top = 3.dp, bottom = 3.dp),
) {
icon?.let {
Icon(it, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
Text(text.replaceFirstChar { it.titlecase() }, style = MaterialTheme.typography.labelLarge)
}
}
}
@@ -491,7 +516,13 @@ private fun Tag(text: String, warning: Boolean = false) {
}
@Composable
internal fun LessonDetails(lesson: Lesson, bells: List<Bell>, date: LocalDate? = null, vm: MainViewModel? = null) {
internal fun LessonDetails(
lesson: Lesson,
bells: List<Bell>,
date: LocalDate? = null,
vm: MainViewModel? = null,
onClose: () -> Unit = {},
) {
val bell = bells.firstOrNull { it.start == lesson.start }
Column(
Modifier
@@ -524,11 +555,27 @@ internal fun LessonDetails(lesson: Lesson, bells: List<Bell>, date: LocalDate? =
offline = false,
onClose = null,
)
if (date != null && vm != null) LessonNotes(vm, lesson, date)
if (date != null && vm != null) {
LessonAttendance(vm, lesson, date)
LessonNotes(vm, lesson, date)
}
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())
}
if (vm != null) {
OutlinedButton(onClick = { vm.setHidden(lesson.hideKey, true); onClose() }) {
Icon(Icons.Rounded.VisibilityOff, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Скрыть такие занятия")
}
Text(
"«${lesson.subject}»${lesson.type.takeIf { it.isNotEmpty() }?.let { " · ${it.lowercase()}" }.orEmpty()} пропадёт из расписания, " +
"обзора, виджета и напоминаний во все недели. Вернуть можно в настройках.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -547,7 +594,7 @@ private fun Detail(icon: androidx.compose.ui.graphics.vector.ImageVector, label:
/** 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) {
private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<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),
@@ -559,6 +606,7 @@ private fun WordRows(t: Timetable, subgroup: String?, onOpen: (Lesson) -> Unit)
Heading(dayName(day.day), style = MaterialTheme.typography.headlineSmallEmphasized)
}
val lessons = day.lessons.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
.filter { it.hideKey !in hidden }
items(lessons, key = { "${day.day}-${it.key}" }) { lesson ->
LessonCard(lesson, time = lesson.start + (lesson.end?.let { " → $it" } ?: ""), onClick = { onOpen(lesson) })
}

View File

@@ -2,6 +2,7 @@
package ritm.schedule.ui
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -77,7 +78,6 @@ 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
@@ -92,28 +92,23 @@ private sealed interface Target {
data class Room(val room: String) : Target
}
private fun startDate(): LocalDate = if (today().dayOfWeek.value == 7) today().plusDays(1) else today()
internal 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 }
@@ -131,18 +126,11 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
}.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 dated = target != null
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(
@@ -193,13 +181,6 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
}
},
)
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)) {
@@ -211,7 +192,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
) {
error?.let { item { Banner(it, offline = true, onClose = null) } }
if (tab == 0 && target == null) {
if (target == null) {
item { SearchField(query, { query = it }, "Фамилия или номер аудитории") }
val h = hits
if (h == null) item {
@@ -276,48 +257,10 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
onClick = null,
)
}
item { CoverageNote(d.coverage, free = false) }
item { CoverageNote(d.coverage) }
}
}
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) }
}
}
}
}
}
@@ -326,7 +269,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
/** 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) {
internal 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 }
@@ -335,8 +278,15 @@ private fun SearchWeekStrip(timetable: ritm.schedule.data.Timetable?, date: Loca
val index = SPAN + ChronoUnit.WEEKS.between(base, date.monday()).toInt()
if (index != pager.currentPage && index in 0 until pager.pageCount) pager.animateScrollToPage(index)
}
// Only a swipe by the finger picks a week. When the strip merely follows a scrolling list,
// reporting its stop would scroll the list back and cut its fling short.
var swiped by remember { mutableStateOf(false) }
val dragged by pager.interactionSource.collectIsDraggedAsState()
LaunchedEffect(dragged) { if (dragged) swiped = true }
LaunchedEffect(pager) {
snapshotFlow { pager.settledPage }.collect { page ->
if (!swiped) return@collect
swiped = false
val monday = mondayAt(page)
if (monday != current.monday()) onDate(monday.plusDays(current.dayOfWeek.value - 1L))
}
@@ -386,41 +336,12 @@ private fun RoomChips(rooms: List<String>, onClick: (String) -> Unit) {
}
}
@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) {
private fun CoverageNote(c: Coverage) {
Text(
"По расписаниям из Word: учтено ${c.documents} из ${c.total} документов каталога ${c.year}." +
(if (free) " Аудитория может быть занята мероприятием или закрыта — расписание этого не знает." else ""),
"",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)

View File

@@ -0,0 +1,219 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package ritm.schedule.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ritm.schedule.data.Exam
import ritm.schedule.data.Timetable
import ritm.schedule.data.longLabel
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 ritm.schedule.ui.theme.card
import java.time.LocalDate
import java.time.temporal.ChronoUnit
/** Upcoming exams of the open group, soonest first; the cache of another group is ignored. */
@Composable
fun upcomingExams(vm: MainViewModel, t: Timetable): List<Exam> {
val exams by vm.exams.collectAsStateWithLifecycle()
val date = today()
return exams?.takeIf { it.group == t.group }?.exams.orEmpty().filter { parseDate(it.date) >= date }
}
private fun daysUntil(date: LocalDate) = ChronoUnit.DAYS.between(today(), date).toInt()
/** "через 12 дней", "завтра", "сегодня". */
private fun countdownLabel(days: Int) = when (days) {
0 -> "сегодня"
1 -> "завтра"
else -> "через $days ${plural(days, "день", "дня", "дней")}"
}
/**
* The session countdown of the overview: days to the next exam or credit, with the subject,
* date and room. Before the university publishes the session it counts down to the end of
* the semester instead, and says so.
*/
@Composable
fun SessionCard(vm: MainViewModel, t: Timetable, onOpen: () -> Unit) {
val exams = upcomingExams(vm, t)
val next = exams.firstOrNull { it.counts } ?: exams.firstOrNull()
val semesterEnd = t.semester?.end?.let(::parseDate)?.takeIf { it >= today() }
if (next == null && semesterEnd == null) return
val target = next?.let { parseDate(it.date) } ?: semesterEnd!!
val days = daysUntil(target)
val label = if (next != null) "Сессия · ${next.type.lowercase()} ${countdownLabel(days)}"
else "До конца семестра ${countdownLabel(days).removePrefix("через ")}"
Surface(
onClick = onOpen,
enabled = next != null,
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.card,
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {},
) {
Row(Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically) {
Countdown(days)
Spacer(Modifier.width(20.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
if (next != null) {
Text(next.subject, style = MaterialTheme.typography.titleMediumEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(
examWhen(next),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (exams.size > 1) Text(
"Ещё ${exams.size - 1} в сессии",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
} else {
Text(t.semester?.label ?: "Семестр", style = MaterialTheme.typography.titleMediumEmphasized)
Text(
"Учёба до ${semesterEnd!!.longLabel()}. Даты экзаменов появятся здесь, когда университет опубликует расписание сессии.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/** The big number on an expressive cookie shape; "!" on the day itself. */
@Composable
private fun Countdown(days: Int) {
val urgent = days <= 3
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(84.dp)
.background(
if (urgent) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.primaryContainer,
MaterialShapes.Cookie9Sided.toShape(),
)
.clearAndSetSemantics {},
) {
val color = if (urgent) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onPrimaryContainer
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
when (days) { 0 -> "!"; else -> "$days" },
style = MaterialTheme.typography.headlineMediumEmphasized,
color = color,
)
Text(
when (days) { 0 -> "сегодня"; 1 -> "день"; else -> plural(days, "день", "дня", "дней") },
style = MaterialTheme.typography.labelMedium,
color = color,
textAlign = TextAlign.Center,
)
}
}
}
/** "Пн, 12 января, 09:00 · ауд. 213А". */
private fun examWhen(e: Exam): String {
val d = parseDate(e.date)
return d.shortWeekday() + ", " + d.longLabel() + ", " + e.start +
(e.rooms.takeIf { it.isNotEmpty() }?.let { " · ауд. ${it.joinToString(", ")}" } ?: "")
}
/** The whole session in date order, consultations included. */
@Composable
fun SessionSheet(exams: List<Exam>) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Heading("Сессия", style = MaterialTheme.typography.headlineSmallEmphasized)
val exams1 = exams.count { it.counts }
Text(
"$exams1 ${plural(exams1, "экзамен или зачёт", "экзамена или зачёта", "экзаменов и зачётов")}" +
(exams.size - exams1).takeIf { it > 0 }?.let { ", консультаций: $it" }.orEmpty(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
exams.forEach { e -> ExamRow(e) }
Text(
"Даты собраны из всех документов каталога по пометкам «экз.», «зач.» и «конс.». Сверяйтесь с документом Word.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ExamRow(e: Exam) {
val d = parseDate(e.date)
val days = daysUntil(d)
val strong = e.counts
Surface(
shape = MaterialTheme.shapes.large,
color = if (strong) MaterialTheme.colorScheme.card else MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {
contentDescription = "${e.type}: ${e.subject}, ${d.weekdayName()}, ${d.longLabel()}, ${e.start}, ${countdownLabel(days)}"
},
) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(48.dp)) {
Text("${d.dayOfMonth}", style = MaterialTheme.typography.headlineSmallEmphasized)
Text(d.shortWeekday(), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(
e.type + " · " + countdownLabel(days),
style = MaterialTheme.typography.labelLarge,
color = if (strong) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(e.subject, style = MaterialTheme.typography.titleSmallEmphasized)
Text(
listOfNotNull(
e.start + (e.end?.let { "–$it" } ?: ""),
e.rooms.takeIf { it.isNotEmpty() }?.let { "ауд. ${it.joinToString(", ")}" },
e.teachers.takeIf { it.isNotEmpty() }?.joinToString(", "),
).joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View File

@@ -2,6 +2,7 @@
package ritm.schedule.ui
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.selection.toggleable
import androidx.compose.ui.semantics.Role
import ritm.schedule.ui.theme.card
@@ -100,6 +101,20 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
SwitchRow("Пометки из Word", "Например, «3-13н.» — даты по ним уже рассчитаны", d.notes) {
vm.setDisplay(d.copy(notes = it))
}
if (s.hidden.isNotEmpty()) {
Text("Скрытые занятия", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
s.hidden.sorted().forEach { key ->
val (subject, type) = key.split("|", limit = 2).let { it[0] to it.getOrElse(1) { "" } }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
subject.replaceFirstChar { it.uppercase() } + if (type.isNotEmpty()) " · $type" else "",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
androidx.compose.material3.TextButton(onClick = { vm.setHidden(key, false) }) { Text("Показать") }
}
}
}
Text(
"Полные сведения о занятии всегда открываются по нажатию на карточку.",
style = MaterialTheme.typography.bodyMedium,
@@ -171,7 +186,8 @@ private fun SwitchRow(title: String, text: String, checked: Boolean, onChange: (
// 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),
// The press highlight follows the row's rounded shape instead of a hard rectangle.
modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).toggleable(value = checked, role = Role.Switch, onValueChange = onChange),
) {
Column(Modifier.weight(1f).padding(end = 16.dp)) {
Text(title, style = MaterialTheme.typography.bodyLarge)

View File

@@ -6,6 +6,7 @@ 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.ui.theme.harmonizeWith
import ritm.schedule.data.Lesson
import ritm.schedule.data.Timetable
import ritm.schedule.data.parseTime
@@ -77,7 +78,23 @@ fun subjectColor(subject: String): SubjectColor {
// 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]
return harmonized(dark, MaterialTheme.colorScheme.primary)[slot]
}
// Harmonized palettes per theme and primary; the primary changes only with the theme or wallpaper.
private val palettes = HashMap<Pair<Boolean, Color>, List<SubjectColor>>()
private fun harmonized(dark: Boolean, primary: Color): List<SubjectColor> = synchronized(palettes) {
palettes.getOrPut(dark to primary) {
(if (dark) DARK else LIGHT).map {
SubjectColor(
container = it.container.harmonizeWith(primary),
content = it.content,
soft = it.soft.harmonizeWith(primary),
onSoft = it.onSoft.harmonizeWith(primary),
)
}
}
}
private val SHORT_WORDS = setOf("и", "в", "во", "на", "по", "с", "со", "к", "о", "об", "для", "из", "от")

View File

@@ -2,6 +2,7 @@
package ritm.schedule.ui
import androidx.compose.material.icons.rounded.HowToReg
import androidx.compose.material.icons.automirrored.rounded.Assignment
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.FilledTonalButton
@@ -280,6 +281,11 @@ private fun SubjectCard(s: SubjectSummary, onClick: () -> Unit) {
strong = next != null,
)
if (show.teachers && s.teachers.isNotEmpty()) Fact(Icons.Rounded.Person, s.teachers.joinToString(", "))
val (present, marked) = LocalMarks.current.statsFor(s.name)
if (marked > 0) Fact(
Icons.Rounded.HowToReg,
"Посещено $present из $marked" + if (marked > present) " · пропусков: ${marked - present}" else "",
)
val tasks = LocalNotes.current.count { it.task && !it.done && subjectKey(it.subject) == subjectKey(s.name) }
if (tasks > 0) Fact(
Icons.AutoMirrored.Rounded.Assignment,
@@ -343,7 +349,7 @@ private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
SubjectDot(lesson.subject, 10.dp)
Spacer(Modifier.width(18.dp))
Column {
Text("${slot.start} → ${slot.end}", style = MaterialTheme.typography.titleSmallEmphasized)
TimeText("${slot.start} → ${slot.end}", style = MaterialTheme.typography.titleSmallEmphasized)
Text(
listOfNotNull(
lesson.type.ifEmpty { null },

View File

@@ -12,68 +12,78 @@ 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.graphics.toArgb
import com.google.android.material.color.MaterialColors
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),
// Material 3 tonal-spot scheme from a blue seed (#0061A4).
internal val Light = lightColorScheme(
primary = Color(0xFF0061A4),
onPrimary = Color.White,
primaryContainer = Color(0xFFEADDFF),
onPrimaryContainer = Color(0xFF21005D),
secondary = Color(0xFF625B71),
primaryContainer = Color(0xFFD1E4FF),
onPrimaryContainer = Color(0xFF001D36),
inversePrimary = Color(0xFF9ECAFF),
secondary = Color(0xFF535F70),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE8DEF8),
onSecondaryContainer = Color(0xFF1D192B),
tertiary = Color(0xFF7D5260),
secondaryContainer = Color(0xFFD7E3F7),
onSecondaryContainer = Color(0xFF101C2B),
tertiary = Color(0xFF6B5778),
onTertiary = Color.White,
tertiaryContainer = Color(0xFFFFD8E4),
onTertiaryContainer = Color(0xFF31111D),
surface = Color(0xFFFEF7FF),
onSurface = Color(0xFF1D1B20),
onSurfaceVariant = Color(0xFF49454F),
tertiaryContainer = Color(0xFFF2DAFF),
onTertiaryContainer = Color(0xFF251431),
surface = Color(0xFFF8F9FF),
onSurface = Color(0xFF191C20),
onSurfaceVariant = Color(0xFF42474E),
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),
surfaceContainerLow = Color(0xFFF2F3FA),
surfaceContainer = Color(0xFFECEEF4),
surfaceContainerHigh = Color(0xFFE6E8EE),
surfaceContainerHighest = Color(0xFFE0E2E8),
surfaceVariant = Color(0xFFDFE2EB),
outline = Color(0xFF73777F),
outlineVariant = Color(0xFFC3C7CF),
inverseSurface = Color(0xFF2E3135),
inverseOnSurface = Color(0xFFEFF0F7),
error = Color(0xFFBA1A1A),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFF8F9FF),
onBackground = Color(0xFF191C20),
)
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),
internal val Dark = darkColorScheme(
primary = Color(0xFF9ECAFF),
onPrimary = Color(0xFF003258),
primaryContainer = Color(0xFF00497D),
onPrimaryContainer = Color(0xFFD1E4FF),
inversePrimary = Color(0xFF0061A4),
secondary = Color(0xFFBBC7DB),
onSecondary = Color(0xFF253140),
secondaryContainer = Color(0xFF3B4858),
onSecondaryContainer = Color(0xFFD7E3F7),
tertiary = Color(0xFFD6BEE4),
onTertiary = Color(0xFF3B2948),
tertiaryContainer = Color(0xFF523F5F),
onTertiaryContainer = Color(0xFFF2DAFF),
surface = Color(0xFF111418),
onSurface = Color(0xFFE1E2E8),
onSurfaceVariant = Color(0xFFC3C7CF),
surfaceContainerLowest = Color(0xFF0C0E13),
surfaceContainerLow = Color(0xFF191C20),
surfaceContainer = Color(0xFF1D2024),
surfaceContainerHigh = Color(0xFF282A2F),
surfaceContainerHighest = Color(0xFF33353A),
surfaceVariant = Color(0xFF42474E),
outline = Color(0xFF8D9199),
outlineVariant = Color(0xFF42474E),
inverseSurface = Color(0xFFE1E2E8),
inverseOnSurface = Color(0xFF2E3135),
error = Color(0xFFFFB4AB),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF111418),
onBackground = Color(0xFFE1E2E8),
)
/** The tinted page behind cards, app bars and the navigation rail. */
@@ -83,6 +93,13 @@ val ColorScheme.page: Color get() = surfaceContainer
val ColorScheme.card: Color
get() = if (surface.red + surface.green + surface.blue < 1.5f) surfaceContainerHigh else surfaceContainerLowest
/**
* Material harmonization: turns a static colour's hue up to 15° towards [primary] in HCT,
* keeping its chroma and tone, so fixed colours sit well with dynamic (wallpaper) schemes.
*/
fun Color.harmonizeWith(primary: Color): Color =
Color(MaterialColors.harmonize(toArgb(), primary.toArgb()))
/** Week colours from the university table: white and green weeks. */
object WeekColors {
fun container(white: Boolean, dark: Boolean) = when {

View File

@@ -0,0 +1,85 @@
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 ritm.schedule.app
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.padding
import androidx.glance.layout.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import ritm.schedule.MainActivity
/** One line on the home screen: when the next lesson is, what it is and where. */
class NextLessonWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val next = upcoming(context)
val dynamic = context.app.settings.now().dynamicColor
provideContent { WidgetTheme(dynamic) { Content(next) } }
}
@Composable
private fun Content(next: Upcoming?) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = GlanceModifier
.fillMaxSize()
.roundedBackground(GlanceTheme.colors.widgetBackground, 28.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickable(actionStartActivity<MainActivity>()),
) {
if (next == null) {
Text(
"Ближайших пар нет",
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp, fontWeight = FontWeight.Medium),
)
return@Row
}
Text(
next.whenLabel(),
maxLines = 1,
style = TextStyle(
color = if (next.running) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onPrimaryContainer,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
),
modifier = GlanceModifier
.roundedBackground(if (next.running) GlanceTheme.colors.primary else GlanceTheme.colors.primaryContainer, 16.dp)
.padding(horizontal = 10.dp, vertical = 6.dp),
)
Spacer(GlanceModifier.width(12.dp))
Column(modifier = GlanceModifier.defaultWeight()) {
Text(
next.subject,
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp, fontWeight = FontWeight.Bold),
)
if (next.rooms.isNotEmpty()) Text(
"ауд. ${next.rooms}",
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp),
)
}
}
}
}
class NextLessonWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = NextLessonWidget()
}

View File

@@ -11,7 +11,8 @@ 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.LocalSize
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
@@ -43,19 +44,21 @@ private data class WidgetState(
val title: String,
val lines: List<Pair<String, String>>,
val empty: String?,
val dynamic: Boolean = false,
)
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 settings = app.settings.now()
val subgroup = settings.subgroup
val now = nowTime()
val todayLessons = app.repository.lessonsOn(timetable, today(), subgroup)
val todayLessons = app.repository.lessonsOn(timetable, today(), subgroup, settings.hidden)
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)
else today().plusDays(1) to app.repository.lessonsOn(timetable, today().plusDays(1), subgroup, settings.hidden)
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()
@@ -77,13 +80,17 @@ private suspend fun state(context: Context): WidgetState {
title,
lines,
if (lines.isEmpty()) "Занятий нет" else null,
settings.dynamicColor,
)
}
class TodayWidget : GlanceAppWidget() {
// Laid out for its real size, so a short widget shows fewer lessons instead of clipping them.
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = state(context)
provideContent { GlanceTheme { Content(data) } }
provideContent { WidgetTheme(data.dynamic) { Content(data) } }
}
@Composable
@@ -91,8 +98,7 @@ class TodayWidget : GlanceAppWidget() {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.widgetBackground)
.cornerRadius(24.dp)
.roundedBackground(GlanceTheme.colors.widgetBackground, 28.dp)
.padding(16.dp)
.clickable(actionStartActivity<MainActivity>()),
) {
@@ -112,15 +118,19 @@ class TodayWidget : GlanceAppWidget() {
data.empty?.let {
Text(it, style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp))
}
data.lines.forEachIndexed { index, (label, text) ->
// Header and padding take about 64dp; a lesson needs about 62dp, or 46dp on one line.
val height = LocalSize.current.height
val roomy = ((height - 64.dp) / 62.dp).toInt()
val compact = roomy < 1
val fits = if (compact) 1 else roomy
data.lines.take(fits).forEachIndexed { index, (label, text) ->
Column(
modifier = GlanceModifier
.fillMaxWidth()
.background(
if (index == 0) GlanceTheme.colors.primaryContainer
else GlanceTheme.colors.secondaryContainer,
.roundedBackground(
if (index == 0) GlanceTheme.colors.primaryContainer else GlanceTheme.colors.secondaryContainer,
16.dp,
)
.cornerRadius(16.dp)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
@@ -134,7 +144,7 @@ class TodayWidget : GlanceAppWidget() {
)
Text(
text,
maxLines = 2,
maxLines = if (compact) 1 else 2,
style = TextStyle(
color = if (index == 0) GlanceTheme.colors.onPrimaryContainer
else GlanceTheme.colors.onSecondaryContainer,

View File

@@ -0,0 +1,51 @@
package ritm.schedule.widget
import android.content.Context
import ritm.schedule.app
import ritm.schedule.data.Lesson
import ritm.schedule.data.minutesLabel
import ritm.schedule.data.nowTime
import ritm.schedule.data.parseTime
import ritm.schedule.data.shortWeekday
import ritm.schedule.data.today
import java.time.Duration
import java.time.LocalDate
/** The lesson in progress or the next one within a week, for the small widget and the tile. */
data class Upcoming(val date: LocalDate, val start: String, val end: String?, val lessons: List<Lesson>, val running: Boolean) {
val subject get() = lessons.joinToString(" / ") { it.subject }
val rooms get() = lessons.flatMap { if (it.distance) listOf("дист.") else it.rooms }.distinct().joinToString(", ")
/** "Идёт · ещё 40 мин", "Через 20 мин", "Завтра, 10:05" or "Пн, 10:05". */
fun whenLabel(): String {
val now = nowTime()
return when {
running && end != null -> "Идёт · ещё ${minutesLabel(Duration.between(now, parseTime(end)).toMinutes())}"
date == today() && Duration.between(now, parseTime(start)).toMinutes() <= 120 ->
"Через ${minutesLabel(Duration.between(now, parseTime(start)).toMinutes())}"
date == today() -> "Сегодня, $start"
date == today().plusDays(1) -> "Завтра, $start"
else -> "${date.shortWeekday()}, $start"
}
}
}
/** From the cached timetable and the student's subgroup and hidden lessons; no network. */
suspend fun upcoming(context: Context): Upcoming? {
val app = context.app
val t = app.repository.timetable.value ?: return null
val s = app.settings.now()
val now = nowTime()
for (offset in 0..7L) {
val date = today().plusDays(offset)
val slots = app.repository.lessonsOn(t, date, s.subgroup, s.hidden).groupBy { it.start }.toSortedMap()
for ((start, lessons) in slots) {
val end = lessons.first().end ?: t.bells.firstOrNull { it.start == start }?.end
val endTime = end?.let(::parseTime) ?: parseTime(start).plusMinutes(80)
if (offset == 0L && !endTime.isAfter(now)) continue
val running = offset == 0L && !now.isBefore(parseTime(start))
return Upcoming(date, start, end, lessons, running)
}
}
return null
}

View File

@@ -0,0 +1,39 @@
package ritm.schedule.widget
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.glance.ColorFilter
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.ImageProvider
import androidx.glance.appwidget.cornerRadius
import androidx.glance.background
import androidx.glance.material3.ColorProviders
import androidx.glance.unit.ColorProvider
import ritm.schedule.R
import ritm.schedule.ui.theme.Dark
import ritm.schedule.ui.theme.Light
/**
* The app's colours on the home screen: wallpaper colours when the student chose them and
* the system has them (Android 12+), otherwise the app's own blue scheme — never Glance's
* default purple, which older systems fall back to.
*/
@Composable
fun WidgetTheme(dynamic: Boolean, content: @Composable () -> Unit) {
if (dynamic && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) GlanceTheme(content = content)
else GlanceTheme(colors = ColorProviders(light = Light, dark = Dark), content = content)
}
/**
* A rounded fill. Glance rounds corners only on Android 12+, so older systems get a rounded
* shape drawable tinted with the same colour.
*/
fun GlanceModifier.roundedBackground(color: ColorProvider, radius: Dp): GlanceModifier =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) background(color).cornerRadius(radius)
else background(
ImageProvider(if (radius >= 24.dp) R.drawable.widget_round_28 else R.drawable.widget_round_16),
colorFilter = ColorFilter.tint(color),
)

View File

@@ -1,5 +1,7 @@
package ritm.schedule.work
import ritm.schedule.tile.LessonTileService
import ritm.schedule.widget.NextLessonWidget
import android.content.Context
import androidx.glance.appwidget.updateAll
import androidx.work.Constraints
@@ -43,6 +45,8 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c
val app = context.app
Reminders.reschedule(context, app.repository.timetable.value, app.settings.now())
TodayWidget().updateAll(context)
NextLessonWidget().updateAll(context)
LessonTileService.refresh(context)
}
}
}
@@ -51,6 +55,8 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c
class WidgetTick(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
TodayWidget().updateAll(applicationContext)
NextLessonWidget().updateAll(applicationContext)
LessonTileService.refresh(applicationContext)
return Result.success()
}
}

View File

@@ -12,7 +12,7 @@
android:scaleY="0.5"
android:translateY="480">
<path
android:fillColor="#4F378B"
android:fillColor="#00497D"
android:pathData="M200,-80Q167,-80 143.5,-103.5Q120,-127 120,-160L120,-720Q120,-753 143.5,-776.5Q167,-800 200,-800L240,-800L240,-840Q240,-857 251.5,-868.5Q263,-880 280,-880Q297,-880 308.5,-868.5Q320,-857 320,-840L320,-800L640,-800L640,-840Q640,-857 651.5,-868.5Q663,-880 680,-880Q697,-880 708.5,-868.5Q720,-857 720,-840L720,-800L760,-800Q793,-800 816.5,-776.5Q840,-753 840,-720L840,-160Q840,-127 816.5,-103.5Q793,-80 760,-80L200,-80ZM200,-160L760,-160Q760,-160 760,-160Q760,-160 760,-160L760,-560L200,-560L200,-160Q200,-160 200,-160Q200,-160 200,-160ZM480,-400Q463,-400 451.5,-411.5Q440,-423 440,-440Q440,-457 451.5,-468.5Q463,-480 480,-480Q497,-480 508.5,-468.5Q520,-457 520,-440Q520,-423 508.5,-411.5Q497,-400 480,-400ZM320,-400Q303,-400 291.5,-411.5Q280,-423 280,-440Q280,-457 291.5,-468.5Q303,-480 320,-480Q337,-480 348.5,-468.5Q360,-457 360,-440Q360,-423 348.5,-411.5Q337,-400 320,-400ZM640,-400Q623,-400 611.5,-411.5Q600,-423 600,-440Q600,-457 611.5,-468.5Q623,-480 640,-480Q657,-480 668.5,-468.5Q680,-457 680,-440Q680,-423 668.5,-411.5Q657,-400 640,-400ZM480,-240Q463,-240 451.5,-251.5Q440,-263 440,-280Q440,-297 451.5,-308.5Q463,-320 480,-320Q497,-320 508.5,-308.5Q520,-297 520,-280Q520,-263 508.5,-251.5Q497,-240 480,-240ZM320,-240Q303,-240 291.5,-251.5Q280,-263 280,-280Q280,-297 291.5,-308.5Q303,-320 320,-320Q337,-320 348.5,-308.5Q360,-297 360,-280Q360,-263 348.5,-251.5Q337,-240 320,-240ZM640,-240Q623,-240 611.5,-251.5Q600,-263 600,-280Q600,-297 611.5,-308.5Q623,-320 640,-320Q657,-320 668.5,-308.5Q680,-297 680,-280Q680,-263 668.5,-251.5Q657,-240 640,-240Z" />
</group>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Material icon "event". -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M17,12h-5v5h5v-5zM16,1v2L8,3L8,1L6,1v2L5,3c-1.11,0 -1.99,0.9 -1.99,2L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2h-1L18,1h-2zM19,19L5,19L5,8h14v11z" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Rounded fill for widgets before Android 12, where Glance cannot round corners; tinted in code. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FFFFFFFF" />
<corners android:radius="16dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Rounded fill for widgets before Android 12, where Glance cannot round corners; tinted in code. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FFFFFFFF" />
<corners android:radius="28dp" />
</shape>

View File

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

View File

@@ -1,6 +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>
<color name="surface">#F8F9FF</color>
<color name="primary">#0061A4</color>
<color name="launcher_background">#D1E4FF</color>
</resources>

View File

@@ -4,4 +4,7 @@
<string name="widget_name">Сегодня</string>
<string name="widget_description">Текущая и следующая пара вашей группы</string>
<string name="channel_reminders">Перед парой</string>
<string name="next_widget_name">Следующая пара</string>
<string name="next_widget_description">Когда следующая пара, что это и в какой аудитории</string>
<string name="tile_name">Пара</string>
</resources>

View File

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