feat: add Next Lesson widget and enhance existing widgets
- Implemented a new Next Lesson widget to display the upcoming lesson details on the home screen. - Updated Today widget to dynamically adjust the number of lessons displayed based on available space. - Enhanced the Settings screen to allow users to manage hidden lessons more effectively. - Introduced harmonized color palettes for better visual consistency across themes. - Updated the timetable API to support a new range parameter for semester-based queries. - Added tests to ensure the new semester range functionality works as expected. - Refactored widget styling for improved appearance on devices with different Android versions.
This commit is contained in:
34
android/.gitignore
vendored
34
android/.gitignore
vendored
@@ -1,7 +1,35 @@
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
.kotlin/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Log/OS Files
|
||||
*.log
|
||||
|
||||
# Android Studio generated files and folders
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.aab
|
||||
*.apk
|
||||
output-metadata.json
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/
|
||||
misc.xml
|
||||
deploymentTargetDropDown.xml
|
||||
render.experimental.xml
|
||||
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
@@ -65,6 +65,7 @@ dependencies {
|
||||
implementation(libs.glance.appwidget)
|
||||
implementation(libs.glance.material3)
|
||||
implementation(libs.work.runtime)
|
||||
implementation(libs.material)
|
||||
coreLibraryDesugaring(libs.desugar)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "ritm.schedule",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 1,
|
||||
"versionName": "0.1.0",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"baselineProfiles": [
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/app-release.dm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/app-release.dm"
|
||||
]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 26
|
||||
}
|
||||
@@ -39,6 +39,29 @@
|
||||
android:resource="@xml/today_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.NextLessonWidgetReceiver"
|
||||
android:exported="true"
|
||||
android:label="@string/next_widget_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/next_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".tile.LessonTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_tile_lesson"
|
||||
android:label="@string/tile_name"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".notify.ReminderReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
package ritm.schedule
|
||||
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.outlined.CalendarViewWeek
|
||||
import androidx.compose.material.icons.outlined.Checklist
|
||||
import androidx.compose.material.icons.rounded.CalendarViewWeek
|
||||
import androidx.compose.material.icons.rounded.Checklist
|
||||
import ritm.schedule.ui.CalendarScreen
|
||||
import ritm.schedule.ui.PlansScreen
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.WideNavigationRailState
|
||||
import androidx.compose.material3.WideNavigationRailValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.automirrored.rounded.MenuOpen
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import ritm.schedule.ui.materialFadeThrough
|
||||
import ritm.schedule.ui.Emphasized
|
||||
import ritm.schedule.ui.MotionDuration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.animation.core.tween
|
||||
import ritm.schedule.ui.materialSharedAxisX
|
||||
import ritm.schedule.ui.rememberSlideDistance
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
@@ -74,6 +110,10 @@ import ritm.schedule.ui.AppNav
|
||||
import ritm.schedule.ui.GroupsScreen
|
||||
import ritm.schedule.ui.HomeScreen
|
||||
import ritm.schedule.ui.LocalDisplay
|
||||
import ritm.schedule.ui.LocalMarks
|
||||
import ritm.schedule.ui.LocalMenuProgress
|
||||
import ritm.schedule.ui.theme.card
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import ritm.schedule.ui.LocalNotes
|
||||
import ritm.schedule.ui.LocalSubjectSlots
|
||||
import ritm.schedule.data.Display
|
||||
@@ -90,7 +130,9 @@ import java.time.LocalDate
|
||||
|
||||
private enum class Tab(val label: String, val icon: ImageVector, val selectedIcon: ImageVector) {
|
||||
Home("Обзор", Icons.Outlined.Home, Icons.Rounded.Home),
|
||||
Schedule("Расписание", Icons.Outlined.CalendarMonth, Icons.Rounded.CalendarMonth),
|
||||
Plans("Планы", Icons.Outlined.Checklist, Icons.Rounded.Checklist),
|
||||
Calendar("Календарь", Icons.Outlined.CalendarMonth, Icons.Rounded.CalendarMonth),
|
||||
Schedule("Расписание", Icons.Outlined.CalendarViewWeek, Icons.Rounded.CalendarViewWeek),
|
||||
Subjects("Предметы", Icons.Outlined.School, Icons.Rounded.School),
|
||||
Search("Поиск", Icons.Outlined.TravelExplore, Icons.Rounded.TravelExplore),
|
||||
}
|
||||
@@ -140,20 +182,25 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
|
||||
var page by rememberSaveable { mutableStateOf(if (firstRun) Page.Groups else null) }
|
||||
var weekView by rememberSaveable { mutableStateOf(false) }
|
||||
var focus by remember { mutableStateOf<LocalDate?>(null) }
|
||||
var menuOpen by rememberSaveable { mutableStateOf(false) }
|
||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||
val hasGroup = settings?.current != null
|
||||
val menu = rememberWideNavigationRailState()
|
||||
val rail = rememberWideNavigationRailState()
|
||||
val scope = rememberCoroutineScope()
|
||||
// Back closes a page first, then returns to the overview instead of closing the app.
|
||||
BackHandler(enabled = page != null && hasGroup) { page = null }
|
||||
BackHandler(enabled = page == null && tab != Tab.Home) { tab = Tab.Home }
|
||||
// Declared last, so an open menu closes before anything else.
|
||||
BackHandler(enabled = menuOpen) { menuOpen = false }
|
||||
|
||||
val slots = remember(timetable) { subjectSlots(timetable) }
|
||||
val motion = MaterialTheme.motionScheme
|
||||
val slide = rememberSlideDistance()
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// Behind the pushed-aside screen: the menu's own card-white background.
|
||||
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.card)) {
|
||||
val wide = maxWidth >= MEDIUM_WIDTH
|
||||
BackHandler(enabled = wide && (rail.targetValue == WideNavigationRailValue.Expanded)) { scope.launch { rail.collapse() } }
|
||||
val nav = remember(wide) {
|
||||
AppNav(
|
||||
openGroups = { page = Page.Groups },
|
||||
@@ -162,160 +209,237 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
|
||||
openWeek = { focus = today(); weekView = true; tab = Tab.Schedule },
|
||||
openSubjects = { tab = Tab.Subjects },
|
||||
openSearch = { tab = Tab.Search },
|
||||
openMenu = if (wide) null else ({ scope.launch { menu.expand() } }),
|
||||
openPlans = { tab = Tab.Plans; page = null },
|
||||
openCalendar = { tab = Tab.Calendar; page = null },
|
||||
openMenu = if (wide) null else ({ menuOpen = true }),
|
||||
)
|
||||
}
|
||||
val notes by vm.notes.collectAsStateWithLifecycle()
|
||||
CompositionLocalProvider(
|
||||
LocalSubjectSlots provides slots,
|
||||
LocalDisplay provides (settings?.display ?: Display()),
|
||||
LocalNotes provides notes,
|
||||
val menuItems = MenuItems(
|
||||
tab = tab,
|
||||
page = page,
|
||||
group = settings?.current?.group,
|
||||
onTab = { tab = it; page = null },
|
||||
onPage = { page = it },
|
||||
)
|
||||
|
||||
// Phones: the menu sits under the screen, and the screen slides right to show it,
|
||||
// as in the M3 expanded navigation rail that appears from the menu icon.
|
||||
// The screen begins and ends on screen, so both ways use the M3 emphasized easing
|
||||
// (md.sys.motion.easing.emphasized): long2 500 ms to open, medium4 400 ms to close.
|
||||
val opening = menuOpen && !wide
|
||||
val openState = animateFloatAsState(
|
||||
if (opening) 1f else 0f,
|
||||
tween(if (opening) MotionDuration.Long2 else MotionDuration.Medium4, easing = Emphasized),
|
||||
label = "menu",
|
||||
)
|
||||
// Read only in layout and draw lambdas, so a running animation never recomposes the app.
|
||||
val open by openState
|
||||
var menuWidth by remember { mutableIntStateOf(0) }
|
||||
// Composed ahead of time: the first frame of opening has nothing to build or measure.
|
||||
if (!wide) Box(
|
||||
Modifier
|
||||
.onSizeChanged { menuWidth = it.width }
|
||||
.graphicsLayer { alpha = open.coerceIn(0f, 1f) }
|
||||
.then(if (menuOpen) Modifier else Modifier.clearAndSetSemantics {}),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = page,
|
||||
transitionSpec = {
|
||||
(slideInHorizontally(motion.defaultSpatialSpec()) { it / 5 } + fadeIn(motion.defaultEffectsSpec())) togetherWith
|
||||
fadeOut(motion.fastEffectsSpec())
|
||||
},
|
||||
label = "page",
|
||||
) { current ->
|
||||
// Pages pad for side insets; their lists end above the navigation bar themselves.
|
||||
val pageInsets = Modifier.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal))
|
||||
when (current) {
|
||||
Page.Groups -> Box(pageInsets) {
|
||||
GroupsScreen(
|
||||
vm,
|
||||
onOpened = { page = null; tab = Tab.Home },
|
||||
onBack = if (hasGroup) ({ page = null }) else null,
|
||||
)
|
||||
MenuRail(
|
||||
state = rememberWideNavigationRailState(WideNavigationRailValue.Expanded),
|
||||
expanded = true,
|
||||
items = menuItems,
|
||||
color = MaterialTheme.colorScheme.card,
|
||||
modifier = Modifier.widthIn(min = 256.dp),
|
||||
onToggle = { menuOpen = false },
|
||||
onPicked = { menuOpen = false },
|
||||
)
|
||||
}
|
||||
|
||||
val notes by vm.notes.collectAsStateWithLifecycle()
|
||||
val marks by vm.marks.collectAsStateWithLifecycle()
|
||||
val corner = with(LocalDensity.current) { 28.dp.toPx() }
|
||||
// The screen keeps one place in the tree whether the rail is shown or not,
|
||||
// so rotating the phone keeps its saved state (selected day, week view).
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
if (wide) MenuRail(
|
||||
state = rail,
|
||||
expanded = (rail.targetValue == WideNavigationRailValue.Expanded),
|
||||
items = menuItems,
|
||||
color = MaterialTheme.colorScheme.page,
|
||||
onToggle = { scope.launch { rail.toggle() } },
|
||||
onPicked = { scope.launch { rail.collapse() } },
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.graphicsLayer {
|
||||
translationX = menuWidth * open
|
||||
if (open > 0f) {
|
||||
shape = RoundedCornerShape(corner * open)
|
||||
clip = true
|
||||
}
|
||||
}
|
||||
Page.Settings -> Box(pageInsets) { SettingsScreen(vm, onBack = { page = null }) }
|
||||
null -> Tabs(tab, wide, onTab = { tab = it }, onMenu = { scope.launch { menu.expand() } }) { shown ->
|
||||
when (shown) {
|
||||
Tab.Home -> HomeScreen(vm, nav)
|
||||
Tab.Schedule -> ScheduleScreen(
|
||||
vm, nav,
|
||||
weekView = weekView,
|
||||
onWeekView = { weekView = it },
|
||||
focus = focus,
|
||||
onFocusHandled = { focus = null },
|
||||
)
|
||||
Tab.Subjects -> SubjectsScreen(vm, nav)
|
||||
Tab.Search -> SearchScreen(vm, nav)
|
||||
.background(MaterialTheme.colorScheme.page)
|
||||
.then(
|
||||
// The rail already sits over the start inset (cutout in landscape),
|
||||
// so the screens' top app bars must not pad for it again.
|
||||
if (wide) Modifier
|
||||
.consumeWindowInsets(WindowInsets.safeDrawing.only(WindowInsetsSides.Start))
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.End))
|
||||
else Modifier,
|
||||
),
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalMenuProgress provides openState,
|
||||
LocalSubjectSlots provides slots,
|
||||
LocalDisplay provides (settings?.display ?: Display()),
|
||||
LocalNotes provides notes,
|
||||
LocalMarks provides marks,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = page,
|
||||
// Shared axis X: forward into a page, back out of it, as in RideBus.
|
||||
transitionSpec = { materialSharedAxisX(forward = targetState != null, distance = slide) },
|
||||
label = "page",
|
||||
) { current ->
|
||||
// Pages pad for side insets; their lists end above the navigation bar themselves.
|
||||
val pageInsets = Modifier.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal))
|
||||
when (current) {
|
||||
Page.Groups -> Box(pageInsets) {
|
||||
GroupsScreen(
|
||||
vm,
|
||||
onOpened = { page = null; tab = Tab.Home },
|
||||
onBack = if (hasGroup) ({ page = null }) else null,
|
||||
)
|
||||
}
|
||||
Page.Settings -> Box(pageInsets) { SettingsScreen(vm, onBack = { page = null }) }
|
||||
null -> AnimatedContent(
|
||||
targetState = tab,
|
||||
transitionSpec = { materialFadeThrough() },
|
||||
label = "tab",
|
||||
) { shown ->
|
||||
when (shown) {
|
||||
Tab.Home -> HomeScreen(vm, nav)
|
||||
Tab.Plans -> PlansScreen(vm, nav)
|
||||
Tab.Calendar -> CalendarScreen(vm, nav)
|
||||
Tab.Schedule -> ScheduleScreen(
|
||||
vm, nav,
|
||||
weekView = weekView,
|
||||
onWeekView = { weekView = it },
|
||||
focus = focus,
|
||||
onFocusHandled = { focus = null },
|
||||
)
|
||||
Tab.Subjects -> SubjectsScreen(vm, nav)
|
||||
Tab.Search -> SearchScreen(vm, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The side menu slides over the screen, as in School Planner: from the app bar's
|
||||
// hamburger on phones, from the rail's menu button on wider windows. It opens in its
|
||||
// own window that styles the system bars by the night mode it reads, so it gets the app's.
|
||||
CompositionLocalProvider(LocalConfiguration provides themedConfiguration(isDark(settings?.theme ?: ThemeMode.System))) {
|
||||
ModalWideNavigationRail(
|
||||
state = menu,
|
||||
hideOnCollapse = true,
|
||||
colors = WideNavigationRailDefaults.colors(modalContainerColor = MaterialTheme.colorScheme.page),
|
||||
header = {
|
||||
Text(
|
||||
stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, top = 8.dp, bottom = 16.dp).semantics { heading() },
|
||||
)
|
||||
},
|
||||
) {
|
||||
fun close(action: () -> Unit) {
|
||||
action()
|
||||
scope.launch { menu.collapse() }
|
||||
}
|
||||
Tab.entries.forEach { item ->
|
||||
val selected = page == null && tab == item
|
||||
WideNavigationRailItem(
|
||||
selected = selected,
|
||||
onClick = { close { page = null; tab = item } },
|
||||
icon = { Icon(if (selected) item.selectedIcon else item.icon, contentDescription = null) },
|
||||
label = { Text(item.label) },
|
||||
railExpanded = true,
|
||||
// While the menu is open the pushed-aside screen only closes it.
|
||||
if (menuOpen && !wide) Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onClickLabel = "Закрыть меню",
|
||||
onClick = { menuOpen = false },
|
||||
),
|
||||
)
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 8.dp))
|
||||
WideNavigationRailItem(
|
||||
selected = page == Page.Groups,
|
||||
onClick = { close { page = Page.Groups } },
|
||||
icon = { Icon(Icons.Rounded.SwapHoriz, contentDescription = null) },
|
||||
label = { Text("Сменить группу") },
|
||||
railExpanded = true,
|
||||
)
|
||||
WideNavigationRailItem(
|
||||
selected = page == Page.Settings,
|
||||
onClick = { close { page = Page.Settings } },
|
||||
icon = { Icon(Icons.Outlined.Settings, contentDescription = null) },
|
||||
label = { Text("Настройки") },
|
||||
railExpanded = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The current configuration with its night mode set to the app's theme. */
|
||||
@Composable
|
||||
private fun themedConfiguration(dark: Boolean): Configuration {
|
||||
val base = LocalConfiguration.current
|
||||
return remember(base, dark) {
|
||||
Configuration(base).apply {
|
||||
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or
|
||||
if (dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
|
||||
}
|
||||
}
|
||||
}
|
||||
/** What the menu shows and where its items lead. */
|
||||
private class MenuItems(
|
||||
val tab: Tab,
|
||||
val page: Page?,
|
||||
val group: String?,
|
||||
val onTab: (Tab) -> Unit,
|
||||
val onPage: (Page) -> Unit,
|
||||
)
|
||||
|
||||
/** On medium and wider windows a permanent rail on the tinted page; on phones the screen alone. */
|
||||
/**
|
||||
* The navigation rail: collapsed it holds the four sections; expanded it adds the group
|
||||
* button, the shared gaps and the settings, as the M3 expanded navigation rail does.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun Tabs(tab: Tab, wide: Boolean, onTab: (Tab) -> Unit, onMenu: () -> Unit, content: @Composable (Tab) -> Unit) {
|
||||
val motion = MaterialTheme.motionScheme
|
||||
// The screen keeps one place in the tree whether the rail is shown or not,
|
||||
// so rotating the phone keeps its saved state (selected day, week view).
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
if (wide) WideNavigationRail(
|
||||
colors = WideNavigationRailDefaults.colors(containerColor = MaterialTheme.colorScheme.page),
|
||||
header = {
|
||||
IconButton(onClick = onMenu, modifier = Modifier.padding(start = 24.dp)) {
|
||||
Icon(Icons.Rounded.Menu, contentDescription = "Открыть меню")
|
||||
}
|
||||
},
|
||||
) {
|
||||
Tab.entries.forEach { item ->
|
||||
WideNavigationRailItem(
|
||||
selected = tab == item,
|
||||
onClick = { onTab(item) },
|
||||
icon = { Icon(if (tab == item) item.selectedIcon else item.icon, contentDescription = null) },
|
||||
label = { Text(item.label) },
|
||||
railExpanded = false,
|
||||
private fun MenuRail(
|
||||
state: WideNavigationRailState,
|
||||
expanded: Boolean,
|
||||
items: MenuItems,
|
||||
color: androidx.compose.ui.graphics.Color,
|
||||
onToggle: () -> Unit,
|
||||
onPicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
WideNavigationRail(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
colors = WideNavigationRailDefaults.colors(containerColor = color),
|
||||
// On a landscape phone the items need every bit of height; they scroll when it runs out.
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
header = {
|
||||
IconButton(
|
||||
onClick = onToggle,
|
||||
modifier = Modifier.padding(start = if (expanded) 12.dp else 24.dp, bottom = if (expanded) 12.dp else 0.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (expanded) Icons.AutoMirrored.Rounded.MenuOpen else Icons.Rounded.Menu,
|
||||
contentDescription = if (expanded) "Закрыть меню" else "Открыть меню",
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.then(
|
||||
// The rail already sits over the start inset (cutout in landscape),
|
||||
// so the screens' top app bars must not pad for it again.
|
||||
if (wide) Modifier
|
||||
.consumeWindowInsets(WindowInsets.safeDrawing.only(WindowInsetsSides.Start))
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.End))
|
||||
else Modifier,
|
||||
),
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = if (expanded) Alignment.Start else Alignment.CenterHorizontally,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = tab,
|
||||
transitionSpec = { fadeIn(motion.defaultEffectsSpec()) togetherWith fadeOut(motion.fastEffectsSpec()) },
|
||||
label = "tab",
|
||||
) { content(it) }
|
||||
@Composable
|
||||
fun TabItem(item: Tab) {
|
||||
val selected = items.page == null && items.tab == item
|
||||
WideNavigationRailItem(
|
||||
selected = selected,
|
||||
onClick = { items.onTab(item); onPicked() },
|
||||
icon = { Icon(if (selected) item.selectedIcon else item.icon, contentDescription = null) },
|
||||
label = { Text(item.label) },
|
||||
railExpanded = expanded,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PageItem(page: Page, icon: ImageVector, label: String) = WideNavigationRailItem(
|
||||
selected = items.page == page,
|
||||
onClick = { items.onPage(page); onPicked() },
|
||||
icon = { Icon(icon, contentDescription = null) },
|
||||
label = { Text(label) },
|
||||
railExpanded = true,
|
||||
)
|
||||
|
||||
// Groups as in School Planner: the day and its plans, then study tools, then the app.
|
||||
MENU_DAY.forEach { TabItem(it) }
|
||||
if (expanded) MenuDivider()
|
||||
MENU_STUDY.forEach { TabItem(it) }
|
||||
if (expanded) {
|
||||
MenuDivider()
|
||||
PageItem(Page.Settings, Icons.Outlined.Settings, "Настройки")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val MENU_DAY = listOf(Tab.Home, Tab.Plans, Tab.Calendar, Tab.Schedule)
|
||||
private val MENU_STUDY = listOf(Tab.Subjects, Tab.Search)
|
||||
|
||||
/** A divider of fixed width: a full-width line would stretch the rail to its widest. */
|
||||
@Composable
|
||||
private fun MenuDivider() {
|
||||
HorizontalDivider(
|
||||
Modifier.padding(start = 28.dp, top = 8.dp, bottom = 8.dp).width(200.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package ritm.schedule
|
||||
|
||||
import android.app.Application
|
||||
import ritm.schedule.data.Api
|
||||
import ritm.schedule.data.AttendanceStore
|
||||
import ritm.schedule.data.NotesStore
|
||||
import ritm.schedule.data.Repository
|
||||
import ritm.schedule.data.Settings
|
||||
@@ -15,12 +16,15 @@ class ScheduleApp : Application() {
|
||||
private set
|
||||
lateinit var notes: NotesStore
|
||||
private set
|
||||
lateinit var attendance: AttendanceStore
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
settings = Settings(this)
|
||||
repository = Repository(this, Api(BuildConfig.API_URL), settings)
|
||||
notes = NotesStore(this)
|
||||
attendance = AttendanceStore(this)
|
||||
Reminders.createChannel(this)
|
||||
SyncWorker.schedule(this)
|
||||
}
|
||||
|
||||
@@ -55,8 +55,12 @@ class Api(baseUrl: String) {
|
||||
json.decodeFromString<T>(body)
|
||||
}
|
||||
|
||||
/** The whole current semester; a server without range=semester answers the from/days window. */
|
||||
suspend fun timetable(docId: String, group: String, from: String, days: Int) =
|
||||
get<Timetable>("api/timetable", "doc" to docId, "group" to group, "from" to from, "days" to days.toString())
|
||||
get<Timetable>(
|
||||
"api/timetable",
|
||||
"doc" to docId, "group" to group, "from" to from, "days" to days.toString(), "range" to "semester",
|
||||
)
|
||||
|
||||
suspend fun groups(query: String = "", faculty: String = "", course: String = "", form: String = "") =
|
||||
get<GroupsResponse>("api/groups", "q" to query, "faculty" to faculty, "course" to course, "form" to form)
|
||||
@@ -69,10 +73,11 @@ class Api(baseUrl: String) {
|
||||
suspend fun roomDay(room: String, date: String) =
|
||||
get<DirectoryDay>("api/directory/day", "room" to room, "date" to date)
|
||||
|
||||
suspend fun freeRooms(date: String, pair: Int) =
|
||||
get<FreeRooms>("api/directory/free", "date" to date, "pair" to pair.toString())
|
||||
|
||||
suspend fun calendar() = get<CalendarInfo>("api/calendar")
|
||||
|
||||
suspend fun exams(group: String, faculty: String) =
|
||||
get<ExamsResponse>("api/exams", "group" to group, "faculty" to faculty)
|
||||
}
|
||||
|
||||
/** runCatching that lets coroutine cancellation through: a cancelled request is not an error. */
|
||||
|
||||
44
android/app/src/main/kotlin/ritm/schedule/data/Attendance.kt
Normal file
44
android/app/src/main/kotlin/ritm/schedule/data/Attendance.kt
Normal file
@@ -0,0 +1,44 @@
|
||||
package ritm.schedule.data
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
|
||||
/** Whether the student was at one lesson: the group, subject, date and start time. */
|
||||
@Serializable
|
||||
data class Mark(
|
||||
val group: String,
|
||||
val subject: String,
|
||||
val date: String,
|
||||
val start: String,
|
||||
val present: Boolean,
|
||||
) {
|
||||
val id get() = listOf(group, date, start, subject.trim().lowercase()).joinToString("|")
|
||||
}
|
||||
|
||||
/** Attendance marks live only on the device, in one small JSON file. */
|
||||
class AttendanceStore(context: Context) {
|
||||
private val file = File(context.filesDir, "attendance.json")
|
||||
private val lock = Mutex()
|
||||
private val _marks = MutableStateFlow(read())
|
||||
val marks: StateFlow<List<Mark>> = _marks
|
||||
|
||||
private fun read(): List<Mark> =
|
||||
runCatching { json.decodeFromString<List<Mark>>(file.readText()) }.getOrDefault(emptyList())
|
||||
|
||||
/** Sets a mark, or clears it when [present] is null. */
|
||||
suspend fun set(mark: Mark, present: Boolean?) = withContext(Dispatchers.IO) {
|
||||
lock.withLock {
|
||||
val rest = _marks.value.filterNot { it.id == mark.id }
|
||||
val next = if (present == null) rest else rest + mark.copy(present = present)
|
||||
file.writeText(json.encodeToString(next))
|
||||
_marks.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,9 @@ data class Lesson(
|
||||
val raw: String = "",
|
||||
)
|
||||
|
||||
/** A recurring lesson the student may hide: the subject with its kind, in every week. */
|
||||
val Lesson.hideKey: String get() = subject.trim().lowercase() + "|" + type.trim().lowercase()
|
||||
|
||||
@Serializable
|
||||
data class GroupsResponse(
|
||||
val results: List<GroupResult> = emptyList(),
|
||||
@@ -131,24 +134,28 @@ data class Coverage(
|
||||
val year: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FreeRooms(
|
||||
val date: String,
|
||||
val pair: Int,
|
||||
val bell: Bell,
|
||||
val free: List<RoomGroup> = emptyList(),
|
||||
val freeCount: Int = 0,
|
||||
val busyCount: Int = 0,
|
||||
val knownRooms: Int = 0,
|
||||
val coverage: Coverage,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RoomGroup(val letter: String, val rooms: List<String>)
|
||||
|
||||
@Serializable
|
||||
data class CalendarInfo(val bells: List<Bell> = emptyList())
|
||||
|
||||
/** An exam, credit or consultation of the group, from any document of the catalog (/api/exams). */
|
||||
@Serializable
|
||||
data class Exam(
|
||||
val date: String,
|
||||
val start: String,
|
||||
val end: String? = null,
|
||||
val type: String,
|
||||
val subject: String,
|
||||
val teachers: List<String> = emptyList(),
|
||||
val rooms: List<String> = emptyList(),
|
||||
) {
|
||||
/** Consultations go into the list but not into the countdown. */
|
||||
val counts get() = type != "Консультация"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ExamsResponse(val group: String = "", val exams: List<Exam> = emptyList())
|
||||
|
||||
/** A group the student opened; kept on the device for quick switching. */
|
||||
@Serializable
|
||||
data class SavedGroup(val docId: String, val group: String, val faculty: String = "", val course: String = "")
|
||||
|
||||
@@ -17,7 +17,18 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
|
||||
private val _timetable = MutableStateFlow(readCache())
|
||||
val timetable: StateFlow<Timetable?> = _timetable
|
||||
|
||||
/** Five weeks from this Monday: enough to browse ahead and to plan reminders. */
|
||||
// The group's session, kept like the timetable so the countdown works offline.
|
||||
private val examsCache = File(context.filesDir, "exams.json")
|
||||
private val _exams = MutableStateFlow(readExams())
|
||||
val exams: StateFlow<ExamsResponse?> = _exams
|
||||
|
||||
private fun readExams(): ExamsResponse? =
|
||||
runCatching { json.decodeFromString<ExamsResponse>(examsCache.readText()) }.getOrNull()
|
||||
|
||||
/**
|
||||
* The fallback window, five weeks from this Monday, for a server that cannot send the
|
||||
* whole semester (or a schedule without dates); otherwise the semester comes whole.
|
||||
*/
|
||||
val span = 35
|
||||
|
||||
private fun readCache(): Timetable? =
|
||||
@@ -31,6 +42,11 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
|
||||
val fresh = api.timetable(group.docId, group.group, from.toString(), span)
|
||||
cache.writeText(json.encodeToString(Timetable.serializer(), fresh))
|
||||
_timetable.value = fresh
|
||||
// Exams come from the whole catalog index; their failure never fails the timetable.
|
||||
attempt { api.exams(group.group, fresh.document.faculty.ifEmpty { group.faculty }) }.onSuccess {
|
||||
examsCache.writeText(json.encodeToString(ExamsResponse.serializer(), it))
|
||||
_exams.value = it
|
||||
}
|
||||
fresh
|
||||
}
|
||||
}
|
||||
@@ -45,10 +61,11 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
|
||||
return refresh()
|
||||
}
|
||||
|
||||
/** Lessons of a date after the student's subgroup filter. */
|
||||
fun lessonsOn(timetable: Timetable, date: LocalDate, subgroup: String?): List<Lesson> =
|
||||
/** Lessons of a date after the student's subgroup filter and the lessons they hid. */
|
||||
fun lessonsOn(timetable: Timetable, date: LocalDate, subgroup: String?, hidden: Set<String> = emptySet()): List<Lesson> =
|
||||
timetable.days.firstOrNull { it.date == date.toString() }?.lessons.orEmpty()
|
||||
.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
|
||||
.filter { it.hideKey !in hidden }
|
||||
|
||||
fun dayOf(timetable: Timetable, date: LocalDate): Day? =
|
||||
timetable.days.firstOrNull { it.date == date.toString() }
|
||||
|
||||
@@ -13,6 +13,17 @@ import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class ThemeMode { System, Light, Dark }
|
||||
|
||||
enum class PlansLayout { Agenda, List, Board }
|
||||
|
||||
/** How the Plans screen lays out tasks, as in School Planner's plan settings. */
|
||||
data class PlansPrefs(
|
||||
val layout: PlansLayout = PlansLayout.Agenda,
|
||||
/** The day's lessons above its plans; off by default, so plans come first. */
|
||||
val lessons: Boolean = false,
|
||||
/** Ticked-off tasks stay in the list. */
|
||||
val done: Boolean = true,
|
||||
)
|
||||
|
||||
/** What lesson cards, the week grid and the overview show; everything is on by default. */
|
||||
data class Display(
|
||||
val colors: Boolean = true,
|
||||
@@ -32,6 +43,9 @@ data class UserSettings(
|
||||
val reminderMinutes: Int = 10,
|
||||
val recent: List<SavedGroup> = emptyList(),
|
||||
val display: Display = Display(),
|
||||
/** Lessons hidden in the open group, as [Lesson.hideKey]s. */
|
||||
val hidden: Set<String> = emptySet(),
|
||||
val plans: PlansPrefs = PlansPrefs(),
|
||||
)
|
||||
|
||||
private val Context.store by preferencesDataStore("settings")
|
||||
@@ -52,8 +66,15 @@ class Settings(private val context: Context) {
|
||||
val pairs = booleanPreferencesKey("show_pairs")
|
||||
val tags = booleanPreferencesKey("show_tags")
|
||||
val notes = booleanPreferencesKey("show_notes")
|
||||
val hidden = stringPreferencesKey("hidden")
|
||||
val plansLayout = stringPreferencesKey("plans_layout")
|
||||
val plansLessons = booleanPreferencesKey("plans_lessons")
|
||||
val plansDone = booleanPreferencesKey("plans_done")
|
||||
}
|
||||
|
||||
private fun Preferences.hiddenMap(): Map<String, List<String>> =
|
||||
this[Keys.hidden]?.let { runCatching { json.decodeFromString<Map<String, List<String>>>(it) }.getOrNull() }.orEmpty()
|
||||
|
||||
val flow: Flow<UserSettings> = context.store.data.map { it.toSettings() }
|
||||
|
||||
suspend fun now(): UserSettings = flow.first()
|
||||
@@ -73,6 +94,12 @@ class Settings(private val context: Context) {
|
||||
recent = this[Keys.recent]?.let {
|
||||
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
|
||||
}.orEmpty(),
|
||||
hidden = current?.let { hiddenMap()[subgroupKey(it)] }.orEmpty().toSet(),
|
||||
plans = PlansPrefs(
|
||||
layout = this[Keys.plansLayout]?.let { runCatching { PlansLayout.valueOf(it) }.getOrNull() } ?: PlansLayout.Agenda,
|
||||
lessons = this[Keys.plansLessons] ?: false,
|
||||
done = this[Keys.plansDone] ?: true,
|
||||
),
|
||||
display = Display(
|
||||
colors = this[Keys.colors] ?: true,
|
||||
teachers = this[Keys.teachers] ?: true,
|
||||
@@ -110,6 +137,23 @@ class Settings(private val context: Context) {
|
||||
suspend fun setReminders(on: Boolean) = context.store.edit { it[Keys.reminders] = on }
|
||||
suspend fun setReminderMinutes(minutes: Int) = context.store.edit { it[Keys.minutes] = minutes }
|
||||
|
||||
/** Hides or shows again a recurring lesson of the open group. */
|
||||
suspend fun setHidden(key: String, hidden: Boolean) = context.store.edit { prefs ->
|
||||
val current = prefs[Keys.current]?.let { runCatching { json.decodeFromString<SavedGroup>(it) }.getOrNull() }
|
||||
?: return@edit
|
||||
val map = prefs.hiddenMap().toMutableMap()
|
||||
val list = map[subgroupKey(current)].orEmpty()
|
||||
map[subgroupKey(current)] = if (hidden) (list + key).distinct() else list - key
|
||||
prefs[Keys.hidden] = json.encodeToString(map.toMap())
|
||||
}
|
||||
|
||||
|
||||
suspend fun setPlans(p: PlansPrefs) = context.store.edit {
|
||||
it[Keys.plansLayout] = p.layout.name
|
||||
it[Keys.plansLessons] = p.lessons
|
||||
it[Keys.plansDone] = p.done
|
||||
}
|
||||
|
||||
suspend fun setDisplay(d: Display) = context.store.edit {
|
||||
it[Keys.colors] = d.colors
|
||||
it[Keys.teachers] = d.teachers
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ritm.schedule.notify
|
||||
|
||||
import ritm.schedule.data.hideKey
|
||||
import android.Manifest
|
||||
import android.app.AlarmManager
|
||||
import android.app.NotificationChannel
|
||||
@@ -77,6 +78,7 @@ object Reminders {
|
||||
// One reminder per start time: parallel subgroups share the notification.
|
||||
for ((start, lessons) in day.lessons
|
||||
.filter { settings.subgroup == null || it.subgroup == null || it.subgroup == settings.subgroup }
|
||||
.filter { it.hideKey !in settings.hidden }
|
||||
.groupBy { it.start }) {
|
||||
val begins = date.atTime(parseTime(start)).atZone(MINSK).toInstant()
|
||||
val at = begins.minusSeconds(settings.reminderMinutes * 60L)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package ritm.schedule.tile
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import ritm.schedule.MainActivity
|
||||
import ritm.schedule.widget.upcoming
|
||||
|
||||
/**
|
||||
* A Quick Settings tile with the lesson in progress or the next one: when, which room and
|
||||
* the subject. It reads the cached timetable, so it works without a network; a tap opens the app.
|
||||
*/
|
||||
class LessonTileService : TileService() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
scope.launch {
|
||||
val next = upcoming(applicationContext)
|
||||
val tile = qsTile ?: return@launch
|
||||
if (next == null) {
|
||||
tile.label = "Пар нет"
|
||||
tile.state = Tile.STATE_INACTIVE
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = "на ближайшую неделю"
|
||||
} else {
|
||||
val room = next.rooms.takeIf { it.isNotEmpty() }?.let { "ауд. $it" }
|
||||
tile.label = listOfNotNull(next.whenLabel(), room).joinToString(" · ")
|
||||
tile.state = if (next.running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = next.subject
|
||||
}
|
||||
tile.contentDescription = listOfNotNull(tile.label, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle else null)
|
||||
.joinToString(", ")
|
||||
tile.updateTile()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
val intent = Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Asks the system to refresh the tile after the timetable or settings changed. */
|
||||
fun refresh(context: Context) {
|
||||
runCatching { requestListeningState(context, ComponentName(context, LessonTileService::class.java)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
121
android/app/src/main/kotlin/ritm/schedule/ui/Attendance.kt
Normal file
121
android/app/src/main/kotlin/ritm/schedule/ui/Attendance.kt
Normal file
@@ -0,0 +1,121 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package ritm.schedule.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.HowToReg
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.Mark
|
||||
import ritm.schedule.data.nowTime
|
||||
import ritm.schedule.data.today
|
||||
import java.time.LocalDate
|
||||
|
||||
/** Attendance marks of the open group, for cards deep in the tree. */
|
||||
val LocalMarks = staticCompositionLocalOf<List<Mark>> { emptyList() }
|
||||
|
||||
fun List<Mark>.forLesson(subject: String, date: LocalDate, start: String): Mark? {
|
||||
val key = subjectKey(subject)
|
||||
val day = date.toString()
|
||||
return firstOrNull { it.date == day && it.start == start && subjectKey(it.subject) == key }
|
||||
}
|
||||
|
||||
/** Attendance makes sense once a lesson has begun. */
|
||||
fun lessonStarted(date: LocalDate, start: String): Boolean =
|
||||
date < today() || (date == today() && start <= nowTime().toString())
|
||||
|
||||
/** Present and marked lessons of a subject. */
|
||||
fun List<Mark>.statsFor(subject: String): Pair<Int, Int> {
|
||||
val key = subjectKey(subject)
|
||||
val own = filter { subjectKey(it.subject) == key }
|
||||
return own.count { it.present } to own.size
|
||||
}
|
||||
|
||||
/** "Был" / "Пропустил" in the lesson sheet, with "Не отмечено" to take a mark back. */
|
||||
@Composable
|
||||
fun LessonAttendance(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
|
||||
if (!lessonStarted(date, lesson.start) || vm.currentGroupKey() == null) return
|
||||
val marks by vm.marks.collectAsStateWithLifecycle()
|
||||
val mark = marks.forLesson(lesson.subject, date, lesson.start)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Heading("Посещаемость", style = MaterialTheme.typography.titleSmallEmphasized)
|
||||
// Tapping the chosen answer again takes the mark back.
|
||||
ConnectedChoice(
|
||||
options = listOf<Pair<Boolean?, String>>(true to "Был", false to "Пропустил"),
|
||||
selected = mark?.present,
|
||||
onSelect = { vm.mark(lesson, date, if (it == mark?.present) null else it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (mark != null) Text(
|
||||
"Нажмите на отметку ещё раз, чтобы снять её.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-tap attendance for today's finished lessons that have no mark yet; the card leaves
|
||||
* the overview once every lesson is marked.
|
||||
*/
|
||||
@Composable
|
||||
fun AttendanceCard(vm: MainViewModel, slots: List<Slot>) {
|
||||
val marks by vm.marks.collectAsStateWithLifecycle()
|
||||
if (vm.currentGroupKey() == null) return
|
||||
val now = nowTime()
|
||||
val date = today()
|
||||
val open = slots.filter { !it.start.isAfter(now) }
|
||||
.flatMap { slot -> slot.lessons.map { slot to it } }
|
||||
.filter { (_, lesson) -> marks.forLesson(lesson.subject, date, lesson.start) == null }
|
||||
if (open.isEmpty()) return
|
||||
ListCard(Icons.Rounded.HowToReg, "Отметьте посещаемость") {
|
||||
open.forEachIndexed { index, (slot, lesson) ->
|
||||
if (index > 0) RowDivider()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 12.dp, top = 6.dp, bottom = 6.dp),
|
||||
) {
|
||||
SubjectDot(lesson.subject, 10.dp)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(lesson.subject, style = MaterialTheme.typography.bodyLargeEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
TimeText("${slot.start} → ${slot.end}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
FilledTonalIconButton(onClick = { vm.mark(lesson, date, true) }) {
|
||||
Icon(Icons.Rounded.Check, contentDescription = "Был на паре «${lesson.subject}»")
|
||||
}
|
||||
FilledTonalIconButton(
|
||||
onClick = { vm.mark(lesson, date, false) },
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = "Пропустил пару «${lesson.subject}»")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
602
android/app/src/main/kotlin/ritm/schedule/ui/CalendarScreen.kt
Normal file
602
android/app/src/main/kotlin/ritm/schedule/ui/CalendarScreen.kt
Normal file
@@ -0,0 +1,602 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package ritm.schedule.ui
|
||||
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.EventBusy
|
||||
import androidx.compose.material.icons.rounded.Weekend
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material.icons.rounded.CalendarToday
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Assignment
|
||||
import androidx.compose.material.icons.rounded.Schedule
|
||||
import androidx.compose.material.icons.rounded.Today
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.Note
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.Week
|
||||
import ritm.schedule.data.longLabel
|
||||
import ritm.schedule.data.monday
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.data.weekdayName
|
||||
import ritm.schedule.ui.theme.page
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
private val MONTH = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru"))
|
||||
private const val MONTHS_BEFORE = 2
|
||||
private const val MONTHS = 9
|
||||
private val WEEKDAYS = listOf("П", "В", "С", "Ч", "П", "С", "В")
|
||||
|
||||
/** One row of the month grid with its gap. */
|
||||
private val ROW = 58.dp
|
||||
private val GRID_BOTTOM = 12.dp
|
||||
|
||||
private fun mondays(month: YearMonth): List<LocalDate> =
|
||||
generateSequence(month.atDay(1).monday()) { it.plusDays(7) }.takeWhile { it <= month.atEndOfMonth() }.toList()
|
||||
|
||||
private fun rowsOf(month: YearMonth) = mondays(month).size
|
||||
private fun weekRow(month: YearMonth, d: LocalDate) = mondays(month).indexOf(d.monday()).coerceAtLeast(0)
|
||||
|
||||
/**
|
||||
* Calendar: when things happen. A month at a glance with marks for days with lessons and
|
||||
* with plans, green weeks tinted; below, the chosen day's plans and lessons.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
|
||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||
val notes by vm.notes.collectAsStateWithLifecycle()
|
||||
var selected by remember { mutableStateOf(today()) }
|
||||
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
|
||||
var editing by remember { mutableStateOf<Note?>(null) }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
var target by remember { mutableStateOf<NoteTarget?>(null) }
|
||||
val base = remember { YearMonth.from(today()).minusMonths(MONTHS_BEFORE.toLong()) }
|
||||
val pager = rememberPagerState(initialPage = MONTHS_BEFORE) { MONTHS }
|
||||
val scope = rememberCoroutineScope()
|
||||
val month = base.plusMonths(pager.currentPage.toLong())
|
||||
// Swiping to another month picks its first day, or today in the current month.
|
||||
LaunchedEffect(pager) {
|
||||
snapshotFlow { pager.settledPage }.collect { page ->
|
||||
val m = base.plusMonths(page.toLong())
|
||||
if (YearMonth.from(selected) != m) selected = if (m == YearMonth.from(today())) today() else m.atDay(1)
|
||||
}
|
||||
}
|
||||
val t = timetable
|
||||
val subgroup = settings?.subgroup
|
||||
|
||||
// 0 — the whole month; 1 — the day panel covers it and only the chosen week stays.
|
||||
var collapse by remember { mutableFloatStateOf(0f) }
|
||||
// Whole pixels, exactly what each row is laid out at, so the folded grid never shows
|
||||
// a sliver of the next (green) week.
|
||||
val rowPx = with(LocalDensity.current) { ROW.roundToPx().toFloat() }
|
||||
val range = (rowsOf(month) - 1).coerceAtLeast(1) * rowPx
|
||||
val snap = remember { tween<Float>(MotionDuration.Medium4, easing = Emphasized) }
|
||||
suspend fun settle(velocity: Float) {
|
||||
val to = when {
|
||||
velocity < -300f -> 1f
|
||||
velocity > 300f -> 0f
|
||||
else -> if (collapse > 0.5f) 1f else 0f
|
||||
}
|
||||
animate(collapse, to, animationSpec = snap) { v, _ -> collapse = v }
|
||||
}
|
||||
// Scrolling the day up first slides the panel over the month; pulling down at the top
|
||||
// brings the month back, as in School Planner's calendar.
|
||||
val connection = remember(range) {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
if (available.y >= 0f || collapse >= 1f) return Offset.Zero
|
||||
val next = (collapse - available.y / range).coerceAtMost(1f)
|
||||
val used = (next - collapse) * range
|
||||
collapse = next
|
||||
return Offset(0f, -used)
|
||||
}
|
||||
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||
if (available.y <= 0f || collapse <= 0f) return Offset.Zero
|
||||
val next = (collapse - available.y / range).coerceAtLeast(0f)
|
||||
val used = (collapse - next) * range
|
||||
collapse = next
|
||||
return Offset(0f, used)
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
if (collapse <= 0f || collapse >= 1f) return Velocity.Zero
|
||||
settle(available.y)
|
||||
return available
|
||||
}
|
||||
}
|
||||
}
|
||||
val drag = rememberDraggableState { delta -> collapse = (collapse - delta / range).coerceIn(0f, 1f) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.page).nestedScroll(connection)) {
|
||||
Column {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
navigationIcon = { MenuButton(nav) },
|
||||
title = {
|
||||
Text(
|
||||
month.format(MONTH).replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
if (selected != today()) IconButton(onClick = {
|
||||
selected = today()
|
||||
scope.launch { pager.animateScrollToPage(MONTHS_BEFORE) }
|
||||
}) { Icon(Icons.Rounded.Today, contentDescription = "Сегодня") }
|
||||
},
|
||||
)
|
||||
Row(Modifier.widthIn(max = MAX_CONTENT).fillMaxWidth().padding(horizontal = 12.dp).align(Alignment.CenterHorizontally)) {
|
||||
WEEKDAYS.forEach {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f).padding(vertical = 4.dp),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalPager(state = pager, verticalAlignment = Alignment.Top) { page ->
|
||||
val m = base.plusMonths(page.toLong())
|
||||
// The chosen week's row stays in view as the grid folds up under the panel.
|
||||
val keep = if (YearMonth.from(selected) == m) weekRow(m, selected) else 0
|
||||
MonthGrid(
|
||||
modifier = Modifier
|
||||
.clipToBounds()
|
||||
.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
// Folded, exactly one row stays: no strip of the next (tinted) week peeks out.
|
||||
val hidden = (placeable.height - rowPx) * collapse
|
||||
layout(placeable.width, (placeable.height - hidden).roundToInt()) {
|
||||
placeable.place(0, -(keep * rowPx * collapse).roundToInt())
|
||||
}
|
||||
},
|
||||
month = m,
|
||||
selected = selected,
|
||||
hasLessons = { d -> t != null && vm.slotsOn(t, d, subgroup).isNotEmpty() },
|
||||
hasPlans = { d -> notes.any { it.date == d.toString() && (!it.task || !it.done) } },
|
||||
weekOf = { m -> t?.days?.firstOrNull { it.date == m.toString() }?.week },
|
||||
onSelect = { selected = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
// The day panel: a sheet with a handle, so its rounded top reads as something that moves.
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraLarge.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Column {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.draggable(drag, Orientation.Vertical, onDragStopped = { v -> settle(v) })
|
||||
.semantics {
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction(if (collapse < 0.5f) "Свернуть календарь до недели" else "Развернуть месяц") {
|
||||
scope.launch { animate(collapse, if (collapse < 0.5f) 1f else 0f, animationSpec = snap) { v, _ -> collapse = v } }
|
||||
true
|
||||
},
|
||||
)
|
||||
},
|
||||
) { BottomSheetDefaults.DragHandle() }
|
||||
DayAgenda(
|
||||
vm, t, subgroup, selected, notes,
|
||||
onLesson = { details = it to selected },
|
||||
onNote = { editing = it },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
FloatingActionButton(
|
||||
onClick = { adding = true },
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp + navBarPadding()),
|
||||
) { Icon(Icons.Rounded.Add, contentDescription = "Новый план") }
|
||||
}
|
||||
|
||||
if (adding) ModalBottomSheet(onDismissRequest = { adding = false }) {
|
||||
AddPlanSheet(vm, t, subgroup, day = selected) { adding = false; target = it }
|
||||
}
|
||||
target?.let { tg ->
|
||||
vm.currentGroupKey()?.let { group ->
|
||||
NoteEditor(
|
||||
target = tg,
|
||||
editing = null,
|
||||
groupKey = group,
|
||||
onSave = { vm.saveNote(it); target = null },
|
||||
onDelete = {},
|
||||
onDismiss = { target = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
details?.let { (lesson, date) ->
|
||||
ModalBottomSheet(onDismissRequest = { details = null }) {
|
||||
LessonDetails(lesson, t?.bells.orEmpty(), date, vm, onClose = { details = null })
|
||||
}
|
||||
}
|
||||
editing?.let { note ->
|
||||
NoteEditor(
|
||||
target = null,
|
||||
editing = note,
|
||||
groupKey = note.group,
|
||||
onSave = { vm.saveNote(it); editing = null },
|
||||
onDelete = { vm.deleteNote(it.id); editing = null },
|
||||
onDismiss = { editing = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Six week rows of a month; a green week's row is tinted the way the schedule header is. */
|
||||
@Composable
|
||||
private fun MonthGrid(
|
||||
modifier: Modifier,
|
||||
month: YearMonth,
|
||||
selected: LocalDate,
|
||||
hasLessons: (LocalDate) -> Boolean,
|
||||
hasPlans: (LocalDate) -> Boolean,
|
||||
weekOf: (LocalDate) -> Week?,
|
||||
onSelect: (LocalDate) -> Unit,
|
||||
) {
|
||||
val rows = mondays(month)
|
||||
Column(modifier.widthIn(max = MAX_CONTENT).fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = GRID_BOTTOM)) {
|
||||
rows.forEach { monday ->
|
||||
val week = weekOf(monday)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ROW)
|
||||
.padding(bottom = 2.dp)
|
||||
.background(
|
||||
if (week != null && !week.white) weekTintOf(week) else Color.Transparent,
|
||||
MaterialTheme.shapes.large,
|
||||
),
|
||||
) {
|
||||
repeat(7) { i ->
|
||||
val d = monday.plusDays(i.toLong())
|
||||
val inMonth = d.month == month.month
|
||||
val isSelected = d == selected
|
||||
val isToday = d == today()
|
||||
val lessons = inMonth && hasLessons(d)
|
||||
val plans = inMonth && hasPlans(d)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
// No pressed ripple: the filled circle already answers the tap.
|
||||
.selectable(
|
||||
selected = isSelected,
|
||||
enabled = inMonth,
|
||||
role = Role.Tab,
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
) { onSelect(d) }
|
||||
.padding(vertical = 4.dp)
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = "${d.weekdayName()}, ${d.longLabel()}" +
|
||||
(if (isToday) ", сегодня" else "") + (if (lessons) ", есть пары" else "") +
|
||||
(if (plans) ", есть планы" else "")
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(if (isSelected && inMonth) MaterialTheme.colorScheme.primary else Color.Transparent, CircleShape),
|
||||
) {
|
||||
Text(
|
||||
"${d.dayOfMonth}",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = when {
|
||||
!inMonth -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
|
||||
isSelected -> MaterialTheme.colorScheme.onPrimary
|
||||
isToday -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
}
|
||||
// Two small marks: lessons in the calm tone, plans in the accent.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.height(8.dp).padding(top = 2.dp)) {
|
||||
if (lessons) Box(Modifier.size(5.dp).background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape))
|
||||
if (plans) Box(Modifier.size(5.dp).background(MaterialTheme.colorScheme.tertiary, CircleShape))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DATE = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
|
||||
/**
|
||||
* The chosen day as a timeline, as School Planner's calendar shows it: numbered sections for
|
||||
* the day's plans and lessons, each item on a line with its marker — a ring to tick a task off,
|
||||
* a dot in the subject's colour for a lesson. Only sections with something in them are shown;
|
||||
* a day with nothing gets an empty state.
|
||||
*/
|
||||
@Composable
|
||||
private fun DayAgenda(
|
||||
vm: MainViewModel,
|
||||
t: Timetable?,
|
||||
subgroup: String?,
|
||||
date: LocalDate,
|
||||
notes: List<Note>,
|
||||
onLesson: (Lesson) -> Unit,
|
||||
onNote: (Note) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val plans = notes.filter { it.date == date.toString() }.sortedWith(compareBy({ it.task && it.done }, { it.start }))
|
||||
val known = t?.days?.any { it.date == date.toString() } == true
|
||||
val slots = if (t != null && known) vm.slotsOn(t, date, subgroup) else emptyList()
|
||||
val lessons = slots.flatMap { slot -> slot.lessons.map { slot to it } }
|
||||
|
||||
if (plans.isEmpty() && lessons.isEmpty()) {
|
||||
Box(modifier) {
|
||||
ContentColumn {
|
||||
// Scrolls, so a low sheet never squeezes the illustration out of shape.
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 80.dp + navBarPadding())) {
|
||||
if (known) Empty(
|
||||
"Свободный день",
|
||||
"${date.weekdayName()}, ${date.longLabel()}: ни пар, ни планов.",
|
||||
icon = Icons.Rounded.Weekend,
|
||||
) else Empty(
|
||||
"Расписание не загружено",
|
||||
"Эта дата вне текущего семестра. Планов на ${date.longLabel()} нет.",
|
||||
icon = Icons.Rounded.EventBusy,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ContentColumn {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 24.dp + navBarPadding()),
|
||||
modifier = modifier,
|
||||
) {
|
||||
var n = 0
|
||||
if (plans.isNotEmpty()) {
|
||||
n++
|
||||
val number = n
|
||||
item(key = "plans") { Section(number, "Ваши планы", trailing = date.format(DATE)) }
|
||||
plans.forEachIndexed { i, note ->
|
||||
item(key = note.id) {
|
||||
TimelineItem(
|
||||
marker = { PlanMarker(note, onToggle = { vm.toggleNote(note.id) }) },
|
||||
over = note.subject.ifBlank { null },
|
||||
title = note.text,
|
||||
done = note.task && note.done,
|
||||
last = i == plans.lastIndex,
|
||||
onClick = { onNote(note) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lessons.isNotEmpty()) {
|
||||
n++
|
||||
val number = n
|
||||
item(key = "lessons") {
|
||||
Section(number, "Ваши занятия", trailing = if (number == 1) date.format(DATE) else null, gap = number > 1)
|
||||
}
|
||||
lessons.forEachIndexed { i, (slot, l) ->
|
||||
item(key = "${slot.start}|${l.key}") {
|
||||
val color = subjectColor(l.subject)
|
||||
TimelineItem(
|
||||
marker = { Box(Modifier.size(20.dp).background(color.container, CircleShape)) },
|
||||
over = "${slot.start} → ${slot.end}",
|
||||
title = l.subject,
|
||||
under = listOfNotNull(
|
||||
if (l.distance) "дистанционно" else l.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" },
|
||||
l.type.ifEmpty { null }?.lowercase(),
|
||||
).joinToString(" · ").ifEmpty { null },
|
||||
last = i == lessons.lastIndex,
|
||||
onClick = { onLesson(l) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "① Ваши планы" — a numbered pill, as in School Planner; the timeline starts under its circle. */
|
||||
@Composable
|
||||
private fun Section(n: Int, title: String, trailing: String? = null, gap: Boolean = false) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(top = if (gap) 24.dp else 8.dp)) {
|
||||
Box(Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(RAIL).background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||
) {
|
||||
Text("$n", style = MaterialTheme.typography.titleMediumEmphasized, color = MaterialTheme.colorScheme.onPrimaryContainer)
|
||||
}
|
||||
Heading(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||
modifier = Modifier.padding(start = 12.dp, end = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
trailing?.let {
|
||||
Icon(Icons.Rounded.CalendarToday, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val RAIL = 40.dp
|
||||
private val MARKER = 24.dp
|
||||
|
||||
/**
|
||||
* One entry on the timeline. The line runs from the item above (or the section circle) down
|
||||
* through the marker; on the last item it stops at the marker instead of trailing off.
|
||||
*/
|
||||
@Composable
|
||||
private fun TimelineItem(
|
||||
marker: @Composable () -> Unit,
|
||||
over: String?,
|
||||
title: String,
|
||||
under: String? = null,
|
||||
done: Boolean = false,
|
||||
last: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val line = MaterialTheme.colorScheme.outlineVariant
|
||||
// Level with the first line of the title: 14dp padding, a 20dp line above it, a 24dp title line.
|
||||
val markerTop = if (over != null) 34.dp else 14.dp
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
modifier = Modifier
|
||||
.width(RAIL)
|
||||
.fillMaxHeight()
|
||||
.drawBehind {
|
||||
val end = if (last) (markerTop + MARKER / 2).toPx() else size.height
|
||||
drawLine(line, Offset(size.width / 2, 0f), Offset(size.width / 2, end), 2.dp.toPx())
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.padding(top = markerTop)
|
||||
.size(MARKER)
|
||||
.background(MaterialTheme.colorScheme.surface, CircleShape),
|
||||
) { marker() }
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(start = 16.dp, top = 14.dp, bottom = 10.dp)) {
|
||||
over?.let {
|
||||
TimeText(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textDecoration = if (done) TextDecoration.LineThrough else null,
|
||||
color = if (done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
under?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A task's ring in its subject's colour, filled with a tick once done; a note gets a small square. */
|
||||
@Composable
|
||||
private fun PlanMarker(n: Note, onToggle: () -> Unit) {
|
||||
val color = if (n.general) MaterialTheme.colorScheme.primary else subjectColor(n.subject).container
|
||||
if (!n.task) {
|
||||
Box(Modifier.size(18.dp).border(2.dp, color, MaterialTheme.shapes.extraSmall))
|
||||
return
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.background(if (n.done) color else Color.Transparent, CircleShape)
|
||||
.border(2.dp, color, CircleShape)
|
||||
.toggleable(value = n.done, role = Role.Checkbox, onValueChange = { onToggle() })
|
||||
.semantics { contentDescription = if (n.done) "Выполнено" else "Не выполнено" },
|
||||
) {
|
||||
if (n.done) Icon(
|
||||
Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
tint = if (n.general) MaterialTheme.colorScheme.onPrimary else subjectColor(n.subject).content,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
package ritm.schedule.ui
|
||||
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.takeOrElse
|
||||
import ritm.schedule.ui.theme.harmonizeWith
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -288,7 +299,7 @@ fun WeekMark(week: Week, modifier: Modifier = Modifier, short: Boolean = false)
|
||||
Box(
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.background(if (week.white) Color.White else WeekColors.mark(dark), CircleShape)
|
||||
.background(if (week.white) Color.White else WeekColors.mark(dark).harmonizeWith(MaterialTheme.colorScheme.primary), CircleShape)
|
||||
.then(if (week.white) Modifier.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) else Modifier),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
@@ -316,7 +327,7 @@ fun weekTintOf(week: Week?): Color {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val dark = scheme.surface.luminance() < 0.5f
|
||||
return if (week == null || week.white) scheme.page
|
||||
else lerp(scheme.page, WeekColors.container(false, dark), if (dark) 0.55f else 0.75f)
|
||||
else lerp(scheme.page, WeekColors.container(false, dark).harmonizeWith(scheme.primary), if (dark) 0.55f else 0.75f)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,10 +369,13 @@ fun DayStrip(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
// No pressed ripple: the filled circle already answers the tap.
|
||||
.selectable(
|
||||
selected = isSelected,
|
||||
enabled = enabled(date),
|
||||
role = Role.Tab,
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onClick = { onSelect(date) },
|
||||
)
|
||||
.padding(vertical = 6.dp)
|
||||
@@ -483,3 +497,47 @@ fun avatarLabel(text: String): String {
|
||||
val parts = text.split(' ', '.', '-').filter { it.isNotEmpty() }
|
||||
return parts.take(2).joinToString("") { it.first().uppercase() }.ifEmpty { "?" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Text with time ranges like "10:05 → 11:30": each "→" is drawn as the Material arrow icon,
|
||||
* sized to the text and in its colour, instead of the font's arrow glyph.
|
||||
*/
|
||||
@Composable
|
||||
fun TimeText(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Color.Unspecified,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
) {
|
||||
val tint = color.takeOrElse { style.color.takeOrElse { LocalContentColor.current } }
|
||||
val parts = text.split("→")
|
||||
val annotated = buildAnnotatedString {
|
||||
parts.forEachIndexed { i, part ->
|
||||
if (i > 0) appendInlineContent(ARROW, "→")
|
||||
append(part)
|
||||
}
|
||||
}
|
||||
val size = style.fontSize.takeOrElse { 14.sp }
|
||||
val inline = mapOf(
|
||||
ARROW to InlineTextContent(Placeholder(size, size, PlaceholderVerticalAlign.TextCenter)) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.ArrowForward,
|
||||
contentDescription = "до",
|
||||
tint = tint,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
},
|
||||
)
|
||||
Text(
|
||||
annotated,
|
||||
style = style,
|
||||
color = color,
|
||||
modifier = modifier,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
inlineContent = inline,
|
||||
)
|
||||
}
|
||||
|
||||
private const val ARROW = "arrow"
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
package ritm.schedule.ui
|
||||
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material.icons.rounded.AccountBalance
|
||||
import androidx.compose.material.icons.rounded.CheckCircle
|
||||
import androidx.compose.material.icons.rounded.History
|
||||
@@ -96,11 +99,22 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
|
||||
}
|
||||
val searching = query.trim().length >= 2
|
||||
|
||||
// Before going back to the overview, a group with subgroups asks which one is yours,
|
||||
// unless you already chose it for this group.
|
||||
var askSubgroup by remember { mutableStateOf<List<String>?>(null) }
|
||||
fun opened() {
|
||||
val t = vm.timetable.value
|
||||
val subgroups = t?.subgroups.orEmpty()
|
||||
val s = vm.settings.value
|
||||
val chosen = s?.current?.group == t?.group && s?.subgroup != null
|
||||
if (subgroups.isNotEmpty() && !chosen) askSubgroup = subgroups else onOpened()
|
||||
}
|
||||
|
||||
fun open(result: GroupResult) {
|
||||
opening = "${result.fileId}|${result.group}"
|
||||
vm.open(result) { ok ->
|
||||
opening = null
|
||||
if (ok) onOpened()
|
||||
if (ok) opened()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +183,7 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
|
||||
color = subjectColor(avatarLabel(g.group)),
|
||||
title = g.group,
|
||||
subtitle = describe(g.faculty, g.course),
|
||||
onClick = { vm.open(g, onOpened) },
|
||||
onClick = { vm.open(g) { opened() } },
|
||||
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
|
||||
)
|
||||
}
|
||||
@@ -228,6 +242,13 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
askSubgroup?.let { subgroups ->
|
||||
SubgroupDialog(
|
||||
group = vm.timetable.value?.group.orEmpty(),
|
||||
subgroups = subgroups,
|
||||
onPick = { vm.setSubgroup(it); askSubgroup = null; onOpened() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -274,3 +295,40 @@ private fun CurrentGroup(current: SavedGroup, subgroups: List<String>, subgroup:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Ваша подгруппа": all of them, or one; the choice is remembered for this group. */
|
||||
@Composable
|
||||
private fun SubgroupDialog(group: String, subgroups: List<String>, onPick: (String?) -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { onPick(null) },
|
||||
icon = { Icon(Icons.Rounded.Groups, contentDescription = null) },
|
||||
title = { Text("Ваша подгруппа") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
"В группе $group занятия делятся на подгруппы. Выберите свою — чужие пары скроются из расписания, " +
|
||||
"виджета и напоминаний. Изменить можно в разделе «Группы».",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
(listOf<String?>(null) + subgroups).forEach { option ->
|
||||
Surface(
|
||||
onClick = { onPick(option) },
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
option ?: "Все подгруппы",
|
||||
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = { onPick(null) }) { Text("Позже") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
|
||||
package ritm.schedule.ui
|
||||
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.material.icons.rounded.ArrowDropDown
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.rounded.CalendarMonth
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.Checklist
|
||||
import androidx.compose.material.icons.rounded.DateRange
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import ritm.schedule.data.shortWeekday
|
||||
import androidx.compose.material.icons.automirrored.rounded.Assignment
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import ritm.schedule.data.Note
|
||||
@@ -91,7 +104,7 @@ import java.time.LocalTime
|
||||
data class Slot(val date: LocalDate, val start: LocalTime, val end: LocalTime, val lessons: List<Lesson>)
|
||||
|
||||
fun MainViewModel.slotsOn(t: Timetable, date: LocalDate, subgroup: String?): List<Slot> =
|
||||
repository.lessonsOn(t, date, subgroup)
|
||||
repository.lessonsOn(t, date, subgroup, hidden)
|
||||
.groupBy { it.start }
|
||||
.map { (start, lessons) -> Slot(date, parseTime(start), lessonEnd(lessons.first(), t), lessons) }
|
||||
.sortedBy { it.start }
|
||||
@@ -116,6 +129,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
||||
val status by vm.status.collectAsStateWithLifecycle()
|
||||
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
|
||||
var editing by remember { mutableStateOf<Note?>(null) }
|
||||
var session by remember { mutableStateOf(false) }
|
||||
val subgroup = settings?.subgroup
|
||||
val clock = rememberClock()
|
||||
val date = today()
|
||||
@@ -125,15 +139,38 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
||||
TopAppBar(
|
||||
colors = appBarColors(),
|
||||
scrollBehavior = scroll,
|
||||
title = {
|
||||
Column {
|
||||
Text("Обзор", style = MaterialTheme.typography.titleLargeEmphasized)
|
||||
(timetable?.group ?: settings?.current?.group)?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
title = { Text("Обзор", style = MaterialTheme.typography.titleLargeEmphasized) },
|
||||
navigationIcon = { MenuButton(nav) },
|
||||
actions = {
|
||||
// The open group, one tap from switching: it used to live in the side menu.
|
||||
val group = timetable?.group ?: settings?.current?.group
|
||||
// An outlined pill with the group's avatar: unlike the tonal shortcuts below,
|
||||
// it reads as "who I am", not as one more action.
|
||||
Surface(
|
||||
onClick = nav.openGroups,
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
modifier = Modifier.padding(end = 8.dp).semantics {
|
||||
contentDescription = group?.let { "Группа $it, сменить" } ?: "Выбрать группу"
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 8.dp, top = 4.dp, bottom = 4.dp).clearAndSetSemantics {},
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(32.dp).background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||
) {
|
||||
Icon(Icons.Rounded.Groups, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(group ?: "Группа", style = MaterialTheme.typography.labelLarge)
|
||||
Icon(Icons.Rounded.ArrowDropDown, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = { MenuButton(nav) },
|
||||
)
|
||||
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage) }
|
||||
val t = timetable
|
||||
@@ -162,9 +199,11 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
||||
) {
|
||||
item { QuickActions(nav, Modifier.bleed(16.dp)) }
|
||||
item { WeekReport(vm, t, subgroup, date, clock) }
|
||||
item { SessionCard(vm, t, onOpen = { session = true }) }
|
||||
item { DayTitle("Сегодня", date) }
|
||||
item { TodayCard(todaySlots, clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) }
|
||||
item { TasksCard(vm, onEdit = { editing = it }) }
|
||||
item { AttendanceCard(vm, todaySlots) }
|
||||
item { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) }
|
||||
if (next != null) {
|
||||
val (day, slots) = next
|
||||
item { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) }
|
||||
@@ -179,6 +218,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (session) timetable?.let { t ->
|
||||
val exams = upcomingExams(vm, t)
|
||||
ModalBottomSheet(onDismissRequest = { session = false }) { SessionSheet(exams) }
|
||||
}
|
||||
editing?.let { note ->
|
||||
NoteEditor(
|
||||
target = null,
|
||||
@@ -190,7 +233,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
||||
)
|
||||
}
|
||||
details?.let { lesson ->
|
||||
ModalBottomSheet(onDismissRequest = { details = null }) { LessonDetails(lesson.first, timetable?.bells.orEmpty(), lesson.second, vm) }
|
||||
ModalBottomSheet(onDismissRequest = { details = null }) { LessonDetails(lesson.first, timetable?.bells.orEmpty(), lesson.second, vm, onClose = { details = null }) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,9 +243,9 @@ private fun QuickActions(nav: AppNav, modifier: Modifier = Modifier) {
|
||||
val actions = listOf(
|
||||
Triple("Сегодня", Icons.Rounded.Today) { nav.openDay(today()) },
|
||||
Triple("Неделя", Icons.Rounded.CalendarViewWeek, nav.openWeek),
|
||||
Triple("Предметы", Icons.AutoMirrored.Rounded.MenuBook, nav.openSubjects),
|
||||
Triple("Планы", Icons.Rounded.Checklist, nav.openPlans),
|
||||
Triple("Календарь", Icons.Rounded.CalendarMonth, nav.openCalendar),
|
||||
Triple("Кто где", Icons.Rounded.TravelExplore, nav.openSearch),
|
||||
Triple("Группа", Icons.Rounded.SwapHoriz, nav.openGroups),
|
||||
)
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
@@ -233,7 +276,7 @@ private fun Modifier.bleed(horizontal: Dp): Modifier = layout { measurable, cons
|
||||
* soonest first. Ticked tasks stay struck through until their day has passed.
|
||||
*/
|
||||
@Composable
|
||||
private fun TasksCard(vm: MainViewModel, onEdit: (Note) -> Unit) {
|
||||
private fun TasksCard(vm: MainViewModel, onEdit: (Note) -> Unit, onAll: () -> Unit) {
|
||||
val notes by vm.notes.collectAsStateWithLifecycle()
|
||||
val date = today()
|
||||
val pending = notes
|
||||
@@ -252,40 +295,107 @@ private fun TasksCard(vm: MainViewModel, onEdit: (Note) -> Unit) {
|
||||
(if (expanded) pending else pending.take(4)).forEach { note ->
|
||||
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { onEdit(note) }, showSubject = true)
|
||||
}
|
||||
if (pending.size > 4) TextButton(
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
) { Text(if (expanded) "Свернуть" else "Показать все (${pending.size})") }
|
||||
Row(Modifier.padding(start = 12.dp)) {
|
||||
if (pending.size > 4) TextButton(onClick = { expanded = !expanded }) {
|
||||
Text(if (expanded) "Свернуть" else "Показать все (${pending.size})")
|
||||
}
|
||||
TextButton(onClick = onAll) { Text("Все планы") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Прошло 12 из 18 пар" with a wavy progress line over the current week. */
|
||||
/**
|
||||
* The current week at a glance: how many lessons are still ahead, a chip per day with its
|
||||
* count (past days ticked off, today highlighted) and the homework due this week. Once the
|
||||
* week is over it looks ahead to the next one.
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekReport(vm: MainViewModel, t: Timetable, subgroup: String?, date: LocalDate, clock: LocalTime) {
|
||||
val monday = date.monday()
|
||||
val slots = (0..6L).flatMap { vm.slotsOn(t, monday.plusDays(it), subgroup) }
|
||||
val done = slots.count { it.date < date || (it.date == date && !it.end.isAfter(clock)) }
|
||||
val week = t.days.firstOrNull { it.date == date.toString() }?.week
|
||||
SectionCard(Icons.Rounded.Insights, "Отчёт за неделю") {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
val days = (0..6L).map { monday.plusDays(it) }.map { it to vm.slotsOn(t, it, subgroup) }
|
||||
.filter { (d, slots) -> d.dayOfWeek.value < 7 || slots.isNotEmpty() }
|
||||
val total = days.sumOf { it.second.size }
|
||||
val left = days.sumOf { (_, slots) -> slots.count { it.date > date || (it.date == date && it.end.isAfter(clock)) } }
|
||||
val week = t.days.firstOrNull { it.date == monday.toString() }?.week
|
||||
val due = LocalNotes.current.count { it.task && !it.done && parseDate(it.date) in date..monday.plusDays(6) }
|
||||
|
||||
SectionCard(Icons.Rounded.DateRange, week?.let { "Неделя ${it.n}" } ?: "Эта неделя") {
|
||||
Column {
|
||||
Text(
|
||||
when {
|
||||
total == 0 -> "На этой неделе пар нет"
|
||||
left == 0 -> "Пары этой недели закончились"
|
||||
left == total -> "Впереди $total ${plural(total, "пара", "пары", "пар")}"
|
||||
else -> "Осталось $left ${plural(left, "пара", "пары", "пар")} из $total"
|
||||
},
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
if (slots.isEmpty()) "На этой неделе занятий нет"
|
||||
else "Прошло $done из ${slots.size} ${plural(slots.size, "пары", "пар", "пар")}",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
)
|
||||
Text(
|
||||
(week?.let { "Неделя ${it.n} · " } ?: "") + weekSpan(monday, monday.plusDays(6)),
|
||||
weekSpan(monday, monday.plusDays(6)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
week?.let { WeekMark(it, Modifier.padding(top = 2.dp)) }
|
||||
week?.let {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
WeekMark(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (slots.isNotEmpty()) LinearWavyProgressIndicator(
|
||||
progress = { done.toFloat() / slots.size },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
if (total > 0) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
days.forEach { (d, slots) -> DayChip(d, slots.size, date, Modifier.weight(1f)) }
|
||||
}
|
||||
if (due > 0) HomeMeta(
|
||||
Icons.AutoMirrored.Rounded.Assignment,
|
||||
"Сдать до воскресенья: $due ${plural(due, "задание", "задания", "заданий")}",
|
||||
)
|
||||
if (left == 0) {
|
||||
val next = monday.plusDays(7)
|
||||
val nextTotal = (0..6L).sumOf { vm.slotsOn(t, next.plusDays(it), subgroup).size }
|
||||
val nextWeek = t.days.firstOrNull { it.date == next.toString() }?.week
|
||||
if (nextTotal > 0) HomeMeta(
|
||||
Icons.Rounded.EventAvailable,
|
||||
"Дальше " + (nextWeek?.let { "неделя ${it.n}, ${if (it.white) "белая" else "зелёная"}: " } ?: "") +
|
||||
"$nextTotal ${plural(nextTotal, "пара", "пары", "пар")}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A weekday with its number of lessons: ticked off once past, strong today. */
|
||||
@Composable
|
||||
private fun DayChip(d: LocalDate, count: Int, date: LocalDate, modifier: Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val past = d < date
|
||||
val (bg, fg) = when {
|
||||
count == 0 -> Color.Transparent to scheme.onSurfaceVariant
|
||||
d == date -> scheme.primary to scheme.onPrimary
|
||||
past -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant
|
||||
else -> scheme.primaryContainer to scheme.onPrimaryContainer
|
||||
}
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier.semantics(mergeDescendants = true) {
|
||||
contentDescription = "${d.weekdayName()}: " + if (count == 0) "пар нет" else
|
||||
"$count ${plural(count, "пара", "пары", "пар")}" + if (past) ", прошли" else ""
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
d.shortWeekday(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (d == date) scheme.primary else scheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxWidth().height(36.dp).background(bg, MaterialTheme.shapes.medium),
|
||||
) {
|
||||
when {
|
||||
count == 0 -> Text("—", style = MaterialTheme.typography.labelLarge, color = fg)
|
||||
past -> Icon(Icons.Rounded.Check, contentDescription = null, tint = fg, modifier = Modifier.size(18.dp))
|
||||
else -> Text("$count", style = MaterialTheme.typography.titleSmallEmphasized, color = fg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +480,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
|
||||
) {
|
||||
SubjectDot(lesson.subject, 14.dp, Modifier.padding(top = 8.dp))
|
||||
@@ -377,7 +488,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(lesson.subject, style = MaterialTheme.typography.titleLargeEmphasized, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(slot.timeLabel(), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
TimeText(slot.timeLabel(), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
val minutes = Duration.between(clock, if (running) slot.end else slot.start).toMinutes()
|
||||
Surface(
|
||||
@@ -407,14 +518,17 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick) else Modifier),
|
||||
.then(
|
||||
if (onClick != null) Modifier.clip(MaterialTheme.shapes.medium).clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick)
|
||||
else Modifier,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SubjectDot(lesson.subject, 10.dp)
|
||||
Spacer(Modifier.width(18.dp))
|
||||
Column {
|
||||
Text(lesson.subject, style = MaterialTheme.typography.titleSmallEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
TimeText(
|
||||
listOfNotNull(
|
||||
slot.timeLabel(),
|
||||
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. $it" },
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package ritm.schedule.ui
|
||||
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.getValue
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -34,12 +36,27 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
app.settings.flow.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
val timetable: StateFlow<Timetable?> = app.repository.timetable
|
||||
|
||||
/** The open group's exams; null until the first answer, then cached on the device. */
|
||||
val exams: StateFlow<ritm.schedule.data.ExamsResponse?> = app.repository.exams
|
||||
|
||||
/** Tasks and notes of the open group. */
|
||||
val notes: StateFlow<List<Note>> = combine(app.notes.notes, app.settings.flow) { list, s ->
|
||||
val key = s.current?.let(::groupKey)
|
||||
list.filter { it.group == key }
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
/** Attendance marks of the open group. */
|
||||
val marks: StateFlow<List<ritm.schedule.data.Mark>> = combine(app.attendance.marks, app.settings.flow) { list, s ->
|
||||
val key = s.current?.let(::groupKey)
|
||||
list.filter { it.group == key }
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
/** Marks a lesson as attended (true), missed (false) or clears the mark (null). */
|
||||
fun mark(lesson: ritm.schedule.data.Lesson, date: java.time.LocalDate, present: Boolean?) = viewModelScope.launch {
|
||||
val group = currentGroupKey() ?: return@launch
|
||||
app.attendance.set(ritm.schedule.data.Mark(group, lesson.subject, date.toString(), lesson.start, present ?: false), present)
|
||||
}
|
||||
|
||||
fun saveNote(note: Note) = viewModelScope.launch { app.notes.save(note) }
|
||||
fun deleteNote(id: String) = viewModelScope.launch { app.notes.delete(id) }
|
||||
fun toggleNote(id: String) = viewModelScope.launch { app.notes.toggle(id) }
|
||||
@@ -50,7 +67,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val _status = MutableStateFlow(Status())
|
||||
val status = _status.asStateFlow()
|
||||
|
||||
/**
|
||||
* Hidden lessons as Compose state: screens that read it through [slotsOn] redraw
|
||||
* as soon as a lesson is hidden or shown again.
|
||||
*/
|
||||
var hidden by androidx.compose.runtime.mutableStateOf(emptySet<String>())
|
||||
private set
|
||||
|
||||
init {
|
||||
viewModelScope.launch { settings.collect { hidden = it?.hidden.orEmpty() } }
|
||||
viewModelScope.launch {
|
||||
if (app.settings.now().current != null) refresh()
|
||||
}
|
||||
@@ -94,6 +119,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun setTheme(mode: ThemeMode) = viewModelScope.launch { app.settings.setTheme(mode) }
|
||||
fun setDynamicColor(on: Boolean) = viewModelScope.launch { app.settings.setDynamicColor(on) }
|
||||
fun setDisplay(display: Display) = viewModelScope.launch { app.settings.setDisplay(display) }
|
||||
fun setPlans(prefs: ritm.schedule.data.PlansPrefs) = viewModelScope.launch { app.settings.setPlans(prefs) }
|
||||
|
||||
fun setHidden(key: String, hidden: Boolean) = viewModelScope.launch {
|
||||
app.settings.setHidden(key, hidden)
|
||||
SyncWorker.afterChange(getApplication())
|
||||
}
|
||||
|
||||
|
||||
fun setReminders(on: Boolean) = viewModelScope.launch {
|
||||
app.settings.setReminders(on)
|
||||
|
||||
76
android/app/src/main/kotlin/ritm/schedule/ui/Motion.kt
Normal file
76
android/app/src/main/kotlin/ritm/schedule/ui/Motion.kt
Normal file
@@ -0,0 +1,76 @@
|
||||
package ritm.schedule.ui
|
||||
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import android.graphics.Path
|
||||
import android.view.animation.PathInterpolator
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// Material motion patterns, as in RideBus: shared axis X between a screen and the page it
|
||||
// opens, fade through between top-level destinations.
|
||||
|
||||
/** How far a shared axis transition slides: 30dp in pixels. */
|
||||
@Composable
|
||||
fun rememberSlideDistance(): Int {
|
||||
val density = LocalDensity.current
|
||||
return remember(density) { with(density) { 30.dp.roundToPx() } }
|
||||
}
|
||||
|
||||
private fun sharedAxisXIn(forward: Boolean, distance: Int): EnterTransition =
|
||||
slideInHorizontally(tween(300, easing = FastOutSlowInEasing)) { if (forward) distance else -distance } +
|
||||
fadeIn(tween(durationMillis = 195, delayMillis = 105, easing = LinearOutSlowInEasing))
|
||||
|
||||
private fun sharedAxisXOut(forward: Boolean, distance: Int): ExitTransition =
|
||||
slideOutHorizontally(tween(300, easing = FastOutSlowInEasing)) { if (forward) -distance else distance } +
|
||||
fadeOut(tween(durationMillis = 105, easing = FastOutLinearInEasing))
|
||||
|
||||
/** Forward into a page slides in from the end; back slides the other way. */
|
||||
fun materialSharedAxisX(forward: Boolean, distance: Int): ContentTransform =
|
||||
sharedAxisXIn(forward, distance) togetherWith sharedAxisXOut(forward, distance)
|
||||
|
||||
/**
|
||||
* md.sys.motion.easing.emphasized: the two-segment path M3 uses for large transitions that
|
||||
* begin and end on screen. A single cubic only approximates it, so this follows the path itself.
|
||||
*/
|
||||
val Emphasized: Easing = run {
|
||||
val path = Path().apply {
|
||||
moveTo(0f, 0f)
|
||||
cubicTo(0.05f, 0f, 0.133333f, 0.06f, 0.166666f, 0.4f)
|
||||
cubicTo(0.208333f, 0.82f, 0.25f, 1f, 1f, 1f)
|
||||
}
|
||||
val interpolator = PathInterpolator(path)
|
||||
Easing { interpolator.getInterpolation(it) }
|
||||
}
|
||||
|
||||
/** md.sys.motion.duration tokens used here, in milliseconds. */
|
||||
object MotionDuration {
|
||||
const val Medium4 = 400
|
||||
const val Long2 = 500
|
||||
}
|
||||
|
||||
/** M3 emphasized easing for components entering the screen: fast start, long gentle stop. */
|
||||
val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
|
||||
|
||||
/** M3 emphasized easing for components leaving: they speed up and come to rest just off screen. */
|
||||
val EmphasizedAccelerate = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f)
|
||||
|
||||
/** Between sibling destinations: the old one fades out, the new one fades and grows in. */
|
||||
fun materialFadeThrough(): ContentTransform =
|
||||
(fadeIn(tween(400, easing = EmphasizedDecelerate)) + scaleIn(tween(400, easing = EmphasizedDecelerate), initialScale = 0.92f)) togetherWith
|
||||
fadeOut(tween(200, easing = EmphasizedAccelerate))
|
||||
@@ -8,6 +8,16 @@ import androidx.compose.material.icons.rounded.SwapHoriz
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.layout
|
||||
import kotlin.math.roundToInt
|
||||
import java.time.LocalDate
|
||||
|
||||
/** Ways a screen can send the student elsewhere in the app. */
|
||||
@@ -18,14 +28,35 @@ class AppNav(
|
||||
val openWeek: () -> Unit,
|
||||
val openSubjects: () -> Unit,
|
||||
val openSearch: () -> Unit,
|
||||
val openPlans: () -> Unit,
|
||||
val openCalendar: () -> Unit,
|
||||
/** Opens the side menu on phones; null when the rail is always on screen. */
|
||||
val openMenu: (() -> Unit)?,
|
||||
)
|
||||
|
||||
/** The hamburger of a top-level screen's app bar. */
|
||||
/** How far the side menu is open, 0 to 1; read in the draw phase only. */
|
||||
val LocalMenuProgress = staticCompositionLocalOf<State<Float>> { mutableFloatStateOf(0f) }
|
||||
|
||||
/** The hamburger of a top-level screen's app bar; it fades away and the title takes its place while the menu is open. */
|
||||
@Composable
|
||||
fun MenuButton(nav: AppNav) {
|
||||
val progress = LocalMenuProgress.current
|
||||
// Recomposes when the menu starts or stops moving, not on every frame.
|
||||
val resting by remember(progress) { derivedStateOf { progress.value == 0f } }
|
||||
nav.openMenu?.let { open ->
|
||||
IconButton(onClick = open) { Icon(Icons.Rounded.Menu, contentDescription = "Открыть меню") }
|
||||
// Taps are ignored while the menu moves, but the icon keeps its colour: a greyed-out
|
||||
// flash on every close would read as a broken button.
|
||||
IconButton(
|
||||
onClick = { if (resting) open() },
|
||||
// Fades and gives its width back, so the title slides into its place.
|
||||
modifier = Modifier
|
||||
.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
// The expressive spring overshoots past 1; a width must never go negative.
|
||||
val width = (placeable.width * (1f - progress.value.coerceIn(0f, 1f))).roundToInt()
|
||||
layout(width, placeable.height) { placeable.place(0, 0) }
|
||||
}
|
||||
.graphicsLayer { alpha = 1f - progress.value.coerceIn(0f, 1f) },
|
||||
) { Icon(Icons.Rounded.Menu, contentDescription = "Открыть меню") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package ritm.schedule.ui
|
||||
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material.icons.rounded.CalendarToday
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -53,6 +60,7 @@ import ritm.schedule.data.nowTime
|
||||
import ritm.schedule.data.parseDate
|
||||
import ritm.schedule.data.shortWeekday
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.data.weekdayName
|
||||
import java.time.LocalDate
|
||||
|
||||
/** Tasks and notes of the open group, for cards deep in the tree. */
|
||||
@@ -70,11 +78,15 @@ fun List<Note>.open(): List<Note> = filter { !it.task || !it.done }
|
||||
fun Note.overdue(): Boolean = task && !done && parseDate(date) < today()
|
||||
|
||||
/** "к сегодня, 11:45", "к завтра, 10:05", "к пн, 19 октября". */
|
||||
/** A plan not pinned to any lesson: just a day. */
|
||||
val Note.general: Boolean get() = subject.isBlank()
|
||||
|
||||
fun Note.dueLabel(): String {
|
||||
val d = parseDate(date)
|
||||
val at = if (start.isNotBlank()) ", $start" else ""
|
||||
val day = when (d) {
|
||||
today() -> "сегодня, $start"
|
||||
today().plusDays(1) -> "завтра, $start"
|
||||
today() -> "сегодня$at"
|
||||
today().plusDays(1) -> "завтра$at"
|
||||
else -> d.shortWeekday().lowercase() + ", " + d.longLabel()
|
||||
}
|
||||
return if (task) "к $day" else day
|
||||
@@ -87,7 +99,7 @@ fun MainViewModel.nextOccurrence(t: Timetable, subject: String, after: LocalDate
|
||||
.map { parseDate(it.date) }
|
||||
.filter { it >= after }
|
||||
.flatMap { d ->
|
||||
repository.lessonsOn(t, d, subgroup)
|
||||
repository.lessonsOn(t, d, subgroup, hidden)
|
||||
.filter { subjectKey(it.subject) == key && (d > after || it.start > start) }
|
||||
.sortedBy { it.start }
|
||||
.map { d to it }
|
||||
@@ -97,12 +109,13 @@ fun MainViewModel.nextOccurrence(t: Timetable, subject: String, after: LocalDate
|
||||
|
||||
/** A task with its checkbox or a note with its icon; tap opens the editor. */
|
||||
@Composable
|
||||
fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject: Boolean) {
|
||||
fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject: Boolean, showDue: Boolean = true) {
|
||||
val overdue = note.overdue()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable(onClickLabel = "Изменить", role = Role.Button, onClick = onClick)
|
||||
.padding(start = 8.dp, end = 16.dp, top = 2.dp, bottom = 2.dp),
|
||||
) {
|
||||
@@ -121,14 +134,19 @@ fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject:
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (showSubject) {
|
||||
if (showSubject && !note.general) {
|
||||
SubjectDot(note.subject, 8.dp)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Text(
|
||||
listOfNotNull(
|
||||
if (showSubject) note.subject else null,
|
||||
if (overdue) "просрочено" else note.dueLabel(),
|
||||
if (showSubject) note.subject.ifBlank { "Общий план" } else null,
|
||||
when {
|
||||
overdue -> "просрочено"
|
||||
showDue -> note.dueLabel()
|
||||
note.start.isNotBlank() -> "к ${note.start}"
|
||||
else -> null
|
||||
},
|
||||
).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (overdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -160,13 +178,25 @@ fun NoteEditor(
|
||||
var task by rememberSaveable { mutableStateOf(editing?.task ?: true) }
|
||||
val started = target != null && (target.date < today() || (target.date == today() && target.start <= nowTime().toString()))
|
||||
var toNext by rememberSaveable { mutableStateOf(editing == null && started && target?.next != null) }
|
||||
// A general plan (not pinned to a lesson) picks its own day.
|
||||
val general = editing?.general ?: (target?.subject?.isBlank() == true)
|
||||
var day by rememberSaveable { mutableStateOf((editing?.date?.let(::parseDate) ?: target?.date ?: today()).toEpochDay()) }
|
||||
var picking by remember { mutableStateOf(false) }
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(if (task) Icons.AutoMirrored.Rounded.Assignment else Icons.Rounded.StickyNote2, contentDescription = null) },
|
||||
title = { Text(if (editing == null) (if (task) "Новое задание" else "Новая заметка") else (if (task) "Задание" else "Заметка")) },
|
||||
title = {
|
||||
Text(
|
||||
when {
|
||||
editing != null -> if (task) "Задание" else "Заметка"
|
||||
general -> if (task) "Новый общий план" else "Новая заметка на день"
|
||||
else -> if (task) "Новое задание" else "Новая заметка"
|
||||
},
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ConnectedChoice(
|
||||
@@ -182,6 +212,15 @@ fun NoteEditor(
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp).focusRequester(focus),
|
||||
)
|
||||
if (general) {
|
||||
Text("День", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
OutlinedButton(onClick = { picking = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Rounded.CalendarToday, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
val d = LocalDate.ofEpochDay(day)
|
||||
Text(d.weekdayName() + ", " + d.longLabel())
|
||||
}
|
||||
}
|
||||
if (editing == null && target?.next != null) {
|
||||
Text("К какому занятию", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
val (nextDate, nextLesson) = target.next
|
||||
@@ -209,7 +248,8 @@ fun NoteEditor(
|
||||
val (date, start) = if (toNext && t.next != null) t.next.first to t.next.second.start else t.date to t.start
|
||||
Note(group = groupKey, subject = t.subject, date = date.toString(), start = start, text = "")
|
||||
}
|
||||
onSave(base.copy(text = text.trim(), task = task))
|
||||
val dated = if (general) base.copy(date = LocalDate.ofEpochDay(day).toString()) else base
|
||||
onSave(dated.copy(text = text.trim(), task = task))
|
||||
},
|
||||
) { Text("Сохранить") }
|
||||
},
|
||||
@@ -222,6 +262,19 @@ fun NoteEditor(
|
||||
}
|
||||
},
|
||||
)
|
||||
if (picking) {
|
||||
val state = rememberDatePickerState(initialSelectedDateMillis = LocalDate.ofEpochDay(day).toEpochDay() * 86_400_000L)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { picking = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
state.selectedDateMillis?.let { day = it / 86_400_000L }
|
||||
picking = false
|
||||
}) { Text("Выбрать") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { picking = false }) { Text("Отмена") } },
|
||||
) { DatePicker(state) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The lesson sheet's section: this lesson's tasks and notes and a button to add one. */
|
||||
|
||||
485
android/app/src/main/kotlin/ritm/schedule/ui/PlansScreen.kt
Normal file
485
android/app/src/main/kotlin/ritm/schedule/ui/PlansScreen.kt
Normal file
@@ -0,0 +1,485 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
|
||||
package ritm.schedule.ui
|
||||
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.material.icons.rounded.EventNote
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Checklist
|
||||
import androidx.compose.material.icons.rounded.Today
|
||||
import androidx.compose.material.icons.rounded.Tune
|
||||
import androidx.compose.material.icons.rounded.ViewAgenda
|
||||
import androidx.compose.material.icons.rounded.ViewColumn
|
||||
import androidx.compose.material.icons.rounded.ViewList
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.Note
|
||||
import ritm.schedule.data.PlansLayout
|
||||
import ritm.schedule.data.PlansPrefs
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.longLabel
|
||||
import ritm.schedule.data.monday
|
||||
import ritm.schedule.data.parseDate
|
||||
import ritm.schedule.data.plural
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.data.weekdayName
|
||||
import ritm.schedule.ui.theme.card
|
||||
import ritm.schedule.ui.theme.page
|
||||
import java.time.LocalDate
|
||||
|
||||
/** "Сегодня • Суббота • 17 октября", "Завтра • …" or "Понедельник • 19 октября". */
|
||||
private fun dayTitle(d: LocalDate): String = when (d) {
|
||||
today() -> "Сегодня • ${d.weekdayName()} • ${d.longLabel()}"
|
||||
today().plusDays(1) -> "Завтра • ${d.weekdayName()} • ${d.longLabel()}"
|
||||
else -> "${d.weekdayName()} • ${d.longLabel()}"
|
||||
}
|
||||
|
||||
/** One line of the plans list: sticky day headers, the day's lessons, its tasks and notes. */
|
||||
private sealed interface Row {
|
||||
val date: LocalDate?
|
||||
data class Header(override val date: LocalDate?, val title: String, val empty: Boolean, val overdue: Boolean) : Row
|
||||
data class Lessons(override val date: LocalDate, val slots: List<Slot>) : Row
|
||||
data class Item(override val date: LocalDate?, val note: Note) : Row
|
||||
data class Divider(override val date: LocalDate?) : Row
|
||||
}
|
||||
|
||||
/**
|
||||
* Plans: what has to be done, day by day — homework and notes pinned to lessons, overdue
|
||||
* tasks first. Three layouts as in School Planner: an agenda with a week strip, a compact
|
||||
* list of busy days, and a board of day columns.
|
||||
*/
|
||||
@Composable
|
||||
fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
||||
val all by vm.notes.collectAsStateWithLifecycle()
|
||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||
val prefs = settings?.plans ?: PlansPrefs()
|
||||
var editing by remember { mutableStateOf<Note?>(null) }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
var tuning by remember { mutableStateOf(false) }
|
||||
var target by remember { mutableStateOf<NoteTarget?>(null) }
|
||||
var date by remember { mutableStateOf(today()) }
|
||||
val list = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val notes = if (prefs.done) all else all.filterNot { it.task && it.done }
|
||||
val first = today()
|
||||
val last = timetable?.days?.lastOrNull()?.date?.let(::parseDate)?.takeIf { it >= first } ?: first.plusDays(13)
|
||||
val days = generateSequence(first) { it.plusDays(1) }.takeWhile { it <= last }.toList()
|
||||
val overdue = notes.filter { it.overdue() }.sortedWith(compareBy({ it.date }, { it.start }))
|
||||
val byDay = notes.filter { parseDate(it.date) >= first }.groupBy { it.date }
|
||||
.mapValues { (_, v) -> v.sortedWith(compareBy({ it.task && it.done }, { it.start })) }
|
||||
val open = all.count { (it.task && !it.done) || (!it.task && parseDate(it.date) >= first) }
|
||||
val lessonsOf: (LocalDate) -> List<Slot> = { d ->
|
||||
val t = timetable
|
||||
if (prefs.lessons && t != null) vm.slotsOn(t, d, settings?.subgroup) else emptyList()
|
||||
}
|
||||
val agenda = prefs.layout == PlansLayout.Agenda
|
||||
val week = timetable?.days?.firstOrNull { it.date == date.monday().toString() }?.week
|
||||
// Built once per data change, not on every day the strip passes while the list scrolls.
|
||||
val rows = remember(all, timetable, prefs, settings?.subgroup, vm.hidden) {
|
||||
if (prefs.layout == PlansLayout.Board) emptyList() else rows(days, byDay, overdue, lessonsOf, agenda)
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.background(if (agenda) weekTint(week) else MaterialTheme.colorScheme.page)) {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
navigationIcon = { MenuButton(nav) },
|
||||
title = {
|
||||
Column {
|
||||
Text("Планы", style = MaterialTheme.typography.titleLargeEmphasized)
|
||||
Text(
|
||||
if (open == 0) "Всё сделано" else "$open ${plural(open, "дело", "дела", "дел")} впереди",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (agenda && date != today()) IconButton(onClick = {
|
||||
date = today()
|
||||
scope.launch { list.scrollToItem(0) }
|
||||
}) { Icon(Icons.Rounded.Today, contentDescription = "Сегодня") }
|
||||
IconButton(onClick = { tuning = true }) { Icon(Icons.Rounded.Tune, contentDescription = "Вид планов") }
|
||||
},
|
||||
)
|
||||
if (agenda) SearchWeekStrip(timetable, date) { d ->
|
||||
date = d
|
||||
val index = rows.indexOfFirst { it is Row.Header && it.date == d }
|
||||
if (index >= 0) scope.launch { list.scrollToItem(index) }
|
||||
}
|
||||
}
|
||||
when (prefs.layout) {
|
||||
PlansLayout.Board -> Board(days, byDay, overdue, lessonsOf, vm::toggleNote, onEdit = { editing = it })
|
||||
else -> {
|
||||
// The strip follows the list: the day at the top of the list is the selected one.
|
||||
if (agenda) LaunchedEffect(rows) {
|
||||
snapshotFlow { list.firstVisibleItemIndex }.collect { i ->
|
||||
rows.getOrNull(i)?.date?.let { if (it != date) date = it }
|
||||
}
|
||||
}
|
||||
PlanList(list, rows, vm::toggleNote, onEdit = { editing = it })
|
||||
}
|
||||
}
|
||||
}
|
||||
FloatingActionButton(
|
||||
onClick = { adding = true },
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp + navBarPadding()),
|
||||
) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание") }
|
||||
}
|
||||
|
||||
if (tuning) ModalBottomSheet(onDismissRequest = { tuning = false }) { PlansSettings(prefs, vm::setPlans) }
|
||||
val group = vm.currentGroupKey()
|
||||
if (adding) ModalBottomSheet(onDismissRequest = { adding = false }) {
|
||||
AddPlanSheet(vm, timetable, settings?.subgroup, day = null) {
|
||||
adding = false
|
||||
target = it
|
||||
}
|
||||
}
|
||||
if (group != null && (target != null || editing != null)) NoteEditor(
|
||||
target = target,
|
||||
editing = editing,
|
||||
groupKey = editing?.group ?: group,
|
||||
onSave = { vm.saveNote(it); target = null; editing = null },
|
||||
onDelete = { vm.deleteNote(it.id); editing = null },
|
||||
onDismiss = { target = null; editing = null },
|
||||
)
|
||||
}
|
||||
|
||||
/** Agenda: every day, empty ones as a grey header. List: only days with plans or lessons. */
|
||||
private fun rows(
|
||||
days: List<LocalDate>,
|
||||
byDay: Map<String, List<Note>>,
|
||||
overdue: List<Note>,
|
||||
lessonsOf: (LocalDate) -> List<Slot>,
|
||||
agenda: Boolean,
|
||||
): List<Row> = buildList {
|
||||
if (overdue.isNotEmpty()) {
|
||||
add(Row.Header(null, "Просрочено", empty = false, overdue = true))
|
||||
overdue.forEach { add(Row.Item(null, it)) }
|
||||
add(Row.Divider(null))
|
||||
}
|
||||
days.forEach { d ->
|
||||
val notes = byDay[d.toString()].orEmpty()
|
||||
// Every layout shows the day's lessons alike; the list only drops days with nothing at all.
|
||||
val slots = lessonsOf(d)
|
||||
if (!agenda && notes.isEmpty() && slots.isEmpty()) return@forEach
|
||||
add(Row.Header(d, dayTitle(d), empty = notes.isEmpty() && slots.isEmpty(), overdue = false))
|
||||
if (slots.isNotEmpty()) add(Row.Lessons(d, slots))
|
||||
notes.forEach { add(Row.Item(d, it)) }
|
||||
add(Row.Divider(d))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlanList(list: LazyListState, rows: List<Row>, onToggle: (String) -> Unit, onEdit: (Note) -> Unit) {
|
||||
if (rows.isEmpty()) {
|
||||
Empty("Планов нет", "Задания и заметки к парам появятся здесь. Добавьте первое кнопкой «+».", icon = Icons.Rounded.Checklist)
|
||||
return
|
||||
}
|
||||
LazyColumn(
|
||||
state = list,
|
||||
contentPadding = PaddingValues(bottom = 96.dp + navBarPadding()),
|
||||
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
rows.forEachIndexed { i, row ->
|
||||
when (row) {
|
||||
is Row.Header -> stickyHeader(key = "h$i") { DayHeader(row) }
|
||||
is Row.Lessons -> item(key = "l$i") { LessonsSummary(row.slots, Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) }
|
||||
is Row.Item -> item(key = "n${row.note.id}") {
|
||||
NoteRow(row.note, onToggle = { onToggle(row.note.id) }, onClick = { onEdit(row.note) }, showSubject = true, showDue = row.date == null)
|
||||
}
|
||||
is Row.Divider -> item(key = "d$i") {
|
||||
HorizontalDivider(Modifier.padding(horizontal = 20.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A sticky day header: opaque, so the list scrolls under it. */
|
||||
@Composable
|
||||
private fun DayHeader(h: Row.Header) {
|
||||
Text(
|
||||
h.title,
|
||||
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||
color = when {
|
||||
h.overdue -> MaterialTheme.colorScheme.error
|
||||
h.empty -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(start = 20.dp, end = 20.dp, top = 14.dp, bottom = 10.dp)
|
||||
.semantics { heading() },
|
||||
)
|
||||
}
|
||||
|
||||
/** The day's lessons in a compact block: "9:00–10:30 ▍Programming". */
|
||||
@Composable
|
||||
private fun LessonsSummary(slots: List<Slot>, modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
Surface(shape = MaterialTheme.shapes.large, color = color, modifier = modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
slots.forEach { slot ->
|
||||
slot.lessons.forEach { l ->
|
||||
androidx.compose.foundation.layout.Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.width(3.dp).height(14.dp).background(subjectColor(l.subject).container, CircleShape))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"${slot.start}–${slot.end}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(l.subject, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Board: a column per day, swiped sideways; the next day's column peeks in from the end. */
|
||||
@Composable
|
||||
private fun Board(
|
||||
days: List<LocalDate>,
|
||||
byDay: Map<String, List<Note>>,
|
||||
overdue: List<Note>,
|
||||
lessonsOf: (LocalDate) -> List<Slot>,
|
||||
onToggle: (String) -> Unit,
|
||||
onEdit: (Note) -> Unit,
|
||||
) {
|
||||
val columns: List<LocalDate?> = (if (overdue.isNotEmpty()) listOf<LocalDate?>(null) else emptyList()) + days
|
||||
val pager: PagerState = rememberPagerState { columns.size }
|
||||
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.page)) {
|
||||
HorizontalPager(
|
||||
state = pager,
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 48.dp),
|
||||
pageSpacing = 12.dp,
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { page ->
|
||||
val d = columns[page]
|
||||
val notes = if (d == null) overdue else byDay[d.toString()].orEmpty()
|
||||
val slots = d?.let(lessonsOf).orEmpty()
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = 8.dp, bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
if (d == null) "Просрочено" else "${d.longLabel()} • ${d.weekdayName()}" + if (d == today()) " • Сегодня" else "",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = if (d == null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 4.dp).semantics { heading() },
|
||||
)
|
||||
if (slots.isNotEmpty()) LessonsSummary(slots, color = MaterialTheme.colorScheme.card)
|
||||
if (notes.isEmpty()) Text(
|
||||
"Ничего не запланировано",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 4.dp),
|
||||
)
|
||||
notes.forEach { n ->
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.card,
|
||||
modifier = Modifier.fillMaxWidth().border(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.large),
|
||||
) {
|
||||
NoteRow(n, onToggle = { onToggle(n.id) }, onClick = { onEdit(n) }, showSubject = true, showDue = d == null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Page dots, as School Planner shows under its board.
|
||||
androidx.compose.foundation.layout.Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp, bottom = 16.dp + navBarPadding()),
|
||||
) {
|
||||
val shown = pager.currentPage
|
||||
val range = (shown - 3).coerceAtLeast(0)..(shown + 3).coerceAtMost(columns.lastIndex)
|
||||
range.forEach { i ->
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = if (i == shown) 16.dp else 6.dp, height = 6.dp)
|
||||
.background(
|
||||
if (i == shown) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
|
||||
CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** School Planner's plan settings: layout, the day's lessons, ticked-off tasks. */
|
||||
@Composable
|
||||
private fun PlansSettings(prefs: PlansPrefs, onChange: (PlansPrefs) -> Unit) {
|
||||
Column(
|
||||
Modifier.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Heading("Вид планов", style = MaterialTheme.typography.headlineSmallEmphasized)
|
||||
Text("Макет", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
androidx.compose.foundation.layout.Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
LayoutTile(Icons.Rounded.ViewAgenda, "Повестка дня", prefs.layout == PlansLayout.Agenda, Modifier.weight(1f)) {
|
||||
onChange(prefs.copy(layout = PlansLayout.Agenda))
|
||||
}
|
||||
LayoutTile(Icons.Rounded.ViewList, "Список", prefs.layout == PlansLayout.List, Modifier.weight(1f)) {
|
||||
onChange(prefs.copy(layout = PlansLayout.List))
|
||||
}
|
||||
LayoutTile(Icons.Rounded.ViewColumn, "Доска", prefs.layout == PlansLayout.Board, Modifier.weight(1f)) {
|
||||
onChange(prefs.copy(layout = PlansLayout.Board))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
when (prefs.layout) {
|
||||
PlansLayout.Agenda -> "Все дни подряд с полосой недели — видно и занятые, и свободные дни."
|
||||
PlansLayout.List -> "Без полосы недели и без пустых дней — только дни с парами или планами."
|
||||
PlansLayout.Board -> "Колонка на каждый день, листаются в сторону."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SettingSwitch("Пары дня", "Короткий список пар над планами дня", prefs.lessons) { onChange(prefs.copy(lessons = it)) }
|
||||
SettingSwitch("Выполненные", "Отмеченные задания остаются в списке зачёркнутыми", prefs.done) { onChange(prefs.copy(done = it)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LayoutTile(icon: ImageVector, label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainer,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
border = if (selected) androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
modifier = modifier.clip(MaterialTheme.shapes.large).selectable(selected = selected, role = Role.RadioButton, onClick = onClick),
|
||||
) {
|
||||
Column(Modifier.padding(vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(label, style = MaterialTheme.typography.labelLarge, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingSwitch(title: String, text: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||
androidx.compose.foundation.layout.Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).toggleable(value = checked, role = Role.Switch, onValueChange = onChange),
|
||||
) {
|
||||
Column(Modifier.weight(1f).padding(end = 16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a new plan belongs to: a general plan for a day, or one of the coming lessons —
|
||||
* of [day] only when given (the calendar), otherwise of the next two weeks.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddPlanSheet(vm: MainViewModel, t: Timetable?, subgroup: String?, day: LocalDate?, onPick: (NoteTarget) -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Heading("Новый план", style = MaterialTheme.typography.headlineSmallEmphasized)
|
||||
Surface(
|
||||
onClick = { onPick(NoteTarget("", day ?: today(), "", next = null)) },
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
androidx.compose.foundation.layout.Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.EventNote, contentDescription = null)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column {
|
||||
Text("Общий план", style = MaterialTheme.typography.titleSmallEmphasized)
|
||||
Text("Без привязки к паре — на любой день", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
val days = if (t == null) emptyList() else (if (day != null) listOf(day) else (0..13L).map { today().plusDays(it) })
|
||||
.map { it to vm.slotsOn(t, it, subgroup) }.filter { it.second.isNotEmpty() }
|
||||
if (days.isNotEmpty()) Text(
|
||||
"Или к занятию",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
days.forEach { (d, slots) ->
|
||||
Text(dayTitle(d), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp))
|
||||
slots.forEach { slot ->
|
||||
slot.lessons.forEach { l -> CompactLesson(l, slot, onClick = { onPick(NoteTarget(l.subject, d, l.start, next = null)) }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
package ritm.schedule.ui
|
||||
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import ritm.schedule.data.hideKey
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.abs
|
||||
@@ -139,12 +145,12 @@ fun ScheduleScreen(
|
||||
icon = Icons.Rounded.CloudOff,
|
||||
action = "Выбрать группу" to nav.openGroups,
|
||||
)
|
||||
else -> WordRows(t, subgroup, onOpen = { details = it to null })
|
||||
else -> WordRows(t, subgroup, settings?.hidden.orEmpty(), onOpen = { details = it to null })
|
||||
}
|
||||
}
|
||||
details?.let { (lesson, date) ->
|
||||
ModalBottomSheet(onDismissRequest = { details = null }) {
|
||||
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm)
|
||||
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm, onClose = { details = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,8 +231,14 @@ private fun Planner(
|
||||
val index = mondays.indexOf(date.monday())
|
||||
if (index >= 0 && index != strip.currentPage) strip.animateScrollToPage(index)
|
||||
}
|
||||
// Only a finger swipe on the strip moves the days; following the day pager must not.
|
||||
var swiped by remember { mutableStateOf(false) }
|
||||
val dragged by strip.interactionSource.collectIsDraggedAsState()
|
||||
LaunchedEffect(dragged) { if (dragged) swiped = true }
|
||||
LaunchedEffect(strip) {
|
||||
snapshotFlow { strip.settledPage }.collect { page ->
|
||||
if (!swiped) return@collect
|
||||
swiped = false
|
||||
val shown = dates.getOrElse(pager.currentPage) { today() }
|
||||
val monday = mondays[page]
|
||||
if (monday != shown.monday()) {
|
||||
@@ -290,7 +302,7 @@ private fun Planner(
|
||||
DayStrip(
|
||||
monday = monday,
|
||||
selected = date,
|
||||
hasLessons = { d -> vm.repository.lessonsOn(t, d, subgroup).isNotEmpty() },
|
||||
hasLessons = { d -> vm.slotsOn(t, d, subgroup).isNotEmpty() },
|
||||
enabled = { d -> dates.contains(d) },
|
||||
onSelect = ::go,
|
||||
onWeek = ::week,
|
||||
@@ -403,7 +415,7 @@ fun LessonCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
time?.let { Text(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
|
||||
time?.let { TimeText(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно")
|
||||
else if (show.rooms && lesson.rooms.isNotEmpty()) Meta(
|
||||
@@ -418,11 +430,15 @@ fun LessonCard(
|
||||
if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null,
|
||||
if (lesson.groups.size > 1) "общее занятие" else null,
|
||||
)
|
||||
if (tags.isNotEmpty() || lesson.uncertain) FlowRow(
|
||||
val mark = date?.let { LocalMarks.current.forLesson(lesson.subject, it, lesson.start) }
|
||||
if (tags.isNotEmpty() || lesson.uncertain || mark != null) FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
) {
|
||||
mark?.let {
|
||||
SoftTag(if (it.present) "был" else "пропуск", color, icon = if (it.present) Icons.Rounded.Check else Icons.Rounded.Close)
|
||||
}
|
||||
tags.forEach { SoftTag(it, color) }
|
||||
if (lesson.uncertain) Tag("Сверьте недели", warning = true)
|
||||
}
|
||||
@@ -450,9 +466,18 @@ fun LessonCard(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SoftTag(text: String, color: SubjectColor) {
|
||||
private fun SoftTag(text: String, color: SubjectColor, icon: ImageVector? = null) {
|
||||
Surface(shape = MaterialTheme.shapes.small, color = color.onSoft.copy(alpha = 0.12f), contentColor = color.onSoft) {
|
||||
Text(text.replaceFirstChar { it.titlecase() }, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = if (icon != null) 6.dp else 8.dp, end = 8.dp, top = 3.dp, bottom = 3.dp),
|
||||
) {
|
||||
icon?.let {
|
||||
Icon(it, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Text(text.replaceFirstChar { it.titlecase() }, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +516,13 @@ private fun Tag(text: String, warning: Boolean = false) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LessonDetails(lesson: Lesson, bells: List<Bell>, date: LocalDate? = null, vm: MainViewModel? = null) {
|
||||
internal fun LessonDetails(
|
||||
lesson: Lesson,
|
||||
bells: List<Bell>,
|
||||
date: LocalDate? = null,
|
||||
vm: MainViewModel? = null,
|
||||
onClose: () -> Unit = {},
|
||||
) {
|
||||
val bell = bells.firstOrNull { it.start == lesson.start }
|
||||
Column(
|
||||
Modifier
|
||||
@@ -524,11 +555,27 @@ internal fun LessonDetails(lesson: Lesson, bells: List<Bell>, date: LocalDate? =
|
||||
offline = false,
|
||||
onClose = null,
|
||||
)
|
||||
if (date != null && vm != null) LessonNotes(vm, lesson, date)
|
||||
if (date != null && vm != null) {
|
||||
LessonAttendance(vm, lesson, date)
|
||||
LessonNotes(vm, lesson, date)
|
||||
}
|
||||
Heading("Исходная запись", style = MaterialTheme.typography.titleSmallEmphasized)
|
||||
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
|
||||
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())
|
||||
}
|
||||
if (vm != null) {
|
||||
OutlinedButton(onClick = { vm.setHidden(lesson.hideKey, true); onClose() }) {
|
||||
Icon(Icons.Rounded.VisibilityOff, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Скрыть такие занятия")
|
||||
}
|
||||
Text(
|
||||
"«${lesson.subject}»${lesson.type.takeIf { it.isNotEmpty() }?.let { " · ${it.lowercase()}" }.orEmpty()} пропадёт из расписания, " +
|
||||
"обзора, виджета и напоминаний во все недели. Вернуть можно в настройках.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,7 +594,7 @@ private fun Detail(icon: androidx.compose.ui.graphics.vector.ImageVector, label:
|
||||
|
||||
/** When the week table has no semester for this schedule: the Word rows by weekday. */
|
||||
@Composable
|
||||
private fun WordRows(t: Timetable, subgroup: String?, onOpen: (Lesson) -> Unit) {
|
||||
private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpen: (Lesson) -> Unit) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 16.dp + navBarPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
@@ -559,6 +606,7 @@ private fun WordRows(t: Timetable, subgroup: String?, onOpen: (Lesson) -> Unit)
|
||||
Heading(dayName(day.day), style = MaterialTheme.typography.headlineSmallEmphasized)
|
||||
}
|
||||
val lessons = day.lessons.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
|
||||
.filter { it.hideKey !in hidden }
|
||||
items(lessons, key = { "${day.day}-${it.key}" }) { lesson ->
|
||||
LessonCard(lesson, time = lesson.start + (lesson.end?.let { " → $it" } ?: ""), onClick = { onOpen(lesson) })
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
package ritm.schedule.ui
|
||||
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -77,7 +78,6 @@ import ritm.schedule.data.attempt
|
||||
import ritm.schedule.data.Coverage
|
||||
import ritm.schedule.data.DirectoryDay
|
||||
import ritm.schedule.data.DirectorySearch
|
||||
import ritm.schedule.data.FreeRooms
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.longLabel
|
||||
import ritm.schedule.data.monday
|
||||
@@ -92,28 +92,23 @@ private sealed interface Target {
|
||||
data class Room(val room: String) : Target
|
||||
}
|
||||
|
||||
private fun startDate(): LocalDate = if (today().dayOfWeek.value == 7) today().plusDays(1) else today()
|
||||
internal fun startDate(): LocalDate = if (today().dayOfWeek.value == 7) today().plusDays(1) else today()
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var target by remember { mutableStateOf<Target?>(null) }
|
||||
var date by remember { mutableStateOf(startDate()) }
|
||||
var bells by remember { mutableStateOf(timetable?.bells.orEmpty()) }
|
||||
var pair by remember { mutableIntStateOf(1) }
|
||||
var hits by remember { mutableStateOf<DirectorySearch?>(null) }
|
||||
var day by remember { mutableStateOf<DirectoryDay?>(null) }
|
||||
var free by remember { mutableStateOf<FreeRooms?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (bells.isEmpty()) attempt { vm.api.calendar() }.onSuccess { bells = it.bells }
|
||||
val now = nowTime()
|
||||
pair = bells.firstOrNull { parseTime(it.end).isAfter(now) }?.pair ?: bells.firstOrNull()?.pair ?: 1
|
||||
}
|
||||
LaunchedEffect(query) {
|
||||
if (query.trim().length < 2) { hits = null; return@LaunchedEffect }
|
||||
@@ -131,18 +126,11 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
}.onSuccess { day = it; error = null }.onFailure { error = it.message }
|
||||
loading = false
|
||||
}
|
||||
LaunchedEffect(tab, date, pair) {
|
||||
if (tab != 1) return@LaunchedEffect
|
||||
loading = true
|
||||
attempt { vm.api.freeRooms(date.toString(), pair) }
|
||||
.onSuccess { free = it; error = null }.onFailure { error = it.message }
|
||||
loading = false
|
||||
}
|
||||
|
||||
// Back from a teacher's or room's day returns to the search results.
|
||||
BackHandler(enabled = target != null) { target = null; day = null }
|
||||
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val dated = target != null || tab == 1
|
||||
val dated = target != null
|
||||
val week = timetable?.days?.firstOrNull { it.date == date.monday().toString() }?.week
|
||||
// Undated search tints its header once results scroll under it; dated views carry the week's colour.
|
||||
val scrolledTint by animateColorAsState(
|
||||
@@ -193,13 +181,6 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
}
|
||||
},
|
||||
)
|
||||
if (target == null) ConnectedChoice(
|
||||
options = listOf(0 to "Кто где", 1 to "Свободные"),
|
||||
selected = tab,
|
||||
onSelect = { tab = it },
|
||||
modifier = Modifier.widthIn(max = MAX_CONTENT).fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
if (dated) SearchWeekStrip(timetable, date) { date = it }
|
||||
}
|
||||
Box(Modifier.fillMaxSize().background(if (target != null) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.page)) {
|
||||
@@ -211,7 +192,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
) {
|
||||
error?.let { item { Banner(it, offline = true, onClose = null) } }
|
||||
|
||||
if (tab == 0 && target == null) {
|
||||
if (target == null) {
|
||||
item { SearchField(query, { query = it }, "Фамилия или номер аудитории") }
|
||||
val h = hits
|
||||
if (h == null) item {
|
||||
@@ -276,48 +257,10 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
onClick = null,
|
||||
)
|
||||
}
|
||||
item { CoverageNote(d.coverage, free = false) }
|
||||
item { CoverageNote(d.coverage) }
|
||||
}
|
||||
}
|
||||
|
||||
if (tab == 1 && target == null) {
|
||||
item {
|
||||
ListCard(Icons.Rounded.Schedule, "Пара") {
|
||||
Box(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp)) { PairPicker(bells, pair) { pair = it } }
|
||||
}
|
||||
}
|
||||
if (loading && free == null) item { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() } }
|
||||
free?.let { f ->
|
||||
item {
|
||||
ListCard(Icons.Rounded.MeetingRoom, "Свободно ${f.freeCount} из ${f.knownRooms}") {
|
||||
Text(
|
||||
"${f.bell.pair} пара, ${f.bell.start}–${f.bell.end} · занято ${f.busyCount}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 64.dp, end = 20.dp, bottom = 8.dp),
|
||||
)
|
||||
f.free.forEachIndexed { index, g ->
|
||||
if (index > 0) HorizontalDivider(
|
||||
Modifier.padding(horizontal = 20.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
if (g.letter.isEmpty()) "Номера без буквы" else "Номера с «${g.letter}»",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
RoomChips(g.rooms) { room -> tab = 0; target = Target.Room(room) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item { CoverageNote(f.coverage, free = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,7 +269,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
|
||||
/** The calm day strip as a pager of weeks around today; each page wears its week's colour. */
|
||||
@Composable
|
||||
private fun SearchWeekStrip(timetable: ritm.schedule.data.Timetable?, date: LocalDate, onDate: (LocalDate) -> Unit) {
|
||||
internal fun SearchWeekStrip(timetable: ritm.schedule.data.Timetable?, date: LocalDate, onDate: (LocalDate) -> Unit) {
|
||||
val base = remember { startDate().monday() }
|
||||
fun mondayAt(page: Int): LocalDate = base.plusWeeks((page - SPAN).toLong())
|
||||
val pager = rememberPagerState(initialPage = SPAN + ChronoUnit.WEEKS.between(base, date.monday()).toInt()) { SPAN * 2 + 1 }
|
||||
@@ -335,8 +278,15 @@ private fun SearchWeekStrip(timetable: ritm.schedule.data.Timetable?, date: Loca
|
||||
val index = SPAN + ChronoUnit.WEEKS.between(base, date.monday()).toInt()
|
||||
if (index != pager.currentPage && index in 0 until pager.pageCount) pager.animateScrollToPage(index)
|
||||
}
|
||||
// Only a swipe by the finger picks a week. When the strip merely follows a scrolling list,
|
||||
// reporting its stop would scroll the list back and cut its fling short.
|
||||
var swiped by remember { mutableStateOf(false) }
|
||||
val dragged by pager.interactionSource.collectIsDraggedAsState()
|
||||
LaunchedEffect(dragged) { if (dragged) swiped = true }
|
||||
LaunchedEffect(pager) {
|
||||
snapshotFlow { pager.settledPage }.collect { page ->
|
||||
if (!swiped) return@collect
|
||||
swiped = false
|
||||
val monday = mondayAt(page)
|
||||
if (monday != current.monday()) onDate(monday.plusDays(current.dayOfWeek.value - 1L))
|
||||
}
|
||||
@@ -386,41 +336,12 @@ private fun RoomChips(rooms: List<String>, onClick: (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairPicker(bells: List<Bell>, pair: Int, onSelect: (Int) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
bells.chunked(4).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
row.forEach { b ->
|
||||
val on = b.pair == pair
|
||||
val corner by animateDpAsState(
|
||||
if (on) 12.dp else 20.dp,
|
||||
MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||
label = "pair-shape",
|
||||
)
|
||||
Surface(
|
||||
onClick = { onSelect(b.pair) },
|
||||
shape = RoundedCornerShape(corner),
|
||||
color = if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.page,
|
||||
contentColor = if (on) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Column(Modifier.padding(vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("${b.pair}", style = MaterialTheme.typography.titleMediumEmphasized)
|
||||
Text(b.start, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CoverageNote(c: Coverage, free: Boolean) {
|
||||
private fun CoverageNote(c: Coverage) {
|
||||
Text(
|
||||
"По расписаниям из Word: учтено ${c.documents} из ${c.total} документов каталога ${c.year}." +
|
||||
(if (free) " Аудитория может быть занята мероприятием или закрыта — расписание этого не знает." else ""),
|
||||
"",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
219
android/app/src/main/kotlin/ritm/schedule/ui/Session.kt
Normal file
219
android/app/src/main/kotlin/ritm/schedule/ui/Session.kt
Normal file
@@ -0,0 +1,219 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package ritm.schedule.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ritm.schedule.data.Exam
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.longLabel
|
||||
import ritm.schedule.data.parseDate
|
||||
import ritm.schedule.data.plural
|
||||
import ritm.schedule.data.shortWeekday
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.data.weekdayName
|
||||
import ritm.schedule.ui.theme.card
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/** Upcoming exams of the open group, soonest first; the cache of another group is ignored. */
|
||||
@Composable
|
||||
fun upcomingExams(vm: MainViewModel, t: Timetable): List<Exam> {
|
||||
val exams by vm.exams.collectAsStateWithLifecycle()
|
||||
val date = today()
|
||||
return exams?.takeIf { it.group == t.group }?.exams.orEmpty().filter { parseDate(it.date) >= date }
|
||||
}
|
||||
|
||||
private fun daysUntil(date: LocalDate) = ChronoUnit.DAYS.between(today(), date).toInt()
|
||||
|
||||
/** "через 12 дней", "завтра", "сегодня". */
|
||||
private fun countdownLabel(days: Int) = when (days) {
|
||||
0 -> "сегодня"
|
||||
1 -> "завтра"
|
||||
else -> "через $days ${plural(days, "день", "дня", "дней")}"
|
||||
}
|
||||
|
||||
/**
|
||||
* The session countdown of the overview: days to the next exam or credit, with the subject,
|
||||
* date and room. Before the university publishes the session it counts down to the end of
|
||||
* the semester instead, and says so.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionCard(vm: MainViewModel, t: Timetable, onOpen: () -> Unit) {
|
||||
val exams = upcomingExams(vm, t)
|
||||
val next = exams.firstOrNull { it.counts } ?: exams.firstOrNull()
|
||||
val semesterEnd = t.semester?.end?.let(::parseDate)?.takeIf { it >= today() }
|
||||
if (next == null && semesterEnd == null) return
|
||||
|
||||
val target = next?.let { parseDate(it.date) } ?: semesterEnd!!
|
||||
val days = daysUntil(target)
|
||||
val label = if (next != null) "Сессия · ${next.type.lowercase()} ${countdownLabel(days)}"
|
||||
else "До конца семестра ${countdownLabel(days).removePrefix("через ")}"
|
||||
|
||||
Surface(
|
||||
onClick = onOpen,
|
||||
enabled = next != null,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.card,
|
||||
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {},
|
||||
) {
|
||||
Row(Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Countdown(days)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
if (next != null) {
|
||||
Text(next.subject, style = MaterialTheme.typography.titleMediumEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
examWhen(next),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (exams.size > 1) Text(
|
||||
"Ещё ${exams.size - 1} в сессии",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
} else {
|
||||
Text(t.semester?.label ?: "Семестр", style = MaterialTheme.typography.titleMediumEmphasized)
|
||||
Text(
|
||||
"Учёба до ${semesterEnd!!.longLabel()}. Даты экзаменов появятся здесь, когда университет опубликует расписание сессии.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The big number on an expressive cookie shape; "!" on the day itself. */
|
||||
@Composable
|
||||
private fun Countdown(days: Int) {
|
||||
val urgent = days <= 3
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(84.dp)
|
||||
.background(
|
||||
if (urgent) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.primaryContainer,
|
||||
MaterialShapes.Cookie9Sided.toShape(),
|
||||
)
|
||||
.clearAndSetSemantics {},
|
||||
) {
|
||||
val color = if (urgent) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onPrimaryContainer
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
when (days) { 0 -> "!"; else -> "$days" },
|
||||
style = MaterialTheme.typography.headlineMediumEmphasized,
|
||||
color = color,
|
||||
)
|
||||
Text(
|
||||
when (days) { 0 -> "сегодня"; 1 -> "день"; else -> plural(days, "день", "дня", "дней") },
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = color,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Пн, 12 января, 09:00 · ауд. 213А". */
|
||||
private fun examWhen(e: Exam): String {
|
||||
val d = parseDate(e.date)
|
||||
return d.shortWeekday() + ", " + d.longLabel() + ", " + e.start +
|
||||
(e.rooms.takeIf { it.isNotEmpty() }?.let { " · ауд. ${it.joinToString(", ")}" } ?: "")
|
||||
}
|
||||
|
||||
/** The whole session in date order, consultations included. */
|
||||
@Composable
|
||||
fun SessionSheet(exams: List<Exam>) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Heading("Сессия", style = MaterialTheme.typography.headlineSmallEmphasized)
|
||||
val exams1 = exams.count { it.counts }
|
||||
Text(
|
||||
"$exams1 ${plural(exams1, "экзамен или зачёт", "экзамена или зачёта", "экзаменов и зачётов")}" +
|
||||
(exams.size - exams1).takeIf { it > 0 }?.let { ", консультаций: $it" }.orEmpty(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
exams.forEach { e -> ExamRow(e) }
|
||||
Text(
|
||||
"Даты собраны из всех документов каталога по пометкам «экз.», «зач.» и «конс.». Сверяйтесь с документом Word.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExamRow(e: Exam) {
|
||||
val d = parseDate(e.date)
|
||||
val days = daysUntil(d)
|
||||
val strong = e.counts
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (strong) MaterialTheme.colorScheme.card else MaterialTheme.colorScheme.surfaceContainer,
|
||||
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {
|
||||
contentDescription = "${e.type}: ${e.subject}, ${d.weekdayName()}, ${d.longLabel()}, ${e.start}, ${countdownLabel(days)}"
|
||||
},
|
||||
) {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(48.dp)) {
|
||||
Text("${d.dayOfMonth}", style = MaterialTheme.typography.headlineSmallEmphasized)
|
||||
Text(d.shortWeekday(), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
e.type + " · " + countdownLabel(days),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (strong) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(e.subject, style = MaterialTheme.typography.titleSmallEmphasized)
|
||||
Text(
|
||||
listOfNotNull(
|
||||
e.start + (e.end?.let { "–$it" } ?: ""),
|
||||
e.rooms.takeIf { it.isNotEmpty() }?.let { "ауд. ${it.joinToString(", ")}" },
|
||||
e.teachers.takeIf { it.isNotEmpty() }?.joinToString(", "),
|
||||
).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
package ritm.schedule.ui
|
||||
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import ritm.schedule.ui.theme.card
|
||||
@@ -100,6 +101,20 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
SwitchRow("Пометки из Word", "Например, «3-13н.» — даты по ним уже рассчитаны", d.notes) {
|
||||
vm.setDisplay(d.copy(notes = it))
|
||||
}
|
||||
if (s.hidden.isNotEmpty()) {
|
||||
Text("Скрытые занятия", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
s.hidden.sorted().forEach { key ->
|
||||
val (subject, type) = key.split("|", limit = 2).let { it[0] to it.getOrElse(1) { "" } }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
subject.replaceFirstChar { it.uppercase() } + if (type.isNotEmpty()) " · $type" else "",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
androidx.compose.material3.TextButton(onClick = { vm.setHidden(key, false) }) { Text("Показать") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Полные сведения о занятии всегда открываются по нажатию на карточку.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -171,7 +186,8 @@ private fun SwitchRow(title: String, text: String, checked: Boolean, onChange: (
|
||||
// The whole row is the touch target, not only the switch.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().toggleable(value = checked, role = Role.Switch, onValueChange = onChange),
|
||||
// The press highlight follows the row's rounded shape instead of a hard rectangle.
|
||||
modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).toggleable(value = checked, role = Role.Switch, onValueChange = onChange),
|
||||
) {
|
||||
Column(Modifier.weight(1f).padding(end = 16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import ritm.schedule.data.Display
|
||||
import ritm.schedule.ui.theme.harmonizeWith
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.parseTime
|
||||
@@ -77,7 +78,23 @@ fun subjectColor(subject: String): SubjectColor {
|
||||
// Subjects of other groups (search results) still get a stable colour.
|
||||
val slot = LocalSubjectSlots.current[key] ?: key.hashCode().mod(LIGHT.size)
|
||||
val dark = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||
return (if (dark) DARK else LIGHT)[slot]
|
||||
return harmonized(dark, MaterialTheme.colorScheme.primary)[slot]
|
||||
}
|
||||
|
||||
// Harmonized palettes per theme and primary; the primary changes only with the theme or wallpaper.
|
||||
private val palettes = HashMap<Pair<Boolean, Color>, List<SubjectColor>>()
|
||||
|
||||
private fun harmonized(dark: Boolean, primary: Color): List<SubjectColor> = synchronized(palettes) {
|
||||
palettes.getOrPut(dark to primary) {
|
||||
(if (dark) DARK else LIGHT).map {
|
||||
SubjectColor(
|
||||
container = it.container.harmonizeWith(primary),
|
||||
content = it.content,
|
||||
soft = it.soft.harmonizeWith(primary),
|
||||
onSoft = it.onSoft.harmonizeWith(primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val SHORT_WORDS = setOf("и", "в", "во", "на", "по", "с", "со", "к", "о", "об", "для", "из", "от")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
package ritm.schedule.ui
|
||||
|
||||
import androidx.compose.material.icons.rounded.HowToReg
|
||||
import androidx.compose.material.icons.automirrored.rounded.Assignment
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
@@ -280,6 +281,11 @@ private fun SubjectCard(s: SubjectSummary, onClick: () -> Unit) {
|
||||
strong = next != null,
|
||||
)
|
||||
if (show.teachers && s.teachers.isNotEmpty()) Fact(Icons.Rounded.Person, s.teachers.joinToString(", "))
|
||||
val (present, marked) = LocalMarks.current.statsFor(s.name)
|
||||
if (marked > 0) Fact(
|
||||
Icons.Rounded.HowToReg,
|
||||
"Посещено $present из $marked" + if (marked > present) " · пропусков: ${marked - present}" else "",
|
||||
)
|
||||
val tasks = LocalNotes.current.count { it.task && !it.done && subjectKey(it.subject) == subjectKey(s.name) }
|
||||
if (tasks > 0) Fact(
|
||||
Icons.AutoMirrored.Rounded.Assignment,
|
||||
@@ -343,7 +349,7 @@ private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
|
||||
SubjectDot(lesson.subject, 10.dp)
|
||||
Spacer(Modifier.width(18.dp))
|
||||
Column {
|
||||
Text("${slot.start} → ${slot.end}", style = MaterialTheme.typography.titleSmallEmphasized)
|
||||
TimeText("${slot.start} → ${slot.end}", style = MaterialTheme.typography.titleSmallEmphasized)
|
||||
Text(
|
||||
listOfNotNull(
|
||||
lesson.type.ifEmpty { null },
|
||||
|
||||
@@ -12,68 +12,78 @@ import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import ritm.schedule.data.ThemeMode
|
||||
|
||||
// The same tokens as the web app (src/styles.css), so both clients look alike.
|
||||
private val Light = lightColorScheme(
|
||||
primary = Color(0xFF6750A4),
|
||||
// Material 3 tonal-spot scheme from a blue seed (#0061A4).
|
||||
internal val Light = lightColorScheme(
|
||||
primary = Color(0xFF0061A4),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFEADDFF),
|
||||
onPrimaryContainer = Color(0xFF21005D),
|
||||
secondary = Color(0xFF625B71),
|
||||
primaryContainer = Color(0xFFD1E4FF),
|
||||
onPrimaryContainer = Color(0xFF001D36),
|
||||
inversePrimary = Color(0xFF9ECAFF),
|
||||
secondary = Color(0xFF535F70),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFE8DEF8),
|
||||
onSecondaryContainer = Color(0xFF1D192B),
|
||||
tertiary = Color(0xFF7D5260),
|
||||
secondaryContainer = Color(0xFFD7E3F7),
|
||||
onSecondaryContainer = Color(0xFF101C2B),
|
||||
tertiary = Color(0xFF6B5778),
|
||||
onTertiary = Color.White,
|
||||
tertiaryContainer = Color(0xFFFFD8E4),
|
||||
onTertiaryContainer = Color(0xFF31111D),
|
||||
surface = Color(0xFFFEF7FF),
|
||||
onSurface = Color(0xFF1D1B20),
|
||||
onSurfaceVariant = Color(0xFF49454F),
|
||||
tertiaryContainer = Color(0xFFF2DAFF),
|
||||
onTertiaryContainer = Color(0xFF251431),
|
||||
surface = Color(0xFFF8F9FF),
|
||||
onSurface = Color(0xFF191C20),
|
||||
onSurfaceVariant = Color(0xFF42474E),
|
||||
surfaceContainerLowest = Color.White,
|
||||
surfaceContainerLow = Color(0xFFF7F2FA),
|
||||
surfaceContainer = Color(0xFFF3EDF7),
|
||||
surfaceContainerHigh = Color(0xFFECE6F0),
|
||||
surfaceContainerHighest = Color(0xFFE6E0E9),
|
||||
outline = Color(0xFF79747E),
|
||||
outlineVariant = Color(0xFFCAC4D0),
|
||||
error = Color(0xFFB3261E),
|
||||
errorContainer = Color(0xFFF9DEDC),
|
||||
onErrorContainer = Color(0xFF410E0B),
|
||||
background = Color(0xFFFEF7FF),
|
||||
onBackground = Color(0xFF1D1B20),
|
||||
surfaceContainerLow = Color(0xFFF2F3FA),
|
||||
surfaceContainer = Color(0xFFECEEF4),
|
||||
surfaceContainerHigh = Color(0xFFE6E8EE),
|
||||
surfaceContainerHighest = Color(0xFFE0E2E8),
|
||||
surfaceVariant = Color(0xFFDFE2EB),
|
||||
outline = Color(0xFF73777F),
|
||||
outlineVariant = Color(0xFFC3C7CF),
|
||||
inverseSurface = Color(0xFF2E3135),
|
||||
inverseOnSurface = Color(0xFFEFF0F7),
|
||||
error = Color(0xFFBA1A1A),
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF410002),
|
||||
background = Color(0xFFF8F9FF),
|
||||
onBackground = Color(0xFF191C20),
|
||||
)
|
||||
|
||||
private val Dark = darkColorScheme(
|
||||
primary = Color(0xFFD0BCFF),
|
||||
onPrimary = Color(0xFF381E72),
|
||||
primaryContainer = Color(0xFF4F378B),
|
||||
onPrimaryContainer = Color(0xFFEADDFF),
|
||||
secondary = Color(0xFFCCC2DC),
|
||||
onSecondary = Color(0xFF332D41),
|
||||
secondaryContainer = Color(0xFF4A4458),
|
||||
onSecondaryContainer = Color(0xFFE8DEF8),
|
||||
tertiary = Color(0xFFEFB8C8),
|
||||
onTertiary = Color(0xFF492532),
|
||||
tertiaryContainer = Color(0xFF633B48),
|
||||
onTertiaryContainer = Color(0xFFFFD8E4),
|
||||
surface = Color(0xFF141218),
|
||||
onSurface = Color(0xFFE6E0E9),
|
||||
onSurfaceVariant = Color(0xFFCAC4D0),
|
||||
surfaceContainerLowest = Color(0xFF0F0D13),
|
||||
surfaceContainerLow = Color(0xFF1D1B20),
|
||||
surfaceContainer = Color(0xFF211F26),
|
||||
surfaceContainerHigh = Color(0xFF2B2930),
|
||||
surfaceContainerHighest = Color(0xFF36343B),
|
||||
outline = Color(0xFF938F99),
|
||||
outlineVariant = Color(0xFF49454F),
|
||||
error = Color(0xFFF2B8B5),
|
||||
errorContainer = Color(0xFF8C1D18),
|
||||
onErrorContainer = Color(0xFFF9DEDC),
|
||||
background = Color(0xFF141218),
|
||||
onBackground = Color(0xFFE6E0E9),
|
||||
internal val Dark = darkColorScheme(
|
||||
primary = Color(0xFF9ECAFF),
|
||||
onPrimary = Color(0xFF003258),
|
||||
primaryContainer = Color(0xFF00497D),
|
||||
onPrimaryContainer = Color(0xFFD1E4FF),
|
||||
inversePrimary = Color(0xFF0061A4),
|
||||
secondary = Color(0xFFBBC7DB),
|
||||
onSecondary = Color(0xFF253140),
|
||||
secondaryContainer = Color(0xFF3B4858),
|
||||
onSecondaryContainer = Color(0xFFD7E3F7),
|
||||
tertiary = Color(0xFFD6BEE4),
|
||||
onTertiary = Color(0xFF3B2948),
|
||||
tertiaryContainer = Color(0xFF523F5F),
|
||||
onTertiaryContainer = Color(0xFFF2DAFF),
|
||||
surface = Color(0xFF111418),
|
||||
onSurface = Color(0xFFE1E2E8),
|
||||
onSurfaceVariant = Color(0xFFC3C7CF),
|
||||
surfaceContainerLowest = Color(0xFF0C0E13),
|
||||
surfaceContainerLow = Color(0xFF191C20),
|
||||
surfaceContainer = Color(0xFF1D2024),
|
||||
surfaceContainerHigh = Color(0xFF282A2F),
|
||||
surfaceContainerHighest = Color(0xFF33353A),
|
||||
surfaceVariant = Color(0xFF42474E),
|
||||
outline = Color(0xFF8D9199),
|
||||
outlineVariant = Color(0xFF42474E),
|
||||
inverseSurface = Color(0xFFE1E2E8),
|
||||
inverseOnSurface = Color(0xFF2E3135),
|
||||
error = Color(0xFFFFB4AB),
|
||||
errorContainer = Color(0xFF93000A),
|
||||
onErrorContainer = Color(0xFFFFDAD6),
|
||||
background = Color(0xFF111418),
|
||||
onBackground = Color(0xFFE1E2E8),
|
||||
)
|
||||
|
||||
/** The tinted page behind cards, app bars and the navigation rail. */
|
||||
@@ -83,6 +93,13 @@ val ColorScheme.page: Color get() = surfaceContainer
|
||||
val ColorScheme.card: Color
|
||||
get() = if (surface.red + surface.green + surface.blue < 1.5f) surfaceContainerHigh else surfaceContainerLowest
|
||||
|
||||
/**
|
||||
* Material harmonization: turns a static colour's hue up to 15° towards [primary] in HCT,
|
||||
* keeping its chroma and tone, so fixed colours sit well with dynamic (wallpaper) schemes.
|
||||
*/
|
||||
fun Color.harmonizeWith(primary: Color): Color =
|
||||
Color(MaterialColors.harmonize(toArgb(), primary.toArgb()))
|
||||
|
||||
/** Week colours from the university table: white and green weeks. */
|
||||
object WeekColors {
|
||||
fun container(white: Boolean, dark: Boolean) = when {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package ritm.schedule.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.action.actionStartActivity
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import ritm.schedule.app
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import ritm.schedule.MainActivity
|
||||
|
||||
/** One line on the home screen: when the next lesson is, what it is and where. */
|
||||
class NextLessonWidget : GlanceAppWidget() {
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val next = upcoming(context)
|
||||
val dynamic = context.app.settings.now().dynamicColor
|
||||
provideContent { WidgetTheme(dynamic) { Content(next) } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(next: Upcoming?) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.roundedBackground(GlanceTheme.colors.widgetBackground, 28.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.clickable(actionStartActivity<MainActivity>()),
|
||||
) {
|
||||
if (next == null) {
|
||||
Text(
|
||||
"Ближайших пар нет",
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
return@Row
|
||||
}
|
||||
Text(
|
||||
next.whenLabel(),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (next.running) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onPrimaryContainer,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.roundedBackground(if (next.running) GlanceTheme.colors.primary else GlanceTheme.colors.primaryContainer, 16.dp)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
Spacer(GlanceModifier.width(12.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Text(
|
||||
next.subject,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp, fontWeight = FontWeight.Bold),
|
||||
)
|
||||
if (next.rooms.isNotEmpty()) Text(
|
||||
"ауд. ${next.rooms}",
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NextLessonWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = NextLessonWidget()
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import androidx.glance.action.actionStartActivity
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
@@ -43,19 +44,21 @@ private data class WidgetState(
|
||||
val title: String,
|
||||
val lines: List<Pair<String, String>>,
|
||||
val empty: String?,
|
||||
val dynamic: Boolean = false,
|
||||
)
|
||||
|
||||
private suspend fun state(context: Context): WidgetState {
|
||||
val app = context.app
|
||||
val timetable = app.repository.timetable.value
|
||||
?: return WidgetState(null, "Расписание", emptyList(), "Откройте приложение и выберите группу")
|
||||
val subgroup = app.settings.now().subgroup
|
||||
val settings = app.settings.now()
|
||||
val subgroup = settings.subgroup
|
||||
val now = nowTime()
|
||||
val todayLessons = app.repository.lessonsOn(timetable, today(), subgroup)
|
||||
val todayLessons = app.repository.lessonsOn(timetable, today(), subgroup, settings.hidden)
|
||||
val left = todayLessons.filter { parseTime(it.end ?: it.start).isAfter(now) }
|
||||
val (date: LocalDate, list: List<Lesson>) =
|
||||
if (left.isNotEmpty()) today() to left
|
||||
else today().plusDays(1) to app.repository.lessonsOn(timetable, today().plusDays(1), subgroup)
|
||||
else today().plusDays(1) to app.repository.lessonsOn(timetable, today().plusDays(1), subgroup, settings.hidden)
|
||||
val title = if (date == today()) "Сегодня, ${date.longLabel()}" else "Завтра, ${date.weekdayName().lowercase()}"
|
||||
val lines = list.groupBy { it.start }.entries.take(3).map { (start, lessons) ->
|
||||
val l = lessons.first()
|
||||
@@ -77,13 +80,17 @@ private suspend fun state(context: Context): WidgetState {
|
||||
title,
|
||||
lines,
|
||||
if (lines.isEmpty()) "Занятий нет" else null,
|
||||
settings.dynamicColor,
|
||||
)
|
||||
}
|
||||
|
||||
class TodayWidget : GlanceAppWidget() {
|
||||
// Laid out for its real size, so a short widget shows fewer lessons instead of clipping them.
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val data = state(context)
|
||||
provideContent { GlanceTheme { Content(data) } }
|
||||
provideContent { WidgetTheme(data.dynamic) { Content(data) } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -91,8 +98,7 @@ class TodayWidget : GlanceAppWidget() {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(GlanceTheme.colors.widgetBackground)
|
||||
.cornerRadius(24.dp)
|
||||
.roundedBackground(GlanceTheme.colors.widgetBackground, 28.dp)
|
||||
.padding(16.dp)
|
||||
.clickable(actionStartActivity<MainActivity>()),
|
||||
) {
|
||||
@@ -112,15 +118,19 @@ class TodayWidget : GlanceAppWidget() {
|
||||
data.empty?.let {
|
||||
Text(it, style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp))
|
||||
}
|
||||
data.lines.forEachIndexed { index, (label, text) ->
|
||||
// Header and padding take about 64dp; a lesson needs about 62dp, or 46dp on one line.
|
||||
val height = LocalSize.current.height
|
||||
val roomy = ((height - 64.dp) / 62.dp).toInt()
|
||||
val compact = roomy < 1
|
||||
val fits = if (compact) 1 else roomy
|
||||
data.lines.take(fits).forEachIndexed { index, (label, text) ->
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
if (index == 0) GlanceTheme.colors.primaryContainer
|
||||
else GlanceTheme.colors.secondaryContainer,
|
||||
.roundedBackground(
|
||||
if (index == 0) GlanceTheme.colors.primaryContainer else GlanceTheme.colors.secondaryContainer,
|
||||
16.dp,
|
||||
)
|
||||
.cornerRadius(16.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
@@ -134,7 +144,7 @@ class TodayWidget : GlanceAppWidget() {
|
||||
)
|
||||
Text(
|
||||
text,
|
||||
maxLines = 2,
|
||||
maxLines = if (compact) 1 else 2,
|
||||
style = TextStyle(
|
||||
color = if (index == 0) GlanceTheme.colors.onPrimaryContainer
|
||||
else GlanceTheme.colors.onSecondaryContainer,
|
||||
|
||||
51
android/app/src/main/kotlin/ritm/schedule/widget/Upcoming.kt
Normal file
51
android/app/src/main/kotlin/ritm/schedule/widget/Upcoming.kt
Normal file
@@ -0,0 +1,51 @@
|
||||
package ritm.schedule.widget
|
||||
|
||||
import android.content.Context
|
||||
import ritm.schedule.app
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.minutesLabel
|
||||
import ritm.schedule.data.nowTime
|
||||
import ritm.schedule.data.parseTime
|
||||
import ritm.schedule.data.shortWeekday
|
||||
import ritm.schedule.data.today
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
|
||||
/** The lesson in progress or the next one within a week, for the small widget and the tile. */
|
||||
data class Upcoming(val date: LocalDate, val start: String, val end: String?, val lessons: List<Lesson>, val running: Boolean) {
|
||||
val subject get() = lessons.joinToString(" / ") { it.subject }
|
||||
val rooms get() = lessons.flatMap { if (it.distance) listOf("дист.") else it.rooms }.distinct().joinToString(", ")
|
||||
|
||||
/** "Идёт · ещё 40 мин", "Через 20 мин", "Завтра, 10:05" or "Пн, 10:05". */
|
||||
fun whenLabel(): String {
|
||||
val now = nowTime()
|
||||
return when {
|
||||
running && end != null -> "Идёт · ещё ${minutesLabel(Duration.between(now, parseTime(end)).toMinutes())}"
|
||||
date == today() && Duration.between(now, parseTime(start)).toMinutes() <= 120 ->
|
||||
"Через ${minutesLabel(Duration.between(now, parseTime(start)).toMinutes())}"
|
||||
date == today() -> "Сегодня, $start"
|
||||
date == today().plusDays(1) -> "Завтра, $start"
|
||||
else -> "${date.shortWeekday()}, $start"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** From the cached timetable and the student's subgroup and hidden lessons; no network. */
|
||||
suspend fun upcoming(context: Context): Upcoming? {
|
||||
val app = context.app
|
||||
val t = app.repository.timetable.value ?: return null
|
||||
val s = app.settings.now()
|
||||
val now = nowTime()
|
||||
for (offset in 0..7L) {
|
||||
val date = today().plusDays(offset)
|
||||
val slots = app.repository.lessonsOn(t, date, s.subgroup, s.hidden).groupBy { it.start }.toSortedMap()
|
||||
for ((start, lessons) in slots) {
|
||||
val end = lessons.first().end ?: t.bells.firstOrNull { it.start == start }?.end
|
||||
val endTime = end?.let(::parseTime) ?: parseTime(start).plusMinutes(80)
|
||||
if (offset == 0L && !endTime.isAfter(now)) continue
|
||||
val running = offset == 0L && !now.isBefore(parseTime(start))
|
||||
return Upcoming(date, start, end, lessons, running)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package ritm.schedule.widget
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.background
|
||||
import androidx.glance.material3.ColorProviders
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import ritm.schedule.R
|
||||
import ritm.schedule.ui.theme.Dark
|
||||
import ritm.schedule.ui.theme.Light
|
||||
|
||||
/**
|
||||
* The app's colours on the home screen: wallpaper colours when the student chose them and
|
||||
* the system has them (Android 12+), otherwise the app's own blue scheme — never Glance's
|
||||
* default purple, which older systems fall back to.
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetTheme(dynamic: Boolean, content: @Composable () -> Unit) {
|
||||
if (dynamic && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) GlanceTheme(content = content)
|
||||
else GlanceTheme(colors = ColorProviders(light = Light, dark = Dark), content = content)
|
||||
}
|
||||
|
||||
/**
|
||||
* A rounded fill. Glance rounds corners only on Android 12+, so older systems get a rounded
|
||||
* shape drawable tinted with the same colour.
|
||||
*/
|
||||
fun GlanceModifier.roundedBackground(color: ColorProvider, radius: Dp): GlanceModifier =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) background(color).cornerRadius(radius)
|
||||
else background(
|
||||
ImageProvider(if (radius >= 24.dp) R.drawable.widget_round_28 else R.drawable.widget_round_16),
|
||||
colorFilter = ColorFilter.tint(color),
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
package ritm.schedule.work
|
||||
|
||||
import ritm.schedule.tile.LessonTileService
|
||||
import ritm.schedule.widget.NextLessonWidget
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.work.Constraints
|
||||
@@ -43,6 +45,8 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c
|
||||
val app = context.app
|
||||
Reminders.reschedule(context, app.repository.timetable.value, app.settings.now())
|
||||
TodayWidget().updateAll(context)
|
||||
NextLessonWidget().updateAll(context)
|
||||
LessonTileService.refresh(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +55,8 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c
|
||||
class WidgetTick(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
TodayWidget().updateAll(applicationContext)
|
||||
NextLessonWidget().updateAll(applicationContext)
|
||||
LessonTileService.refresh(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
android:scaleY="0.5"
|
||||
android:translateY="480">
|
||||
<path
|
||||
android:fillColor="#4F378B"
|
||||
android:fillColor="#00497D"
|
||||
android:pathData="M200,-80Q167,-80 143.5,-103.5Q120,-127 120,-160L120,-720Q120,-753 143.5,-776.5Q167,-800 200,-800L240,-800L240,-840Q240,-857 251.5,-868.5Q263,-880 280,-880Q297,-880 308.5,-868.5Q320,-857 320,-840L320,-800L640,-800L640,-840Q640,-857 651.5,-868.5Q663,-880 680,-880Q697,-880 708.5,-868.5Q720,-857 720,-840L720,-800L760,-800Q793,-800 816.5,-776.5Q840,-753 840,-720L840,-160Q840,-127 816.5,-103.5Q793,-80 760,-80L200,-80ZM200,-160L760,-160Q760,-160 760,-160Q760,-160 760,-160L760,-560L200,-560L200,-160Q200,-160 200,-160Q200,-160 200,-160ZM480,-400Q463,-400 451.5,-411.5Q440,-423 440,-440Q440,-457 451.5,-468.5Q463,-480 480,-480Q497,-480 508.5,-468.5Q520,-457 520,-440Q520,-423 508.5,-411.5Q497,-400 480,-400ZM320,-400Q303,-400 291.5,-411.5Q280,-423 280,-440Q280,-457 291.5,-468.5Q303,-480 320,-480Q337,-480 348.5,-468.5Q360,-457 360,-440Q360,-423 348.5,-411.5Q337,-400 320,-400ZM640,-400Q623,-400 611.5,-411.5Q600,-423 600,-440Q600,-457 611.5,-468.5Q623,-480 640,-480Q657,-480 668.5,-468.5Q680,-457 680,-440Q680,-423 668.5,-411.5Q657,-400 640,-400ZM480,-240Q463,-240 451.5,-251.5Q440,-263 440,-280Q440,-297 451.5,-308.5Q463,-320 480,-320Q497,-320 508.5,-308.5Q520,-297 520,-280Q520,-263 508.5,-251.5Q497,-240 480,-240ZM320,-240Q303,-240 291.5,-251.5Q280,-263 280,-280Q280,-297 291.5,-308.5Q303,-320 320,-320Q337,-320 348.5,-308.5Q360,-297 360,-280Q360,-263 348.5,-251.5Q337,-240 320,-240ZM640,-240Q623,-240 611.5,-251.5Q600,-263 600,-280Q600,-297 611.5,-308.5Q623,-320 640,-320Q657,-320 668.5,-308.5Q680,-297 680,-280Q680,-263 668.5,-251.5Q657,-240 640,-240Z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
10
android/app/src/main/res/drawable/ic_tile_lesson.xml
Normal file
10
android/app/src/main/res/drawable/ic_tile_lesson.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!-- Material icon "event". -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M17,12h-5v5h5v-5zM16,1v2L8,3L8,1L6,1v2L5,3c-1.11,0 -1.99,0.9 -1.99,2L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2h-1L18,1h-2zM19,19L5,19L5,8h14v11z" />
|
||||
</vector>
|
||||
6
android/app/src/main/res/drawable/widget_round_16.xml
Normal file
6
android/app/src/main/res/drawable/widget_round_16.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Rounded fill for widgets before Android 12, where Glance cannot round corners; tinted in code. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="#FFFFFFFF" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
6
android/app/src/main/res/drawable/widget_round_28.xml
Normal file
6
android/app/src/main/res/drawable/widget_round_28.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Rounded fill for widgets before Android 12, where Glance cannot round corners; tinted in code. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="#FFFFFFFF" />
|
||||
<corners android:radius="28dp" />
|
||||
</shape>
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="surface">#141218</color>
|
||||
<color name="surface">#111418</color>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="surface">#FEF7FF</color>
|
||||
<color name="primary">#6750A4</color>
|
||||
<color name="launcher_background">#EADDFF</color>
|
||||
<color name="surface">#F8F9FF</color>
|
||||
<color name="primary">#0061A4</color>
|
||||
<color name="launcher_background">#D1E4FF</color>
|
||||
</resources>
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
<string name="widget_name">Сегодня</string>
|
||||
<string name="widget_description">Текущая и следующая пара вашей группы</string>
|
||||
<string name="channel_reminders">Перед парой</string>
|
||||
<string name="next_widget_name">Следующая пара</string>
|
||||
<string name="next_widget_description">Когда следующая пара, что это и в какой аудитории</string>
|
||||
<string name="tile_name">Пара</string>
|
||||
</resources>
|
||||
|
||||
11
android/app/src/main/res/xml/next_widget_info.xml
Normal file
11
android/app/src/main/res/xml/next_widget_info.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/next_widget_description"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="40dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="1"
|
||||
android:resizeMode="horizontal"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen" />
|
||||
@@ -12,6 +12,7 @@ datastore = "1.2.1"
|
||||
glance = "1.2.0"
|
||||
work = "2.12.0"
|
||||
desugar = "2.1.5"
|
||||
material = "1.13.0"
|
||||
junit = "4.13.2"
|
||||
|
||||
[libraries]
|
||||
@@ -33,6 +34,8 @@ glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref =
|
||||
glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" }
|
||||
work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
||||
desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar" }
|
||||
# Only for MaterialColors.harmonize; R8 drops the rest from release builds.
|
||||
material = { module = "com.google.android.material:material", version.ref = "material" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
|
||||
[plugins]
|
||||
|
||||
Reference in New Issue
Block a user