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
This commit is contained in:
2026-04-06 19:55:32 +02:00
parent 9869ad2476
commit 200ce74cb5
9 changed files with 542 additions and 8 deletions

View File

@@ -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()
}

View File

@@ -10,6 +10,7 @@ object EmailLoginScreenContract {
sealed interface Action {
data object OpenManage : Action
data object NavigateToRegister : Action
data class ShowMessage(val message: String) : Action
}

View File

@@ -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 {

View File

@@ -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 = {}
)
}
}

View File

@@ -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
}
}

View File

@@ -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<State> = _uiState.asStateFlow()
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
val actions: SharedFlow<Action> = _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)
}
}
}
}

View File

@@ -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() }
)
}

View File

@@ -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}"

View File

@@ -93,6 +93,7 @@
<string name="button_connecting">Connecting…</string>
<string name="text_sign_in_email">Sign in with email</string>
<string name="text_register_email_title">Create account</string>
<!-- Hints -->
<string name="hint_email">Email</string>
@@ -101,6 +102,14 @@
<string name="hint_setup_name">Name</string>
<string name="hint_setup_model">Model</string>
<string name="hint_setup_wifi">WiFi network</string>
<string name="hint_register_name">Your name</string>
<string name="hint_password_confirm">Confirm password</string>
<string name="error_register_name_required">Enter your name</string>
<string name="error_register_email_required">Enter your email</string>
<string name="error_register_email_invalid">Enter a valid email address</string>
<string name="error_register_password_short">Password must be at least 6 characters</string>
<string name="error_register_password_mismatch">Passwords do not match</string>
<!-- Content descriptors -->
<string name="content_desc_user_pic">User Picture</string>