authscreen-changed

This commit is contained in:
v3less11
2025-12-10 17:26:04 +03:00
parent 945b9d347d
commit 5a79376482
9 changed files with 314 additions and 67 deletions

View File

@@ -2,11 +2,11 @@ package ru.myitschool.work
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import ru.myitschool.work.data.repo.AuthRepository
class App: Application() { class App: Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
context = this AuthRepository.initialize(this)
} }
companion object { companion object {

View File

@@ -1,16 +1,45 @@
package ru.myitschool.work.data.repo package ru.myitschool.work.data.repo
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.myitschool.work.data.source.NetworkDataSource import ru.myitschool.work.data.source.NetworkDataSource
object AuthRepository { object AuthRepository {
private const val PREFS_NAME = "auth_prefs"
private const val KEY_AUTH_CODE = "auth_code"
private var codeCache: String? = null private var codeCache: String? = null
private lateinit var prefs: SharedPreferences
fun initialize(context: Context) {
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
codeCache = prefs.getString(KEY_AUTH_CODE, null)
}
suspend fun checkAndSave(text: String): Result<Boolean> { suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success -> return withContext(Dispatchers.IO) {
if (success) { NetworkDataSource.checkAuth(code = text).onSuccess { success ->
codeCache = text if (success) {
saveCode(text)
}
} }
} }
} }
private fun saveCode(code: String) {
codeCache = code
prefs.edit().putString(KEY_AUTH_CODE, code).apply()
}
fun getSavedCode(): String? = codeCache
suspend fun clearSavedCode() {
codeCache = null
withContext(Dispatchers.IO) {
prefs.edit().remove(KEY_AUTH_CODE).apply()
}
}
} }

View File

@@ -3,25 +3,38 @@ package ru.myitschool.work.ui.root
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.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.screen.AppNavHost import ru.myitschool.work.ui.screen.AppNavHost
import ru.myitschool.work.ui.theme.WorkTheme import ru.myitschool.work.ui.theme.WorkTheme
class RootActivity : ComponentActivity() { class RootActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Определяем стартовый экран
val startDestination = if (AuthRepository.getSavedCode() != null) {
MainScreenDestination
} else {
AuthScreenDestination
}
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
WorkTheme { WorkTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
AppNavHost( AppNavHost(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding),
startDestination = startDestination
) )
} }
} }

View File

@@ -11,38 +11,39 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import ru.myitschool.work.ui.nav.AppDestination
import ru.myitschool.work.ui.nav.AuthScreenDestination import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.BookScreenDestination import ru.myitschool.work.ui.nav.BookScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.screen.auth.AuthScreen import ru.myitschool.work.ui.screen.auth.AuthScreen
import ru.myitschool.work.ui.screen.main.MainScreen
@Composable @Composable
fun AppNavHost( fun AppNavHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController() navController: NavHostController = rememberNavController(),
startDestination: AppDestination = AuthScreenDestination
) { ) {
NavHost( NavHost(
modifier = modifier, modifier = modifier,
enterTransition = { EnterTransition.None }, enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None }, exitTransition = { ExitTransition.None },
navController = navController, navController = navController,
startDestination = AuthScreenDestination, startDestination = startDestination,
) { ) {
composable<AuthScreenDestination> { composable<AuthScreenDestination> {
AuthScreen(navController = navController) AuthScreen(navController = navController)
} }
composable<MainScreenDestination> { composable<MainScreenDestination> {
Box( MainScreen(navController = navController)
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
}
} }
composable<BookScreenDestination> { composable<BookScreenDestination> {
Box( Box(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text(text = "Hello") Text(text = "Экран бронирования (будет реализован позже)")
} }
} }
} }

View File

@@ -1,29 +1,14 @@
package ru.myitschool.work.ui.screen.auth package ru.myitschool.work.ui.screen.auth
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.*
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.*
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -38,6 +23,7 @@ fun AuthScreen(
navController: NavController navController: NavController
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val error by viewModel.error.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.actionFlow.collect { viewModel.actionFlow.collect {
@@ -57,13 +43,14 @@ fun AuthScreen(
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
when (val currentState = state) {
is AuthState.Data -> Content(viewModel, currentState) // Исправление: Loading теперь внутри AuthState.Data
is AuthState.Loading -> { if (state is AuthState.Data && (state as AuthState.Data).isLoading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(64.dp) modifier = Modifier.size(64.dp)
) )
} } else if (state is AuthState.Data) {
Content(viewModel, state as AuthState.Data, error)
} }
} }
} }
@@ -71,26 +58,44 @@ fun AuthScreen(
@Composable @Composable
private fun Content( private fun Content(
viewModel: AuthViewModel, viewModel: AuthViewModel,
state: AuthState.Data state: AuthState.Data,
error: String?
) { ) {
var inputText by remember { mutableStateOf("") }
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
// Показать ошибку, если есть
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.testTag(TestIds.Auth.ERROR)
.fillMaxWidth()
.padding(bottom = 8.dp),
textAlign = TextAlign.Center
)
}
TextField( TextField(
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(), modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
value = inputText, value = state.inputText,
onValueChange = { onValueChange = {
inputText = it
viewModel.onIntent(AuthIntent.TextInput(it)) viewModel.onIntent(AuthIntent.TextInput(it))
}, },
label = { Text(stringResource(R.string.auth_label)) } label = { Text(stringResource(R.string.auth_label)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
isError = error != null
) )
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Button( Button(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(), modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
onClick = { onClick = {
viewModel.onIntent(AuthIntent.Send(inputText)) viewModel.onIntent(AuthIntent.Send(state.inputText))
}, },
enabled = true enabled = state.isValid && !state.isLoading // Добавил проверку валидности
) { ) {
Text(stringResource(R.string.auth_sign_in)) Text(stringResource(R.string.auth_sign_in))
} }

View File

@@ -1,6 +1,10 @@
package ru.myitschool.work.ui.screen.auth package ru.myitschool.work.ui.screen.auth
sealed interface AuthState { sealed interface AuthState {
object Loading: AuthState data class Data(
object Data: AuthState val inputText: String = "",
val error: String? = null,
val isValid: Boolean = false,
val isLoading: Boolean = false
) : AuthState
} }

View File

@@ -3,41 +3,72 @@ package ru.myitschool.work.ui.screen.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
class AuthViewModel : ViewModel() { class AuthViewModel : ViewModel() {
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data) private val checkAndSaveAuthCodeUseCase by lazy {
CheckAndSaveAuthCodeUseCase(AuthRepository)
}
private val _uiState = MutableStateFlow<AuthState>(
AuthState.Data(inputText = "", isValid = false, isLoading = false)
)
val uiState: StateFlow<AuthState> = _uiState.asStateFlow() val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow() private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow val actionFlow: SharedFlow<Unit> = _actionFlow.asSharedFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
fun onIntent(intent: AuthIntent) { fun onIntent(intent: AuthIntent) {
when (intent) { when (intent) {
is AuthIntent.Send -> { is AuthIntent.Send -> {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.IO) {
_uiState.update { AuthState.Loading } _uiState.update {
checkAndSaveAuthCodeUseCase.invoke("9999").fold( if (it is AuthState.Data) it.copy(isLoading = true) else it
}
checkAndSaveAuthCodeUseCase.invoke(intent.text).fold(
onSuccess = { onSuccess = {
_actionFlow.emit(Unit) _actionFlow.emit(Unit)
_error.value = null
}, },
onFailure = { error -> onFailure = { error ->
error.printStackTrace() _error.value = error.message ?: "Ошибка авторизации"
_actionFlow.emit(Unit) _uiState.update {
if (it is AuthState.Data) it.copy(isLoading = false) else it
}
} }
) )
} }
} }
is AuthIntent.TextInput -> Unit
is AuthIntent.TextInput -> {
if (_error.value != null) {
_error.value = null
}
val isValid = isValidCode(intent.text)
_uiState.update {
if (it is AuthState.Data) it.copy(
inputText = intent.text,
isValid = isValid
) else it
}
}
}
}
private fun isValidCode(text: String): Boolean {
if (text.isEmpty() || text.length != 4) return false
return text.all { char ->
char in 'A'..'Z' || char in 'a'..'z' || char in '0'..'9'
} }
} }
} }

View File

@@ -0,0 +1,94 @@
package ru.myitschool.work.ui.screen.main
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import ru.myitschool.work.R
import ru.myitschool.work.ui.nav.AuthScreenDestination
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
viewModel: MainViewModel = viewModel(),
navController: NavController
) {
val state by viewModel.uiState.collectAsState()
// При первом открытии загружаем данные
LaunchedEffect(key1 = Unit) {
viewModel.loadUserInfo()
}
// Проверяем авторизацию
LaunchedEffect(key1 = viewModel.isUserAuthorized) {
if (!viewModel.isUserAuthorized) {
navController.navigate(AuthScreenDestination) {
popUpTo(0)
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = "Главная") }
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
when (state) {
is MainState.Loading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
is MainState.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = (state as MainState.Error).message,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag("main_error"),
textAlign = TextAlign.Center
)
}
}
is MainState.Success -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Главный экран",
style = MaterialTheme.typography.headlineMedium
)
Text(
text = "Здесь будет информация о пользователе и бронированиях",
modifier = Modifier.padding(top = 16.dp)
)
}
}
}
}
}
}

View File

@@ -0,0 +1,70 @@
package ru.myitschool.work.ui.screen.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository
class MainViewModel : ViewModel() {
private val _uiState = MutableStateFlow<MainState>(MainState.Loading)
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
val isUserAuthorized: Boolean
get() = AuthRepository.getSavedCode() != null
fun loadUserInfo() {
viewModelScope.launch {
_uiState.value = MainState.Loading
try {
val code = AuthRepository.getSavedCode()
if (code == null) {
_uiState.value = MainState.Error("Пользователь не авторизован")
return@launch
}
// TODO: Реальный запрос к API
// Пока заглушка
_uiState.value = MainState.Success(
userInfo = UserInfo("Иван Иванов", ""),
bookings = emptyList()
)
} catch (e: Exception) {
_uiState.value = MainState.Error("Ошибка загрузки: ${e.message}")
}
}
}
fun logout() {
viewModelScope.launch {
AuthRepository.clearSavedCode()
}
}
fun refresh() {
loadUserInfo()
}
}
sealed interface MainState {
object Loading : MainState
data class Error(val message: String) : MainState
data class Success(
val userInfo: UserInfo,
val bookings: List<Booking>
) : MainState
}
// Временные модели (потом перенесем в data/model)
data class UserInfo(
val name: String,
val photoUrl: String
)
data class Booking(
val date: String,
val place: String
)