Skip to content

Commit 7df3159

Browse files
committed
feat: pre-fill existing config in setup screens
1 parent 257a1b1 commit 7df3159

File tree

3 files changed

+384
-0
lines changed

3 files changed

+384
-0
lines changed

app/src/main/java/com/jodli/coffeeshottimer/ui/viewmodel/EquipmentSetupFlowViewModel.kt

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ class EquipmentSetupFlowViewModel @Inject constructor(
3131

3232
private val _uiState = MutableStateFlow(EquipmentSetupFlowUiState())
3333
val uiState: StateFlow<EquipmentSetupFlowUiState> = _uiState.asStateFlow()
34+
35+
init {
36+
// Pre-load existing configurations to make it easier for returning users
37+
loadExistingConfigurations()
38+
}
3439

3540
/**
3641
* Navigate to the next step in the equipment setup flow
@@ -423,6 +428,53 @@ class EquipmentSetupFlowViewModel @Inject constructor(
423428
fun clearError() {
424429
_uiState.value = _uiState.value.copy(error = null)
425430
}
431+
432+
/**
433+
* Loads existing equipment configurations from repositories
434+
* and pre-fills the UI state to make it easier for returning users
435+
* to update only the new fields they care about.
436+
*/
437+
private fun loadExistingConfigurations() {
438+
viewModelScope.launch {
439+
try {
440+
// Load existing grinder configuration if available
441+
val grinderResult = grinderConfigRepository.getCurrentConfig()
442+
grinderResult.fold(
443+
onSuccess = { grinderConfig ->
444+
if (grinderConfig != null) {
445+
_uiState.value = _uiState.value.copy(
446+
grinderScaleMin = grinderConfig.scaleMin.toString(),
447+
grinderScaleMax = grinderConfig.scaleMax.toString(),
448+
grinderStepSize = grinderConfig.stepSize.toString()
449+
)
450+
validateGrinder()
451+
}
452+
},
453+
onFailure = { /* Silently continue with defaults */ }
454+
)
455+
456+
// Load existing basket configuration if available
457+
val basketResult = basketConfigRepository.getActiveConfig()
458+
basketResult.fold(
459+
onSuccess = { basketConfig ->
460+
if (basketConfig != null) {
461+
_uiState.value = _uiState.value.copy(
462+
coffeeInMin = basketConfig.coffeeInMin.toInt().toString(),
463+
coffeeInMax = basketConfig.coffeeInMax.toInt().toString(),
464+
coffeeOutMin = basketConfig.coffeeOutMin.toInt().toString(),
465+
coffeeOutMax = basketConfig.coffeeOutMax.toInt().toString()
466+
)
467+
validateBasket()
468+
}
469+
},
470+
onFailure = { /* Silently continue with defaults */ }
471+
)
472+
} catch (e: Exception) {
473+
// Silently continue with defaults if loading fails
474+
// Users can still configure manually if needed
475+
}
476+
}
477+
}
426478
}
427479

428480
/**
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package com.jodli.coffeeshottimer.ui.viewmodel
2+
3+
import com.jodli.coffeeshottimer.data.model.BasketConfiguration
4+
import com.jodli.coffeeshottimer.data.model.GrinderConfiguration
5+
import com.jodli.coffeeshottimer.data.onboarding.OnboardingManager
6+
import com.jodli.coffeeshottimer.data.repository.BasketConfigRepository
7+
import com.jodli.coffeeshottimer.data.repository.GrinderConfigRepository
8+
import io.mockk.coEvery
9+
import io.mockk.mockk
10+
import kotlinx.coroutines.Dispatchers
11+
import kotlinx.coroutines.ExperimentalCoroutinesApi
12+
import kotlinx.coroutines.flow.first
13+
import kotlinx.coroutines.test.StandardTestDispatcher
14+
import kotlinx.coroutines.test.advanceUntilIdle
15+
import kotlinx.coroutines.test.resetMain
16+
import kotlinx.coroutines.test.runTest
17+
import kotlinx.coroutines.test.setMain
18+
import org.junit.After
19+
import org.junit.Assert.assertEquals
20+
import org.junit.Assert.assertTrue
21+
import org.junit.Before
22+
import org.junit.Test
23+
24+
/**
25+
* Unit tests for EquipmentSetupFlowViewModel pre-filling behavior.
26+
* Tests that existing configurations are loaded and pre-filled in the UI state.
27+
*/
28+
@OptIn(ExperimentalCoroutinesApi::class)
29+
class EquipmentSetupFlowViewModelPreFillTest {
30+
31+
private lateinit var grinderConfigRepository: GrinderConfigRepository
32+
private lateinit var basketConfigRepository: BasketConfigRepository
33+
private lateinit var onboardingManager: OnboardingManager
34+
private lateinit var viewModel: EquipmentSetupFlowViewModel
35+
private val testDispatcher = StandardTestDispatcher()
36+
37+
@Before
38+
fun setup() {
39+
Dispatchers.setMain(testDispatcher)
40+
grinderConfigRepository = mockk()
41+
basketConfigRepository = mockk()
42+
onboardingManager = mockk()
43+
}
44+
45+
@After
46+
fun tearDown() {
47+
Dispatchers.resetMain()
48+
}
49+
50+
@Test
51+
fun `when existing grinder config exists, pre-fills grinder fields`() = runTest {
52+
// Given
53+
val existingGrinderConfig = GrinderConfiguration(
54+
scaleMin = 1,
55+
scaleMax = 10,
56+
stepSize = 0.5
57+
)
58+
59+
coEvery { grinderConfigRepository.getCurrentConfig() } returns Result.success(existingGrinderConfig)
60+
coEvery { basketConfigRepository.getActiveConfig() } returns Result.success(null)
61+
62+
// When
63+
viewModel = EquipmentSetupFlowViewModel(grinderConfigRepository, basketConfigRepository, onboardingManager)
64+
advanceUntilIdle()
65+
66+
// Then
67+
val uiState = viewModel.uiState.first()
68+
assertEquals("1", uiState.grinderScaleMin)
69+
assertEquals("10", uiState.grinderScaleMax)
70+
assertEquals("0.5", uiState.grinderStepSize)
71+
assertTrue("Grinder should be valid after pre-filling", uiState.isGrinderValid)
72+
}
73+
74+
@Test
75+
fun `when existing basket config exists, pre-fills basket fields`() = runTest {
76+
// Given
77+
val existingBasketConfig = BasketConfiguration(
78+
coffeeInMin = 14f,
79+
coffeeInMax = 22f,
80+
coffeeOutMin = 28f,
81+
coffeeOutMax = 55f,
82+
isActive = true
83+
)
84+
85+
coEvery { grinderConfigRepository.getCurrentConfig() } returns Result.success(null)
86+
coEvery { basketConfigRepository.getActiveConfig() } returns Result.success(existingBasketConfig)
87+
88+
// When
89+
viewModel = EquipmentSetupFlowViewModel(grinderConfigRepository, basketConfigRepository, onboardingManager)
90+
advanceUntilIdle()
91+
92+
// Then
93+
val uiState = viewModel.uiState.first()
94+
assertEquals("14", uiState.coffeeInMin)
95+
assertEquals("22", uiState.coffeeInMax)
96+
assertEquals("28", uiState.coffeeOutMin)
97+
assertEquals("55", uiState.coffeeOutMax)
98+
assertTrue("Basket should be valid after pre-filling", uiState.isBasketValid)
99+
}
100+
101+
@Test
102+
fun `when both configs exist, pre-fills both grinder and basket fields`() = runTest {
103+
// Given
104+
val existingGrinderConfig = GrinderConfiguration(
105+
scaleMin = 30,
106+
scaleMax = 80,
107+
stepSize = 1.0
108+
)
109+
val existingBasketConfig = BasketConfiguration(
110+
coffeeInMin = 7f,
111+
coffeeInMax = 12f,
112+
coffeeOutMin = 20f,
113+
coffeeOutMax = 40f,
114+
isActive = true
115+
)
116+
117+
coEvery { grinderConfigRepository.getCurrentConfig() } returns Result.success(existingGrinderConfig)
118+
coEvery { basketConfigRepository.getActiveConfig() } returns Result.success(existingBasketConfig)
119+
120+
// When
121+
viewModel = EquipmentSetupFlowViewModel(grinderConfigRepository, basketConfigRepository, onboardingManager)
122+
advanceUntilIdle()
123+
124+
// Then
125+
val uiState = viewModel.uiState.first()
126+
127+
// Grinder fields
128+
assertEquals("30", uiState.grinderScaleMin)
129+
assertEquals("80", uiState.grinderScaleMax)
130+
assertEquals("1.0", uiState.grinderStepSize)
131+
assertTrue("Grinder should be valid after pre-filling", uiState.isGrinderValid)
132+
133+
// Basket fields
134+
assertEquals("7", uiState.coffeeInMin)
135+
assertEquals("12", uiState.coffeeInMax)
136+
assertEquals("20", uiState.coffeeOutMin)
137+
assertEquals("40", uiState.coffeeOutMax)
138+
assertTrue("Basket should be valid after pre-filling", uiState.isBasketValid)
139+
}
140+
141+
@Test
142+
fun `when no existing configs, uses default empty values`() = runTest {
143+
// Given
144+
coEvery { grinderConfigRepository.getCurrentConfig() } returns Result.success(null)
145+
coEvery { basketConfigRepository.getActiveConfig() } returns Result.success(null)
146+
147+
// When
148+
viewModel = EquipmentSetupFlowViewModel(grinderConfigRepository, basketConfigRepository, onboardingManager)
149+
advanceUntilIdle()
150+
151+
// Then
152+
val uiState = viewModel.uiState.first()
153+
154+
// Should use defaults from EquipmentSetupFlowUiState
155+
assertEquals("", uiState.grinderScaleMin)
156+
assertEquals("", uiState.grinderScaleMax)
157+
assertEquals("0.5", uiState.grinderStepSize) // Default step size
158+
assertEquals("", uiState.coffeeInMin)
159+
assertEquals("", uiState.coffeeInMax)
160+
assertEquals("", uiState.coffeeOutMin)
161+
assertEquals("", uiState.coffeeOutMax)
162+
}
163+
164+
@Test
165+
fun `when repository fails, gracefully continues with defaults`() = runTest {
166+
// Given
167+
coEvery { grinderConfigRepository.getCurrentConfig() } returns Result.failure(Exception("Database error"))
168+
coEvery { basketConfigRepository.getActiveConfig() } returns Result.failure(Exception("Database error"))
169+
170+
// When
171+
viewModel = EquipmentSetupFlowViewModel(grinderConfigRepository, basketConfigRepository, onboardingManager)
172+
advanceUntilIdle()
173+
174+
// Then
175+
val uiState = viewModel.uiState.first()
176+
177+
// Should gracefully handle errors and use defaults
178+
assertEquals("", uiState.grinderScaleMin)
179+
assertEquals("", uiState.grinderScaleMax)
180+
assertEquals("0.5", uiState.grinderStepSize)
181+
assertEquals("", uiState.coffeeInMin)
182+
assertEquals("", uiState.coffeeInMax)
183+
assertEquals("", uiState.coffeeOutMin)
184+
assertEquals("", uiState.coffeeOutMax)
185+
}
186+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Equipment Setup Versioning Guidelines
2+
3+
## Overview
4+
5+
The app uses an equipment setup versioning system to force existing users through new equipment configuration features without requiring them to complete the entire onboarding flow.
6+
7+
## When to Use
8+
9+
Use this versioning system when adding **new equipment configuration features** such as:
10+
- New grinder settings (e.g., step size configuration)
11+
- New basket/portafilter options
12+
- Additional hardware setup parameters
13+
- Any feature that affects shot recording accuracy
14+
15+
**Do NOT use for:**
16+
- UI improvements or bug fixes
17+
- New screens unrelated to equipment setup
18+
- Features that don't require user configuration
19+
20+
## How It Works
21+
22+
### 1. Version Tracking
23+
```kotlin
24+
// OnboardingProgress.kt
25+
companion object {
26+
const val CURRENT_EQUIPMENT_SETUP_VERSION = 2 // INCREMENT THIS
27+
}
28+
29+
data class OnboardingProgress(
30+
val equipmentSetupVersion: Int = 1, // User's completed version
31+
// ... other fields
32+
)
33+
```
34+
35+
### 2. Detection Logic
36+
```kotlin
37+
fun needsEquipmentSetup(): Boolean {
38+
return !hasCompletedEquipmentSetup ||
39+
equipmentSetupVersion < CURRENT_EQUIPMENT_SETUP_VERSION
40+
}
41+
```
42+
43+
### 3. Routing Behavior
44+
- **New users**: See complete onboarding flow including new features
45+
- **Existing users**: Skip intro/bean creation, only see equipment setup screens
46+
- **Up-to-date users**: Skip equipment setup entirely
47+
48+
## Implementation Steps
49+
50+
### Step 1: Add New Equipment Feature
51+
Add your new configuration options to:
52+
- Data models (e.g., `GrinderConfiguration`, `BasketConfiguration`)
53+
- UI components in equipment setup flow
54+
- Validation logic
55+
- Database migrations if needed
56+
57+
**Note**: The `EquipmentSetupFlowViewModel` automatically loads existing configurations on initialization, so users will see their current settings pre-filled when forced through equipment setup.
58+
59+
### Step 2: Increment Version
60+
```kotlin
61+
// In OnboardingProgress.kt
62+
const val CURRENT_EQUIPMENT_SETUP_VERSION = 3 // Was 2, now 3
63+
```
64+
65+
### Step 3: Test User Flows
66+
Verify that:
67+
- **New users** see the new feature during onboarding
68+
- **Existing users** are prompted to configure the new feature
69+
- **Users who skip** are still marked with current version
70+
- **Up-to-date users** skip equipment setup
71+
72+
## Example: Adding Step Size Configuration
73+
74+
**Before (Version 2):**
75+
- Grinder setup: min/max scale only
76+
- Basket setup: weight ranges
77+
78+
**After (Version 3):**
79+
```kotlin
80+
// 1. Updated GrinderConfiguration with stepSize field
81+
// 2. Added step size UI to grinder setup screen
82+
// 3. Incremented version:
83+
const val CURRENT_EQUIPMENT_SETUP_VERSION = 3
84+
```
85+
86+
**Result:**
87+
- Existing users get prompted to configure step size on next app launch
88+
- They skip intro screens and go straight to equipment setup
89+
- New users see step size configuration as part of normal onboarding
90+
91+
## User Experience
92+
93+
### New User Flow
94+
```
95+
App Launch → Introduction → Equipment Setup (with new feature) → Bean Creation → Main App
96+
```
97+
98+
### Existing User Flow (Feature Update)
99+
```
100+
App Launch → Equipment Setup (pre-filled with existing values) → Main App
101+
↑ Skips intro and bean creation, existing values loaded
102+
```
103+
104+
### Up-to-date User Flow
105+
```
106+
App Launch → Main App
107+
```
108+
109+
## Pre-filled Configuration Behavior
110+
111+
When existing users are prompted for equipment setup due to version increments:
112+
113+
1. **Existing values are automatically loaded** from the database
114+
2. **All screens show current settings** - users see their existing configuration
115+
3. **Users can click through unchanged screens** quickly
116+
4. **Users stop only at screens with new features** they want to configure
117+
5. **Graceful fallback to defaults** if existing configuration can't be loaded
118+
119+
This makes the update experience much smoother for existing users who may only want to configure the new feature.
120+
121+
## Best Practices
122+
123+
1. **Increment carefully**: Only increment when users MUST configure the new feature
124+
2. **Backward compatibility**: Ensure old configurations still work with sensible defaults
125+
3. **Clear validation**: New features should have helpful validation messages
126+
4. **Skip option**: Allow users to skip if the feature is optional
127+
5. **Version in DB**: Consider adding version fields to configuration entities for future flexibility
128+
129+
## Testing Checklist
130+
131+
- [ ] New users see the new feature in onboarding
132+
- [ ] Existing users are prompted for the new feature only
133+
- [ ] **Existing users see their current settings pre-filled**
134+
- [ ] **Users can click through unchanged screens quickly**
135+
- [ ] Skip functionality works correctly
136+
- [ ] Database migration handles existing data
137+
- [ ] App doesn't crash with old configuration data
138+
- [ ] **Pre-filling gracefully handles missing configurations**
139+
- [ ] Version increment is committed and documented
140+
141+
## Notes
142+
143+
- The version system is stored in `OnboardingProgress` and persisted via `OnboardingManager`
144+
- MainActivityViewModel handles the routing logic based on version checks
145+
- Equipment setup screens are defined in `EquipmentSetupFlowScreen.kt`
146+
- Version increments should be documented in commit messages and release notes

0 commit comments

Comments
 (0)