Skip to content

Commit 0ea7ab9

Browse files
committed
refactor: migrate RestoreWalletScreen to MVVM
1 parent ab5bacf commit 0ea7ab9

File tree

2 files changed

+217
-220
lines changed

2 files changed

+217
-220
lines changed

app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt

Lines changed: 43 additions & 220 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,17 @@ import androidx.compose.material3.OutlinedTextField
2424
import androidx.compose.material3.Scaffold
2525
import androidx.compose.material3.Text
2626
import androidx.compose.runtime.Composable
27-
import androidx.compose.runtime.derivedStateOf
27+
import androidx.compose.runtime.LaunchedEffect
2828
import androidx.compose.runtime.getValue
29-
import androidx.compose.runtime.mutableStateListOf
30-
import androidx.compose.runtime.mutableStateOf
3129
import androidx.compose.runtime.remember
32-
import androidx.compose.runtime.rememberCoroutineScope
33-
import androidx.compose.runtime.setValue
34-
import androidx.compose.runtime.snapshots.SnapshotStateList
30+
import androidx.hilt.navigation.compose.hiltViewModel
31+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
3532
import androidx.compose.ui.Alignment
3633
import androidx.compose.ui.Modifier
3734
import androidx.compose.ui.focus.onFocusChanged
3835
import androidx.compose.ui.layout.onGloballyPositioned
3936
import androidx.compose.ui.layout.positionInParent
37+
import androidx.compose.ui.platform.LocalFocusManager
4038
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
4139
import androidx.compose.ui.platform.testTag
4240
import androidx.compose.ui.res.stringResource
@@ -58,60 +56,35 @@ import to.bitkit.ui.theme.AppTextFieldDefaults
5856
import to.bitkit.ui.theme.AppThemeSurface
5957
import to.bitkit.ui.theme.Colors
6058
import to.bitkit.ui.utils.withAccent
61-
import to.bitkit.utils.bip39Words
62-
import to.bitkit.utils.isBip39
63-
import to.bitkit.utils.validBip39Checksum
59+
import to.bitkit.viewmodels.RestoreWalletViewModel
6460

6561
@Composable
6662
fun RestoreWalletView(
63+
viewModel: RestoreWalletViewModel = hiltViewModel(),
6764
onBackClick: () -> Unit,
6865
onRestoreClick: (mnemonic: String, passphrase: String?) -> Unit,
6966
) {
70-
val words = remember { mutableStateListOf(*Array(24) { "" }) }
71-
val invalidWordIndices = remember { mutableStateListOf<Int>() }
72-
val suggestions = remember { mutableStateListOf<String>() }
73-
var focusedIndex by remember { mutableStateOf<Int?>(null) }
74-
var bip39Passphrase by remember { mutableStateOf("") }
75-
var showingPassphrase by remember { mutableStateOf(false) }
76-
var firstFieldText by remember { mutableStateOf("") }
77-
var is24Words by remember { mutableStateOf(false) }
78-
val checksumErrorVisible by remember {
79-
derivedStateOf {
80-
val wordCount = if (is24Words) 24 else 12
81-
words.subList(0, wordCount).none { it.isBlank() } && invalidWordIndices.isEmpty() && !words.subList(
82-
0,
83-
wordCount
84-
).validBip39Checksum()
85-
}
86-
}
67+
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
8768

8869
val scrollState = rememberScrollState()
89-
val coroutineScope = rememberCoroutineScope()
90-
val keyboardController = LocalSoftwareKeyboardController.current
9170
val inputFieldPositions = remember { mutableMapOf<Int, Int>() }
71+
val keyboardController = LocalSoftwareKeyboardController.current
72+
val focusManager = LocalFocusManager.current
9273

93-
val wordsPerColumn = if (is24Words) 12 else 6
94-
95-
val bip39Mnemonic by remember {
96-
derivedStateOf {
97-
val wordCount = if (is24Words) 24 else 12
98-
words.subList(0, wordCount)
99-
.joinToString(separator = " ")
100-
.trim()
74+
LaunchedEffect(uiState.shouldDismissKeyboard) {
75+
if (uiState.shouldDismissKeyboard) {
76+
focusManager.clearFocus()
77+
keyboardController?.hide()
78+
viewModel.onKeyboardDismissed()
10179
}
10280
}
10381

104-
fun updateSuggestions(input: String, index: Int?) {
105-
if (index == null || input.length < 2) {
106-
suggestions.clear()
107-
return
108-
}
109-
110-
suggestions.clear()
111-
if (input.isNotEmpty()) {
112-
val filtered = bip39Words.filter { it.startsWith(input.lowercase()) }.take(3)
113-
if (filtered.size == 1 && filtered.firstOrNull() == input) return
114-
suggestions.addAll(filtered)
82+
LaunchedEffect(uiState.scrollToFieldIndex) {
83+
uiState.scrollToFieldIndex?.let { index ->
84+
inputFieldPositions[index]?.let { position ->
85+
scrollState.animateScrollTo(position)
86+
}
87+
viewModel.onScrollCompleted()
11588
}
11689
}
11790

@@ -149,60 +122,14 @@ fun RestoreWalletView(
149122
verticalArrangement = Arrangement.spacedBy(4.dp),
150123
modifier = Modifier.weight(1f)
151124
) {
152-
for (index in 0 until wordsPerColumn) {
125+
for (index in 0 until uiState.wordsPerColumn) {
153126
MnemonicInputField(
154127
label = "${index + 1}.",
155-
value = if (index == 0) firstFieldText else words[index],
156-
isError = index in invalidWordIndices,
157-
onValueChanged = { newValue ->
158-
if (index == 0) {
159-
if (newValue.contains(" ")) {
160-
handlePastedWords(
161-
newValue,
162-
words,
163-
onWordCountChanged = { is24Words = it },
164-
onFirstWordChanged = { firstFieldText = it },
165-
onValidWords = { keyboardController?.hide() },
166-
onInvalidWords = { invalidIndices ->
167-
invalidWordIndices.clear()
168-
invalidWordIndices.addAll(invalidIndices)
169-
},
170-
)
171-
} else {
172-
updateWordValidity(
173-
newValue,
174-
index,
175-
words,
176-
invalidWordIndices,
177-
onWordUpdate = { firstFieldText = it }
178-
)
179-
updateSuggestions(newValue, focusedIndex)
180-
}
181-
} else {
182-
updateWordValidity(
183-
newValue,
184-
index,
185-
words,
186-
invalidWordIndices,
187-
)
188-
updateSuggestions(newValue, focusedIndex)
189-
}
190-
coroutineScope.launch {
191-
inputFieldPositions[index]?.let { scrollState.animateScrollTo(it) }
192-
}
193-
},
128+
value = uiState.words[index],
129+
isError = index in uiState.invalidWordIndices,
130+
onValueChanged = { viewModel.onWordChanged(index, it) },
194131
onFocusChanged = { focused ->
195-
if (focused) {
196-
focusedIndex = index
197-
updateSuggestions(if (index == 0) firstFieldText else words[index], index)
198-
199-
coroutineScope.launch {
200-
inputFieldPositions[index]?.let { scrollState.animateScrollTo(it) }
201-
}
202-
} else if (focusedIndex == index) {
203-
focusedIndex = null
204-
suggestions.clear()
205-
}
132+
viewModel.onWordFocusChanged(index, focused)
206133
},
207134
onPositionChanged = { position ->
208135
inputFieldPositions[index] = position
@@ -216,37 +143,14 @@ fun RestoreWalletView(
216143
verticalArrangement = Arrangement.spacedBy(4.dp),
217144
modifier = Modifier.weight(1f)
218145
) {
219-
for (index in wordsPerColumn until (wordsPerColumn * 2)) {
146+
for (index in uiState.wordsPerColumn until (uiState.wordsPerColumn * 2)) {
220147
MnemonicInputField(
221148
label = "${index + 1}.",
222-
value = words[index],
223-
isError = index in invalidWordIndices,
224-
onValueChanged = { newValue ->
225-
words[index] = newValue
226-
227-
updateWordValidity(
228-
newValue,
229-
index,
230-
words,
231-
invalidWordIndices,
232-
)
233-
updateSuggestions(newValue, focusedIndex)
234-
coroutineScope.launch {
235-
inputFieldPositions[index]?.let { scrollState.animateScrollTo(it) }
236-
}
237-
},
149+
value = uiState.words[index],
150+
isError = index in uiState.invalidWordIndices,
151+
onValueChanged = { viewModel.onWordChanged(index, it) },
238152
onFocusChanged = { focused ->
239-
if (focused) {
240-
focusedIndex = index
241-
updateSuggestions(words[index], index)
242-
243-
coroutineScope.launch {
244-
inputFieldPositions[index]?.let { scrollState.animateScrollTo(it) }
245-
}
246-
} else if (focusedIndex == index) {
247-
focusedIndex = null
248-
suggestions.clear()
249-
}
153+
viewModel.onWordFocusChanged(index, focused)
250154
},
251155
onPositionChanged = { position ->
252156
inputFieldPositions[index] = position
@@ -257,10 +161,10 @@ fun RestoreWalletView(
257161
}
258162
}
259163
// Passphrase
260-
if (showingPassphrase) {
164+
if (uiState.showingPassphrase) {
261165
OutlinedTextField(
262-
value = bip39Passphrase,
263-
onValueChange = { bip39Passphrase = it },
166+
value = uiState.bip39Passphrase,
167+
onValueChange = { viewModel.onPassphraseChanged(it) },
264168
placeholder = {
265169
Text(
266170
text = stringResource(R.string.onboarding__restore_passphrase_placeholder)
@@ -293,7 +197,7 @@ fun RestoreWalletView(
293197
.weight(1f)
294198
)
295199

296-
AnimatedVisibility(visible = invalidWordIndices.isNotEmpty()) {
200+
AnimatedVisibility(visible = uiState.invalidWordIndices.isNotEmpty()) {
297201
BodyS(
298202
text = stringResource(
299203
R.string.onboarding__restore_red_explain
@@ -303,7 +207,7 @@ fun RestoreWalletView(
303207
)
304208
}
305209

306-
AnimatedVisibility(visible = checksumErrorVisible) {
210+
AnimatedVisibility(visible = uiState.checksumErrorVisible) {
307211
BodyS(
308212
text = stringResource(R.string.onboarding__restore_inv_checksum),
309213
color = Colors.Red,
@@ -317,21 +221,11 @@ fun RestoreWalletView(
317221
.padding(vertical = 16.dp)
318222
.fillMaxWidth(),
319223
) {
320-
val areButtonsEnabled by remember {
321-
derivedStateOf {
322-
val wordCount = if (is24Words) 24 else 12
323-
words.subList(0, wordCount)
324-
.none { it.isBlank() } && invalidWordIndices.isEmpty() && !checksumErrorVisible
325-
}
326-
}
327-
AnimatedVisibility(visible = !showingPassphrase, modifier = Modifier.weight(1f)) {
224+
AnimatedVisibility(visible = !uiState.showingPassphrase, modifier = Modifier.weight(1f)) {
328225
SecondaryButton(
329226
text = stringResource(R.string.onboarding__advanced),
330-
onClick = {
331-
showingPassphrase = !showingPassphrase
332-
bip39Passphrase = ""
333-
},
334-
enabled = areButtonsEnabled,
227+
onClick = { viewModel.onAdvancedClick() },
228+
enabled = uiState.areButtonsEnabled,
335229
modifier = Modifier
336230
.weight(1f)
337231
.testTag("AdvancedButton")
@@ -340,9 +234,9 @@ fun RestoreWalletView(
340234
PrimaryButton(
341235
text = stringResource(R.string.onboarding__restore),
342236
onClick = {
343-
onRestoreClick(bip39Mnemonic, bip39Passphrase.takeIf { it.isNotEmpty() })
237+
onRestoreClick(uiState.bip39Mnemonic, uiState.bip39Passphrase.takeIf { it.isNotEmpty() })
344238
},
345-
enabled = areButtonsEnabled,
239+
enabled = uiState.areButtonsEnabled,
346240
modifier = Modifier
347241
.weight(1f)
348242
.testTag("RestoreButton")
@@ -352,7 +246,7 @@ fun RestoreWalletView(
352246

353247
// Suggestions row
354248
AnimatedVisibility(
355-
visible = suggestions.isNotEmpty(),
249+
visible = uiState.suggestions.isNotEmpty(),
356250
enter = fadeIn() + expandVertically(),
357251
exit = fadeOut() + shrinkVertically(),
358252
modifier = Modifier
@@ -376,31 +270,10 @@ fun RestoreWalletView(
376270
.fillMaxWidth()
377271
.padding(top = 12.dp)
378272
) {
379-
suggestions.forEach { suggestion ->
273+
uiState.suggestions.forEach { suggestion ->
380274
PrimaryButton(
381275
text = suggestion,
382-
onClick = {
383-
focusedIndex?.let { index ->
384-
if (index == 0) {
385-
firstFieldText = suggestion
386-
updateWordValidity(
387-
suggestion,
388-
index,
389-
words,
390-
invalidWordIndices,
391-
onWordUpdate = { firstFieldText = it }
392-
)
393-
} else {
394-
updateWordValidity(
395-
suggestion,
396-
index,
397-
words,
398-
invalidWordIndices,
399-
)
400-
}
401-
suggestions.clear()
402-
}
403-
},
276+
onClick = { viewModel.onSuggestionSelected(suggestion) },
404277
size = ButtonSize.Small,
405278
fullWidth = false
406279
)
@@ -452,56 +325,6 @@ fun MnemonicInputField(
452325
)
453326
}
454327

455-
private fun handlePastedWords(
456-
pastedText: String,
457-
words: SnapshotStateList<String>,
458-
onWordCountChanged: (Boolean) -> Unit,
459-
onFirstWordChanged: (String) -> Unit,
460-
onInvalidWords: (List<Int>) -> Unit,
461-
onValidWords: () -> Unit,
462-
) {
463-
val pastedWords = pastedText.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() }
464-
if (pastedWords.size == 12 || pastedWords.size == 24) {
465-
val invalidWordIndices = pastedWords.withIndex()
466-
.filter { !it.value.isBip39() }
467-
.map { it.index }
468-
469-
if (invalidWordIndices.isNotEmpty()) {
470-
onInvalidWords(invalidWordIndices)
471-
}
472-
473-
onWordCountChanged(pastedWords.size == 24)
474-
for (index in pastedWords.indices) {
475-
words[index] = pastedWords[index]
476-
}
477-
for (index in pastedWords.size until words.size) {
478-
words[index] = ""
479-
}
480-
onFirstWordChanged(pastedWords.first())
481-
onValidWords()
482-
}
483-
}
484-
485-
private fun updateWordValidity(
486-
newValue: String,
487-
index: Int,
488-
words: SnapshotStateList<String>,
489-
invalidWordIndices: SnapshotStateList<Int>,
490-
onWordUpdate: ((String) -> Unit)? = null,
491-
) {
492-
words[index] = newValue
493-
onWordUpdate?.invoke(newValue)
494-
495-
val isValid = newValue.isBip39()
496-
if (!isValid && newValue.isNotEmpty()) {
497-
if (!invalidWordIndices.contains(index)) {
498-
invalidWordIndices.add(index)
499-
}
500-
} else {
501-
invalidWordIndices.remove(index)
502-
}
503-
}
504-
505328
@Preview(showSystemUi = true)
506329
@Composable
507330
fun RestoreWalletViewPreview() {

0 commit comments

Comments
 (0)