diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index c369e66..1fab839 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt index f9fd6ec..e6ec322 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt @@ -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() } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/token/TokenBundle.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/token/TokenBundle.kt new file mode 100644 index 0000000..8d8bd94 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/token/TokenBundle.kt @@ -0,0 +1,7 @@ +package ru.daemonlord.messenger.core.token + +data class TokenBundle( + val accessToken: String, + val refreshToken: String, + val savedAtMillis: Long, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/token/TokenRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/token/TokenRepository.kt new file mode 100644 index 0000000..28a3d0c --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/token/TokenRepository.kt @@ -0,0 +1,10 @@ +package ru.daemonlord.messenger.core.token + +import kotlinx.coroutines.flow.Flow + +interface TokenRepository { + fun observeTokens(): Flow + suspend fun getTokens(): TokenBundle? + suspend fun saveTokens(tokens: TokenBundle) + suspend fun clearTokens() +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthUiState.kt new file mode 100644 index 0000000..52dc5f4 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthUiState.kt @@ -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, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..2dc4e93 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt @@ -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 = _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." + } + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..6c98e5a --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt @@ -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") + } + } + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatsPlaceholderScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatsPlaceholderScreen.kt new file mode 100644 index 0000000..dc87746 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatsPlaceholderScreen.kt @@ -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, + ) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt new file mode 100644 index 0000000..d7b9a5e --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt @@ -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() + } +}