Compare commits

2 Commits
main ... main

Author SHA1 Message Date
v3less11
2e332b8ff9 added generalscreen
added booking screeen
2025-12-11 20:50:28 +03:00
v3less11
5a79376482 authscreen-changed 2025-12-10 17:26:04 +03:00
23 changed files with 1787 additions and 92 deletions

View File

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

View File

@@ -2,11 +2,11 @@ package ru.myitschool.work
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import ru.myitschool.work.data.repo.AuthRepository
class App: Application() { class App: Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
context = this AuthRepository.initialize(this)
} }
companion object { companion object {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,45 @@
package ru.myitschool.work.data.repo package ru.myitschool.work.data.repo
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.myitschool.work.data.source.NetworkDataSource import ru.myitschool.work.data.source.NetworkDataSource
object AuthRepository { object AuthRepository {
private const val PREFS_NAME = "auth_prefs"
private const val KEY_AUTH_CODE = "auth_code"
private var codeCache: String? = null private var codeCache: String? = null
private lateinit var prefs: SharedPreferences
fun initialize(context: Context) {
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
codeCache = prefs.getString(KEY_AUTH_CODE, null)
}
suspend fun checkAndSave(text: String): Result<Boolean> { suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success -> return withContext(Dispatchers.IO) {
if (success) { NetworkDataSource.checkAuth(code = text).onSuccess { success ->
codeCache = text if (success) {
saveCode(text)
}
} }
} }
} }
private fun saveCode(code: String) {
codeCache = code
prefs.edit().putString(KEY_AUTH_CODE, code).apply()
}
fun getSavedCode(): String? = codeCache
suspend fun clearSavedCode() {
codeCache = null
withContext(Dispatchers.IO) {
prefs.edit().remove(KEY_AUTH_CODE).apply()
}
}
} }

View File

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

View File

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

View File

@@ -1,16 +1,20 @@
package ru.myitschool.work.data.source 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()
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,25 +3,38 @@ package ru.myitschool.work.ui.root
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.screen.AppNavHost import ru.myitschool.work.ui.screen.AppNavHost
import ru.myitschool.work.ui.theme.WorkTheme import ru.myitschool.work.ui.theme.WorkTheme
class RootActivity : ComponentActivity() { class RootActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Определяем стартовый экран
val startDestination = if (AuthRepository.getSavedCode() != null) {
MainScreenDestination
} else {
AuthScreenDestination
}
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
WorkTheme { WorkTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
AppNavHost( AppNavHost(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding),
startDestination = startDestination
) )
} }
} }

View File

@@ -11,39 +11,37 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import ru.myitschool.work.ui.nav.AppDestination
import ru.myitschool.work.ui.nav.AuthScreenDestination import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.BookScreenDestination 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.booking.BookingScreen
@Composable @Composable
fun AppNavHost( fun AppNavHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController() navController: NavHostController = rememberNavController(),
startDestination: AppDestination = AuthScreenDestination
) { ) {
NavHost( NavHost(
modifier = modifier, modifier = modifier,
enterTransition = { EnterTransition.None }, enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None }, exitTransition = { ExitTransition.None },
navController = navController, navController = navController,
startDestination = AuthScreenDestination, startDestination = startDestination,
) { ) {
composable<AuthScreenDestination> { composable<AuthScreenDestination> {
AuthScreen(navController = navController) AuthScreen(navController = navController)
} }
composable<MainScreenDestination> { composable<MainScreenDestination> {
Box( MainScreen(navController = navController)
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
}
} }
composable<BookScreenDestination> { composable<BookScreenDestination> {
Box( BookingScreen(navController = navController)
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
}
} }
} }
} }

View File

@@ -1,29 +1,14 @@
package ru.myitschool.work.ui.screen.auth package ru.myitschool.work.ui.screen.auth
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.*
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.*
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
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
@@ -38,6 +23,7 @@ fun AuthScreen(
navController: NavController navController: NavController
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val error by viewModel.error.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.actionFlow.collect { viewModel.actionFlow.collect {
@@ -57,13 +43,14 @@ fun AuthScreen(
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
when (val currentState = state) {
is AuthState.Data -> Content(viewModel, currentState) // Исправление: Loading теперь внутри AuthState.Data
is AuthState.Loading -> { if (state is AuthState.Data && (state as AuthState.Data).isLoading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(64.dp) modifier = Modifier.size(64.dp)
) )
} } else if (state is AuthState.Data) {
Content(viewModel, state as AuthState.Data, error)
} }
} }
} }
@@ -71,26 +58,44 @@ fun AuthScreen(
@Composable @Composable
private fun Content( private fun Content(
viewModel: AuthViewModel, viewModel: AuthViewModel,
state: AuthState.Data state: AuthState.Data,
error: String?
) { ) {
var inputText by remember { mutableStateOf("") }
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
// Показать ошибку, если есть
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.testTag(TestIds.Auth.ERROR)
.fillMaxWidth()
.padding(bottom = 8.dp),
textAlign = TextAlign.Center
)
}
TextField( TextField(
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(), modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
value = inputText, value = state.inputText,
onValueChange = { onValueChange = {
inputText = it
viewModel.onIntent(AuthIntent.TextInput(it)) viewModel.onIntent(AuthIntent.TextInput(it))
}, },
label = { Text(stringResource(R.string.auth_label)) } label = { Text(stringResource(R.string.auth_label)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
isError = error != null
) )
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Button( Button(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(), modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
onClick = { onClick = {
viewModel.onIntent(AuthIntent.Send(inputText)) viewModel.onIntent(AuthIntent.Send(state.inputText))
}, },
enabled = true enabled = state.isValid && !state.isLoading // Добавил проверку валидности
) { ) {
Text(stringResource(R.string.auth_sign_in)) Text(stringResource(R.string.auth_sign_in))
} }

View File

@@ -1,6 +1,10 @@
package ru.myitschool.work.ui.screen.auth package ru.myitschool.work.ui.screen.auth
sealed interface AuthState { sealed interface AuthState {
object Loading: AuthState data class Data(
object Data: AuthState val inputText: String = "",
val error: String? = null,
val isValid: Boolean = false,
val isLoading: Boolean = false
) : AuthState
} }

View File

@@ -3,41 +3,72 @@ package ru.myitschool.work.ui.screen.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
class AuthViewModel : ViewModel() { class AuthViewModel : ViewModel() {
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data) private val checkAndSaveAuthCodeUseCase by lazy {
CheckAndSaveAuthCodeUseCase(AuthRepository)
}
private val _uiState = MutableStateFlow<AuthState>(
AuthState.Data(inputText = "", isValid = false, isLoading = false)
)
val uiState: StateFlow<AuthState> = _uiState.asStateFlow() val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow() private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow val actionFlow: SharedFlow<Unit> = _actionFlow.asSharedFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
fun onIntent(intent: AuthIntent) { fun onIntent(intent: AuthIntent) {
when (intent) { when (intent) {
is AuthIntent.Send -> { is AuthIntent.Send -> {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.IO) {
_uiState.update { AuthState.Loading } _uiState.update {
checkAndSaveAuthCodeUseCase.invoke("9999").fold( if (it is AuthState.Data) it.copy(isLoading = true) else it
}
checkAndSaveAuthCodeUseCase.invoke(intent.text).fold(
onSuccess = { onSuccess = {
_actionFlow.emit(Unit) _actionFlow.emit(Unit)
_error.value = null
}, },
onFailure = { error -> onFailure = { error ->
error.printStackTrace() _error.value = error.message ?: "Ошибка авторизации"
_actionFlow.emit(Unit) _uiState.update {
if (it is AuthState.Data) it.copy(isLoading = false) else it
}
} }
) )
} }
} }
is AuthIntent.TextInput -> Unit
is AuthIntent.TextInput -> {
if (_error.value != null) {
_error.value = null
}
val isValid = isValidCode(intent.text)
_uiState.update {
if (it is AuthState.Data) it.copy(
inputText = intent.text,
isValid = isValid
) else it
}
}
}
}
private fun isValidCode(text: String): Boolean {
if (text.isEmpty() || text.length != 4) return false
return text.all { char ->
char in 'A'..'Z' || char in 'a'..'z' || char in '0'..'9'
} }
} }
} }

View File

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

View File

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

View File

@@ -0,0 +1,439 @@
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
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
fun MainScreen(
viewModel: MainViewModel = viewModel(),
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 = state) {
if (state is MainState.Error) {
val errorMessage = (state as MainState.Error).message
if (errorMessage.contains("авторизация", ignoreCase = true) ||
errorMessage.contains("Сессия", ignoreCase = true)) {
AuthRepository.clearSavedCode()
navController.navigate(AuthScreenDestination) {
popUpTo(0)
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = "Мои бронирования") }, // МОЙ КОД: Изменен заголовок
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 ->
MainContent(
state = state,
error = error,
onRefresh = { viewModel.refresh() },
onLogout = { viewModel.logout() },
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
)
}
}
@Composable
private fun MainContent(
state: MainState,
error: String?,
onRefresh: () -> Unit,
onLogout: () -> Unit,
modifier: Modifier = Modifier
) {
when (state) {
is MainState.Loading -> {
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
)
}
}
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)
)
}
}
}

View File

@@ -0,0 +1,187 @@
package ru.myitschool.work.ui.screen.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
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()
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(Dispatchers.IO) {
_uiState.value = MainState.Loading
_error.value = null
try {
val code = AuthRepository.getSavedCode()
if (code == null) {
throw IllegalStateException("Пользователь не авторизован")
}
// ПАРАЛЛЕЛЬНАЯ ЗАГРУЗКА ДАННЫХ С СЕРВЕРА
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
)
val bookings = sortedBookings.map { booking ->
Booking(
date = booking.date,
place = booking.place
)
}
_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
data class Success(
val userInfo: UserInfo,
val bookings: List<Booking>
) : MainState
}
data class UserInfo(
val name: String,
val photoUrl: String
)
data class Booking(
val date: String,
val place: String
)