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 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`.
|
- 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.
|
- 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 android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
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() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -30,14 +27,5 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppRoot() {
|
private fun AppRoot() {
|
||||||
Column(
|
MessengerNavHost()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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