Skip to content

Commit 0acf794

Browse files
authored
Trainings tab component (#18)
1 parent 623ebf5 commit 0acf794

12 files changed

Lines changed: 855 additions & 13 deletions

File tree

shared/component/home/src/commonMain/kotlin/com/sedsoftware/blinkly/component/home/integration/HomeScreenComponentDefault.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,14 @@ class HomeScreenComponentDefault private constructor(
7575
)
7676
},
7777
trainingsTabComponent = { childContext, componentOutput ->
78-
TrainingsTabComponentDefault(childContext, componentOutput)
78+
TrainingsTabComponentDefault(
79+
componentContext = childContext,
80+
storeFactory = storeFactory,
81+
dispatchers = dispatchers,
82+
timeUtils = timeUtils,
83+
calendarWatcher = calendarWatcher,
84+
trainingsTabOutput = componentOutput,
85+
)
7986
},
8087
progressTabComponent = { childContext, componentOutput ->
8188
ProgressTabComponentDefault(
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
@file:OptIn(kotlin.time.ExperimentalTime::class)
2+
3+
package com.sedsoftware.blinkly.component.trainings
4+
5+
import assertk.assertThat
6+
import assertk.assertions.contains
7+
import assertk.assertions.isEqualTo
8+
import assertk.assertions.isFalse
9+
import assertk.assertions.isTrue
10+
import com.arkivanov.decompose.DefaultComponentContext
11+
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
12+
import com.sedsoftware.blinkly.component.ComponentTest
13+
import com.sedsoftware.blinkly.component.trainings.integration.TrainingsTabComponentDefault
14+
import com.sedsoftware.blinkly.domain.BlinklyCalendarWatcher
15+
import com.sedsoftware.blinkly.domain.external.BlinklyTimeUtils
16+
import com.sedsoftware.blinkly.domain.model.ComponentOutput
17+
import com.sedsoftware.blinkly.domain.model.Exercise
18+
import com.sedsoftware.blinkly.domain.model.ExerciseBlock
19+
import com.sedsoftware.blinkly.domain.model.ExerciseType
20+
import com.sedsoftware.blinkly.domain.model.Workout
21+
import kotlinx.coroutines.flow.Flow
22+
import kotlinx.coroutines.flow.MutableStateFlow
23+
import kotlinx.coroutines.flow.flow
24+
import kotlinx.coroutines.test.runTest
25+
import kotlinx.datetime.LocalDateTime
26+
import kotlinx.datetime.TimeZone
27+
import kotlinx.datetime.toInstant
28+
import kotlin.test.Test
29+
import kotlin.time.Instant
30+
31+
class TrainingsTabComponentTest : ComponentTest<TrainingsTabComponent>() {
32+
33+
private val calendarFlow: MutableStateFlow<List<Workout>> = MutableStateFlow(emptyList())
34+
private val timeUtils: FakeTimeUtils = FakeTimeUtils()
35+
private var calendarWatcher: BlinklyCalendarWatcher = object : BlinklyCalendarWatcher {
36+
override val calendar: Flow<List<Workout>> = calendarFlow
37+
}
38+
39+
@Test
40+
fun `when component created then model contains all training cards in catalog order`() = runTest(testScheduler) {
41+
// when
42+
testScheduler.advanceUntilIdle()
43+
44+
// then
45+
val cards = component.model.value.cards
46+
assertThat(cards.map { it.block }).isEqualTo(
47+
listOf(
48+
ExerciseBlock.A,
49+
ExerciseBlock.C,
50+
ExerciseBlock.B,
51+
)
52+
)
53+
assertThat(cards.any { it.completedToday }).isFalse()
54+
}
55+
56+
@Test
57+
fun `when exercises completed today then matching cards are marked completed`() = runTest(testScheduler) {
58+
// given
59+
calendarFlow.value = listOf(
60+
Workout(
61+
exercises = listOf(
62+
exercise(ExerciseBlock.A, ExerciseType.BLINK_BREAK, day = 15),
63+
exercise(ExerciseBlock.A, ExerciseType.NEAR_FAR_FOCUS, day = 15),
64+
exercise(ExerciseBlock.A, ExerciseType.DIAGONAL_GAZES, day = 15),
65+
exercise(ExerciseBlock.C, ExerciseType.TWENTY_X3, day = 15),
66+
)
67+
)
68+
)
69+
70+
// when
71+
testScheduler.advanceUntilIdle()
72+
73+
// then
74+
val cards = component.model.value.cards
75+
assertThat(cards.first { it.block == ExerciseBlock.A }.completedToday).isTrue()
76+
assertThat(cards.first { it.block == ExerciseBlock.C }.completedToday).isTrue()
77+
assertThat(cards.first { it.block == ExerciseBlock.B }.completedToday).isFalse()
78+
}
79+
80+
@Test
81+
fun `when only part of a full block was completed today then card stays not completed`() = runTest(testScheduler) {
82+
// given
83+
calendarFlow.value = listOf(
84+
Workout(
85+
exercises = listOf(
86+
exercise(ExerciseBlock.A, ExerciseType.BLINK_BREAK, day = 15),
87+
exercise(ExerciseBlock.B, ExerciseType.PALMING, day = 15),
88+
)
89+
)
90+
)
91+
92+
// when
93+
testScheduler.advanceUntilIdle()
94+
95+
// then
96+
assertThat(component.model.value.cards.first { it.block == ExerciseBlock.A }.completedToday).isFalse()
97+
assertThat(component.model.value.cards.first { it.block == ExerciseBlock.B }.completedToday).isFalse()
98+
}
99+
100+
@Test
101+
fun `when exercises were completed before today then cards stay not completed`() = runTest(testScheduler) {
102+
// given
103+
calendarFlow.value = listOf(
104+
Workout(
105+
exercises = listOf(
106+
exercise(ExerciseBlock.A, ExerciseType.BLINK_BREAK, day = 14),
107+
exercise(ExerciseBlock.B, ExerciseType.PALMING, day = 14),
108+
)
109+
)
110+
)
111+
112+
// when
113+
testScheduler.advanceUntilIdle()
114+
115+
// then
116+
assertThat(component.model.value.cards.any { it.completedToday }).isFalse()
117+
}
118+
119+
@Test
120+
fun `when local time zone changes the today calculation uses local date`() = runTest(testScheduler) {
121+
// given
122+
val timeZone = TimeZone.of("Europe/Moscow")
123+
timeUtils.zone = timeZone
124+
timeUtils.current = instantAt(day = 16, hour = 1, minute = 0, timeZone = timeZone)
125+
calendarFlow.value = listOf(
126+
Workout(
127+
exercises = listOf(
128+
exercise(ExerciseBlock.B, ExerciseType.FIGURE_EIGHT, day = 16, hour = 0, minute = 20, timeZone = timeZone),
129+
exercise(ExerciseBlock.B, ExerciseType.CLOCK_ROLLS, day = 16, hour = 0, minute = 25, timeZone = timeZone),
130+
exercise(ExerciseBlock.B, ExerciseType.PALMING, day = 16, hour = 0, minute = 30, timeZone = timeZone),
131+
)
132+
)
133+
)
134+
135+
// when
136+
testScheduler.advanceUntilIdle()
137+
138+
// then
139+
assertThat(component.model.value.cards.first { it.block == ExerciseBlock.B }.completedToday).isTrue()
140+
}
141+
142+
@Test
143+
fun `when training clicked then output opens matching exercise block`() = runTest(testScheduler) {
144+
// when
145+
component.onBlockAClick()
146+
component.onBlockBClick()
147+
component.onBlockCClick()
148+
149+
// then
150+
assertThat(componentOutput).contains(ComponentOutput.Trainings.OpenExerciseBlock(ExerciseBlock.A))
151+
assertThat(componentOutput).contains(ComponentOutput.Trainings.OpenExerciseBlock(ExerciseBlock.B))
152+
assertThat(componentOutput).contains(ComponentOutput.Trainings.OpenExerciseBlock(ExerciseBlock.C))
153+
}
154+
155+
@Test
156+
fun `when calendar watcher fails then component publishes error output`() = runTest(testScheduler) {
157+
// given
158+
val exception = IllegalStateException("calendar failed")
159+
calendarWatcher = object : BlinklyCalendarWatcher {
160+
override val calendar: Flow<List<Workout>> = flow { throw exception }
161+
}
162+
val testComponent = createComponent()
163+
164+
// when
165+
testScheduler.advanceUntilIdle()
166+
167+
// then
168+
assertThat(testComponent.model.value.cards.any { it.completedToday }).isFalse()
169+
assertThat(
170+
componentOutput.filterIsInstance<ComponentOutput.Common.ErrorCaught>()
171+
.any { it.throwable.message == exception.message }
172+
).isTrue()
173+
}
174+
175+
override fun createComponent(): TrainingsTabComponent =
176+
TrainingsTabComponentDefault(
177+
componentContext = DefaultComponentContext(lifecycle),
178+
storeFactory = DefaultStoreFactory(),
179+
dispatchers = testDispatchers,
180+
timeUtils = timeUtils,
181+
calendarWatcher = calendarWatcher,
182+
trainingsTabOutput = { componentOutput.add(it) },
183+
)
184+
185+
private fun exercise(
186+
block: ExerciseBlock,
187+
type: ExerciseType,
188+
day: Int,
189+
hour: Int = 12,
190+
minute: Int = 0,
191+
timeZone: TimeZone = TimeZone.UTC,
192+
): Exercise =
193+
Exercise(
194+
block = block,
195+
type = type,
196+
completedAt = instantAt(day = day, hour = hour, minute = minute, timeZone = timeZone),
197+
)
198+
199+
private fun instantAt(
200+
day: Int = 15,
201+
hour: Int,
202+
minute: Int,
203+
timeZone: TimeZone = TimeZone.UTC,
204+
): Instant =
205+
LocalDateTime(year = 2026, month = 3, day = day, hour = hour, minute = minute).toInstant(timeZone)
206+
207+
private class FakeTimeUtils : BlinklyTimeUtils {
208+
var current: Instant = LocalDateTime(year = 2026, month = 3, day = 15, hour = 12, minute = 0).toInstant(TimeZone.UTC)
209+
var zone: TimeZone = TimeZone.UTC
210+
211+
override fun now(): Instant = current
212+
override fun timeZone(): TimeZone = zone
213+
}
214+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
11
package com.sedsoftware.blinkly.component.trainings
22

3+
import com.arkivanov.decompose.value.Value
4+
import com.sedsoftware.blinkly.domain.model.ExerciseBlock
5+
36
interface TrainingsTabComponent {
7+
8+
val model: Value<Model>
9+
410
fun onBlockAClick()
511
fun onBlockBClick()
612
fun onBlockCClick()
13+
14+
data class Model(
15+
val cards: List<TrainingCard>,
16+
)
17+
18+
data class TrainingCard(
19+
val block: ExerciseBlock,
20+
val completedToday: Boolean,
21+
)
722
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
@file:OptIn(kotlin.time.ExperimentalTime::class)
2+
3+
package com.sedsoftware.blinkly.component.trainings.domain
4+
5+
import com.sedsoftware.blinkly.domain.BlinklyCalendarWatcher
6+
import com.sedsoftware.blinkly.domain.extension.asLocalDate
7+
import com.sedsoftware.blinkly.domain.external.BlinklyTimeUtils
8+
import com.sedsoftware.blinkly.domain.model.Exercise
9+
import com.sedsoftware.blinkly.domain.model.ExerciseBlock
10+
import com.sedsoftware.blinkly.domain.model.ExerciseType
11+
import com.sedsoftware.blinkly.domain.model.Workout
12+
import kotlinx.coroutines.flow.Flow
13+
import kotlinx.datetime.toLocalDateTime
14+
15+
internal class TrainingsTabManager(
16+
private val calendarWatcher: BlinklyCalendarWatcher,
17+
private val timeUtils: BlinklyTimeUtils,
18+
) {
19+
20+
val calendar: Flow<List<Workout>>
21+
get() = calendarWatcher.calendar
22+
23+
fun completedToday(calendar: List<Workout>): Result<Set<ExerciseBlock>> = runCatching {
24+
val timeZone = timeUtils.timeZone()
25+
val today = timeUtils.now().toLocalDateTime(timeZone).date
26+
27+
val todayExercises = calendar
28+
.flatMap { workout -> workout.exercises }
29+
.filter { exercise -> exercise.completedAt.asLocalDate(timeZone) == today }
30+
31+
buildSet {
32+
if (todayExercises.isBlockACompleted()) {
33+
add(ExerciseBlock.A)
34+
}
35+
if (todayExercises.isBlockBCompleted()) {
36+
add(ExerciseBlock.B)
37+
}
38+
if (todayExercises.any { it.type == ExerciseType.TWENTY_X3 }) {
39+
add(ExerciseBlock.C)
40+
}
41+
}
42+
}
43+
44+
private fun List<Exercise>.isBlockACompleted(): Boolean =
45+
filter { it.block == ExerciseBlock.A }
46+
.map { it.type }
47+
.containsAll(BLOCK_A_TYPES)
48+
49+
private fun List<Exercise>.isBlockBCompleted(): Boolean =
50+
filter { it.block == ExerciseBlock.B }
51+
.map { it.type }
52+
.containsAll(BLOCK_B_TYPES)
53+
54+
private companion object {
55+
val BLOCK_A_TYPES: Set<ExerciseType> = setOf(
56+
ExerciseType.BLINK_BREAK,
57+
ExerciseType.NEAR_FAR_FOCUS,
58+
ExerciseType.DIAGONAL_GAZES,
59+
)
60+
val BLOCK_B_TYPES: Set<ExerciseType> = setOf(
61+
ExerciseType.FIGURE_EIGHT,
62+
ExerciseType.CLOCK_ROLLS,
63+
ExerciseType.PALMING,
64+
)
65+
}
66+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.sedsoftware.blinkly.component.trainings.integration
2+
3+
import com.sedsoftware.blinkly.component.trainings.TrainingsTabComponent.Model
4+
import com.sedsoftware.blinkly.component.trainings.TrainingsTabComponent.TrainingCard
5+
import com.sedsoftware.blinkly.component.trainings.store.TrainingsTabStore.State
6+
import com.sedsoftware.blinkly.domain.model.ExerciseBlock
7+
8+
internal val stateToModel: (State) -> Model = { state ->
9+
Model(
10+
cards = listOf(
11+
ExerciseBlock.A,
12+
ExerciseBlock.C,
13+
ExerciseBlock.B,
14+
).map { block ->
15+
TrainingCard(
16+
block = block,
17+
completedToday = block in state.completedToday,
18+
)
19+
}
20+
)
21+
}

0 commit comments

Comments
 (0)