Skip to content

Commit 463426d

Browse files
Rajan MauryaRajan Maurya
authored andcommitted
refactor(auth): W1.b — LoginScreen now uses MutationScreenContent (replaces raw Scaffold + MifosProgressIndicatorOverlay)
Per user direction: the W1 alignment was incomplete — LoginScreen still wrapped the form in a raw `Scaffold` and used the project-local `MifosProgressIndicatorOverlay` for the submit-in-flight state. That bypasses the canonical mutation-screen pattern established in core-base/ui (`template.core.base.ui.submit.MutationScreenContent`) which auto-composes ScreenContent + SubmitProgressOverlay + SubmitResultHandler for any mutation lifecycle. The Scaffold here genuinely matters (the persistent "Update Server Configuration" bottom-bar button + snackbar host) — that stays as the outer container. The form body + submit lifecycle now flow through MutationScreenContent inside the Scaffold's content area. What changed in LoginScreen.kt: Old structure: Scaffold(bottomBar = serverConfigButton) { padding -> Column { LoginForm } } if (isSubmitting) { MifosProgressIndicatorOverlay() } ← project-local overlay New structure: Scaffold(bottomBar = serverConfigButton) { padding -> MutationScreenContent<Unit, PostAuthenticationResponse>( screenState = ScreenState.Content(Unit, DataFreshness.FRESH), ← degenerate; login has no fetched data submitState = submitState, onRetry = {}, onSubmitted = { _ -> }, ← navigation handled by LoginViewModel via NavigateToPasscode Event modifier = Modifier.padding(padding), ) { _, _ -> LoginForm(state, onSubmit, isSubmitting = submitState is SubmitState.Submitting) } } The previous monolithic `LoginContent` was split into `LoginContent` (Scaffold shell + MutationScreenContent wiring) + `LoginForm` (pure form composable that takes `state`, `onSubmit`, `isSubmitting`). This separates the framework-binding concerns from the actual form layout — easier to preview, easier to reuse. Previews updated to pass `submitState: SubmitState<PostAuthenticationResponse>` instead of the previous `isSubmitting: Boolean` flag — the 3 @DevicePreview variants are Idle / Submitting / ValidationError, each constructing the right SubmitState directly. Net behavior: same UX (form layout unchanged; submit progress overlay still appears during the network call). Difference: the overlay is now the framework- provided SubmitProgressOverlay (consistent across all mutation screens) instead of the project-local MifosProgressIndicatorOverlay. Also unlocks: future SubmitResultHandler integration for consistent error categorization across all mutation screens. The MIGRATION_PLAYBOOK.md (framework repo, separate batch commit) now carries W1's full E2E architecture mapping table — first wave to author this canonical artifact. Every future wave will document its 7-layer mapping (Compose → ViewModel → Repository → Store builder → DAO → Api → Domain DTOs) before authoring code.
1 parent 8adcbb8 commit 463426d

1 file changed

Lines changed: 122 additions & 93 deletions

File tree

  • feature/auth/src/commonMain/kotlin/com/mifos/feature/auth/ui

feature/auth/src/commonMain/kotlin/com/mifos/feature/auth/ui/LoginScreen.kt

Lines changed: 122 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,21 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
5454
import androidx.compose.ui.text.input.TextFieldValue
5555
import androidx.compose.ui.text.input.VisualTransformation
5656
import androidx.compose.ui.text.style.TextAlign
57+
import com.mifos.core.data.store.DataFreshness
58+
import com.mifos.core.data.store.ScreenState
5759
import com.mifos.core.data.store.SubmitState
5860
import com.mifos.core.designsystem.component.MifosAndroidClientIcon
5961
import com.mifos.core.designsystem.component.MifosOutlinedTextField
6062
import com.mifos.core.designsystem.icon.MifosIcons
6163
import com.mifos.core.designsystem.theme.DesignToken
62-
import com.mifos.core.ui.components.MifosProgressIndicatorOverlay
64+
import com.mifos.core.model.network.PostAuthenticationResponse
6365
import com.mifos.core.ui.util.DevicePreview
6466
import org.jetbrains.compose.resources.getString
6567
import org.jetbrains.compose.resources.painterResource
6668
import org.jetbrains.compose.resources.stringResource
6769
import org.koin.compose.viewmodel.koinViewModel
6870
import template.core.base.designsystem.theme.KptTheme
71+
import template.core.base.ui.submit.MutationScreenContent
6972

7073
@Composable
7174
internal fun LoginScreen(
@@ -92,7 +95,7 @@ internal fun LoginScreen(
9295

9396
LoginContent(
9497
state = state,
95-
isSubmitting = submitState is SubmitState.Submitting,
98+
submitState = submitState,
9699
onSubmit = { username, password ->
97100
loginViewModel.actionChannel.trySend(LoginAction.Submit(username, password))
98101
},
@@ -105,24 +108,20 @@ internal fun LoginScreen(
105108
@Composable
106109
internal fun LoginContent(
107110
state: LoginState,
108-
isSubmitting: Boolean,
111+
submitState: SubmitState<PostAuthenticationResponse>,
109112
onSubmit: (username: String, password: String) -> Unit,
110113
onClickToUpdateServerConfig: () -> Unit,
111114
snackbarHostState: SnackbarHostState,
112115
modifier: Modifier = Modifier,
113116
) {
114-
var userName by rememberSaveable(stateSaver = TextFieldValue.Saver) {
115-
mutableStateOf(TextFieldValue(""))
116-
}
117-
var password by rememberSaveable(stateSaver = TextFieldValue.Saver) {
118-
mutableStateOf(TextFieldValue(""))
119-
}
120-
var passwordVisibility: Boolean by remember { mutableStateOf(false) }
117+
// Login has no server-side data to fetch (the user isn't authenticated yet),
118+
// so screenState is a degenerate Content(Unit) — MutationScreenContent still
119+
// gives us the SubmitProgressOverlay + SubmitResultHandler for the submit
120+
// lifecycle, which is what we want for the consistent mutation-screen UX.
121+
val screenState = remember { ScreenState.Content(Unit, DataFreshness.FRESH) }
121122

122123
Scaffold(
123-
modifier = modifier
124-
.fillMaxSize()
125-
.padding(KptTheme.spacing.md),
124+
modifier = modifier.fillMaxSize(),
126125
containerColor = KptTheme.colorScheme.surface,
127126
snackbarHost = { SnackbarHost(snackbarHostState) },
128127
bottomBar = {
@@ -134,7 +133,6 @@ internal fun LoginContent(
134133
) {
135134
FilledTonalButton(
136135
onClick = onClickToUpdateServerConfig,
137-
modifier = Modifier.align(Alignment.Center),
138136
colors = ButtonDefaults.filledTonalButtonColors(
139137
containerColor = KptTheme.colorScheme.tertiaryContainer,
140138
contentColor = KptTheme.colorScheme.tertiary,
@@ -150,94 +148,125 @@ internal fun LoginContent(
150148
}
151149
},
152150
) { paddingValues ->
153-
Column(
151+
MutationScreenContent<Unit, PostAuthenticationResponse>(
152+
screenState = screenState,
153+
submitState = submitState,
154+
onRetry = {},
155+
// Terminal `Submitted` navigation is handled by LoginViewModel emitting
156+
// a NavigateToPasscode Event (after persistUser succeeds). The Screen
157+
// listens via LaunchedEffect on eventFlow — see LoginScreen above. So
158+
// MutationScreenContent's own onSubmitted is a no-op here.
159+
onSubmitted = { _ -> },
160+
modifier = Modifier.padding(paddingValues),
161+
) { _, _ ->
162+
LoginForm(
163+
state = state,
164+
onSubmit = onSubmit,
165+
isSubmitting = submitState is SubmitState.Submitting,
166+
)
167+
}
168+
}
169+
}
170+
171+
@Composable
172+
private fun LoginForm(
173+
state: LoginState,
174+
onSubmit: (username: String, password: String) -> Unit,
175+
isSubmitting: Boolean,
176+
modifier: Modifier = Modifier,
177+
) {
178+
var userName by rememberSaveable(stateSaver = TextFieldValue.Saver) {
179+
mutableStateOf(TextFieldValue(""))
180+
}
181+
var password by rememberSaveable(stateSaver = TextFieldValue.Saver) {
182+
mutableStateOf(TextFieldValue(""))
183+
}
184+
var passwordVisibility: Boolean by remember { mutableStateOf(false) }
185+
186+
Column(
187+
modifier = modifier
188+
.fillMaxWidth()
189+
.padding(KptTheme.spacing.md)
190+
.verticalScroll(rememberScrollState()),
191+
horizontalAlignment = Alignment.CenterHorizontally,
192+
) {
193+
Spacer(modifier = Modifier.height(DesignToken.spacing.dp80))
194+
MifosAndroidClientIcon(imageVector = painterResource(Res.drawable.feature_auth_mifos_logo))
195+
196+
Text(
154197
modifier = Modifier
155198
.fillMaxWidth()
156-
.padding(paddingValues)
157-
.verticalScroll(rememberScrollState()),
158-
horizontalAlignment = Alignment.CenterHorizontally,
159-
) {
160-
Spacer(modifier = Modifier.height(DesignToken.spacing.dp80))
161-
MifosAndroidClientIcon(imageVector = painterResource(Res.drawable.feature_auth_mifos_logo))
199+
.padding(top = KptTheme.spacing.sm),
200+
text = stringResource(Res.string.feature_auth_enter_credentials),
201+
textAlign = TextAlign.Center,
202+
style = KptTheme.typography.bodyMedium,
203+
)
162204

163-
Text(
164-
modifier = Modifier
165-
.fillMaxWidth()
166-
.padding(top = KptTheme.spacing.sm),
167-
text = stringResource(Res.string.feature_auth_enter_credentials),
168-
textAlign = TextAlign.Center,
169-
style = KptTheme.typography.bodyMedium,
170-
)
171-
172-
Spacer(modifier = Modifier.height(KptTheme.spacing.md))
205+
Spacer(modifier = Modifier.height(KptTheme.spacing.md))
173206

174-
MifosOutlinedTextField(
175-
value = userName,
176-
onValueChanged = { userName = it },
177-
icon = MifosIcons.Person,
178-
label = stringResource(Res.string.feature_auth_username),
179-
error = state.usernameError?.let { stringResource(it) },
180-
trailingIcon = {
181-
if (state.usernameError != null) {
182-
Icon(
183-
imageVector = MifosIcons.Error,
184-
contentDescription = stringResource(Res.string.feature_auth_cd_error_icon),
185-
)
186-
}
187-
},
188-
)
207+
MifosOutlinedTextField(
208+
value = userName,
209+
onValueChanged = { userName = it },
210+
icon = MifosIcons.Person,
211+
label = stringResource(Res.string.feature_auth_username),
212+
error = state.usernameError?.let { stringResource(it) },
213+
trailingIcon = {
214+
if (state.usernameError != null) {
215+
Icon(
216+
imageVector = MifosIcons.Error,
217+
contentDescription = stringResource(Res.string.feature_auth_cd_error_icon),
218+
)
219+
}
220+
},
221+
)
189222

190-
Spacer(modifier = Modifier.height(DesignToken.spacing.mediumSmall))
223+
Spacer(modifier = Modifier.height(DesignToken.spacing.mediumSmall))
191224

192-
MifosOutlinedTextField(
193-
value = password,
194-
onValueChanged = { password = it },
195-
visualTransformation = if (passwordVisibility) {
196-
VisualTransformation.None
197-
} else {
198-
PasswordVisualTransformation()
199-
},
200-
icon = MifosIcons.Lock,
201-
label = stringResource(Res.string.feature_auth_password),
202-
error = state.passwordError?.let { stringResource(it) },
203-
trailingIcon = {
204-
if (state.passwordError == null) {
205-
val image = if (passwordVisibility) {
206-
MifosIcons.Visibility
207-
} else {
208-
MifosIcons.VisibilityOff
209-
}
210-
IconButton(onClick = { passwordVisibility = !passwordVisibility }) {
211-
Icon(
212-
imageVector = image,
213-
contentDescription = stringResource(Res.string.feature_auth_cd_password_visibility),
214-
)
215-
}
225+
MifosOutlinedTextField(
226+
value = password,
227+
onValueChanged = { password = it },
228+
visualTransformation = if (passwordVisibility) {
229+
VisualTransformation.None
230+
} else {
231+
PasswordVisualTransformation()
232+
},
233+
icon = MifosIcons.Lock,
234+
label = stringResource(Res.string.feature_auth_password),
235+
error = state.passwordError?.let { stringResource(it) },
236+
trailingIcon = {
237+
if (state.passwordError == null) {
238+
val image = if (passwordVisibility) {
239+
MifosIcons.Visibility
216240
} else {
241+
MifosIcons.VisibilityOff
242+
}
243+
IconButton(onClick = { passwordVisibility = !passwordVisibility }) {
217244
Icon(
218-
imageVector = MifosIcons.Error,
219-
contentDescription = stringResource(Res.string.feature_auth_cd_error_icon),
245+
imageVector = image,
246+
contentDescription = stringResource(Res.string.feature_auth_cd_password_visibility),
220247
)
221248
}
222-
},
223-
)
249+
} else {
250+
Icon(
251+
imageVector = MifosIcons.Error,
252+
contentDescription = stringResource(Res.string.feature_auth_cd_error_icon),
253+
)
254+
}
255+
},
256+
)
224257

225-
Spacer(modifier = Modifier.height(KptTheme.spacing.sm))
258+
Spacer(modifier = Modifier.height(KptTheme.spacing.sm))
226259

227-
Button(
228-
onClick = { onSubmit(userName.text, password.text) },
229-
enabled = !isSubmitting,
230-
modifier = Modifier
231-
.fillMaxWidth()
232-
.heightIn(DesignToken.spacing.dp44)
233-
.padding(horizontal = KptTheme.spacing.md),
234-
contentPadding = PaddingValues(),
235-
) {
236-
Text(text = stringResource(Res.string.feature_auth_login), style = KptTheme.typography.bodyLarge)
237-
}
238-
}
239-
if (isSubmitting) {
240-
MifosProgressIndicatorOverlay()
260+
Button(
261+
onClick = { onSubmit(userName.text, password.text) },
262+
enabled = !isSubmitting,
263+
modifier = Modifier
264+
.fillMaxWidth()
265+
.heightIn(DesignToken.spacing.dp44)
266+
.padding(horizontal = KptTheme.spacing.md),
267+
contentPadding = PaddingValues(),
268+
) {
269+
Text(text = stringResource(Res.string.feature_auth_login), style = KptTheme.typography.bodyLarge)
241270
}
242271
}
243272
}
@@ -249,7 +278,7 @@ internal fun LoginContent(
249278
private fun LoginScreenIdlePreview() {
250279
LoginContent(
251280
state = LoginState(),
252-
isSubmitting = false,
281+
submitState = SubmitState.Idle,
253282
onSubmit = { _, _ -> },
254283
onClickToUpdateServerConfig = {},
255284
snackbarHostState = remember { SnackbarHostState() },
@@ -261,7 +290,7 @@ private fun LoginScreenIdlePreview() {
261290
private fun LoginScreenSubmittingPreview() {
262291
LoginContent(
263292
state = LoginState(),
264-
isSubmitting = true,
293+
submitState = SubmitState.Submitting,
265294
onSubmit = { _, _ -> },
266295
onClickToUpdateServerConfig = {},
267296
snackbarHostState = remember { SnackbarHostState() },
@@ -276,7 +305,7 @@ private fun LoginScreenValidationErrorPreview() {
276305
usernameError = Res.string.feature_auth_username,
277306
passwordError = Res.string.feature_auth_password,
278307
),
279-
isSubmitting = false,
308+
submitState = SubmitState.Idle,
280309
onSubmit = { _, _ -> },
281310
onClickToUpdateServerConfig = {},
282311
snackbarHostState = remember { SnackbarHostState() },

0 commit comments

Comments
 (0)