feat(auth): email sign-in and register via GraphQL

Add loginLocal and register Apollo mutations, LocalEmailAuthStore for profile snapshot when not using Firebase, extend AuthService/FirebaseAuthService with session handoff vs Google, wire EmailLoginViewModel, refresh Manage on resume, and expand unit tests.

Made-with: Cursor
This commit is contained in:
2026-04-06 19:30:31 +02:00
parent df4e6f9c56
commit 9869ad2476
16 changed files with 319 additions and 26 deletions

View File

@@ -1,7 +1,9 @@
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
@@ -11,14 +13,17 @@ 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.EmailLoginScreenContract.Action
import org.db3.airmq.features.login.EmailLoginScreenContract.Event
import org.db3.airmq.features.login.EmailLoginScreenContract.State
import org.db3.airmq.sdk.auth.AuthService
@HiltViewModel
class EmailLoginViewModel @Inject constructor(
@ApplicationContext private val appContext: Context
@ApplicationContext private val appContext: Context,
private val authService: AuthService
) : ViewModel() {
private val _uiState = MutableStateFlow(State())
@@ -35,19 +40,45 @@ class EmailLoginViewModel @Inject constructor(
is Event.PasswordChanged -> {
_uiState.value = _uiState.value.copy(password = event.value)
}
Event.SignInClicked -> {
_uiState.value = _uiState.value.copy(isLoading = true)
_actions.tryEmit(
Action.ShowMessage(appContext.getString(R.string.coming_soon))
)
_uiState.value = _uiState.value.copy(isLoading = false)
}
Event.RegisterClicked -> {
_actions.tryEmit(
Action.ShowMessage(appContext.getString(R.string.coming_soon))
)
}
Event.SignInClicked -> submitEmailAuth(register = false)
Event.RegisterClicked -> submitEmailAuth(register = true)
Event.BackClicked -> Unit
}
}
private fun submitEmailAuth(register: Boolean) {
val email = _uiState.value.email.trim()
val password = _uiState.value.password
if (email.isEmpty() || password.isEmpty()) {
_actions.tryEmit(
Action.ShowMessage(appContext.getString(R.string.toast_email_fields_required))
)
return
}
if (!Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
_actions.tryEmit(
Action.ShowMessage(appContext.getString(R.string.toast_invalid_email))
)
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
try {
val result = if (register) {
authService.registerWithEmail(email, password, name = "")
} else {
authService.loginWithEmailPassword(email, password)
}
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

@@ -25,9 +25,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@@ -63,6 +67,16 @@ fun ManageScreen(
viewModel: ManageViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, viewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
viewModel.refreshAuthState()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
LaunchedEffect(viewModel) {
viewModel.actions.collectLatest { action ->
when (action) {

View File

@@ -71,7 +71,7 @@ class ManageViewModel @Inject constructor(
private fun initialState(): State = anonymousState()
private fun refreshAuthState() {
fun refreshAuthState() {
viewModelScope.launch {
val session = authService.getUser()
if (session?.isAuthenticated == true) {

View File

@@ -82,6 +82,9 @@
<string name="notification_device_status_title">Ваша прылада адключылася ад сеткі!</string>
<string name="notification_device_status_text">%s цяпер неактыўны. Націсніце на гэта апавяшчэнне, каб адчыніць канфігурацыю прылады</string>
<string name="toast_oauth_failed">Памылка аўтарызацыі Google</string>
<string name="toast_email_fields_required">Увядзіце email і пароль</string>
<string name="toast_invalid_email">Увядзіце карэктны email</string>
<string name="toast_email_auth_failed">Не ўдалося ўвайсці</string>
<string name="snackbar_not_registered">Патрабуецца рэгістрацыя</string>
<string name="text_policy_label">Уваходзячы ў сістэму, вы згаджаецеся з нашымі %1$s и %2$s</string>
<string name="text_what_does_it_mean"><u>Што гэта значыць?</u></string>

View File

@@ -82,6 +82,9 @@
<string name="notification_device_status_title">Ваше устройство отключилось от сети!</string>
<string name="notification_device_status_text">%s теперь неактивен. Нажмите на это уведомление, чтобы открыть конфигурацию устройства</string>
<string name="toast_oauth_failed">Ошибка авторизации Google</string>
<string name="toast_email_fields_required">Введите email и пароль</string>
<string name="toast_invalid_email">Введите корректный email</string>
<string name="toast_email_auth_failed">Не удалось войти</string>
<string name="snackbar_not_registered">Требуется регистрация</string>
<string name="text_policy_label">Входя в систему, вы соглашаетесь с нашими %1$s и %2$s</string>
<string name="text_what_does_it_mean"><u>Что это значит?</u></string>

View File

@@ -142,6 +142,9 @@
<!-- Snackbars and toasts -->
<string name="toast_oauth_failed" tools:ignore="UnusedResources">Google authorisation failed</string>
<string name="toast_email_fields_required">Enter email and password</string>
<string name="toast_invalid_email">Enter a valid email address</string>
<string name="toast_email_auth_failed">Sign-in failed</string>
<string name="toast_copied">Copied</string>
<string name="snackbar_not_registered">Device requires registration</string>