feat: Add skeleton loading UI components for various screens

- Implemented `Skeleton.kt` for loading placeholders in the app, including lesson cards, schedule, overview, and subjects.
- Introduced `Undo.kt` to manage undo actions with a snackbar interface.
- Created `WeekWidget.kt` for a widget displaying the week's schedule, including dynamic updates based on the current timetable.
- Added various splash animations in the `animator` directory for a smooth launch experience.
- Designed drawable resources for the widget and splash screen, including icons for city and room moves.
- Defined XML resources for widget configuration and photo paths.
This commit is contained in:
2026-10-23 21:56:07 +03:00
parent a3df53713f
commit b9c1f63301
59 changed files with 194390 additions and 178421 deletions

View File

@@ -59,6 +59,7 @@ dependencies {
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.core.ktx)
implementation(libs.splashscreen)
implementation(libs.coroutines.android)
implementation(libs.serialization.json)
implementation(libs.okhttp)

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/Theme.PsuSchedule.Starting"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -58,6 +59,18 @@
android:resource="@xml/next_widget_info" />
</receiver>
<receiver
android:name=".widget.WeekWidgetReceiver"
android:exported="true"
android:label="@string/week_widget_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/week_widget_info" />
</receiver>
<service
android:name=".tile.LessonTileService"
android:exported="true"
@@ -69,6 +82,17 @@
</intent-filter>
</service>
<!-- The camera app saves a photo for a task here (ui/Photos.kt). -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.photos"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/photo_paths" />
</provider>
<receiver
android:name=".notify.ReminderReceiver"
android:exported="false" />

View File

@@ -33,16 +33,20 @@ sealed interface Launch {
}
}
/** The intent of a target: "day" with its [date], "week", "today", "search" or "task". */
fun intent(context: Context, target: String, date: LocalDate? = null): Intent =
Intent(context, MainActivity::class.java)
.setAction(ACTION)
.putExtra(TARGET, target)
.apply { date?.let { putExtra(DATE, it.toString()) } }
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
/** Opens a day of the timetable, for a notification about it. */
fun day(context: Context, date: LocalDate, request: Int): PendingIntent =
PendingIntent.getActivity(
context,
request,
Intent(context, MainActivity::class.java)
.setAction(ACTION)
.putExtra(TARGET, "day")
.putExtra(DATE, date.toString())
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
intent(context, "day", date),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}

View File

@@ -6,6 +6,11 @@ import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.Orientation
import kotlin.coroutines.cancellation.CancellationException
import androidx.compose.runtime.LaunchedEffect
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import org.xtimms.psu.schedule.ui.UndoArea
import org.xtimms.psu.schedule.ui.LocalMorph
import org.xtimms.psu.schedule.ui.MorphHost
import org.xtimms.psu.schedule.ui.MorphOverlay
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.SeekableTransitionState
@@ -160,6 +165,17 @@ class MainActivity : ComponentActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
// The launch screen stays until the stored settings are read, so a returning student
// never sees an empty frame; then the icon swells and fades into the app.
val splash = installSplashScreen()
splash.setKeepOnScreenCondition { vm.settings.value == null }
splash.setOnExitAnimationListener { provider ->
val icon = provider.iconView
icon.animate().scaleX(1.15f).scaleY(1.15f).alpha(0f).setDuration(250L)
.setInterpolator(android.view.animation.PathInterpolator(0.3f, 0f, 0.8f, 0.15f)).start()
provider.view.animate().alpha(0f).setStartDelay(100L).setDuration(250L)
.withEndAction { provider.remove() }.start()
}
super.onCreate(savedInstanceState)
// A recreated activity (rotation) must not open the same screen again.
if (savedInstanceState == null) launch.value = Launch.of(intent)
@@ -261,6 +277,10 @@ private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunche
val slide = rememberSlideDistance()
// Behind the pushed-aside screen: the menu's own card-white background.
// "Задание удалено · Вернуть" and the like, over every screen (Undo.kt).
val morph = remember { MorphHost() }
UndoArea(Modifier.fillMaxSize()) {
androidx.compose.runtime.CompositionLocalProvider(LocalMorph provides morph) {
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.card)) {
val wide = maxWidth >= MEDIUM_WIDTH
// An open book-style foldable gets the tablet layout; a flip phone keeps the phone one.
@@ -515,6 +535,10 @@ private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunche
}
}
}
// A card's details and the "+" sheet open over everything, growing from what was tapped.
MorphOverlay(morph)
}
}
}
/** What the menu shows and where its items lead. */

View File

@@ -1,6 +1,7 @@
package org.xtimms.psu.schedule
import android.app.Application
import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.data.Api
import org.xtimms.psu.schedule.data.AttendanceStore
import org.xtimms.psu.schedule.data.NotesStore
@@ -30,6 +31,8 @@ class ScheduleApp : Application() {
org.xtimms.psu.schedule.notify.DayAlerts.createChannel(this)
org.xtimms.psu.schedule.notify.LiveLesson.createChannel(this)
SyncWorker.schedule(this)
// Before any editor opens: photos left by an edit that never got saved.
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { notes.sweep() }
}
}

View File

@@ -12,6 +12,10 @@ data class Timetable(
val semester: Semester? = null,
val note: String? = null,
val subgroups: List<String> = emptyList(),
/** What parts of the group choose between: [["Немецкий язык", "Французский язык"]]. */
val choices: List<List<String>> = emptyList(),
/** Some lessons are for foreign students only ([Lesson.audience]), so the student may say which they are. */
val audience: Boolean = false,
val bells: List<Bell> = emptyList(),
val from: String,
val days: List<Day> = emptyList(),
@@ -81,6 +85,10 @@ data class Lesson(
val condition: String = "",
val variant: String = "all",
val subgroup: String? = null,
/** "Немецкий язык": the lesson of the part of the group that took this option (see [Timetable.choices]). */
val option: String? = null,
/** "foreign": for foreign students only (Russian as a foreign language); "local": for the others. */
val audience: String? = null,
val groups: List<String> = emptyList(),
val uncertain: Boolean = false,
val raw: String = "",
@@ -89,6 +97,24 @@ data class Lesson(
/** 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()
/** How a choice's option the student did not take sits among the hidden lessons. */
const val OPTION_PREFIX = "option|"
fun optionKey(option: String): String = OPTION_PREFIX + option.trim().lowercase()
/** How the lessons for foreign students, or for the others, sit among the hidden ones. */
const val AUDIENCE_PREFIX = "audience|"
/**
* Hidden by the student, the lesson of another option than theirs (German when they study
* French), or one for foreign students when they are not (and the other way round).
*/
fun Lesson.hiddenIn(hidden: Set<String>): Boolean =
hideKey in hidden || (option != null && optionKey(option) in hidden) || (audience != null && AUDIENCE_PREFIX + audience in hidden)
/** One choice of a group, the same whatever order the document lists its options in. */
fun choiceKey(options: List<String>): String = options.map { it.trim().lowercase() }.sorted().joinToString("|")
/** A room label with the lesson's town when the document names one: "ауд. 203Г · Полоцк", or the town alone. */
fun withPlace(room: String?, place: String?): String? =
listOfNotNull(room?.takeIf { it.isNotEmpty() }, place).joinToString(" · ").ifEmpty { null }

View File

@@ -1,6 +1,11 @@
package org.xtimms.psu.schedule.data
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.ExifInterface
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -27,13 +32,16 @@ data class Note(
val task: Boolean = true,
val done: Boolean = false,
val created: Long = System.currentTimeMillis(),
/** Photos of the board or the assignment: file names in [NotesStore.photos]. */
val photos: List<String> = emptyList(),
)
fun groupKey(g: SavedGroup) = "${g.docId}|${g.group}"
/** Notes live only on the device, in one small JSON file. */
class NotesStore(context: Context) {
/** Notes live only on the device, in one small JSON file; their photos in a folder beside it. */
class NotesStore(private val context: Context) {
private val file = File(context.filesDir, "notes.json")
val photos = File(context.filesDir, "photos")
private val lock = Mutex()
private val _notes = MutableStateFlow(read())
val notes: StateFlow<List<Note>> = _notes
@@ -50,10 +58,77 @@ class NotesStore(context: Context) {
}
suspend fun save(note: Note) = change { list ->
// Photos taken out of the note go from the disk too.
list.firstOrNull { it.id == note.id }?.let { discard(it.photos - note.photos.toSet()) }
if (list.any { it.id == note.id }) list.map { if (it.id == note.id) note else it } else list + note
}
suspend fun delete(id: String) = change { list -> list.filterNot { it.id == id } }
suspend fun delete(id: String) = change { list ->
list.firstOrNull { it.id == id }?.let { discard(it.photos) }
list.filterNot { it.id == id }
}
/** Takes a note out of the list but keeps its photos, so "Вернуть" can bring it back whole. */
suspend fun remove(id: String): Note? {
var removed: Note? = null
change { list ->
removed = list.firstOrNull { it.id == id }
list.filterNot { it.id == id }
}
return removed
}
fun photo(name: String) = File(photos, name)
/**
* A picked or taken picture, kept as a JPEG of at most [MAX_SIDE] px on its long side and
* turned upright: a phone photo of a board is a few hundred kilobytes, not several megabytes.
* Returns the file name, or null when the picture cannot be read.
*/
suspend fun importPhoto(uri: Uri): String? = withContext(Dispatchers.IO) {
runCatching {
val resolver = context.contentResolver
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= MAX_SIDE) sample *= 2
val decoded = resolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample })
} ?: return@runCatching null
val rotation = resolver.openInputStream(uri)?.use {
when (ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
else -> 0f
}
} ?: 0f
val scale = minOf(1f, MAX_SIDE.toFloat() / maxOf(decoded.width, decoded.height))
val matrix = Matrix().apply { postScale(scale, scale); postRotate(rotation) }
val upright = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
photos.mkdirs()
val name = UUID.randomUUID().toString() + ".jpg"
photo(name).outputStream().use { upright.compress(Bitmap.CompressFormat.JPEG, 85, it) }
name
}.getOrNull()
}
/** Removes photo files: of a deleted note, or added to a note that was never saved. */
fun discard(names: Collection<String>) {
names.forEach { photo(it).delete() }
}
/** Photos no note refers to any more, after a crash mid-edit, for example. */
suspend fun sweep() = withContext(Dispatchers.IO) {
lock.withLock {
val used = _notes.value.flatMap { it.photos }.toSet()
photos.listFiles()?.filter { it.name !in used }?.forEach { it.delete() }
}
}
private companion object {
const val MAX_SIDE = 2048
}
suspend fun toggle(id: String) = change { list -> list.map { if (it.id == id) it.copy(done = !it.done) else it } }
}

View File

@@ -27,6 +27,13 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
private val _changes = MutableStateFlow(readChanges())
val changes: StateFlow<List<ScheduleChanges>> = _changes
// Lessons just added or changed by a republished document: their cards glow once when shown.
private val _fresh = MutableStateFlow<Set<String>>(emptySet())
val fresh: StateFlow<Set<String>> = _fresh
fun freshShown(key: String) {
_fresh.value = _fresh.value - key
}
private fun readChanges(): List<ScheduleChanges> =
runCatching { json.decodeFromString<List<ScheduleChanges>>(changesCache.readText()) }.getOrDefault(emptyList())
@@ -58,6 +65,7 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
changed = changed,
)
writeChanges((listOf(record) + _changes.value.filter { it.document != record.document || it.group != record.group }).take(12))
_fresh.value = (added.map { it.lesson.key } + changed.map { it.after.lesson.key }).toSet()
}
/** The overview's card was opened or closed: the record stays for "Последние изменения". */
@@ -110,7 +118,7 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
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 }
.filter { !it.hiddenIn(hidden) }
fun dayOf(timetable: Timetable, date: LocalDate): Day? =
timetable.days.firstOrNull { it.date == date.toString() }

View File

@@ -58,8 +58,15 @@ data class UserSettings(
val liveLesson: Boolean = false,
val recent: List<SavedGroup> = emptyList(),
val display: Display = Display(),
/** Lessons hidden in the open group, as [Lesson.hideKey]s. */
/**
* Lessons hidden in the open group, as [Lesson.hideKey]s, and the options of its choices the
* student did not take, as [optionKey]s.
*/
val hidden: Set<String> = emptySet(),
/** The open group's choices ([choiceKey]) and the option taken in each: "Французский язык". */
val choices: Map<String, String> = emptyMap(),
/** Whether the student is a foreign student in the open group; null: show every lesson. */
val foreign: Boolean? = null,
val plans: PlansPrefs = PlansPrefs(),
)
@@ -70,6 +77,8 @@ class Settings(private val context: Context) {
private object Keys {
val current = stringPreferencesKey("current")
val subgroups = stringPreferencesKey("subgroups")
val choices = stringPreferencesKey("choices")
val foreign = stringPreferencesKey("foreign")
val theme = stringPreferencesKey("theme")
val dynamic = booleanPreferencesKey("dynamic_color")
val reminders = booleanPreferencesKey("reminders")
@@ -94,6 +103,12 @@ class Settings(private val context: Context) {
val plansDone = booleanPreferencesKey("plans_done")
}
private fun Preferences.choiceMap(): Map<String, Map<String, String>> =
this[Keys.choices]?.let { runCatching { json.decodeFromString<Map<String, Map<String, String>>>(it) }.getOrNull() }.orEmpty()
private fun Preferences.foreignMap(): Map<String, Boolean> =
this[Keys.foreign]?.let { runCatching { json.decodeFromString<Map<String, Boolean>>(it) }.getOrNull() }.orEmpty()
private fun Preferences.hiddenMap(): Map<String, List<String>> =
this[Keys.hidden]?.let { runCatching { json.decodeFromString<Map<String, List<String>>>(it) }.getOrNull() }.orEmpty()
@@ -106,6 +121,14 @@ class Settings(private val context: Context) {
val subgroups = this[Keys.subgroups]?.let {
runCatching { json.decodeFromString<Map<String, String>>(it) }.getOrNull()
}.orEmpty()
val choices = current?.let { choiceMap()[subgroupKey(it)] }.orEmpty()
val foreign = current?.let { foreignMap()[subgroupKey(it)] }
// A foreign student does not see the others' lessons, and the other way round.
val audience = when (foreign) {
true -> setOf(AUDIENCE_PREFIX + "local")
false -> setOf(AUDIENCE_PREFIX + "foreign")
null -> emptySet()
}
return UserSettings(
current = current,
subgroup = current?.let { subgroups[subgroupKey(it)] },
@@ -123,7 +146,9 @@ 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(),
hidden = current?.let { hiddenMap()[subgroupKey(it)] }.orEmpty().toSet() + skipped(choices) + audience,
choices = choices,
foreign = foreign,
plans = PlansPrefs(
layout = this[Keys.plansLayout]?.let { runCatching { PlansLayout.valueOf(it) }.getOrNull() } ?: PlansLayout.Agenda,
lessons = this[Keys.plansLessons] ?: false,
@@ -142,6 +167,10 @@ class Settings(private val context: Context) {
private fun subgroupKey(g: SavedGroup) = "${g.docId}|${g.group}"
/** The options not taken: their lessons are hidden like the ones the student hid. */
private fun skipped(choices: Map<String, String>): Set<String> =
choices.flatMap { (key, taken) -> key.split("|").filter { it != taken.trim().lowercase() }.map(::optionKey) }.toSet()
suspend fun openGroup(group: SavedGroup) = context.store.edit { prefs ->
prefs[Keys.current] = json.encodeToString(group)
val recent = prefs[Keys.recent]?.let {
@@ -169,6 +198,26 @@ class Settings(private val context: Context) {
prefs[Keys.subgroups] = json.encodeToString(map.toMap())
}
/** The option the student takes in a choice of the open group ([option] null: all of them). */
suspend fun setChoice(options: List<String>, option: String?) = context.store.edit { prefs ->
val current = prefs[Keys.current]?.let { runCatching { json.decodeFromString<SavedGroup>(it) }.getOrNull() }
?: return@edit
val all = prefs.choiceMap().toMutableMap()
val mine = all[subgroupKey(current)].orEmpty().toMutableMap()
if (option == null) mine.remove(choiceKey(options)) else mine[choiceKey(options)] = option
all[subgroupKey(current)] = mine
prefs[Keys.choices] = json.encodeToString(all.toMap())
}
/** Whether the student is a foreign student in the open group; null forgets the answer. */
suspend fun setForeign(foreign: Boolean?) = context.store.edit { prefs ->
val current = prefs[Keys.current]?.let { runCatching { json.decodeFromString<SavedGroup>(it) }.getOrNull() }
?: return@edit
val map = prefs.foreignMap().toMutableMap()
if (foreign == null) map.remove(subgroupKey(current)) else map[subgroupKey(current)] = foreign
prefs[Keys.foreign] = json.encodeToString(map.toMap())
}
suspend fun setTheme(mode: ThemeMode) = context.store.edit { it[Keys.theme] = mode.name }
suspend fun setDynamicColor(on: Boolean) = context.store.edit { it[Keys.dynamic] = on }
suspend fun setReminders(on: Boolean) = context.store.edit { it[Keys.reminders] = on }

View File

@@ -1,6 +1,6 @@
package org.xtimms.psu.schedule.notify
import org.xtimms.psu.schedule.data.hideKey
import org.xtimms.psu.schedule.data.hiddenIn
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.data.forSubgroup
import android.Manifest
@@ -79,7 +79,7 @@ object Reminders {
val date = parseDate(day.date)
val shown = day.lessons
.filter { settings.subgroup == null || it.subgroup == null || it.subgroup == settings.subgroup }
.filter { it.hideKey !in settings.hidden }
.filter { !it.hiddenIn(settings.hidden) }
// A day in another town: the first lesson is announced earlier, for the road.
val away = settings.travelAlerts && day.places.forSubgroup(settings.subgroup).isNotEmpty()
val first = shown.minOfOrNull { it.start }

View File

@@ -2,6 +2,8 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -83,6 +85,7 @@ fun LessonAttendance(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
@Composable
fun AttendanceCard(vm: MainViewModel, slots: List<Slot>) {
val marks by vm.marks.collectAsStateWithLifecycle()
val haptic = LocalHapticFeedback.current
if (vm.currentGroupKey() == null) return
val now = nowTime()
val date = today()
@@ -103,11 +106,17 @@ fun AttendanceCard(vm: MainViewModel, slots: List<Slot>) {
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) }) {
FilledTonalIconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.Confirm)
vm.mark(lesson, date, true)
}) {
Icon(Icons.Rounded.Check, contentDescription = "Был на паре «${lesson.subject}»")
}
FilledTonalIconButton(
onClick = { vm.mark(lesson, date, false) },
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.Confirm)
vm.mark(lesson, date, false)
},
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,

View File

@@ -123,13 +123,14 @@ private fun weekRow(month: YearMonth, d: LocalDate) = mondays(month).indexOf(d.m
*/
@Composable
fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
val undo = LocalUndo.current
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) }
val openLesson = rememberLessonOpener(vm)
var editing by remember { mutableStateOf<Note?>(null) }
var adding by remember { mutableStateOf(false) }
val morph = LocalMorph.current
var target by remember { mutableStateOf<NoteTarget?>(null) }
val base = remember { YearMonth.from(today()).minusMonths(MONTHS_BEFORE.toLong()) }
val pager = rememberPagerState(initialPage = MONTHS_BEFORE) { MONTHS }
@@ -254,7 +255,7 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
val agenda: @Composable (Modifier) -> Unit = { modifier ->
DayAgenda(
vm, t, subgroup, selected, notes,
onLesson = { details = it to selected },
onLesson = { openLesson(it, selected) },
onNote = { editing = it },
modifier = modifier,
)
@@ -304,15 +305,23 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
}
}
}
// "+" grows into the sheet of a new plan for the chosen day (Morph.kt).
FloatingActionButton(
onClick = { adding = true },
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp + navBarPadding()),
onClick = {
morph?.open(MorphKind.Sheet) { close ->
AddPlanSheet(vm, t, subgroup, day = selected) {
close()
target = it
}
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp + navBarPadding())
.morphOrigin("fab|calendar", MaterialTheme.colorScheme.primaryContainer, 16.dp),
) { 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(
@@ -325,18 +334,13 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
)
}
}
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 },
onDelete = { vm.deleteNote(it.id, undo); editing = null },
onDismiss = { editing = null },
)
}
@@ -504,11 +508,22 @@ private fun DayAgendaContent(
item(key = "plans") { AnimatedItem { Section(number, "Ваши планы", trailing = date.format(DATE)) } }
plans.forEachIndexed { i, note ->
item(key = note.id) { AnimatedItem {
// Struck through in place first, then the list moves it down.
var shownDone by remember(note.id, note.done) { mutableStateOf(note.done) }
val scope = rememberCoroutineScope()
TimelineItem(
marker = { PlanMarker(note, onToggle = { vm.toggleNote(note.id) }) },
marker = {
PlanMarker(note.copy(done = shownDone), onToggle = {
shownDone = !shownDone
if (shownDone) scope.launch {
kotlinx.coroutines.delay(DONE_PAUSE)
vm.toggleNote(note.id)
} else vm.toggleNote(note.id)
})
},
over = note.subject.ifBlank { null },
title = note.text,
done = note.task && note.done,
done = note.task && shownDone,
last = i == plans.lastIndex,
onClick = { onNote(note) },
)
@@ -540,6 +555,7 @@ private fun DayAgendaContent(
).joinToString(" · ").ifEmpty { null },
last = i == lessons.lastIndex,
onClick = { onLesson(l) },
origin = Modifier.morphOrigin("cal|$date|${l.key}|${l.start}", MaterialTheme.colorScheme.surfaceContainerLow, 16.dp),
)
}
}
@@ -593,6 +609,8 @@ private fun TimelineItem(
overColor: Color? = null,
last: Boolean,
onClick: () -> Unit,
/** For a lesson: where its details page grows from. */
origin: Modifier = Modifier,
) {
val line = MaterialTheme.colorScheme.outlineVariant
// Level with the first line of the title: 14dp padding, a 20dp line above it, a 24dp title line.
@@ -601,6 +619,7 @@ private fun TimelineItem(
Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.then(origin)
.clip(MaterialTheme.shapes.large)
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
) {
@@ -626,14 +645,7 @@ private fun TimelineItem(
over?.let {
TimeText(it, style = MaterialTheme.typography.bodyMedium, color = overColor ?: 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,
)
StrikeText(title, done = done, style = MaterialTheme.typography.titleMedium, maxLines = 2)
under?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) }
}
}

View File

@@ -2,6 +2,8 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalConfiguration
@@ -21,6 +23,26 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.HorizontalDivider
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.border
import androidx.compose.ui.graphics.lerp
@@ -164,6 +186,7 @@ fun <T> ConnectedChoice(
/** The check before the chosen label; times like "20:00" need the room, the fill marks it. */
mark: Boolean = true,
) {
val haptic = LocalHapticFeedback.current
Row(
modifier.selectableGroup(),
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
@@ -172,7 +195,10 @@ fun <T> ConnectedChoice(
val checked = value == selected
ToggleButton(
checked = checked,
onCheckedChange = { onSelect(value) },
onCheckedChange = {
if (!checked) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
onSelect(value)
},
modifier = Modifier.weight(1f).semantics { role = Role.RadioButton },
// Unselected segments need their own tonal fill to read as one group.
colors = ToggleButtonDefaults.colors(
@@ -357,6 +383,10 @@ fun weekTintOf(week: Week?): Color {
* Seven days of a week in School Planner's calm style: the weekday, the date in a circle,
* a filled circle for the selected day and primary text for today. Swiping between weeks
* is the caller's pager; TalkBack gets the same moves as custom actions.
*
* The filled circle is one shape under the whole row: picking another day, it flows there on
* springs, its leading edge ahead of the trailing one. With [marks], a dot in a day's circle
* says it has lessons; the dots of a week pop in one day after another when it is picked.
*/
@Composable
fun DayStrip(
@@ -366,8 +396,38 @@ fun DayStrip(
enabled: (LocalDate) -> Boolean = { true },
onSelect: (LocalDate) -> Unit,
onWeek: ((Int) -> Unit)? = null,
marks: Boolean = false,
modifier: Modifier = Modifier,
) {
val haptic = LocalHapticFeedback.current
val scheme = MaterialTheme.colorScheme
val reduced = rememberReducedMotion()
val index = selected?.let { ChronoUnit.DAYS.between(monday, it).toInt() }?.takeIf { it in 0..6 }
// The indicator's edges, in days from Monday.
val left = remember { Animatable(index?.toFloat() ?: 0f) }
val right = remember { Animatable(index?.toFloat() ?: 0f) }
// False until the indicator stands in this week: a week just picked gets it in place, not sliding in.
var placed by remember { mutableStateOf(index != null) }
LaunchedEffect(index) {
if (index == null) {
placed = false
return@LaunchedEffect
}
val target = index.toFloat()
if (!placed || reduced) {
left.snapTo(target)
right.snapTo(target)
placed = true
return@LaunchedEffect
}
val forward = target > left.value
val lead = spring<Float>(dampingRatio = 0.8f, stiffness = Spring.StiffnessMedium)
val trail = spring<Float>(dampingRatio = 0.8f, stiffness = Spring.StiffnessMediumLow)
launch { (if (forward) right else left).animateTo(target, lead) }
(if (forward) left else right).animateTo(target, trail)
}
var rowTop by remember { mutableFloatStateOf(0f) }
var circleTop by remember { mutableFloatStateOf(-1f) }
Row(
modifier
.fillMaxWidth()
@@ -380,14 +440,48 @@ fun DayStrip(
CustomAccessibilityAction("Следующая неделя") { onWeek(1); true },
)
},
),
)
.onGloballyPositioned { rowTop = it.positionInRoot().y }
.drawBehind {
if (index == null || circleTop < 0f) return@drawBehind
val cell = size.width / 7
val d = 40.dp.toPx()
val from = if (placed) left.value else index.toFloat()
val to = if (placed) right.value else index.toFloat()
drawRoundRect(
scheme.primary,
topLeft = Offset(from * cell + (cell - d) / 2, circleTop - rowTop),
size = Size((to - from) * cell + d, d),
cornerRadius = CornerRadius(d / 2),
)
},
) {
repeat(7) { i ->
val date = monday.plusDays(i.toLong())
val isSelected = date == selected
val isToday = date == today()
val busy = hasLessons(date)
val scheme = MaterialTheme.colorScheme
val ink by animateColorAsState(
when {
isSelected -> scheme.onPrimary
isToday -> scheme.primary
busy -> scheme.onSurface
else -> scheme.onSurfaceVariant
},
if (reduced) snap() else tween(200),
label = "day-ink",
)
// A week's dots wait until it is the one picked, then pop in from Monday on.
val dot = remember(monday) { Animatable(0f) }
val dotShown = marks && busy && (selected == null || index != null)
LaunchedEffect(monday, dotShown) {
if (!dotShown) dot.snapTo(0f)
else if (reduced || dot.value > 0f) dot.snapTo(1f)
else {
delay(60L + 35L * i)
dot.animateTo(1f, spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium))
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
@@ -399,7 +493,10 @@ fun DayStrip(
role = Role.Tab,
interactionSource = null,
indication = null,
onClick = { onSelect(date) },
onClick = {
if (!isSelected) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
onSelect(date)
},
)
.padding(vertical = 6.dp)
.semantics(mergeDescendants = true) {
@@ -417,17 +514,16 @@ fun DayStrip(
modifier = Modifier
.padding(top = 4.dp)
.size(40.dp)
.background(if (isSelected) scheme.primary else Color.Transparent, CircleShape),
.then(if (i == 0) Modifier.onGloballyPositioned { circleTop = it.positionInRoot().y } else Modifier),
) {
Text(
"${date.dayOfMonth}",
style = MaterialTheme.typography.titleMediumEmphasized,
color = when {
isSelected -> scheme.onPrimary
isToday -> scheme.primary
busy -> scheme.onSurface
else -> scheme.onSurfaceVariant
},
Text("${date.dayOfMonth}", style = MaterialTheme.typography.titleMediumEmphasized, color = ink)
if (marks) Box(
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 4.dp)
.size(4.dp)
.graphicsLayer { scaleX = dot.value; scaleY = dot.value }
.background(ink, CircleShape),
)
}
}

View File

@@ -0,0 +1,119 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
// Finishing a task: its text is struck through from left to right, line after line, and once the
// last open task of the plans is done a small check pops up by "Всё сделано".
/** A task's text, struck through as it is done; the line draws itself instead of appearing. */
@Composable
fun StrikeText(text: String, done: Boolean, style: TextStyle, maxLines: Int, modifier: Modifier = Modifier) {
val reduced = rememberReducedMotion()
val strike by animateFloatAsState(if (done) 1f else 0f, if (reduced) snap() else tween(350, easing = EmphasizedDecelerate), label = "strike")
val color by animateColorAsState(
if (done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
tween(350),
label = "done-color",
)
var layout by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text,
style = style,
color = color,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
onTextLayout = { layout = it },
modifier = modifier.drawWithContent {
drawContent()
val l = layout ?: return@drawWithContent
if (strike <= 0f) return@drawWithContent
// One run across all the lines: the first line is struck through, then the next.
val widths = (0 until l.lineCount).map { l.getLineRight(it) - l.getLineLeft(it) }
var left = widths.sum() * strike
for (i in 0 until l.lineCount) {
if (left <= 0f) break
val w = minOf(widths[i], left)
val y = (l.getLineTop(i) + l.getLineBottom(i)) / 2
drawLine(color, Offset(l.getLineLeft(i), y), Offset(l.getLineLeft(i) + w, y), strokeWidth = 1.5.dp.toPx())
left -= w
}
},
)
}
/** How long a ticked task stays in place, struck through, before it moves down to the done ones. */
const val DONE_PAUSE = 420L
/**
* A check in a circle that pops up (a springy scale, M3 Expressive) and draws its tick when
* [visible] turns true; already true when shown, it just sits there.
*/
@Composable
fun DoneMark(visible: Boolean, modifier: Modifier = Modifier) {
val reduced = rememberReducedMotion()
val first = remember { visible }
val scale = remember { Animatable(if (first) 1f else 0f) }
val tick = remember { Animatable(if (first) 1f else 0f) }
LaunchedEffect(visible) {
if (!visible) {
scale.snapTo(0f)
tick.snapTo(0f)
return@LaunchedEffect
}
if (reduced) {
scale.snapTo(1f)
tick.snapTo(1f)
return@LaunchedEffect
}
launch { scale.animateTo(1f, spring(dampingRatio = 0.45f, stiffness = Spring.StiffnessMediumLow)) }
delay(120)
tick.animateTo(1f, tween(300, easing = EmphasizedDecelerate))
}
if (!visible && scale.value == 0f) return
val fill = MaterialTheme.colorScheme.primary
val ink = MaterialTheme.colorScheme.onPrimary
Canvas(modifier.padding(end = 6.dp).size(16.dp).graphicsLayer { scaleX = scale.value; scaleY = scale.value }) {
drawCircle(fill)
val path = Path().apply {
moveTo(size.width * 0.28f, size.height * 0.52f)
lineTo(size.width * 0.44f, size.height * 0.68f)
lineTo(size.width * 0.74f, size.height * 0.36f)
}
val measure = PathMeasure().apply { setPath(path, false) }
val drawn = Path()
measure.getSegment(0f, measure.length * tick.value, drawn, true)
drawPath(drawn, ink, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
}
}

View File

@@ -56,6 +56,7 @@ import org.xtimms.psu.schedule.data.GroupResult
import org.xtimms.psu.schedule.data.attempt
import org.xtimms.psu.schedule.data.GroupsResponse
import org.xtimms.psu.schedule.data.SavedGroup
import org.xtimms.psu.schedule.data.choiceKey
private val PLAIN_PERIOD = Regex("^(осенний|весенний) семестр$|^расписание из word$", RegexOption.IGNORE_CASE)
@@ -97,15 +98,25 @@ fun GroupsScreen(
}
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.
// Before going back to the overview, a group with subgroups asks which one is yours, and a
// group whose parts study German or French which you study, unless you chose it already.
var askSubgroup by remember { mutableStateOf<List<String>?>(null) }
var askChoices by remember { mutableStateOf<List<List<String>>>(emptyList()) }
var askForeign by remember { mutableStateOf(false) }
// After the subgroup, the choices and then whether you are a foreign student; then the overview.
fun next() {
if (askSubgroup == null && askChoices.isEmpty() && !askForeign) onOpened()
}
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 && !onboarding) askSubgroup = subgroups else onOpened()
val same = s?.current?.group == t?.group
if (onboarding) return onOpened()
askChoices = t?.choices.orEmpty().filterNot { same && s?.choices?.containsKey(choiceKey(it)) == true }
askForeign = t?.audience == true && !(same && s?.foreign != null)
if (subgroups.isNotEmpty() && !(same && s?.subgroup != null)) askSubgroup = subgroups
next()
}
fun open(result: GroupResult) {
@@ -169,6 +180,12 @@ fun GroupsScreen(
subgroups = timetable?.subgroups.orEmpty(),
subgroup = settings?.subgroup,
onSubgroup = vm::setSubgroup,
choices = timetable?.choices.orEmpty(),
taken = settings?.choices.orEmpty(),
onChoice = vm::setChoice,
audience = timetable?.audience == true,
foreign = settings?.foreign,
onForeign = vm::setForeign,
)
}
val recent = settings?.recent.orEmpty().filterNot { it.docId == current?.docId && it.group == current?.group }
@@ -233,9 +250,7 @@ fun GroupsScreen(
}
}
}
if (data == null && error == null) item {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() }
}
if (data == null && error == null) item { RowsSkeleton() }
}
}
}
@@ -244,7 +259,32 @@ fun GroupsScreen(
SubgroupDialog(
group = vm.timetable.value?.group.orEmpty(),
subgroups = subgroups,
onPick = { vm.setSubgroup(it); askSubgroup = null; onOpened() },
onPick = {
vm.setSubgroup(it)
askSubgroup = null
next()
},
)
}
if (askSubgroup == null) askChoices.firstOrNull()?.let { options ->
ChoiceDialog(
group = vm.timetable.value?.group.orEmpty(),
options = options,
onPick = {
vm.setChoice(options, it)
askChoices = askChoices.drop(1)
next()
},
)
}
if (askSubgroup == null && askChoices.isEmpty() && askForeign) {
ForeignDialog(
group = vm.timetable.value?.group.orEmpty(),
onPick = {
vm.setForeign(it)
askForeign = false
next()
},
)
}
}
@@ -270,7 +310,18 @@ private fun GroupRows(results: List<GroupResult>, opening: String?, onOpen: (Gro
}
@Composable
private fun CurrentGroup(current: SavedGroup, subgroups: List<String>, subgroup: String?, onSubgroup: (String?) -> Unit) {
private fun CurrentGroup(
current: SavedGroup,
subgroups: List<String>,
subgroup: String?,
onSubgroup: (String?) -> Unit,
choices: List<List<String>>,
taken: Map<String, String>,
onChoice: (List<String>, String?) -> Unit,
audience: Boolean,
foreign: Boolean?,
onForeign: (Boolean?) -> Unit,
) {
ListCard(Icons.Rounded.CheckCircle, "Сейчас открыта") {
CardRow(
avatar = avatarLabel(current.group),
@@ -291,9 +342,113 @@ private fun CurrentGroup(current: SavedGroup, subgroups: List<String>, subgroup:
modifier = Modifier.fillMaxWidth(),
)
}
// Long names ("Стилистика русского языка. Культура речи") wrap as chips instead of a button row.
choices.forEach { options ->
val mine = taken[choiceKey(options)]
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 12.dp),
) {
Text("Что вы изучаете", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
(listOf<String?>(null) + options).forEach { option ->
val on = mine == option
FilterChip(
selected = on,
onClick = { onChoice(options, option) },
label = { Text(option ?: "Всё") },
leadingIcon = if (on) ({ Icon(Icons.Rounded.Check, null, Modifier.size(FilterChipDefaults.IconSize)) }) else null,
)
}
}
}
}
if (audience) Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 12.dp),
) {
Text("Иностранный студент", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
ConnectedChoice(
options = listOf<Pair<Boolean?, String>>(null to "Всё", true to "Да", false to "Нет"),
selected = foreign,
onSelect = onForeign,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
internal fun foreignText(group: String): String =
"В группе $group часть пар только для иностранных студентов («Русский язык как иностранный»), а часть — только для остальных. " +
"Ответьте, и чужие пары скроются из расписания, виджета и напоминаний. Изменить можно в разделе «Группы»."
/** "Вы иностранный студент?": the answer is remembered for this group. */
@Composable
private fun ForeignDialog(group: String, onPick: (Boolean?) -> Unit) {
AlertDialog(
onDismissRequest = { onPick(null) },
icon = { Icon(Icons.Rounded.Groups, contentDescription = null) },
title = { Text("Вы иностранный студент?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(foreignText(group), style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
listOf<Pair<Boolean?, String>>(true to "Да, иностранный", false to "Нет", null to "Показывать всё").forEach { (value, label) ->
Surface(
onClick = { onPick(value) },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
Text(label, style = MaterialTheme.typography.titleSmallEmphasized, modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp))
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = { onPick(null) }) { Text("Позже") } },
)
}
/** What a part of the group takes, in a sentence: "«Немецкий язык» или «Французский язык»". */
internal fun choiceText(group: String, options: List<String>): String =
"В группе $group часть пар идёт на выбор: ${options.joinToString(" или ") { "«$it»" }}. Выберите, что изучаете вы, — " +
"пары другого варианта скроются из расписания, виджета и напоминаний. Изменить можно в разделе «Группы»."
/** "Что вы изучаете": German or French, say; the choice is remembered for this group. */
@Composable
private fun ChoiceDialog(group: String, options: 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(choiceText(group, options), style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
(options + listOf<String?>(null)).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("Позже") } },
)
}
/** "Ваша подгруппа": all of them, or one; the choice is remembered for this group. */
@Composable
private fun SubgroupDialog(group: String, subgroups: List<String>, onPick: (String?) -> Unit) {

View File

@@ -62,7 +62,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
@@ -127,10 +126,11 @@ fun rememberClock(): LocalTime {
@Composable
fun HomeScreen(vm: MainViewModel, nav: AppNav) {
val undo = LocalUndo.current
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle()
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
val openLesson = rememberLessonOpener(vm)
var editing by remember { mutableStateOf<Note?>(null) }
var session by remember { mutableStateOf(false) }
val changes by vm.changes.collectAsStateWithLifecycle()
@@ -180,7 +180,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null, action = if (status.needsGroup) "Выбрать группу" to nav.openGroups else null) }
val t = timetable
when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
t == null && status.loading -> OverviewSkeleton()
t == null -> CenteredEmpty {
Empty(
"Расписание не загружено",
@@ -196,8 +196,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
Empty("Обзор недоступен", "Строки Word по дням недели — во вкладке «Расписание».", icon = Icons.Rounded.Description, action = "Открыть" to { nav.openDay(date) })
}
}
else -> BoxWithConstraints(Modifier.fillMaxSize()) {
// Pull down for the latest timetable, as on the schedule.
else -> ExpressiveRefresh(refreshing = status.loading, onRefresh = { vm.refresh() }) { BoxWithConstraints(Modifier.fillMaxSize()) {
val twoPane = twoPane(maxWidth)
val intro = remember { vm.takeIntro() }
val overview: @Composable (Modifier) -> Unit = { listModifier ->
val todaySlots = vm.slotsOn(t, date, subgroup)
val next = (1..7).asSequence()
@@ -225,10 +227,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
}
}
}
item(key = "week") { AnimatedItem { WeekReport(vm, t, subgroup, date, clock) } }
item(key = "session") { AnimatedItem { SessionCard(vm, t, onOpen = { session = true }) } }
item(key = "today-title") { AnimatedItem { DayTitle("Сегодня", date) } }
item(key = "today") { AnimatedItem { TodayCard(todaySlots, vm.placesOn(t, date, subgroup), clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) } }
item(key = "week") { AnimatedItem { Entrance(0, intro) { WeekReport(vm, t, subgroup, date, clock) } } }
item(key = "session") { AnimatedItem { Entrance(1, intro) { SessionCard(vm, t, onOpen = { session = true }) } } }
item(key = "today-title") { AnimatedItem { Entrance(2, intro) { DayTitle("Сегодня", date) } } }
item(key = "today") { AnimatedItem { Entrance(3, intro) { TodayCard(todaySlots, vm.placesOn(t, date, subgroup), clock, onOpen = { openLesson(it, date) }, onDay = { nav.openDay(date) }) } } }
item(key = "attendance") { AnimatedItem { AttendanceCard(vm, todaySlots) } }
item(key = "tasks") { AnimatedItem { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } }
if (next != null) {
@@ -237,7 +239,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
item(key = "next") {
SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) {
DayPlaces(vm.placesOn(t, day, subgroup))
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it to day }) } }
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { openLesson(it, day) }) } }
DayButton { nav.openDay(day) }
}
}
@@ -248,10 +250,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
// a foldable half opened like a book puts one on each side of the fold.
if (twoPane) PaneRow(Modifier.fillMaxSize().padding(end = 16.dp), firstWidth = 440.dp, gap = 0.dp, first = { overview(it) }) {
DetailPane(it) {
WeekPane(vm, t, subgroup, onOpen = { lesson, day -> details = lesson to day })
WeekPane(vm, t, subgroup, onOpen = { lesson, day -> openLesson(lesson, day) })
}
} else ContentColumn { overview(Modifier.fillMaxSize()) }
}
} }
}
}
if (changesOpen) changes?.let { record ->
@@ -267,13 +269,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
editing = note,
groupKey = note.group,
onSave = { vm.saveNote(it); editing = null },
onDelete = { vm.deleteNote(it.id); editing = null },
onDelete = { vm.deleteNote(it.id, undo); editing = null },
onDismiss = { editing = null },
)
}
details?.let { lesson ->
ModalBottomSheet(onDismissRequest = { details = null }) { LessonDetails(lesson.first, timetable?.bells.orEmpty(), lesson.second, vm, onClose = { details = null }) }
}
}
/** Shortcuts under the app bar, as in School Planner's overview. */
@@ -520,6 +519,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
Row(
Modifier
.fillMaxWidth()
.morphOrigin("featured|${slot.date}|${lesson.key}|${lesson.start}", MaterialTheme.colorScheme.card, 12.dp)
.clip(MaterialTheme.shapes.medium)
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
) {
@@ -537,7 +537,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
color = if (running) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondaryContainer,
contentColor = if (running) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer,
) {
Text(
RollingText(
if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
@@ -560,7 +560,10 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
Modifier
.fillMaxWidth()
.then(
if (onClick != null) Modifier.clip(MaterialTheme.shapes.medium).clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick)
if (onClick != null) Modifier
.morphOrigin("compact|${slot.date}|${lesson.key}|${lesson.start}", MaterialTheme.colorScheme.card, 12.dp)
.clip(MaterialTheme.shapes.medium)
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick)
else Modifier,
),
verticalAlignment = Alignment.CenterVertically,

View File

@@ -74,7 +74,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
fun saveNote(note: Note) = viewModelScope.launch { app.notes.save(note) }
fun deleteNote(id: String) = viewModelScope.launch { app.notes.delete(id) }
/** Deletes at once and offers it back in [undo]; the photos go only when the offer is gone. */
fun deleteNote(id: String, undo: UndoHost? = null) = viewModelScope.launch {
val note = app.notes.remove(id) ?: return@launch
if (undo == null) {
app.notes.discard(note.photos)
return@launch
}
undo.offer(
if (note.task) "Задание удалено" else "Заметка удалена",
onUndo = { viewModelScope.launch { app.notes.save(note) } },
onGone = { app.notes.discard(note.photos) },
)
}
fun toggleNote(id: String) = viewModelScope.launch { app.notes.toggle(id) }
/** The key new notes are filed under, or null before a group is chosen. */
@@ -148,6 +160,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
SyncWorker.afterChange(getApplication())
}
fun setChoice(options: List<String>, option: String?) = viewModelScope.launch {
app.settings.setChoice(options, option)
SyncWorker.afterChange(getApplication())
}
fun setForeign(foreign: Boolean?) = viewModelScope.launch {
app.settings.setForeign(foreign)
SyncWorker.afterChange(getApplication())
}
fun setTheme(mode: ThemeMode) = viewModelScope.launch { app.settings.setTheme(mode) }
fun setDynamicColor(on: Boolean) = viewModelScope.launch { app.settings.setDynamicColor(on) }
fun setDisplay(display: Display) = viewModelScope.launch { app.settings.setDisplay(display) }
@@ -166,6 +188,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun setChangeAlerts(on: Boolean) = viewModelScope.launch { app.settings.setChangeAlerts(on) }
/** The overview's cards rise in once per launch, after the launch screen, not on every return. */
private var introShown = false
fun takeIntro(): Boolean = !introShown.also { introShown = true }
/** Lessons a republished document just added or changed; each card glows once. */
val fresh: StateFlow<Set<String>> = app.repository.fresh
fun freshShown(key: String) = app.repository.freshShown(key)
/** "Новое задание" from the launcher: the plans open with the add sheet. */
val newTask = kotlinx.coroutines.flow.MutableStateFlow(false)

View File

@@ -0,0 +1,261 @@
package org.xtimms.psu.schedule.ui
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
// The M3 container transform: a card grows into its details, the "+" button into the sheet of a
// new plan, and back. Bottom sheets live in windows of their own and cannot grow from a card, so
// these open in one overlay over the app (MorphOverlay) instead.
/** Where a transform starts: an element's bounds, its fill and corners and a picture of it. */
class MorphOrigin(val bounds: Rect, val color: Color, val radius: Dp, val picture: GraphicsLayer)
enum class MorphKind {
/** A page: the whole screen on a phone, a large panel on a tablet. */
Page,
/** A sheet from the bottom, with a handle to drag it away. */
Sheet,
}
class MorphEntry(val key: String?, val kind: MorphKind, val content: @Composable (close: () -> Unit) -> Unit)
@Stable
class MorphHost {
internal val origins = mutableMapOf<String, MorphOrigin>()
// The element the finger went down on, and when: the page grows from what was tapped.
internal var touched: String? = null
internal var touchedAt = 0L
var entry by mutableStateOf<MorphEntry?>(null)
internal set
internal var closing by mutableStateOf(false)
internal val progress = Animatable(0f)
/** Opens [content] growing out of the element tapped a moment ago, if any. */
fun open(kind: MorphKind, content: @Composable (close: () -> Unit) -> Unit) {
val key = touched?.takeIf { System.currentTimeMillis() - touchedAt < 1500 }
closing = false
entry = MorphEntry(key, kind, content)
}
fun close() {
if (entry != null) closing = true
}
}
val LocalMorph = staticCompositionLocalOf<MorphHost?> { null }
/**
* Marks a card or a button as a place a page or a sheet grows from. While its page is open the
* element itself is hidden: the page has taken its place.
*/
@Composable
fun Modifier.morphOrigin(key: String, color: Color, radius: Dp): Modifier {
val host = LocalMorph.current ?: return this
val picture = rememberGraphicsLayer()
DisposableEffect(host, key) { onDispose { host.origins.remove(key) } }
val hidden = host.entry?.key == key
return this
.onGloballyPositioned { host.origins[key] = MorphOrigin(it.boundsInRoot(), color, radius, picture) }
.pointerInput(host, key) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
host.touched = key
host.touchedAt = System.currentTimeMillis()
}
}
.drawWithContent {
picture.record { this@drawWithContent.drawContent() }
if (!hidden) drawLayer(picture)
}
}
/**
* The open page or sheet over the app. Opening, it grows from its origin (or from the middle):
* the origin's picture fades out over the first third, the content fades in after it. Back,
* a tap on the scrim or the predictive back gesture shrink it back the same way.
*/
@Composable
fun MorphOverlay(host: MorphHost) {
val entry = host.entry ?: return
val reduced = rememberReducedMotion()
val scope = rememberCoroutineScope()
var back by remember { mutableFloatStateOf(0f) }
var drag by remember { mutableFloatStateOf(0f) }
var rootOffset by remember { mutableStateOf(Offset.Zero) }
LaunchedEffect(entry) {
back = 0f
drag = 0f
host.progress.snapTo(0f)
host.progress.animateTo(1f, tween(if (reduced) 0 else 450, easing = Emphasized))
}
LaunchedEffect(host.closing) {
if (!host.closing) return@LaunchedEffect
host.progress.animateTo(0f, tween(if (reduced) 0 else 350, easing = Emphasized))
host.entry = null
host.closing = false
}
PredictiveBackHandler(enabled = !host.closing) { events ->
try {
events.collect { back = it.progress }
host.close()
} catch (e: CancellationException) {
scope.launch { back = 0f }
throw e
}
}
val density = LocalDensity.current
val surface = MaterialTheme.colorScheme.surfaceContainerLow
BoxWithConstraints(Modifier.fillMaxSize().onGloballyPositioned { rootOffset = it.positionInRoot() }) {
val w = constraints.maxWidth.toFloat()
val h = constraints.maxHeight.toFloat()
val wide = maxWidth >= 600.dp
val margin = with(density) { 24.dp.toPx() }
val target = when (entry.kind) {
MorphKind.Page -> if (wide) {
val pw = minOf(w - 2 * margin, with(density) { 680.dp.toPx() })
Rect(Offset((w - pw) / 2, margin), androidx.compose.ui.geometry.Size(pw, h - 2 * margin))
} else Rect(0f, 0f, w, h)
MorphKind.Sheet -> {
val sw = minOf(w, with(density) { 640.dp.toPx() })
val sh = h * 0.82f
Rect(Offset((w - sw) / 2, h - sh), androidx.compose.ui.geometry.Size(sw, sh))
}
}
val origin = entry.key?.let { host.origins[it] }
val from = origin?.bounds?.translate(-rootOffset) ?: Rect(target.center, 0f).inflate(minOf(target.width, target.height) * 0.3f)
val p = host.progress.value
val rect = lerp(from, target, p)
val targetRadius = when {
entry.kind == MorphKind.Sheet -> 28.dp
wide -> 28.dp
else -> 0.dp
}
val radius = lerp(origin?.radius ?: 28.dp, targetRadius, p)
val shape = if (entry.kind == MorphKind.Sheet) RoundedCornerShape(radius, radius, lerp(origin?.radius ?: 28.dp, 0.dp, p), lerp(origin?.radius ?: 28.dp, 0.dp, p))
else RoundedCornerShape(radius)
// The scrim; a tap on it closes.
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.32f * p))
.pointerInput(host) { detectTapGestures { host.close() } },
)
Layout(
content = {
// The content, laid out once at its final size, revealed by the growing bounds.
Box(Modifier.graphicsLayer { alpha = ((p - 0.3f) / 0.7f).coerceIn(0f, 1f) }) {
if (entry.kind == MorphKind.Sheet) SheetChrome(onDrag = { drag = (drag + it).coerceAtLeast(0f) }, onRelease = {
if (drag > with(density) { 120.dp.toPx() }) host.close() else drag = 0f
}) { entry.content(host::close) }
else entry.content(host::close)
}
// The origin's own picture, fading out as the page takes over.
if (origin != null) Box(
Modifier
.graphicsLayer { alpha = (1f - p / 0.3f).coerceIn(0f, 1f) }
.drawBehind {
val s = rect.width / origin.bounds.width.coerceAtLeast(1f)
scale(s, s, pivot = Offset.Zero) { drawLayer(origin.picture) }
},
)
},
modifier = Modifier
.offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) }
.graphicsLayer {
val s = 1f - 0.08f * back
scaleX = s
scaleY = s
translationY = drag
}
.clip(shape)
.background(lerp(origin?.color ?: surface, surface, (p / 0.5f).coerceIn(0f, 1f)))
// Taps inside the page never reach the scrim under it.
.pointerInput(Unit) { detectTapGestures { } },
) { measurables, _ ->
val content = measurables[0].measure(Constraints.fixed(target.width.roundToInt(), target.height.roundToInt()))
val picture = measurables.getOrNull(1)?.measure(Constraints.fixed(rect.width.roundToInt().coerceAtLeast(0), rect.height.roundToInt().coerceAtLeast(0)))
layout(rect.width.roundToInt().coerceAtLeast(0), rect.height.roundToInt().coerceAtLeast(0)) {
content.place((target.left - rect.left).roundToInt(), (target.top - rect.top).roundToInt())
picture?.place(0, 0)
}
}
}
}
/** A sheet's handle, which drags the sheet down and away. */
@Composable
private fun SheetChrome(onDrag: (Float) -> Unit, onRelease: () -> Unit, content: @Composable () -> Unit) {
Column(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxWidth()
.height(36.dp)
.pointerInput(Unit) { detectVerticalDragGestures(onDragEnd = onRelease, onDragCancel = onRelease) { _, dy -> onDrag(dy) } },
contentAlignment = Alignment.Center,
) {
Surface(shape = RoundedCornerShape(2.dp), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), modifier = Modifier.size(32.dp, 4.dp)) {}
}
Box(Modifier.weight(1f)) { content() }
}
}

View File

@@ -28,6 +28,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.drawWithContent
// Material motion patterns, as in RideBus: shared axis X between a screen and the page it
// opens, fade through between top-level destinations.
@@ -103,3 +105,130 @@ fun LazyStaggeredGridItemScope.AnimatedItem(content: @Composable () -> Unit) {
fun materialFadeThrough(): ContentTransform =
(fadeIn(tween(400, easing = EmphasizedDecelerate)) + scaleIn(tween(400, easing = EmphasizedDecelerate), initialScale = 0.92f)) togetherWith
fadeOut(tween(200, easing = EmphasizedAccelerate))
/**
* Whether the student turned animations off ("Убрать анимацию" sets the animator scale to 0):
* decorative motion is skipped then.
*/
@Composable
fun rememberReducedMotion(): Boolean {
val resolver = androidx.compose.ui.platform.LocalContext.current.contentResolver
return remember {
android.provider.Settings.Global.getFloat(resolver, android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}
}
/**
* The overview's cards after the launch screen: each rises 24dp and fades in, a little after
* the one above it. Only on [play] (the first overview of a launch), never with reduced motion.
*/
@Composable
fun Entrance(index: Int, play: Boolean, content: @Composable () -> Unit) {
val reduced = rememberReducedMotion()
if (!play || reduced) {
content()
return
}
val shown = remember { androidx.compose.animation.core.Animatable(0f) }
androidx.compose.runtime.LaunchedEffect(Unit) {
kotlinx.coroutines.delay(60L * index)
shown.animateTo(1f, tween(MotionDuration.Medium4, easing = EmphasizedDecelerate))
}
Box(
Modifier.graphicsLayer {
alpha = shown.value
translationY = (1f - shown.value) * 24.dp.toPx()
},
) { content() }
}
/**
* A short changing label ("ещё 25 мин" → "ещё 24 мин") that rolls up to its new value instead
* of flicking; still with animations turned off.
*/
@Composable
fun RollingText(text: String, style: androidx.compose.ui.text.TextStyle, modifier: Modifier = Modifier) {
if (rememberReducedMotion()) {
androidx.compose.material3.Text(text, style = style, modifier = modifier)
return
}
androidx.compose.animation.AnimatedContent(
targetState = text,
transitionSpec = {
(androidx.compose.animation.slideInVertically(tween(MotionDuration.Short4, easing = EmphasizedDecelerate)) { it } + fadeIn(tween(MotionDuration.Short4))) togetherWith
(androidx.compose.animation.slideOutVertically(tween(MotionDuration.Short4, easing = EmphasizedAccelerate)) { -it } + fadeOut(tween(MotionDuration.Short4))) using
androidx.compose.animation.SizeTransform(clip = true)
},
label = "rolling",
modifier = modifier,
) { androidx.compose.material3.Text(it, style = style) }
}
/**
* The lesson under way: a line runs around the card's edge as the pair goes, from its top-left
* corner clockwise, over a faint full outline. [progress] is 0..1.
*/
fun Modifier.progressBorder(progress: Float, color: androidx.compose.ui.graphics.Color, radius: androidx.compose.ui.unit.Dp): Modifier =
drawWithContent {
drawContent()
val stroke = 3.dp.toPx()
val inset = stroke / 2
val r = radius.toPx() - inset
// Round the card clockwise from just after its top-left corner, so the line starts there.
val l = inset
val t = inset
val rr = size.width - inset
val b = size.height - inset
val path = androidx.compose.ui.graphics.Path().apply {
moveTo(l + r, t)
lineTo(rr - r, t)
arcTo(androidx.compose.ui.geometry.Rect(rr - 2 * r, t, rr, t + 2 * r), -90f, 90f, false)
lineTo(rr, b - r)
arcTo(androidx.compose.ui.geometry.Rect(rr - 2 * r, b - 2 * r, rr, b), 0f, 90f, false)
lineTo(l + r, b)
arcTo(androidx.compose.ui.geometry.Rect(l, b - 2 * r, l + 2 * r, b), 90f, 90f, false)
lineTo(l, t + r)
arcTo(androidx.compose.ui.geometry.Rect(l, t, l + 2 * r, t + 2 * r), 180f, 90f, false)
close()
}
drawPath(path, color.copy(alpha = 0.22f), style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx()))
val measure = androidx.compose.ui.graphics.PathMeasure().apply { setPath(path, false) }
val done = androidx.compose.ui.graphics.Path()
measure.getSegment(0f, measure.length * progress.coerceIn(0f, 1f), done, true)
drawPath(done, color, style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke, cap = androidx.compose.ui.graphics.StrokeCap.Round))
}
/**
* Pull to refresh with the M3 Expressive loading indicator, its shape morphing while the
* timetable loads; a tick under the finger when the pull is far enough to refresh.
*/
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class, androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ExpressiveRefresh(
refreshing: Boolean,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit,
) {
val state = androidx.compose.material3.pulltorefresh.rememberPullToRefreshState()
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
val busy = androidx.compose.runtime.rememberUpdatedState(refreshing)
androidx.compose.runtime.LaunchedEffect(state) {
androidx.compose.runtime.snapshotFlow { state.distanceFraction >= 1f }
.collect { far -> if (far && !busy.value) haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.GestureThresholdActivate) }
}
androidx.compose.material3.pulltorefresh.PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = onRefresh,
state = state,
modifier = modifier,
indicator = {
androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.LoadingIndicator(
state = state,
isRefreshing = refreshing,
modifier = Modifier.align(androidx.compose.ui.Alignment.TopCenter),
)
},
content = content,
)
}

View File

@@ -2,6 +2,11 @@
package org.xtimms.psu.schedule.ui
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.draw.clip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material.icons.rounded.CalendarToday
@@ -9,7 +14,13 @@ import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DatePicker
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.rounded.Image
import androidx.compose.ui.platform.LocalContext
import org.xtimms.psu.schedule.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -111,6 +122,11 @@ fun MainViewModel.nextOccurrence(t: Timetable, subject: String, after: LocalDate
@Composable
fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject: Boolean, showDue: Boolean = true) {
val overdue = note.overdue()
var viewing by remember { mutableStateOf(false) }
val haptic = LocalHapticFeedback.current
// A ticked task is struck through in place first, then moves down to the done ones.
var shownDone by remember(note.id, note.done) { mutableStateOf(note.done) }
val scope = rememberCoroutineScope()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -119,20 +135,20 @@ fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject:
.clickable(onClickLabel = "Изменить", role = Role.Button, onClick = onClick)
.padding(start = 8.dp, end = 16.dp, top = 2.dp, bottom = 2.dp),
) {
if (note.task) Checkbox(checked = note.done, onCheckedChange = { onToggle() })
if (note.task) Checkbox(checked = shownDone, onCheckedChange = { done ->
haptic.performHapticFeedback(if (done) HapticFeedbackType.Confirm else HapticFeedbackType.ToggleOff)
shownDone = done
if (done) scope.launch {
delay(DONE_PAUSE)
onToggle()
} else onToggle()
})
else Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.StickyNote2, contentDescription = "Заметка", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.width(8.dp))
Column(Modifier.weight(1f).padding(vertical = 8.dp)) {
Text(
note.text,
style = MaterialTheme.typography.bodyLarge,
textDecoration = if (note.task && note.done) TextDecoration.LineThrough else null,
color = if (note.task && note.done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
StrikeText(note.text, done = note.task && shownDone, style = MaterialTheme.typography.bodyLarge, maxLines = 3)
Row(verticalAlignment = Alignment.CenterVertically) {
if (showSubject && !note.general) {
SubjectDot(note.subject, 8.dp)
@@ -155,7 +171,22 @@ fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject:
)
}
}
// The first photo, with how many more; it opens the viewer, the row the editor.
if (note.photos.isNotEmpty()) Box(Modifier.padding(start = 12.dp)) {
PhotoThumb(note.photos.first(), 48.dp, onClick = { viewing = true })
if (note.photos.size > 1) Text(
"+${note.photos.size - 1}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(3.dp)
.background(MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f), MaterialTheme.shapes.small)
.padding(horizontal = 5.dp),
)
}
}
if (viewing) PhotoViewer(note.photos, 0) { viewing = false }
}
/** What the editor needs to file a new note: the lesson it opened from and the next one. */
@@ -182,11 +213,19 @@ fun NoteEditor(
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) }
// Photos of this edit; the ones added here go from the disk again if it is not saved.
var photos by rememberSaveable { mutableStateOf(editing?.photos.orEmpty()) }
var added by rememberSaveable { mutableStateOf(emptyList<String>()) }
val store = LocalContext.current.app.notes
val cancel = {
store.discard(added)
onDismiss()
}
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
AlertDialog(
onDismissRequest = onDismiss,
onDismissRequest = cancel,
icon = { Icon(if (task) Icons.AutoMirrored.Rounded.Assignment else Icons.Rounded.StickyNote2, contentDescription = null) },
title = {
Text(
@@ -198,7 +237,7 @@ fun NoteEditor(
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
ConnectedChoice(
options = listOf(true to "Задание", false to "Заметка"),
selected = task,
@@ -212,6 +251,19 @@ fun NoteEditor(
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp).focusRequester(focus),
)
// A photo of the board or the assignment instead of copying it out.
NotePhotosEditor(
photos = photos,
onAdd = { photos = photos + it; added = added + it },
onRemove = { name ->
photos = photos - name
// A photo added in this edit has no saved note to keep it.
if (name in added) {
added = added - name
store.discard(listOf(name))
}
},
)
if (general) {
Text("День", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedButton(onClick = { picking = true }, modifier = Modifier.fillMaxWidth()) {
@@ -241,7 +293,8 @@ fun NoteEditor(
},
confirmButton = {
TextButton(
enabled = text.isNotBlank(),
// A photo alone is a note too: "задание на фото".
enabled = text.isNotBlank() || photos.isNotEmpty(),
onClick = {
val base = editing ?: run {
val t = target ?: return@TextButton
@@ -249,16 +302,16 @@ fun NoteEditor(
Note(group = groupKey, subject = t.subject, date = date.toString(), start = start, text = "")
}
val dated = if (general) base.copy(date = LocalDate.ofEpochDay(day).toString()) else base
onSave(dated.copy(text = text.trim(), task = task))
onSave(dated.copy(text = text.trim().ifEmpty { "Фото" }, task = task, photos = photos))
},
) { Text("Сохранить") }
},
dismissButton = {
Row {
if (editing != null) TextButton(onClick = { onDelete(editing) }) {
if (editing != null) TextButton(onClick = { store.discard(added); onDelete(editing) }) {
Text("Удалить", color = MaterialTheme.colorScheme.error)
}
TextButton(onClick = onDismiss) { Text("Отмена") }
TextButton(onClick = cancel) { Text("Отмена") }
}
},
)
@@ -280,6 +333,7 @@ fun NoteEditor(
/** The lesson sheet's section: this lesson's tasks and notes and a button to add one. */
@Composable
fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
val undo = LocalUndo.current
val all by vm.notes.collectAsStateWithLifecycle()
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
@@ -317,7 +371,7 @@ fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
editing = editing,
groupKey = group,
onSave = { vm.saveNote(it); adding = false; editing = null },
onDelete = { vm.deleteNote(it.id); editing = null },
onDelete = { vm.deleteNote(it.id, undo); editing = null },
onDismiss = { adding = false; editing = null },
)
}
@@ -342,6 +396,11 @@ fun NotesHint(notes: List<Note>, color: androidx.compose.ui.graphics.Color) {
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (open.any { it.photos.isNotEmpty() }) {
Spacer(Modifier.width(6.dp))
Icon(Icons.Rounded.Image, contentDescription = "С фото", tint = color, modifier = Modifier.size(18.dp))
}
}
}

View File

@@ -51,6 +51,8 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.CalendarMonth
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.Check
import org.xtimms.psu.schedule.data.choiceKey
import androidx.compose.material.icons.rounded.NotificationsActive
import androidx.compose.material.icons.rounded.DownloadForOffline
import androidx.compose.material3.Button
@@ -93,14 +95,15 @@ import org.xtimms.psu.schedule.ui.theme.page
import kotlin.coroutines.cancellation.CancellationException
/** The first run, in the order a student needs it: what this is, their group, their subgroup, reminders. */
private enum class Step { Welcome, Group, Subgroup, Reminders }
private enum class Step { Welcome, Group, Subgroup, Choice, Foreign, Reminders }
/** Text and controls of the onboarding pages keep to a phone's width on tablets. */
private val PAGE_WIDTH = 560.dp
/**
* First-run onboarding. Only the group is required: the welcome leads straight to it,
* the subgroup page appears only for groups split into subgroups, and reminders can wait.
* the subgroup page appears only for groups split into subgroups, the choice page only for groups
* whose parts study one subject or another (German or French), and reminders can wait.
* [onDone] opens the overview with the chosen group.
*/
@Composable
@@ -108,14 +111,18 @@ fun Onboarding(vm: MainViewModel, onDone: () -> Unit) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val subgroups = timetable?.subgroups.orEmpty()
val choices = timetable?.choices.orEmpty()
var step by rememberSaveable { mutableStateOf(Step.Welcome) }
// The steps the progress bar counts; the welcome is not one of them.
val steps = if (subgroups.isEmpty()) listOf(Step.Group, Step.Reminders) else listOf(Step.Group, Step.Subgroup, Step.Reminders)
fun previous(of: Step): Step = when (of) {
Step.Welcome, Step.Group -> Step.Welcome
Step.Subgroup -> Step.Group
Step.Reminders -> if (subgroups.isEmpty()) Step.Group else Step.Subgroup
}
val steps = listOfNotNull(
Step.Group,
Step.Subgroup.takeIf { subgroups.isNotEmpty() },
Step.Choice.takeIf { choices.isNotEmpty() },
Step.Foreign.takeIf { timetable?.audience == true },
Step.Reminders,
)
fun previous(of: Step): Step = if (of == Step.Welcome) Step.Welcome else steps.getOrNull(steps.indexOf(of) - 1) ?: Step.Welcome
fun next(of: Step): Step = steps.getOrNull(steps.indexOf(of) + 1) ?: Step.Reminders
// Predictive back, as in the rest of the app: the gesture scrubs the way back to the previous step.
val state = remember { SeekableTransitionState(step) }
@@ -162,7 +169,16 @@ fun Onboarding(vm: MainViewModel, onDone: () -> Unit) {
Step.Welcome -> Welcome(onStart = { step = Step.Group })
Step.Group -> GroupsScreen(
vm,
onOpened = { step = if (vm.timetable.value?.subgroups.isNullOrEmpty()) Step.Reminders else Step.Subgroup },
// The steps above still count the previous group's timetable: read the new one.
onOpened = {
val t = vm.timetable.value
step = when {
!t?.subgroups.isNullOrEmpty() -> Step.Subgroup
!t?.choices.isNullOrEmpty() -> Step.Choice
t?.audience == true -> Step.Foreign
else -> Step.Reminders
}
},
onBack = null,
onboarding = true,
)
@@ -170,7 +186,18 @@ fun Onboarding(vm: MainViewModel, onDone: () -> Unit) {
group = timetable?.group.orEmpty(),
subgroups = subgroups,
selected = settings?.subgroup,
onPick = { vm.setSubgroup(it); step = Step.Reminders },
onPick = { vm.setSubgroup(it); step = next(Step.Subgroup) },
)
Step.Choice -> ChoiceStep(
group = timetable?.group.orEmpty(),
choices = choices,
taken = settings?.choices.orEmpty(),
onPick = vm::setChoice,
onNext = { step = next(Step.Choice) },
)
Step.Foreign -> ForeignStep(
group = timetable?.group.orEmpty(),
onPick = { vm.setForeign(it); step = Step.Reminders },
)
Step.Reminders -> RemindersStep(
minutes = settings?.reminderMinutes ?: 10,
@@ -441,6 +468,75 @@ private fun SubgroupStep(group: String, subgroups: List<String>, selected: Strin
}
}
/**
* "Что вы изучаете": German or French, say. With one choice a tap picks and moves on; with
* several, each list keeps its pick and "Далее" moves on.
*/
@Composable
private fun ChoiceStep(
group: String,
choices: List<List<String>>,
taken: Map<String, String>,
onPick: (List<String>, String?) -> Unit,
onNext: () -> Unit,
) {
StepPage(
centered = false,
buttons = {
if (choices.size > 1) PrimaryButton("Далее", onNext)
TextButton(onClick = onNext) { Text("Пропустить") }
},
) {
StepHeading(Icons.Rounded.Groups, "Что вы изучаете?", choiceText(group, choices.first()))
choices.forEachIndexed { n, options ->
if (n > 0) Spacer(Modifier.height(16.dp))
SegmentedList {
val all = options + listOf<String?>(null)
all.forEachIndexed { i, option ->
SegmentedItem(
index = i,
count = all.size,
headline = option ?: "Показывать всё",
supporting = if (option == null) "Занятия всех вариантов" else null,
onClick = {
onPick(options, option)
if (choices.size == 1) onNext()
},
trailing = {
if (choices.size > 1 && taken[choiceKey(options)] == option) Icon(Icons.Rounded.Check, contentDescription = "Выбрано")
else Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null)
},
)
}
}
}
}
}
/** "Вы иностранный студент?" as a page: one tap answers and moves on. */
@Composable
private fun ForeignStep(group: String, onPick: (Boolean?) -> Unit) {
StepPage(
centered = false,
buttons = { TextButton(onClick = { onPick(null) }) { Text("Пропустить") } },
) {
StepHeading(Icons.Rounded.Groups, "Вы иностранный студент?", foreignText(group))
SegmentedList {
val answers = listOf<Pair<Boolean?, String>>(true to "Да, иностранный", false to "Нет", null to "Показывать всё")
answers.forEachIndexed { i, (value, label) ->
SegmentedItem(
index = i,
count = answers.size,
headline = label,
supporting = if (value == null) "Пары для всех студентов" else null,
onClick = { onPick(value) },
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
)
}
}
}
}
/** Reminders before each lesson; Android asks for the notification permission here, when it makes sense. */
@Composable
private fun RemindersStep(minutes: Int, onMinutes: (Int) -> Unit, onDone: (Boolean) -> Unit) {

View File

@@ -0,0 +1,274 @@
package org.xtimms.psu.schedule.ui
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.LruCache
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.horizontalScroll
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.PhotoCamera
import androidx.compose.material.icons.rounded.PhotoLibrary
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.xtimms.psu.schedule.app
import java.io.File
// Photos of the board or the assignment, pinned to a task or a note (NotesStore keeps the files).
/** Decoded thumbnails, so lists scrolling past the same photos do not decode them again. */
private val thumbnails = object : LruCache<String, ImageBitmap>(24 * 1024 * 1024) {
override fun sizeOf(key: String, value: ImageBitmap) = value.width * value.height * 4
}
/** A photo decoded no larger than [max] pixels on its long side; null while it loads. */
@Composable
private fun rememberPhoto(file: File, max: Int): ImageBitmap? {
val key = "${file.name}@$max"
return produceState(thumbnails.get(key), key) {
if (value != null) return@produceState
value = withContext(Dispatchers.IO) {
runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, bounds)
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= max) sample *= 2
BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample })?.asImageBitmap()
}.getOrNull()
}?.also { thumbnails.put(key, it) }
}.value
}
/** A square thumbnail; [onClick] opens the viewer. */
@Composable
fun PhotoThumb(name: String, size: Dp, onClick: (() -> Unit)?, modifier: Modifier = Modifier) {
val store = LocalContext.current.app.notes
val px = with(LocalDensity.current) { size.roundToPx() } * 2
val image = rememberPhoto(store.photo(name), px)
Box(
modifier
.size(size)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Открыть фото", onClick = onClick) else Modifier),
) {
image?.let { Image(it, contentDescription = "Фото", contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) }
}
}
/**
* The editor's photos: thumbnails with a remove button, then "Снять" (the camera app) and
* "Галерея" (the system photo picker, no storage permission needed).
*/
@Composable
fun NotePhotosEditor(photos: List<String>, onAdd: (String) -> Unit, onRemove: (String) -> Unit) {
val context = LocalContext.current
val store = context.app.notes
val scope = rememberCoroutineScope()
var loading by remember { mutableIntStateOf(0) }
var viewing by remember { mutableStateOf<Int?>(null) }
fun import(uris: List<Uri>) {
uris.forEach { uri ->
loading++
scope.launch {
store.importPhoto(uri)?.let(onAdd)
loading--
}
}
}
// The camera app writes the full-size shot here; the store keeps a smaller copy.
val shot = remember { File(context.cacheDir, "camera/shot.jpg") }
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { taken ->
if (taken) {
loading++
scope.launch {
store.importPhoto(Uri.fromFile(shot))?.let(onAdd)
shot.delete()
loading--
}
}
}
val gallery = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(MAX_PHOTOS)) { import(it) }
val room = MAX_PHOTOS - photos.size
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (photos.isNotEmpty() || loading > 0) Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.horizontalScroll(rememberScrollState()),
) {
photos.forEachIndexed { index, name ->
Box {
PhotoThumb(name, 72.dp, onClick = { viewing = index })
FilledIconButton(
onClick = { onRemove(name) },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
),
modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(24.dp),
) { Icon(Icons.Rounded.Close, contentDescription = "Убрать фото", modifier = Modifier.size(16.dp)) }
}
}
repeat(loading) {
Box(
Modifier.size(72.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) }
}
}
if (room > 0) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = {
shot.parentFile?.mkdirs()
val uri = FileProvider.getUriForFile(context, context.packageName + ".photos", shot)
// A phone without a camera app: the gallery is still there.
runCatching { camera.launch(uri) }
},
modifier = Modifier.weight(1f),
) {
Icon(Icons.Rounded.PhotoCamera, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Снять", maxLines = 1)
}
OutlinedButton(
onClick = { gallery.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
modifier = Modifier.weight(1f),
) {
Icon(Icons.Rounded.PhotoLibrary, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Галерея", maxLines = 1)
}
}
}
viewing?.let { PhotoViewer(photos, it) { viewing = null } }
}
/** Photos full screen, swiped sideways; pinch or double-tap to zoom into the board. */
@Composable
fun PhotoViewer(photos: List<String>, start: Int, onDismiss: () -> Unit) {
if (photos.isEmpty()) return
val store = LocalContext.current.app.notes
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
val pager = rememberPagerState(initialPage = start.coerceIn(0, photos.lastIndex)) { photos.size }
Box(Modifier.fillMaxSize().background(Color.Black)) {
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val image = rememberPhoto(store.photo(photos[page]), 2048)
Box(
Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(onDoubleTap = {
if (scale > 1f) { scale = 1f; offset = Offset.Zero } else scale = 2.5f
})
}
.pointerInput(Unit) {
// Two fingers zoom; one finger pans only while zoomed in, so at 1x the
// pager still takes the swipe to the next photo.
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
if (event.changes.size > 1 || scale > 1f) {
scale = (scale * event.calculateZoom()).coerceIn(1f, 5f)
offset = if (scale > 1f) offset + event.calculatePan() else Offset.Zero
event.changes.forEach { if (it.positionChanged()) it.consume() }
}
} while (event.changes.any { it.pressed })
}
},
contentAlignment = Alignment.Center,
) {
if (image == null) CircularProgressIndicator(color = Color.White)
else Image(
image,
contentDescription = "Фото ${page + 1} из ${photos.size}",
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
},
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().statusBarsPadding().padding(8.dp),
) {
IconButton(onClick = onDismiss) { Icon(Icons.Rounded.Close, contentDescription = "Закрыть", tint = Color.White) }
Spacer(Modifier.weight(1f))
if (photos.size > 1) Surface(shape = CircleShape, color = Color.Black.copy(alpha = 0.5f), contentColor = Color.White) {
Text("${pager.currentPage + 1} / ${photos.size}", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp))
}
}
}
}
}
/** Up to this many photos per task or note. */
const val MAX_PHOTOS = 6

View File

@@ -28,6 +28,7 @@ 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.navigationBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -119,22 +120,33 @@ private sealed interface Row {
*/
@Composable
fun PlansScreen(vm: MainViewModel, nav: AppNav) {
val undo = LocalUndo.current
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 target by remember { mutableStateOf<NoteTarget?>(null) }
// "+" grows into the sheet of a new plan (Morph.kt); a shortcut opens it from the middle.
val morph = LocalMorph.current
val addPlan = {
morph?.open(MorphKind.Sheet) { close ->
AddPlanSheet(vm, timetable, settings?.subgroup, day = null) {
close()
target = it
}
}
Unit
}
val wantsTask by vm.newTask.collectAsStateWithLifecycle()
LaunchedEffect(wantsTask) {
if (wantsTask) {
adding = true
addPlan()
vm.newTask.value = false
}
}
var tuning by remember { mutableStateOf(false) }
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
var target by remember { mutableStateOf<NoteTarget?>(null) }
val openLesson = rememberLessonOpener(vm)
var date by remember { mutableStateOf(today()) }
// Each layout keeps its own list, so the outgoing and incoming layouts can be on screen
// together while one fades through to the other.
@@ -174,11 +186,15 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
title = {
Column {
Text("Планы", style = MaterialTheme.typography.titleLargeEmphasized)
Text(
if (open == 0) "Всё сделано" else "$open ${plural(open, "дело", "дела", "дел")} впереди",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
androidx.compose.foundation.layout.Row(verticalAlignment = Alignment.CenterVertically) {
// The last task done: a check pops up by "Всё сделано".
DoneMark(open == 0)
Text(
if (open == 0) "Всё сделано" else "$open ${plural(open, "дело", "дела", "дел")} впереди",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
actions = {
@@ -254,7 +270,7 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
DetailPane(it) {
DayAgenda(
vm, timetable, settings?.subgroup, date, all,
onLesson = { details = it to date },
onLesson = { openLesson(it, date) },
onNote = { editing = it },
modifier = Modifier.fillMaxSize().padding(top = 12.dp),
)
@@ -265,30 +281,22 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
}
}
FloatingActionButton(
onClick = { adding = true },
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp + navBarPadding()),
onClick = addPlan,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp + navBarPadding())
.morphOrigin("fab|plans", MaterialTheme.colorScheme.primaryContainer, 16.dp),
) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание") }
}
details?.let { (lesson, d) ->
ModalBottomSheet(onDismissRequest = { details = null }) {
LessonDetails(lesson, timetable?.bells.orEmpty(), d, vm, onClose = { details = null })
}
}
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 },
onDelete = { vm.deleteNote(it.id, undo); editing = null },
onDismiss = { target = null; editing = null },
)
}
@@ -564,6 +572,7 @@ internal fun AddPlanSheet(vm: MainViewModel, t: Timetable?, subgroup: String?, d
Column(
Modifier
.verticalScroll(rememberScrollState())
.navigationBarsPadding()
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {

View File

@@ -2,6 +2,10 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.animation.core.tween
import kotlinx.coroutines.flow.drop
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.unit.Dp
import androidx.compose.animation.togetherWith
import androidx.compose.animation.Crossfade
@@ -19,6 +23,7 @@ import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.OutlinedButton
import org.xtimms.psu.schedule.data.hideKey
import org.xtimms.psu.schedule.data.hiddenIn
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import kotlin.math.abs
@@ -33,6 +38,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.widthIn
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material.icons.rounded.Today
@@ -81,16 +87,15 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -128,12 +133,12 @@ fun ScheduleScreen(
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle()
var details by remember { mutableStateOf<Pair<Lesson, LocalDate?>?>(null) }
val openLesson = rememberLessonOpener(vm)
val subgroup = settings?.subgroup
val t = timetable
if (t?.semester != null) {
Planner(vm, nav, t, subgroup, weekView, onWeekView, focus, onFocusHandled, onOpen = { lesson, date -> details = lesson to date })
Planner(vm, nav, t, subgroup, weekView, onWeekView, focus, onFocusHandled, onOpen = { lesson, date -> openLesson(lesson, date) })
} else Column(Modifier.fillMaxSize()) {
TopAppBar(
colors = appBarColors(),
@@ -143,7 +148,7 @@ fun ScheduleScreen(
)
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null, action = if (status.needsGroup) "Выбрать группу" to nav.openGroups else null) }
when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
t == null && status.loading -> ScheduleSkeleton()
t == null -> CenteredEmpty {
Empty(
"Расписание не загружено",
@@ -153,12 +158,7 @@ fun ScheduleScreen(
action = if (settings?.current != null) "Повторить" to { vm.refresh(); Unit } else "Выбрать группу" to nav.openGroups,
)
}
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, onClose = { details = null })
else -> WordRows(t, subgroup, settings?.hidden.orEmpty(), onOpen = { openLesson(it, null) })
}
}
}
@@ -233,6 +233,17 @@ private fun Planner(
}
}
// A finger swipe to another day ticks like a tap on the strip.
val haptic = LocalHapticFeedback.current
val draggingDays by pager.interactionSource.collectIsDraggedAsState()
var daySwiped by remember { mutableStateOf(false) }
LaunchedEffect(draggingDays) { if (draggingDays) daySwiped = true }
LaunchedEffect(pager) {
snapshotFlow { pager.settledPage }.drop(1).collect {
if (daySwiped) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
daySwiped = false
}
}
// The strip is a pager of weeks: it follows the day pager, and swiping it moves the days.
val strip = rememberPagerState(initialPage = mondays.indexOf(date.monday()).coerceAtLeast(0)) { mondays.size }
LaunchedEffect(date.monday()) {
@@ -336,6 +347,7 @@ private fun Planner(
enabled = { d -> dates.contains(d) },
onSelect = ::go,
onWeek = ::week,
marks = true,
modifier = Modifier.widthIn(max = MAX_CONTENT).padding(horizontal = 8.dp),
)
}
@@ -382,7 +394,8 @@ private fun Planner(
modifier = if (wide) Modifier.padding(top = 16.dp) else Modifier,
)
}
} else PullToRefreshBox(isRefreshing = status.loading, onRefresh = { vm.refresh() }) {
} else ExpressiveRefresh(refreshing = status.loading, onRefresh = { vm.refresh() }) {
val fresh by vm.fresh.collectAsStateWithLifecycle()
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
val pageDate = dates[page]
DayPage(
@@ -390,6 +403,8 @@ private fun Planner(
slots = vm.slotsOn(t, pageDate, subgroup),
places = vm.placesOn(t, pageDate, subgroup),
onOpen = onOpen,
fresh = fresh,
onFreshShown = vm::freshShown,
)
}
}
@@ -437,12 +452,30 @@ private fun Planner(
}
@Composable
private fun DayPage(date: LocalDate, slots: List<Slot>, places: List<DayPlace>, onOpen: (Lesson, LocalDate) -> Unit) {
private fun DayPage(
date: LocalDate,
slots: List<Slot>,
places: List<DayPlace>,
onOpen: (Lesson, LocalDate) -> Unit,
fresh: Set<String> = emptySet(),
onFreshShown: (String) -> Unit = {},
) {
val clock = rememberClock()
val isToday = date == today()
val next = if (isToday) slots.firstOrNull { it.start.isAfter(clock) } else null
val list = rememberLazyListState()
// Today opens on the lesson under way (or the next one), not on the morning's first: one
// look tells where to go now. Once per page, so a scroll back up stays where it is.
LaunchedEffect(isToday) {
if (!isToday) return@LaunchedEffect
val now = slots.indexOfFirst { clock.isBefore(it.end) }
if (now <= 0) return@LaunchedEffect
val before = (if (places.isNotEmpty()) 1 else 0) + slots.take(now).sumOf { it.lessons.size }
list.scrollToItem(before)
}
ContentColumn {
LazyColumn(
state = list,
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
@@ -472,7 +505,11 @@ private fun DayPage(date: LocalDate, slots: List<Slot>, places: List<DayPlace>,
date = date,
status = status,
running = running,
progress = if (running) Duration.between(slot.start, clock).toSeconds().toFloat() /
Duration.between(slot.start, slot.end).toSeconds().coerceAtLeast(1) else null,
onClick = { onOpen(lesson, date) },
fresh = lesson.key in fresh,
onFreshShown = { onFreshShown(lesson.key) },
)
}
}
@@ -491,7 +528,12 @@ fun LessonCard(
date: LocalDate? = null,
status: String? = null,
running: Boolean = false,
/** How far the lesson under way has gone, 0..1: a line runs round the card. */
progress: Float? = null,
onClick: (() -> Unit)?,
/** Just added or changed by a republished document: the card glows twice, then [onFreshShown]. */
fresh: Boolean = false,
onFreshShown: () -> Unit = {},
) {
val color = subjectColor(lesson.subject)
val show = LocalDisplay.current
@@ -509,7 +551,7 @@ fun LessonCard(
color = if (running) color.onSoft else color.onSoft.copy(alpha = 0.12f),
contentColor = if (running) color.soft else color.onSoft,
) {
Text(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
RollingText(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
}
}
}
@@ -527,6 +569,13 @@ fun LessonCard(
lesson.type.ifEmpty { null }?.takeIf { session == null },
if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null,
if (lesson.groups.size > 1) "общее занятие" else null,
// German or French: a part of the group has this one, the rest the other.
if (lesson.option != null) "на выбор" else null,
when (lesson.audience) {
"foreign" -> "для иностранных студентов"
"local" -> "кроме иностранных студентов"
else -> null
},
)
val mark = date?.let { LocalMarks.current.forLesson(lesson.subject, it, lesson.start) }
if (tags.isNotEmpty() || lesson.uncertain || mark != null) FlowRow(
@@ -544,7 +593,26 @@ fun LessonCard(
if (date != null) NotesHint(LocalNotes.current.forLesson(lesson.subject, date, lesson.start), color.onSoft)
}
}
// A tapped card grows into its details page (Morph.kt).
val glow = remember { androidx.compose.animation.core.Animatable(0f) }
val reduced = rememberReducedMotion()
LaunchedEffect(fresh) {
if (!fresh) return@LaunchedEffect
if (!reduced) repeat(2) {
glow.animateTo(1f, tween(350, easing = EmphasizedDecelerate))
glow.animateTo(0f, tween(450))
}
onFreshShown()
}
// The fill itself turns tertiary and back, so the text stays as crisp as on any card.
val highlight = MaterialTheme.colorScheme.tertiaryContainer
val fill = if (glow.value > 0f) androidx.compose.ui.graphics.lerp(color.soft, highlight, glow.value) else color.soft
val origin = if (onClick != null) Modifier.morphOrigin("card|$date|${lesson.key}|${lesson.start}", color.soft, 16.dp) else Modifier
// The line round a running card grows a little every half minute, never jumps.
val shown by androidx.compose.animation.core.animateFloatAsState(progress ?: 0f, tween(1000), label = "progress")
val live = if (running && progress != null) Modifier.progressBorder(shown, color.onSoft, 16.dp) else Modifier
val border = when {
running && progress != null -> null
running -> BorderStroke(2.dp, color.onSoft)
session != null -> BorderStroke(2.dp, session.outline())
else -> null
@@ -552,10 +620,10 @@ fun LessonCard(
if (onClick != null) Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = color.soft,
color = fill,
contentColor = color.onSoft,
border = border,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().then(origin).then(live),
content = content,
) else Surface(
shape = MaterialTheme.shapes.large,
@@ -627,6 +695,9 @@ internal fun LessonDetails(
onClose: () -> Unit = {},
) {
val bell = bells.firstOrNull { it.start == lesson.start }
// Hiding closes the sheet, so its "Вернуть" goes to the screen; tasks deleted here, to the sheet.
val screenUndo = LocalUndo.current
UndoArea(bottom = false) {
Column(
Modifier
.verticalScroll(rememberScrollState())
@@ -668,7 +739,11 @@ internal fun LessonDetails(
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())
}
if (vm != null) {
OutlinedButton(onClick = { vm.setHidden(lesson.hideKey, true); onClose() }) {
OutlinedButton(onClick = {
vm.setHidden(lesson.hideKey, true)
onClose()
screenUndo?.offer("Скрыто: «${lesson.subject}»", onUndo = { vm.setHidden(lesson.hideKey, false) })
}) {
Icon(Icons.Rounded.VisibilityOff, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Скрыть такие занятия")
@@ -681,6 +756,7 @@ internal fun LessonDetails(
)
}
}
}
}
@Composable
@@ -710,7 +786,7 @@ private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpe
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 }
.filter { !it.hiddenIn(hidden) }
val places = if (lessons.isEmpty()) emptyList() else day.places.forSubgroup(subgroup)
if (places.isNotEmpty()) item(key = "p${day.day}") { DayPlaces(places) }
items(lessons, key = { "${day.day}-${it.key}" }) { lesson ->
@@ -828,3 +904,31 @@ private val FACULTIES = mapOf(
)
fun facultyShort(name: String): String? = name.takeIf { it.isNotEmpty() }?.let { FACULTIES[it] ?: it }
/**
* Opens a lesson's details as a page growing out of the card that was tapped (Morph.kt); from
* anywhere else (the week grid of a narrow column, a notification) it grows from the middle.
*/
@Composable
fun rememberLessonOpener(vm: MainViewModel): (Lesson, LocalDate?) -> Unit {
val morph = LocalMorph.current
return remember(morph, vm) {
{ lesson, date -> morph?.open(MorphKind.Page) { close -> LessonPage(lesson, date, vm, close) } }
}
}
/** The details as a page: a close button over them; full screen on a phone, a panel on a tablet. */
@Composable
private fun LessonPage(lesson: Lesson, date: LocalDate?, vm: MainViewModel, close: () -> Unit) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
BoxWithConstraints(Modifier.fillMaxSize()) {
// The phone's page covers the bars; a tablet's panel sits inside them.
val insets = if (maxWidth >= 600.dp) Modifier else Modifier.windowInsetsPadding(WindowInsets.systemBars)
Column(Modifier.fillMaxSize().then(insets)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp, top = 4.dp)) {
IconButton(onClick = close) { Icon(Icons.Rounded.Close, contentDescription = "Закрыть") }
}
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm, onClose = close)
}
}
}

View File

@@ -51,7 +51,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -243,7 +242,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
) {
if (target != null) {
val d = day
if (loading && d == null) item { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() } }
if (loading && d == null) item { LessonsSkeleton(3) }
if (d != null) {
if (d.lessons.isEmpty()) item {
Box(Modifier.fillParentMaxHeight(0.8f).fillMaxWidth(), contentAlignment = Alignment.Center) {

View File

@@ -2,6 +2,8 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.selection.toggleable
import androidx.compose.ui.semantics.Role
@@ -43,6 +45,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.psu.schedule.BuildConfig
import org.xtimms.psu.schedule.data.ThemeMode
import org.xtimms.psu.schedule.data.OPTION_PREFIX
import org.xtimms.psu.schedule.data.AUDIENCE_PREFIX
import org.xtimms.psu.schedule.notify.Reminders
@OptIn(ExperimentalMaterial3Api::class)
@@ -120,9 +124,12 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
SwitchRow("Пометки из Word", "Например, «3-13н.» — даты по ним уже рассчитаны", d.notes) {
vm.setDisplay(d.copy(notes = it))
}
if (s.hidden.isNotEmpty()) {
// The options of a choice the student did not take, and the lessons for foreign
// students or the others, are set in "Группы", not here.
val hidden = s.hidden.filterNot { it.startsWith(OPTION_PREFIX) || it.startsWith(AUDIENCE_PREFIX) }
if (hidden.isNotEmpty()) {
Text("Скрытые занятия", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
s.hidden.sorted().forEach { key ->
hidden.sorted().forEach { key ->
val (subject, type) = key.split("|", limit = 2).let { it[0] to it.getOrElse(1) { "" } }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -283,7 +290,12 @@ private fun Section(title: String, content: @Composable () -> Unit) {
}
@Composable
private fun SwitchRow(title: String, text: String, checked: Boolean, onChange: (Boolean) -> Unit) {
private fun SwitchRow(title: String, text: String, checked: Boolean, change: (Boolean) -> Unit) {
val haptic = LocalHapticFeedback.current
val onChange: (Boolean) -> Unit = { on ->
haptic.performHapticFeedback(if (on) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff)
change(on)
}
// The whole row is the touch target, not only the switch.
Row(
verticalAlignment = Alignment.CenterVertically,

View File

@@ -0,0 +1,195 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
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.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.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.xtimms.psu.schedule.ui.theme.card
// Placeholders in the shape of what is loading: the first timetable of a group, a teacher's day,
// the catalog. The screen keeps its layout instead of a lone spinner in the middle.
/**
* A soft light sweeping across a placeholder, once every 1.2 s. With animations turned off in
* the system it stays a plain tone.
*/
@Composable
private fun Modifier.shimmer(): Modifier {
val ink = MaterialTheme.colorScheme.onSurface
val base = ink.copy(alpha = 0.08f)
val light = ink.copy(alpha = 0.15f)
if (rememberReducedMotion()) return background(base)
val sweep = rememberInfiniteTransition(label = "shimmer")
val x by sweep.animateFloat(-1f, 2f, infiniteRepeatable(tween(1200, easing = LinearEasing)), label = "x")
return drawBehind {
val w = size.width.coerceAtLeast(1f)
drawRect(Brush.linearGradient(listOf(base, light, base), start = Offset(w * (x - 0.5f), 0f), end = Offset(w * (x + 0.5f), size.height)))
}
}
/** One placeholder bar or shape. */
@Composable
private fun Bone(modifier: Modifier, shape: Shape = MaterialTheme.shapes.small) {
Box(modifier.clip(shape).shimmer())
}
/** Screen readers hear "Загрузка" once, not every bar. */
private fun Modifier.loading(): Modifier = clearAndSetSemantics {
contentDescription = "Загрузка"
liveRegion = LiveRegionMode.Polite
}
/** A lesson card: subject, time, room and teacher. */
@Composable
fun LessonCardSkeleton(modifier: Modifier = Modifier) {
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.fillMaxWidth()) {
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Bone(Modifier.fillMaxWidth(0.72f).height(20.dp))
Bone(Modifier.fillMaxWidth(0.4f).height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Bone(Modifier.width(72.dp).height(14.dp))
Bone(Modifier.width(112.dp).height(14.dp))
}
}
}
}
/** A few lesson cards, for a day that is loading. */
@Composable
fun LessonsSkeleton(count: Int = 3, modifier: Modifier = Modifier) {
Column(modifier.loading(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(count) { LessonCardSkeleton() }
}
}
/** The schedule: the week's day strip, then the day's cards. */
@Composable
fun ScheduleSkeleton() {
Column(Modifier.fillMaxSize().loading()) {
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
repeat(7) {
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
Bone(Modifier.width(18.dp).height(12.dp))
Spacer(Modifier.height(8.dp))
Bone(Modifier.size(36.dp), CircleShape)
}
}
}
ContentColumn {
LessonsSkeleton(4, Modifier.padding(16.dp))
}
}
}
/** The overview: the week card, "Сегодня" and the next lessons. */
@Composable
fun OverviewSkeleton() {
ContentColumn {
Column(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp).loading(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SkeletonCard {
Bone(Modifier.width(120.dp).height(20.dp))
Bone(Modifier.fillMaxWidth(0.7f).height(26.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
repeat(6) { Bone(Modifier.weight(1f).height(52.dp), MaterialTheme.shapes.medium) }
}
}
Bone(Modifier.width(140.dp).height(28.dp).padding(top = 4.dp))
SkeletonCard {
Bone(Modifier.width(180.dp).height(20.dp))
repeat(3) {
Row(verticalAlignment = Alignment.CenterVertically) {
Bone(Modifier.size(12.dp), CircleShape)
Spacer(Modifier.width(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Bone(Modifier.widthIn(max = 260.dp).fillMaxWidth(0.8f).height(18.dp))
Bone(Modifier.width(120.dp).height(14.dp))
}
}
}
}
}
}
}
/** Subjects: a card per subject with its badge, name and next lesson. */
@Composable
fun SubjectsSkeleton() {
ContentColumn {
Column(
Modifier.fillMaxSize().padding(16.dp).loading(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
repeat(5) {
SkeletonCard {
Row(verticalAlignment = Alignment.CenterVertically) {
Bone(Modifier.size(48.dp), CircleShape)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Bone(Modifier.fillMaxWidth(0.75f).height(18.dp))
Bone(Modifier.fillMaxWidth(0.5f).height(14.dp))
}
}
}
}
}
}
}
/** Rows of a list with an avatar: the groups of the catalog. */
@Composable
fun RowsSkeleton(count: Int = 6, avatar: Dp = 40.dp) {
Column(Modifier.fillMaxWidth().loading(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
repeat(count) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp)) {
Bone(Modifier.size(avatar), CircleShape)
Spacer(Modifier.width(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Bone(Modifier.width(120.dp).height(16.dp))
Bone(Modifier.width(180.dp).height(13.dp))
}
}
}
}
}
@Composable
private fun SkeletonCard(content: @Composable () -> Unit) {
Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.card, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { content() }
}
}

View File

@@ -41,7 +41,6 @@ import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
@@ -128,7 +127,7 @@ fun SubjectsScreen(vm: MainViewModel, nav: AppNav) {
)
val t = timetable
when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
t == null && status.loading -> SubjectsSkeleton()
t == null -> CenteredEmpty {
Empty("Предметов пока нет", "Выберите группу — предметы появятся из её расписания.", icon = Icons.AutoMirrored.Rounded.MenuBook, action = "Выбрать группу" to nav.openGroups)
}
@@ -328,6 +327,8 @@ private fun Fact(icon: ImageVector, text: String, strong: Boolean = false) {
@Composable
private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
val color = subjectColor(s.name)
// A task deleted in the sheet offers "Вернуть" in the sheet itself.
UndoArea(bottom = false) {
Column(
Modifier
.verticalScroll(rememberScrollState())
@@ -372,6 +373,7 @@ private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
}
}
}
}
}
@Composable
@@ -389,6 +391,7 @@ private fun DetailLine(icon: ImageVector, label: String, value: String) {
/** Every task and note of the subject, open ones first, with a quick add for the next lesson. */
@Composable
private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
val undo = LocalUndo.current
val all by vm.notes.collectAsStateWithLifecycle()
val group = vm.currentGroupKey() ?: return
val notes = all.filter { subjectKey(it.subject) == subjectKey(s.name) }
@@ -414,7 +417,7 @@ private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
editing = editing,
groupKey = editing?.group ?: group,
onSave = { vm.saveNote(it); adding = false; editing = null },
onDelete = { vm.deleteNote(it.id); editing = null },
onDelete = { vm.deleteNote(it.id, undo); editing = null },
onDismiss = { adding = false; editing = null },
)
}

View File

@@ -131,7 +131,7 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
shape = MaterialTheme.shapes.extraLarge,
color = color.soft,
contentColor = color.onSoft,
modifier = modifier,
modifier = modifier.morphOrigin("stand|${slot.date}|${lesson.key}|${lesson.start}", color.soft, 28.dp),
) {
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -147,7 +147,7 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
color = if (running) color.container else MaterialTheme.colorScheme.surface,
contentColor = if (running) color.content else MaterialTheme.colorScheme.onSurface,
) {
Text(
RollingText(
if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),

View File

@@ -0,0 +1,67 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* "Задание удалено · Вернуть": actions that are easy to regret are done at once and offered back
* in a snackbar instead of asking first. [offer] shows it in the nearest host — the screen's, or
* a bottom sheet's own, since a sheet covers the screen's.
*/
class UndoHost(val state: SnackbarHostState, private val scope: CoroutineScope) {
/** [onGone] runs when the chance to undo has passed: the snackbar timed out or was replaced. */
fun offer(message: String, onUndo: () -> Unit, onGone: () -> Unit = {}) {
scope.launch {
// A new action replaces the previous offer, which then cannot be undone any more.
state.currentSnackbarData?.dismiss()
var undone = false
try {
undone = state.showSnackbar(message, actionLabel = "Вернуть", duration = SnackbarDuration.Long) ==
SnackbarResult.ActionPerformed
if (undone) onUndo()
} finally {
if (!undone) onGone()
}
}
}
}
val LocalUndo = staticCompositionLocalOf<UndoHost?> { null }
/** A host for [content] and its snackbar at the bottom: the app's screens, or a sheet. */
@Composable
fun UndoArea(modifier: Modifier = Modifier, bottom: Boolean = true, content: @Composable BoxScope.() -> Unit) {
val state = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val host = remember(state, scope) { UndoHost(state, scope) }
Box(modifier) {
CompositionLocalProvider(LocalUndo provides host) { content() }
SnackbarHost(
state,
Modifier
.align(Alignment.BottomCenter)
.then(if (bottom) Modifier.navigationBarsPadding() else Modifier)
.padding(horizontal = 16.dp, vertical = 12.dp)
.widthIn(max = 560.dp)
.fillMaxWidth(),
)
}
}

View File

@@ -2,6 +2,8 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.runtime.getValue
import androidx.compose.animation.core.tween
import androidx.compose.ui.text.intl.LocaleList
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak
@@ -165,7 +167,8 @@ fun WeekGrid(
}
}
if (d == today() && clock.hour in first until last) {
val y = top(clock)
// The "now" line slides down with the clock instead of hopping every half minute.
val y by androidx.compose.animation.core.animateDpAsState(top(clock), tween(1000), label = "now")
Box(Modifier.offset(y = y - 1.dp).fillMaxWidth().height(2.dp).background(MaterialTheme.colorScheme.primary))
Box(Modifier.offset(x = (-5).dp, y = y - 5.dp).size(10.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
}
@@ -179,6 +182,7 @@ fun WeekGrid(
private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, onClick: () -> Unit, modifier: Modifier) {
val color = subjectColor(lesson.subject)
val session = lesson.session
val origin = Modifier.morphOrigin("grid|${slot.date}|${lesson.key}|${lesson.start}", color.container, 8.dp)
val rooms = lesson.rooms.joinToString(", ").takeIf { LocalDisplay.current.rooms }.orEmpty()
// A narrow column keeps the room whole: the day's town is under its date.
val room = when {
@@ -192,7 +196,7 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o
color = color.container,
contentColor = color.content,
border = session?.let { androidx.compose.foundation.BorderStroke(2.dp, it.outline()) },
modifier = modifier.semantics {
modifier = modifier.then(origin).semantics {
contentDescription = "${lesson.subject}, ${slot.start}–${slot.end}" +
(if (room.isNotEmpty()) ", ауд. $room" else "") + (lesson.type.takeIf { it.isNotEmpty() }?.let { ", $it" } ?: "") +
(if (notes > 0) ", заданий и заметок: $notes" else "")

View File

@@ -0,0 +1,246 @@
package org.xtimms.psu.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.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalSize
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import org.xtimms.psu.schedule.Launch
import org.xtimms.psu.schedule.R
import org.xtimms.psu.schedule.app
import org.xtimms.psu.schedule.data.Week
import org.xtimms.psu.schedule.ui.theme.WeekColors
import androidx.glance.color.ColorProvider
import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.monday
import org.xtimms.psu.schedule.data.plural
import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.ui.abbreviate
import java.time.LocalDate
/** A day of the week as the widget shows it. */
private data class WeekDay(
val date: LocalDate,
/** Abbreviations of the day's lessons, one per start time: "ФГА", "ЯПОА / ПУПР". */
val lessons: List<String>,
/** "Полоцк" when the day is in another town. */
val place: String?,
val moved: Boolean,
)
private data class WeekState(
val group: String?,
val week: Week?,
val monday: LocalDate,
val days: List<WeekDay>,
val dynamic: Boolean,
)
private suspend fun state(context: Context): WeekState? {
val app = context.app
val t = app.repository.timetable.value ?: return null
val s = app.settings.now()
// On Sunday the coming week is the useful one, as in the overview's week pane.
val monday = (if (today().dayOfWeek.value == 7) today().plusDays(1) else today()).monday()
val all = (0..6L).map { monday.plusDays(it) }.map { d ->
val lessons = app.repository.lessonsOn(t, d, s.subgroup, s.hidden)
WeekDay(
date = d,
lessons = lessons.groupBy { it.start }.toSortedMap().values.map { slot -> slot.joinToString(" / ") { abbreviate(it.subject) } },
place = app.repository.placesOn(t, d, s.subgroup, s.hidden).firstOrNull()?.place,
moved = lessons.any { it.roomMoved },
)
}
return WeekState(
group = t.group,
week = t.days.firstOrNull { it.date == monday.toString() }?.week,
monday = monday,
// Sunday only takes a column when something happens on it.
days = all.filter { it.date.dayOfWeek.value < 7 || it.lessons.isNotEmpty() },
dynamic = s.dynamicColor,
)
}
/**
* The week at a glance (4×2 and larger): each day with its number of lessons, a city mark on
* days in another town and a swap mark on days with a room move; taller, the lessons too. A day
* opens in the app.
*/
class WeekWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = state(context)
val dynamic = data?.dynamic ?: context.app.settings.now().dynamicColor
provideContent { WidgetTheme(dynamic) { Content(context, data) } }
}
@Composable
private fun Content(context: Context, data: WeekState?) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.roundedBackground(GlanceTheme.colors.widgetBackground, 28.dp)
.padding(12.dp),
) {
if (data == null) {
Text(
"Откройте приложение и выберите группу",
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp),
modifier = GlanceModifier.padding(4.dp).clickable(actionStartActivity(Launch.intent(context, "today"))),
)
return@Column
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = GlanceModifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, bottom = 8.dp)
.clickable(actionStartActivity(Launch.intent(context, "week"))),
) {
Text(
data.week?.let { "Неделя ${it.n}" } ?: "Неделя",
style = TextStyle(color = GlanceTheme.colors.primary, fontWeight = FontWeight.Bold, fontSize = 15.sp),
)
data.week?.let { w ->
Spacer(GlanceModifier.width(8.dp))
// The university's week colours, as on the app's week mark.
Text(
if (w.white) "белая" else "зелёная",
style = TextStyle(
color = ColorProvider(day = WeekColors.content(w.white, false), night = WeekColors.content(w.white, true)),
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier
.roundedBackground(
if (w.white) GlanceTheme.colors.surfaceVariant
else ColorProvider(day = WeekColors.container(false, false), night = WeekColors.container(false, true)),
8.dp,
)
.padding(horizontal = 6.dp, vertical = 1.dp),
)
}
Spacer(GlanceModifier.defaultWeight())
Text(data.group.orEmpty(), maxLines = 1, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp))
}
// Header about 40dp, padding 24dp; a lesson line about 14dp below the day's head.
val height = LocalSize.current.height
val lines = ((height - 40.dp - 24.dp - 62.dp) / 14.dp).toInt().coerceIn(0, 6)
val summary = summary(data.days)
val roomForSummary = summary != null && height >= 150.dp
// Glance keeps at most ten children in a row: the gaps are the tiles' padding, not spacers.
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
data.days.forEachIndexed { index, day ->
DayTile(
context, day, lines - if (roomForSummary) 1 else 0,
GlanceModifier.defaultWeight().fillMaxHeight()
.padding(start = if (index > 0) 2.dp else 0.dp, end = if (index < data.days.lastIndex) 2.dp else 0.dp),
)
}
}
if (roomForSummary) Text(
summary!!,
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 11.sp),
modifier = GlanceModifier.padding(start = 4.dp, top = 6.dp),
)
}
}
@Composable
private fun DayTile(context: Context, day: WeekDay, lines: Int, modifier: GlanceModifier) {
val isToday = day.date == today()
val past = day.date < today()
val count = day.lessons.size
val (fill, ink) = when {
isToday -> GlanceTheme.colors.primary to GlanceTheme.colors.onPrimary
count > 0 -> GlanceTheme.colors.secondaryContainer to GlanceTheme.colors.onSecondaryContainer
else -> GlanceTheme.colors.surfaceVariant to GlanceTheme.colors.onSurfaceVariant
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.roundedBackground(fill, 16.dp)
.padding(vertical = 6.dp, horizontal = 2.dp)
.clickable(actionStartActivity(Launch.intent(context, "day", day.date))),
) {
Text(day.date.shortWeekday(), style = TextStyle(color = ink, fontSize = 11.sp, textAlign = TextAlign.Center))
Text(
"${day.date.dayOfMonth}",
style = TextStyle(color = ink, fontSize = 17.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
)
Text(
if (count == 0) "—" else "$count ${plural(count, "пара", "пары", "пар")}",
maxLines = 1,
style = TextStyle(
color = ink,
fontSize = 10.sp,
textAlign = TextAlign.Center,
fontWeight = if (past) FontWeight.Normal else FontWeight.Medium,
),
)
if (day.place != null || day.moved) Row(modifier = GlanceModifier.padding(top = 2.dp)) {
if (day.place != null) Image(
ImageProvider(R.drawable.ic_widget_city),
contentDescription = "Занятия: ${day.place}",
colorFilter = ColorFilter.tint(if (isToday) ink else GlanceTheme.colors.tertiary),
modifier = GlanceModifier.size(14.dp),
)
if (day.moved) Image(
ImageProvider(R.drawable.ic_widget_move),
contentDescription = "Перенос аудитории",
colorFilter = ColorFilter.tint(if (isToday) ink else GlanceTheme.colors.error),
modifier = GlanceModifier.size(14.dp).padding(start = if (day.place != null) 2.dp else 0.dp),
)
}
// Taller: the lessons themselves, as the week grid abbreviates them.
if (lines > 0) {
Spacer(GlanceModifier.height(2.dp))
day.lessons.take(lines).forEach {
Text(it, maxLines = 1, style = TextStyle(color = ink, fontSize = 10.sp, textAlign = TextAlign.Center))
}
if (day.lessons.size > lines) Text("…", style = TextStyle(color = ink, fontSize = 10.sp))
}
}
}
/** "Полоцк: вт, пт · перенос: пн, чт", or null for an ordinary week. */
private fun summary(days: List<WeekDay>): String? {
val away = days.filter { it.place != null }.groupBy { it.place!! }
.map { (place, list) -> "$place: " + list.joinToString(", ") { it.date.shortWeekday().lowercase() } }
val moved = days.filter { it.moved }.takeIf { it.isNotEmpty() }
?.let { list -> "перенос: " + list.joinToString(", ") { it.date.shortWeekday().lowercase() } }
return (away + listOfNotNull(moved)).joinToString(" · ").ifEmpty { null }
}
}
class WeekWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = WeekWidget()
}

View File

@@ -17,6 +17,7 @@ import org.xtimms.psu.schedule.notify.DayAlerts
import org.xtimms.psu.schedule.notify.LiveLesson
import org.xtimms.psu.schedule.notify.Reminders
import org.xtimms.psu.schedule.widget.TodayWidget
import org.xtimms.psu.schedule.widget.WeekWidget
import java.util.concurrent.TimeUnit
/**
@@ -61,6 +62,7 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c
LiveLesson.reschedule(context, t, s, app.repository)
TodayWidget().updateAll(context)
NextLessonWidget().updateAll(context)
WeekWidget().updateAll(context)
LessonTileService.refresh(context)
}
}
@@ -71,6 +73,7 @@ class WidgetTick(context: Context, params: WorkerParameters) : CoroutineWorker(c
override suspend fun doWork(): Result {
TodayWidget().updateAll(applicationContext)
NextLessonWidget().updateAll(applicationContext)
WeekWidget().updateAll(applicationContext)
LessonTileService.refresh(applicationContext)
return Result.success()
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The calendar grows into place. -->
<set xmlns:android="http://schemas.android.com/apk/res/android">
<objectAnimator
android:duration="450"
android:interpolator="@interpolator/emphasized_decelerate"
android:propertyName="scaleX"
android:valueFrom="0.7"
android:valueTo="1" />
<objectAnimator
android:duration="450"
android:interpolator="@interpolator/emphasized_decelerate"
android:propertyName="scaleY"
android:valueFrom="0.7"
android:valueTo="1" />
</set>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Last, the green check is drawn: the day is sorted. -->
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="350"
android:startOffset="400"
android:interpolator="@interpolator/emphasized_decelerate"
android:propertyName="trimPathEnd"
android:valueFrom="0"
android:valueTo="1" />

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A line of the calendar appears. -->
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="250"
android:interpolator="@android:interpolator/linear"
android:propertyName="strokeAlpha"
android:valueFrom="0"
android:valueTo="1" />

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The rings fade in as they drop. -->
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="250"
android:startOffset="150"
android:interpolator="@android:interpolator/linear"
android:propertyName="strokeAlpha"
android:valueFrom="0"
android:valueTo="1" />

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The binder rings drop onto the calendar. -->
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="350"
android:startOffset="150"
android:interpolator="@interpolator/emphasized_decelerate"
android:propertyName="translateY"
android:valueFrom="-4"
android:valueTo="0" />

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The first frame of the launch animation (drawable/splash_icon_animated.xml).
The app icon for the launch screen: a blue disc, the calendar, the green check (ios/Config/AppIcon.svg). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M54,18a36,36 0,1 1,0 72a36,36 0,1 1,0 -72z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="54"
android:startY="18"
android:endX="54"
android:endY="90">
<item android:offset="0" android:color="#FF1A7FD1" />
<item android:offset="1" android:color="#FF004F87" />
</gradient>
</aapt:attr>
</path>
<group
android:name="calendar"
android:pivotX="54"
android:pivotY="54"
android:scaleX="0.7"
android:scaleY="0.7">
<path
android:name="frame"
android:pathData="M35.76,40.08H72.24V72.08H35.76Z"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="3.84"
android:strokeLineJoin="round"
android:strokeAlpha="0" />
<path
android:name="header"
android:pathData="M35.76,48.72H72.24"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="3.84"
android:strokeLineCap="round"
android:strokeAlpha="0" />
<group
android:name="tabs"
android:translateY="-4">
<path
android:name="rings"
android:pathData="M45.68,35.92V43.6M62.32,35.92V43.6"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="3.84"
android:strokeLineCap="round"
android:strokeAlpha="0" />
</group>
<path
android:name="check"
android:pathData="M45.84,59.92L51.44,65.52L62.48,54.16"
android:strokeColor="#FFC8EFB8"
android:strokeWidth="4"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:trimPathEnd="0" />
</group>
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Week widget: a day in another town; tinted by the widget. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M15,11V5l-3,-3 -3,3v2H3v14h18V11h-6zM7,19H5v-2h2v2zM7,15H5v-2h2v2zM7,11H5V9h2v2zM13,19h-2v-2h2v2zM13,15h-2v-2h2v2zM13,11h-2V9h2v2zM13,7h-2V5h2v2zM19,19h-2v-2h2v2zM19,15h-2v-2h2v2z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Week widget: a room move; tinted by the widget. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M6.99,11L3,15l3.99,4v-3H14v-2H6.99v-3zM21,9l-3.99,-4v3H10v2h7.01v3L21,9z" />
</vector>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Before Android 12 the launch icon does not move: its last frame. The animation starts from drawable-v31.
The app icon for the launch screen: a blue disc, the calendar, the green check (ios/Config/AppIcon.svg). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M54,18a36,36 0,1 1,0 72a36,36 0,1 1,0 -72z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="54"
android:startY="18"
android:endX="54"
android:endY="90">
<item android:offset="0" android:color="#FF1A7FD1" />
<item android:offset="1" android:color="#FF004F87" />
</gradient>
</aapt:attr>
</path>
<group
android:name="calendar"
android:pivotX="54"
android:pivotY="54"
android:scaleX="1"
android:scaleY="1">
<path
android:name="frame"
android:pathData="M35.76,40.08H72.24V72.08H35.76Z"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="3.84"
android:strokeLineJoin="round"
android:strokeAlpha="1" />
<path
android:name="header"
android:pathData="M35.76,48.72H72.24"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="3.84"
android:strokeLineCap="round"
android:strokeAlpha="1" />
<group
android:name="tabs"
android:translateY="0">
<path
android:name="rings"
android:pathData="M45.68,35.92V43.6M62.32,35.92V43.6"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="3.84"
android:strokeLineCap="round"
android:strokeAlpha="1" />
</group>
<path
android:name="check"
android:pathData="M45.84,59.92L51.44,65.52L62.48,54.16"
android:strokeColor="#FFC8EFB8"
android:strokeWidth="4"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:trimPathEnd="1" />
</group>
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The launch screen's icon, about 750 ms: the calendar grows, its rings drop, the check is drawn. -->
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/splash_icon">
<target android:name="calendar" android:animation="@animator/splash_calendar" />
<target android:name="frame" android:animation="@animator/splash_fade" />
<target android:name="header" android:animation="@animator/splash_fade" />
<target android:name="tabs" android:animation="@animator/splash_tabs" />
<target android:name="rings" android:animation="@animator/splash_rings" />
<target android:name="check" android:animation="@animator/splash_check" />
</animated-vector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- M3 emphasized decelerate: things entering the screen. -->
<pathInterpolator xmlns:android="http://schemas.android.com/apk/res/android"
android:controlX1="0.05"
android:controlY1="0.7"
android:controlX2="0.1"
android:controlY2="1" />

View File

@@ -5,4 +5,12 @@
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<!-- The launch screen (core-splashscreen): the app surface and the animated icon, then the app. -->
<style name="Theme.PsuSchedule.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/surface</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon_animated</item>
<item name="windowSplashScreenAnimationDuration">750</item>
<item name="postSplashScreenTheme">@style/Theme.PsuSchedule</item>
</style>
</resources>

View File

@@ -17,5 +17,7 @@
<string name="shortcut_task_long">Новое задание</string>
<string name="next_widget_name">Следующая пара</string>
<string name="next_widget_description">Когда следующая пара, что это и в какой аудитории</string>
<string name="week_widget_name">Неделя</string>
<string name="week_widget_description">Пары каждого дня недели, дни в другом городе и переносы аудиторий</string>
<string name="tile_name">Пара</string>
</resources>

View File

@@ -6,4 +6,12 @@
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<!-- The launch screen (core-splashscreen): the app surface and the animated icon, then the app. -->
<style name="Theme.PsuSchedule.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/surface</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon_animated</item>
<item name="windowSplashScreenAnimationDuration">750</item>
<item name="postSplashScreenTheme">@style/Theme.PsuSchedule</item>
</style>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Where the camera app may write a photo for a task: the cache, copied and shrunk afterwards. -->
<paths>
<cache-path name="camera" path="camera/" />
</paths>

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/week_widget_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp"
android:minHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />

View File

@@ -5,6 +5,7 @@ composeBom = "2026.09.01"
activity = "1.13.0"
lifecycle = "2.11.0"
coreKtx = "1.19.1"
splashscreen = "1.2.0"
coroutines = "1.11.0"
serialization = "1.11.0"
okhttp = "4.12.0"
@@ -27,6 +28,8 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref
lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
# The launch screen with the animated icon on every Android version (Android 12+ draws its own).
splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashscreen" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }