Files
psu-schedule/android/app/src/main/kotlin/ritm/schedule/ui/Subjects.kt
Zakhar Timoshenko d900e038b6 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.
2026-10-18 00:39:32 +03:00

120 lines
4.9 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ritm.schedule.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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
import java.time.LocalTime
/**
* A subject's colours: [container]/[content] for small strong fills (week grid, dots),
* [soft]/[onSoft] for School Planner style lesson cards. Every pair keeps 4.5:1 contrast.
*/
data class SubjectColor(val container: Color, val content: Color, val soft: Color, val onSoft: Color)
// Material 700 tones (strong in light theme) and 200 tones (strong in dark theme).
private val TONE_700 = listOf(
0xFF1565C0, 0xFF7B1FA2, 0xFFC62828, 0xFF2E7D32, 0xFFD84315, 0xFF00695C,
0xFF283593, 0xFFAD1457, 0xFF5D4037, 0xFF00838F, 0xFF4527A0, 0xFF455A64,
).map { Color(it) }
private val TONE_200 = listOf(
0xFF90CAF9, 0xFFCE93D8, 0xFFEF9A9A, 0xFFA5D6A7, 0xFFFFAB91, 0xFF80CBC4,
0xFF9FA8DA, 0xFFF48FB1, 0xFFBCAAA4, 0xFF80DEEA, 0xFFB39DDB, 0xFFB0BEC5,
).map { Color(it) }
private val LIGHT = TONE_700.indices.map { i ->
SubjectColor(
container = TONE_700[i],
content = Color.White,
soft = lerp(TONE_200[i], Color.White, 0.35f),
onSoft = lerp(TONE_700[i], Color.Black, 0.35f),
)
}
private val DARK = TONE_700.indices.map { i ->
SubjectColor(
container = TONE_200[i],
content = Color(0xFF1D1B20),
soft = lerp(Color(0xFF211F26), TONE_700[i], 0.5f),
onSoft = lerp(TONE_200[i], Color.White, 0.4f),
)
}
fun subjectKey(subject: String) = subject.trim().lowercase()
/**
* Palette slot of every subject of the open timetable. Slots go in alphabetical order,
* so a subject keeps its colour across refreshes and two subjects rarely share one.
*/
fun subjectSlots(t: Timetable?): Map<String, Int> {
if (t == null) return emptyMap()
val subjects = (t.days.flatMap { it.lessons } + t.weekly.flatMap { it.lessons })
.map { subjectKey(it.subject) }
.distinct()
.sorted()
return subjects.withIndex().associate { (i, key) -> key to i % LIGHT.size }
}
val LocalSubjectSlots = staticCompositionLocalOf<Map<String, Int>> { emptyMap() }
/** The student's display choices from the settings. */
val LocalDisplay = staticCompositionLocalOf { Display() }
@Composable
fun subjectColor(subject: String): SubjectColor {
if (!LocalDisplay.current.colors) {
// Colours off: every lesson in the calm theme tones.
val scheme = MaterialTheme.colorScheme
return SubjectColor(scheme.primaryContainer, scheme.onPrimaryContainer, scheme.surfaceContainerHigh, scheme.onSurface)
}
val key = subjectKey(subject)
// 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 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("и", "в", "во", "на", "по", "с", "со", "к", "о", "об", "для", "из", "от")
/** "Экономика предприятия в машиностроении" → "ЭПМ"; short names stay as they are. */
fun abbreviate(subject: String): String {
val name = subject.substringBefore(':').trim()
if (name.length <= 10) return name
val letters = name.split(' ', '-', '(', ')', ',')
.filter { it.isNotEmpty() && it.lowercase() !in SHORT_WORDS && it.first().isLetter() }
.joinToString("") { it.first().uppercase() }
// Four letters still fit a phone's week column at the default font size.
return letters.take(4).takeIf { it.length >= 2 } ?: name.take(8)
}
/** End of a lesson: its own end, else the bell table's, else 80 minutes after the start. */
fun lessonEnd(lesson: Lesson, t: Timetable): LocalTime =
lesson.end?.let(::parseTime)
?: t.bells.firstOrNull { it.start == lesson.start }?.end?.let(::parseTime)
?: parseTime(lesson.start).plusMinutes(80)
fun Color.luminance(): Float = 0.2126f * red + 0.7152f * green + 0.0722f * blue