From 200ce74cb543783f58f25c0a2c91487609df7b65 Mon Sep 17 00:00:00 2001 From: beetzung Date: Mon, 6 Apr 2026 19:55:32 +0200 Subject: [PATCH] Add email registration screen with AuthService integration Introduce EmailRegisterScreen (Compose) with contract, validation, and loading UX matching the email sign-in layout. Register submits via AuthService.registerWithEmail; success navigates to Manage and clears the login stack. Email login Register action opens the new route instead of handling registration on the same screen. Made-with: Cursor --- .../airmq/features/login/EmailLoginScreen.kt | 2 + .../login/EmailLoginScreenContract.kt | 1 + .../features/login/EmailLoginViewModel.kt | 12 +- .../features/login/EmailRegisterScreen.kt | 339 ++++++++++++++++++ .../login/EmailRegisterScreenContract.kt | 52 +++ .../features/login/EmailRegisterViewModel.kt | 122 +++++++ .../features/navigation/AirMQNavGraph.kt | 12 + .../airmq/features/navigation/AirMqRoutes.kt | 1 + app/src/main/res/values/strings.xml | 9 + 9 files changed, 542 insertions(+), 8 deletions(-) create mode 100644 app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterScreen.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterScreenContract.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterViewModel.kt diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginScreen.kt index bcfd197..c1e6430 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginScreen.kt @@ -54,6 +54,7 @@ private val LegacyLoginGradientEnd = Color(0xFF5CE4BB) @Composable fun EmailLoginScreen( onLogInToManage: () -> Unit, + onOpenRegister: () -> Unit, onBack: () -> Unit, viewModel: EmailLoginViewModel = hiltViewModel() ) { @@ -64,6 +65,7 @@ fun EmailLoginScreen( viewModel.actions.collectLatest { action -> when (action) { Action.OpenManage -> onLogInToManage() + Action.NavigateToRegister -> onOpenRegister() is Action.ShowMessage -> { Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginScreenContract.kt b/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginScreenContract.kt index f6ba9d5..da6dc71 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginScreenContract.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginScreenContract.kt @@ -10,6 +10,7 @@ object EmailLoginScreenContract { sealed interface Action { data object OpenManage : Action + data object NavigateToRegister : Action data class ShowMessage(val message: String) : Action } diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginViewModel.kt index beddcbd..c802320 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginViewModel.kt @@ -40,13 +40,13 @@ class EmailLoginViewModel @Inject constructor( is Event.PasswordChanged -> { _uiState.value = _uiState.value.copy(password = event.value) } - Event.SignInClicked -> submitEmailAuth(register = false) - Event.RegisterClicked -> submitEmailAuth(register = true) + Event.SignInClicked -> submitEmailAuth() + Event.RegisterClicked -> _actions.tryEmit(Action.NavigateToRegister) Event.BackClicked -> Unit } } - private fun submitEmailAuth(register: Boolean) { + private fun submitEmailAuth() { val email = _uiState.value.email.trim() val password = _uiState.value.password if (email.isEmpty() || password.isEmpty()) { @@ -64,11 +64,7 @@ class EmailLoginViewModel @Inject constructor( viewModelScope.launch { _uiState.value = _uiState.value.copy(isLoading = true) try { - val result = if (register) { - authService.registerWithEmail(email, password, name = "") - } else { - authService.loginWithEmailPassword(email, password) - } + val result = authService.loginWithEmailPassword(email, password) if (result.isSuccess) { _actions.tryEmit(Action.OpenManage) } else { diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterScreen.kt new file mode 100644 index 0000000..0018947 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterScreen.kt @@ -0,0 +1,339 @@ +package org.db3.airmq.features.login + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.runtime.collectAsState +import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.coroutines.flow.collectLatest +import org.db3.airmq.R +import org.db3.airmq.features.common.AirMQOutlinedLightButton +import org.db3.airmq.features.login.EmailRegisterScreenContract.Action +import org.db3.airmq.features.login.EmailRegisterScreenContract.Event +import org.db3.airmq.features.login.EmailRegisterScreenContract.State +import org.db3.airmq.ui.theme.AirMQTheme + +private val LegacyLoginGradientStart = Color(0xFF449CF5) +private val LegacyLoginGradientEnd = Color(0xFF5CE4BB) + +@Composable +fun EmailRegisterScreen( + onRegisterSuccess: () -> Unit, + onBack: () -> Unit, + viewModel: EmailRegisterViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + + LaunchedEffect(viewModel) { + viewModel.actions.collectLatest { action -> + when (action) { + Action.OpenManage -> onRegisterSuccess() + is Action.ShowMessage -> { + Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show() + } + } + } + } + + EmailRegisterScreenContent( + uiState = uiState, + onEvent = viewModel::onEvent, + onBack = onBack + ) +} + +@Composable +private fun EmailRegisterScreenContent( + uiState: State, + onEvent: (Event) -> Unit, + onBack: () -> Unit +) { + val fieldEnabled = !uiState.isLoading + val scroll = rememberScrollState() + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.linearGradient( + colorStops = arrayOf( + 0.0f to LegacyLoginGradientStart, + 0.35f to LegacyLoginGradientStart, + 1.0f to LegacyLoginGradientEnd + ), + start = Offset(0f, 0f), + end = Offset(1200f, 1200f) + ) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(horizontal = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterStart + ) { + IconButton(onClick = onBack, enabled = fieldEnabled) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back), + contentDescription = stringResource(id = R.string.content_back), + tint = Color.White + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(scroll), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(id = R.string.text_register_email_title), + color = Color.White, + fontSize = 36.sp, + fontWeight = FontWeight.Light, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.name, + onValueChange = { onEvent(Event.NameChanged(it)) }, + singleLine = true, + enabled = fieldEnabled, + modifier = Modifier.fillMaxWidth(), + isError = uiState.nameError != null, + supportingText = uiState.nameError?.let { err -> + { Text(err, color = Color.White.copy(alpha = 0.9f)) } + }, + placeholder = { + Text( + stringResource(id = R.string.hint_register_name), + color = Color.White.copy(alpha = 0.7f) + ) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + colors = legacyLoginFieldColors() + ) + + OutlinedTextField( + value = uiState.email, + onValueChange = { onEvent(Event.EmailChanged(it)) }, + singleLine = true, + enabled = fieldEnabled, + modifier = Modifier.fillMaxWidth(), + isError = uiState.emailError != null, + supportingText = uiState.emailError?.let { err -> + { Text(err, color = Color.White.copy(alpha = 0.9f)) } + }, + placeholder = { + Text( + stringResource(id = R.string.hint_email), + color = Color.White.copy(alpha = 0.7f) + ) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + colors = legacyLoginFieldColors() + ) + + OutlinedTextField( + value = uiState.password, + onValueChange = { onEvent(Event.PasswordChanged(it)) }, + singleLine = true, + enabled = fieldEnabled, + modifier = Modifier.fillMaxWidth(), + isError = uiState.passwordError != null, + supportingText = uiState.passwordError?.let { err -> + { Text(err, color = Color.White.copy(alpha = 0.9f)) } + }, + placeholder = { + Text( + stringResource(id = R.string.hint_password), + color = Color.White.copy(alpha = 0.7f) + ) + }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next + ), + colors = legacyLoginFieldColors() + ) + + OutlinedTextField( + value = uiState.passwordConfirm, + onValueChange = { onEvent(Event.PasswordConfirmChanged(it)) }, + singleLine = true, + enabled = fieldEnabled, + modifier = Modifier.fillMaxWidth(), + isError = uiState.passwordConfirmError != null, + supportingText = uiState.passwordConfirmError?.let { err -> + { Text(err, color = Color.White.copy(alpha = 0.9f)) } + }, + placeholder = { + Text( + stringResource(id = R.string.hint_password_confirm), + color = Color.White.copy(alpha = 0.7f) + ) + }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + colors = legacyLoginFieldColors() + ) + + if (uiState.isLoading) { + CircularProgressIndicator(color = Color.White) + } else { + AirMQOutlinedLightButton( + text = stringResource(id = R.string.button_register), + modifier = Modifier.fillMaxWidth(), + onClick = { onEvent(Event.RegisterClicked) } + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun legacyLoginFieldColors() = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + cursorColor = Color.White, + focusedBorderColor = Color.White.copy(alpha = 0.8f), + unfocusedBorderColor = Color.White.copy(alpha = 0.55f), + focusedContainerColor = Color.White.copy(alpha = 0.1f), + unfocusedContainerColor = Color.White.copy(alpha = 0.05f), + errorBorderColor = Color.White.copy(alpha = 0.95f), + errorCursorColor = Color.White, + errorSupportingTextColor = Color.White.copy(alpha = 0.95f) +) + +@Preview(showBackground = true) +@Composable +private fun EmailRegisterScreenPreviewEmpty() { + AirMQTheme { + EmailRegisterScreenContent( + uiState = EmailRegisterScreenContract.previewState(), + onEvent = {}, + onBack = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun EmailRegisterScreenPreviewFilled() { + AirMQTheme { + EmailRegisterScreenContent( + uiState = EmailRegisterScreenContract.previewState( + name = "Alex", + email = "alex@example.com", + password = "secret1", + passwordConfirm = "secret1" + ), + onEvent = {}, + onBack = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun EmailRegisterScreenPreviewLoading() { + AirMQTheme { + EmailRegisterScreenContent( + uiState = EmailRegisterScreenContract.previewState( + name = "Alex", + email = "alex@example.com", + password = "secret1", + passwordConfirm = "secret1", + isLoading = true + ), + onEvent = {}, + onBack = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun EmailRegisterScreenPreviewErrors() { + AirMQTheme { + EmailRegisterScreenContent( + uiState = EmailRegisterScreenContract.previewState( + name = "", + email = "bad", + password = "12", + passwordConfirm = "34", + nameError = "Enter your name", + emailError = "Invalid email", + passwordError = "Too short", + passwordConfirmError = "Does not match" + ), + onEvent = {}, + onBack = {} + ) + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterScreenContract.kt b/app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterScreenContract.kt new file mode 100644 index 0000000..238c6cc --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterScreenContract.kt @@ -0,0 +1,52 @@ +package org.db3.airmq.features.login + +object EmailRegisterScreenContract { + + data class State( + val name: String = "", + val email: String = "", + val password: String = "", + val passwordConfirm: String = "", + val isLoading: Boolean = false, + val nameError: String? = null, + val emailError: String? = null, + val passwordError: String? = null, + val passwordConfirmError: String? = null + ) + + fun previewState( + name: String = "", + email: String = "", + password: String = "", + passwordConfirm: String = "", + isLoading: Boolean = false, + nameError: String? = null, + emailError: String? = null, + passwordError: String? = null, + passwordConfirmError: String? = null + ): State = State( + name = name, + email = email, + password = password, + passwordConfirm = passwordConfirm, + isLoading = isLoading, + nameError = nameError, + emailError = emailError, + passwordError = passwordError, + passwordConfirmError = passwordConfirmError + ) + + sealed interface Action { + data object OpenManage : Action + data class ShowMessage(val message: String) : Action + } + + sealed interface Event { + data class NameChanged(val value: String) : Event + data class EmailChanged(val value: String) : Event + data class PasswordChanged(val value: String) : Event + data class PasswordConfirmChanged(val value: String) : Event + data object RegisterClicked : Event + data object BackClicked : Event + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterViewModel.kt new file mode 100644 index 0000000..d60fe51 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterViewModel.kt @@ -0,0 +1,122 @@ +package org.db3.airmq.features.login + +import android.content.Context +import android.util.Patterns +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.db3.airmq.R +import org.db3.airmq.features.login.EmailRegisterScreenContract.Action +import org.db3.airmq.features.login.EmailRegisterScreenContract.Event +import org.db3.airmq.features.login.EmailRegisterScreenContract.State +import org.db3.airmq.sdk.auth.AuthService + +private const val MinPasswordLength = 6 + +@HiltViewModel +class EmailRegisterViewModel @Inject constructor( + @ApplicationContext private val appContext: Context, + private val authService: AuthService +) : ViewModel() { + + private val _uiState = MutableStateFlow(State()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actions = MutableSharedFlow(extraBufferCapacity = 1) + val actions: SharedFlow = _actions.asSharedFlow() + + fun onEvent(event: Event) { + when (event) { + is Event.NameChanged -> { + _uiState.value = _uiState.value.copy(name = event.value, nameError = null) + } + is Event.EmailChanged -> { + _uiState.value = _uiState.value.copy(email = event.value, emailError = null) + } + is Event.PasswordChanged -> { + _uiState.value = _uiState.value.copy(password = event.value, passwordError = null) + } + is Event.PasswordConfirmChanged -> { + _uiState.value = _uiState.value.copy( + passwordConfirm = event.value, + passwordConfirmError = null + ) + } + Event.RegisterClicked -> register() + Event.BackClicked -> Unit + } + } + + private fun register() { + val s = _uiState.value + val nameError = if (s.name.isBlank()) { + appContext.getString(R.string.error_register_name_required) + } else { + null + } + val emailTrimmed = s.email.trim() + val emailError = when { + emailTrimmed.isBlank() -> appContext.getString(R.string.error_register_email_required) + !Patterns.EMAIL_ADDRESS.matcher(emailTrimmed).matches() -> { + appContext.getString(R.string.error_register_email_invalid) + } + else -> null + } + val passwordError = when { + s.password.length < MinPasswordLength -> { + appContext.getString(R.string.error_register_password_short) + } + else -> null + } + val passwordConfirmError = when { + s.password != s.passwordConfirm -> { + appContext.getString(R.string.error_register_password_mismatch) + } + else -> null + } + if (nameError != null || emailError != null || passwordError != null || passwordConfirmError != null) { + _uiState.value = s.copy( + nameError = nameError, + emailError = emailError, + passwordError = passwordError, + passwordConfirmError = passwordConfirmError + ) + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isLoading = true, + nameError = null, + emailError = null, + passwordError = null, + passwordConfirmError = null + ) + try { + val result = authService.registerWithEmail( + email = emailTrimmed, + password = s.password, + name = s.name.trim() + ) + if (result.isSuccess) { + _actions.tryEmit(Action.OpenManage) + } else { + val message = result.exceptionOrNull()?.message + ?: appContext.getString(R.string.toast_email_auth_failed) + _actions.tryEmit(Action.ShowMessage(message)) + } + } finally { + _uiState.value = _uiState.value.copy(isLoading = false) + } + } + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMQNavGraph.kt b/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMQNavGraph.kt index 115f7c0..d7b16f3 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMQNavGraph.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMQNavGraph.kt @@ -43,6 +43,7 @@ import org.db3.airmq.features.entry.SplashScreen import org.db3.airmq.features.entry.WizardScreen import org.db3.airmq.features.location.LocationScreen import org.db3.airmq.features.login.EmailLoginScreen +import org.db3.airmq.features.login.EmailRegisterScreen import org.db3.airmq.features.login.LoginScreen import org.db3.airmq.features.manage.ManageScreen import org.db3.airmq.features.map.MapScreen @@ -234,6 +235,17 @@ fun AirMQNavGraph(modifier: Modifier = Modifier) { popUpTo(AirMqRoutes.LOGIN) { inclusive = true } } }, + onOpenRegister = { navController.navigate(AirMqRoutes.EMAIL_REGISTER) }, + onBack = { navController.popBackStack() } + ) + } + composable(AirMqRoutes.EMAIL_REGISTER) { + EmailRegisterScreen( + onRegisterSuccess = { + navController.navigate(AirMqRoutes.MANAGE) { + popUpTo(AirMqRoutes.LOGIN) { inclusive = true } + } + }, onBack = { navController.popBackStack() } ) } diff --git a/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMqRoutes.kt b/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMqRoutes.kt index 4f7fad1..359b878 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMqRoutes.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMqRoutes.kt @@ -13,6 +13,7 @@ object AirMqRoutes { const val SETUP = "detail/setup" const val LOGIN = "detail/login" const val EMAIL_LOGIN = "detail/email-login" + const val EMAIL_REGISTER = "detail/email-register" const val NEWS = "detail/news" const val NEWS_DETAIL = "detail/news/{newsId}" const val DEVICE = "detail/device/{deviceId}" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1efbfbf..39f6ab2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -93,6 +93,7 @@ Connecting… Sign in with email + Create account Email @@ -101,6 +102,14 @@ Name Model WiFi network + Your name + Confirm password + + Enter your name + Enter your email + Enter a valid email address + Password must be at least 6 characters + Passwords do not match User Picture