forked from Olympic/NTO-2025-Android-TeamTask
added generalscreen
added booking screeen
This commit is contained in:
@@ -35,17 +35,93 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// Базовая библиотека Compose (если есть в проекте)
|
||||||
defaultComposeLibrary()
|
defaultComposeLibrary()
|
||||||
|
|
||||||
|
// DataStore для хранения настроек
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.7")
|
implementation("androidx.datastore:datastore-preferences:1.1.7")
|
||||||
|
|
||||||
|
// Коллекции
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
|
||||||
|
|
||||||
|
// Navigation Compose
|
||||||
implementation("androidx.navigation:navigation-compose:2.9.6")
|
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-core:$ktor")
|
||||||
implementation("io.ktor:ktor-client-cio:$ktor")
|
implementation("io.ktor:ktor-client-cio:$ktor")
|
||||||
implementation("io.ktor:ktor-client-content-negotiation:$ktor")
|
implementation("io.ktor:ktor-client-content-negotiation:$ktor")
|
||||||
implementation("io.ktor:ktor-serialization-kotlinx-json:$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")
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package ru.myitschool.work.data.local
|
||||||
|
|
||||||
|
class AuthDataStore {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)*/
|
||||||
143
app/src/main/java/ru/myitschool/work/data/model/Models.kt
Normal file
143
app/src/main/java/ru/myitschool/work/data/model/Models.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
120
app/src/main/java/ru/myitschool/work/data/repo/UserRepository.kt
Normal file
120
app/src/main/java/ru/myitschool/work/data/repo/UserRepository.kt
Normal 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 // Если не удалось, ставим максимальную дату
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
package ru.myitschool.work.data.source
|
package ru.myitschool.work.data.source
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.*
|
||||||
import io.ktor.client.engine.cio.CIO
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
import io.ktor.client.engine.cio.*
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.plugins.*
|
||||||
import io.ktor.client.statement.bodyAsText
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.client.plugins.logging.*
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.client.request.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import io.ktor.client.statement.*
|
||||||
import kotlinx.coroutines.withContext
|
import io.ktor.http.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import ru.myitschool.work.core.Constants
|
import ru.myitschool.work.core.Constants
|
||||||
|
import ru.myitschool.work.data.model.*
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
object NetworkDataSource {
|
object NetworkDataSource {
|
||||||
private val client by lazy {
|
private val client by lazy {
|
||||||
@@ -20,23 +24,203 @@ object NetworkDataSource {
|
|||||||
Json {
|
Json {
|
||||||
isLenient = true
|
isLenient = true
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
explicitNulls = true
|
explicitNulls = false
|
||||||
encodeDefaults = true
|
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) {
|
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||||
return@withContext runCatching {
|
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) {
|
when (response.status) {
|
||||||
HttpStatusCode.OK -> true
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,4 +3,4 @@ package ru.myitschool.work.ui.nav
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object BookScreenDestination: AppDestination
|
data object BookScreenDestination: AppDestination{}
|
||||||
@@ -3,4 +3,4 @@ package ru.myitschool.work.ui.nav
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object MainScreenDestination: AppDestination
|
data object MainScreenDestination: AppDestination{}
|
||||||
@@ -17,6 +17,7 @@ import ru.myitschool.work.ui.nav.BookScreenDestination
|
|||||||
import ru.myitschool.work.ui.nav.MainScreenDestination
|
import ru.myitschool.work.ui.nav.MainScreenDestination
|
||||||
import ru.myitschool.work.ui.screen.auth.AuthScreen
|
import ru.myitschool.work.ui.screen.auth.AuthScreen
|
||||||
import ru.myitschool.work.ui.screen.main.MainScreen
|
import ru.myitschool.work.ui.screen.main.MainScreen
|
||||||
|
import ru.myitschool.work.ui.screen.booking.BookingScreen
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavHost(
|
fun AppNavHost(
|
||||||
@@ -40,11 +41,7 @@ fun AppNavHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable<BookScreenDestination> {
|
composable<BookScreenDestination> {
|
||||||
Box(
|
BookingScreen(navController = navController)
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(text = "Экран бронирования (будет реализован позже)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,18 +1,34 @@
|
|||||||
package ru.myitschool.work.ui.screen.main
|
package ru.myitschool.work.ui.screen.main
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
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.AuthScreenDestination
|
||||||
|
import ru.myitschool.work.ui.nav.BookScreenDestination
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -21,17 +37,38 @@ fun MainScreen(
|
|||||||
navController: NavController
|
navController: NavController
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
val error by viewModel.error.collectAsState()
|
||||||
|
|
||||||
// При первом открытии загружаем данные
|
// МОЙ КОД: Инициализация и обработка навигации
|
||||||
LaunchedEffect(key1 = Unit) {
|
LaunchedEffect(key1 = Unit) {
|
||||||
|
// Загрузка данных при открытии
|
||||||
viewModel.loadUserInfo()
|
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) {
|
LaunchedEffect(key1 = state) {
|
||||||
if (!viewModel.isUserAuthorized) {
|
if (state is MainState.Error) {
|
||||||
navController.navigate(AuthScreenDestination) {
|
val errorMessage = (state as MainState.Error).message
|
||||||
popUpTo(0)
|
if (errorMessage.contains("авторизация", ignoreCase = true) ||
|
||||||
|
errorMessage.contains("Сессия", ignoreCase = true)) {
|
||||||
|
AuthRepository.clearSavedCode()
|
||||||
|
navController.navigate(AuthScreenDestination) {
|
||||||
|
popUpTo(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,56 +76,364 @@ fun MainScreen(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
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 ->
|
) { paddingValues ->
|
||||||
Box(
|
MainContent(
|
||||||
|
state = state,
|
||||||
|
error = error,
|
||||||
|
onRefresh = { viewModel.refresh() },
|
||||||
|
onLogout = { viewModel.logout() },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
)
|
||||||
when (state) {
|
}
|
||||||
is MainState.Loading -> {
|
}
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is MainState.Error -> {
|
@Composable
|
||||||
Column(
|
private fun MainContent(
|
||||||
modifier = Modifier.fillMaxSize(),
|
state: MainState,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
error: String?,
|
||||||
verticalArrangement = Arrangement.Center
|
onRefresh: () -> Unit,
|
||||||
) {
|
onLogout: () -> Unit,
|
||||||
Text(
|
modifier: Modifier = Modifier
|
||||||
text = (state as MainState.Error).message,
|
) {
|
||||||
color = MaterialTheme.colorScheme.error,
|
when (state) {
|
||||||
modifier = Modifier.testTag("main_error"),
|
is MainState.Loading -> {
|
||||||
textAlign = TextAlign.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,
|
||||||
|
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<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 -> {
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
// Имя пользователя
|
||||||
.fillMaxSize()
|
Text(
|
||||||
.padding(16.dp),
|
text = userInfo.name,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
verticalArrangement = Arrangement.Center
|
modifier = Modifier.testTag(TestIds.Main.PROFILE_NAME) // МОЙ КОД: Исправлен тег
|
||||||
) {
|
)
|
||||||
Text(
|
}
|
||||||
text = "Главный экран",
|
|
||||||
style = MaterialTheme.typography.headlineMedium
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
)
|
|
||||||
Text(
|
// Две кнопки в ряд
|
||||||
text = "Здесь будет информация о пользователе и бронированиях",
|
Row(
|
||||||
modifier = Modifier.padding(top = 16.dp)
|
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<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.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,53 +2,171 @@ package ru.myitschool.work.ui.screen.main
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import ru.myitschool.work.data.repo.AuthRepository
|
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() {
|
class MainViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val userRepository = UserRepository()
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow<MainState>(MainState.Loading)
|
private val _uiState = MutableStateFlow<MainState>(MainState.Loading)
|
||||||
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
|
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
val isUserAuthorized: Boolean
|
private val _navigationAction = MutableSharedFlow<NavigationAction>()
|
||||||
get() = AuthRepository.getSavedCode() != null
|
val navigationAction: SharedFlow<NavigationAction> = _navigationAction.asSharedFlow()
|
||||||
|
|
||||||
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
|
val error: StateFlow<String?> = _error.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadUserInfo()
|
||||||
|
}
|
||||||
|
|
||||||
fun loadUserInfo() {
|
fun loadUserInfo() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
_uiState.value = MainState.Loading
|
_uiState.value = MainState.Loading
|
||||||
|
_error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val code = AuthRepository.getSavedCode()
|
val code = AuthRepository.getSavedCode()
|
||||||
if (code == null) {
|
if (code == null) {
|
||||||
_uiState.value = MainState.Error("Пользователь не авторизован")
|
throw IllegalStateException("Пользователь не авторизован")
|
||||||
return@launch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Реальный запрос к API
|
// ПАРАЛЛЕЛЬНАЯ ЗАГРУЗКА ДАННЫХ С СЕРВЕРА
|
||||||
// Пока заглушка
|
val userInfoDeferred = async {
|
||||||
_uiState.value = MainState.Success(
|
runCatching { userRepository.getUserInfo(code) }
|
||||||
userInfo = UserInfo("Иван Иванов", ""),
|
}
|
||||||
bookings = emptyList()
|
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) {
|
val bookings = sortedBookings.map { booking ->
|
||||||
_uiState.value = MainState.Error("Ошибка загрузки: ${e.message}")
|
Booking(
|
||||||
}
|
date = booking.date,
|
||||||
}
|
place = booking.place
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun logout() {
|
_uiState.value = MainState.Success(userInfo, bookings)
|
||||||
viewModelScope.launch {
|
|
||||||
AuthRepository.clearSavedCode()
|
} 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() {
|
fun refresh() {
|
||||||
loadUserInfo()
|
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 {
|
sealed interface MainState {
|
||||||
object Loading : MainState
|
object Loading : MainState
|
||||||
data class Error(val message: String) : MainState
|
data class Error(val message: String) : MainState
|
||||||
@@ -58,7 +176,6 @@ sealed interface MainState {
|
|||||||
) : MainState
|
) : MainState
|
||||||
}
|
}
|
||||||
|
|
||||||
// Временные модели (потом перенесем в data/model)
|
|
||||||
data class UserInfo(
|
data class UserInfo(
|
||||||
val name: String,
|
val name: String,
|
||||||
val photoUrl: String
|
val photoUrl: String
|
||||||
|
|||||||
Reference in New Issue
Block a user