diff --git a/app/src/main/java/ru/myitschool/work/booking/data/AvailableBookingResponse.kt b/app/src/main/java/ru/myitschool/work/booking/data/AvailableBookingResponse.kt index f9b7b78..fa5a302 100644 --- a/app/src/main/java/ru/myitschool/work/booking/data/AvailableBookingResponse.kt +++ b/app/src/main/java/ru/myitschool/work/booking/data/AvailableBookingResponse.kt @@ -2,10 +2,7 @@ package ru.myitschool.work.booking.data import kotlinx.serialization.Serializable -@Serializable -data class AvailableBookingResponse( - val dates: Map> = emptyMap() -) +typealias AvailableBookingResponse = Map> @Serializable data class AvailablePlace( diff --git a/app/src/main/java/ru/myitschool/work/booking/data/BookingRepository.kt b/app/src/main/java/ru/myitschool/work/booking/data/BookingRepository.kt index 8734ff3..458cf69 100644 --- a/app/src/main/java/ru/myitschool/work/booking/data/BookingRepository.kt +++ b/app/src/main/java/ru/myitschool/work/booking/data/BookingRepository.kt @@ -9,7 +9,7 @@ object BookingRepository { return when (val result = NetworkDataSource.getAvailableForBooking(code)) { is MyResult.Success -> { val bookingData = BookingData( - dateToPlaces = result.data.dates + dateToPlaces = result.data ) MyResult.Success(bookingData) } diff --git a/app/src/main/java/ru/myitschool/work/booking/domain/BookingData.kt b/app/src/main/java/ru/myitschool/work/booking/domain/BookingData.kt index 7e88028..36eec64 100644 --- a/app/src/main/java/ru/myitschool/work/booking/domain/BookingData.kt +++ b/app/src/main/java/ru/myitschool/work/booking/domain/BookingData.kt @@ -4,19 +4,4 @@ import ru.myitschool.work.booking.data.AvailablePlace data class BookingData( val dateToPlaces: Map> -){ - fun getSortedDatesWithPlaces(): List { - return dateToPlaces.keys - .filter { dateToPlaces[it]?.isNotEmpty() == true } - .sorted() - } - - fun getPlacesForDate(date: String): List { - return dateToPlaces[date] ?: emptyList() - } - - fun hasAvailableDates(): Boolean { - return dateToPlaces.any { it.value.isNotEmpty() } - } -} - +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/booking/presentation/BookingIntent.kt b/app/src/main/java/ru/myitschool/work/booking/presentation/BookingIntent.kt new file mode 100644 index 0000000..bccfb1a --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/booking/presentation/BookingIntent.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.booking.presentation + +sealed interface BookingIntent { + object LoadData: BookingIntent + data class SelectDate(val date: String): BookingIntent + data class SelectPlace(val placeId: Int): BookingIntent + object Book: BookingIntent + object Refresh: BookingIntent +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/booking/presentation/BookingScreen.kt b/app/src/main/java/ru/myitschool/work/booking/presentation/BookingScreen.kt index 4be8733..bd914bf 100644 --- a/app/src/main/java/ru/myitschool/work/booking/presentation/BookingScreen.kt +++ b/app/src/main/java/ru/myitschool/work/booking/presentation/BookingScreen.kt @@ -1,19 +1,213 @@ package ru.myitschool.work.booking.presentation -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +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.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavController +import ru.myitschool.work.core.TestIds +import java.text.SimpleDateFormat +import java.util.Locale +@OptIn(ExperimentalMaterial3Api::class) @Composable fun BookingScreen( - viewModel: BookingViewModel = viewModel(), - navController: NavController + onNavigateBack: () -> Unit, + onBookingSuccess: () -> Unit, + viewModel: BookingViewModel = viewModel() ) { - Scaffold() { padding -> - Box(Modifier.padding(padding)) + val state by viewModel.state.collectAsState() + + LaunchedEffect(state.bookingSuccess) { + if (state.bookingSuccess) { + onBookingSuccess() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Бронирование") }, + navigationIcon = { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON) + ) { + Text("<") + } + } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + when { + state.isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + state.error != null -> { + ErrorContent( + error = state.error!!, + onRefresh = { viewModel.onIntent(BookingIntent.Refresh) } + ) + } + state.dates.isEmpty() -> { + EmptyContent(onNavigateBack = onNavigateBack) + } + else -> { + BookingContent( + state = state, + onIntent = { viewModel.onIntent(it) } + ) + } + } + } + } +} + +@Composable +private fun ErrorContent( + error: String, + onRefresh: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = error, + modifier = Modifier.testTag(TestIds.Book.ERROR) + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = onRefresh, + modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON) + ) { + Text("Обновить") + } + } +} + +@Composable +private fun EmptyContent(onNavigateBack: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Всё забронировано", + modifier = Modifier.testTag(TestIds.Book.EMPTY) + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = onNavigateBack, + modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON) + ) { + Text("Назад") + } + } +} + +@Composable +private fun BookingContent( + state: BookingState, + onIntent: (BookingIntent) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + ) { + TabRow( + selectedTabIndex = state.dates.indexOf(state.selectedDate), + modifier = Modifier.fillMaxWidth() + ) { + state.dates.forEachIndexed { index, date -> + Tab( + selected = date == state.selectedDate, + onClick = { onIntent(BookingIntent.SelectDate(date)) }, + modifier = Modifier.testTag(TestIds.Book.getIdDateItemByPosition(index)) + ) { + Text( + text = formatDateForDisplay(date), + modifier = Modifier + .testTag(TestIds.Book.ITEM_DATE) + .padding(16.dp) + ) + } + } + } + + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + state.availablePlaces.forEachIndexed { index, place -> + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Book.getIdPlaceItemByPosition(index)) + .selectable( + selected = place.id == state.selectedPlaceId, + onClick = { onIntent(BookingIntent.SelectPlace(place.id)) } + ) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = place.id == state.selectedPlaceId, + onClick = { onIntent(BookingIntent.SelectPlace(place.id)) }, + modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_SELECTOR) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = place.place, + modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_TEXT) + ) + } + } + } + + Button( + onClick = { onIntent(BookingIntent.Book) }, + enabled = state.selectedPlaceId != null && !state.isBookingInProgress, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .testTag(TestIds.Book.BOOK_BUTTON) + ) { + Text( + text = if (state.isBookingInProgress) "Бронирование..." else "Забронировать" + ) + } + } +} + +private fun formatDateForDisplay(isoDate: String): String { + return try { + val inputFormat = SimpleDateFormat("yyyy-MM-dd") + val outputFormat = SimpleDateFormat("dd.MM") + val date = inputFormat.parse(isoDate) + date?.let { outputFormat.format(it) } ?: isoDate + } catch (e: Exception) { + isoDate } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/booking/presentation/BookingState.kt b/app/src/main/java/ru/myitschool/work/booking/presentation/BookingState.kt new file mode 100644 index 0000000..da6e09c --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/booking/presentation/BookingState.kt @@ -0,0 +1,15 @@ +package ru.myitschool.work.booking.presentation + +import ru.myitschool.work.booking.data.AvailablePlace + +data class BookingState( + val isLoading: Boolean = true, + val error: String? = null, + val dates: List = emptyList(), + val dateToPlacesMap: Map> = emptyMap(), + val selectedDate: String? = null, + val availablePlaces: List = emptyList(), + val selectedPlaceId: Int? = null, + val isBookingInProgress: Boolean = false, + val bookingSuccess: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/booking/presentation/BookingViewModel.kt b/app/src/main/java/ru/myitschool/work/booking/presentation/BookingViewModel.kt index 4cd9fdb..8a36cc0 100644 --- a/app/src/main/java/ru/myitschool/work/booking/presentation/BookingViewModel.kt +++ b/app/src/main/java/ru/myitschool/work/booking/presentation/BookingViewModel.kt @@ -1,7 +1,142 @@ package ru.myitschool.work.booking.presentation import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ru.myitschool.work.booking.data.BookingRepository +import ru.myitschool.work.core.MyResult +import ru.myitschool.work.util.DataStoreManager +import java.text.SimpleDateFormat +import java.util.Locale -class BookingViewModel: ViewModel() { +class BookingViewModel : ViewModel() { + private val _state = MutableStateFlow(BookingState()) + val state: StateFlow = _state.asStateFlow() + init { + onIntent(BookingIntent.LoadData) + } + + fun onIntent(intent: BookingIntent) { + when (intent) { + is BookingIntent.LoadData -> loadData() + is BookingIntent.SelectDate -> selectDate(intent.date) + is BookingIntent.SelectPlace -> selectPlace(intent.placeId) + is BookingIntent.Book -> book() + is BookingIntent.Refresh -> refresh() + } + } + + private fun loadData() { + viewModelScope.launch { + _state.value = _state.value.copy(isLoading = true, error = null) + + val code = DataStoreManager.getAuthCode() + if (code == null) { + _state.value = _state.value.copy( + isLoading = false, + error = "Код авторизации не найден" + ) + return@launch + } + + when (val result = BookingRepository.getAvailableForBooking(code)) { + is MyResult.Success -> { + val dateToPlacesMap = result.data.dateToPlaces + + val sortedDates = dateToPlacesMap.keys + .filter { date -> + val places = dateToPlacesMap[date] + places != null && places.isNotEmpty() + } + .sortedBy { parseDate(it) } + + val firstDate = sortedDates.firstOrNull() + val places = firstDate?.let { dateToPlacesMap[it] } ?: emptyList() + + _state.value = BookingState( + isLoading = false, + dates = sortedDates, + dateToPlacesMap = dateToPlacesMap, + selectedDate = firstDate, + availablePlaces = places, + selectedPlaceId = null + ) + } + is MyResult.Error -> { + _state.value = _state.value.copy( + isLoading = false, + error = result.error + ) + } + } + } + } + + private fun selectDate(date: String) { + val currentState = _state.value + val places = currentState.dateToPlacesMap[date] ?: emptyList() + + _state.value = _state.value.copy( + selectedDate = date, + availablePlaces = places, + selectedPlaceId = null + ) + } + + private fun selectPlace(placeId: Int) { + _state.value = _state.value.copy(selectedPlaceId = placeId) + } + + private fun book() { + val currentState = _state.value + val date = currentState.selectedDate + val placeId = currentState.selectedPlaceId + + if (date == null || placeId == null) return + + viewModelScope.launch { + _state.value = _state.value.copy(isBookingInProgress = true, error = null) + + val code = DataStoreManager.getAuthCode() + if (code == null) { + _state.value = _state.value.copy( + isBookingInProgress = false, + error = "Код авторизации не найден" + ) + return@launch + } + + when (val result = BookingRepository.createBooking(code, date, placeId)) { + is MyResult.Success -> { + _state.value = _state.value.copy( + isBookingInProgress = false, + bookingSuccess = true + ) + } + is MyResult.Error -> { + _state.value = _state.value.copy( + isBookingInProgress = false, + error = result.error + ) + } + } + } + } + + private fun refresh() { + loadData() + } + + private fun parseDate(dateString: String): Long { + return try { + val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) + dateFormat.parse(dateString)?.time ?: 0L + } catch (e: Exception) { + 0L + } + } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/core/NetworkDataSource.kt b/app/src/main/java/ru/myitschool/work/core/NetworkDataSource.kt index c550ff7..67b44db 100644 --- a/app/src/main/java/ru/myitschool/work/core/NetworkDataSource.kt +++ b/app/src/main/java/ru/myitschool/work/core/NetworkDataSource.kt @@ -1,5 +1,6 @@ package ru.myitschool.work.core +import android.util.Log import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.cio.CIO @@ -7,6 +8,7 @@ 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 @@ -15,6 +17,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import ru.myitschool.work.booking.data.AvailableBookingResponse +import ru.myitschool.work.booking.data.AvailablePlace import ru.myitschool.work.booking.data.BookRequest import ru.myitschool.work.user.data.UserInfoResponse @@ -73,7 +76,8 @@ object NetworkDataSource { val response = client.get(getUrl(code, Constants.BOOKING_URL)) when (response.status) { HttpStatusCode.OK -> { - MyResult.Success(response.body()) + val bookingResponse: Map> = response.body() + MyResult.Success(bookingResponse) } else -> { @@ -93,7 +97,7 @@ object NetworkDataSource { setBody(BookRequest(date = date, placeID = placeId)) } when (response.status) { - HttpStatusCode.OK -> { + HttpStatusCode.OK, HttpStatusCode.Created -> { MyResult.Success(Unit) } diff --git a/app/src/main/java/ru/myitschool/work/ui/NavigationGraph.kt b/app/src/main/java/ru/myitschool/work/ui/NavigationGraph.kt index 8841ec5..21ada0e 100644 --- a/app/src/main/java/ru/myitschool/work/ui/NavigationGraph.kt +++ b/app/src/main/java/ru/myitschool/work/ui/NavigationGraph.kt @@ -47,7 +47,14 @@ fun AppNavHost( MainScreen(navController = navController) } composable { - BookingScreen(navController = navController) + BookingScreen( + onNavigateBack = { + navController.popBackStack() + }, + onBookingSuccess = { + navController.popBackStack() + } + ) } } } \ No newline at end of file