This commit is contained in:
imglmd
2025-12-05 16:42:09 +03:00
parent 2ddf6ec534
commit 30ecbb6008
9 changed files with 380 additions and 34 deletions

View File

@@ -2,10 +2,7 @@ package ru.myitschool.work.booking.data
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable typealias AvailableBookingResponse = Map<String, List<AvailablePlace>>
data class AvailableBookingResponse(
val dates: Map<String, List<AvailablePlace>> = emptyMap()
)
@Serializable @Serializable
data class AvailablePlace( data class AvailablePlace(

View File

@@ -9,7 +9,7 @@ object BookingRepository {
return when (val result = NetworkDataSource.getAvailableForBooking(code)) { return when (val result = NetworkDataSource.getAvailableForBooking(code)) {
is MyResult.Success -> { is MyResult.Success -> {
val bookingData = BookingData( val bookingData = BookingData(
dateToPlaces = result.data.dates dateToPlaces = result.data
) )
MyResult.Success(bookingData) MyResult.Success(bookingData)
} }

View File

@@ -4,19 +4,4 @@ import ru.myitschool.work.booking.data.AvailablePlace
data class BookingData( data class BookingData(
val dateToPlaces: Map<String, List<AvailablePlace>> val dateToPlaces: Map<String, List<AvailablePlace>>
){ )
fun getSortedDatesWithPlaces(): List<String> {
return dateToPlaces.keys
.filter { dateToPlaces[it]?.isNotEmpty() == true }
.sorted()
}
fun getPlacesForDate(date: String): List<AvailablePlace> {
return dateToPlaces[date] ?: emptyList()
}
fun hasAvailableDates(): Boolean {
return dateToPlaces.any { it.value.isNotEmpty() }
}
}

View File

@@ -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
}

View File

@@ -1,19 +1,213 @@
package ru.myitschool.work.booking.presentation package ru.myitschool.work.booking.presentation
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Scaffold import androidx.compose.foundation.selection.selectable
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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 @Composable
fun BookingScreen( fun BookingScreen(
viewModel: BookingViewModel = viewModel(), onNavigateBack: () -> Unit,
navController: NavController onBookingSuccess: () -> Unit,
viewModel: BookingViewModel = viewModel()
) { ) {
Scaffold() { padding -> val state by viewModel.state.collectAsState()
Box(Modifier.padding(padding))
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
} }
} }

View File

@@ -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<String> = emptyList(),
val dateToPlacesMap: Map<String, List<AvailablePlace>> = emptyMap(),
val selectedDate: String? = null,
val availablePlaces: List<AvailablePlace> = emptyList(),
val selectedPlaceId: Int? = null,
val isBookingInProgress: Boolean = false,
val bookingSuccess: Boolean = false
)

View File

@@ -1,7 +1,142 @@
package ru.myitschool.work.booking.presentation package ru.myitschool.work.booking.presentation
import androidx.lifecycle.ViewModel 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<BookingState> = _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
}
}
} }

View File

@@ -1,5 +1,6 @@
package ru.myitschool.work.core package ru.myitschool.work.core
import android.util.Log
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO 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.get
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType import io.ktor.http.contentType
@@ -15,6 +17,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.myitschool.work.booking.data.AvailableBookingResponse 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.booking.data.BookRequest
import ru.myitschool.work.user.data.UserInfoResponse import ru.myitschool.work.user.data.UserInfoResponse
@@ -73,7 +76,8 @@ object NetworkDataSource {
val response = client.get(getUrl(code, Constants.BOOKING_URL)) val response = client.get(getUrl(code, Constants.BOOKING_URL))
when (response.status) { when (response.status) {
HttpStatusCode.OK -> { HttpStatusCode.OK -> {
MyResult.Success(response.body()) val bookingResponse: Map<String, List<AvailablePlace>> = response.body()
MyResult.Success(bookingResponse)
} }
else -> { else -> {
@@ -93,7 +97,7 @@ object NetworkDataSource {
setBody(BookRequest(date = date, placeID = placeId)) setBody(BookRequest(date = date, placeID = placeId))
} }
when (response.status) { when (response.status) {
HttpStatusCode.OK -> { HttpStatusCode.OK, HttpStatusCode.Created -> {
MyResult.Success(Unit) MyResult.Success(Unit)
} }

View File

@@ -47,7 +47,14 @@ fun AppNavHost(
MainScreen(navController = navController) MainScreen(navController = navController)
} }
composable<Screen.Book> { composable<Screen.Book> {
BookingScreen(navController = navController) BookingScreen(
onNavigateBack = {
navController.popBackStack()
},
onBookingSuccess = {
navController.popBackStack()
}
)
} }
} }
} }