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:
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
119
android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Done.kt
Normal file
119
android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Done.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
261
android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Morph.kt
Normal file
261
android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Morph.kt
Normal 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() }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
274
android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Photos.kt
Normal file
274
android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Photos.kt
Normal 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
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
16
android/app/src/main/res/animator/splash_calendar.xml
Normal file
16
android/app/src/main/res/animator/splash_calendar.xml
Normal 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>
|
||||
9
android/app/src/main/res/animator/splash_check.xml
Normal file
9
android/app/src/main/res/animator/splash_check.xml
Normal 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" />
|
||||
8
android/app/src/main/res/animator/splash_fade.xml
Normal file
8
android/app/src/main/res/animator/splash_fade.xml
Normal 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" />
|
||||
9
android/app/src/main/res/animator/splash_rings.xml
Normal file
9
android/app/src/main/res/animator/splash_rings.xml
Normal 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" />
|
||||
9
android/app/src/main/res/animator/splash_tabs.xml
Normal file
9
android/app/src/main/res/animator/splash_tabs.xml
Normal 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" />
|
||||
63
android/app/src/main/res/drawable-v31/splash_icon.xml
Normal file
63
android/app/src/main/res/drawable-v31/splash_icon.xml
Normal 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>
|
||||
11
android/app/src/main/res/drawable/ic_widget_city.xml
Normal file
11
android/app/src/main/res/drawable/ic_widget_city.xml
Normal 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>
|
||||
11
android/app/src/main/res/drawable/ic_widget_move.xml
Normal file
11
android/app/src/main/res/drawable/ic_widget_move.xml
Normal 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>
|
||||
63
android/app/src/main/res/drawable/splash_icon.xml
Normal file
63
android/app/src/main/res/drawable/splash_icon.xml
Normal 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>
|
||||
11
android/app/src/main/res/drawable/splash_icon_animated.xml
Normal file
11
android/app/src/main/res/drawable/splash_icon_animated.xml
Normal 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>
|
||||
@@ -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" />
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
5
android/app/src/main/res/xml/photo_paths.xml
Normal file
5
android/app/src/main/res/xml/photo_paths.xml
Normal 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>
|
||||
11
android/app/src/main/res/xml/week_widget_info.xml
Normal file
11
android/app/src/main/res/xml/week_widget_info.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/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" />
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user