android: add auth UI flow and auth-to-chats navigation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.daemonlord.messenger.core.token
|
||||
|
||||
data class TokenBundle(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val savedAtMillis: Long,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user