android: add auth UI flow and auth-to-chats navigation

This commit is contained in:
Codex
2026-03-08 22:21:51 +03:00
parent 0ff838baf7
commit 54b0d4eb8c
9 changed files with 367 additions and 17 deletions

View File

@@ -14,3 +14,9 @@
- Added auth network stack: bearer interceptor, 401 authenticator with refresh flow and retry guard.
- Added clean-layer contracts and implementations: `domain/common`, `domain/auth`, `data/auth/repository`.
- Wired dependencies with Hilt modules for DataStore, OkHttp/Retrofit, and repository bindings.
### Step 3 - Minimal auth UI and navigation
- Replaced Phase 0 placeholder UI with Compose auth flow (`AuthViewModel` + login screen).
- Added loading/error states for login and startup session restore.
- Added navigation graph: `AuthGraph (login)` to placeholder `Chats` screen after successful auth.
- Implemented automatic session restore on app start using stored tokens.

View File

@@ -3,18 +3,15 @@ package ru.daemonlord.messenger
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.fillMaxSize
import dagger.hilt.android.AndroidEntryPoint
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -30,14 +27,5 @@ class MainActivity : ComponentActivity() {
@Composable
private fun AppRoot() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Benya Messenger Android", style = MaterialTheme.typography.headlineSmall)
Text(text = "Phase 0 skeleton is ready.", style = MaterialTheme.typography.bodyMedium)
}
MessengerNavHost()
}

View File

@@ -0,0 +1,7 @@
package ru.daemonlord.messenger.core.token
data class TokenBundle(
val accessToken: String,
val refreshToken: String,
val savedAtMillis: Long,
)

View File

@@ -0,0 +1,10 @@
package ru.daemonlord.messenger.core.token
import kotlinx.coroutines.flow.Flow
interface TokenRepository {
fun observeTokens(): Flow<TokenBundle?>
suspend fun getTokens(): TokenBundle?
suspend fun saveTokens(tokens: TokenBundle)
suspend fun clearTokens()
}

View File

@@ -0,0 +1,10 @@
package ru.daemonlord.messenger.ui.auth
data class AuthUiState(
val email: String = "",
val password: String = "",
val isCheckingSession: Boolean = true,
val isLoading: Boolean = false,
val isAuthenticated: Boolean = false,
val errorMessage: String? = null,
)

View File

@@ -0,0 +1,107 @@
package ru.daemonlord.messenger.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.domain.auth.usecase.LoginUseCase
import ru.daemonlord.messenger.domain.auth.usecase.RestoreSessionUseCase
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
private val restoreSessionUseCase: RestoreSessionUseCase,
private val loginUseCase: LoginUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
init {
restoreSession()
}
fun onEmailChanged(value: String) {
_uiState.update { it.copy(email = value, errorMessage = null) }
}
fun onPasswordChanged(value: String) {
_uiState.update { it.copy(password = value, errorMessage = null) }
}
fun login() {
val state = uiState.value
if (state.email.isBlank() || state.password.isBlank()) {
_uiState.update { it.copy(errorMessage = "Email and password are required.") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
when (val result = loginUseCase(state.email.trim(), state.password)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isLoading = false,
isAuthenticated = true,
errorMessage = null,
)
}
}
is AppResult.Error -> {
_uiState.update {
it.copy(
isLoading = false,
isAuthenticated = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
}
private fun restoreSession() {
viewModelScope.launch {
_uiState.update { it.copy(isCheckingSession = true) }
when (val result = restoreSessionUseCase()) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isCheckingSession = false,
isAuthenticated = true,
errorMessage = null,
)
}
}
is AppResult.Error -> {
_uiState.update {
it.copy(
isCheckingSession = false,
isAuthenticated = false,
errorMessage = null,
)
}
}
}
}
}
private fun AppError.toUiMessage(): String {
return when (this) {
AppError.InvalidCredentials -> "Invalid email or password."
AppError.Network -> "Network error. Check your connection."
AppError.Unauthorized -> "Session expired. Please sign in again."
is AppError.Server -> "Server error. Please try again."
is AppError.Unknown -> "Unknown error. Please try again."
}
}
}

View File

@@ -0,0 +1,95 @@
package ru.daemonlord.messenger.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun LoginScreen(
state: AuthUiState,
onEmailChanged: (String) -> Unit,
onPasswordChanged: (String) -> Unit,
onLoginClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Messenger Login",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 24.dp),
)
OutlinedTextField(
value = state.email,
onValueChange = onEmailChanged,
label = { Text(text = "Email") },
singleLine = true,
enabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
)
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChanged,
label = { Text(text = "Password") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
enabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
)
Button(
onClick = onLoginClick,
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth(),
) {
if (state.isLoading) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.padding(2.dp),
)
} else {
Text(text = "Login")
}
}
if (!state.errorMessage.isNullOrBlank()) {
Text(
text = state.errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
} else {
TextButton(
onClick = {},
enabled = false,
modifier = Modifier.padding(top = 8.dp),
) {
Text(text = "Use your existing backend account")
}
}
}
}

View File

@@ -0,0 +1,32 @@
package ru.daemonlord.messenger.ui.chats
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun ChatsPlaceholderScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Chats",
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = "Phase 1 placeholder. Chats list comes next.",
style = MaterialTheme.typography.bodyMedium,
)
}
}

View File

@@ -0,0 +1,95 @@
package ru.daemonlord.messenger.ui.navigation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import ru.daemonlord.messenger.ui.auth.AuthViewModel
import ru.daemonlord.messenger.ui.auth.LoginScreen
import ru.daemonlord.messenger.ui.chats.ChatsPlaceholderScreen
private object Routes {
const val AuthGraph = "auth_graph"
const val Login = "login"
const val Chats = "chats"
}
@Composable
fun MessengerNavHost(
navController: NavHostController = rememberNavController(),
viewModel: AuthViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(uiState.isCheckingSession, uiState.isAuthenticated) {
if (uiState.isCheckingSession) {
return@LaunchedEffect
}
if (uiState.isAuthenticated) {
navController.navigate(Routes.Chats) {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = true
}
launchSingleTop = true
}
} else {
navController.navigate(Routes.Login) {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = true
}
launchSingleTop = true
}
}
}
NavHost(
navController = navController,
startDestination = Routes.AuthGraph,
) {
navigation(
route = Routes.AuthGraph,
startDestination = Routes.Login,
) {
composable(route = Routes.Login) {
if (uiState.isCheckingSession) {
SessionCheckingScreen()
} else {
LoginScreen(
state = uiState,
onEmailChanged = viewModel::onEmailChanged,
onPasswordChanged = viewModel::onPasswordChanged,
onLoginClick = viewModel::login,
)
}
}
}
composable(route = Routes.Chats) {
ChatsPlaceholderScreen()
}
}
}
@Composable
private fun SessionCheckingScreen() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
}
}