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..418492f 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 @@ -4,7 +4,8 @@ import ru.myitschool.work.data.source.NetworkDataSource object AuthRepository { - private var codeCache: String? = null + var codeCache: String? = null + private set suspend fun checkAndSave(text: String): Result { return NetworkDataSource.checkAuth(text).onSuccess { success -> @@ -13,4 +14,8 @@ object AuthRepository { } } } + + fun clear() { + codeCache = 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..7ff248b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/BookingRepository.kt @@ -0,0 +1,8 @@ +package ru.myitschool.work.data.repo + +import ru.myitschool.work.data.source.NetworkDataSource + +class BookingRepository { + suspend fun getBookingInfo() = NetworkDataSource.getBookingInfo() + suspend fun bookPlace(date: String, placeId: Int) = NetworkDataSource.bookPlace(date, placeId) +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/repo/InfoRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/InfoRepository.kt new file mode 100644 index 0000000..293e920 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/InfoRepository.kt @@ -0,0 +1,7 @@ +package ru.myitschool.work.data.repo + +import ru.myitschool.work.data.source.NetworkDataSource + +class InfoRepository { + suspend fun getInfo() = NetworkDataSource.getInfo() +} \ No newline at end of file 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..12690d0 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 @@ -4,13 +4,37 @@ import io.ktor.client.HttpClient 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 kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import ru.myitschool.work.core.Constants +import ru.myitschool.work.data.repo.AuthRepository + +data class Info( + val name: String, + val photoUrl: String, + val bookings: List +) + +data class Booking( + val date: String, + val place: String +) + +typealias BookingInfo = JsonObject object NetworkDataSource { private val client by lazy { @@ -33,10 +57,97 @@ object NetworkDataSource { val response = client.get(getUrl(code, Constants.AUTH_URL)) when (response.status) { HttpStatusCode.OK -> true + HttpStatusCode.BadRequest -> error("Bad request") + HttpStatusCode.Unauthorized -> error("Unauthorized") else -> error(response.bodyAsText()) } } } + suspend fun getInfo(): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + val code = AuthRepository.codeCache ?: error("No auth code") + val response = client.get(getUrl(code, Constants.INFO_URL)) + when (response.status) { + HttpStatusCode.OK -> { + val json = response.bodyAsText() + val jsonElement = Json.parseToJsonElement(json) + if (jsonElement !is JsonObject) { + error("Response is not a JSON object") + } + val name = jsonElement["name"]?.jsonPrimitive?.content ?: "" + val photoUrl = jsonElement["photoUrl"]?.jsonPrimitive?.content ?: "" + val bookingsElement = jsonElement["booking"] + val bookings = mutableListOf() + if (bookingsElement is JsonObject) { + for ((isoDate, bookingElement) in bookingsElement) { + if (bookingElement is JsonObject) { + val date = formatDate(isoDate) + val place = bookingElement["place"]?.jsonPrimitive?.content ?: "" + bookings.add(Booking(date, place)) + } + } + } + Info(name, photoUrl, bookings) + } + HttpStatusCode.BadRequest -> error("Bad request") + HttpStatusCode.Unauthorized -> error("Unauthorized") + else -> error(response.bodyAsText()) + } + } + } + + private fun formatDate(isoDate: String): String { + return try { + val parts = isoDate.split("-") + "${parts[2]}.${parts[1]}.${parts[0]}" + } catch (e: Exception) { + isoDate + } + } + + suspend fun getBookingInfo(): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + val code = AuthRepository.codeCache ?: error("No auth code") + val response = client.get(getUrl(code, Constants.BOOKING_URL)) + when (response.status) { + HttpStatusCode.OK -> { + val json = response.bodyAsText() + val jsonElement = Json.parseToJsonElement(json) + if (jsonElement is JsonObject) { + jsonElement + } else { + error("Response is not a JSON object") + } + } + HttpStatusCode.BadRequest -> error("Bad request") + HttpStatusCode.Unauthorized -> error("Unauthorized") + else -> error(response.bodyAsText()) + } + } + } + + suspend fun bookPlace(date: String, placeId: Int): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + val code = AuthRepository.codeCache ?: error("No auth code") + val response = client.post(getUrl(code, Constants.BOOK_URL)) { + setBody(BookRequest(date, placeId)) + contentType(io.ktor.http.ContentType.Application.Json) + } + when (response.status) { + HttpStatusCode.Created -> Unit + HttpStatusCode.Conflict -> error("Already booked") + HttpStatusCode.BadRequest -> error("Bad request") + HttpStatusCode.Unauthorized -> error("Unauthorized") + else -> error(response.bodyAsText()) + } + } + } + + data class BookRequest( + val date: String, + val placeId: Int + ) + private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl" } \ No newline at end of file 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 943be46..012fb6f 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 @@ -9,7 +9,7 @@ class CheckAndSaveAuthCodeUseCase( text: String ): Result { return repository.checkAndSave(text).mapCatching { success -> - if (!success) error("Code is Incorrect") + if (!success) error("Code is incorrect") } } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/booking/GetBookingInfoUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/booking/GetBookingInfoUseCase.kt new file mode 100644 index 0000000..fda3764 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/booking/GetBookingInfoUseCase.kt @@ -0,0 +1,76 @@ +package ru.myitschool.work.domain.booking + +import ru.myitschool.work.data.repo.BookingRepository +import ru.myitschool.work.data.source.BookingInfo as SourceBookingInfo +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +data class PlaceInfo( + val id: Int, + val name: String +) + +data class BookingInfo( + val availableDates: List, + val availablePlaces: Map> +) + +class GetBookingInfoUseCase( + private val repository: BookingRepository +) { + private fun formatDate(isoDate: String): String { + return try { + val parts = isoDate.split("-") + "${parts[2]}.${parts[1]}.${parts[0]}" + } catch (e: Exception) { + isoDate + } + } + + suspend operator fun invoke(): Result { + return repository.getBookingInfo().map { sourceInfo -> + val availableDates = mutableListOf() + val availablePlaces = mutableMapOf>() + + // Итерируемся по всем элементам JSON объекта + for ((isoDate, placesElement) in sourceInfo) { + val date = formatDate(isoDate) + availableDates.add(date) + + if (placesElement is JsonArray) { + val places = placesElement.mapNotNull { placeElement -> + if (placeElement is JsonObject) { + val id = placeElement["id"]?.jsonPrimitive?.int ?: 0 + val placeName = placeElement["place"]?.jsonPrimitive?.content ?: "" + if (id != null && placeName != null) { + PlaceInfo(id = id, name = placeName) + } else { + null + } + } else { + null + } + } + availablePlaces[date] = places + } + } + + BookingInfo( + availableDates = availableDates.sorted(), + availablePlaces = availablePlaces + ) + } + } +} + +class BookPlaceUseCase( + private val repository: BookingRepository +) { + suspend operator fun invoke(date: String, placeId: Int): Result { + return repository.bookPlace(date, placeId) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/info/GetInfoUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/info/GetInfoUseCase.kt new file mode 100644 index 0000000..eb3f220 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/info/GetInfoUseCase.kt @@ -0,0 +1,35 @@ +package ru.myitschool.work.domain.info + +import ru.myitschool.work.data.repo.InfoRepository +import ru.myitschool.work.data.source.Info as SourceInfo +import ru.myitschool.work.data.source.Booking as SourceBooking + + data class Info( + val name: String, + val photoUrl: String, + val bookings: List +) + +data class Booking( + val date: String, + val place: String +) + +class GetInfoUseCase( + private val repository: InfoRepository +) { + suspend operator fun invoke(): Result { + return repository.getInfo().map { sourceInfo -> + Info( + name = sourceInfo.name, + photoUrl = sourceInfo.photoUrl, + bookings = sourceInfo.bookings.map { sourceBooking -> + Booking( + date = sourceBooking.date, + place = sourceBooking.place + ) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt index 557b893..ba34922 100644 --- a/app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt +++ b/app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt @@ -1,3 +1,14 @@ package ru.myitschool.work.ui.nav -sealed interface AppDestination \ No newline at end of file +import androidx.navigation.NavDestination + +sealed interface AppDestination { + val route: String + get() = javaClass.simpleName +} + +val AppDestination.asRoute: String + get() = route + +fun AppDestination.matches(destination: NavDestination?): Boolean = + destination?.route?.startsWith(route) == true \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt b/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt index 54b156d..8fe7477 100644 --- a/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt +++ b/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt @@ -10,6 +10,12 @@ import androidx.compose.material3.Scaffold import androidx.compose.ui.Modifier import ru.myitschool.work.ui.screen.AppNavHost import ru.myitschool.work.ui.theme.WorkTheme +import androidx.navigation.compose.rememberNavController +import ru.myitschool.work.ui.nav.AuthScreenDestination +import ru.myitschool.work.ui.nav.MainScreenDestination +import ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.ui.nav.asRoute + class RootActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -21,7 +27,20 @@ class RootActivity : ComponentActivity() { AppNavHost( modifier = Modifier .fillMaxSize() - .padding(innerPadding) + .padding(innerPadding), + navController = rememberNavController().apply { + addOnDestinationChangedListener { controller, destination, arguments -> + if (destination.route == MainScreenDestination.route && AuthRepository.codeCache == null) { + controller.navigate(AuthScreenDestination) { + popUpTo(controller.graph.startDestinationId) { inclusive = true } + } + } else if (destination.route == AuthScreenDestination.route && AuthRepository.codeCache != null) { + controller.navigate(MainScreenDestination) { + popUpTo(controller.graph.startDestinationId) { inclusive = true } + } + } + } + } ) } } 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..4953c08 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 @@ -15,6 +15,9 @@ 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 +35,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/AuthScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt index f99978e..6c6e6d8 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 @@ -73,7 +73,7 @@ private fun Content( viewModel: AuthViewModel, state: AuthState.Data ) { - var inputText by remember { mutableStateOf("") } + var inputText by remember { mutableStateOf(state.inputCode) } Spacer(modifier = Modifier.size(16.dp)) TextField( modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(), @@ -82,15 +82,24 @@ private fun Content( inputText = it viewModel.onIntent(AuthIntent.TextInput(it)) }, - label = { Text(stringResource(R.string.auth_label)) } + label = { Text(stringResource(R.string.auth_label)) }, + isError = state.error != null ) + if (state.error != null) { + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = state.error, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(TestIds.Auth.ERROR) + ) + } Spacer(modifier = Modifier.size(16.dp)) Button( modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(), onClick = { viewModel.onIntent(AuthIntent.Send(inputText)) }, - enabled = true + enabled = inputText.isNotBlank() && inputText.length == 4 && inputText.matches(Regex("^[a-zA-Z0-9]*$")) ) { Text(stringResource(R.string.auth_sign_in)) } 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..748dd8d 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,9 @@ package ru.myitschool.work.ui.screen.auth sealed interface AuthState { - object Loading: AuthState - object Data: AuthState + object Loading : AuthState + data class Data( + val error: String? = null, + val inputCode: String = "" + ) : AuthState } \ 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..4f76749 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 @@ -15,29 +15,62 @@ import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase class AuthViewModel : ViewModel() { private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) } - private val _uiState = MutableStateFlow(AuthState.Data) + private val _uiState = MutableStateFlow(AuthState.Data()) val uiState: StateFlow = _uiState.asStateFlow() private val _actionFlow: MutableSharedFlow = MutableSharedFlow() val actionFlow: SharedFlow = _actionFlow + init { + // Проверяем, есть ли уже сохранённый код авторизации + if (AuthRepository.codeCache != null) { + viewModelScope.launch { + _actionFlow.emit(Unit) + } + } + } + fun onIntent(intent: AuthIntent) { when (intent) { is AuthIntent.Send -> { + val code = intent.text + if (code.isEmpty()) { + _uiState.update { AuthState.Data(error = "Код не может быть пустым") } + return + } + + if (code.length != 4) { + _uiState.update { AuthState.Data(error = "Код должен содержать 4 символа") } + return + } + + if (!code.matches(Regex("^[a-zA-Z0-9]*$"))) { + _uiState.update { AuthState.Data(error = "Код может содержать только латинские буквы и цифры") } + return + } + viewModelScope.launch(Dispatchers.Default) { _uiState.update { AuthState.Loading } - checkAndSaveAuthCodeUseCase.invoke("9999").fold( + checkAndSaveAuthCodeUseCase.invoke(code).fold( onSuccess = { _actionFlow.emit(Unit) }, onFailure = { error -> error.printStackTrace() + _uiState.update { AuthState.Data(error = "Неверный код авторизации", inputCode = code) } _actionFlow.emit(Unit) } ) } } - is AuthIntent.TextInput -> Unit + is AuthIntent.TextInput -> { + _uiState.update { currentState -> + when (currentState) { + is AuthState.Data -> currentState.copy(inputCode = intent.text, error = null) + else -> currentState + } + } + } } } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt new file mode 100644 index 0000000..3ff40b7 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt @@ -0,0 +1,11 @@ +package ru.myitschool.work.ui.screen.book + +import ru.myitschool.work.domain.booking.PlaceInfo + +sealed interface BookIntent { + data class SelectDate(val date: String) : BookIntent + data class SelectPlace(val place: PlaceInfo) : BookIntent + object Book : BookIntent + object Refresh : BookIntent + object Back : BookIntent +} \ 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..b2bd3a3 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt @@ -0,0 +1,264 @@ +package ru.myitschool.work.ui.screen.book + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +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.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.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +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.core.TestIds +import ru.myitschool.work.ui.nav.MainScreenDestination +import ru.myitschool.work.domain.booking.PlaceInfo + +@Composable +fun BookScreen( + viewModel: BookViewModel = viewModel(), + navController: NavController +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actionFlow.collect { + // При успешном бронировании или возврате - возвращаемся на главный экран + navController.navigateUp() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(all = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + // Кнопка возврата + Button( + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Book.BACK_BUTTON), + onClick = { + viewModel.onIntent(BookIntent.Back) + } + ) { + Text("Назад") + } + + Spacer(modifier = Modifier.size(16.dp)) + + when (val currentState = state) { + is BookState.Loading -> LoadingContent() + is BookState.Data -> DataContent(currentState, viewModel) + is BookState.Error -> ErrorContent(viewModel) + is BookState.Empty -> EmptyContent() + } + } +} + +@Composable +private fun LoadingContent() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp) + ) + } +} + +@Composable +private fun DataContent( + state: BookState.Data, + viewModel: BookViewModel +) { + // Вкладки с датами + LazyRow( + modifier = Modifier.fillMaxWidth() + ) { + items(state.availableDates.size) { index -> + val date = state.availableDates[index] + DateTab( + date = date, + isSelected = state.selectedDate == date, + position = index + ) { + viewModel.onIntent(BookIntent.SelectDate(date)) + } + if (index < state.availableDates.size - 1) { + Spacer(modifier = Modifier.size(8.dp)) + } + } + } + + Spacer(modifier = Modifier.size(24.dp)) + + // Список мест для выбранной даты + if (state.selectedDate != null) { + val places = state.availablePlaces[state.selectedDate] ?: emptyList() + + if (places.isEmpty()) { + Text("Нет доступных мест") + } else { + Text( + text = "Доступные места на ${state.selectedDate}", + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.size(16.dp)) + + Column( + modifier = Modifier.fillMaxWidth() + ) { + places.forEachIndexed { index, place -> + PlaceItem( + place = place, + isSelected = state.selectedPlace == place, + position = index + ) { + viewModel.onIntent(BookIntent.SelectPlace(place)) + } + if (index < places.size - 1) { + Spacer(modifier = Modifier.size(8.dp)) + } + } + } + } + } + + Spacer(modifier = Modifier.size(24.dp)) + + // Кнопка бронирования + Button( + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Book.BOOK_BUTTON), + onClick = { + viewModel.onIntent(BookIntent.Book) + }, + enabled = state.selectedDate != null && state.selectedPlace != null + ) { + Text("Забронировать") + } +} + +@Composable +private fun DateTab( + date: String, + isSelected: Boolean, + position: Int, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .testTag(TestIds.Book.getIdDateItemByPosition(position)) + .selectable( + selected = isSelected, + onClick = onClick, + role = Role.Tab + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = date, + modifier = Modifier.testTag(TestIds.Book.ITEM_DATE), + style = if (isSelected) { + MaterialTheme.typography.titleMedium + } else { + MaterialTheme.typography.bodyMedium + } + ) + } +} + +@Composable +private fun PlaceItem( + place: PlaceInfo, + isSelected: Boolean, + position: Int, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Book.getIdPlaceItemByPosition(position)) + .selectable( + selected = isSelected, + onClick = onClick + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = place.name, + modifier = Modifier + .weight(1f) + .testTag(TestIds.Book.ITEM_PLACE_TEXT) + ) + RadioButton( + selected = isSelected, + modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_SELECTOR), + onClick = null // null because onClick handled by Modifier.selectable + ) + } +} + +@Composable +private fun ErrorContent( + viewModel: BookViewModel +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Произошла ошибка при загрузке данных", + modifier = Modifier.testTag(TestIds.Book.ERROR) + ) + Spacer(modifier = Modifier.size(16.dp)) + Button( + modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON), + onClick = { + viewModel.onIntent(BookIntent.Refresh) + } + ) { + Text("Обновить") + } + } +} + +@Composable +private fun EmptyContent() { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Всё забронировано", + modifier = Modifier.testTag(TestIds.Book.EMPTY) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt new file mode 100644 index 0000000..e5be254 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt @@ -0,0 +1,18 @@ +package ru.myitschool.work.ui.screen.book + +import ru.myitschool.work.ui.screen.main.Booking +import ru.myitschool.work.domain.booking.PlaceInfo + +sealed interface BookState { + object Loading : BookState + data class Data( + val availableDates: List, + val availablePlaces: Map>, + val selectedDate: String? = null, + val selectedPlace: PlaceInfo? = null + ) : BookState + object Error : BookState + object Empty : BookState +} + +// Используем тот же Booking из MainState для единообразия \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt new file mode 100644 index 0000000..d66b587 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt @@ -0,0 +1,124 @@ +package ru.myitschool.work.ui.screen.book + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.BookingRepository +import ru.myitschool.work.domain.booking.GetBookingInfoUseCase +import ru.myitschool.work.domain.booking.BookPlaceUseCase +import ru.myitschool.work.domain.booking.PlaceInfo +import java.time.LocalDate + + +class BookViewModel : ViewModel() { + private val getBookingInfoUseCase by lazy { GetBookingInfoUseCase(BookingRepository()) } + private val bookPlaceUseCase by lazy { BookPlaceUseCase(BookingRepository()) } + + private val _uiState = MutableStateFlow(BookState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow + + init { + loadBookingInfo() + } + + fun onIntent(intent: BookIntent) { + when (intent) { + is BookIntent.SelectDate -> { + _uiState.update { currentState -> + when (currentState) { + is BookState.Data -> { + currentState.copy(selectedDate = intent.date, selectedPlace = null) + } + else -> currentState + } + } + } + is BookIntent.SelectPlace -> { + _uiState.update { currentState -> + when (currentState) { + is BookState.Data -> { + currentState.copy(selectedPlace = intent.place) + } + else -> currentState + } + } + } + BookIntent.Book -> { + bookSelectedPlace() + } + BookIntent.Refresh -> { + loadBookingInfo() + } + BookIntent.Back -> { + viewModelScope.launch { + _actionFlow.emit(Unit) + } + } + } + } + + private fun loadBookingInfo() { + viewModelScope.launch(Dispatchers.Default) { + _uiState.update { BookState.Loading } + getBookingInfoUseCase.invoke().fold( + onSuccess = { bookingInfo -> + if (bookingInfo.availableDates.isEmpty()) { + _uiState.update { BookState.Empty } + } else { + _uiState.update { + BookState.Data( + availableDates = bookingInfo.availableDates.sorted(), + availablePlaces = bookingInfo.availablePlaces, + selectedDate = bookingInfo.availableDates.minOrNull() + ) + } + } + }, + onFailure = { error -> + error.printStackTrace() + _uiState.update { BookState.Error } + } + ) + } + } + + private fun bookSelectedPlace() { + viewModelScope.launch(Dispatchers.Default) { + val currentState = _uiState.value + if (currentState is BookState.Data && + currentState.selectedDate != null && + currentState.selectedPlace != null) { + + _uiState.update { BookState.Loading } + + // Используем реальный placeId из выбранного места + val placeId = currentState.selectedPlace.id + + bookPlaceUseCase.invoke(currentState.selectedDate, placeId).fold( + onSuccess = { + // Успешное бронирование + _actionFlow.emit(Unit) + }, + onFailure = { error -> + error.printStackTrace() + // При ошибке не переходим в состояние Error, а остаемся на экране + // и показываем ошибку в интерфейсе + _uiState.update { + currentState.copy(selectedDate = currentState.selectedDate) + } + } + ) + } + } + } +} \ No newline at end of file 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..086f793 --- /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 { + object Refresh : MainIntent + object Logout : MainIntent + object AddBooking : MainIntent +} \ No newline at end of file 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..28ff9ee --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt @@ -0,0 +1,223 @@ +package ru.myitschool.work.ui.screen.main + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +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.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.core.TestIds +import ru.myitschool.work.ui.nav.BookScreenDestination + +@Composable +fun MainScreen( + viewModel: MainViewModel = viewModel(), + navController: NavController +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actionFlow.collect { action -> + when (action) { + // Переход к экрану бронирования + else -> navController.navigate(BookScreenDestination) + } + } + } + + LaunchedEffect(Unit) { + viewModel.onIntent(MainIntent.Refresh) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(all = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + when (val currentState = state) { + is MainState.Loading -> LoadingContent() + is MainState.Data -> DataContent(currentState, viewModel) + is MainState.Error -> ErrorContent(viewModel) + } + } +} + +@Composable +private fun LoadingContent() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp) + ) + } +} + +@Composable +private fun DataContent( + state: MainState.Data, + viewModel: MainViewModel +) { + // Кнопка выхода + Button( + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Main.LOGOUT_BUTTON), + onClick = { + viewModel.onIntent(MainIntent.Logout) + } + ) { + Text("Выйти") + } + + Spacer(modifier = Modifier.size(16.dp)) + + // Информация о пользователе + Text( + text = "Привет, ${state.name}!", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + modifier = Modifier.testTag(TestIds.Main.PROFILE_NAME) + ) + + Spacer(modifier = Modifier.size(16.dp)) + + // Фото пользователя + // В реальном приложении здесь будет загрузка изображения + Box( + modifier = Modifier + .size(120.dp) + .testTag(TestIds.Main.PROFILE_IMAGE), + contentAlignment = Alignment.Center + ) { + Text("Фото") + } + + Spacer(modifier = Modifier.size(24.dp)) + + // Кнопка обновления + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Текущие бронирования", + style = MaterialTheme.typography.titleMedium + ) + Button( + modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON), + onClick = { + viewModel.onIntent(MainIntent.Refresh) + } + ) { + Text("Обновить") + } + } + + Spacer(modifier = Modifier.size(16.dp)) + + // Список бронирований + LazyColumn( + modifier = Modifier.fillMaxWidth() + ) { + items(state.bookings.size) { index -> + val booking = state.bookings[index] + BookingItem( + booking = booking, + position = index + ) + if (index < state.bookings.size - 1) { + Spacer(modifier = Modifier.size(8.dp)) + } + } + } + + Spacer(modifier = Modifier.size(24.dp)) + + // Кнопка добавления бронирования + Button( + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Main.ADD_BUTTON), + onClick = { + viewModel.onIntent(MainIntent.AddBooking) + } + ) { + Text("Забронировать место") + } +} + +@Composable +private fun BookingItem( + booking: Booking, + position: Int +) { + Column( + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Main.getIdItemByPosition(position)) + ) { + Text( + text = booking.date, + modifier = Modifier.testTag(TestIds.Main.ITEM_DATE) + ) + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = booking.place, + modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE) + ) + } +} + +@Composable +private fun ErrorContent( + viewModel: MainViewModel +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Произошла ошибка при загрузке данных", + modifier = Modifier.testTag(TestIds.Main.ERROR) + ) + Spacer(modifier = Modifier.size(16.dp)) + Button( + modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON), + onClick = { + viewModel.onIntent(MainIntent.Refresh) + } + ) { + Text("Обновить") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt new file mode 100644 index 0000000..d4f04ba --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt @@ -0,0 +1,16 @@ +package ru.myitschool.work.ui.screen.main + +sealed interface MainState { + object Loading : MainState + data class Data( + val name: String, + val photoUrl: String, + val bookings: List + ) : MainState + object Error : MainState +} + +data class Booking( + val date: String, + val place: String +) \ No newline at end of file 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..53d55e9 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt @@ -0,0 +1,68 @@ +package ru.myitschool.work.ui.screen.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.InfoRepository +import ru.myitschool.work.domain.info.GetInfoUseCase +import ru.myitschool.work.data.repo.AuthRepository + +class MainViewModel : ViewModel() { + private val getInfoUseCase by lazy { GetInfoUseCase(InfoRepository()) } + private val _uiState = MutableStateFlow(MainState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow + + init { + loadInfo() + } + + fun onIntent(intent: MainIntent) { + when (intent) { + MainIntent.Refresh -> loadInfo() + MainIntent.Logout -> { + // Очистка данных авторизации + AuthRepository.clear() + // Навигация на экран авторизации + viewModelScope.launch { + _actionFlow.emit(Unit) + } + } + MainIntent.AddBooking -> { + viewModelScope.launch { + _actionFlow.emit(Unit) + } + } + } + } + + private fun loadInfo() { + viewModelScope.launch(Dispatchers.Default) { + _uiState.update { MainState.Loading } + getInfoUseCase.invoke().fold( + onSuccess = { info -> + _uiState.update { + MainState.Data( + name = info.name, + photoUrl = info.photoUrl, + bookings = info.bookings.sortedBy { it.date }.map { Booking(it.date, it.place) } + ) + } + }, + onFailure = { error -> + error.printStackTrace() + _uiState.update { MainState.Error } + } + ) + } + } +} \ No newline at end of file