Skip to content

Commit 5324d9c

Browse files
committed
[Feature] [Item] Refactor password generation into a standalone component
- Decouple `PasswordViewModel` from `GeneratePasswordViewModel` by removing inheritance and using composition. - Introduce `GeneratePasswordModalBottomSheet` to encapsulate password generation logic. - Update `GeneratePasswordContent` to use its own `koinViewModel` and internal state management. - Add `OnPasswordGenerated` event to `PasswordUiEvent` to handle passing the generated password back to the main form. - Apply `internal` visibility modifiers to presentation classes in the create item feature. - Implement a workaround in `ModalBottomSheet` for a Compose M3 keyboard-related crash.
1 parent ae8d1d1 commit 5324d9c

9 files changed

Lines changed: 74 additions & 85 deletions

File tree

feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordContent.kt

Lines changed: 20 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import androidx.compose.animation.shrinkVertically
88
import androidx.compose.foundation.layout.Arrangement
99
import androidx.compose.foundation.layout.Box
1010
import androidx.compose.foundation.layout.FlowRow
11-
import androidx.compose.foundation.layout.fillMaxSize
1211
import androidx.compose.foundation.layout.fillMaxWidth
1312
import androidx.compose.foundation.layout.padding
1413
import androidx.compose.foundation.lazy.LazyColumn
@@ -22,9 +21,9 @@ import androidx.compose.material3.Icon
2221
import androidx.compose.material3.IconButton
2322
import androidx.compose.material3.MaterialTheme
2423
import androidx.compose.material3.Slider
25-
import androidx.compose.material3.Surface
2624
import androidx.compose.material3.Text
2725
import androidx.compose.runtime.Composable
26+
import androidx.compose.runtime.getValue
2827
import androidx.compose.ui.Alignment
2928
import androidx.compose.ui.Modifier
3029
import androidx.compose.ui.draw.clip
@@ -33,29 +32,34 @@ import androidx.compose.ui.res.stringResource
3332
import androidx.compose.ui.text.SpanStyle
3433
import androidx.compose.ui.text.buildAnnotatedString
3534
import androidx.compose.ui.text.withStyle
36-
import androidx.compose.ui.tooling.preview.Preview
3735
import androidx.compose.ui.unit.dp
38-
import de.davis.keygo.core.item.domain.model.Password
36+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
3937
import de.davis.keygo.core.item.presentation.StrengthIndicator
4038
import de.davis.keygo.core.ui.components.KeyGoCard
4139
import de.davis.keygo.core.ui.components.KeyGoCardProperties
40+
import de.davis.keygo.core.util.presentation.ObserveAsEvents
4241
import de.davis.keygo.feature.item.create.R
4342
import de.davis.keygo.feature.item.create.presentation.password.model.GeneratePasswordUiEvent
44-
import de.davis.keygo.feature.item.create.presentation.password.model.GeneratePasswordUiState
4543
import de.davis.keygo.feature.item.create.presentation.password.model.UiCharacterSet
4644
import de.davis.keygo.feature.item.create.presentation.password.model.UiPassword
47-
import de.davis.keygo.feature.item.create.presentation.password.model.UiPassword.Companion.asUiPassword
45+
import org.koin.androidx.compose.koinViewModel
4846
import de.davis.keygo.core.item.R as CoreItemR
4947

5048
@OptIn(ExperimentalMaterial3Api::class)
5149
@Composable
5250
fun GeneratePasswordContent(
53-
state: GeneratePasswordUiState,
54-
onEvent: (GeneratePasswordUiEvent) -> Unit = {},
51+
onGenerated: (String) -> Unit,
5552
containerColor: Color = MaterialTheme.colorScheme.surface,
5653
) {
54+
val viewModel = koinViewModel<GeneratePasswordViewModel>()
55+
val state by viewModel.generationState.collectAsStateWithLifecycle()
56+
5757
val cardProp = KeyGoCardProperties.outlined(containerColor = containerColor)
5858

59+
ObserveAsEvents(viewModel.finalPassword) {
60+
onGenerated(it)
61+
}
62+
5963
LazyColumn(
6064
modifier = Modifier
6165
.fillMaxWidth()
@@ -107,7 +111,7 @@ fun GeneratePasswordContent(
107111
properties = cardProp,
108112
trailingItem = {
109113
IconButton(
110-
onClick = { onEvent(GeneratePasswordUiEvent.OnGeneratePasswordClick) }
114+
onClick = { viewModel.onEvent(GeneratePasswordUiEvent.OnGeneratePasswordClick) }
111115
) {
112116
Icon(imageVector = Icons.Default.Refresh, contentDescription = null)
113117
}
@@ -143,15 +147,15 @@ fun GeneratePasswordContent(
143147
Text(
144148
text = stringResource(
145149
R.string.length,
146-
state.sliderState.value.toInt()
150+
viewModel.sliderState.value.toInt()
147151
)
148152
)
149153
},
150154
modifier = Modifier
151155
.fillMaxWidth(),
152156
properties = cardProp,
153157
) {
154-
Slider(state = state.sliderState)
158+
Slider(state = viewModel.sliderState)
155159
}
156160
}
157161

@@ -171,7 +175,7 @@ fun GeneratePasswordContent(
171175
selectedCharacterSet = state.characterSet,
172176
characterSet = UiCharacterSet.LOWERCASE,
173177
onClick = {
174-
onEvent(
178+
viewModel.onEvent(
175179
GeneratePasswordUiEvent.OnCharacterSetClick(
176180
UiCharacterSet.LOWERCASE
177181
)
@@ -185,7 +189,7 @@ fun GeneratePasswordContent(
185189
selectedCharacterSet = state.characterSet,
186190
characterSet = UiCharacterSet.UPPERCASE,
187191
onClick = {
188-
onEvent(
192+
viewModel.onEvent(
189193
GeneratePasswordUiEvent.OnCharacterSetClick(
190194
UiCharacterSet.UPPERCASE
191195
)
@@ -199,7 +203,7 @@ fun GeneratePasswordContent(
199203
selectedCharacterSet = state.characterSet,
200204
characterSet = UiCharacterSet.DIGITS,
201205
onClick = {
202-
onEvent(
206+
viewModel.onEvent(
203207
GeneratePasswordUiEvent.OnCharacterSetClick(
204208
UiCharacterSet.DIGITS
205209
)
@@ -213,7 +217,7 @@ fun GeneratePasswordContent(
213217
selectedCharacterSet = state.characterSet,
214218
characterSet = UiCharacterSet.PUNCTUATIONS,
215219
onClick = {
216-
onEvent(
220+
viewModel.onEvent(
217221
GeneratePasswordUiEvent.OnCharacterSetClick(
218222
UiCharacterSet.PUNCTUATIONS
219223
)
@@ -233,7 +237,7 @@ fun GeneratePasswordContent(
233237
contentAlignment = Alignment.CenterEnd,
234238
) {
235239
Button(
236-
onClick = { onEvent(GeneratePasswordUiEvent.OnUseClick) }
240+
onClick = { viewModel.onEvent(GeneratePasswordUiEvent.OnUseClick) }
237241
) {
238242
Text(text = stringResource(R.string.use_generated_password))
239243
}
@@ -255,20 +259,3 @@ fun CharacterSetChip(
255259
onClick = onClick,
256260
)
257261
}
258-
259-
@OptIn(ExperimentalMaterial3Api::class)
260-
@Preview
261-
@Composable
262-
private fun GeneratePasswordBottomSheetPreview() {
263-
MaterialTheme {
264-
Surface(modifier = Modifier.fillMaxSize()) {
265-
GeneratePasswordContent(
266-
state = GeneratePasswordUiState(
267-
generatedPassword = "p@ssw0rd".asUiPassword(),
268-
passwordStrength = Password.Score.Ridiculous,
269-
showCaution = true,
270-
)
271-
)
272-
}
273-
}
274-
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package de.davis.keygo.feature.item.create.presentation.password
2+
3+
import androidx.compose.material3.BottomSheetDefaults
4+
import androidx.compose.material3.ExperimentalMaterial3Api
5+
import androidx.compose.material3.ModalBottomSheet
6+
import androidx.compose.material3.rememberModalBottomSheetState
7+
import androidx.compose.runtime.Composable
8+
9+
@OptIn(ExperimentalMaterial3Api::class)
10+
@Composable
11+
fun GeneratePasswordModalBottomSheet(
12+
onGenerated: (String) -> Unit,
13+
onDismiss: () -> Unit,
14+
) {
15+
ModalBottomSheet(
16+
onDismissRequest = onDismiss,
17+
sheetState = rememberModalBottomSheetState(
18+
// Workaround for a Compose M3 bug: prevents the app from crashing
19+
// when the keyboard opens and shrinks the available screen height.
20+
// Setting this to true prevents the AnchoredDraggableUninitializedException.
21+
skipPartiallyExpanded = true
22+
),
23+
) {
24+
GeneratePasswordContent(
25+
onGenerated = onGenerated,
26+
containerColor = BottomSheetDefaults.ContainerColor,
27+
)
28+
}
29+
}

feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordViewModel.kt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,20 +33,18 @@ import kotlin.time.Duration.Companion.milliseconds
3333

3434
@KoinViewModel
3535
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
36-
open class GeneratePasswordViewModel(
36+
internal class GeneratePasswordViewModel(
3737
private val passwordGenerator: PasswordGenerator,
3838
private val passwordStrengthEstimator: PasswordStrengthEstimator,
3939
) : ViewModel() {
4040

4141
@OptIn(ExperimentalMaterial3Api::class)
42-
private val sliderState = SliderState(value = 10f, valueRange = 8f..100f)
42+
val sliderState = SliderState(value = 10f, valueRange = 8f..100f)
4343

4444
private val finalPasswordChannel = Channel<String>()
4545
val finalPassword = finalPasswordChannel.receiveAsFlow()
4646

47-
@OptIn(ExperimentalMaterial3Api::class)
48-
private val _generationState =
49-
MutableStateFlow(GeneratePasswordUiState(sliderState = sliderState))
47+
private val _generationState = MutableStateFlow(GeneratePasswordUiState())
5048
val generationState = _generationState
5149
.onStart {
5250
observeLength()
@@ -55,7 +53,7 @@ open class GeneratePasswordViewModel(
5553
.stateIn(
5654
scope = viewModelScope,
5755
started = SharingStarted.WhileSubscribed(5_000),
58-
initialValue = GeneratePasswordUiState(sliderState = sliderState)
56+
initialValue = GeneratePasswordUiState()
5957
)
6058

6159
private fun observeLength() {

feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/PasswordContent.kt

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,12 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
1515
import androidx.compose.material.icons.filled.AutoAwesome
1616
import androidx.compose.material.icons.filled.Done
1717
import androidx.compose.material.icons.filled.QrCodeScanner
18-
import androidx.compose.material3.BottomSheetDefaults
1918
import androidx.compose.material3.ExperimentalMaterial3Api
2019
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
2120
import androidx.compose.material3.Icon
2221
import androidx.compose.material3.IconButton
2322
import androidx.compose.material3.InputChip
2423
import androidx.compose.material3.MediumFlexibleTopAppBar
25-
import androidx.compose.material3.ModalBottomSheet
2624
import androidx.compose.material3.Scaffold
2725
import androidx.compose.material3.Text
2826
import androidx.compose.material3.TopAppBarDefaults
@@ -63,7 +61,7 @@ import de.davis.keygo.feature.item.core.R as ItemCoreR
6361

6462
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
6563
@Composable
66-
fun PasswordContent(state: PasswordUiState, onEvent: (PasswordUiEvent) -> Unit) {
64+
internal fun PasswordContent(state: PasswordUiState, onEvent: (PasswordUiEvent) -> Unit) {
6765
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
6866
val domainTextFieldState = rememberTextFieldState()
6967
val schemeTransformation = rememberSchemeStrippingTransformation()
@@ -275,15 +273,10 @@ fun PasswordContent(state: PasswordUiState, onEvent: (PasswordUiEvent) -> Unit)
275273
}
276274

277275
if (state.generatePasswordBottomSheetVisible) {
278-
ModalBottomSheet(
279-
onDismissRequest = { onEvent(PasswordUiEvent.OnCloseBottomSheet) },
280-
) {
281-
GeneratePasswordContent(
282-
state = state.generatePasswordState,
283-
onEvent = onEvent,
284-
containerColor = BottomSheetDefaults.ContainerColor,
285-
)
286-
}
276+
GeneratePasswordModalBottomSheet(
277+
onGenerated = { onEvent(PasswordUiEvent.OnPasswordGenerated(it)) },
278+
onDismiss = { onEvent(PasswordUiEvent.OnCloseBottomSheet) }
279+
)
287280
}
288281

289282
if (state.scanning) {

feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/PasswordViewModel.kt

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.util.Log
44
import androidx.compose.foundation.text.input.TextFieldState
55
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
66
import androidx.compose.runtime.snapshotFlow
7+
import androidx.lifecycle.ViewModel
78
import androidx.lifecycle.viewModelScope
89
import de.davis.keygo.core.item.domain.alias.ItemId
910
import de.davis.keygo.core.item.domain.alias.ItemIdNone
@@ -29,9 +30,7 @@ import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation
2930
import de.davis.keygo.feature.item.core.presentation.model.InputFieldError
3031
import de.davis.keygo.feature.item.core.presentation.password.model.FieldType
3132
import de.davis.keygo.feature.item.create.R
32-
import de.davis.keygo.feature.item.create.domain.PasswordGenerator
3333
import de.davis.keygo.feature.item.create.presentation.password.model.DialogState
34-
import de.davis.keygo.feature.item.create.presentation.password.model.GeneratePasswordUiEvent
3534
import de.davis.keygo.feature.item.create.presentation.password.model.OverrideTotpField
3635
import de.davis.keygo.feature.item.create.presentation.password.model.PasswordUiEvent
3736
import de.davis.keygo.feature.item.create.presentation.password.model.PasswordUiState
@@ -60,8 +59,7 @@ import org.koin.core.annotation.KoinViewModel
6059
import kotlin.time.Duration.Companion.milliseconds
6160

6261
@KoinViewModel
63-
class PasswordViewModel(
64-
passwordGenerator: PasswordGenerator,
62+
internal class PasswordViewModel(
6563
private val vaultItemRepository: VaultItemRepository,
6664
private val passwordRepository: PasswordRepository,
6765
private val cryptographicScopeProvider: CryptographicScopeProvider,
@@ -70,7 +68,7 @@ class PasswordViewModel(
7068
private val snackbarManager: SnackbarManager,
7169
private val getTotpSecret: GetTotpSecretFromUrlUseCase,
7270
private val registrableDomainResolver: RegistrableDomainResolver
73-
) : GeneratePasswordViewModel(passwordGenerator, passwordStrengthEstimator) {
71+
) : ViewModel() {
7472

7573
private val nameTextFieldState = TextFieldState()
7674
private val passwordTextFieldState = TextFieldState()
@@ -85,7 +83,6 @@ class PasswordViewModel(
8583
.onStart {
8684
observeNameTextField()
8785
observePasswordTextField()
88-
observeGenerator()
8986
}
9087
.stateIn(
9188
scope = viewModelScope,
@@ -131,21 +128,6 @@ class PasswordViewModel(
131128
.launchIn(viewModelScope)
132129
}
133130

134-
private fun observeGenerator() {
135-
generationState.onEach { state ->
136-
_uiState.update {
137-
it.copy(generatePasswordState = state)
138-
}
139-
}.launchIn(viewModelScope)
140-
141-
finalPassword.onEach { password ->
142-
passwordTextFieldState.setTextAndPlaceCursorAtEnd(password)
143-
_uiState.update {
144-
it.copy(generatePasswordBottomSheetVisible = false)
145-
}
146-
}.launchIn(viewModelScope)
147-
}
148-
149131
private fun navigateUp(itemId: ItemId? = null) {
150132
viewModelScope.launch {
151133
itemCreatedEventChannel.send(itemId)
@@ -340,8 +322,6 @@ class PasswordViewModel(
340322
_uiState.update { it.copy(generatePasswordBottomSheetVisible = false) }
341323
}
342324

343-
is GeneratePasswordUiEvent -> super.onEvent(event)
344-
345325
is PasswordUiEvent.OnScanCodeRequest -> {
346326
_uiState.update { it.copy(scanning = true) }
347327
}
@@ -441,6 +421,13 @@ class PasswordViewModel(
441421
it.copy(domains = newList)
442422
}
443423
}
424+
425+
is PasswordUiEvent.OnPasswordGenerated -> {
426+
passwordTextFieldState.setTextAndPlaceCursorAtEnd(event.password)
427+
_uiState.update {
428+
it.copy(generatePasswordBottomSheetVisible = false)
429+
}
430+
}
444431
}
445432
}
446433

feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/GeneratePasswordUiEvent.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package de.davis.keygo.feature.item.create.presentation.password.model
22

3-
sealed interface GeneratePasswordUiEvent : PasswordUiEvent {
3+
internal sealed interface GeneratePasswordUiEvent {
44
data class OnCharacterSetClick(val uiCharacterSet: UiCharacterSet) : GeneratePasswordUiEvent
55

66
data object OnGeneratePasswordClick : GeneratePasswordUiEvent

feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/GeneratePasswordUiState.kt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
package de.davis.keygo.feature.item.create.presentation.password.model
22

3-
import androidx.compose.material3.ExperimentalMaterial3Api
4-
import androidx.compose.material3.SliderState
53
import de.davis.keygo.core.item.domain.model.Password
64

7-
data class GeneratePasswordUiState(
5+
internal data class GeneratePasswordUiState(
86
val generatedPassword: UiPassword = UiPassword(""),
97
val passwordStrength: Password.Score = Password.Score.None,
10-
@OptIn(ExperimentalMaterial3Api::class)
11-
val sliderState: SliderState = SliderState(value = 10f, valueRange = 8f..100f),
128

139
val characterSet: UiCharacterSet = UiCharacterSet.ALL,
1410
val showCaution: Boolean = false,

feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/PasswordUiEvent.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package de.davis.keygo.feature.item.create.presentation.password.model
33
import de.davis.keygo.core.item.domain.alias.ItemId
44
import de.davis.keygo.feature.item.core.presentation.password.model.FieldType
55

6-
sealed interface PasswordUiEvent {
6+
internal sealed interface PasswordUiEvent {
77
data object OnSubmit : PasswordUiEvent
88
data object OnGeneratePasswordClick : PasswordUiEvent
99
data object OnBackClick : PasswordUiEvent
@@ -22,4 +22,6 @@ sealed interface PasswordUiEvent {
2222
data class OnOverrideFieldClicked(val fieldType: FieldType) : PasswordUiEvent
2323
data object OnOverrideTotpFieldsConfirmed : PasswordUiEvent
2424
data object OnOverrideTotpFieldsKept : PasswordUiEvent
25+
26+
data class OnPasswordGenerated(val password: String) : PasswordUiEvent
2527
}

0 commit comments

Comments
 (0)