- 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.
120 lines
4.9 KiB
Kotlin
120 lines
4.9 KiB
Kotlin
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
|