diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a5ccda1..d3e39a6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/local/AuthDataStore.kt b/app/src/main/java/ru/myitschool/work/data/local/AuthDataStore.kt new file mode 100644 index 0000000..007d95b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/local/AuthDataStore.kt @@ -0,0 +1,4 @@ +package ru.myitschool.work.data.local + +class AuthDataStore { +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/model/AvailableBooking.kt b/app/src/main/java/ru/myitschool/work/data/model/AvailableBooking.kt new file mode 100644 index 0000000..7986d6e --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/model/AvailableBooking.kt @@ -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 +)*/ \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/model/Models.kt b/app/src/main/java/ru/myitschool/work/data/model/Models.kt new file mode 100644 index 0000000..b537a4d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/model/Models.kt @@ -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 = 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> +) + +@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 +) + +@Serializable +data class BookingDate( + val date: String, // формат dd.MM.yyyy + val places: List +) + +// ✅ Запрос для бронирования (альтернативный формат из вашего кода) +@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 +) + +// ========== 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/model/UserInfoResponse.kt b/app/src/main/java/ru/myitschool/work/data/model/UserInfoResponse.kt new file mode 100644 index 0000000..f0238e4 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/model/UserInfoResponse.kt @@ -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 = 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 +) \ 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..c4584c4 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/BookingRepository.kt @@ -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 + suspend fun getAvailableBookingsLegacy(authCode: String): Result + suspend fun bookPlace(authCode: String, date: String, placeId: Long): Result + suspend fun bookPlaceByName(authCode: String, request: BookPlaceRequest): Result +} + +class BookingRepositoryImpl : BookingRepository { + + override suspend fun getAvailableBookings(authCode: String): Result { + return NetworkDataSource.getAvailableBookings(authCode) + } + + override suspend fun getAvailableBookingsLegacy(authCode: String): Result { + return NetworkDataSource.getAvailableBookingsLegacy(authCode) + } + + override suspend fun bookPlace(authCode: String, date: String, placeId: Long): Result { + return NetworkDataSource.bookPlace(authCode, date, placeId) + } + + override suspend fun bookPlaceByName(authCode: String, request: BookPlaceRequest): Result { + return NetworkDataSource.bookPlaceByName(authCode, request) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/repo/UserRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/UserRepository.kt new file mode 100644 index 0000000..4450b31 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/UserRepository.kt @@ -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 { + return try { + val result = NetworkDataSource.getUserInfo(code) + + result.fold( + onSuccess = { response -> + // ✅ Теперь bookings это Map, а не List + 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 // Если не удалось, ставим максимальную дату + } + } + } +} \ 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..5b1d926 100644 --- a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt +++ b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt @@ -1,16 +1,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 = 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 = 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() + } 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 = 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>) + response.body() + } 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 = 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 = 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() + + // Конвертируем Map> в List + 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 = 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 = 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() + } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/booking/BookPlaceUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/booking/BookPlaceUseCase.kt new file mode 100644 index 0000000..f9b07c0 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/booking/BookPlaceUseCase.kt @@ -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 { + return bookingRepository.bookPlace(authCode, date, placeId) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/booking/GetAvailableBookingsUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/booking/GetAvailableBookingsUseCase.kt new file mode 100644 index 0000000..92ee962 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/booking/GetAvailableBookingsUseCase.kt @@ -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 { + return bookingRepository.getAvailableBookings(authCode) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt index 9a33073..8223fbd 100644 --- a/app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt +++ b/app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt @@ -3,4 +3,4 @@ package ru.myitschool.work.ui.nav import kotlinx.serialization.Serializable @Serializable -data object BookScreenDestination: AppDestination \ No newline at end of file +data object BookScreenDestination: AppDestination{} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt index deca45f..e64e763 100644 --- a/app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt +++ b/app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt @@ -3,4 +3,4 @@ package ru.myitschool.work.ui.nav import kotlinx.serialization.Serializable @Serializable -data object MainScreenDestination: AppDestination \ No newline at end of file +data object MainScreenDestination: AppDestination{} \ No newline at end of file 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 aee4054..3cba753 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 @@ -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 { - Box( - contentAlignment = Alignment.Center - ) { - Text(text = "Экран бронирования (будет реализован позже)") - } + BookingScreen(navController = navController) } } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/booking/BookingScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/booking/BookingScreen.kt new file mode 100644 index 0000000..8f4efbc --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/booking/BookingScreen.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/booking/BookingViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/booking/BookingViewModel.kt new file mode 100644 index 0000000..61a82a4 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/booking/BookingViewModel.kt @@ -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.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _selectedDateIndex = MutableStateFlow(0) + val selectedDateIndex: StateFlow = _selectedDateIndex.asStateFlow() + + private val _selectedPlaceIndex = MutableStateFlow(null) + val selectedPlaceIndex: StateFlow = _selectedPlaceIndex.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private val _navigationEvent = MutableSharedFlow() + val navigationEvent: SharedFlow = _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) : BookingState() + object Empty : BookingState() + object Error : BookingState() +} + +sealed class BookingNavigationEvent { + object Back : BookingNavigationEvent() + object Success : BookingNavigationEvent() +} \ 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 index 1ee8ede..f101227 100644 --- 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 @@ -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,17 +37,38 @@ fun MainScreen( navController: NavController ) { val state by viewModel.uiState.collectAsState() + val error by viewModel.error.collectAsState() - // При первом открытии загружаем данные + // МОЙ КОД: Инициализация и обработка навигации LaunchedEffect(key1 = Unit) { + // Загрузка данных при открытии viewModel.loadUserInfo() + + // Навигация при логауте + viewModel.navigationAction.collect { action -> + when (action) { + MainViewModel.NavigationAction.NavigateToAuth -> { + navController.navigate(AuthScreenDestination) { + popUpTo(0) + } + } + MainViewModel.NavigationAction.NavigateToBooking -> { + navController.navigate(BookScreenDestination) + } + } + } } - // Проверяем авторизацию - LaunchedEffect(key1 = viewModel.isUserAuthorized) { - if (!viewModel.isUserAuthorized) { - navController.navigate(AuthScreenDestination) { - popUpTo(0) + // МОЙ КОД: Обработка ошибок авторизации + 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) + } } } } @@ -39,56 +76,364 @@ fun MainScreen( 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) - ) { - when (state) { - is MainState.Loading -> { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center) - ) - } + ) + } +} - is MainState.Error -> { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = (state as MainState.Error).message, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.testTag("main_error"), - textAlign = TextAlign.Center +@Composable +private fun MainContent( + state: MainState, + error: String?, + onRefresh: () -> Unit, + onLogout: () -> Unit, + modifier: Modifier = Modifier +) { + when (state) { + is MainState.Loading -> { + 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, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // МОЙ КОД: Улучшенное отображение ошибки + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.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, + 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( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = "Главный экран", - style = MaterialTheme.typography.headlineMedium - ) - Text( - text = "Здесь будет информация о пользователе и бронированиях", - modifier = Modifier.padding(top = 16.dp) + 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 + .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, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Мои бронирования", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp) + ) + + if (bookings.isEmpty()) { + Text( + 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") ) } } } } } +} + +@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) + ) + } + } } \ 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 index 3c8d3c8..adc4085 100644 --- 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 @@ -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.Loading) val uiState: StateFlow = _uiState.asStateFlow() - val isUserAuthorized: Boolean - get() = AuthRepository.getSavedCode() != null + private val _navigationAction = MutableSharedFlow() + val navigationAction: SharedFlow = _navigationAction.asSharedFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _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