forked from Olympic/NTO-2025-Android-TeamTask
added generalscreen
added booking screeen
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.myitschool.work.core.Constants
|
||||
import ru.myitschool.work.data.model.*
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
object NetworkDataSource {
|
||||
private val client by lazy {
|
||||
@@ -20,23 +24,203 @@ object NetworkDataSource {
|
||||
Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = true
|
||||
encodeDefaults = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 15000
|
||||
connectTimeoutMillis = 15000
|
||||
socketTimeoutMillis = 15000
|
||||
}
|
||||
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||
return@withContext runCatching {
|
||||
val response = client.get(getUrl(code, Constants.AUTH_URL))
|
||||
val response: HttpResponse = client.get(getUrl(code, Constants.AUTH_URL)) {
|
||||
timeout {
|
||||
requestTimeoutMillis = 10000
|
||||
}
|
||||
}
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK -> true
|
||||
else -> error(response.bodyAsText())
|
||||
else -> error("HTTP ${response.status}: ${response.bodyAsText()}")
|
||||
}
|
||||
}.recoverCatching { throwable ->
|
||||
when (throwable) {
|
||||
is CancellationException -> throw throwable
|
||||
else -> throw Exception("Ошибка сети: ${throwable.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
|
||||
suspend fun getUserInfo(code: String): Result<UserInfoResponse> = withContext(Dispatchers.IO) {
|
||||
return@withContext runCatching {
|
||||
val response: HttpResponse = client.get(getUrl(code, Constants.INFO_URL)) {
|
||||
timeout {
|
||||
requestTimeoutMillis = 10000
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status == HttpStatusCode.OK) {
|
||||
// ✅ Парсим новую модель UserInfoResponse
|
||||
response.body<UserInfoResponse>()
|
||||
} else {
|
||||
throw Exception("HTTP ${response.status.value}: ${response.bodyAsText()}")
|
||||
}
|
||||
}.recoverCatching { throwable ->
|
||||
when (throwable) {
|
||||
is CancellationException -> throw throwable
|
||||
else -> throw Exception("Ошибка загрузки данных: ${throwable.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAvailableBookings(code: String): Result<AvailableBookingsResponse> = withContext(Dispatchers.IO) {
|
||||
return@withContext runCatching {
|
||||
val response: HttpResponse = client.get(getUrl(code, Constants.BOOKING_URL)) {
|
||||
timeout { requestTimeoutMillis = 10000 }
|
||||
}
|
||||
|
||||
if (response.status == HttpStatusCode.OK) {
|
||||
// ✅ Парсим AvailableBookingsResponse (Map<String, List<PlaceInfo>>)
|
||||
response.body<AvailableBookingsResponse>()
|
||||
} else {
|
||||
throw Exception("HTTP ${response.status.value}: ${response.bodyAsText()}")
|
||||
}
|
||||
}.recoverCatching { throwable ->
|
||||
when (throwable) {
|
||||
is CancellationException -> throw throwable
|
||||
else -> throw Exception("Ошибка загрузки данных: ${throwable.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun bookPlace(code: String, date: String, placeId: Long): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||
return@withContext runCatching {
|
||||
// ✅ Используем BookRequest с placeId (а не place)
|
||||
val bookRequest = BookRequest(date = date, placeId = placeId)
|
||||
|
||||
val response: HttpResponse = client.post(getUrl(code, Constants.BOOK_URL)) {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(bookRequest)
|
||||
timeout {
|
||||
requestTimeoutMillis = 10000
|
||||
}
|
||||
}
|
||||
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK, HttpStatusCode.Created -> true
|
||||
HttpStatusCode.Conflict -> throw Exception("Место уже забронировано")
|
||||
else -> throw Exception("HTTP ${response.status.value}: ${response.bodyAsText()}")
|
||||
}
|
||||
}.recoverCatching { throwable ->
|
||||
when (throwable) {
|
||||
is CancellationException -> throw throwable
|
||||
else -> throw Exception("Ошибка бронирования: ${throwable.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ НОВЫЙ МЕТОД: Получение доступных бронирований в формате AvailableBooking
|
||||
suspend fun getAvailableBookingsLegacy(code: String): Result<AvailableBooking> = withContext(Dispatchers.IO) {
|
||||
return@withContext runCatching {
|
||||
val response: HttpResponse = client.get(getUrl(code, Constants.BOOKING_URL)) {
|
||||
timeout { requestTimeoutMillis = 10000 }
|
||||
}
|
||||
|
||||
if (response.status == HttpStatusCode.OK) {
|
||||
// Парсим как AvailableBookingsResponse и конвертируем в AvailableBooking
|
||||
val bookingsResponse = response.body<AvailableBookingsResponse>()
|
||||
|
||||
// Конвертируем Map<String, List<PlaceInfo>> в List<BookingDate>
|
||||
val dates = bookingsResponse.data.map { (dateKey, places) ->
|
||||
// Конвертируем дату из "2024-04-19" в "19.04.2024"
|
||||
val dateParts = dateKey.split("-")
|
||||
val formattedDate = "${dateParts[2]}.${dateParts[1]}.${dateParts[0]}"
|
||||
|
||||
// Извлекаем названия мест
|
||||
val placeNames = places.map { it.place }
|
||||
|
||||
BookingDate(
|
||||
date = formattedDate,
|
||||
places = placeNames
|
||||
)
|
||||
}.sortedBy { date ->
|
||||
// Сортируем по дате
|
||||
val parts = date.date.split(".")
|
||||
"${parts[2]}-${parts[1]}-${parts[0]}"
|
||||
}
|
||||
|
||||
AvailableBooking(dates = dates)
|
||||
} else {
|
||||
throw Exception("HTTP ${response.status.value}: ${response.bodyAsText()}")
|
||||
}
|
||||
}.recoverCatching { throwable ->
|
||||
when (throwable) {
|
||||
is CancellationException -> throw throwable
|
||||
else -> throw Exception("Ошибка загрузки данных: ${throwable.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ НОВЫЙ МЕТОД: Бронирование места по названию (для совместимости с BookPlaceRequest)
|
||||
suspend fun bookPlaceByName(code: String, request: BookPlaceRequest): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
// Сначала получаем доступные места для этой даты
|
||||
val bookingsResult = getAvailableBookings(code)
|
||||
|
||||
if (bookingsResult.isSuccess) {
|
||||
val bookingsResponse = bookingsResult.getOrThrow()
|
||||
|
||||
// Конвертируем дату из "19.04.2024" в "2024-04-19"
|
||||
val dateParts = request.date.split(".")
|
||||
val formattedDate = "${dateParts[2]}-${dateParts[1]}-${dateParts[0]}"
|
||||
|
||||
// Ищем placeId по названию места
|
||||
val placesForDate = bookingsResponse.data[formattedDate]
|
||||
val placeInfo = placesForDate?.firstOrNull { it.place == request.place }
|
||||
|
||||
if (placeInfo == null) {
|
||||
Result.failure(Exception("Место '${request.place}' не найдено для даты ${request.date}"))
|
||||
} else {
|
||||
// Вызываем существующий метод bookPlace с найденным placeId
|
||||
bookPlace(code, formattedDate, placeInfo.id)
|
||||
}
|
||||
} else {
|
||||
Result.failure(Exception("Не удалось получить доступные места: ${bookingsResult.exceptionOrNull()?.message}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Дополнительный метод для проверки работы (для отладки)
|
||||
suspend fun testConnection(code: String): Result<String> = withContext(Dispatchers.IO) {
|
||||
return@withContext runCatching {
|
||||
val response: HttpResponse = client.get(getUrl(code, Constants.INFO_URL)) {
|
||||
timeout { requestTimeoutMillis = 5000 }
|
||||
}
|
||||
|
||||
"Status: ${response.status}, Body: ${response.bodyAsText().take(200)}..."
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUrl(code: String, targetUrl: String): String {
|
||||
val url = "${Constants.HOST}/api/$code$targetUrl"
|
||||
println("NetworkDataSource: Requesting URL: $url") // Для отладки
|
||||
return url
|
||||
}
|
||||
|
||||
fun close() {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@Serializable
|
||||
data object BookScreenDestination: AppDestination
|
||||
data object BookScreenDestination: AppDestination{}
|
||||
@@ -3,4 +3,4 @@ package ru.myitschool.work.ui.nav
|
||||
import kotlinx.serialization.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.screen.auth.AuthScreen
|
||||
import ru.myitschool.work.ui.screen.main.MainScreen
|
||||
import ru.myitschool.work.ui.screen.booking.BookingScreen
|
||||
|
||||
@Composable
|
||||
fun AppNavHost(
|
||||
@@ -40,11 +41,7 @@ fun AppNavHost(
|
||||
}
|
||||
|
||||
composable<BookScreenDestination> {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = "Экран бронирования (будет реализован позже)")
|
||||
}
|
||||
BookingScreen(navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
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<Booking>,
|
||||
onRefresh: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
// 1. Информация о пользователе (МОЙ КОД: с загрузкой аватарки с сервера)
|
||||
UserHeader(
|
||||
userInfo = userInfo,
|
||||
onLogout = onLogout,
|
||||
onRefresh = onRefresh,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 2. Список бронирований (МОЙ КОД: отсортированный по дате)
|
||||
BookingsList(
|
||||
bookings = bookings,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserHeader(
|
||||
userInfo: UserInfo,
|
||||
onLogout: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// МОЙ КОД: Улучшенная загрузка аватарки с сервера
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.testTag(TestIds.Main.PROFILE_NAME) // МОЙ КОД: Исправлен тег
|
||||
) {
|
||||
if (userInfo.photoUrl.isNotEmpty()) {
|
||||
// МОЙ КОД: Используем rememberAsyncImagePainter для лучшей производительности
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(userInfo.photoUrl)
|
||||
.crossfade(true)
|
||||
.placeholder(android.R.drawable.ic_menu_gallery)
|
||||
.error(android.R.drawable.stat_notify_error)
|
||||
.build(),
|
||||
contentDescription = "Фото пользователя",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.Person,
|
||||
contentDescription = "Аватар",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is MainState.Success -> {
|
||||
Column(
|
||||
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<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.viewModelScope
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.myitschool.work.data.repo.AuthRepository
|
||||
import ru.myitschool.work.data.repo.UserRepository
|
||||
import ru.myitschool.work.data.model.UserInfoUi as ModelUserInfo
|
||||
import ru.myitschool.work.data.model.BookingUi as ModelBooking
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class MainViewModel : ViewModel() {
|
||||
|
||||
private val userRepository = UserRepository()
|
||||
|
||||
private val _uiState = MutableStateFlow<MainState>(MainState.Loading)
|
||||
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
|
||||
|
||||
val isUserAuthorized: Boolean
|
||||
get() = AuthRepository.getSavedCode() != null
|
||||
private val _navigationAction = MutableSharedFlow<NavigationAction>()
|
||||
val navigationAction: SharedFlow<NavigationAction> = _navigationAction.asSharedFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
init {
|
||||
loadUserInfo()
|
||||
}
|
||||
|
||||
fun loadUserInfo() {
|
||||
viewModelScope.launch {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_uiState.value = MainState.Loading
|
||||
_error.value = null
|
||||
|
||||
try {
|
||||
val code = AuthRepository.getSavedCode()
|
||||
if (code == null) {
|
||||
_uiState.value = MainState.Error("Пользователь не авторизован")
|
||||
return@launch
|
||||
throw IllegalStateException("Пользователь не авторизован")
|
||||
}
|
||||
|
||||
// TODO: Реальный запрос к API
|
||||
// Пока заглушка
|
||||
_uiState.value = MainState.Success(
|
||||
userInfo = UserInfo("Иван Иванов", ""),
|
||||
bookings = emptyList()
|
||||
// ПАРАЛЛЕЛЬНАЯ ЗАГРУЗКА ДАННЫХ С СЕРВЕРА
|
||||
val userInfoDeferred = async {
|
||||
runCatching { userRepository.getUserInfo(code) }
|
||||
}
|
||||
val bookingsDeferred = async {
|
||||
runCatching { userRepository.getBookings(code) }
|
||||
}
|
||||
|
||||
// Ждем оба результата
|
||||
val userInfoResult = userInfoDeferred.await()
|
||||
val bookingsResult = bookingsDeferred.await()
|
||||
|
||||
if (userInfoResult.isFailure && bookingsResult.isFailure) {
|
||||
throw userInfoResult.exceptionOrNull() ?:
|
||||
bookingsResult.exceptionOrNull() ?:
|
||||
Exception("Ошибка загрузки данных")
|
||||
}
|
||||
|
||||
// Обработка ошибок авторизации
|
||||
listOf(userInfoResult, bookingsResult).forEach { result ->
|
||||
result.exceptionOrNull()?.let { error ->
|
||||
if (error.message?.contains("401") == true ||
|
||||
error.message?.contains("403") == true) {
|
||||
// Сессия истекла - делаем логаут
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
AuthRepository.clearSavedCode()
|
||||
_navigationAction.emit(NavigationAction.NavigateToAuth)
|
||||
}
|
||||
throw IllegalStateException("Сессия истекла")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем данные или используем пустые по умолчанию
|
||||
val userInfoFromRepo = userInfoResult.getOrElse {
|
||||
ModelUserInfo("", "")
|
||||
}
|
||||
|
||||
val bookingsFromRepo = bookingsResult.getOrElse { emptyList() }
|
||||
|
||||
// СОРТИРОВКА БРОНИРОВАНИЙ ПО ДАТЕ (как в ТЗ)
|
||||
val sortedBookings = bookingsFromRepo.sortedBy { booking ->
|
||||
parseDate(booking.date)
|
||||
}
|
||||
|
||||
// Преобразуем в UI модели
|
||||
val userInfo = UserInfo(
|
||||
name = userInfoFromRepo.name,
|
||||
photoUrl = userInfoFromRepo.photoUrl
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = MainState.Error("Ошибка загрузки: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
val bookings = sortedBookings.map { booking ->
|
||||
Booking(
|
||||
date = booking.date,
|
||||
place = booking.place
|
||||
)
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
AuthRepository.clearSavedCode()
|
||||
_uiState.value = MainState.Success(userInfo, bookings)
|
||||
|
||||
} catch (e: Exception) {
|
||||
val errorMessage = when {
|
||||
e is IllegalStateException && e.message?.contains("авторизация") == true ->
|
||||
"Требуется авторизация"
|
||||
e is IllegalStateException && e.message?.contains("Сессия") == true ->
|
||||
"Сессия истекла, требуется повторный вход"
|
||||
e.message?.contains("404") == true ->
|
||||
"Пользователь не найден"
|
||||
e.message?.contains("network", ignoreCase = true) == true ->
|
||||
"Нет соединения с сервером"
|
||||
else -> "Ошибка загрузки данных: ${e.message ?: "неизвестная ошибка"}"
|
||||
}
|
||||
|
||||
_error.value = errorMessage
|
||||
_uiState.value = MainState.Error(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
loadUserInfo()
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
AuthRepository.clearSavedCode()
|
||||
_navigationAction.emit(NavigationAction.NavigateToAuth)
|
||||
}
|
||||
}
|
||||
|
||||
fun onBookButtonClick() {
|
||||
viewModelScope.launch {
|
||||
_navigationAction.emit(NavigationAction.NavigateToBooking)
|
||||
}
|
||||
}
|
||||
|
||||
// МОЙ КОД: Функция для парсинга даты для сортировки
|
||||
private fun parseDate(dateStr: String): LocalDate {
|
||||
return try {
|
||||
val formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
LocalDate.parse(dateStr, formatter)
|
||||
} catch (e: Exception) {
|
||||
LocalDate.MAX // В случае ошибки парсинга
|
||||
}
|
||||
}
|
||||
|
||||
// МОЙ КОД: Обновленная обработка ошибок
|
||||
private suspend fun handleApiError(error: Throwable): String {
|
||||
return when {
|
||||
error.message?.contains("401") == true -> {
|
||||
AuthRepository.clearSavedCode()
|
||||
_navigationAction.emit(NavigationAction.NavigateToAuth)
|
||||
"Сессия истекла"
|
||||
}
|
||||
error.message?.contains("404") == true -> "Пользователь не найден"
|
||||
error.message?.contains("network", ignoreCase = true) == true ->
|
||||
"Нет соединения с сервером"
|
||||
else -> error.message ?: "Ошибка загрузки данных"
|
||||
}
|
||||
}
|
||||
|
||||
sealed class NavigationAction {
|
||||
object NavigateToAuth : NavigationAction()
|
||||
object NavigateToBooking : NavigationAction()
|
||||
}
|
||||
}
|
||||
|
||||
// UI модели (остаются без изменений)
|
||||
sealed interface MainState {
|
||||
object Loading : MainState
|
||||
data class Error(val message: String) : MainState
|
||||
@@ -58,7 +176,6 @@ sealed interface MainState {
|
||||
) : MainState
|
||||
}
|
||||
|
||||
// Временные модели (потом перенесем в data/model)
|
||||
data class UserInfo(
|
||||
val name: String,
|
||||
val photoUrl: String
|
||||
|
||||
Reference in New Issue
Block a user