Skip to content

Commit 09fea6e

Browse files
Gavin-WangSCclaude
andcommitted
Rewrite Today screen for iOS pixel parity
Replace CountdownCard/NextClassCard/WeekTimetableSection with a single UnifiedScheduleCard that mirrors the iOS layout: colored header band, subject-colored per-period rows, embedded progress bar + countdown on the active row, dimmed past rows, and a dual edge/glow shadow. Add WeekendCard/NoClassCard/HolidayModeCard status cards, a 2x2 QuickLinksCard gradient grid, subject color keyword map, shared coloredRichCard modifier, and a spring-based staggeredEntry animation. TodayUiState collapses InClass/Break into a single Weekday variant carrying the full day and optional active index. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 807328a commit 09fea6e

14 files changed

Lines changed: 726 additions & 343 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.computerization.outspire.designsystem
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.border
5+
import androidx.compose.foundation.shape.RoundedCornerShape
6+
import androidx.compose.ui.Modifier
7+
import androidx.compose.ui.composed
8+
import androidx.compose.ui.draw.clip
9+
import androidx.compose.ui.draw.shadow
10+
import androidx.compose.ui.graphics.Brush
11+
import androidx.compose.ui.graphics.Color
12+
import androidx.compose.ui.unit.Dp
13+
import androidx.compose.ui.unit.dp
14+
15+
fun Modifier.coloredRichCard(
16+
colors: List<Color>,
17+
cornerRadius: Dp = 20.dp,
18+
shadowRadius: Dp = 12.dp,
19+
): Modifier = composed {
20+
val shape = RoundedCornerShape(cornerRadius)
21+
this
22+
.shadow(elevation = shadowRadius, shape = shape, clip = false)
23+
.clip(shape)
24+
.background(
25+
Brush.linearGradient(colors),
26+
)
27+
.border(
28+
width = 1.dp,
29+
brush = Brush.verticalGradient(
30+
listOf(Color.White.copy(alpha = 0.25f), Color.Transparent),
31+
),
32+
shape = shape,
33+
)
34+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.computerization.outspire.designsystem
2+
3+
import androidx.compose.animation.core.Spring
4+
import androidx.compose.animation.core.animateFloatAsState
5+
import androidx.compose.animation.core.spring
6+
import androidx.compose.runtime.LaunchedEffect
7+
import androidx.compose.runtime.getValue
8+
import androidx.compose.runtime.mutableStateOf
9+
import androidx.compose.runtime.remember
10+
import androidx.compose.runtime.setValue
11+
import androidx.compose.ui.Modifier
12+
import androidx.compose.ui.composed
13+
import androidx.compose.ui.graphics.graphicsLayer
14+
import androidx.compose.ui.unit.dp
15+
import kotlinx.coroutines.delay
16+
17+
fun Modifier.staggeredEntry(index: Int, animate: Boolean): Modifier = composed {
18+
var started by remember { mutableStateOf(false) }
19+
LaunchedEffect(animate) {
20+
if (animate) {
21+
delay(index * 120L)
22+
started = true
23+
}
24+
}
25+
val progress by animateFloatAsState(
26+
targetValue = if (started) 1f else 0f,
27+
animationSpec = spring(
28+
dampingRatio = 0.75f,
29+
stiffness = Spring.StiffnessMediumLow,
30+
),
31+
label = "stagger$index",
32+
)
33+
graphicsLayer {
34+
alpha = progress
35+
translationY = (1f - progress) * 30.dp.toPx()
36+
}
37+
}
Lines changed: 78 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,109 @@
11
package com.computerization.outspire.feature.today
22

33
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Box
45
import androidx.compose.foundation.layout.Column
56
import androidx.compose.foundation.layout.fillMaxSize
67
import androidx.compose.foundation.layout.padding
78
import androidx.compose.foundation.rememberScrollState
89
import androidx.compose.foundation.verticalScroll
910
import androidx.compose.material3.MaterialTheme
11+
import androidx.compose.material3.Snackbar
12+
import androidx.compose.material3.SnackbarHost
13+
import androidx.compose.material3.SnackbarHostState
1014
import androidx.compose.material3.Text
1115
import androidx.compose.runtime.Composable
16+
import androidx.compose.runtime.LaunchedEffect
1217
import androidx.compose.runtime.collectAsState
1318
import androidx.compose.runtime.getValue
19+
import androidx.compose.runtime.mutableStateOf
20+
import androidx.compose.runtime.remember
21+
import androidx.compose.runtime.rememberCoroutineScope
22+
import androidx.compose.runtime.setValue
1423
import androidx.compose.ui.Alignment
1524
import androidx.compose.ui.Modifier
1625
import androidx.hilt.navigation.compose.hiltViewModel
1726
import com.computerization.outspire.designsystem.AppSpace
18-
import com.computerization.outspire.feature.today.components.CountdownCard
19-
import com.computerization.outspire.feature.today.components.NextClassCard
27+
import com.computerization.outspire.designsystem.staggeredEntry
28+
import com.computerization.outspire.feature.today.components.NoClassCard
29+
import com.computerization.outspire.feature.today.components.QuickLinksCard
30+
import com.computerization.outspire.feature.today.components.UnifiedScheduleCard
2031
import com.computerization.outspire.feature.today.components.WeatherBadge
21-
import com.computerization.outspire.feature.today.components.WeekTimetableSection
32+
import com.computerization.outspire.feature.today.components.WeekendCard
33+
import kotlinx.coroutines.launch
2234

2335
@Composable
2436
fun TodayScreen(
37+
onNavigate: (String) -> Unit = {},
2538
viewModel: TodayViewModel = hiltViewModel(),
2639
) {
2740
val state by viewModel.state.collectAsState()
28-
val week by viewModel.weekFlow.collectAsState()
41+
val snackbarHostState = remember { SnackbarHostState() }
42+
val scope = rememberCoroutineScope()
43+
var animateCards by remember { mutableStateOf(false) }
44+
LaunchedEffect(Unit) { animateCards = true }
2945

30-
Column(
31-
modifier = Modifier
32-
.fillMaxSize()
33-
.verticalScroll(rememberScrollState())
34-
.padding(horizontal = AppSpace.md, vertical = AppSpace.lg),
35-
verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing),
36-
) {
37-
Text(
38-
text = "Today",
39-
style = MaterialTheme.typography.displayLarge,
40-
color = MaterialTheme.colorScheme.onBackground,
41-
)
42-
WeatherBadge(modifier = Modifier.align(Alignment.Start))
46+
Box(Modifier.fillMaxSize()) {
47+
Column(
48+
modifier = Modifier
49+
.fillMaxSize()
50+
.verticalScroll(rememberScrollState())
51+
.padding(horizontal = AppSpace.md, vertical = AppSpace.lg),
52+
verticalArrangement = Arrangement.spacedBy(AppSpace.lg),
53+
) {
54+
Text(
55+
text = "Today",
56+
style = MaterialTheme.typography.displayLarge,
57+
color = MaterialTheme.colorScheme.onBackground,
58+
)
59+
WeatherBadge(modifier = Modifier.align(Alignment.Start))
4360

44-
when (val s = state) {
45-
TodayUiState.Loading -> {
46-
Text("Loading…", color = MaterialTheme.colorScheme.onBackground)
47-
}
48-
is TodayUiState.InClass -> {
49-
CountdownCard(
50-
title = "Current class · ends in",
51-
subtitle = s.current.subject,
52-
remaining = s.remaining,
53-
)
54-
}
55-
is TodayUiState.Break -> {
56-
CountdownCard(
57-
title = "Next class · starts in",
58-
subtitle = s.next.subject,
59-
remaining = s.until,
60-
)
61-
NextClassCard(clazz = s.next)
62-
}
63-
is TodayUiState.Done -> {
64-
Text(
65-
text = if (s.isWeekend) "Weekend — enjoy your rest ✦"
66-
else "All classes wrapped for today ✦",
67-
style = MaterialTheme.typography.titleMedium,
68-
color = MaterialTheme.colorScheme.onBackground,
69-
)
70-
}
71-
is TodayUiState.Error -> {
72-
Text(
73-
text = "Couldn't load timetable: ${s.message}",
74-
style = MaterialTheme.typography.bodyMedium,
75-
color = MaterialTheme.colorScheme.error,
76-
)
61+
when (val s = state) {
62+
TodayUiState.Loading -> {
63+
Text("Loading…", color = MaterialTheme.colorScheme.onBackground)
64+
}
65+
is TodayUiState.Weekday -> {
66+
UnifiedScheduleCard(
67+
dayName = s.dayName,
68+
classes = s.classes,
69+
activeIndex = s.activeIndex,
70+
nowLocal = s.now,
71+
modifier = Modifier.staggeredEntry(0, animateCards),
72+
)
73+
}
74+
is TodayUiState.DayDone -> {
75+
if (s.isWeekend) {
76+
WeekendCard(modifier = Modifier.staggeredEntry(0, animateCards))
77+
} else {
78+
NoClassCard(
79+
isDimmed = s.isAfterSchool,
80+
modifier = Modifier.staggeredEntry(0, animateCards),
81+
)
82+
}
83+
}
84+
is TodayUiState.Error -> {
85+
Text(
86+
text = "Couldn't load timetable: ${s.message}",
87+
style = MaterialTheme.typography.bodyMedium,
88+
color = MaterialTheme.colorScheme.error,
89+
)
90+
}
7791
}
92+
93+
QuickLinksCard(
94+
modifier = Modifier.staggeredEntry(1, animateCards),
95+
onClubs = { onNavigate("cas") },
96+
onDining = {
97+
scope.launch { snackbarHostState.showSnackbar("Dining · coming soon") }
98+
},
99+
onActivities = { onNavigate("cas") },
100+
onReflect = { onNavigate("cas") },
101+
)
78102
}
79103

80-
WeekTimetableSection(week = week)
104+
SnackbarHost(
105+
hostState = snackbarHostState,
106+
modifier = Modifier.align(Alignment.BottomCenter),
107+
) { Snackbar(it) }
81108
}
82109
}
Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
package com.computerization.outspire.feature.today
22

33
import com.computerization.outspire.data.model.DomainClass
4-
import kotlin.time.Duration
4+
import kotlinx.datetime.LocalTime
55

66
sealed interface TodayUiState {
77
data object Loading : TodayUiState
88

9-
data class InClass(
10-
val current: DomainClass,
11-
val remaining: Duration,
9+
data class Weekday(
10+
val dayName: String,
11+
val classes: List<DomainClass>,
12+
val activeIndex: Int?,
13+
val now: LocalTime,
1214
) : TodayUiState
1315

14-
data class Break(
15-
val next: DomainClass,
16-
val until: Duration,
16+
data class DayDone(
17+
val isWeekend: Boolean,
18+
val isAfterSchool: Boolean,
1719
) : TodayUiState
1820

19-
data class Done(val lastSubject: String?, val isWeekend: Boolean) : TodayUiState
20-
2121
data class Error(val message: String) : TodayUiState
2222
}

app/src/main/java/com/computerization/outspire/feature/today/TodayViewModel.kt

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
1111
import kotlinx.coroutines.flow.MutableStateFlow
1212
import kotlinx.coroutines.flow.SharingStarted
1313
import kotlinx.coroutines.flow.StateFlow
14-
import kotlinx.coroutines.flow.asStateFlow
1514
import kotlinx.coroutines.flow.combine
1615
import kotlinx.coroutines.flow.stateIn
1716
import kotlinx.coroutines.launch
18-
import kotlinx.datetime.Clock
1917
import kotlinx.datetime.DayOfWeek
2018
import kotlinx.datetime.Instant
2119
import kotlinx.datetime.LocalTime
2220
import kotlinx.datetime.TimeZone
2321
import kotlinx.datetime.toLocalDateTime
2422
import javax.inject.Inject
25-
import kotlin.time.Duration
2623
import kotlin.time.Duration.Companion.seconds
2724

2825
@HiltViewModel
@@ -32,8 +29,6 @@ class TodayViewModel @Inject constructor(
3229

3330
private val classesFlow = MutableStateFlow<List<DomainClass>?>(null)
3431
private val errorFlow = MutableStateFlow<String?>(null)
35-
private val _weekFlow = MutableStateFlow<Map<DayOfWeek, List<DomainClass>>>(emptyMap())
36-
val weekFlow: StateFlow<Map<DayOfWeek, List<DomainClass>>> = _weekFlow.asStateFlow()
3732

3833
init {
3934
load()
@@ -49,8 +44,7 @@ class TodayViewModel @Inject constructor(
4944
when {
5045
error != null && classes == null -> TodayUiState.Error(error)
5146
classes == null -> TodayUiState.Loading
52-
classes.isEmpty() -> TodayUiState.Done(null, isWeekend)
53-
else -> computeState(now, classes, isWeekend)
47+
else -> computeState(now, classes, isWeekend, dow)
5448
}
5549
}.stateIn(
5650
scope = viewModelScope,
@@ -66,7 +60,6 @@ class TodayViewModel @Inject constructor(
6660
classesFlow.value = MockClasstable.today.map {
6761
DomainClass(it.subject, it.teacher, it.room, it.start, it.end)
6862
}
69-
_weekFlow.value = emptyMap()
7063
return@launch
7164
}
7265
repository.todayClasses()
@@ -77,9 +70,6 @@ class TodayViewModel @Inject constructor(
7770
.onFailure { t ->
7871
errorFlow.value = t.message ?: "Failed to load timetable"
7972
}
80-
repository.weekClasses()
81-
.onSuccess { _weekFlow.value = it }
82-
.onFailure { /* ignore; today already reported */ }
8373
}
8474
}
8575

@@ -88,26 +78,33 @@ class TodayViewModel @Inject constructor(
8878
now: Instant,
8979
classes: List<DomainClass>,
9080
isWeekend: Boolean = false,
81+
dayOfWeek: DayOfWeek = DayOfWeek.MONDAY,
9182
): TodayUiState {
83+
if (isWeekend) return TodayUiState.DayDone(isWeekend = true, isAfterSchool = false)
84+
if (classes.isEmpty()) return TodayUiState.DayDone(isWeekend = false, isAfterSchool = false)
9285
val nowLocal = now.toLocalDateTime(TimeZone.currentSystemDefault()).time
93-
94-
val current = classes.firstOrNull { nowLocal >= it.start && nowLocal < it.end }
95-
if (current != null) {
96-
val remaining = (current.end.secondOfDay() - nowLocal.secondOfDay()).seconds
97-
return TodayUiState.InClass(current, remaining.clampPositive())
98-
}
99-
val next = classes.firstOrNull { nowLocal < it.start }
100-
if (next != null) {
101-
val until = (next.start.secondOfDay() - nowLocal.secondOfDay()).seconds
102-
return TodayUiState.Break(next, until.clampPositive())
86+
if (classes.all { nowLocal >= it.end }) {
87+
return TodayUiState.DayDone(isWeekend = false, isAfterSchool = true)
10388
}
104-
return TodayUiState.Done(classes.lastOrNull()?.subject, isWeekend)
89+
val activeIdx = classes.indexOfFirst { nowLocal >= it.start && nowLocal < it.end }
90+
.takeIf { it >= 0 }
91+
return TodayUiState.Weekday(
92+
dayName = dayName(dayOfWeek),
93+
classes = classes,
94+
activeIndex = activeIdx,
95+
now = nowLocal,
96+
)
10597
}
10698

107-
private fun LocalTime.secondOfDay(): Int =
108-
hour * 3600 + minute * 60 + second
109-
110-
private fun Duration.clampPositive(): Duration =
111-
if (this < Duration.ZERO) Duration.ZERO else this
99+
private fun dayName(d: DayOfWeek): String = when (d) {
100+
DayOfWeek.MONDAY -> "Monday"
101+
DayOfWeek.TUESDAY -> "Tuesday"
102+
DayOfWeek.WEDNESDAY -> "Wednesday"
103+
DayOfWeek.THURSDAY -> "Thursday"
104+
DayOfWeek.FRIDAY -> "Friday"
105+
DayOfWeek.SATURDAY -> "Saturday"
106+
DayOfWeek.SUNDAY -> "Sunday"
107+
else -> ""
108+
}
112109
}
113110
}

0 commit comments

Comments
 (0)