feat: Add Settings and Subjects screens with theme reveal functionality
- Implemented SettingsScreen for user preferences including theme selection, reminders, and notifications. - Created SubjectsScreen to display subjects with upcoming lessons and associated details. - Introduced ThemeReveal for a smooth transition between light and dark themes. - Added support for managing notes and tasks related to subjects. - Enhanced UI components for better user experience and accessibility.
This commit is contained in:
@@ -19,8 +19,8 @@ android {
|
||||
applicationId = "org.xtimms.psu.schedule"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionCode = 2
|
||||
versionName = "0.1.1"
|
||||
buildConfigField("String", "API_URL", "\"${apiUrl.get()}\"")
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,10 @@ 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 org.xtimms.psu.schedule.ui.components.UndoArea
|
||||
import org.xtimms.psu.schedule.ui.motion.LocalMorph
|
||||
import org.xtimms.psu.schedule.ui.motion.MorphHost
|
||||
import org.xtimms.psu.schedule.ui.motion.MorphOverlay
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.core.SeekableTransitionState
|
||||
@@ -20,8 +20,8 @@ import androidx.compose.material.icons.outlined.CalendarViewWeek
|
||||
import androidx.compose.material.icons.outlined.Checklist
|
||||
import androidx.compose.material.icons.rounded.CalendarViewWeek
|
||||
import androidx.compose.material.icons.rounded.Checklist
|
||||
import org.xtimms.psu.schedule.ui.CalendarScreen
|
||||
import org.xtimms.psu.schedule.ui.PlansScreen
|
||||
import org.xtimms.psu.schedule.ui.plans.CalendarScreen
|
||||
import org.xtimms.psu.schedule.ui.plans.PlansScreen
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -39,13 +39,13 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.automirrored.rounded.MenuOpen
|
||||
import org.xtimms.psu.schedule.ui.materialFadeThrough
|
||||
import org.xtimms.psu.schedule.ui.Emphasized
|
||||
import org.xtimms.psu.schedule.ui.MotionDuration
|
||||
import org.xtimms.psu.schedule.ui.motion.materialFadeThrough
|
||||
import org.xtimms.psu.schedule.ui.motion.Emphasized
|
||||
import org.xtimms.psu.schedule.ui.motion.MotionDuration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.animation.core.tween
|
||||
import org.xtimms.psu.schedule.ui.materialSharedAxisX
|
||||
import org.xtimms.psu.schedule.ui.rememberSlideDistance
|
||||
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -60,10 +60,10 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import org.xtimms.psu.schedule.ui.LocalHinge
|
||||
import org.xtimms.psu.schedule.ui.EmphasizedDecelerate
|
||||
import org.xtimms.psu.schedule.ui.EmphasizedAccelerate
|
||||
import org.xtimms.psu.schedule.ui.LocalFoldTablet
|
||||
import org.xtimms.psu.schedule.ui.components.LocalHinge
|
||||
import org.xtimms.psu.schedule.ui.motion.EmphasizedDecelerate
|
||||
import org.xtimms.psu.schedule.ui.motion.EmphasizedAccelerate
|
||||
import org.xtimms.psu.schedule.ui.components.LocalFoldTablet
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -115,27 +115,31 @@ import kotlinx.coroutines.launch
|
||||
import org.xtimms.psu.schedule.data.ThemeMode
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.GroupsScreen
|
||||
import org.xtimms.psu.schedule.ui.HomeScreen
|
||||
import org.xtimms.psu.schedule.ui.Onboarding
|
||||
import org.xtimms.psu.schedule.ui.LocalDisplay
|
||||
import org.xtimms.psu.schedule.ui.LocalMarks
|
||||
import org.xtimms.psu.schedule.ui.groups.GroupsScreen
|
||||
import org.xtimms.psu.schedule.ui.home.HomeScreen
|
||||
import org.xtimms.psu.schedule.ui.onboarding.Onboarding
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalMarks
|
||||
import org.xtimms.psu.schedule.ui.LocalMenuProgress
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import org.xtimms.psu.schedule.ui.LocalNotes
|
||||
import org.xtimms.psu.schedule.ui.LocalSubjectSlots
|
||||
import org.xtimms.psu.schedule.ui.notes.LocalNotes
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalSubjectSlots
|
||||
import org.xtimms.psu.schedule.data.Display
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.ScheduleScreen
|
||||
import org.xtimms.psu.schedule.ui.SearchScreen
|
||||
import org.xtimms.psu.schedule.ui.SettingsScreen
|
||||
import org.xtimms.psu.schedule.ui.SubjectsScreen
|
||||
import org.xtimms.psu.schedule.ui.subjectSlots
|
||||
import org.xtimms.psu.schedule.ui.schedule.ScheduleScreen
|
||||
import org.xtimms.psu.schedule.ui.search.SearchScreen
|
||||
import org.xtimms.psu.schedule.ui.settings.SettingsScreen
|
||||
import org.xtimms.psu.schedule.ui.subjects.SubjectsScreen
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectSlots
|
||||
import org.xtimms.psu.schedule.ui.theme.ScheduleTheme
|
||||
import org.xtimms.psu.schedule.ui.theme.ThemeRevealHost
|
||||
import org.xtimms.psu.schedule.ui.lesson.LessonActionsHost
|
||||
import org.xtimms.psu.schedule.ui.theme.isDark
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.components.LocalSwipeHint
|
||||
import org.xtimms.psu.schedule.ui.components.SwipeHint
|
||||
|
||||
private enum class Tab(val label: String, val icon: ImageVector, val selectedIcon: ImageVector) {
|
||||
Home("Обзор", Icons.Outlined.Home, Icons.Rounded.Home),
|
||||
@@ -193,9 +197,11 @@ class MainActivity : ComponentActivity() {
|
||||
onDispose {}
|
||||
}
|
||||
// The fold of a foldable phone, for screens that split their panes around it.
|
||||
val hinge = org.xtimms.psu.schedule.ui.rememberHinge(this)
|
||||
val hinge = org.xtimms.psu.schedule.ui.components.rememberHinge(this)
|
||||
ScheduleTheme(mode = s?.theme ?: ThemeMode.System, dynamicColor = s?.dynamicColor ?: false) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(org.xtimms.psu.schedule.ui.LocalHinge provides hinge) {
|
||||
// Light and dark swap with a circle spreading from the finger (ThemeReveal.kt).
|
||||
ThemeRevealHost(key = dark to (s?.dynamicColor ?: false)) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(org.xtimms.psu.schedule.ui.components.LocalHinge provides hinge) {
|
||||
Surface(color = MaterialTheme.colorScheme.page, modifier = Modifier.fillMaxSize()) {
|
||||
// Wait for the stored settings so a returning student never sees the onboarding.
|
||||
if (s != null) {
|
||||
@@ -207,11 +213,18 @@ class MainActivity : ComponentActivity() {
|
||||
label = "onboarding",
|
||||
) { first ->
|
||||
if (first) Onboarding(vm, onDone = { onboarding = false })
|
||||
else App(vm, firstRun = s.current == null, launch = launch.value, onLaunched = { launch.value = null })
|
||||
else {
|
||||
// Until the first swipe, a task row shows how it is done (SwipeActions.kt).
|
||||
val hint = remember(s.swipeTried) { SwipeHint(!s.swipeTried, vm::swipeTried) }
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalSwipeHint provides hint) {
|
||||
App(vm, firstRun = s.current == null, launch = launch.value, onLaunched = { launch.value = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,6 +294,8 @@ private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunche
|
||||
val morph = remember { MorphHost() }
|
||||
UndoArea(Modifier.fillMaxSize()) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalMorph provides morph) {
|
||||
// A long press on a lesson anywhere opens its actions (LessonActions.kt).
|
||||
LessonActionsHost(vm) {
|
||||
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.
|
||||
@@ -535,6 +550,7 @@ 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)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,16 @@ data class WeeklyDay(val day: Int, val lessons: List<Lesson> = emptyList(), val
|
||||
* when `subgroup` is set) are in another town (server/timetable.js placesOn).
|
||||
*/
|
||||
@Serializable
|
||||
data class DayPlace(val place: String, val text: String, val subgroup: String? = null)
|
||||
data class DayPlace(
|
||||
val place: String,
|
||||
val text: String,
|
||||
val subgroup: String? = null,
|
||||
/** "13:40": the note stands in the middle of the day and holds from that lesson on. */
|
||||
val from: String? = null,
|
||||
) {
|
||||
/** "С 13:40 — занятия в Полоцке", or the note as it is when it holds all day. */
|
||||
val label: String get() = from?.let { "С $it — " + text.replaceFirstChar { c -> c.lowercase() } } ?: text
|
||||
}
|
||||
|
||||
/** The notes for the student's subgroup; a note for their own subgroup reads as the whole day's. */
|
||||
fun List<DayPlace>.forSubgroup(subgroup: String?): List<DayPlace> =
|
||||
|
||||
@@ -56,6 +56,12 @@ data class UserSettings(
|
||||
val roomTime: Int = 7 * 60,
|
||||
/** The lesson under way as an ongoing notification (a Live Update on Android 16). */
|
||||
val liveLesson: Boolean = false,
|
||||
/** The student has swiped a task once: the rows stop showing how. */
|
||||
val swipeTried: Boolean = false,
|
||||
/** Teachers and rooms starred in the search, as "teacher:Ковшик О.С." or "room:206Г". */
|
||||
val searchFavorites: List<String> = emptyList(),
|
||||
/** The teachers and rooms opened in the search lately, newest first. */
|
||||
val searchRecent: List<String> = emptyList(),
|
||||
val recent: List<SavedGroup> = emptyList(),
|
||||
val display: Display = Display(),
|
||||
/**
|
||||
@@ -90,6 +96,9 @@ class Settings(private val context: Context) {
|
||||
val roomAlerts = booleanPreferencesKey("room_alerts")
|
||||
val roomTime = intPreferencesKey("room_time")
|
||||
val liveLesson = booleanPreferencesKey("live_lesson")
|
||||
val swipeTried = booleanPreferencesKey("swipe_tried")
|
||||
val searchFavorites = stringPreferencesKey("search_favorites")
|
||||
val searchRecent = stringPreferencesKey("search_recent")
|
||||
val recent = stringPreferencesKey("recent")
|
||||
val colors = booleanPreferencesKey("show_colors")
|
||||
val teachers = booleanPreferencesKey("show_teachers")
|
||||
@@ -103,6 +112,9 @@ class Settings(private val context: Context) {
|
||||
val plansDone = booleanPreferencesKey("plans_done")
|
||||
}
|
||||
|
||||
private fun Preferences.list(key: Preferences.Key<String>): List<String> =
|
||||
this[key]?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }.orEmpty()
|
||||
|
||||
private fun Preferences.choiceMap(): Map<String, Map<String, String>> =
|
||||
this[Keys.choices]?.let { runCatching { json.decodeFromString<Map<String, Map<String, String>>>(it) }.getOrNull() }.orEmpty()
|
||||
|
||||
@@ -143,6 +155,9 @@ class Settings(private val context: Context) {
|
||||
roomAlerts = this[Keys.roomAlerts] ?: true,
|
||||
roomTime = this[Keys.roomTime] ?: 7 * 60,
|
||||
liveLesson = this[Keys.liveLesson] ?: false,
|
||||
swipeTried = this[Keys.swipeTried] ?: false,
|
||||
searchFavorites = list(Keys.searchFavorites),
|
||||
searchRecent = list(Keys.searchRecent),
|
||||
recent = this[Keys.recent]?.let {
|
||||
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
|
||||
}.orEmpty(),
|
||||
@@ -229,6 +244,18 @@ class Settings(private val context: Context) {
|
||||
suspend fun setRoomAlerts(on: Boolean) = context.store.edit { it[Keys.roomAlerts] = on }
|
||||
suspend fun setRoomTime(minutes: Int) = context.store.edit { it[Keys.roomTime] = minutes }
|
||||
suspend fun setLiveLesson(on: Boolean) = context.store.edit { it[Keys.liveLesson] = on }
|
||||
suspend fun swipeTried() = context.store.edit { it[Keys.swipeTried] = true }
|
||||
|
||||
/** Stars a teacher or a room of the search, or takes the star back. */
|
||||
suspend fun toggleSearchFavorite(target: String) = context.store.edit { prefs ->
|
||||
val list = prefs.list(Keys.searchFavorites)
|
||||
prefs[Keys.searchFavorites] = json.encodeToString(if (target in list) list - target else list + target)
|
||||
}
|
||||
|
||||
/** Remembers a teacher or a room opened in the search; the latest eight stay. */
|
||||
suspend fun rememberSearch(target: String) = context.store.edit { prefs ->
|
||||
prefs[Keys.searchRecent] = json.encodeToString((listOf(target) + prefs.list(Keys.searchRecent).filter { it != target }).take(8))
|
||||
}
|
||||
|
||||
/** Hides or shows again a recurring lesson of the open group. */
|
||||
suspend fun setHidden(key: String, hidden: Boolean) = context.store.edit { prefs ->
|
||||
|
||||
@@ -13,8 +13,8 @@ import org.xtimms.psu.schedule.data.ScheduleChanges
|
||||
import org.xtimms.psu.schedule.data.dayName
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.ui.changeDate
|
||||
import org.xtimms.psu.schedule.ui.changesLabel
|
||||
import org.xtimms.psu.schedule.ui.home.changeDate
|
||||
import org.xtimms.psu.schedule.ui.home.changesLabel
|
||||
|
||||
/**
|
||||
* A notification when the background sync finds that the university republished the group's
|
||||
|
||||
@@ -74,7 +74,8 @@ object DayAlerts {
|
||||
val at = date.minusDays(1).atTime(LocalTime.ofSecondOfDay(settings.travelTime * 60L)).atZone(MINSK).toInstant()
|
||||
if (at.isAfter(now)) {
|
||||
val note = places.first()
|
||||
val title = "Завтра " + note.text.replaceFirstChar { it.lowercase() } + (note.subgroup?.let { " · ${it.lowercase()}" } ?: "")
|
||||
// "Завтра с 13:40 — занятия в Полоцке" when the town is only for the afternoon.
|
||||
val title = "Завтра " + note.label.replaceFirstChar { it.lowercase() } + (note.subgroup?.let { " · ${it.lowercase()}" } ?: "")
|
||||
val text = "Первая пара в ${first.start} — ${first.subject}" +
|
||||
(withPlace(rooms(first), first.place)?.let { ", $it" } ?: "") +
|
||||
". Всего ${lessons.distinctBy { it.start }.size} ${plural(lessons.distinctBy { it.start }.size, "пара", "пары", "пар")}."
|
||||
|
||||
@@ -24,7 +24,7 @@ import org.xtimms.psu.schedule.data.nowDateTime
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.withPlace
|
||||
import org.xtimms.psu.schedule.ui.lessonEnd
|
||||
import org.xtimms.psu.schedule.ui.lesson.lessonEnd
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@@ -23,6 +23,11 @@ import org.xtimms.psu.schedule.data.ThemeMode
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.UserSettings
|
||||
import org.xtimms.psu.schedule.work.SyncWorker
|
||||
import org.xtimms.psu.schedule.ui.components.UndoHost
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.ui.notes.dueLabel
|
||||
import org.xtimms.psu.schedule.ui.notes.general
|
||||
import org.xtimms.psu.schedule.ui.notes.nextOccurrence
|
||||
|
||||
data class Status(val loading: Boolean = false, val message: String? = null, val offline: Boolean = false, val code: String? = null) {
|
||||
/**
|
||||
@@ -89,6 +94,26 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
fun toggleNote(id: String) = viewModelScope.launch { app.notes.toggle(id) }
|
||||
|
||||
/**
|
||||
* Moves a task or a note to the next lesson of its subject, a plan without one to the next
|
||||
* day, and offers the old place back in [undo]. With no such lesson loaded it says so.
|
||||
*/
|
||||
fun postponeNote(note: Note, undo: UndoHost?) = viewModelScope.launch {
|
||||
val from = parseDate(note.date)
|
||||
val moved = if (note.general) note.copy(date = from.plusDays(1).toString())
|
||||
else {
|
||||
val t = timetable.value ?: return@launch
|
||||
val next = nextOccurrence(t, note.subject, from, note.start, settings.value?.subgroup)
|
||||
?: return@launch undo?.say("Следующей пары «${note.subject}» в расписании нет") ?: Unit
|
||||
note.copy(date = next.first.toString(), start = next.second.start)
|
||||
}
|
||||
app.notes.save(moved)
|
||||
// "на пн, 26 октября, 15:20": the time too, as the next lesson may be later the same day.
|
||||
val day = moved.dueLabel().removePrefix("к ")
|
||||
val at = if (moved.start.isNotBlank() && moved.start !in day) "$day, ${moved.start}" else day
|
||||
undo?.offer("Перенесено на $at", onUndo = { viewModelScope.launch { app.notes.save(note) } })
|
||||
}
|
||||
|
||||
/** The key new notes are filed under, or null before a group is chosen. */
|
||||
fun currentGroupKey(): String? = settings.value?.current?.let(::groupKey)
|
||||
|
||||
@@ -170,6 +195,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
SyncWorker.afterChange(getApplication())
|
||||
}
|
||||
|
||||
fun toggleSearchFavorite(target: String) = viewModelScope.launch { app.settings.toggleSearchFavorite(target) }
|
||||
fun rememberSearch(target: String) = viewModelScope.launch { app.settings.rememberSearch(target) }
|
||||
|
||||
fun swipeTried() = viewModelScope.launch { if (settings.value?.swipeTried == false) app.settings.swipeTried() }
|
||||
|
||||
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) }
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
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
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
@@ -116,6 +116,13 @@ import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekSpan
|
||||
import org.xtimms.psu.schedule.ui.theme.WeekColors
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.lesson.SubjectColor
|
||||
import org.xtimms.psu.schedule.ui.lesson.luminance
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
|
||||
/** A tablet, by its smallest side: its app bars stay calm, as School Planner's do. */
|
||||
@Composable
|
||||
@@ -176,7 +183,11 @@ fun Heading(
|
||||
Text(text, style = style, modifier = modifier.semantics { heading() })
|
||||
}
|
||||
|
||||
/** M3 Expressive connected button group: one choice out of a few. */
|
||||
/**
|
||||
* M3 Expressive connected button group: one choice out of a few. The chosen one gets a check
|
||||
* before its label when both fit; a long label ("Светлая" in a third of a phone) keeps whole
|
||||
* and the fill alone marks it.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> ConnectedChoice(
|
||||
options: List<Pair<T, String>>,
|
||||
@@ -187,42 +198,56 @@ fun <T> ConnectedChoice(
|
||||
mark: Boolean = true,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
Row(
|
||||
modifier.selectableGroup(),
|
||||
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
|
||||
) {
|
||||
options.forEachIndexed { index, (value, label) ->
|
||||
val checked = value == selected
|
||||
ToggleButton(
|
||||
checked = checked,
|
||||
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(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
checkedContainerColor = MaterialTheme.colorScheme.primary,
|
||||
checkedContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
shapes = when (index) {
|
||||
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
|
||||
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
|
||||
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
|
||||
},
|
||||
) {
|
||||
if (checked && mark) {
|
||||
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
val measurer = rememberTextMeasurer()
|
||||
val style = MaterialTheme.typography.labelLarge
|
||||
val density = LocalDensity.current
|
||||
BoxWithConstraints(modifier) {
|
||||
val gap = ButtonGroupDefaults.ConnectedSpaceBetween
|
||||
val segment = (maxWidth - gap * (options.size - 1)) / options.size
|
||||
Row(
|
||||
Modifier.fillMaxWidth().selectableGroup(),
|
||||
horizontalArrangement = Arrangement.spacedBy(gap),
|
||||
) {
|
||||
options.forEachIndexed { index, (value, label) ->
|
||||
val checked = value == selected
|
||||
val text = with(density) { measurer.measure(label, style, maxLines = 1).size.width.toDp() }
|
||||
val check = checked && mark && text + CHECK + PADDING * 2 <= segment
|
||||
ToggleButton(
|
||||
checked = checked,
|
||||
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(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
checkedContainerColor = MaterialTheme.colorScheme.primary,
|
||||
checkedContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
shapes = when (index) {
|
||||
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
|
||||
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
|
||||
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = PADDING),
|
||||
) {
|
||||
if (check) {
|
||||
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Text(label, style = style, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A connected button's side padding, and its check with the gap after it. */
|
||||
private val PADDING = 12.dp
|
||||
private val CHECK = 24.dp
|
||||
|
||||
/** M3 search input field on a tonal container; reports every edit through [onQueryChange]. */
|
||||
@Composable
|
||||
fun SearchField(query: String, onQueryChange: (String) -> Unit, placeholder: String, modifier: Modifier = Modifier) {
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
@@ -33,6 +33,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.psu.schedule.ui.motion.EmphasizedDecelerate
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
|
||||
// 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 "Всё сделано".
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
@@ -0,0 +1,140 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CloudOff
|
||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||
import androidx.compose.material.icons.rounded.EventBusy
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun Banner(
|
||||
text: String,
|
||||
offline: Boolean,
|
||||
onClose: (() -> Unit)?,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
/** A way out the message asks for, such as "Выбрать группу". */
|
||||
action: Pair<String, () -> Unit>? = null,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (offline) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = if (offline) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
val buttons: @Composable () -> Unit = {
|
||||
if (onRetry != null) TextButton(onClick = onRetry) { Text("Повторить") }
|
||||
action?.let { (label, onClick) -> TextButton(onClick = onClick) { Text(label) } }
|
||||
if (onClose != null) TextButton(onClick = onClose) { Text("OK") }
|
||||
}
|
||||
// With an action the message needs the whole width; the buttons go under it.
|
||||
if (action != null) Column(Modifier.padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 4.dp)) {
|
||||
Row {
|
||||
Icon(Icons.Rounded.ErrorOutline.takeIf { !offline } ?: Icons.Rounded.CloudOff, contentDescription = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(end = 12.dp))
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { buttons() }
|
||||
} else Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(if (offline) Icons.Rounded.CloudOff else Icons.Rounded.ErrorOutline, contentDescription = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(vertical = 8.dp))
|
||||
buttons()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centres an empty state in the space it is given — a phone screen or a tablet's pane —
|
||||
* and scrolls when that space is too low, so the illustration is never squeezed.
|
||||
*/
|
||||
@Composable
|
||||
fun CenteredEmpty(
|
||||
modifier: Modifier = Modifier,
|
||||
/** Room kept clear inside the scroll (under a FAB): content scrolls past it, never gets cut. */
|
||||
bottomInset: Dp = 0.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val height = maxHeight
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).heightIn(min = height).padding(bottom = bottomInset),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state in the RideBus style: an icon on a seven-sided cookie shape, a title,
|
||||
* a calm explanation and an optional tonal action.
|
||||
*/
|
||||
@Composable
|
||||
fun Empty(
|
||||
title: String,
|
||||
text: String,
|
||||
icon: ImageVector = Icons.Rounded.EventBusy,
|
||||
action: Pair<String, () -> Unit>? = null,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(vertical = 48.dp, horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(112.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, MaterialShapes.Cookie7Sided.toShape()),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleLargeEmphasized, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
action?.let { (label, onClick) ->
|
||||
Spacer(Modifier.height(20.dp))
|
||||
FilledTonalButton(onClick = onClick) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
@@ -37,6 +37,7 @@ 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
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
|
||||
// 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.
|
||||
@@ -0,0 +1,239 @@
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Swiping a row sideways does something with it, as in Buckwheat's history: a strip opens under
|
||||
// the row with the action's icon; past the threshold the action's colour floods it in a circle
|
||||
// from the icon, the icon bounces and the phone ticks. Letting go there runs the action, and the
|
||||
// row always springs back into place.
|
||||
|
||||
/** One side's action: its icon, its name for TalkBack, its colours and what it does. */
|
||||
class SwipeAction(
|
||||
val icon: ImageVector,
|
||||
val label: String,
|
||||
val color: Color,
|
||||
val onColor: Color,
|
||||
val run: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The swipe hint: until the student has swiped a row once, one row on the screen rocks to each
|
||||
* side by itself, showing the actions under it. [tried] remembers that they found it.
|
||||
*/
|
||||
@Stable
|
||||
class SwipeHint(val show: Boolean, val tried: () -> Unit) {
|
||||
internal var owner by mutableStateOf<Any?>(null)
|
||||
}
|
||||
|
||||
val LocalSwipeHint = staticCompositionLocalOf<SwipeHint?> { null }
|
||||
|
||||
/**
|
||||
* A row with [start] (swiped to the right) and [end] (to the left) actions. Either may be null;
|
||||
* with both null it is just [content]. TalkBack users get the same actions in the actions menu.
|
||||
*/
|
||||
@Composable
|
||||
fun SwipeActions(
|
||||
start: SwipeAction?,
|
||||
end: SwipeAction?,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (start == null && end == null) return Box(modifier) { content() }
|
||||
// The row's shift: set straight from the finger, so a late drag event can never stop the
|
||||
// spring that brings it back.
|
||||
var x by remember { mutableFloatStateOf(0f) }
|
||||
var width by remember { mutableFloatStateOf(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var settling by remember { mutableStateOf<Job?>(null) }
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val reduced = rememberReducedMotion()
|
||||
val startNow by rememberUpdatedState(start)
|
||||
val endNow by rememberUpdatedState(end)
|
||||
// A third of the row, but never a hair-trigger on a narrow one.
|
||||
val threshold = maxOf(width * 0.33f, with(LocalDensity.current) { 88.dp.toPx() })
|
||||
val armed = when {
|
||||
x > threshold -> start
|
||||
x < -threshold -> end
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun settle() {
|
||||
settling?.cancel()
|
||||
settling = scope.launch {
|
||||
animate(x, 0f, animationSpec = spring(dampingRatio = 0.75f, stiffness = Spring.StiffnessMediumLow)) { v, _ -> x = v }
|
||||
}
|
||||
}
|
||||
|
||||
// The hint, on the first row that asks for it while the student has never swiped.
|
||||
val hint = LocalSwipeHint.current
|
||||
val me = remember { Any() }
|
||||
var touched by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(hint) { if (hint?.show == true && hint.owner == null) hint.owner = me }
|
||||
DisposableEffect(hint) { onDispose { if (hint?.owner == me) hint.owner = null } }
|
||||
val hinting = hint?.show == true && hint.owner === me && !touched && !reduced && width > 0f
|
||||
LaunchedEffect(hinting) {
|
||||
if (!hinting) return@LaunchedEffect
|
||||
// Half way to the threshold and back, to one side and then the other, as in Buckwheat.
|
||||
var side = if (start != null) 1f else -1f
|
||||
try {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
animate(0f, side * threshold / 2, animationSpec = tween(500, easing = FastOutSlowInEasing)) { v, _ -> x = v }
|
||||
animate(x, 0f, animationSpec = tween(500, easing = FastOutSlowInEasing)) { v, _ -> x = v }
|
||||
if ((side > 0 && endNow != null) || (side < 0 && startNow != null)) side = -side
|
||||
}
|
||||
} finally {
|
||||
if (x != 0f) settle()
|
||||
}
|
||||
}
|
||||
|
||||
// The flood from the icon and the icon's bounce, when the threshold is passed.
|
||||
val flood = remember { Animatable(0f) }
|
||||
val bounce = remember { Animatable(1f) }
|
||||
var wasArmed by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(armed != null) {
|
||||
val on = armed != null
|
||||
if (on == wasArmed) return@LaunchedEffect
|
||||
wasArmed = on
|
||||
haptic.performHapticFeedback(if (on) HapticFeedbackType.GestureThresholdActivate else HapticFeedbackType.SegmentFrequentTick)
|
||||
if (reduced) {
|
||||
flood.snapTo(if (on) 1f else 0f)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (on) {
|
||||
launch { flood.animateTo(1f, tween(350)) }
|
||||
bounce.snapTo(0.8f)
|
||||
bounce.animateTo(1.25f, spring(dampingRatio = Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessMedium))
|
||||
bounce.animateTo(1f, spring(dampingRatio = Spring.DampingRatioLowBouncy))
|
||||
} else {
|
||||
launch { bounce.animateTo(1f) }
|
||||
flood.animateTo(0f, tween(200))
|
||||
}
|
||||
}
|
||||
|
||||
val rest = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
val restInk = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val startIcon = start?.let { rememberVectorPainter(it.icon) }
|
||||
val endIcon = end?.let { rememberVectorPainter(it.icon) }
|
||||
val drag = rememberDraggableState { delta ->
|
||||
val max = if (startNow != null) width else 0f
|
||||
val min = if (endNow != null) -width else 0f
|
||||
x = (x + delta).coerceIn(min, max)
|
||||
}
|
||||
Box(
|
||||
modifier
|
||||
.clipToBounds()
|
||||
.onSizeChanged { width = it.width.toFloat() }
|
||||
.semantics {
|
||||
customActions = listOfNotNull(
|
||||
start?.let { CustomAccessibilityAction(it.label) { it.run(); true } },
|
||||
end?.let { CustomAccessibilityAction(it.label) { it.run(); true } },
|
||||
)
|
||||
}
|
||||
// A finger on the row stops the hint there.
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
touched = true
|
||||
}
|
||||
}
|
||||
.drawBehind {
|
||||
if (x == 0f) return@drawBehind
|
||||
val action = (if (x > 0) start else end) ?: return@drawBehind
|
||||
val painter = (if (x > 0) startIcon else endIcon) ?: return@drawBehind
|
||||
// Only the strip the row has uncovered, square at its ends.
|
||||
val left = if (x > 0) 0f else size.width + x
|
||||
val right = if (x > 0) x else size.width
|
||||
clipRect(left, 0f, right, size.height) {
|
||||
drawRect(rest)
|
||||
val icon = 24.dp.toPx()
|
||||
val margin = 24.dp.toPx()
|
||||
val center = Offset(if (x > 0) margin + icon / 2 else size.width - margin - icon / 2, size.height / 2)
|
||||
if (flood.value > 0f) drawCircle(action.color, radius = hypot(size.width, size.height) * flood.value, center = center)
|
||||
val ink = lerp(restInk, action.onColor, flood.value)
|
||||
// The icon fades in as the strip opens.
|
||||
val shown = (abs(x) / (margin * 2 + icon)).coerceIn(0f, 1f)
|
||||
translate(center.x - icon / 2, center.y - icon / 2) {
|
||||
scale(bounce.value, pivot = Offset(icon / 2, icon / 2)) {
|
||||
with(painter) { draw(Size(icon, icon), alpha = shown, colorFilter = ColorFilter.tint(ink)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.draggable(
|
||||
drag,
|
||||
Orientation.Horizontal,
|
||||
onDragStarted = {
|
||||
settling?.cancel()
|
||||
hint?.tried()
|
||||
},
|
||||
onDragStopped = {
|
||||
val action = when {
|
||||
x > threshold -> startNow
|
||||
x < -threshold -> endNow
|
||||
else -> null
|
||||
}
|
||||
settle()
|
||||
action?.run?.invoke()
|
||||
},
|
||||
),
|
||||
) {
|
||||
Box(Modifier.offset { IntOffset(x.roundToInt(), 0) }) { content() }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
@@ -42,6 +42,14 @@ class UndoHost(val state: SnackbarHostState, private val scope: CoroutineScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A message with nothing to take back: "У предмета больше нет пар в расписании". */
|
||||
fun say(message: String) {
|
||||
scope.launch {
|
||||
state.currentSnackbarData?.dismiss()
|
||||
state.showSnackbar(message, duration = SnackbarDuration.Short)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val LocalUndo = staticCompositionLocalOf<UndoHost?> { null }
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.xtimms.psu.schedule.ui.groups
|
||||
|
||||
private val FACULTIES = mapOf(
|
||||
"Финансово-экономический факультет" to "ФЭФ",
|
||||
"Факультет информационных технологий" to "ФИТ",
|
||||
"Факультет компьютерных наук и электроники" to "ФКНЭ",
|
||||
"Гуманитарный факультет" to "ГФ",
|
||||
"Инженерно-строительный факультет" to "ИСФ",
|
||||
"Механико-технологический факультет" to "МТФ",
|
||||
"Юридический факультет" to "ЮФ",
|
||||
)
|
||||
|
||||
fun facultyShort(name: String): String? = name.takeIf { it.isNotEmpty() }?.let { FACULTIES[it] ?: it }
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.groups
|
||||
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -57,6 +57,22 @@ 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
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.Banner
|
||||
import org.xtimms.psu.schedule.ui.components.CardRow
|
||||
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
|
||||
import org.xtimms.psu.schedule.ui.components.ContentColumn
|
||||
import org.xtimms.psu.schedule.ui.components.Empty
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.ListCard
|
||||
import org.xtimms.psu.schedule.ui.components.RowDivider
|
||||
import org.xtimms.psu.schedule.ui.components.RowsSkeleton
|
||||
import org.xtimms.psu.schedule.ui.components.SearchField
|
||||
import org.xtimms.psu.schedule.ui.components.appBarColors
|
||||
import org.xtimms.psu.schedule.ui.components.avatarLabel
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.notes.open
|
||||
|
||||
private val PLAIN_PERIOD = Regex("^(осенний|весенний) семестр$|^расписание из word$", RegexOption.IGNORE_CASE)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.home
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -41,6 +41,7 @@ import org.xtimms.psu.schedule.ui.theme.card
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
|
||||
/** "3 изменения". */
|
||||
fun changesLabel(n: Int) = "$n ${plural(n, "изменение", "изменения", "изменений")}"
|
||||
@@ -1,12 +1,11 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.home
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
@@ -70,7 +69,6 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -84,15 +82,12 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import org.xtimms.psu.schedule.data.DayPlace
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.minutesLabel
|
||||
import org.xtimms.psu.schedule.data.monday
|
||||
import org.xtimms.psu.schedule.data.nowTime
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import org.xtimms.psu.schedule.data.plural
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekSpan
|
||||
@@ -101,28 +96,49 @@ import org.xtimms.psu.schedule.data.withPlace
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
|
||||
/** One time slot of a day: parallel lessons of subgroups share it. */
|
||||
data class Slot(val date: LocalDate, val start: LocalTime, val end: LocalTime, val lessons: List<Lesson>)
|
||||
|
||||
fun MainViewModel.slotsOn(t: Timetable, date: LocalDate, subgroup: String?): List<Slot> =
|
||||
repository.lessonsOn(t, date, subgroup, hidden)
|
||||
.groupBy { it.start }
|
||||
.map { (start, lessons) -> Slot(date, parseTime(start), lessonEnd(lessons.first(), t), lessons) }
|
||||
.sortedBy { it.start }
|
||||
|
||||
/** The current time, refreshed every half minute while the screen is shown. */
|
||||
@Composable
|
||||
fun rememberClock(): LocalTime {
|
||||
var clock by remember { mutableStateOf(nowTime()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(30_000)
|
||||
clock = nowTime()
|
||||
}
|
||||
}
|
||||
return clock
|
||||
}
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.MenuButton
|
||||
import org.xtimms.psu.schedule.ui.components.Banner
|
||||
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
|
||||
import org.xtimms.psu.schedule.ui.components.ContentColumn
|
||||
import org.xtimms.psu.schedule.ui.components.DetailPane
|
||||
import org.xtimms.psu.schedule.ui.components.Empty
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.ListCard
|
||||
import org.xtimms.psu.schedule.ui.components.LocalUndo
|
||||
import org.xtimms.psu.schedule.ui.components.OverviewSkeleton
|
||||
import org.xtimms.psu.schedule.ui.components.PaneRow
|
||||
import org.xtimms.psu.schedule.ui.components.TimeText
|
||||
import org.xtimms.psu.schedule.ui.components.WeekMark
|
||||
import org.xtimms.psu.schedule.ui.components.appBarColors
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.components.twoPane
|
||||
import org.xtimms.psu.schedule.ui.lesson.AttendanceCard
|
||||
import org.xtimms.psu.schedule.ui.lesson.DayPlaces
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
|
||||
import org.xtimms.psu.schedule.ui.lesson.SessionBadge
|
||||
import org.xtimms.psu.schedule.ui.lesson.SessionDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.Slot
|
||||
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.placesOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberClock
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
|
||||
import org.xtimms.psu.schedule.ui.lesson.session
|
||||
import org.xtimms.psu.schedule.ui.lesson.sessionColors
|
||||
import org.xtimms.psu.schedule.ui.lesson.slotsOn
|
||||
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
|
||||
import org.xtimms.psu.schedule.ui.motion.Entrance
|
||||
import org.xtimms.psu.schedule.ui.motion.ExpressiveRefresh
|
||||
import org.xtimms.psu.schedule.ui.motion.RollingText
|
||||
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
|
||||
import org.xtimms.psu.schedule.ui.motion.morphOrigin
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
|
||||
import org.xtimms.psu.schedule.ui.notes.LocalNotes
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteEditor
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteRow
|
||||
import org.xtimms.psu.schedule.ui.schedule.WeekGrid
|
||||
import org.xtimms.psu.schedule.ui.lesson.lessonPress
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
||||
@@ -331,7 +347,7 @@ private fun TasksCard(vm: MainViewModel, onEdit: (Note) -> Unit, onAll: () -> Un
|
||||
},
|
||||
) {
|
||||
(if (expanded) pending else pending.take(4)).forEach { note ->
|
||||
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { onEdit(note) }, showSubject = true)
|
||||
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { onEdit(note) }, showSubject = true, onPostpone = { vm.postponeNote(note, it) })
|
||||
}
|
||||
Row(Modifier.padding(start = 12.dp)) {
|
||||
if (pending.size > 4) TextButton(onClick = { expanded = !expanded }) {
|
||||
@@ -505,11 +521,6 @@ private fun DayButton(onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SubjectDot(subject: String, size: Dp = 12.dp, modifier: Modifier = Modifier) {
|
||||
Box(modifier.size(size).background(subjectColor(subject).container, CircleShape))
|
||||
}
|
||||
|
||||
private fun Slot.timeLabel() = "$start → $end"
|
||||
|
||||
/** The next lesson in full: subject, time with a countdown, room and teacher. */
|
||||
@@ -521,7 +532,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
|
||||
.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),
|
||||
.lessonPress(lesson, slot.date, onClick),
|
||||
) {
|
||||
SubjectDot(lesson.subject, 14.dp, Modifier.padding(top = 8.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
@@ -563,7 +574,7 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
|
||||
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)
|
||||
.lessonPress(lesson, slot.date, onClick)
|
||||
else Modifier,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.home
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -42,6 +42,8 @@ import org.xtimms.psu.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.ChronoUnit
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
|
||||
/** Upcoming exams of the open group, soonest first; the cache of another group is ignored. */
|
||||
@Composable
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
@@ -34,6 +34,12 @@ import org.xtimms.psu.schedule.data.Mark
|
||||
import org.xtimms.psu.schedule.data.nowTime
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.ListCard
|
||||
import org.xtimms.psu.schedule.ui.components.RowDivider
|
||||
import org.xtimms.psu.schedule.ui.components.TimeText
|
||||
|
||||
/** Attendance marks of the open group, for cards deep in the tree. */
|
||||
val LocalMarks = staticCompositionLocalOf<List<Mark>> { emptyList() }
|
||||
@@ -0,0 +1,101 @@
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.DirectionsWalk
|
||||
import androidx.compose.material.icons.rounded.Coffee
|
||||
import androidx.compose.material.icons.rounded.DirectionsBus
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.minutesLabel
|
||||
import java.time.Duration
|
||||
|
||||
// Between two lessons of a day: a free window where a pair is missing, a walk to another
|
||||
// building, a ride to another town. Breaks of 10–30 minutes are the usual and say nothing.
|
||||
|
||||
/** What lies between two lessons: the gap, and where the next one is, when it is elsewhere. */
|
||||
class Between(val minutes: Long, val window: Boolean, val building: Pair<String, String>?, val town: String?, val away: String?)
|
||||
|
||||
/** From this many minutes the gap is a window: a pair is missing, not a break. */
|
||||
private const val WINDOW = 45L
|
||||
|
||||
/** The building of a lesson's room, its letters: "Г" of "305Г", "" for "121"; null when unknown. */
|
||||
private fun Lesson.building(): String? =
|
||||
if (distance) null else rooms.firstOrNull()?.uppercase()?.trim()?.takeLastWhile { it.isLetter() }
|
||||
|
||||
/** "из Полоцка", "из Междуречья". */
|
||||
private fun fromTown(town: String): String = "из " + when {
|
||||
town.endsWith("е") -> town.dropLast(1) + "я"
|
||||
town.endsWith("к") -> town + "а"
|
||||
else -> town
|
||||
}
|
||||
|
||||
/**
|
||||
* Between [a] and [b], two slots in a row of one day; null when nothing is worth a line. Only
|
||||
* a slot with one lesson has a room to leave from or go to.
|
||||
*/
|
||||
fun between(a: Slot, b: Slot): Between? {
|
||||
val minutes = Duration.between(a.end, b.start).toMinutes()
|
||||
if (minutes < 0) return null
|
||||
val from = a.lessons.singleOrNull()
|
||||
val to = b.lessons.singleOrNull()
|
||||
val window = minutes >= WINDOW
|
||||
val moved = from != null && to != null && from.place != to.place
|
||||
val town = if (moved) to?.place else null
|
||||
val away = if (moved && to?.place == null) from?.place else null
|
||||
val building = if (!moved && from != null && to != null) {
|
||||
val x = from.building()
|
||||
val y = to.building()
|
||||
if (x != null && y != null && x != y) from.rooms.first() to to.rooms.first() else null
|
||||
} else null
|
||||
if (!window && !moved && building == null) return null
|
||||
return Between(minutes, window, building, town, away)
|
||||
}
|
||||
|
||||
/** The line between two lesson cards: "Окно 1 ч 35 мин · Другой корпус: 305Г → 208А". */
|
||||
@Composable
|
||||
fun BetweenRow(b: Between, modifier: Modifier = Modifier) {
|
||||
val parts = listOfNotNull(
|
||||
if (b.window) "Окно ${minutesLabel(b.minutes)}" else null,
|
||||
b.town?.let { "Переезд в $it" } ?: b.away?.let { "Переезд " + fromTown(it) },
|
||||
b.building?.let { (x, y) -> "Другой корпус: $x → $y" },
|
||||
)
|
||||
// A ride takes long: with little time it is the one thing to see.
|
||||
val ride = b.town != null || b.away != null
|
||||
val tight = ride && b.minutes < 60
|
||||
val ink = if (tight) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val text = parts.joinToString(" · ") + if (!b.window) " · перерыв ${minutesLabel(b.minutes)}" else ""
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.clearAndSetSemantics { contentDescription = text },
|
||||
) {
|
||||
Icon(
|
||||
when {
|
||||
ride -> Icons.Rounded.DirectionsBus
|
||||
b.building != null -> Icons.AutoMirrored.Rounded.DirectionsWalk
|
||||
else -> Icons.Rounded.Coffee
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = ink,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = ink)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
|
||||
import androidx.compose.material.icons.rounded.AddTask
|
||||
import androidx.compose.material.icons.rounded.Share
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.hideKey
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.shortWeekday
|
||||
import org.xtimms.psu.schedule.data.withPlace
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
|
||||
import org.xtimms.psu.schedule.ui.components.LocalUndo
|
||||
import org.xtimms.psu.schedule.ui.motion.LocalMorph
|
||||
import org.xtimms.psu.schedule.ui.motion.MorphKind
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteEditor
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteTarget
|
||||
import org.xtimms.psu.schedule.ui.notes.nextOccurrence
|
||||
import java.time.LocalDate
|
||||
|
||||
// A long press on a lesson: what one does with it most, without opening its page first.
|
||||
|
||||
/** Opens the actions of a lesson on a date; null where there is no app around (previews). */
|
||||
val LocalLessonActions = staticCompositionLocalOf<((Lesson, LocalDate) -> Unit)?> { null }
|
||||
|
||||
/**
|
||||
* A lesson's tap and long press: the tap opens its page, the long press its actions, with a
|
||||
* buzz. TalkBack offers both ("Подробнее", "Действия").
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun Modifier.lessonPress(lesson: Lesson, date: LocalDate?, onClick: () -> Unit): Modifier {
|
||||
val actions = LocalLessonActions.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
if (actions == null || date == null) return clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick)
|
||||
return combinedClickable(
|
||||
onClickLabel = "Подробнее",
|
||||
role = Role.Button,
|
||||
onLongClickLabel = "Действия",
|
||||
onLongClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
actions(lesson, date)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** Provides [LocalLessonActions] to [content] and shows the actions sheet when asked. */
|
||||
@Composable
|
||||
fun LessonActionsHost(vm: MainViewModel, content: @Composable () -> Unit) {
|
||||
var target by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
|
||||
// The card pressed, so "Подробнее" grows out of it even after the sheet.
|
||||
var from by remember { mutableStateOf<String?>(null) }
|
||||
val morph = LocalMorph.current
|
||||
val open: (Lesson, LocalDate) -> Unit = remember(morph) {
|
||||
{ lesson, date ->
|
||||
from = morph?.recentTouch()
|
||||
target = lesson to date
|
||||
}
|
||||
}
|
||||
CompositionLocalProvider(LocalLessonActions provides open) { content() }
|
||||
target?.let { (lesson, date) -> LessonActionsSheet(vm, lesson, date, from) { target = null } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LessonActionsSheet(vm: MainViewModel, lesson: Lesson, date: LocalDate, from: String?, onDismiss: () -> Unit) {
|
||||
val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val undo = LocalUndo.current
|
||||
val morph = LocalMorph.current
|
||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||
val marks by vm.marks.collectAsStateWithLifecycle()
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
val end = timetable?.let { lessonEnd(lesson, it) }
|
||||
val where = if (lesson.distance) "дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, lesson.place)
|
||||
val summary = listOfNotNull(
|
||||
date.shortWeekday().lowercase() + ", " + date.longLabel(),
|
||||
lesson.start + (end?.let { " – $it" } ?: ""),
|
||||
where,
|
||||
).joinToString(" · ")
|
||||
// Closes the sheet first, then does [then]: the next screen or dialog is not under it.
|
||||
fun close(then: () -> Unit = {}) {
|
||||
scope.launch { sheet.hide() }.invokeOnCompletion {
|
||||
onDismiss()
|
||||
then()
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheet) {
|
||||
Column(Modifier.navigationBarsPadding().padding(bottom = 16.dp)) {
|
||||
Column(Modifier.padding(horizontal = 24.dp).padding(bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(lesson.subject, style = MaterialTheme.typography.titleLargeEmphasized)
|
||||
Text(summary, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
ActionRow(Icons.AutoMirrored.Rounded.OpenInNew, "Подробнее") {
|
||||
close {
|
||||
morph?.open(MorphKind.Page, from = from) { done -> LessonPage(lesson, date, vm, done) }
|
||||
}
|
||||
}
|
||||
if (vm.currentGroupKey() != null) ActionRow(Icons.Rounded.AddTask, "Задание или заметка") { adding = true }
|
||||
// Once the lesson has begun: was there or not, in one tap.
|
||||
if (vm.currentGroupKey() != null && lessonStarted(date, lesson.start)) {
|
||||
val mark = marks.forLesson(lesson.subject, date, lesson.start)
|
||||
ConnectedChoice(
|
||||
options = listOf<Pair<Boolean?, String>>(true to "Был", false to "Пропустил"),
|
||||
selected = mark?.present,
|
||||
onSelect = { vm.mark(lesson, date, if (it == mark?.present) null else it) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
ActionRow(Icons.Rounded.Share, "Поделиться") {
|
||||
val text = listOfNotNull(lesson.subject, lesson.type.takeIf { it.isNotEmpty() }, summary, lesson.teachers.joinToString(", ").ifEmpty { null })
|
||||
.joinToString("\n")
|
||||
close {
|
||||
context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, text), null))
|
||||
}
|
||||
}
|
||||
ActionRow(Icons.Rounded.VisibilityOff, "Скрыть такие занятия") {
|
||||
close {
|
||||
vm.setHidden(lesson.hideKey, true)
|
||||
undo?.offer("Скрыто: «${lesson.subject}»", onUndo = { vm.setHidden(lesson.hideKey, false) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (adding) {
|
||||
val group = vm.currentGroupKey() ?: return
|
||||
NoteEditor(
|
||||
target = NoteTarget(
|
||||
subject = lesson.subject,
|
||||
date = date,
|
||||
start = lesson.start,
|
||||
next = timetable?.let { vm.nextOccurrence(it, lesson.subject, date, lesson.start, settings?.subgroup) },
|
||||
),
|
||||
editing = null,
|
||||
groupKey = group,
|
||||
onSave = {
|
||||
vm.saveNote(it)
|
||||
adding = false
|
||||
close()
|
||||
},
|
||||
onDelete = {},
|
||||
onDismiss = { adding = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionRow(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(20.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import org.xtimms.psu.schedule.data.hideKey
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.EventRepeat
|
||||
import androidx.compose.material.icons.rounded.Groups
|
||||
import androidx.compose.material.icons.rounded.LocationCity
|
||||
import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material.icons.rounded.Schedule
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.psu.schedule.data.Bell
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.withPlace
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.Banner
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.LocalUndo
|
||||
import org.xtimms.psu.schedule.ui.components.TimeText
|
||||
import org.xtimms.psu.schedule.ui.components.UndoArea
|
||||
import org.xtimms.psu.schedule.ui.motion.EmphasizedDecelerate
|
||||
import org.xtimms.psu.schedule.ui.motion.RollingText
|
||||
import org.xtimms.psu.schedule.ui.motion.morphOrigin
|
||||
import org.xtimms.psu.schedule.ui.motion.progressBorder
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
import org.xtimms.psu.schedule.ui.notes.LessonNotes
|
||||
import org.xtimms.psu.schedule.ui.notes.LocalNotes
|
||||
import org.xtimms.psu.schedule.ui.notes.NotesHint
|
||||
import org.xtimms.psu.schedule.ui.notes.forLesson
|
||||
import org.xtimms.psu.schedule.ui.lesson.forLesson
|
||||
import org.xtimms.psu.schedule.ui.motion.morphContent
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
||||
/** A lesson as a soft card in its subject's colour, like School Planner's day list. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun LessonCard(
|
||||
lesson: Lesson,
|
||||
time: String?,
|
||||
pair: Int? = null,
|
||||
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
|
||||
val session = lesson.session
|
||||
val content: @Composable () -> Unit = {
|
||||
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
// Exams, credits and consultations announce themselves before the subject.
|
||||
session?.let { SessionBadge(it, Modifier.padding(bottom = 2.dp)) }
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Text(lesson.subject, style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f))
|
||||
status?.let {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (running) color.onSoft else color.onSoft.copy(alpha = 0.12f),
|
||||
contentColor = if (running) color.soft else color.onSoft,
|
||||
) {
|
||||
RollingText(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
time?.let { TimeText(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно")
|
||||
else withPlace(
|
||||
lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { it.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "" },
|
||||
lesson.place,
|
||||
)?.let { Meta(Icons.Rounded.LocationOn, it) }
|
||||
if (show.teachers && lesson.teachers.isNotEmpty()) Meta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
|
||||
lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) }
|
||||
}
|
||||
val tags = if (!show.tags) emptyList() else listOfNotNull(
|
||||
lesson.type.ifEmpty { null }?.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(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
) {
|
||||
mark?.let {
|
||||
SoftTag(if (it.present) "был" else "пропуск", color, icon = if (it.present) Icons.Rounded.Check else Icons.Rounded.Close)
|
||||
}
|
||||
tags.forEach { SoftTag(it, color) }
|
||||
if (lesson.uncertain) Tag("Сверьте недели", warning = true)
|
||||
}
|
||||
if (show.notes && lesson.condition.isNotEmpty()) Meta(Icons.Rounded.EventRepeat, "Пометка в Word: ${lesson.condition}")
|
||||
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 morphKey = "card|$date|${lesson.key}|${lesson.start}"
|
||||
val origin = if (onClick != null) Modifier.morphOrigin(morphKey, 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
|
||||
}
|
||||
if (onClick != null) Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = fill,
|
||||
contentColor = color.onSoft,
|
||||
border = border,
|
||||
// A tap opens the page, a long press the lesson's actions.
|
||||
modifier = Modifier.fillMaxWidth().then(origin).then(live).clip(MaterialTheme.shapes.large).lessonPress(lesson, date, onClick),
|
||||
// The page grows from the card's text; the card's fill is the container's.
|
||||
content = { Box(Modifier.morphContent(morphKey)) { content() } },
|
||||
) else Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = color.soft,
|
||||
contentColor = color.onSoft,
|
||||
border = border,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SoftTag(text: String, color: SubjectColor, icon: ImageVector? = null) {
|
||||
Surface(shape = MaterialTheme.shapes.small, color = color.onSoft.copy(alpha = 0.12f), contentColor = color.onSoft) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = if (icon != null) 6.dp else 8.dp, end = 8.dp, top = 3.dp, bottom = 3.dp),
|
||||
) {
|
||||
icon?.let {
|
||||
Icon(it, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Text(text.replaceFirstChar { it.titlecase() }, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Meta(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TypeTag(type: String) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val (bg, fg) = when (type) {
|
||||
"Лекция" -> scheme.primaryContainer to scheme.onPrimaryContainer
|
||||
"Практика" -> scheme.tertiaryContainer to scheme.onTertiaryContainer
|
||||
"Экзамен", "Зачёт", "Защита курсовой работы" -> sessionColors().let { it.strong to it.onStrong }
|
||||
"Консультация" -> sessionColors().let { it.soft to it.onSoft }
|
||||
else -> scheme.secondaryContainer to scheme.onSecondaryContainer
|
||||
}
|
||||
Surface(shape = MaterialTheme.shapes.small, color = bg, contentColor = fg) {
|
||||
Text(type.ifEmpty { "Занятие" }, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Tag(text: String, warning: Boolean = false) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (warning) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = if (warning) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Text(text, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LessonDetails(
|
||||
lesson: Lesson,
|
||||
bells: List<Bell>,
|
||||
date: LocalDate? = null,
|
||||
vm: MainViewModel? = null,
|
||||
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())
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
TypeTag(lesson.type)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SubjectDot(lesson.subject, 16.dp)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Heading(lesson.subject, style = MaterialTheme.typography.headlineSmallEmphasized)
|
||||
}
|
||||
Detail(
|
||||
Icons.Rounded.Schedule,
|
||||
bell?.let { "${it.pair} пара" } ?: "Время",
|
||||
lesson.start + (lesson.end?.let { " — $it" } ?: ""),
|
||||
bell?.halves?.takeIf { it.size > 1 }?.joinToString(", ") { it.joinToString("–") },
|
||||
)
|
||||
Detail(Icons.Rounded.Person, "Преподаватель", lesson.teachers.joinToString(", ").ifEmpty { "Не указан" })
|
||||
Detail(
|
||||
Icons.Rounded.LocationOn,
|
||||
"Аудитория",
|
||||
if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { "Не указана" },
|
||||
if (lesson.roomMoved) "В этот день — перенос аудитории" else null,
|
||||
)
|
||||
lesson.place?.let { Detail(Icons.Rounded.LocationCity, "Город", it, "Так указано в Word") }
|
||||
Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup)
|
||||
if (lesson.uncertain) Banner(
|
||||
"Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.",
|
||||
offline = false,
|
||||
onClose = null,
|
||||
)
|
||||
if (date != null && vm != null) {
|
||||
LessonAttendance(vm, lesson, date)
|
||||
LessonNotes(vm, lesson, date)
|
||||
}
|
||||
Heading("Исходная запись", style = MaterialTheme.typography.titleSmallEmphasized)
|
||||
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
|
||||
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())
|
||||
}
|
||||
if (vm != null) {
|
||||
OutlinedButton(onClick = {
|
||||
vm.setHidden(lesson.hideKey, true)
|
||||
onClose()
|
||||
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("Скрыть такие занятия")
|
||||
}
|
||||
Text(
|
||||
"«${lesson.subject}»${lesson.type.takeIf { it.isNotEmpty() }?.let { " · ${it.lowercase()}" }.orEmpty()} пропадёт из расписания, " +
|
||||
"обзора, виджета и напоминаний во все недели. Вернуть можно в настройках.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Detail(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String, extra: String? = null) {
|
||||
Row {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodyLargeEmphasized)
|
||||
extra?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.motion.LocalMorph
|
||||
import org.xtimms.psu.schedule.ui.motion.MorphKind
|
||||
|
||||
/**
|
||||
* 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
|
||||
internal 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, bottom = 8.dp)) {
|
||||
IconButton(onClick = close) { Icon(Icons.Rounded.Close, contentDescription = "Закрыть") }
|
||||
}
|
||||
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm, onClose = close)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.psu.schedule.data.DayPlace
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
|
||||
// "Занятия в Полоцке": Word marks the days a group spends in another town over the day's first
|
||||
// row. The server reads it as a note of the day, not a lesson, and gives each lesson its town.
|
||||
@@ -47,7 +48,7 @@ fun DayPlaces(places: List<DayPlace>, modifier: Modifier = Modifier) {
|
||||
Icon(Icons.Rounded.LocationCity, contentDescription = null, modifier = Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column {
|
||||
Text(p.text, style = MaterialTheme.typography.titleMediumEmphasized)
|
||||
Text(p.label, style = MaterialTheme.typography.titleMediumEmphasized)
|
||||
Text(
|
||||
listOfNotNull(p.subgroup, "пометка в Word на этот день").joinToString(" · ").replaceFirstChar { it.titlecase() },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -67,7 +68,7 @@ fun DayPlaceMark(places: List<DayPlace>, modifier: Modifier = Modifier) {
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
modifier = modifier.semantics { contentDescription = places.joinToString(", ") { listOfNotNull(it.text, it.subgroup).joinToString(", ") } },
|
||||
modifier = modifier.semantics { contentDescription = places.joinToString(", ") { listOfNotNull(it.label, it.subgroup).joinToString(", ") } },
|
||||
) {
|
||||
Text(
|
||||
first.place,
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
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 kotlinx.coroutines.delay
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.nowTime
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
|
||||
/** One time slot of a day: parallel lessons of subgroups share it. */
|
||||
data class Slot(val date: LocalDate, val start: LocalTime, val end: LocalTime, val lessons: List<Lesson>)
|
||||
|
||||
fun MainViewModel.slotsOn(t: Timetable, date: LocalDate, subgroup: String?): List<Slot> =
|
||||
repository.lessonsOn(t, date, subgroup, hidden)
|
||||
.groupBy { it.start }
|
||||
.map { (start, lessons) -> Slot(date, parseTime(start), lessonEnd(lessons.first(), t), lessons) }
|
||||
.sortedBy { it.start }
|
||||
|
||||
/** The current time, refreshed every half minute while the screen is shown. */
|
||||
@Composable
|
||||
fun rememberClock(): LocalTime {
|
||||
var clock by remember { mutableStateOf(nowTime()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(30_000)
|
||||
clock = nowTime()
|
||||
}
|
||||
}
|
||||
return clock
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -11,6 +11,13 @@ import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import java.time.LocalTime
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* A subject's colours: [container]/[content] for small strong fills (week grid, dots),
|
||||
@@ -117,3 +124,9 @@ fun lessonEnd(lesson: Lesson, t: Timetable): LocalTime =
|
||||
?: parseTime(lesson.start).plusMinutes(80)
|
||||
|
||||
fun Color.luminance(): Float = 0.2126f * red + 0.7152f * green + 0.0722f * blue
|
||||
|
||||
@Composable
|
||||
fun SubjectDot(subject: String, size: Dp = 12.dp, modifier: Modifier = Modifier) {
|
||||
Box(modifier.size(size).background(subjectColor(subject).container, CircleShape))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.motion
|
||||
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
@@ -40,6 +43,7 @@ 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.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
@@ -66,8 +70,14 @@ import kotlin.math.roundToInt
|
||||
// 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)
|
||||
/** Where a transform starts: an element's bounds, its fill, corners and shadow and a picture of it. */
|
||||
class MorphOrigin(val bounds: Rect, val color: Color, val radius: Dp, val elevation: Dp, val picture: GraphicsLayer)
|
||||
|
||||
/**
|
||||
* The inside of an origin, without the origin's own fill and shadow: the container draws those
|
||||
* itself, so a button's shape and shadow are never seen twice while it grows ([morphContent]).
|
||||
*/
|
||||
class MorphContent(val bounds: Rect, val picture: GraphicsLayer)
|
||||
|
||||
enum class MorphKind {
|
||||
/** A page: the whole screen on a phone, a large panel on a tablet. */
|
||||
@@ -82,6 +92,7 @@ class MorphEntry(val key: String?, val kind: MorphKind, val content: @Composable
|
||||
@Stable
|
||||
class MorphHost {
|
||||
internal val origins = mutableMapOf<String, MorphOrigin>()
|
||||
internal val contents = mutableMapOf<String, MorphContent>()
|
||||
// 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
|
||||
@@ -90,11 +101,16 @@ class MorphHost {
|
||||
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 }
|
||||
/** The element tapped a moment ago, if any. */
|
||||
fun recentTouch(): String? = touched?.takeIf { System.currentTimeMillis() - touchedAt < 1500 }
|
||||
|
||||
/**
|
||||
* Opens [content] growing out of [from] (an origin's key) or, by default, out of the element
|
||||
* tapped a moment ago, if any.
|
||||
*/
|
||||
fun open(kind: MorphKind, from: String? = recentTouch(), content: @Composable (close: () -> Unit) -> Unit) {
|
||||
closing = false
|
||||
entry = MorphEntry(key, kind, content)
|
||||
entry = MorphEntry(from, kind, content)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
@@ -105,17 +121,18 @@ class MorphHost {
|
||||
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.
|
||||
* Marks a card or a button as a place a page or a sheet grows from: its fill [color], corner
|
||||
* [radius] and shadow [elevation] are the container's at the start. While its page is open the
|
||||
* element itself is hidden, shadow and all: the page has taken its place.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.morphOrigin(key: String, color: Color, radius: Dp): Modifier {
|
||||
fun Modifier.morphOrigin(key: String, color: Color, radius: Dp, elevation: Dp = 0.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) }
|
||||
.onGloballyPositioned { host.origins[key] = MorphOrigin(it.boundsInRoot(), color, radius, elevation, picture) }
|
||||
.pointerInput(host, key) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
@@ -130,9 +147,30 @@ fun Modifier.morphOrigin(key: String, color: Color, radius: Dp): Modifier {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Marks what is inside an origin with the same [key] (a button's icon, a card's text): the
|
||||
* container draws this picture instead of the whole element, on its own fill and shadow.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.morphContent(key: String): Modifier {
|
||||
val host = LocalMorph.current ?: return this
|
||||
val picture = rememberGraphicsLayer()
|
||||
DisposableEffect(host, key) { onDispose { host.contents.remove(key) } }
|
||||
return this
|
||||
.onGloballyPositioned { host.contents[key] = MorphContent(it.boundsInRoot(), picture) }
|
||||
.drawWithContent {
|
||||
picture.record { this@drawWithContent.drawContent() }
|
||||
drawLayer(picture)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The open page or sheet over the app, as the M3 container transform (MDC's
|
||||
* MaterialContainerTransform). Opening, the container grows from its origin (or from the middle),
|
||||
* and both contents ride in it, scaled to its width: the card's picture underneath, the page
|
||||
* laid out at its final size on top, fading in over the first quarter while the corners
|
||||
* straighten over three quarters. Back, a tap on the scrim, the predictive back gesture or the
|
||||
* sheet's handle shrink it into the card again, the page fading out late, between 60 and 90 %
|
||||
* of the way.
|
||||
*/
|
||||
@Composable
|
||||
fun MorphOverlay(host: MorphHost) {
|
||||
@@ -150,7 +188,11 @@ fun MorphOverlay(host: MorphHost) {
|
||||
}
|
||||
LaunchedEffect(host.closing) {
|
||||
if (!host.closing) return@LaunchedEffect
|
||||
host.progress.animateTo(0f, tween(if (reduced) 0 else 350, easing = Emphasized))
|
||||
val spec = tween<Float>(if (reduced) 0 else 350, easing = Emphasized)
|
||||
// A sheet dragged down or a page shrunk by the back gesture still lands on its origin.
|
||||
launch { animate(drag, 0f, animationSpec = spec) { v, _ -> drag = v } }
|
||||
launch { animate(back, 0f, animationSpec = spec) { v, _ -> back = v } }
|
||||
host.progress.animateTo(0f, spec)
|
||||
host.entry = null
|
||||
host.closing = false
|
||||
}
|
||||
@@ -183,6 +225,7 @@ fun MorphOverlay(host: MorphHost) {
|
||||
}
|
||||
}
|
||||
val origin = entry.key?.let { host.origins[it] }
|
||||
val inside = entry.key?.let { host.contents[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)
|
||||
@@ -191,9 +234,16 @@ fun MorphOverlay(host: MorphHost) {
|
||||
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))
|
||||
// The thresholds of MDC's container transform, as fractions of this way (p) and back.
|
||||
val closing = host.closing
|
||||
val fade = (if (closing) (p - 0.1f) / 0.3f else p / 0.25f).coerceIn(0f, 1f)
|
||||
val corners = (if (closing) (p - 0.1f) / 0.6f else p / 0.75f).coerceIn(0f, 1f)
|
||||
val startRadius = origin?.radius ?: 28.dp
|
||||
val radius = lerp(startRadius, targetRadius, corners)
|
||||
val shape = if (entry.kind == MorphKind.Sheet) RoundedCornerShape(radius, radius, lerp(startRadius, 0.dp, corners), lerp(startRadius, 0.dp, corners))
|
||||
else RoundedCornerShape(radius)
|
||||
// Both contents are scaled to the container's width and move with it.
|
||||
val pageScale = rect.width / target.width.coerceAtLeast(1f)
|
||||
// The scrim; a tap on it closes.
|
||||
Box(
|
||||
Modifier
|
||||
@@ -203,22 +253,35 @@ fun MorphOverlay(host: MorphHost) {
|
||||
)
|
||||
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) }) {
|
||||
// The card's own picture underneath, growing with the container.
|
||||
if (origin != null) Box(
|
||||
Modifier.drawBehind {
|
||||
val s = rect.width / origin.bounds.width.coerceAtLeast(1f)
|
||||
scale(s, s, pivot = Offset.Zero) {
|
||||
// Just the inside where it is marked: the fill and shadow are the container's.
|
||||
if (inside != null) translate(inside.bounds.left - origin.bounds.left, inside.bounds.top - origin.bounds.top) {
|
||||
drawLayer(inside.picture)
|
||||
} else drawLayer(origin.picture)
|
||||
}
|
||||
},
|
||||
)
|
||||
// The page, laid out once at its final size and shrunk to the container's width
|
||||
// from its top left corner: it comes out of the card instead of being uncovered.
|
||||
Box(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
alpha = fade
|
||||
scaleX = pageScale
|
||||
scaleY = pageScale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
}
|
||||
.background(surface),
|
||||
) {
|
||||
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()) }
|
||||
@@ -228,16 +291,18 @@ fun MorphOverlay(host: MorphHost) {
|
||||
scaleY = s
|
||||
translationY = drag
|
||||
}
|
||||
// The origin's shadow (a button's), fading out as it grows and back as it lands.
|
||||
.shadow(lerp(origin?.elevation ?: 0.dp, 0.dp, p), shape, clip = false)
|
||||
.clip(shape)
|
||||
.background(lerp(origin?.color ?: surface, surface, (p / 0.5f).coerceIn(0f, 1f)))
|
||||
.background(lerp(origin?.color ?: surface, surface, fade))
|
||||
// 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)))
|
||||
val picture = if (origin != null) measurables[0].measure(Constraints.fixed(rect.width.roundToInt().coerceAtLeast(0), rect.height.roundToInt().coerceAtLeast(0))) else null
|
||||
val content = measurables.last().measure(Constraints.fixed(target.width.roundToInt(), target.height.roundToInt()))
|
||||
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)
|
||||
content.place(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.motion
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.notes
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -73,6 +73,20 @@ import org.xtimms.psu.schedule.data.shortWeekday
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekdayName
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
|
||||
import org.xtimms.psu.schedule.ui.components.DONE_PAUSE
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.LocalUndo
|
||||
import org.xtimms.psu.schedule.ui.components.StrikeText
|
||||
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectKey
|
||||
import org.xtimms.psu.schedule.ui.components.SwipeAction
|
||||
import org.xtimms.psu.schedule.ui.components.SwipeActions
|
||||
import org.xtimms.psu.schedule.ui.components.UndoHost
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.automirrored.rounded.Undo
|
||||
import androidx.compose.material.icons.rounded.EventRepeat
|
||||
|
||||
/** Tasks and notes of the open group, for cards deep in the tree. */
|
||||
val LocalNotes = staticCompositionLocalOf<List<Note>> { emptyList() }
|
||||
@@ -118,15 +132,48 @@ fun MainViewModel.nextOccurrence(t: Timetable, subject: String, after: LocalDate
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
/** A task with its checkbox or a note with its icon; tap opens the editor. */
|
||||
/**
|
||||
* A task with its checkbox or a note with its icon; tap opens the editor. Swiped to the right a
|
||||
* task is done (or open again); swiped to the left it moves to the next lesson of its subject with
|
||||
* [onPostpone]. [swipe] is off where a sideways swipe pages, as on the plans board.
|
||||
*/
|
||||
@Composable
|
||||
fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject: Boolean, showDue: Boolean = true) {
|
||||
fun NoteRow(
|
||||
note: Note,
|
||||
onToggle: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
showSubject: Boolean,
|
||||
showDue: Boolean = true,
|
||||
onPostpone: ((UndoHost?) -> Unit)? = null,
|
||||
swipe: 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()
|
||||
val undo = LocalUndo.current
|
||||
fun check(done: Boolean) {
|
||||
haptic.performHapticFeedback(if (done) HapticFeedbackType.Confirm else HapticFeedbackType.ToggleOff)
|
||||
shownDone = done
|
||||
if (done) scope.launch {
|
||||
delay(DONE_PAUSE)
|
||||
onToggle()
|
||||
} else onToggle()
|
||||
}
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val doneAction = if (!swipe || !note.task) null
|
||||
else if (!shownDone) SwipeAction(Icons.Rounded.Check, "Выполнено", scheme.primary, scheme.onPrimary) { check(true) }
|
||||
else SwipeAction(Icons.AutoMirrored.Rounded.Undo, "Не выполнено", scheme.secondary, scheme.onSecondary) { check(false) }
|
||||
val laterAction = if (!swipe || onPostpone == null || (note.task && shownDone)) null
|
||||
else SwipeAction(
|
||||
Icons.Rounded.EventRepeat,
|
||||
if (note.general) "Перенести на завтра" else "Перенести к следующей паре",
|
||||
scheme.tertiary,
|
||||
scheme.onTertiary,
|
||||
) { onPostpone(undo) }
|
||||
SwipeActions(doneAction, laterAction) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
@@ -135,14 +182,7 @@ 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 = shownDone, onCheckedChange = { done ->
|
||||
haptic.performHapticFeedback(if (done) HapticFeedbackType.Confirm else HapticFeedbackType.ToggleOff)
|
||||
shownDone = done
|
||||
if (done) scope.launch {
|
||||
delay(DONE_PAUSE)
|
||||
onToggle()
|
||||
} else onToggle()
|
||||
})
|
||||
if (note.task) Checkbox(checked = shownDone, onCheckedChange = ::check)
|
||||
else Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Rounded.StickyNote2, contentDescription = "Заметка", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
@@ -173,19 +213,10 @@ 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),
|
||||
)
|
||||
PhotoThumb(note.photos.first(), 48.dp, onClick = { viewing = true }, more = note.photos.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (viewing) PhotoViewer(note.photos, 0) { viewing = false }
|
||||
}
|
||||
|
||||
@@ -350,7 +381,7 @@ fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
notes.forEach { note ->
|
||||
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false)
|
||||
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false, onPostpone = { vm.postponeNote(note, it) })
|
||||
}
|
||||
FilledTonalButton(onClick = { adding = true }, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
@@ -0,0 +1,472 @@
|
||||
package org.xtimms.psu.schedule.ui.notes
|
||||
|
||||
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
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.lerp
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.view.WindowCompat
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import org.xtimms.psu.schedule.ui.motion.Emphasized
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// 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) {
|
||||
// The state outlives a change of photo: never keep showing the previous one.
|
||||
thumbnails.get(key)?.let {
|
||||
value = it
|
||||
return@produceState
|
||||
}
|
||||
value = null
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each photo's thumbnail is on the screen, so the viewer grows out of it and shrinks back
|
||||
* into it (the M3 container transform for a hero moment). Screen coordinates, since a thumbnail
|
||||
* may sit in a dialog (the editor) and the viewer is a window of its own.
|
||||
*/
|
||||
private object PhotoHero {
|
||||
val bounds = mutableMapOf<String, Rect>()
|
||||
|
||||
/** The photo the viewer has taken over: its thumbnail steps aside meanwhile. */
|
||||
var hidden by mutableStateOf<String?>(null)
|
||||
}
|
||||
|
||||
/** A square thumbnail; [onClick] opens the viewer. [more] photos behind it show as "+2". */
|
||||
@Composable
|
||||
fun PhotoThumb(name: String, size: Dp, onClick: (() -> Unit)?, modifier: Modifier = Modifier, more: Int = 0) {
|
||||
val store = LocalContext.current.app.notes
|
||||
val px = with(LocalDensity.current) { size.roundToPx() } * 2
|
||||
val image = rememberPhoto(store.photo(name), px)
|
||||
val view = LocalView.current
|
||||
val placed = remember { mutableStateOf<Rect?>(null) }
|
||||
DisposableEffect(name) {
|
||||
onDispose { if (PhotoHero.bounds[name] == placed.value) PhotoHero.bounds.remove(name) }
|
||||
}
|
||||
Box(
|
||||
modifier
|
||||
.size(size)
|
||||
.onGloballyPositioned { c ->
|
||||
val at = IntArray(2).also(view::getLocationOnScreen)
|
||||
val rect = c.boundsInRoot().translate(at[0].toFloat(), at[1].toFloat())
|
||||
placed.value = rect
|
||||
PhotoHero.bounds[name] = rect
|
||||
}
|
||||
.graphicsLayer { alpha = if (PhotoHero.hidden == name) 0f else 1f }
|
||||
.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()) }
|
||||
if (more > 0) Text(
|
||||
"+$more",
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. It opens as a
|
||||
* container transform from the thumbnail that was tapped: the photo grows from its square crop
|
||||
* into the whole picture, its corners straighten and the black comes up around it. Closing
|
||||
* shrinks the photo shown back into its thumbnail, or fades it away when that one is not there.
|
||||
*/
|
||||
@Composable
|
||||
fun PhotoViewer(photos: List<String>, start: Int, onDismiss: () -> Unit) {
|
||||
if (photos.isEmpty()) return
|
||||
var closing by remember { mutableStateOf(false) }
|
||||
Dialog(
|
||||
onDismissRequest = { closing = true },
|
||||
// Edge to edge and with no dim of its own: the viewer draws its own black.
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
|
||||
) {
|
||||
val view = LocalView.current
|
||||
(view.parent as? DialogWindowProvider)?.window?.let { window ->
|
||||
SideEffect {
|
||||
window.setDimAmount(0f)
|
||||
// The transform is the whole animation: no window fade on top of it.
|
||||
window.setWindowAnimations(0)
|
||||
// Light status bar icons over the black.
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false
|
||||
}
|
||||
}
|
||||
PhotoPages(photos, start, closing, onClose = { closing = true }, onClosed = onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhotoPages(photos: List<String>, start: Int, closing: Boolean, onClose: () -> Unit, onClosed: () -> Unit) {
|
||||
val store = LocalContext.current.app.notes
|
||||
val reduced = rememberReducedMotion()
|
||||
val pager = rememberPagerState(initialPage = start.coerceIn(0, photos.lastIndex)) { photos.size }
|
||||
val name = photos[pager.currentPage]
|
||||
val image = rememberPhoto(store.photo(name), 2048)
|
||||
// The thumbnail the photo shown returns to: its own, or the one the viewer opened from (a
|
||||
// task's row shows only its first photo).
|
||||
val anchor = if (PhotoHero.bounds.containsKey(name)) name else photos[start.coerceIn(0, photos.lastIndex)]
|
||||
// Returning to another photo's thumbnail, the photo turns into that one on the last stretch.
|
||||
val anchorImage = if (anchor != name && closing) rememberPhoto(store.photo(anchor), 1024) else null
|
||||
val progress = remember { Animatable(0f) }
|
||||
var back by remember { mutableFloatStateOf(0f) }
|
||||
var opened by remember { mutableStateOf(false) }
|
||||
var gone by remember { mutableStateOf(false) }
|
||||
val view = LocalView.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// Opens once the photo is decoded, so it grows as itself and not as an empty box.
|
||||
LaunchedEffect(image != null) {
|
||||
if (image == null || opened) return@LaunchedEffect
|
||||
PhotoHero.hidden = name
|
||||
progress.animateTo(1f, if (reduced) snap() else tween(450, easing = Emphasized))
|
||||
opened = true
|
||||
PhotoHero.hidden = null
|
||||
}
|
||||
LaunchedEffect(closing) {
|
||||
if (!closing) return@LaunchedEffect
|
||||
PhotoHero.hidden = anchor
|
||||
progress.animateTo(0f, if (reduced) snap() else tween(350, easing = Emphasized))
|
||||
// The thumbnail takes over again before the window goes.
|
||||
gone = true
|
||||
PhotoHero.hidden = null
|
||||
withFrameNanos {}
|
||||
onClosed()
|
||||
}
|
||||
PredictiveBackHandler(enabled = !closing) { events ->
|
||||
try {
|
||||
events.collect { back = it.progress }
|
||||
onClose()
|
||||
} catch (e: CancellationException) {
|
||||
scope.launch { animate(back, 0f) { v, _ -> back = v } }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val w = constraints.maxWidth.toFloat()
|
||||
val h = constraints.maxHeight.toFloat()
|
||||
val p = progress.value
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = p)))
|
||||
val hero = !opened || closing
|
||||
if (hero && image != null && !gone) {
|
||||
// The whole photo on the screen, and the thumbnail it grows from, in this window.
|
||||
val fit = minOf(w / image.width, h / image.height)
|
||||
val target = Rect(Offset((w - image.width * fit) / 2, (h - image.height * fit) / 2), Size(image.width * fit, image.height * fit))
|
||||
val at = IntArray(2).also(view::getLocationOnScreen)
|
||||
val from = PhotoHero.bounds[anchor]?.translate(-at[0].toFloat(), -at[1].toFloat())
|
||||
val rect = if (from != null) lerp(from, target, p) else target
|
||||
val corner = with(LocalDensity.current) { (12.dp * (1f - p)).toPx() }
|
||||
Image(
|
||||
image,
|
||||
contentDescription = null,
|
||||
// A square crop at the thumbnail, the whole photo on the screen.
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) }
|
||||
.size(with(LocalDensity.current) { rect.width.toDp() }, with(LocalDensity.current) { rect.height.toDp() })
|
||||
.graphicsLayer {
|
||||
// With no thumbnail to return to, it fades and settles a little smaller.
|
||||
if (from == null) {
|
||||
alpha = p
|
||||
val s = 0.9f + 0.1f * p
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
}
|
||||
shape = RoundedCornerShape(corner)
|
||||
clip = true
|
||||
},
|
||||
)
|
||||
if (from != null && anchorImage != null) Image(
|
||||
anchorImage,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) }
|
||||
.size(with(LocalDensity.current) { rect.width.toDp() }, with(LocalDensity.current) { rect.height.toDp() })
|
||||
.graphicsLayer {
|
||||
alpha = (1f - p / 0.35f).coerceIn(0f, 1f)
|
||||
shape = RoundedCornerShape(corner)
|
||||
clip = true
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!hero) Box(
|
||||
Modifier.fillMaxSize().graphicsLayer {
|
||||
// The predictive back gesture shrinks the photo a little before it lets go.
|
||||
val s = 1f - 0.1f * back
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
},
|
||||
) {
|
||||
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
|
||||
ZoomablePhoto(photos[page], page, photos.size)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { alpha = ((p - 0.5f) / 0.5f).coerceIn(0f, 1f) }
|
||||
.statusBarsPadding()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
IconButton(onClick = onClose) { 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One photo of the viewer: two fingers zoom, a double tap zooms in and out. */
|
||||
@Composable
|
||||
private fun ZoomablePhoto(name: String, page: Int, count: Int) {
|
||||
val store = LocalContext.current.app.notes
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||
val image = rememberPhoto(store.photo(name), 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} из $count",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = offset.x
|
||||
translationY = offset.y
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Up to this many photos per task or note. */
|
||||
const val MAX_PHOTOS = 6
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.onboarding
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
@@ -93,6 +93,17 @@ import org.xtimms.psu.schedule.notify.Reminders
|
||||
import org.xtimms.psu.schedule.ui.theme.WeekColors
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.SegmentedItem
|
||||
import org.xtimms.psu.schedule.ui.components.SegmentedList
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.groups.GroupsScreen
|
||||
import org.xtimms.psu.schedule.ui.groups.choiceText
|
||||
import org.xtimms.psu.schedule.ui.groups.foreignText
|
||||
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
|
||||
|
||||
/** 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, Choice, Foreign, Reminders }
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.plans
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -101,6 +101,44 @@ import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.MenuButton
|
||||
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
|
||||
import org.xtimms.psu.schedule.ui.components.ContentColumn
|
||||
import org.xtimms.psu.schedule.ui.components.DONE_PAUSE
|
||||
import org.xtimms.psu.schedule.ui.components.DetailPane
|
||||
import org.xtimms.psu.schedule.ui.components.Empty
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.LocalPaneColor
|
||||
import org.xtimms.psu.schedule.ui.components.LocalUndo
|
||||
import org.xtimms.psu.schedule.ui.components.MAX_CONTENT
|
||||
import org.xtimms.psu.schedule.ui.components.PaneRow
|
||||
import org.xtimms.psu.schedule.ui.components.StrikeText
|
||||
import org.xtimms.psu.schedule.ui.components.TimeText
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.components.twoPane
|
||||
import org.xtimms.psu.schedule.ui.components.weekTintOf
|
||||
import org.xtimms.psu.schedule.ui.lesson.DayPlaces
|
||||
import org.xtimms.psu.schedule.ui.lesson.SessionDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.placesOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
|
||||
import org.xtimms.psu.schedule.ui.lesson.session
|
||||
import org.xtimms.psu.schedule.ui.lesson.sessionColors
|
||||
import org.xtimms.psu.schedule.ui.lesson.slotsOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
|
||||
import org.xtimms.psu.schedule.ui.motion.Emphasized
|
||||
import org.xtimms.psu.schedule.ui.motion.LocalMorph
|
||||
import org.xtimms.psu.schedule.ui.motion.MorphKind
|
||||
import org.xtimms.psu.schedule.ui.motion.MotionDuration
|
||||
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
|
||||
import org.xtimms.psu.schedule.ui.motion.morphOrigin
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteEditor
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteTarget
|
||||
import org.xtimms.psu.schedule.ui.notes.general
|
||||
import org.xtimms.psu.schedule.ui.motion.morphContent
|
||||
|
||||
private val MONTH = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru"))
|
||||
private const val MONTHS_BEFORE = 2
|
||||
@@ -318,8 +356,8 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
|
||||
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 = "Новый план") }
|
||||
.morphOrigin("fab|calendar", MaterialTheme.colorScheme.primaryContainer, 16.dp, elevation = 6.dp),
|
||||
) { Icon(Icons.Rounded.Add, contentDescription = "Новый план", modifier = Modifier.morphContent("fab|calendar")) }
|
||||
}
|
||||
|
||||
target?.let { tg ->
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.plans
|
||||
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -96,6 +96,38 @@ import org.xtimms.psu.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.MenuButton
|
||||
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
|
||||
import org.xtimms.psu.schedule.ui.components.DetailPane
|
||||
import org.xtimms.psu.schedule.ui.components.DoneMark
|
||||
import org.xtimms.psu.schedule.ui.components.Empty
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.LocalUndo
|
||||
import org.xtimms.psu.schedule.ui.components.PaneRow
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.components.twoPane
|
||||
import org.xtimms.psu.schedule.ui.components.weekTint
|
||||
import org.xtimms.psu.schedule.ui.home.CompactLesson
|
||||
import org.xtimms.psu.schedule.ui.lesson.SessionDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.Slot
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
|
||||
import org.xtimms.psu.schedule.ui.lesson.session
|
||||
import org.xtimms.psu.schedule.ui.lesson.sessionColors
|
||||
import org.xtimms.psu.schedule.ui.lesson.slotsOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.motion.LocalMorph
|
||||
import org.xtimms.psu.schedule.ui.motion.MorphKind
|
||||
import org.xtimms.psu.schedule.ui.motion.materialFadeThrough
|
||||
import org.xtimms.psu.schedule.ui.motion.morphOrigin
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteEditor
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteRow
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteTarget
|
||||
import org.xtimms.psu.schedule.ui.search.SearchWeekStrip
|
||||
import org.xtimms.psu.schedule.ui.notes.overdue
|
||||
import org.xtimms.psu.schedule.ui.components.UndoHost
|
||||
import org.xtimms.psu.schedule.ui.motion.morphContent
|
||||
|
||||
/** "Сегодня • Суббота • 17 октября", "Завтра • …" or "Понедельник • 19 октября". */
|
||||
private fun dayTitle(d: LocalDate): String = when (d) {
|
||||
@@ -222,7 +254,7 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
||||
rows.getOrNull(i)?.date?.let { if (it != date) date = it }
|
||||
}
|
||||
}
|
||||
PlanList(list, rows, vm::toggleNote, onEdit = { editing = it }, color = color)
|
||||
PlanList(list, rows, vm::toggleNote, onEdit = { editing = it }, onPostpone = vm::postponeNote, color = color)
|
||||
}
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
@@ -285,8 +317,8 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
||||
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 = "Новое задание") }
|
||||
.morphOrigin("fab|plans", MaterialTheme.colorScheme.primaryContainer, 16.dp, elevation = 6.dp),
|
||||
) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание", modifier = Modifier.morphContent("fab|plans")) }
|
||||
}
|
||||
|
||||
if (tuning) ModalBottomSheet(onDismissRequest = { tuning = false }) { PlansSettings(prefs, vm::setPlans) }
|
||||
@@ -332,6 +364,7 @@ private fun PlanList(
|
||||
rows: List<Row>,
|
||||
onToggle: (String) -> Unit,
|
||||
onEdit: (Note) -> Unit,
|
||||
onPostpone: (Note, UndoHost?) -> Unit,
|
||||
color: Color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
if (rows.isEmpty()) {
|
||||
@@ -356,7 +389,14 @@ private fun PlanList(
|
||||
}
|
||||
is Row.Item -> item(key = "n${row.note.id}") {
|
||||
Box(Modifier.animateItem()) {
|
||||
NoteRow(row.note, onToggle = { onToggle(row.note.id) }, onClick = { onEdit(row.note) }, showSubject = true, showDue = row.date == null)
|
||||
NoteRow(
|
||||
row.note,
|
||||
onToggle = { onToggle(row.note.id) },
|
||||
onClick = { onEdit(row.note) },
|
||||
showSubject = true,
|
||||
showDue = row.date == null,
|
||||
onPostpone = { onPostpone(row.note, it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
is Row.Divider -> item(key = "d$day") {
|
||||
@@ -471,7 +511,8 @@ private fun Board(
|
||||
color = MaterialTheme.colorScheme.card,
|
||||
modifier = Modifier.fillMaxWidth().border(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.large),
|
||||
) {
|
||||
NoteRow(n, onToggle = { onToggle(n.id) }, onClick = { onEdit(n) }, showSubject = true, showDue = d == null)
|
||||
// The board's columns page sideways: a sideways swipe on a task would fight them.
|
||||
NoteRow(n, onToggle = { onToggle(n.id) }, onClick = { onEdit(n) }, showSubject = true, showDue = d == null, swipe = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.schedule
|
||||
|
||||
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
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
@@ -16,29 +14,16 @@ import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import 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
|
||||
import androidx.compose.material.icons.rounded.Weekend
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.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
|
||||
@@ -50,12 +35,8 @@ import org.xtimms.psu.schedule.ui.theme.page
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -71,27 +52,17 @@ import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CloudOff
|
||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||
import androidx.compose.material.icons.rounded.EventBusy
|
||||
import androidx.compose.material.icons.rounded.EventRepeat
|
||||
import androidx.compose.material.icons.rounded.Groups
|
||||
import androidx.compose.material.icons.rounded.LocationCity
|
||||
import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.CalendarViewWeek
|
||||
import androidx.compose.material.icons.rounded.ViewDay
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.material.icons.rounded.Schedule
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -106,7 +77,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.psu.schedule.data.Bell
|
||||
import org.xtimms.psu.schedule.data.DayPlace
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
@@ -116,9 +86,40 @@ import org.xtimms.psu.schedule.data.minutesLabel
|
||||
import org.xtimms.psu.schedule.data.monday
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.withPlace
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.MenuButton
|
||||
import org.xtimms.psu.schedule.ui.components.Banner
|
||||
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
|
||||
import org.xtimms.psu.schedule.ui.components.ContentColumn
|
||||
import org.xtimms.psu.schedule.ui.components.DayStrip
|
||||
import org.xtimms.psu.schedule.ui.components.Empty
|
||||
import org.xtimms.psu.schedule.ui.components.FoldSplit
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.LocalFoldTablet
|
||||
import org.xtimms.psu.schedule.ui.components.LocalHinge
|
||||
import org.xtimms.psu.schedule.ui.components.MAX_CONTENT
|
||||
import org.xtimms.psu.schedule.ui.components.ScheduleSkeleton
|
||||
import org.xtimms.psu.schedule.ui.components.TWO_PANE
|
||||
import org.xtimms.psu.schedule.ui.components.WeekMark
|
||||
import org.xtimms.psu.schedule.ui.components.appBarColors
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.components.trackFold
|
||||
import org.xtimms.psu.schedule.ui.components.weekTint
|
||||
import org.xtimms.psu.schedule.ui.components.weekTintOf
|
||||
import org.xtimms.psu.schedule.ui.lesson.DayPlaces
|
||||
import org.xtimms.psu.schedule.ui.lesson.LessonCard
|
||||
import org.xtimms.psu.schedule.ui.lesson.BetweenRow
|
||||
import org.xtimms.psu.schedule.ui.lesson.between
|
||||
import org.xtimms.psu.schedule.ui.lesson.Slot
|
||||
import org.xtimms.psu.schedule.ui.lesson.placesOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberClock
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
|
||||
import org.xtimms.psu.schedule.ui.lesson.slotsOn
|
||||
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
|
||||
import org.xtimms.psu.schedule.ui.motion.ExpressiveRefresh
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -470,7 +471,8 @@ private fun DayPage(
|
||||
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 }
|
||||
val before = (if (places.isNotEmpty()) 1 else 0) + slots.take(now).sumOf { it.lessons.size } +
|
||||
(1..now).count { i -> slots.getOrNull(i - 1)?.let { between(it, slots[i]) } != null }
|
||||
list.scrollToItem(before)
|
||||
}
|
||||
ContentColumn {
|
||||
@@ -487,7 +489,11 @@ private fun DayPage(
|
||||
}
|
||||
// "Занятия в Полоцке": the day's town before its lessons.
|
||||
if (places.isNotEmpty()) item(key = "places") { AnimatedItem { DayPlaces(places) } }
|
||||
slots.forEach { slot ->
|
||||
slots.forEachIndexed { index, slot ->
|
||||
// A window, another building or another town since the lesson before.
|
||||
slots.getOrNull(index - 1)?.let { prev -> between(prev, slot) }?.let { b ->
|
||||
item(key = "between|${slot.start}") { AnimatedItem { BetweenRow(b) } }
|
||||
}
|
||||
val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end)
|
||||
val status = when {
|
||||
running -> "Идёт · ещё ${minutesLabel(Duration.between(clock, slot.end).toMinutes())}"
|
||||
@@ -518,260 +524,6 @@ private fun DayPage(
|
||||
}
|
||||
}
|
||||
|
||||
/** A lesson as a soft card in its subject's colour, like School Planner's day list. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun LessonCard(
|
||||
lesson: Lesson,
|
||||
time: String?,
|
||||
pair: Int? = null,
|
||||
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
|
||||
val session = lesson.session
|
||||
val content: @Composable () -> Unit = {
|
||||
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
// Exams, credits and consultations announce themselves before the subject.
|
||||
session?.let { SessionBadge(it, Modifier.padding(bottom = 2.dp)) }
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Text(lesson.subject, style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f))
|
||||
status?.let {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (running) color.onSoft else color.onSoft.copy(alpha = 0.12f),
|
||||
contentColor = if (running) color.soft else color.onSoft,
|
||||
) {
|
||||
RollingText(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
time?.let { TimeText(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно")
|
||||
else withPlace(
|
||||
lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { it.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "" },
|
||||
lesson.place,
|
||||
)?.let { Meta(Icons.Rounded.LocationOn, it) }
|
||||
if (show.teachers && lesson.teachers.isNotEmpty()) Meta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
|
||||
lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) }
|
||||
}
|
||||
val tags = if (!show.tags) emptyList() else listOfNotNull(
|
||||
lesson.type.ifEmpty { null }?.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(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
) {
|
||||
mark?.let {
|
||||
SoftTag(if (it.present) "был" else "пропуск", color, icon = if (it.present) Icons.Rounded.Check else Icons.Rounded.Close)
|
||||
}
|
||||
tags.forEach { SoftTag(it, color) }
|
||||
if (lesson.uncertain) Tag("Сверьте недели", warning = true)
|
||||
}
|
||||
if (show.notes && lesson.condition.isNotEmpty()) Meta(Icons.Rounded.EventRepeat, "Пометка в Word: ${lesson.condition}")
|
||||
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
|
||||
}
|
||||
if (onClick != null) Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = fill,
|
||||
contentColor = color.onSoft,
|
||||
border = border,
|
||||
modifier = Modifier.fillMaxWidth().then(origin).then(live),
|
||||
content = content,
|
||||
) else Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = color.soft,
|
||||
contentColor = color.onSoft,
|
||||
border = border,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SoftTag(text: String, color: SubjectColor, icon: ImageVector? = null) {
|
||||
Surface(shape = MaterialTheme.shapes.small, color = color.onSoft.copy(alpha = 0.12f), contentColor = color.onSoft) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = if (icon != null) 6.dp else 8.dp, end = 8.dp, top = 3.dp, bottom = 3.dp),
|
||||
) {
|
||||
icon?.let {
|
||||
Icon(it, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Text(text.replaceFirstChar { it.titlecase() }, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Meta(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TypeTag(type: String) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val (bg, fg) = when (type) {
|
||||
"Лекция" -> scheme.primaryContainer to scheme.onPrimaryContainer
|
||||
"Практика" -> scheme.tertiaryContainer to scheme.onTertiaryContainer
|
||||
"Экзамен", "Зачёт", "Защита курсовой работы" -> sessionColors().let { it.strong to it.onStrong }
|
||||
"Консультация" -> sessionColors().let { it.soft to it.onSoft }
|
||||
else -> scheme.secondaryContainer to scheme.onSecondaryContainer
|
||||
}
|
||||
Surface(shape = MaterialTheme.shapes.small, color = bg, contentColor = fg) {
|
||||
Text(type.ifEmpty { "Занятие" }, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Tag(text: String, warning: Boolean = false) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (warning) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = if (warning) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Text(text, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LessonDetails(
|
||||
lesson: Lesson,
|
||||
bells: List<Bell>,
|
||||
date: LocalDate? = null,
|
||||
vm: MainViewModel? = null,
|
||||
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())
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
TypeTag(lesson.type)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SubjectDot(lesson.subject, 16.dp)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Heading(lesson.subject, style = MaterialTheme.typography.headlineSmallEmphasized)
|
||||
}
|
||||
Detail(
|
||||
Icons.Rounded.Schedule,
|
||||
bell?.let { "${it.pair} пара" } ?: "Время",
|
||||
lesson.start + (lesson.end?.let { " — $it" } ?: ""),
|
||||
bell?.halves?.takeIf { it.size > 1 }?.joinToString(", ") { it.joinToString("–") },
|
||||
)
|
||||
Detail(Icons.Rounded.Person, "Преподаватель", lesson.teachers.joinToString(", ").ifEmpty { "Не указан" })
|
||||
Detail(
|
||||
Icons.Rounded.LocationOn,
|
||||
"Аудитория",
|
||||
if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { "Не указана" },
|
||||
if (lesson.roomMoved) "В этот день — перенос аудитории" else null,
|
||||
)
|
||||
lesson.place?.let { Detail(Icons.Rounded.LocationCity, "Город", it, "Так указано в Word") }
|
||||
Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup)
|
||||
if (lesson.uncertain) Banner(
|
||||
"Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.",
|
||||
offline = false,
|
||||
onClose = null,
|
||||
)
|
||||
if (date != null && vm != null) {
|
||||
LessonAttendance(vm, lesson, date)
|
||||
LessonNotes(vm, lesson, date)
|
||||
}
|
||||
Heading("Исходная запись", style = MaterialTheme.typography.titleSmallEmphasized)
|
||||
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
|
||||
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())
|
||||
}
|
||||
if (vm != null) {
|
||||
OutlinedButton(onClick = {
|
||||
vm.setHidden(lesson.hideKey, true)
|
||||
onClose()
|
||||
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("Скрыть такие занятия")
|
||||
}
|
||||
Text(
|
||||
"«${lesson.subject}»${lesson.type.takeIf { it.isNotEmpty() }?.let { " · ${it.lowercase()}" }.orEmpty()} пропадёт из расписания, " +
|
||||
"обзора, виджета и напоминаний во все недели. Вернуть можно в настройках.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Detail(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String, extra: String? = null) {
|
||||
Row {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodyLargeEmphasized)
|
||||
extra?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** When the week table has no semester for this schedule: the Word rows by weekday. */
|
||||
@Composable
|
||||
private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpen: (Lesson) -> Unit) {
|
||||
@@ -795,140 +547,3 @@ private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Banner(
|
||||
text: String,
|
||||
offline: Boolean,
|
||||
onClose: (() -> Unit)?,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
/** A way out the message asks for, such as "Выбрать группу". */
|
||||
action: Pair<String, () -> Unit>? = null,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (offline) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = if (offline) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
val buttons: @Composable () -> Unit = {
|
||||
if (onRetry != null) TextButton(onClick = onRetry) { Text("Повторить") }
|
||||
action?.let { (label, onClick) -> TextButton(onClick = onClick) { Text(label) } }
|
||||
if (onClose != null) TextButton(onClick = onClose) { Text("OK") }
|
||||
}
|
||||
// With an action the message needs the whole width; the buttons go under it.
|
||||
if (action != null) Column(Modifier.padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 4.dp)) {
|
||||
Row {
|
||||
Icon(Icons.Rounded.ErrorOutline.takeIf { !offline } ?: Icons.Rounded.CloudOff, contentDescription = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(end = 12.dp))
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { buttons() }
|
||||
} else Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(if (offline) Icons.Rounded.CloudOff else Icons.Rounded.ErrorOutline, contentDescription = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(vertical = 8.dp))
|
||||
buttons()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centres an empty state in the space it is given — a phone screen or a tablet's pane —
|
||||
* and scrolls when that space is too low, so the illustration is never squeezed.
|
||||
*/
|
||||
@Composable
|
||||
fun CenteredEmpty(
|
||||
modifier: Modifier = Modifier,
|
||||
/** Room kept clear inside the scroll (under a FAB): content scrolls past it, never gets cut. */
|
||||
bottomInset: Dp = 0.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val height = maxHeight
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).heightIn(min = height).padding(bottom = bottomInset),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state in the RideBus style: an icon on a seven-sided cookie shape, a title,
|
||||
* a calm explanation and an optional tonal action.
|
||||
*/
|
||||
@Composable
|
||||
fun Empty(
|
||||
title: String,
|
||||
text: String,
|
||||
icon: ImageVector = Icons.Rounded.EventBusy,
|
||||
action: Pair<String, () -> Unit>? = null,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(vertical = 48.dp, horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(112.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, MaterialShapes.Cookie7Sided.toShape()),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleLargeEmphasized, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
action?.let { (label, onClick) ->
|
||||
Spacer(Modifier.height(20.dp))
|
||||
FilledTonalButton(onClick = onClick) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val FACULTIES = mapOf(
|
||||
"Финансово-экономический факультет" to "ФЭФ",
|
||||
"Факультет информационных технологий" to "ФИТ",
|
||||
"Факультет компьютерных наук и электроники" to "ФКНЭ",
|
||||
"Гуманитарный факультет" to "ГФ",
|
||||
"Инженерно-строительный факультет" to "ИСФ",
|
||||
"Механико-технологический факультет" to "МТФ",
|
||||
"Юридический факультет" to "ЮФ",
|
||||
)
|
||||
|
||||
fun facultyShort(name: String): String? = name.takeIf { it.isNotEmpty() }?.let { FACULTIES[it] ?: it }
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.schedule
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -49,6 +49,19 @@ import org.xtimms.psu.schedule.data.weekdayName
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.TimeText
|
||||
import org.xtimms.psu.schedule.ui.components.WeekMark
|
||||
import org.xtimms.psu.schedule.ui.home.CompactLesson
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
|
||||
import org.xtimms.psu.schedule.ui.lesson.SessionBadge
|
||||
import org.xtimms.psu.schedule.ui.lesson.Slot
|
||||
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.session
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.motion.RollingText
|
||||
import org.xtimms.psu.schedule.ui.motion.morphOrigin
|
||||
import org.xtimms.psu.schedule.ui.motion.morphContent
|
||||
|
||||
// The schedule of a foldable half opened on a table, as a desk stand: above the fold, facing the
|
||||
// student, one lesson large enough to read from a distance; below it, under the thumb, the bar,
|
||||
@@ -133,7 +146,7 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
|
||||
contentColor = color.onSoft,
|
||||
modifier = modifier.morphOrigin("stand|${slot.date}|${lesson.key}|${lesson.start}", color.soft, 28.dp),
|
||||
) {
|
||||
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(Modifier.morphContent("stand|${slot.date}|${lesson.key}|${lesson.start}").padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TimeText(
|
||||
"${slot.start} → ${slot.end}" + (lesson.pair?.let { " · $it пара" } ?: ""),
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.schedule
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.animation.core.tween
|
||||
@@ -50,6 +50,27 @@ import org.xtimms.psu.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.data.withPlace
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.lesson.DayPlaceMark
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
|
||||
import org.xtimms.psu.schedule.ui.lesson.SessionDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.Slot
|
||||
import org.xtimms.psu.schedule.ui.lesson.abbreviate
|
||||
import org.xtimms.psu.schedule.ui.lesson.outline
|
||||
import org.xtimms.psu.schedule.ui.lesson.placesOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberClock
|
||||
import org.xtimms.psu.schedule.ui.lesson.session
|
||||
import org.xtimms.psu.schedule.ui.lesson.slotsOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.motion.morphContent
|
||||
import org.xtimms.psu.schedule.ui.motion.morphOrigin
|
||||
import org.xtimms.psu.schedule.ui.notes.LocalNotes
|
||||
import org.xtimms.psu.schedule.ui.lesson.forLesson
|
||||
import org.xtimms.psu.schedule.ui.notes.forLesson
|
||||
import org.xtimms.psu.schedule.ui.notes.open
|
||||
import androidx.compose.ui.draw.clip
|
||||
import org.xtimms.psu.schedule.ui.lesson.lessonPress
|
||||
|
||||
private val HOUR = 60.dp
|
||||
private val GUTTER = 36.dp
|
||||
@@ -182,7 +203,8 @@ 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 morphKey = "grid|${slot.date}|${lesson.key}|${lesson.start}"
|
||||
val origin = Modifier.morphOrigin(morphKey, 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 {
|
||||
@@ -191,17 +213,16 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o
|
||||
else -> rooms.ifEmpty { lesson.place.orEmpty() }
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = color.container,
|
||||
contentColor = color.content,
|
||||
border = session?.let { androidx.compose.foundation.BorderStroke(2.dp, it.outline()) },
|
||||
modifier = modifier.then(origin).semantics {
|
||||
modifier = modifier.then(origin).clip(MaterialTheme.shapes.small).lessonPress(lesson, slot.date, onClick).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 "")
|
||||
},
|
||||
) {
|
||||
) { Box(Modifier.fillMaxSize().morphContent(morphKey)) {
|
||||
// A small dot in the corner marks lessons with open tasks or notes.
|
||||
if (notes > 0) Box(Modifier.fillMaxSize().padding(5.dp), contentAlignment = Alignment.TopEnd) {
|
||||
Box(Modifier.size(8.dp).background(color.content, CircleShape))
|
||||
@@ -217,5 +238,5 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o
|
||||
)
|
||||
if (room.isNotEmpty()) Text(room, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
} }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.search
|
||||
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
@@ -75,18 +75,77 @@ import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.monday
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.MenuButton
|
||||
import org.xtimms.psu.schedule.ui.components.Banner
|
||||
import org.xtimms.psu.schedule.ui.components.CardRow
|
||||
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
|
||||
import org.xtimms.psu.schedule.ui.components.ContentColumn
|
||||
import org.xtimms.psu.schedule.ui.components.DayStrip
|
||||
import org.xtimms.psu.schedule.ui.components.DetailPane
|
||||
import org.xtimms.psu.schedule.ui.components.Empty
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.LessonsSkeleton
|
||||
import org.xtimms.psu.schedule.ui.components.ListCard
|
||||
import org.xtimms.psu.schedule.ui.components.MAX_CONTENT
|
||||
import org.xtimms.psu.schedule.ui.components.PaneRow
|
||||
import org.xtimms.psu.schedule.ui.components.RowDivider
|
||||
import org.xtimms.psu.schedule.ui.components.SearchField
|
||||
import org.xtimms.psu.schedule.ui.components.WeekMark
|
||||
import org.xtimms.psu.schedule.ui.components.avatarLabel
|
||||
import org.xtimms.psu.schedule.ui.components.isTablet
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.components.twoPane
|
||||
import org.xtimms.psu.schedule.ui.components.weekTint
|
||||
import org.xtimms.psu.schedule.ui.components.weekTintOf
|
||||
import org.xtimms.psu.schedule.ui.groups.facultyShort
|
||||
import org.xtimms.psu.schedule.ui.lesson.LessonCard
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
|
||||
import org.xtimms.psu.schedule.ui.schedule.weekTitle
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material.icons.rounded.StarBorder
|
||||
import androidx.compose.material.icons.rounded.History
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import org.xtimms.psu.schedule.data.minutesLabel
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberClock
|
||||
import java.time.Duration
|
||||
|
||||
private sealed interface Target {
|
||||
data class Teacher(val name: String) : Target
|
||||
data class Room(val room: String) : Target
|
||||
}
|
||||
|
||||
/** How a teacher or a room is kept among the favorites and the recent ones: "teacher:Ковшик О.С.". */
|
||||
private val Target.key: String
|
||||
get() = when (this) {
|
||||
is Target.Teacher -> "teacher:$name"
|
||||
is Target.Room -> "room:$room"
|
||||
}
|
||||
|
||||
private fun targetOf(key: String): Target? = when {
|
||||
key.startsWith("teacher:") -> Target.Teacher(key.removePrefix("teacher:"))
|
||||
key.startsWith("room:") -> Target.Room(key.removePrefix("room:"))
|
||||
else -> null
|
||||
}
|
||||
|
||||
private val Target.title: String
|
||||
get() = when (this) {
|
||||
is Target.Teacher -> name
|
||||
is Target.Room -> "Аудитория $room"
|
||||
}
|
||||
|
||||
internal fun startDate(): LocalDate = if (today().dayOfWeek.value == 7) today().plusDays(1) else today()
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||
val favorites = settings?.searchFavorites.orEmpty()
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var target by remember { mutableStateOf<Target?>(null) }
|
||||
var date by remember { mutableStateOf(startDate()) }
|
||||
@@ -106,6 +165,8 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
delay(250)
|
||||
attempt { vm.api.searchDirectory(query.trim()) }.onSuccess { hits = it; error = null }.onFailure { error = it.message }
|
||||
}
|
||||
// Whoever is opened goes to the top of "Недавние".
|
||||
LaunchedEffect(target) { target?.let { vm.rememberSearch(it.key) } }
|
||||
LaunchedEffect(target, date, retries) {
|
||||
val t = target ?: return@LaunchedEffect
|
||||
loading = true
|
||||
@@ -180,6 +241,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
if (dated && date != startDate()) IconButton(onClick = { date = startDate() }) {
|
||||
Icon(Icons.Rounded.Today, contentDescription = "Сегодня")
|
||||
}
|
||||
target?.let { t -> FavoriteButton(t.key in favorites) { vm.toggleSearchFavorite(t.key) } }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -195,13 +257,26 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
if (target == null || pane) {
|
||||
item { SearchField(query, { query = it }, "Фамилия или номер аудитории") }
|
||||
val h = hits
|
||||
if (h == null && !pane) item {
|
||||
Box(Modifier.fillParentMaxHeight(0.75f).fillMaxWidth(), contentAlignment = Alignment.Center) { Empty(
|
||||
"Кто где",
|
||||
"Найдите преподавателя или аудиторию — покажем занятия на выбранный день по всем расписаниям университета.",
|
||||
icon = Icons.Rounded.PersonSearch,
|
||||
) }
|
||||
} else if (h != null) {
|
||||
if (h == null) {
|
||||
// Before a query: the starred ones and the latest, a tap from today's day.
|
||||
val starred = favorites.mapNotNull(::targetOf)
|
||||
val recent = settings?.searchRecent.orEmpty().filter { it !in favorites }.mapNotNull(::targetOf)
|
||||
val open: (Target) -> Unit = { target = it; date = startDate() }
|
||||
if (starred.isNotEmpty()) item(key = "starred") {
|
||||
SavedTargets(Icons.Rounded.Star, "Избранное", starred, favorites, open, vm::toggleSearchFavorite)
|
||||
}
|
||||
if (recent.isNotEmpty()) item(key = "recent") {
|
||||
SavedTargets(Icons.Rounded.History, "Недавние", recent, favorites, open, vm::toggleSearchFavorite)
|
||||
}
|
||||
if (starred.isEmpty() && recent.isEmpty() && !pane) item {
|
||||
Box(Modifier.fillParentMaxHeight(0.75f).fillMaxWidth(), contentAlignment = Alignment.Center) { Empty(
|
||||
"Кто где",
|
||||
"Найдите преподавателя или аудиторию — покажем занятия на выбранный день по всем расписаниям университета. " +
|
||||
"Звёздочкой их можно добавить в избранное.",
|
||||
icon = Icons.Rounded.PersonSearch,
|
||||
) }
|
||||
}
|
||||
} else {
|
||||
if (h.teachers.isNotEmpty()) item {
|
||||
ListCard(Icons.Rounded.Person, "Преподаватели") {
|
||||
h.teachers.forEachIndexed { index, t ->
|
||||
@@ -235,6 +310,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
}
|
||||
}
|
||||
val dayList: @Composable (Modifier) -> Unit = { modifier ->
|
||||
val clock = rememberClock()
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp + navBarPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -250,6 +326,12 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
}
|
||||
}
|
||||
items(d.lessons, key = { "${it.start}|${it.subject}|${it.rooms}" }) { l -> AnimatedItem {
|
||||
// Today: where the teacher is now and where next, as on the student's own day.
|
||||
val start = parseTime(l.start)
|
||||
val end = l.end?.let(::parseTime) ?: start.plusMinutes(85)
|
||||
val now = date == today()
|
||||
val running = now && !clock.isBefore(start) && clock.isBefore(end)
|
||||
val soon = now && clock.isBefore(start) && Duration.between(clock, start).toMinutes() <= 120
|
||||
LessonCard(
|
||||
Lesson(
|
||||
key = l.start + l.subject,
|
||||
@@ -269,6 +351,13 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
),
|
||||
time = l.start + (l.end?.let { " → $it" } ?: ""),
|
||||
pair = bells.firstOrNull { it.start == l.start }?.pair,
|
||||
status = when {
|
||||
running -> "Идёт · ещё ${minutesLabel(Duration.between(clock, end).toMinutes())}"
|
||||
soon -> "Через ${minutesLabel(Duration.between(clock, start).toMinutes())}"
|
||||
else -> null
|
||||
},
|
||||
running = running,
|
||||
progress = if (running) Duration.between(start, clock).toSeconds().toFloat() / Duration.between(start, end).toSeconds().coerceAtLeast(1) else null,
|
||||
onClick = null,
|
||||
) }
|
||||
}
|
||||
@@ -323,6 +412,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
if (date != startDate()) IconButton(onClick = { date = startDate() }) {
|
||||
Icon(Icons.Rounded.Today, contentDescription = "Сегодня")
|
||||
}
|
||||
FavoriteButton(t.key in favorites) { vm.toggleSearchFavorite(t.key) }
|
||||
IconButton(onClick = { target = null; day = null }) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = "Закрыть")
|
||||
}
|
||||
@@ -411,6 +501,49 @@ private const val SPAN = 52
|
||||
@Composable
|
||||
private fun SectionTitle(text: String) = Heading(text)
|
||||
|
||||
/** The star of a teacher or a room: filled while it is among the favorites. */
|
||||
@Composable
|
||||
private fun FavoriteButton(starred: Boolean, onToggle: () -> Unit) {
|
||||
IconButton(onClick = onToggle) {
|
||||
Icon(
|
||||
if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
|
||||
contentDescription = if (starred) "Убрать из избранного" else "В избранное",
|
||||
tint = if (starred) MaterialTheme.colorScheme.primary else LocalContentColor.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Избранное" or "Недавние": a row per teacher or room, each with its star. */
|
||||
@Composable
|
||||
private fun SavedTargets(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
targets: List<Target>,
|
||||
favorites: List<String>,
|
||||
onOpen: (Target) -> Unit,
|
||||
onStar: (String) -> Unit,
|
||||
) {
|
||||
ListCard(icon, title) {
|
||||
targets.forEachIndexed { index, t ->
|
||||
if (index > 0) RowDivider()
|
||||
CardRow(
|
||||
avatar = when (t) {
|
||||
is Target.Teacher -> avatarLabel(t.name)
|
||||
is Target.Room -> t.room.take(4)
|
||||
},
|
||||
color = subjectColor(t.title),
|
||||
title = t.title,
|
||||
subtitle = when (t) {
|
||||
is Target.Teacher -> "Преподаватель · занятия сегодня"
|
||||
is Target.Room -> "Занятия сегодня"
|
||||
},
|
||||
onClick = { onOpen(t) },
|
||||
trailing = { FavoriteButton(t.key in favorites) { onStar(t.key) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun RoomChips(rooms: List<String>, onClick: (String) -> Unit) {
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.settings
|
||||
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
@@ -48,6 +48,21 @@ 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
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
|
||||
import org.xtimms.psu.schedule.ui.components.ContentColumn
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.appBarColors
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.home.ChangesSheet
|
||||
import org.xtimms.psu.schedule.ui.home.changeDate
|
||||
import org.xtimms.psu.schedule.ui.home.changesLabel
|
||||
import org.xtimms.psu.schedule.ui.theme.LocalThemeReveal
|
||||
import org.xtimms.psu.schedule.ui.theme.TapPoint
|
||||
import org.xtimms.psu.schedule.ui.theme.rememberTapPoint
|
||||
import org.xtimms.psu.schedule.ui.theme.tapPoint
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -56,6 +71,14 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||
val changes by vm.changes.collectAsStateWithLifecycle()
|
||||
var changesOpen by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
// A new theme spreads from the option or the switch that was tapped.
|
||||
val reveal = LocalThemeReveal.current
|
||||
val themeTap = rememberTapPoint()
|
||||
val colorsTap = rememberTapPoint()
|
||||
val scope = rememberCoroutineScope()
|
||||
fun reveal(tap: TapPoint, apply: () -> Unit) {
|
||||
scope.launch { reveal?.change(tap.at, apply) ?: apply() }
|
||||
}
|
||||
val s = settings ?: return
|
||||
val context = LocalContext.current
|
||||
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
@@ -100,15 +123,15 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
ConnectedChoice(
|
||||
options = listOf(ThemeMode.System to "Авто", ThemeMode.Light to "Светлая", ThemeMode.Dark to "Тёмная"),
|
||||
selected = s.theme,
|
||||
onSelect = vm::setTheme,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onSelect = { mode -> if (mode != s.theme) reveal(themeTap) { vm.setTheme(mode) } },
|
||||
modifier = Modifier.fillMaxWidth().tapPoint(themeTap),
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) SwitchRow(
|
||||
"Цвета обоев",
|
||||
"Цвета приложения подстраиваются под обои телефона",
|
||||
s.dynamicColor,
|
||||
vm::setDynamicColor,
|
||||
)
|
||||
Modifier.tapPoint(colorsTap),
|
||||
) { on -> reveal(colorsTap) { vm.setDynamicColor(on) } }
|
||||
}
|
||||
Section("Расписание") {
|
||||
val d = s.display
|
||||
@@ -196,7 +219,7 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
"Занятия в другом городе",
|
||||
"Вечером накануне — «Завтра занятия в Полоцке» с первой парой и аудиторией. Если включены напоминания, о первой паре такого дня напомнит раньше: нужно время на дорогу.",
|
||||
s.travelAlerts && allowed,
|
||||
notifying(vm::setTravelAlerts),
|
||||
change = notifying(vm::setTravelAlerts),
|
||||
)
|
||||
if (s.travelAlerts && allowed) {
|
||||
Text("Вечером в", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -222,7 +245,7 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
"Переносы аудиторий",
|
||||
"Утром в день переноса — какая пара и в какой аудитории, вместо обычной.",
|
||||
s.roomAlerts && allowed,
|
||||
notifying(vm::setRoomAlerts),
|
||||
change = notifying(vm::setRoomAlerts),
|
||||
)
|
||||
if (s.roomAlerts && allowed) {
|
||||
Text("Утром в", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -238,7 +261,7 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
"Текущая пара в шторке",
|
||||
"Пока идёт пара — уведомление с оставшимся временем и следующей парой. На Android 16 оно видно в строке состояния и на экране блокировки.",
|
||||
s.liveLesson && allowed,
|
||||
notifying(vm::setLiveLesson),
|
||||
change = notifying(vm::setLiveLesson),
|
||||
)
|
||||
}
|
||||
Section("О расписании") {
|
||||
@@ -290,7 +313,7 @@ private fun Section(title: String, content: @Composable () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(title: String, text: String, checked: Boolean, change: (Boolean) -> Unit) {
|
||||
private fun SwitchRow(title: String, text: String, checked: Boolean, modifier: Modifier = Modifier, change: (Boolean) -> Unit) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val onChange: (Boolean) -> Unit = { on ->
|
||||
haptic.performHapticFeedback(if (on) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff)
|
||||
@@ -300,7 +323,7 @@ private fun SwitchRow(title: String, text: String, checked: Boolean, change: (Bo
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// The press highlight follows the row's rounded shape instead of a hard rectangle.
|
||||
modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).toggleable(value = checked, role = Role.Switch, onValueChange = onChange),
|
||||
modifier = modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).toggleable(value = checked, role = Role.Switch, onValueChange = onChange),
|
||||
) {
|
||||
Column(Modifier.weight(1f).padding(end = 16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.subjects
|
||||
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.items
|
||||
@@ -72,6 +72,34 @@ import org.xtimms.psu.schedule.data.shortWeekday
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekdayName
|
||||
import java.time.LocalTime
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.MenuButton
|
||||
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
|
||||
import org.xtimms.psu.schedule.ui.components.Empty
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.LocalUndo
|
||||
import org.xtimms.psu.schedule.ui.components.SubjectsSkeleton
|
||||
import org.xtimms.psu.schedule.ui.components.TimeText
|
||||
import org.xtimms.psu.schedule.ui.components.UndoArea
|
||||
import org.xtimms.psu.schedule.ui.components.appBarColors
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.home.SectionCard
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalMarks
|
||||
import org.xtimms.psu.schedule.ui.lesson.Slot
|
||||
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.abbreviate
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberClock
|
||||
import org.xtimms.psu.schedule.ui.lesson.slotsOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectKey
|
||||
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
|
||||
import org.xtimms.psu.schedule.ui.notes.LocalNotes
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteEditor
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteRow
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteTarget
|
||||
import org.xtimms.psu.schedule.ui.lesson.statsFor
|
||||
|
||||
/** Everything the loaded weeks say about one subject. */
|
||||
private class SubjectSummary(
|
||||
@@ -406,7 +434,7 @@ private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
notes.forEach { note -> NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false) }
|
||||
notes.forEach { note -> NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false, onPostpone = { vm.postponeNote(note, it) }) }
|
||||
if (next != null) FilledTonalButton(onClick = { adding = true }) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
@@ -0,0 +1,141 @@
|
||||
package org.xtimms.psu.schedule.ui.theme
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.ClipOp
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.xtimms.psu.schedule.ui.motion.Emphasized
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
import kotlin.math.hypot
|
||||
|
||||
// Switching between light and dark: the new theme spreads in a circle from the finger, as if the
|
||||
// old screen were wiped away. A picture of the old screen covers the app while the theme changes
|
||||
// under it, and a growing hole in that picture shows the new one.
|
||||
|
||||
@Stable
|
||||
class ThemeReveal internal constructor(private val layer: GraphicsLayer, private val reduced: () -> Boolean) {
|
||||
internal var picture by mutableStateOf<ImageBitmap?>(null)
|
||||
internal var center = Offset.Zero
|
||||
internal val radius = Animatable(0f)
|
||||
internal var key: Any? by mutableStateOf(null)
|
||||
|
||||
/**
|
||||
* Applies a theme change with [apply], revealing it from [at] (a point in the window). Without
|
||||
* a point, or with animations turned off in the system, the theme just changes.
|
||||
*/
|
||||
suspend fun change(at: Offset?, apply: () -> Unit) {
|
||||
if (at == null || reduced() || picture != null) return apply()
|
||||
picture = layer.toImageBitmap()
|
||||
center = at
|
||||
radius.snapTo(0f)
|
||||
val before = key
|
||||
apply()
|
||||
// The settings are saved first; the new theme comes a moment later. "Авто" may change nothing.
|
||||
val changed = withTimeoutOrNull(600) { snapshotFlow { key }.first { it != before } } != null
|
||||
if (changed) {
|
||||
// Let the new theme draw once under the picture before the hole opens.
|
||||
withFrameNanos {}
|
||||
withFrameNanos {}
|
||||
val p = picture ?: return
|
||||
val far = listOf(Offset.Zero, Offset(p.width.toFloat(), 0f), Offset(0f, p.height.toFloat()), Offset(p.width.toFloat(), p.height.toFloat()))
|
||||
.maxOf { hypot(it.x - at.x, it.y - at.y) }
|
||||
radius.animateTo(far, tween(550, easing = Emphasized))
|
||||
}
|
||||
picture = null
|
||||
}
|
||||
}
|
||||
|
||||
val LocalThemeReveal = staticCompositionLocalOf<ThemeReveal?> { null }
|
||||
|
||||
/**
|
||||
* The app under a theme reveal. [key] is the theme as drawn (dark or light, wallpaper colours):
|
||||
* when it changes, the reveal knows the new theme is in place.
|
||||
*/
|
||||
@Composable
|
||||
fun ThemeRevealHost(key: Any, content: @Composable () -> Unit) {
|
||||
val layer = rememberGraphicsLayer()
|
||||
val reduced = rememberReducedMotion()
|
||||
val reveal = remember(layer, reduced) { ThemeReveal(layer) { reduced } }
|
||||
SideEffect { reveal.key = key }
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.drawWithContent {
|
||||
layer.record { this@drawWithContent.drawContent() }
|
||||
drawLayer(layer)
|
||||
},
|
||||
) {
|
||||
CompositionLocalProvider(LocalThemeReveal provides reveal) { content() }
|
||||
}
|
||||
reveal.picture?.let { picture ->
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// The old screen is only a picture: taps wait for the new one.
|
||||
.pointerInput(Unit) { awaitEachGesture { awaitFirstDown(pass = PointerEventPass.Initial).consume() } },
|
||||
) {
|
||||
val hole = Path().apply {
|
||||
addOval(androidx.compose.ui.geometry.Rect(reveal.center, reveal.radius.value))
|
||||
}
|
||||
clipPath(hole, ClipOp.Difference) { drawImage(picture) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the finger last went down on an element, in the window: the centre of a theme reveal. */
|
||||
class TapPoint {
|
||||
var at: Offset? = null
|
||||
private set
|
||||
private var coordinates: LayoutCoordinates? = null
|
||||
|
||||
internal fun place(c: LayoutCoordinates) {
|
||||
coordinates = c
|
||||
}
|
||||
|
||||
internal fun down(local: Offset) {
|
||||
at = coordinates?.takeIf { it.isAttached }?.localToRoot(local)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberTapPoint(): TapPoint = remember { TapPoint() }
|
||||
|
||||
fun Modifier.tapPoint(point: TapPoint): Modifier = this
|
||||
.onGloballyPositioned(point::place)
|
||||
.pointerInput(point) {
|
||||
awaitEachGesture {
|
||||
point.down(awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial).position)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ 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 org.xtimms.psu.schedule.ui.lesson.abbreviate
|
||||
import java.time.LocalDate
|
||||
|
||||
/** A day of the week as the widget shows it. */
|
||||
|
||||
Reference in New Issue
Block a user