From 69943c80798270fba8e81f2485b36cccc44aebc4 Mon Sep 17 00:00:00 2001 From: bot-tg-simple <144138955+bot-tg-simple@users.noreply.github.com> Date: Tue, 9 Dec 2025 22:49:58 +0300 Subject: [PATCH] solve --- app/src/main/AndroidManifest.xml | 1 + .../work/data/model/BookRequestDto.kt | 12 + .../work/data/model/BookingAvailabilityDto.kt | 8 + .../work/data/model/BookingOptionDto.kt | 9 + .../myitschool/work/data/model/UserInfoDto.kt | 18 ++ .../work/data/repo/AuthRepository.kt | 42 ++- .../work/data/repo/BookingRepository.kt | 16 + .../work/data/repo/UserRepository.kt | 10 + .../work/data/source/NetworkDataSource.kt | 45 ++- .../work/data/storage/AuthLocalDataSource.kt | 33 +++ .../work/data/storage/SettingsDataStore.kt | 15 + .../auth/CheckAndSaveAuthCodeUseCase.kt | 8 +- .../ru/myitschool/work/ui/nav/NavResults.kt | 3 + .../work/ui/screen/NavigationGraph.kt | 17 +- .../work/ui/screen/auth/AuthAction.kt | 5 + .../work/ui/screen/auth/AuthIntent.kt | 2 +- .../work/ui/screen/auth/AuthScreen.kt | 102 +++++-- .../work/ui/screen/auth/AuthState.kt | 10 +- .../work/ui/screen/auth/AuthViewModel.kt | 75 +++-- .../work/ui/screen/book/BookScreen.kt | 279 ++++++++++++++++++ .../work/ui/screen/book/BookingAction.kt | 6 + .../work/ui/screen/book/BookingIntent.kt | 9 + .../work/ui/screen/book/BookingUiState.kt | 31 ++ .../work/ui/screen/book/BookingViewModel.kt | 141 +++++++++ .../work/ui/screen/main/MainAction.kt | 6 + .../work/ui/screen/main/MainIntent.kt | 7 + .../work/ui/screen/main/MainScreen.kt | 264 +++++++++++++++++ .../work/ui/screen/main/MainUiState.kt | 21 ++ .../work/ui/screen/main/MainViewModel.kt | 102 +++++++ app/src/main/res/values/strings.xml | 15 + 30 files changed, 1230 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/ru/myitschool/work/data/model/BookRequestDto.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/model/BookingAvailabilityDto.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/model/BookingOptionDto.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/model/UserInfoDto.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/repo/BookingRepository.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/repo/UserRepository.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/storage/AuthLocalDataSource.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/storage/SettingsDataStore.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/nav/NavResults.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthAction.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/book/BookingAction.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/book/BookingIntent.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/book/BookingUiState.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/book/BookingViewModel.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/main/MainAction.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/main/MainUiState.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a2c02bd..497b591 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,7 @@ android:name=".ui.root.RootActivity" android:exported="true" android:windowSoftInputMode="adjustResize" + android:screenOrientation="portrait" android:label="@string/title_activity_root"> diff --git a/app/src/main/java/ru/myitschool/work/data/model/BookRequestDto.kt b/app/src/main/java/ru/myitschool/work/data/model/BookRequestDto.kt new file mode 100644 index 0000000..8ad0099 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/model/BookRequestDto.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BookRequestDto( + @SerialName("date") + val date: String, + @SerialName("placeId") + val placeId: Int +) diff --git a/app/src/main/java/ru/myitschool/work/data/model/BookingAvailabilityDto.kt b/app/src/main/java/ru/myitschool/work/data/model/BookingAvailabilityDto.kt new file mode 100644 index 0000000..571ae50 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/model/BookingAvailabilityDto.kt @@ -0,0 +1,8 @@ +package ru.myitschool.work.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class BookingAvailabilityDto( + val entries: Map> +) diff --git a/app/src/main/java/ru/myitschool/work/data/model/BookingOptionDto.kt b/app/src/main/java/ru/myitschool/work/data/model/BookingOptionDto.kt new file mode 100644 index 0000000..7bead94 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/model/BookingOptionDto.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class BookingOptionDto( + val id: Int, + val place: String +) diff --git a/app/src/main/java/ru/myitschool/work/data/model/UserInfoDto.kt b/app/src/main/java/ru/myitschool/work/data/model/UserInfoDto.kt new file mode 100644 index 0000000..2b0ed4b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/model/UserInfoDto.kt @@ -0,0 +1,18 @@ +package ru.myitschool.work.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserInfoDto( + val name: String, + @SerialName("photoUrl") + val photoUrl: String, + val booking: Map = emptyMap() +) + +@Serializable +data class UserBookingDto( + val id: Int, + val place: String +) diff --git a/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt index 3ef28f1..3344523 100644 --- a/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt +++ b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt @@ -1,16 +1,48 @@ package ru.myitschool.work.data.repo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import ru.myitschool.work.data.source.NetworkDataSource +import ru.myitschool.work.data.storage.AuthLocalDataSource object AuthRepository { - private var codeCache: String? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val _codeState: MutableStateFlow = MutableStateFlow(null) + val codeState: StateFlow = _codeState.asStateFlow() - suspend fun checkAndSave(text: String): Result { - return NetworkDataSource.checkAuth(text).onSuccess { success -> - if (success) { - codeCache = text + init { + scope.launch { + AuthLocalDataSource.codeFlow.collect { storedCode -> + _codeState.value = storedCode } } } + + suspend fun checkAndSave(text: String): Result { + return NetworkDataSource.checkAuth(text).onSuccess { + AuthLocalDataSource.saveCode(text) + _codeState.value = text + } + } + + suspend fun getCurrentCode(): String? { + val cached = _codeState.value + if (cached != null) return cached + val loaded = AuthLocalDataSource.currentCode() + if (loaded != null) { + _codeState.value = loaded + } + return loaded + } + + suspend fun clear() { + AuthLocalDataSource.clear() + _codeState.value = null + } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/repo/BookingRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/BookingRepository.kt new file mode 100644 index 0000000..f31ca56 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/BookingRepository.kt @@ -0,0 +1,16 @@ +package ru.myitschool.work.data.repo + +import ru.myitschool.work.data.model.BookRequestDto +import ru.myitschool.work.data.model.BookingOptionDto +import ru.myitschool.work.data.source.NetworkDataSource + +object BookingRepository { + + suspend fun fetchAvailability(code: String): Result>> { + return NetworkDataSource.fetchBookingAvailability(code) + } + + suspend fun createBooking(code: String, date: String, placeId: Int): Result { + return NetworkDataSource.createBooking(code, BookRequestDto(date = date, placeId = placeId)) + } +} diff --git a/app/src/main/java/ru/myitschool/work/data/repo/UserRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/UserRepository.kt new file mode 100644 index 0000000..9865a0c --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/UserRepository.kt @@ -0,0 +1,10 @@ +package ru.myitschool.work.data.repo + +import ru.myitschool.work.data.model.UserInfoDto +import ru.myitschool.work.data.source.NetworkDataSource + +object UserRepository { + suspend fun fetchUserInfo(code: String): Result { + return NetworkDataSource.fetchUserInfo(code) + } +} diff --git a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt index fbdfef5..9c5173f 100644 --- a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt +++ b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt @@ -1,16 +1,24 @@ package ru.myitschool.work.data.source import io.ktor.client.HttpClient +import io.ktor.client.call.body import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import ru.myitschool.work.core.Constants +import ru.myitschool.work.data.model.BookRequestDto +import ru.myitschool.work.data.model.BookingOptionDto +import ru.myitschool.work.data.model.UserInfoDto object NetworkDataSource { private val client by lazy { @@ -28,11 +36,44 @@ object NetworkDataSource { } } - suspend fun checkAuth(code: String): Result = withContext(Dispatchers.IO) { + suspend fun checkAuth(code: String): Result = withContext(Dispatchers.IO) { return@withContext runCatching { val response = client.get(getUrl(code, Constants.AUTH_URL)) when (response.status) { - HttpStatusCode.OK -> true + HttpStatusCode.OK -> Unit + else -> error(response.bodyAsText()) + } + } + } + + suspend fun fetchUserInfo(code: String): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.get(getUrl(code, Constants.INFO_URL)) + when (response.status) { + HttpStatusCode.OK -> response.body() + else -> error(response.bodyAsText()) + } + } + } + + suspend fun fetchBookingAvailability(code: String): Result>> = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.get(getUrl(code, Constants.BOOKING_URL)) + when (response.status) { + HttpStatusCode.OK -> response.body() + else -> error(response.bodyAsText()) + } + } + } + + suspend fun createBooking(code: String, request: BookRequestDto): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.post(getUrl(code, Constants.BOOK_URL)) { + contentType(ContentType.Application.Json) + setBody(request) + } + when (response.status) { + HttpStatusCode.Created -> Unit else -> error(response.bodyAsText()) } } diff --git a/app/src/main/java/ru/myitschool/work/data/storage/AuthLocalDataSource.kt b/app/src/main/java/ru/myitschool/work/data/storage/AuthLocalDataSource.kt new file mode 100644 index 0000000..11e3ade --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/storage/AuthLocalDataSource.kt @@ -0,0 +1,33 @@ +package ru.myitschool.work.data.storage + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map + +object AuthLocalDataSource { + + private val codeKey = stringPreferencesKey("auth_code") + + val codeFlow: Flow = SettingsDataStore.dataStore.data + .map { preferences -> preferences[codeKey] } + + suspend fun saveCode(code: String) { + SettingsDataStore.dataStore.edit { preferences -> + preferences[codeKey] = code + } + } + + suspend fun clear() { + SettingsDataStore.dataStore.edit { preferences -> + preferences.remove(codeKey) + } + } + + suspend fun currentCode(): String? { + return SettingsDataStore.dataStore.data + .map { it[codeKey] } + .firstOrNull() + } +} diff --git a/app/src/main/java/ru/myitschool/work/data/storage/SettingsDataStore.kt b/app/src/main/java/ru/myitschool/work/data/storage/SettingsDataStore.kt new file mode 100644 index 0000000..5472c36 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/storage/SettingsDataStore.kt @@ -0,0 +1,15 @@ +package ru.myitschool.work.data.storage + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import ru.myitschool.work.App + +private val Context.authDataStore by preferencesDataStore(name = "auth_settings") + +object SettingsDataStore { + val dataStore: DataStore by lazy { + App.context.authDataStore + } +} diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt index 012fb6f..733d95c 100644 --- a/app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt +++ b/app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt @@ -5,11 +5,7 @@ import ru.myitschool.work.data.repo.AuthRepository class CheckAndSaveAuthCodeUseCase( private val repository: AuthRepository ) { - suspend operator fun invoke( - text: String - ): Result { - return repository.checkAndSave(text).mapCatching { success -> - if (!success) error("Code is incorrect") - } + suspend operator fun invoke(text: String): Result { + return repository.checkAndSave(text) } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/NavResults.kt b/app/src/main/java/ru/myitschool/work/ui/nav/NavResults.kt new file mode 100644 index 0000000..5e614e6 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/nav/NavResults.kt @@ -0,0 +1,3 @@ +package ru.myitschool.work.ui.nav + +const val BOOKING_RESULT_KEY = "booking_result" diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt b/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt index 01b0f32..ef82b0c 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt @@ -2,10 +2,7 @@ package ru.myitschool.work.ui.screen import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -15,6 +12,8 @@ import ru.myitschool.work.ui.nav.AuthScreenDestination import ru.myitschool.work.ui.nav.BookScreenDestination import ru.myitschool.work.ui.nav.MainScreenDestination import ru.myitschool.work.ui.screen.auth.AuthScreen +import ru.myitschool.work.ui.screen.book.BookScreen +import ru.myitschool.work.ui.screen.main.MainScreen @Composable fun AppNavHost( @@ -32,18 +31,10 @@ fun AppNavHost( AuthScreen(navController = navController) } composable { - Box( - contentAlignment = Alignment.Center - ) { - Text(text = "Hello") - } + MainScreen(navController = navController) } composable { - Box( - contentAlignment = Alignment.Center - ) { - Text(text = "Hello") - } + BookScreen(navController = navController) } } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthAction.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthAction.kt new file mode 100644 index 0000000..7c62de2 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthAction.kt @@ -0,0 +1,5 @@ +package ru.myitschool.work.ui.screen.auth + +sealed interface AuthAction { + data object NavigateToMain : AuthAction +} diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthIntent.kt index 74f200a..4be2f68 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthIntent.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthIntent.kt @@ -1,6 +1,6 @@ package ru.myitschool.work.ui.screen.auth sealed interface AuthIntent { - data class Send(val text: String): AuthIntent data class TextInput(val text: String): AuthIntent + data object Send: AuthIntent } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt index f99978e..499d359 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt @@ -10,28 +10,30 @@ 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.OutlinedTextField 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.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.KeyboardOptions import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController +import kotlinx.coroutines.flow.collectLatest import ru.myitschool.work.R import ru.myitschool.work.core.TestIds import ru.myitschool.work.ui.nav.MainScreenDestination +private val CODE_REGEX = Regex("^[A-Za-z0-9]{4}$") + @Composable fun AuthScreen( viewModel: AuthViewModel = viewModel(), @@ -40,11 +42,22 @@ fun AuthScreen( val state by viewModel.uiState.collectAsState() LaunchedEffect(Unit) { - viewModel.actionFlow.collect { - navController.navigate(MainScreenDestination) + viewModel.actionFlow.collectLatest { + when (it) { + AuthAction.NavigateToMain -> { + navController.navigate(MainScreenDestination) { + popUpTo(MainScreenDestination) { inclusive = true } + } + } + } } } + if (state.isCheckingSavedCode) { + BoxLoading() + return + } + Column( modifier = Modifier .fillMaxSize() @@ -57,41 +70,66 @@ fun AuthScreen( style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center ) - when (val currentState = state) { - is AuthState.Data -> Content(viewModel, currentState) - is AuthState.Loading -> { - CircularProgressIndicator( - modifier = Modifier.size(64.dp) - ) - } - } + Spacer(modifier = Modifier.size(24.dp)) + Content(viewModel = viewModel, state = state) } } @Composable private fun Content( viewModel: AuthViewModel, - state: AuthState.Data + state: AuthUiState ) { - var inputText by remember { mutableStateOf("") } - Spacer(modifier = Modifier.size(16.dp)) - TextField( - modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(), - value = inputText, - onValueChange = { - inputText = it - viewModel.onIntent(AuthIntent.TextInput(it)) - }, - label = { Text(stringResource(R.string.auth_label)) } + val isCodeValidForButton = state.code.length == 4 && CODE_REGEX.matches(state.code) + + OutlinedTextField( + modifier = Modifier + .testTag(TestIds.Auth.CODE_INPUT) + .fillMaxWidth(), + value = state.code, + onValueChange = { viewModel.onIntent(AuthIntent.TextInput(it)) }, + label = { Text(stringResource(R.string.auth_label)) }, + placeholder = { Text(stringResource(R.string.auth_label)) }, + singleLine = true, + enabled = !state.isLoading, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Done + ) ) + if (state.showError) { + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.auth_error_text), + modifier = Modifier.testTag(TestIds.Auth.ERROR), + color = MaterialTheme.colorScheme.error + ) + } Spacer(modifier = Modifier.size(16.dp)) Button( - modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(), - onClick = { - viewModel.onIntent(AuthIntent.Send(inputText)) - }, - enabled = true + modifier = Modifier + .testTag(TestIds.Auth.SIGN_BUTTON) + .fillMaxWidth(), + onClick = { viewModel.onIntent(AuthIntent.Send) }, + enabled = !state.isLoading && isCodeValidForButton ) { - Text(stringResource(R.string.auth_sign_in)) + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + Text(stringResource(R.string.auth_sign_in)) + } + } +} + +@Composable +private fun BoxLoading() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt index a06ba76..25be22e 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt @@ -1,6 +1,8 @@ package ru.myitschool.work.ui.screen.auth -sealed interface AuthState { - object Loading: AuthState - object Data: AuthState -} \ No newline at end of file +data class AuthUiState( + val code: String = "", + val isLoading: Boolean = false, + val showError: Boolean = false, + val isCheckingSavedCode: Boolean = true +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt index 3153640..08f30b5 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt @@ -12,32 +12,69 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import ru.myitschool.work.data.repo.AuthRepository import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase +import ru.myitschool.work.ui.screen.auth.AuthIntent.Send +import ru.myitschool.work.ui.screen.auth.AuthIntent.TextInput class AuthViewModel : ViewModel() { private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) } - private val _uiState = MutableStateFlow(AuthState.Data) - val uiState: StateFlow = _uiState.asStateFlow() + private val _uiState = MutableStateFlow(AuthUiState()) + val uiState: StateFlow = _uiState.asStateFlow() - private val _actionFlow: MutableSharedFlow = MutableSharedFlow() - val actionFlow: SharedFlow = _actionFlow + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow + + init { + viewModelScope.launch(Dispatchers.IO) { + val savedCode = AuthRepository.getCurrentCode() + if (savedCode != null) { + _actionFlow.emit(AuthAction.NavigateToMain) + } else { + _uiState.update { it.copy(isCheckingSavedCode = false) } + } + } + } fun onIntent(intent: AuthIntent) { when (intent) { - is AuthIntent.Send -> { - viewModelScope.launch(Dispatchers.Default) { - _uiState.update { AuthState.Loading } - checkAndSaveAuthCodeUseCase.invoke("9999").fold( - onSuccess = { - _actionFlow.emit(Unit) - }, - onFailure = { error -> - error.printStackTrace() - _actionFlow.emit(Unit) - } - ) - } - } - is AuthIntent.TextInput -> Unit + is TextInput -> onTextChanged(intent.text) + Send -> onSendClicked() } } + + private fun onTextChanged(text: String) { + _uiState.update { + it.copy( + code = text, + showError = false + ) + } + } + + private fun onSendClicked() { + val code = _uiState.value.code + if (!isCodeValid(code)) { + _uiState.update { it.copy(showError = true) } + return + } + + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { it.copy(isLoading = true) } + checkAndSaveAuthCodeUseCase.invoke(code).fold( + onSuccess = { + _uiState.update { state -> state.copy(isLoading = false, showError = false) } + _actionFlow.emit(AuthAction.NavigateToMain) + }, + onFailure = { + _uiState.update { state -> state.copy(isLoading = false, showError = true) } + } + ) + } + } + + private fun isCodeValid(code: String): Boolean { + if (code.length != 4) return false + if (code.isBlank()) return false + val regex = "^[A-Za-z0-9]{4}$".toRegex() + return regex.matches(code) + } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt new file mode 100644 index 0000000..ec8d8f5 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt @@ -0,0 +1,279 @@ +package ru.myitschool.work.ui.screen.book + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import kotlinx.coroutines.flow.collectLatest +import ru.myitschool.work.R +import ru.myitschool.work.core.TestIds +import ru.myitschool.work.ui.nav.AuthScreenDestination +import ru.myitschool.work.ui.nav.BOOKING_RESULT_KEY +import androidx.compose.ui.res.stringResource + +@Composable +fun BookScreen( + navController: NavController, + viewModel: BookingViewModel = viewModel() +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actionFlow.collectLatest { action -> + when (action) { + BookingAction.NavigateToAuth -> { + navController.navigate(AuthScreenDestination) { + popUpTo(AuthScreenDestination) { inclusive = true } + } + } + + BookingAction.CloseWithSuccess -> { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(BOOKING_RESULT_KEY, true) + navController.popBackStack() + } + } + } + } + + when { + state.isLoading -> BookLoading() + state.showError -> BookError( + onRefresh = { viewModel.onIntent(BookingIntent.Refresh) }, + onBack = { navController.popBackStack() } + ) + state.showEmpty -> BookEmpty(onBack = { navController.popBackStack() }) + else -> BookContent( + state = state, + onSelectDate = { viewModel.onIntent(BookingIntent.SelectDate(it)) }, + onSelectPlace = { viewModel.onIntent(BookingIntent.SelectPlace(it)) }, + onBook = { viewModel.onIntent(BookingIntent.Book) }, + onBack = { navController.popBackStack() } + ) + } +} + +@Composable +private fun BookLoading() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun BookError(onRefresh: () -> Unit, onBack: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier.testTag(TestIds.Book.ERROR), + text = stringResource(R.string.book_error_text), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Book.REFRESH_BUTTON), + onClick = onRefresh + ) { + Text(text = stringResource(R.string.book_refresh)) + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Book.BACK_BUTTON), + onClick = onBack + ) { + Text(text = stringResource(R.string.book_back)) + } + } +} + +@Composable +private fun BookEmpty(onBack: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier.testTag(TestIds.Book.EMPTY), + text = stringResource(R.string.book_empty), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Book.BACK_BUTTON), + onClick = onBack + ) { + Text(text = stringResource(R.string.book_back)) + } + } +} + +@Composable +private fun BookContent( + state: BookingUiState, + onSelectDate: (Int) -> Unit, + onSelectPlace: (Int) -> Unit, + onBook: () -> Unit, + onBack: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + ScrollableTabRow(selectedTabIndex = state.selectedDateIndex) { + state.dates.forEachIndexed { index, date -> + Tab( + selected = state.selectedDateIndex == index, + onClick = { onSelectDate(index) } + ) { + Column( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 8.dp) + .testTag(TestIds.Book.getIdDateItemByPosition(index)), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier.testTag(TestIds.Book.ITEM_DATE), + text = date.displayDate, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + + val places = state.dates.getOrNull(state.selectedDateIndex)?.places.orEmpty() + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(places) { index, place -> + PlaceItem( + index = index, + title = place.title, + selected = index == state.selectedPlaceIndex, + onClick = { onSelectPlace(index) } + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextButton( + modifier = Modifier + .weight(1f) + .testTag(TestIds.Book.BACK_BUTTON), + onClick = onBack + ) { + Text(text = stringResource(R.string.book_back)) + } + Button( + modifier = Modifier + .weight(1f) + .testTag(TestIds.Book.BOOK_BUTTON), + enabled = state.canBook, + onClick = onBook + ) { + if (state.isBooking) { + CircularProgressIndicator(modifier = Modifier.height(16.dp)) + } else { + Text(text = stringResource(R.string.book_book)) + } + } + } + } +} + +@Composable +private fun PlaceItem( + index: Int, + title: String, + selected: Boolean, + onClick: () -> Unit +) { + Surface( + tonalElevation = if (selected) 4.dp else 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Book.getIdPlaceItemByPosition(index)) + .selectable( + selected = selected, + role = Role.RadioButton, + onClick = onClick + ) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_SELECTOR), + selected = selected, + onClick = null + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_TEXT), + text = title, + style = MaterialTheme.typography.bodyLarge + ) + } + } +} diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookingAction.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookingAction.kt new file mode 100644 index 0000000..9e79b48 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookingAction.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.screen.book + +sealed interface BookingAction { + data object CloseWithSuccess : BookingAction + data object NavigateToAuth : BookingAction +} diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookingIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookingIntent.kt new file mode 100644 index 0000000..5d1c280 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookingIntent.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.ui.screen.book + +sealed interface BookingIntent { + data object Refresh : BookingIntent + data class SelectDate(val index: Int) : BookingIntent + data class SelectPlace(val index: Int) : BookingIntent + data object Book : BookingIntent + data object Back : BookingIntent +} diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookingUiState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookingUiState.kt new file mode 100644 index 0000000..dc5a6e6 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookingUiState.kt @@ -0,0 +1,31 @@ +package ru.myitschool.work.ui.screen.book + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class BookingUiState( + val isLoading: Boolean = true, + val showError: Boolean = false, + val showEmpty: Boolean = false, + val dates: ImmutableList = persistentListOf(), + val selectedDateIndex: Int = 0, + val selectedPlaceIndex: Int = -1, + val isBooking: Boolean = false +) { + val canBook: Boolean + get() = dates.isNotEmpty() && + selectedDateIndex in dates.indices && + selectedPlaceIndex in dates[selectedDateIndex].places.indices && + !isBooking +} + +data class BookingDateUi( + val isoDate: String, + val displayDate: String, + val places: ImmutableList +) + +data class BookingPlaceUi( + val id: Int, + val title: String +) diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookingViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookingViewModel.kt new file mode 100644 index 0000000..8724422 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookingViewModel.kt @@ -0,0 +1,141 @@ +package ru.myitschool.work.ui.screen.book + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +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 ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.data.repo.BookingRepository +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class BookingViewModel : ViewModel() { + private val _uiState = MutableStateFlow(BookingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow + + private val isoFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + private val displayFormatter = DateTimeFormatter.ofPattern("dd.MM") + + init { + refresh() + } + + fun onIntent(intent: BookingIntent) { + when (intent) { + BookingIntent.Refresh -> refresh() + is BookingIntent.SelectDate -> selectDate(intent.index) + is BookingIntent.SelectPlace -> selectPlace(intent.index) + BookingIntent.Book -> book() + BookingIntent.Back -> Unit + } + } + + private fun refresh() { + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { + it.copy( + isLoading = true, + showError = false, + showEmpty = false, + isBooking = false + ) + } + val code = AuthRepository.getCurrentCode() + if (code == null) { + _actionFlow.emit(BookingAction.NavigateToAuth) + return@launch + } + BookingRepository.fetchAvailability(code).fold( + onSuccess = { map -> + val dates = map.entries + .filter { it.value.isNotEmpty() } + .sortedBy { LocalDate.parse(it.key, isoFormatter) } + .map { entry -> + BookingDateUi( + isoDate = entry.key, + displayDate = LocalDate.parse(entry.key, isoFormatter).format(displayFormatter), + places = entry.value.map { option -> + BookingPlaceUi( + id = option.id, + title = option.place + ) + }.toImmutableList() + ) + } + .toImmutableList() + + val showEmpty = dates.isEmpty() + _uiState.update { + it.copy( + isLoading = false, + showError = false, + showEmpty = showEmpty, + dates = dates, + selectedDateIndex = 0, + selectedPlaceIndex = if (!showEmpty && dates[0].places.isNotEmpty()) 0 else -1 + ) + } + }, + onFailure = { + _uiState.update { state -> + state.copy(isLoading = false, showError = true) + } + } + ) + } + } + + private fun selectDate(index: Int) { + _uiState.update { state -> + if (index !in state.dates.indices) state + else state.copy( + selectedDateIndex = index, + selectedPlaceIndex = if (state.dates[index].places.isNotEmpty()) 0 else -1 + ) + } + } + + private fun selectPlace(index: Int) { + _uiState.update { state -> + val currentPlaces = state.dates.getOrNull(state.selectedDateIndex)?.places + ?: return@update state + if (index !in currentPlaces.indices) return@update state + state.copy(selectedPlaceIndex = index) + } + } + + private fun book() { + val currentState = _uiState.value + if (!currentState.canBook) return + + viewModelScope.launch(Dispatchers.IO) { + val code = AuthRepository.getCurrentCode() + if (code == null) { + _actionFlow.emit(BookingAction.NavigateToAuth) + return@launch + } + val date = currentState.dates[currentState.selectedDateIndex] + val place = date.places[currentState.selectedPlaceIndex] + _uiState.update { it.copy(isBooking = true) } + BookingRepository.createBooking(code, date.isoDate, place.id).fold( + onSuccess = { + _uiState.update { it.copy(isBooking = false) } + _actionFlow.emit(BookingAction.CloseWithSuccess) + }, + onFailure = { + _uiState.update { it.copy(isBooking = false, showError = true) } + } + ) + } + } +} diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainAction.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainAction.kt new file mode 100644 index 0000000..f5a9a37 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainAction.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.screen.main + +sealed interface MainAction { + data object NavigateToAuth : MainAction + data object NavigateToBooking : MainAction +} diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt new file mode 100644 index 0000000..3ef600f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt @@ -0,0 +1,7 @@ +package ru.myitschool.work.ui.screen.main + +sealed interface MainIntent { + data object Refresh : MainIntent + data object Logout : MainIntent + data object AddBooking : MainIntent +} diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt new file mode 100644 index 0000000..e301b5a --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt @@ -0,0 +1,264 @@ +package ru.myitschool.work.ui.screen.main + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +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 coil3.compose.AsyncImage +import kotlinx.coroutines.flow.collectLatest +import ru.myitschool.work.R +import ru.myitschool.work.core.TestIds +import ru.myitschool.work.ui.nav.AuthScreenDestination +import ru.myitschool.work.ui.nav.BOOKING_RESULT_KEY +import ru.myitschool.work.ui.nav.BookScreenDestination +import ru.myitschool.work.ui.screen.main.MainAction.NavigateToAuth +import ru.myitschool.work.ui.screen.main.MainAction.NavigateToBooking + +@Composable +private fun ProfileAvatar( + name: String, + photoUrl: String? +) { + val modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .testTag(TestIds.Main.PROFILE_IMAGE) + if (photoUrl.isNullOrBlank()) { + Box( + modifier = modifier.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + val placeholder = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?" + Text( + text = placeholder, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } else { + AsyncImage( + modifier = modifier, + model = photoUrl, + contentDescription = null, + contentScale = ContentScale.Crop + ) + } +} + +@Composable +fun MainScreen( + navController: NavController, + viewModel: MainViewModel = viewModel() +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actionFlow.collectLatest { action -> + when (action) { + NavigateToAuth -> { + navController.navigate(AuthScreenDestination) { + popUpTo(AuthScreenDestination) { inclusive = true } + } + } + + NavigateToBooking -> { + navController.navigate(BookScreenDestination) + } + } + } + } + + val refreshFlow = navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow(BOOKING_RESULT_KEY, false) + + LaunchedEffect(refreshFlow) { + refreshFlow?.collectLatest { shouldRefresh -> + if (shouldRefresh) { + viewModel.onIntent(MainIntent.Refresh) + navController.currentBackStackEntry?.savedStateHandle?.set(BOOKING_RESULT_KEY, false) + } + } + } + + when { + state.isLoading -> MainLoading() + state.showError -> MainError(onRetry = { viewModel.onIntent(MainIntent.Refresh) }) + else -> MainContent( + state = state, + onRefresh = { viewModel.onIntent(MainIntent.Refresh) }, + onAddBooking = { viewModel.onIntent(MainIntent.AddBooking) }, + onLogout = { viewModel.onIntent(MainIntent.Logout) } + ) + } +} + +@Composable +private fun MainLoading() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun MainError(onRetry: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier.testTag(TestIds.Main.ERROR), + text = stringResource(R.string.main_error_text), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Main.REFRESH_BUTTON), + onClick = onRetry + ) { + Text(text = stringResource(R.string.main_refresh)) + } + } +} + +@Composable +private fun MainContent( + state: MainUiState, + onRefresh: () -> Unit, + onAddBooking: () -> Unit, + onLogout: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + ProfileAvatar( + name = state.name, + photoUrl = state.photoUrl + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + modifier = Modifier.testTag(TestIds.Main.PROFILE_NAME), + text = state.name, + style = MaterialTheme.typography.headlineSmall + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + modifier = Modifier + .weight(1f) + .testTag(TestIds.Main.ADD_BUTTON), + onClick = onAddBooking + ) { + Text(text = stringResource(R.string.main_add)) + } + Button( + modifier = Modifier + .weight(1f) + .testTag(TestIds.Main.REFRESH_BUTTON), + onClick = onRefresh + ) { + Text(text = stringResource(R.string.main_refresh)) + } + Button( + modifier = Modifier + .weight(1f) + .testTag(TestIds.Main.LOGOUT_BUTTON), + onClick = onLogout + ) { + Text(text = stringResource(R.string.main_logout)) + } + } + + Text( + text = stringResource(R.string.main_bookings_title), + style = MaterialTheme.typography.titleMedium + ) + + if (state.bookings.isEmpty()) { + Text( + text = stringResource(R.string.main_empty_list), + style = MaterialTheme.typography.bodyMedium + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(state.bookings) { index, booking -> + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Main.getIdItemByPosition(index)), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + modifier = Modifier.testTag(TestIds.Main.ITEM_DATE), + text = booking.dateDisplay, + style = MaterialTheme.typography.titleMedium + ) + Text( + modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE), + text = booking.place, + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainUiState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainUiState.kt new file mode 100644 index 0000000..5afab8b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainUiState.kt @@ -0,0 +1,21 @@ +package ru.myitschool.work.ui.screen.main + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class MainUiState( + val isLoading: Boolean = true, + val showError: Boolean = false, + val name: String = "", + val photoUrl: String? = null, + val bookings: ImmutableList = persistentListOf() +) { + val hasContent: Boolean get() = !isLoading && !showError +} + +data class MainBookingUi( + val id: Int, + val dateIso: String, + val dateDisplay: String, + val place: String +) diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt new file mode 100644 index 0000000..987519c --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt @@ -0,0 +1,102 @@ +package ru.myitschool.work.ui.screen.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +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 ru.myitschool.work.core.Constants +import ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.data.repo.UserRepository +import ru.myitschool.work.ui.screen.main.MainAction.NavigateToAuth +import ru.myitschool.work.ui.screen.main.MainAction.NavigateToBooking +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class MainViewModel : ViewModel() { + private val _uiState = MutableStateFlow(MainUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow + + private val dateFormatterServer = DateTimeFormatter.ofPattern("yyyy-MM-dd") + private val dateFormatterDisplay = DateTimeFormatter.ofPattern("dd.MM.yyyy") + + init { + refresh() + } + + fun onIntent(intent: MainIntent) { + when (intent) { + MainIntent.Refresh -> refresh() + MainIntent.Logout -> logout() + MainIntent.AddBooking -> openBooking() + } + } + + private fun refresh() { + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { it.copy(isLoading = true, showError = false) } + val code = AuthRepository.getCurrentCode() + if (code == null) { + _actionFlow.emit(NavigateToAuth) + return@launch + } + UserRepository.fetchUserInfo(code).fold( + onSuccess = { info -> + val bookings = info.booking.entries + .sortedBy { LocalDate.parse(it.key, dateFormatterServer) } + .mapIndexed { index, entry -> + val date = LocalDate.parse(entry.key, dateFormatterServer) + MainBookingUi( + id = index, + dateIso = entry.key, + dateDisplay = date.format(dateFormatterDisplay), + place = entry.value.place + ) + } + .toImmutableList() + val resolvedPhoto = info.photoUrl.let { url -> + if (url.isBlank()) null + else if (url.startsWith("http")) url + else "${Constants.HOST}$url" + } + _uiState.update { + it.copy( + isLoading = false, + showError = false, + name = info.name, + photoUrl = resolvedPhoto, + bookings = bookings + ) + } + }, + onFailure = { + _uiState.update { state -> + state.copy(isLoading = false, showError = true) + } + } + ) + } + } + + private fun openBooking() { + viewModelScope.launch { + _actionFlow.emit(NavigateToBooking) + } + } + + private fun logout() { + viewModelScope.launch(Dispatchers.IO) { + AuthRepository.clear() + _actionFlow.emit(NavigateToAuth) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fa8bda6..4b0f841 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,4 +4,19 @@ Привет! Введи код для авторизации Код Войти + Неверный код или произошла ошибка. Попробуйте снова. + Выйти + Обновить + Забронировать + Не удалось загрузить данные. Попробуйте обновить. + Ваши бронирования + Нет активных бронирований + Бронирование + Забронировать + Назад + Не удалось получить данные. Повторите попытку. + Обновить + Выберите дату + Выберите место + Всё забронировано \ No newline at end of file