added generalscreen

added booking screeen
This commit is contained in:
v3less11
2025-12-11 20:50:28 +03:00
parent 5a79376482
commit 2e332b8ff9
17 changed files with 1537 additions and 89 deletions

View File

@@ -35,17 +35,93 @@ android {
}
dependencies {
// Базовая библиотека Compose (если есть в проекте)
defaultComposeLibrary()
// DataStore для хранения настроек
implementation("androidx.datastore:datastore-preferences:1.1.7")
// Коллекции
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
// Navigation Compose
implementation("androidx.navigation:navigation-compose:2.9.6")
val coil = "3.3.0"
implementation("io.coil-kt.coil3:coil-compose:$coil")
implementation("io.coil-kt.coil3:coil-network-ktor3:$coil")
val ktor = "3.3.1"
// ===== КЛЮЧЕВЫЕ ЗАВИСИМОСТИ ДЛЯ РАБОТЫ: =====
// Core KTX
implementation("androidx.core:core-ktx:1.12.0")
// Lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
// ⚠️ САМАЯ ВАЖНАЯ: Activity Compose (рабочая версия)
implementation("androidx.activity:activity-compose:1.8.0")
// ViewModel для Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.5")
// ===== COMPOSE: =====
// Compose BOM (управляет версиями)
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
// Базовые библиотеки Compose (без версий - берутся из BOM)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.ui:ui-tooling")
// Material3
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material3:material3-window-size-class")
// Иконки Material (ОБЯЗАТЕЛЬНО для Icons.Default)
implementation("androidx.compose.material:material-icons-extended")
// Foundation
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.foundation:foundation-layout")
// Runtime для StateFlow
implementation("androidx.compose.runtime:runtime-livedata")
// ===== СЕТЬ И ИЗОБРАЖЕНИЯ: =====
// Coil для загрузки изображений (версия 2.x - проще)
implementation("io.coil-kt:coil-compose:2.5.0")
// Ktor (РЕКОМЕНДУЮ 2.3.8 вместо 3.3.1)
val ktor = "2.3.8" // ← ИЗМЕНИЛ НА 2.3.8 (стабильнее)
implementation("io.ktor:ktor-client-core:$ktor")
implementation("io.ktor:ktor-client-cio:$ktor")
implementation("io.ktor:ktor-client-content-negotiation:$ktor")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor")
implementation("io.ktor:ktor-client-core:2.3.8")
implementation("io.ktor:ktor-client-cio:2.3.8")
implementation("io.ktor:ktor-client-content-negotiation:2.3.8")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.8")
implementation("io.ktor:ktor-client-logging:2.3.8") // Добавьте это!
// Kotlinx Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
// Coil для изображений
implementation("io.coil-kt:coil-compose:2.6.0")
// Kotlinx Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
// ===== ТЕСТИРОВАНИЕ: =====
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

View File

@@ -0,0 +1,4 @@
package ru.myitschool.work.data.local
class AuthDataStore {
}

View File

@@ -0,0 +1,16 @@
/*package ru.myitschool.work.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AvailableBooking (
@SerialName("date")
val date: String,
@SerialName("place")
val place: String,
@SerialName("available")
val available: Boolean = true
)*/

View File

@@ -0,0 +1,143 @@
package ru.myitschool.work.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// ========== API МОДЕЛИ (для сетевых запросов) ==========
// ✅ Ответ для GET /api/{code}/info (согласно ТЗ Backend)
@Serializable
data class UserInfoResponse(
@SerialName("name")
val name: String,
@SerialName("photoUrl")
val photoUrl: String,
@SerialName("booking")
val booking: Map<String, BookingInfo> = emptyMap()
)
// ✅ Информация о бронировании (вложенный объект)
@Serializable
data class BookingInfo(
@SerialName("id")
val id: Long,
@SerialName("place")
val place: String
)
// ✅ Ответ для GET /api/{code}/booking (доступные места)
@Serializable
data class AvailableBookingsResponse(
// Ключ: дата в формате "2025-01-05", Значение: список мест
val data: Map<String, List<PlaceInfo>>
)
@Serializable
data class PlaceInfo(
@SerialName("id")
val id: Long,
@SerialName("place")
val place: String
)
// ✅ Запрос для POST /api/{code}/book (создание брони)
@Serializable
data class BookRequest(
@SerialName("date")
val date: String, // формат: "2025-01-05"
@SerialName("placeId")
val placeId: Long
)
// ✅ Ответ для GET /api/{code}/booking (альтернативный формат из вашего кода)
@Serializable
data class AvailableBooking(
val dates: List<BookingDate>
)
@Serializable
data class BookingDate(
val date: String, // формат dd.MM.yyyy
val places: List<String>
)
// ✅ Запрос для бронирования (альтернативный формат из вашего кода)
@Serializable
data class BookPlaceRequest(
val date: String, // формат dd.MM.yyyy
val place: String
)
// ========== ДОПОЛНИТЕЛЬНЫЕ МОДЕЛИ ДЛЯ СОВМЕСТИМОСТИ ==========
// Модель для конвертации данных из AvailableBookingsResponse в AvailableBooking
data class ConvertedBookingDate(
val date: String, // в формате dd.MM.yyyy
val places: List<String>
)
// ========== UI МОДЕЛИ (для отображения) ==========
// Модель для главного экрана
data class UserInfoUi(
val name: String,
val photoUrl: String
)
// Модель для отображения бронирования
data class BookingUi(
val date: String, // формат: "05.01.2025"
val place: String
)
// Модель для экрана бронирования (доступные места)
data class AvailablePlaceUi(
val id: Long,
val place: String,
val date: String, // формат: "05.01.2025"
val available: Boolean = true
)
// ========== ХЕЛПЕРЫ ДЛЯ КОНВЕРТАЦИИ ==========
// Функция для конвертации AvailableBookingsResponse в AvailableBooking
fun AvailableBookingsResponse.toAvailableBooking(): AvailableBooking {
val dates = this.data.map { (dateKey, places) ->
// Конвертируем дату из "2025-01-05" в "05.01.2025"
val dateParts = dateKey.split("-")
val formattedDate = "${dateParts[2]}.${dateParts[1]}.${dateParts[0]}"
// Извлекаем названия мест
val placeNames = places.map { it.place }
BookingDate(
date = formattedDate,
places = placeNames
)
}
return AvailableBooking(dates = dates)
}
// Функция для конвертации BookPlaceRequest в BookRequest
fun BookPlaceRequest.toBookRequest(): BookRequest? {
return try {
// Конвертируем дату из "05.01.2025" в "2025-01-05"
val dateParts = this.date.split(".")
val formattedDate = "${dateParts[2]}-${dateParts[1]}-${dateParts[0]}"
// ВНИМАНИЕ: Здесь нужно знать ID места, что невозможно без дополнительной информации
// Это проблема в вашем текущем дизайне моделей
BookRequest(
date = formattedDate,
placeId = 0 // Нужно передавать реальный ID из PlaceInfo
)
} catch (e: Exception) {
null
}
}

View File

@@ -0,0 +1,52 @@
package ru.myitschool.work.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// ДОБАВЬТЕ "Api" ко всем API моделям
@Serializable
data class UserInfoApiResponse(
@SerialName("user_info")
val userInfo: UserInfoApiData? = null,
@SerialName("bookings")
val bookings: List<BookingApiData> = emptyList()
)
@Serializable
data class UserInfoApiData(
@SerialName("name")
val name: String = "",
@SerialName("photo_url")
val photoUrl: String = ""
)
@Serializable
data class BookingApiData(
@SerialName("date")
val date: String = "", // формат из API: dd.MM.yyyy
@SerialName("place")
val place: String = ""
)
@Serializable
data class BookRequestApi(
@SerialName("date")
val date: String,
@SerialName("place")
val place: String
)
// UI модели - ДОБАВЬТЕ "Ui" или "Model"
data class UserInfoModel(
val name: String,
val photoUrl: String
)
data class BookingModel(
val date: String,
val place: String
)

View File

@@ -0,0 +1,31 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.model.AvailableBookingsResponse
import ru.myitschool.work.data.model.BookPlaceRequest
import ru.myitschool.work.data.source.NetworkDataSource
interface BookingRepository {
suspend fun getAvailableBookings(authCode: String): Result<AvailableBookingsResponse>
suspend fun getAvailableBookingsLegacy(authCode: String): Result<ru.myitschool.work.data.model.AvailableBooking>
suspend fun bookPlace(authCode: String, date: String, placeId: Long): Result<Boolean>
suspend fun bookPlaceByName(authCode: String, request: BookPlaceRequest): Result<Boolean>
}
class BookingRepositoryImpl : BookingRepository {
override suspend fun getAvailableBookings(authCode: String): Result<AvailableBookingsResponse> {
return NetworkDataSource.getAvailableBookings(authCode)
}
override suspend fun getAvailableBookingsLegacy(authCode: String): Result<ru.myitschool.work.data.model.AvailableBooking> {
return NetworkDataSource.getAvailableBookingsLegacy(authCode)
}
override suspend fun bookPlace(authCode: String, date: String, placeId: Long): Result<Boolean> {
return NetworkDataSource.bookPlace(authCode, date, placeId)
}
override suspend fun bookPlaceByName(authCode: String, request: BookPlaceRequest): Result<Boolean> {
return NetworkDataSource.bookPlaceByName(authCode, request)
}
}

View File

@@ -0,0 +1,120 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.source.NetworkDataSource
import ru.myitschool.work.data.model.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
class UserRepository {
suspend fun getUserInfo(code: String): UserInfoUi {
return try {
val result = NetworkDataSource.getUserInfo(code)
result.fold(
onSuccess = { response ->
// ✅ Теперь берем напрямую из response (не через userInfo)
UserInfoUi(
name = response.name ?: "Пользователь $code",
photoUrl = response.photoUrl ?: ""
)
},
onFailure = { error ->
// При ошибке возвращаем тестовые данные
UserInfoUi(
name = "Тестовый пользователь (код: $code)",
photoUrl = "https://i.pravatar.cc/300?u=$code"
)
}
)
} catch (e: Exception) {
// Для отладки - данные из ТЗ для кода 1111
if (code == "1111") {
UserInfoUi(
name = "Ivanov Ivan",
photoUrl = "https://catalog-cdn.detmir.st/media/2fe02057f9915e72a378795d32c79ea9.jpeg"
)
} else {
UserInfoUi(
name = "Ошибка: ${e.message}",
photoUrl = "https://via.placeholder.com/150?text=Error"
)
}
}
}
suspend fun getBookings(code: String): List<BookingUi> {
return try {
val result = NetworkDataSource.getUserInfo(code)
result.fold(
onSuccess = { response ->
// ✅ Теперь bookings это Map<String, BookingInfo>, а не List<BookingApi>
response.booking.map { (dateStr, bookingInfo) ->
// Преобразуем дату из формата "2025-11-08" в "08.11.2025"
val formattedDate = formatDate(dateStr)
BookingUi(
date = formattedDate,
place = bookingInfo.place
)
}.sortedBy { booking ->
// Сортируем по дате (раньше сначала)
parseDate(booking.date)
}
},
onFailure = {
// Тестовые данные при ошибке
if (code == "1111") {
listOf(
BookingUi("08.11.2025", "K-19"),
BookingUi("10.11.2025", "M-16")
)
} else {
emptyList()
}
}
)
} catch (e: Exception) {
emptyList()
}
}
private fun formatDate(isoDate: String): String {
return try {
// Преобразуем "2025-11-08" в "08.11.2025"
val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val outputFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
val date = LocalDate.parse(isoDate, inputFormatter)
date.format(outputFormatter)
} catch (e: Exception) {
// Если не удалось распарсить, пробуем другой формат
try {
// Может быть уже в формате "dd.MM.yyyy"
val inputFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
val outputFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
val date = LocalDate.parse(isoDate, inputFormatter)
date.format(outputFormatter)
} catch (e2: Exception) {
isoDate // Оставляем как есть
}
}
}
private fun parseDate(dateStr: String): LocalDate {
return try {
// Пробуем формат "dd.MM.yyyy"
val formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
LocalDate.parse(dateStr, formatter)
} catch (e: Exception) {
try {
// Пробуем формат "yyyy-MM-dd"
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
LocalDate.parse(dateStr, formatter)
} catch (e2: Exception) {
LocalDate.MAX // Если не удалось, ставим максимальную дату
}
}
}
}

View File

@@ -1,16 +1,20 @@
package ru.myitschool.work.data.source
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.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.*
import kotlinx.serialization.json.Json
import ru.myitschool.work.core.Constants
import ru.myitschool.work.data.model.*
import kotlin.coroutines.cancellation.CancellationException
object NetworkDataSource {
private val client by lazy {
@@ -20,23 +24,203 @@ object NetworkDataSource {
Json {
isLenient = true
ignoreUnknownKeys = true
explicitNulls = true
encodeDefaults = true
explicitNulls = false
encodeDefaults = false
}
)
}
install(HttpTimeout) {
requestTimeoutMillis = 15000
connectTimeoutMillis = 15000
socketTimeoutMillis = 15000
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.ALL
}
}
}
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get(getUrl(code, Constants.AUTH_URL))
val response: HttpResponse = client.get(getUrl(code, Constants.AUTH_URL)) {
timeout {
requestTimeoutMillis = 10000
}
}
when (response.status) {
HttpStatusCode.OK -> true
else -> error(response.bodyAsText())
else -> error("HTTP ${response.status}: ${response.bodyAsText()}")
}
}.recoverCatching { throwable ->
when (throwable) {
is CancellationException -> throw throwable
else -> throw Exception("Ошибка сети: ${throwable.message}")
}
}
}
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
suspend fun getUserInfo(code: String): Result<UserInfoResponse> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response: HttpResponse = client.get(getUrl(code, Constants.INFO_URL)) {
timeout {
requestTimeoutMillis = 10000
}
}
if (response.status == HttpStatusCode.OK) {
// ✅ Парсим новую модель UserInfoResponse
response.body<UserInfoResponse>()
} else {
throw Exception("HTTP ${response.status.value}: ${response.bodyAsText()}")
}
}.recoverCatching { throwable ->
when (throwable) {
is CancellationException -> throw throwable
else -> throw Exception("Ошибка загрузки данных: ${throwable.message}")
}
}
}
suspend fun getAvailableBookings(code: String): Result<AvailableBookingsResponse> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response: HttpResponse = client.get(getUrl(code, Constants.BOOKING_URL)) {
timeout { requestTimeoutMillis = 10000 }
}
if (response.status == HttpStatusCode.OK) {
// ✅ Парсим AvailableBookingsResponse (Map<String, List<PlaceInfo>>)
response.body<AvailableBookingsResponse>()
} else {
throw Exception("HTTP ${response.status.value}: ${response.bodyAsText()}")
}
}.recoverCatching { throwable ->
when (throwable) {
is CancellationException -> throw throwable
else -> throw Exception("Ошибка загрузки данных: ${throwable.message}")
}
}
}
suspend fun bookPlace(code: String, date: String, placeId: Long): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching {
// ✅ Используем BookRequest с placeId (а не place)
val bookRequest = BookRequest(date = date, placeId = placeId)
val response: HttpResponse = client.post(getUrl(code, Constants.BOOK_URL)) {
contentType(ContentType.Application.Json)
setBody(bookRequest)
timeout {
requestTimeoutMillis = 10000
}
}
when (response.status) {
HttpStatusCode.OK, HttpStatusCode.Created -> true
HttpStatusCode.Conflict -> throw Exception("Место уже забронировано")
else -> throw Exception("HTTP ${response.status.value}: ${response.bodyAsText()}")
}
}.recoverCatching { throwable ->
when (throwable) {
is CancellationException -> throw throwable
else -> throw Exception("Ошибка бронирования: ${throwable.message}")
}
}
}
// ✅ НОВЫЙ МЕТОД: Получение доступных бронирований в формате AvailableBooking
suspend fun getAvailableBookingsLegacy(code: String): Result<AvailableBooking> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response: HttpResponse = client.get(getUrl(code, Constants.BOOKING_URL)) {
timeout { requestTimeoutMillis = 10000 }
}
if (response.status == HttpStatusCode.OK) {
// Парсим как AvailableBookingsResponse и конвертируем в AvailableBooking
val bookingsResponse = response.body<AvailableBookingsResponse>()
// Конвертируем Map<String, List<PlaceInfo>> в List<BookingDate>
val dates = bookingsResponse.data.map { (dateKey, places) ->
// Конвертируем дату из "2024-04-19" в "19.04.2024"
val dateParts = dateKey.split("-")
val formattedDate = "${dateParts[2]}.${dateParts[1]}.${dateParts[0]}"
// Извлекаем названия мест
val placeNames = places.map { it.place }
BookingDate(
date = formattedDate,
places = placeNames
)
}.sortedBy { date ->
// Сортируем по дате
val parts = date.date.split(".")
"${parts[2]}-${parts[1]}-${parts[0]}"
}
AvailableBooking(dates = dates)
} else {
throw Exception("HTTP ${response.status.value}: ${response.bodyAsText()}")
}
}.recoverCatching { throwable ->
when (throwable) {
is CancellationException -> throw throwable
else -> throw Exception("Ошибка загрузки данных: ${throwable.message}")
}
}
}
// ✅ НОВЫЙ МЕТОД: Бронирование места по названию (для совместимости с BookPlaceRequest)
suspend fun bookPlaceByName(code: String, request: BookPlaceRequest): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext try {
// Сначала получаем доступные места для этой даты
val bookingsResult = getAvailableBookings(code)
if (bookingsResult.isSuccess) {
val bookingsResponse = bookingsResult.getOrThrow()
// Конвертируем дату из "19.04.2024" в "2024-04-19"
val dateParts = request.date.split(".")
val formattedDate = "${dateParts[2]}-${dateParts[1]}-${dateParts[0]}"
// Ищем placeId по названию места
val placesForDate = bookingsResponse.data[formattedDate]
val placeInfo = placesForDate?.firstOrNull { it.place == request.place }
if (placeInfo == null) {
Result.failure(Exception("Место '${request.place}' не найдено для даты ${request.date}"))
} else {
// Вызываем существующий метод bookPlace с найденным placeId
bookPlace(code, formattedDate, placeInfo.id)
}
} else {
Result.failure(Exception("Не удалось получить доступные места: ${bookingsResult.exceptionOrNull()?.message}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
// ✅ Дополнительный метод для проверки работы (для отладки)
suspend fun testConnection(code: String): Result<String> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response: HttpResponse = client.get(getUrl(code, Constants.INFO_URL)) {
timeout { requestTimeoutMillis = 5000 }
}
"Status: ${response.status}, Body: ${response.bodyAsText().take(200)}..."
}
}
private fun getUrl(code: String, targetUrl: String): String {
val url = "${Constants.HOST}/api/$code$targetUrl"
println("NetworkDataSource: Requesting URL: $url") // Для отладки
return url
}
fun close() {
client.close()
}
}

View File

@@ -0,0 +1,11 @@
package ru.myitschool.work.domain.booking
import ru.myitschool.work.data.repo.BookingRepository
class BookPlaceUseCase(
private val bookingRepository: BookingRepository
) {
suspend operator fun invoke(authCode: String, date: String, placeId: Long): Result<Boolean> {
return bookingRepository.bookPlace(authCode, date, placeId)
}
}

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.domain.booking
import ru.myitschool.work.data.model.AvailableBookingsResponse
import ru.myitschool.work.data.repo.BookingRepository
class GetAvailableBookingsUseCase(
private val bookingRepository: BookingRepository
) {
suspend operator fun invoke(authCode: String): Result<AvailableBookingsResponse> {
return bookingRepository.getAvailableBookings(authCode)
}
}

View File

@@ -3,4 +3,4 @@ package ru.myitschool.work.ui.nav
import kotlinx.serialization.Serializable
@Serializable
data object BookScreenDestination: AppDestination
data object BookScreenDestination: AppDestination{}

View File

@@ -3,4 +3,4 @@ package ru.myitschool.work.ui.nav
import kotlinx.serialization.Serializable
@Serializable
data object MainScreenDestination: AppDestination
data object MainScreenDestination: AppDestination{}

View File

@@ -17,6 +17,7 @@ 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.main.MainScreen
import ru.myitschool.work.ui.screen.booking.BookingScreen
@Composable
fun AppNavHost(
@@ -40,11 +41,7 @@ fun AppNavHost(
}
composable<BookScreenDestination> {
Box(
contentAlignment = Alignment.Center
) {
Text(text = "Экран бронирования (будет реализован позже)")
}
BookingScreen(navController = navController)
}
}
}

View File

@@ -0,0 +1,180 @@
package ru.myitschool.work.ui.screen.booking
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Refresh
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 kotlinx.coroutines.flow.collectLatest
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.MainScreenDestination
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookingScreen(
viewModel: BookingViewModel = viewModel(),
navController: NavController
) {
val state by viewModel.uiState.collectAsState()
val selectedDateIndex by viewModel.selectedDateIndex.collectAsState()
val selectedPlaceIndex by viewModel.selectedPlaceIndex.collectAsState()
val error by viewModel.error.collectAsState()
LaunchedEffect(key1 = Unit) {
viewModel.navigationEvent.collectLatest { event ->
when (event) {
BookingNavigationEvent.Back -> {
navController.popBackStack()
}
BookingNavigationEvent.Success -> {
// Возвращаемся на главный экран с обновлением
navController.navigate(MainScreenDestination) {
popUpTo(MainScreenDestination) { inclusive = true }
}
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Бронирование места") },
navigationIcon = {
IconButton(
onClick = { viewModel.onIntent(BookingIntent.Back) },
modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON)
) {
Icon(Icons.Default.ArrowBack, contentDescription = "Назад")
}
}
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
when (val currentState = state) {
is BookingState.Loading -> {
CircularProgressIndicator(
modifier = Modifier
.size(64.dp)
.align(Alignment.Center)
)
}
is BookingState.Error -> {
ErrorContent(viewModel, error)
}
is BookingState.Empty -> {
EmptyContent()
}
is BookingState.Data -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// Контент с датами и местами
Text(
text = "Выберите дату и место для бронирования",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
// TODO: Здесь будут вкладки с датами
Text(
text = "Даты будут здесь (вкладки)",
modifier = Modifier.padding(bottom = 16.dp)
)
// TODO: Здесь будут места для выбора
Text(
text = "Места будут здесь (радио-кнопки)",
modifier = Modifier.padding(bottom = 16.dp)
)
Spacer(modifier = Modifier.weight(1f))
// Кнопка бронирования
Button(
onClick = { viewModel.onIntent(BookingIntent.Book) },
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Book.BOOK_BUTTON),
enabled = selectedPlaceIndex != null
) {
Icon(Icons.Default.Check, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Забронировать выбранное место")
}
}
}
}
}
}
}
@Composable
private fun ErrorContent(
viewModel: BookingViewModel,
error: String?
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.testTag(TestIds.Book.ERROR)
.padding(bottom = 16.dp)
)
} else {
// Если error == null, все равно добавляем элемент с тегом, но скрываем его
Text(
text = "",
modifier = Modifier
.testTag(TestIds.Book.ERROR)
.padding(bottom = 16.dp)
)
}
Button(
onClick = { viewModel.onIntent(BookingIntent.Refresh) },
modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON)
) {
Icon(Icons.Default.Refresh, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Обновить")
}
}
}
@Composable
private fun EmptyContent() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Всё забронировано",
modifier = Modifier.testTag(TestIds.Book.EMPTY),
style = MaterialTheme.typography.titleMedium
)
}
}

View File

@@ -0,0 +1,160 @@
package ru.myitschool.work.ui.screen.booking
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import ru.myitschool.work.data.model.BookingDate
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.data.repo.BookingRepository
import ru.myitschool.work.data.repo.BookingRepositoryImpl
class BookingViewModel : ViewModel() {
private val bookingRepository: BookingRepository = BookingRepositoryImpl()
private val _uiState = MutableStateFlow<BookingState>(BookingState.Loading)
val uiState: StateFlow<BookingState> = _uiState.asStateFlow()
private val _selectedDateIndex = MutableStateFlow(0)
val selectedDateIndex: StateFlow<Int> = _selectedDateIndex.asStateFlow()
private val _selectedPlaceIndex = MutableStateFlow<Int?>(null)
val selectedPlaceIndex: StateFlow<Int?> = _selectedPlaceIndex.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _navigationEvent = MutableSharedFlow<BookingNavigationEvent>()
val navigationEvent: SharedFlow<BookingNavigationEvent> = _navigationEvent.asSharedFlow()
init {
loadAvailableBookings()
}
fun onIntent(intent: BookingIntent) {
when (intent) {
is BookingIntent.SelectDate -> {
_selectedDateIndex.value = intent.index
_selectedPlaceIndex.value = null
}
is BookingIntent.SelectPlace -> {
_selectedPlaceIndex.value = intent.index
}
BookingIntent.Book -> {
bookSelectedPlace()
}
BookingIntent.Refresh -> {
loadAvailableBookings()
}
BookingIntent.Back -> {
viewModelScope.launch {
_navigationEvent.emit(BookingNavigationEvent.Back)
}
}
}
}
private fun loadAvailableBookings() {
viewModelScope.launch(Dispatchers.IO) {
_uiState.value = BookingState.Loading
_error.value = null
// Получаем код из AuthRepository (как в MainViewModel)
val authCode = AuthRepository.getSavedCode()
if (authCode == null) {
// Если нет кода - возвращаемся назад
viewModelScope.launch {
_navigationEvent.emit(BookingNavigationEvent.Back)
}
return@launch
}
// Используем legacy метод
val result = bookingRepository.getAvailableBookingsLegacy(authCode)
result.fold(
onSuccess = { availableBooking ->
val filteredDates = availableBooking.dates
.filter { it.places.isNotEmpty() }
if (filteredDates.isEmpty()) {
_uiState.value = BookingState.Empty
} else {
_uiState.value = BookingState.Data(
dates = filteredDates
)
_selectedDateIndex.value = 0
_selectedPlaceIndex.value = null
}
},
onFailure = { error ->
_error.value = error.message ?: "Ошибка загрузки данных"
_uiState.value = BookingState.Error
}
)
}
}
private fun bookSelectedPlace() {
viewModelScope.launch(Dispatchers.IO) {
val currentState = _uiState.value
val dateIndex = _selectedDateIndex.value
val placeIndex = _selectedPlaceIndex.value
if (currentState !is BookingState.Data || placeIndex == null) {
_error.value = "Выберите место для бронирования"
return@launch
}
// Получаем код из AuthRepository
val authCode = AuthRepository.getSavedCode()
if (authCode == null) {
viewModelScope.launch {
_navigationEvent.emit(BookingNavigationEvent.Back)
}
return@launch
}
val selectedDate = currentState.dates[dateIndex]
val selectedPlaceName = selectedDate.places[placeIndex]
// Используем bookPlaceByName (так как у нас нет ID мест)
val request = ru.myitschool.work.data.model.BookPlaceRequest(
date = selectedDate.date,
place = selectedPlaceName
)
val result = bookingRepository.bookPlaceByName(authCode, request)
result.fold(
onSuccess = {
_navigationEvent.emit(BookingNavigationEvent.Success)
},
onFailure = { error ->
_error.value = error.message ?: "Ошибка бронирования"
}
)
}
}
}
// Остальные классы без изменений
sealed class BookingIntent {
data class SelectDate(val index: Int) : BookingIntent()
data class SelectPlace(val index: Int) : BookingIntent()
object Book : BookingIntent()
object Refresh : BookingIntent()
object Back : BookingIntent()
}
sealed class BookingState {
object Loading : BookingState()
data class Data(val dates: List<BookingDate>) : BookingState()
object Empty : BookingState()
object Error : BookingState()
}
sealed class BookingNavigationEvent {
object Back : BookingNavigationEvent()
object Success : BookingNavigationEvent()
}

View File

@@ -1,18 +1,34 @@
package ru.myitschool.work.ui.screen.main
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ExitToApp
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.LocalContext
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 coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.BookScreenDestination
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -21,70 +37,364 @@ fun MainScreen(
navController: NavController
) {
val state by viewModel.uiState.collectAsState()
val error by viewModel.error.collectAsState()
// При первом открытии загружаем данные
// МОЙ КОД: Инициализация и обработка навигации
LaunchedEffect(key1 = Unit) {
// Загрузка данных при открытии
viewModel.loadUserInfo()
}
// Проверяем авторизацию
LaunchedEffect(key1 = viewModel.isUserAuthorized) {
if (!viewModel.isUserAuthorized) {
// Навигация при логауте
viewModel.navigationAction.collect { action ->
when (action) {
MainViewModel.NavigationAction.NavigateToAuth -> {
navController.navigate(AuthScreenDestination) {
popUpTo(0)
}
}
MainViewModel.NavigationAction.NavigateToBooking -> {
navController.navigate(BookScreenDestination)
}
}
}
}
// МОЙ КОД: Обработка ошибок авторизации
LaunchedEffect(key1 = state) {
if (state is MainState.Error) {
val errorMessage = (state as MainState.Error).message
if (errorMessage.contains("авторизация", ignoreCase = true) ||
errorMessage.contains("Сессия", ignoreCase = true)) {
AuthRepository.clearSavedCode()
navController.navigate(AuthScreenDestination) {
popUpTo(0)
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = "Главная") }
title = { Text(text = "Мои бронирования") }, // МОЙ КОД: Изменен заголовок
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
titleContentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { viewModel.onBookButtonClick() },
modifier = Modifier.testTag(TestIds.Main.ADD_BUTTON),
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(Icons.Default.Add, contentDescription = "Забронировать",
modifier = Modifier.size(24.dp))
}
}
) { paddingValues ->
Box(
MainContent(
state = state,
error = error,
onRefresh = { viewModel.refresh() },
onLogout = { viewModel.logout() },
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
)
}
}
@Composable
private fun MainContent(
state: MainState,
error: String?,
onRefresh: () -> Unit,
onLogout: () -> Unit,
modifier: Modifier = Modifier
) {
when (state) {
is MainState.Loading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is MainState.Error -> {
ErrorState(
errorMessage = error ?: state.message, // МОЙ КОД: Используем оба источника ошибок
onRefresh = onRefresh,
onLogout = onLogout, // МОЙ КОД: Добавлена кнопка выхода
modifier = modifier
)
}
is MainState.Success -> {
SuccessState(
userInfo = state.userInfo,
bookings = state.bookings,
onRefresh = onRefresh,
onLogout = onLogout,
modifier = modifier
)
}
}
}
@Composable
private fun ErrorState(
errorMessage: String,
onRefresh: () -> Unit,
onLogout: () -> Unit, // МОЙ КОД: Добавлен параметр
modifier: Modifier = Modifier
) {
Column(
modifier = Modifier.fillMaxSize(),
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// МОЙ КОД: Улучшенное отображение ошибки
Text(
text = (state as MainState.Error).message,
text = errorMessage,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag("main_error"),
modifier = Modifier
.testTag(TestIds.Main.ERROR)
.padding(horizontal = 32.dp, vertical = 16.dp),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
// МОЙ КОД: Две кнопки для ошибки
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(horizontal = 32.dp)
) {
// Кнопка "Обновить данные"
Button(
onClick = onRefresh,
modifier = Modifier
.weight(1f)
.testTag(TestIds.Main.REFRESH_BUTTON),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Icon(Icons.Default.Refresh, contentDescription = null,
modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(text = "Обновить")
}
// МОЙ КОД: Кнопка "Выйти" в состоянии ошибки
if (errorMessage.contains("авторизация", ignoreCase = true) ||
errorMessage.contains("Сессия", ignoreCase = true)) {
Button(
onClick = onLogout,
modifier = Modifier
.weight(1f)
.testTag(TestIds.Main.LOGOUT_BUTTON),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Icon(Icons.Default.ExitToApp, contentDescription = null,
modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(text = "Выйти")
}
}
}
}
}
@Composable
private fun SuccessState(
userInfo: UserInfo,
bookings: List<Booking>,
onRefresh: () -> Unit,
onLogout: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
// 1. Информация о пользователе (МОЙ КОД: с загрузкой аватарки с сервера)
UserHeader(
userInfo = userInfo,
onLogout = onLogout,
onRefresh = onRefresh,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
// 2. Список бронирований (МОЙ КОД: отсортированный по дате)
BookingsList(
bookings = bookings,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
}
}
@Composable
private fun UserHeader(
userInfo: UserInfo,
onLogout: () -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
// МОЙ КОД: Улучшенная загрузка аватарки с сервера
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.testTag(TestIds.Main.PROFILE_NAME) // МОЙ КОД: Исправлен тег
) {
if (userInfo.photoUrl.isNotEmpty()) {
// МОЙ КОД: Используем rememberAsyncImagePainter для лучшей производительности
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(userInfo.photoUrl)
.crossfade(true)
.placeholder(android.R.drawable.ic_menu_gallery)
.error(android.R.drawable.stat_notify_error)
.build(),
contentDescription = "Фото пользователя",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
} else {
Icon(
Icons.Default.Person,
contentDescription = "Аватар",
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
is MainState.Success -> {
Column(
Spacer(modifier = Modifier.width(16.dp))
// Имя пользователя
Text(
text = userInfo.name,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.testTag(TestIds.Main.PROFILE_NAME) // МОЙ КОД: Исправлен тег
)
}
Spacer(modifier = Modifier.height(16.dp))
// Две кнопки в ряд
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
// Кнопка "Обновить данные"
Button(
onClick = onRefresh,
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
.weight(1f)
.testTag(TestIds.Main.REFRESH_BUTTON),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Icon(Icons.Default.Refresh, contentDescription = null,
modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(6.dp))
Text(text = "Обновить")
}
// Кнопка "Выход"
Button(
onClick = onLogout,
modifier = Modifier
.weight(1f)
.testTag(TestIds.Main.LOGOUT_BUTTON),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Icon(Icons.Default.ExitToApp, contentDescription = null,
modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(6.dp))
Text(text = "Выход")
}
}
}
}
}
@Composable
private fun BookingsList(
bookings: List<Booking>,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Главный экран",
style = MaterialTheme.typography.headlineMedium
text = "Мои бронирования",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp)
)
if (bookings.isEmpty()) {
Text(
text = "Здесь будет информация о пользователе и бронированиях",
modifier = Modifier.padding(top = 16.dp)
text = "Нет активных бронирований",
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(bookings) { index, booking ->
// МОЙ КОД: Исправлены теги согласно ТЗ
BookingItem(
booking = booking,
index = index,
modifier = Modifier.testTag("${TestIds.Main.getIdItemByPosition(index)}_$index")
)
}
}
@@ -92,3 +402,38 @@ fun MainScreen(
}
}
}
@Composable
private fun BookingItem(
booking: Booking,
index: Int,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// Дата бронирования
Text(
text = booking.date,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag(TestIds.Main.ITEM_DATE)
)
Spacer(modifier = Modifier.height(4.dp))
// Место бронирования
Text(
text = booking.place,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE)
)
}
}
}

View File

@@ -2,53 +2,171 @@ package ru.myitschool.work.ui.screen.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.data.repo.UserRepository
import ru.myitschool.work.data.model.UserInfoUi as ModelUserInfo
import ru.myitschool.work.data.model.BookingUi as ModelBooking
import java.time.LocalDate
import java.time.format.DateTimeFormatter
class MainViewModel : ViewModel() {
private val userRepository = UserRepository()
private val _uiState = MutableStateFlow<MainState>(MainState.Loading)
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
val isUserAuthorized: Boolean
get() = AuthRepository.getSavedCode() != null
private val _navigationAction = MutableSharedFlow<NavigationAction>()
val navigationAction: SharedFlow<NavigationAction> = _navigationAction.asSharedFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
init {
loadUserInfo()
}
fun loadUserInfo() {
viewModelScope.launch {
viewModelScope.launch(Dispatchers.IO) {
_uiState.value = MainState.Loading
_error.value = null
try {
val code = AuthRepository.getSavedCode()
if (code == null) {
_uiState.value = MainState.Error("Пользователь не авторизован")
return@launch
throw IllegalStateException("Пользователь не авторизован")
}
// TODO: Реальный запрос к API
// Пока заглушка
_uiState.value = MainState.Success(
userInfo = UserInfo("Иван Иванов", ""),
bookings = emptyList()
// ПАРАЛЛЕЛЬНАЯ ЗАГРУЗКА ДАННЫХ С СЕРВЕРА
val userInfoDeferred = async {
runCatching { userRepository.getUserInfo(code) }
}
val bookingsDeferred = async {
runCatching { userRepository.getBookings(code) }
}
// Ждем оба результата
val userInfoResult = userInfoDeferred.await()
val bookingsResult = bookingsDeferred.await()
if (userInfoResult.isFailure && bookingsResult.isFailure) {
throw userInfoResult.exceptionOrNull() ?:
bookingsResult.exceptionOrNull() ?:
Exception("Ошибка загрузки данных")
}
// Обработка ошибок авторизации
listOf(userInfoResult, bookingsResult).forEach { result ->
result.exceptionOrNull()?.let { error ->
if (error.message?.contains("401") == true ||
error.message?.contains("403") == true) {
// Сессия истекла - делаем логаут
viewModelScope.launch(Dispatchers.IO) {
AuthRepository.clearSavedCode()
_navigationAction.emit(NavigationAction.NavigateToAuth)
}
throw IllegalStateException("Сессия истекла")
}
}
}
// Получаем данные или используем пустые по умолчанию
val userInfoFromRepo = userInfoResult.getOrElse {
ModelUserInfo("", "")
}
val bookingsFromRepo = bookingsResult.getOrElse { emptyList() }
// СОРТИРОВКА БРОНИРОВАНИЙ ПО ДАТЕ (как в ТЗ)
val sortedBookings = bookingsFromRepo.sortedBy { booking ->
parseDate(booking.date)
}
// Преобразуем в UI модели
val userInfo = UserInfo(
name = userInfoFromRepo.name,
photoUrl = userInfoFromRepo.photoUrl
)
} catch (e: Exception) {
_uiState.value = MainState.Error("Ошибка загрузки: ${e.message}")
}
}
val bookings = sortedBookings.map { booking ->
Booking(
date = booking.date,
place = booking.place
)
}
fun logout() {
viewModelScope.launch {
AuthRepository.clearSavedCode()
_uiState.value = MainState.Success(userInfo, bookings)
} catch (e: Exception) {
val errorMessage = when {
e is IllegalStateException && e.message?.contains("авторизация") == true ->
"Требуется авторизация"
e is IllegalStateException && e.message?.contains("Сессия") == true ->
"Сессия истекла, требуется повторный вход"
e.message?.contains("404") == true ->
"Пользователь не найден"
e.message?.contains("network", ignoreCase = true) == true ->
"Нет соединения с сервером"
else -> "Ошибка загрузки данных: ${e.message ?: "неизвестная ошибка"}"
}
_error.value = errorMessage
_uiState.value = MainState.Error(errorMessage)
}
}
}
fun refresh() {
loadUserInfo()
}
fun logout() {
viewModelScope.launch(Dispatchers.IO) {
AuthRepository.clearSavedCode()
_navigationAction.emit(NavigationAction.NavigateToAuth)
}
}
fun onBookButtonClick() {
viewModelScope.launch {
_navigationAction.emit(NavigationAction.NavigateToBooking)
}
}
// МОЙ КОД: Функция для парсинга даты для сортировки
private fun parseDate(dateStr: String): LocalDate {
return try {
val formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
LocalDate.parse(dateStr, formatter)
} catch (e: Exception) {
LocalDate.MAX // В случае ошибки парсинга
}
}
// МОЙ КОД: Обновленная обработка ошибок
private suspend fun handleApiError(error: Throwable): String {
return when {
error.message?.contains("401") == true -> {
AuthRepository.clearSavedCode()
_navigationAction.emit(NavigationAction.NavigateToAuth)
"Сессия истекла"
}
error.message?.contains("404") == true -> "Пользователь не найден"
error.message?.contains("network", ignoreCase = true) == true ->
"Нет соединения с сервером"
else -> error.message ?: "Ошибка загрузки данных"
}
}
sealed class NavigationAction {
object NavigateToAuth : NavigationAction()
object NavigateToBooking : NavigationAction()
}
}
// UI модели (остаются без изменений)
sealed interface MainState {
object Loading : MainState
data class Error(val message: String) : MainState
@@ -58,7 +176,6 @@ sealed interface MainState {
) : MainState
}
// Временные модели (потом перенесем в data/model)
data class UserInfo(
val name: String,
val photoUrl: String