From 04ac941ba48fbc2871d16319a36fe788df6e7fd7 Mon Sep 17 00:00:00 2001 From: Eresperto Date: Mon, 1 Dec 2025 14:03:20 +0600 Subject: [PATCH] No ava --- .../work/data/repo/UserRepository.kt | 93 ++++++++++++---- .../work/data/source/NetworkDataSource.kt | 104 ++++++++++++------ .../work/data/source/dto/UserDto.kt | 71 ++++++++++-- .../work/domain/booking/BookPlaceUseCase.kt | 12 +- .../work/domain/entities/BookingEntity.kt | 1 + .../work/domain/entities/UserEntity.kt | 1 + .../work/ui/screen/book/BookViewModel.kt | 4 +- .../work/ui/screen/main/MainScreen.kt | 53 ++++++++- .../work/ui/screen/main/MainState.kt | 1 + .../work/ui/screen/main/MainViewModel.kt | 1 + 10 files changed, 264 insertions(+), 77 deletions(-) diff --git a/app/src/main/java/ru/myitschool/work/data/repo/UserRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/UserRepository.kt index 2f4faa5..f933d7c 100644 --- a/app/src/main/java/ru/myitschool/work/data/repo/UserRepository.kt +++ b/app/src/main/java/ru/myitschool/work/data/repo/UserRepository.kt @@ -3,52 +3,97 @@ package ru.myitschool.work.data.repo import java.time.LocalDate import ru.myitschool.work.core.DateUtils import ru.myitschool.work.data.source.NetworkDataSource +import ru.myitschool.work.data.source.dto.AvailablePlaceDto +import ru.myitschool.work.data.source.dto.BookedPlaceDto import ru.myitschool.work.data.source.dto.UserDto import ru.myitschool.work.domain.entities.BookingEntity import ru.myitschool.work.domain.entities.UserEntity - object UserRepository { + /** + * Получение информации о пользователе через GET /api//info + */ suspend fun getUserInfo(): Result { val code = AuthRepository.getSavedCode() ?: return Result.failure(IllegalStateException("Auth code is not saved")) - return NetworkDataSource.getUserInfo(code).map { dto -> - val bookings = dto.booking - .map { it.toDomain() } - .sortedWith( - compareBy { booking -> - DateUtils.parseDate(booking.time) ?: LocalDate.MAX - }.thenBy { it.time } - ) - - UserEntity( - name = dto.name, - bookings = bookings, - ) - } + return NetworkDataSource + .getUserInfo(code) + .map { dto -> dto.toDomainUser() } } + /** + * Доступные слоты бронирования через GET /api//booking + */ suspend fun getAvailableBookings(): Result> { val code = AuthRepository.getSavedCode() ?: return Result.failure(IllegalStateException("Auth code is not saved")) - return NetworkDataSource.getAvailableBookings(code).map { list -> - list.map { it.toDomain() } - } + return NetworkDataSource + .getAvailableBookings(code) + .map { map -> map.toDomainBookings() } } - suspend fun book(room: String, time: String): Result { + /** + * Создание нового бронирования через POST /api//book + */ + suspend fun book(date: String, placeId: Int): Result { val code = AuthRepository.getSavedCode() ?: return Result.failure(IllegalStateException("Auth code is not saved")) - return NetworkDataSource.book(code, room, time) + return NetworkDataSource.book( + code = code, + date = date, + placeId = placeId, + ) } - private fun UserDto.BookingDto.toDomain(): BookingEntity = - BookingEntity( - roomName = room, - time = time, + // -------------------- Маппинг DTO -> domain -------------------- + + private fun UserDto.toDomainUser(): UserEntity { + val bookings = booking + .flatMap { (dateString, bookedPlace) -> + listOfNotNull(bookedPlace.toDomainBooking(dateString)) + } + .sortedBy { booking -> + DateUtils.parseDate(booking.time) ?: LocalDate.MAX + } + + return UserEntity( + name = name, + photoUrl = photoUrl, + bookings = bookings, ) + } + + private fun Map>.toDomainBookings(): List { + return entries + .flatMap { (dateString, places) -> + places.mapNotNull { dto -> + dto.toDomainBooking(dateString) + } + } + .sortedBy { booking -> + DateUtils.parseDate(booking.time) ?: LocalDate.MAX + } + } + + private fun BookedPlaceDto.toDomainBooking(dateString: String): BookingEntity? { + val parsedDate = DateUtils.parseDate(dateString) ?: return null + return BookingEntity( + id = id, + roomName = place, + time = parsedDate.toString(), + ) + } + + private fun AvailablePlaceDto.toDomainBooking(dateString: String): BookingEntity? { + val parsedDate = DateUtils.parseDate(dateString) ?: return null + return BookingEntity( + id = id, + roomName = place, + time = parsedDate.toString(), + ) + } } diff --git a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt index af2f340..12fcee3 100644 --- a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt +++ b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt @@ -4,8 +4,6 @@ import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.request.forms.MultiPartFormDataContent -import io.ktor.client.request.forms.formData import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody @@ -16,14 +14,19 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import ru.myitschool.work.core.Constants +import ru.myitschool.work.data.source.dto.AvailablePlaceDto +import ru.myitschool.work.data.source.dto.BookRequestDto +import ru.myitschool.work.data.source.dto.BookedPlaceDto import ru.myitschool.work.data.source.dto.UserDto object NetworkDataSource { - /** Поставь false, когда поднимешь настоящий бэкенд */ - private const val USE_STUB = false + /** + * Поставь false, когда поднимешь настоящий бэкенд. + * При true используются локальные заглушки. + */ + private const val USE_STUB: Boolean = false - // Реальный клиент Ktor (для будущего) private val client by lazy { HttpClient(CIO) { install(ContentNegotiation) { @@ -31,7 +34,7 @@ object NetworkDataSource { Json { isLenient = true ignoreUnknownKeys = true - explicitNulls = true + explicitNulls = false encodeDefaults = true } ) @@ -39,35 +42,44 @@ object NetworkDataSource { } } - // ----------------- Заглушечные данные ----------------- + // --------- Заглушечные данные --------- private val stubUser = UserDto( name = "Тестовый пользователь", - booking = listOf( - UserDto.BookingDto( - room = "Опенспейс 1", - time = "2025-01-05" - ), - UserDto.BookingDto( - room = "Опенспейс 2", - time = "2025-01-06" - ), - ) + photoUrl = null, + booking = mapOf( + "2025-01-05" to BookedPlaceDto(id = 1, place = "102"), + "2025-01-06" to BookedPlaceDto(id = 2, place = "209.13"), + "2025-01-09" to BookedPlaceDto(id = 3, place = "Зона 51. 50"), + ), ) - private val stubAvailableBookings: List = listOf( - UserDto.BookingDto(room = "Опенспейс 1", time = "2025-01-10"), - UserDto.BookingDto(room = "Опенспейс 1", time = "2025-01-11"), - UserDto.BookingDto(room = "Опенспейс 2", time = "2025-01-10"), - UserDto.BookingDto(room = "Опенспейс 3", time = "2025-01-12"), + private val stubAvailableBookings: Map> = mapOf( + "2025-01-05" to listOf( + AvailablePlaceDto(id = 1, place = "102"), + AvailablePlaceDto(id = 2, place = "209.13"), + ), + "2025-01-06" to listOf( + AvailablePlaceDto(id = 3, place = "Зона 51. 50"), + ), + "2025-01-07" to listOf( + AvailablePlaceDto(id = 4, place = "102"), + AvailablePlaceDto(id = 5, place = "209.13"), + ), + "2025-01-08" to listOf( + AvailablePlaceDto(id = 6, place = "209.13"), + ), ) // ----------------- Публичные методы ----------------- + /** + * Проверка кода авторизации через GET /api//auth + */ suspend fun checkAuth(code: String): Result { if (USE_STUB) { - // Примитивная проверка заглушки: 4 символа → ок - return Result.success(code.length == 4) + // Примитивная проверка заглушки: 4+ символа → ок + return Result.success(code.length >= 4) } return withContext(Dispatchers.IO) { @@ -75,12 +87,17 @@ object NetworkDataSource { val response = client.get(getUrl(code, Constants.AUTH_URL)) when (response.status) { HttpStatusCode.OK -> true + HttpStatusCode.BadRequest, + HttpStatusCode.Unauthorized -> error(response.bodyAsText()) else -> error(response.bodyAsText()) } } } } + /** + * Получение информации о пользователе через GET /api//info + */ suspend fun getUserInfo(code: String): Result { if (USE_STUB) { return Result.success(stubUser) @@ -91,15 +108,20 @@ object NetworkDataSource { val response = client.get(getUrl(code, Constants.INFO_URL)) when (response.status) { HttpStatusCode.OK -> response.body() + HttpStatusCode.BadRequest, + HttpStatusCode.Unauthorized -> error(response.bodyAsText()) else -> error(response.bodyAsText()) } } } } + /** + * Получение доступных слотов бронирования через GET /api//booking + */ suspend fun getAvailableBookings( - code: String - ): Result> { + code: String, + ): Result>> { if (USE_STUB) { return Result.success(stubAvailableBookings) } @@ -108,20 +130,28 @@ object NetworkDataSource { runCatching { val response = client.get(getUrl(code, Constants.BOOKING_URL)) when (response.status) { - HttpStatusCode.OK -> response.body>() + HttpStatusCode.OK -> + response.body>>() + HttpStatusCode.BadRequest, + HttpStatusCode.Unauthorized -> error(response.bodyAsText()) else -> error(response.bodyAsText()) } } } } + /** + * Создание нового бронирования через POST /api//book + * + * Тело: { "date": "2025-01-05", "placeID": 1 } + */ suspend fun book( code: String, - room: String, - time: String + date: String, + placeId: Int, ): Result { if (USE_STUB) { - // Типа всё хорошо + // В режиме заглушки считаем, что всё прошло успешно return Result.success(Unit) } @@ -129,22 +159,24 @@ object NetworkDataSource { runCatching { val response = client.post(getUrl(code, Constants.BOOK_URL)) { setBody( - MultiPartFormDataContent( - formData { - append("room", room) - append("time", time) - } + BookRequestDto( + date = date, + placeId = placeId, ) ) } when (response.status) { + HttpStatusCode.Created, HttpStatusCode.OK -> Unit + HttpStatusCode.Conflict -> error("Уже забронировано") + HttpStatusCode.BadRequest, + HttpStatusCode.Unauthorized -> error(response.bodyAsText()) else -> error(response.bodyAsText()) } } } } - private fun getUrl(code: String, targetUrl: String) = + private fun getUrl(code: String, targetUrl: String): String = "${Constants.HOST}/api/$code$targetUrl" } diff --git a/app/src/main/java/ru/myitschool/work/data/source/dto/UserDto.kt b/app/src/main/java/ru/myitschool/work/data/source/dto/UserDto.kt index aa5a093..acc1537 100644 --- a/app/src/main/java/ru/myitschool/work/data/source/dto/UserDto.kt +++ b/app/src/main/java/ru/myitschool/work/data/source/dto/UserDto.kt @@ -3,18 +3,67 @@ package ru.myitschool.work.data.source.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** + * DTO для ответа GET /api//info + * + * Пример: + * { + * "name":"Иванов Петр Федорович", + * "photoUrl":"https://...", + * "booking":{ + * "2025-01-05": {"id":1,"place":"102"}, + * "2025-01-06": {"id":2,"place":"209.13"} + * } + * } + */ @Serializable data class UserDto( @SerialName("name") - val name: String = "Administrator", + val name: String, + @SerialName("photoUrl") + val photoUrl: String? = null, @SerialName("booking") - val booking: List -) { - @Serializable - data class BookingDto( - @SerialName("room") - val room: String, - @SerialName("time") - val time: String, - ) -} \ No newline at end of file + val booking: Map = emptyMap(), +) + +/** + * Элемент бронирования в ответе /info и /booking. + */ +@Serializable +data class BookedPlaceDto( + @SerialName("id") + val id: Int, + @SerialName("place") + val place: String, +) + +/** + * DTO для доступных мест в ответе GET /api//booking: + * + * { + * "2025-01-05": [{"id": 1, "place": "102"}, ...] + * } + */ +@Serializable +data class AvailablePlaceDto( + @SerialName("id") + val id: Int, + @SerialName("place") + val place: String, +) + +/** + * Тело запроса POST /api//book: + * + * { + * "date": "2025-01-05", + * "placeID": 1 + * } + */ +@Serializable +data class BookRequestDto( + @SerialName("date") + val date: String, + @SerialName("placeID") + val placeId: Int, +) diff --git a/app/src/main/java/ru/myitschool/work/domain/booking/BookPlaceUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/booking/BookPlaceUseCase.kt index bfc5787..02a9829 100644 --- a/app/src/main/java/ru/myitschool/work/domain/booking/BookPlaceUseCase.kt +++ b/app/src/main/java/ru/myitschool/work/domain/booking/BookPlaceUseCase.kt @@ -2,10 +2,16 @@ package ru.myitschool.work.domain.booking import ru.myitschool.work.data.repo.UserRepository +/** + * Юзкейс для создания бронирования. + * + * @param date дата бронирования в формате yyyy-MM-dd + * @param placeId идентификатор места (placeID из бэкенда) + */ class BookPlaceUseCase( - private val repository: UserRepository + private val repository: UserRepository, ) { - suspend operator fun invoke(room: String, time: String): Result { - return repository.book(room, time) + suspend operator fun invoke(date: String, placeId: Int): Result { + return repository.book(date, placeId) } } diff --git a/app/src/main/java/ru/myitschool/work/domain/entities/BookingEntity.kt b/app/src/main/java/ru/myitschool/work/domain/entities/BookingEntity.kt index 0bf603d..a2ce3dd 100644 --- a/app/src/main/java/ru/myitschool/work/domain/entities/BookingEntity.kt +++ b/app/src/main/java/ru/myitschool/work/domain/entities/BookingEntity.kt @@ -1,6 +1,7 @@ package ru.myitschool.work.domain.entities data class BookingEntity( + val id: Int, val roomName: String, val time: String, ) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/entities/UserEntity.kt b/app/src/main/java/ru/myitschool/work/domain/entities/UserEntity.kt index 7aba90e..b0024b0 100644 --- a/app/src/main/java/ru/myitschool/work/domain/entities/UserEntity.kt +++ b/app/src/main/java/ru/myitschool/work/domain/entities/UserEntity.kt @@ -2,5 +2,6 @@ package ru.myitschool.work.domain.entities data class UserEntity( val name: String, + val photoUrl: String?, val bookings: List, ) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt index 5c517f3..68025d8 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt @@ -129,7 +129,7 @@ class BookViewModel : ViewModel() { val group = groups[index] val places = group.slots.mapIndexed { idx, slot -> BookPlaceItem( - id = idx, + id = slot.id, roomName = slot.roomName, time = slot.time, isSelected = false, @@ -162,7 +162,7 @@ class BookViewModel : ViewModel() { val place = current.places.firstOrNull { it.id == placeId } ?: return viewModelScope.launch(Dispatchers.Default) { - bookPlaceUseCase(place.roomName, place.time) + bookPlaceUseCase(place.roomName, place.id) .fold( onSuccess = { _actionFlow.emit(Action.CloseWithSuccess) diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt index 8bdb961..284739d 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt @@ -1,5 +1,6 @@ package ru.myitschool.work.ui.screen.main +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -11,21 +12,28 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.Person import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.SegmentedButtonDefaults.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.testTag import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController +import coil3.compose.SubcomposeAsyncImage import ru.myitschool.work.core.TestIds import ru.myitschool.work.ui.nav.AuthScreenDestination import ru.myitschool.work.ui.nav.BookScreenDestination @@ -131,7 +139,11 @@ private fun MainDataContent( .testTag(TestIds.Main.PROFILE_IMAGE), contentAlignment = Alignment.Center ) { - Text("🙂") + UserAvatar( + photoUrl = state.photoUrl, + modifier = Modifier + .size(64.dp) + ) } Spacer(modifier = Modifier.size(16.dp)) Text( @@ -196,3 +208,42 @@ private fun MainDataContent( } } } +@Composable +private fun UserAvatar( + photoUrl: String?, + modifier: Modifier = Modifier, +) { + if (photoUrl.isNullOrBlank()) { + // Фолбек на старый смайл / иконку + Image( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = modifier + .size(64.dp) + .clip(CircleShape), + ) + } else { + SubcomposeAsyncImage( + model = photoUrl, + contentDescription = null, + modifier = modifier + .size(64.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + loading = { + Image( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(64.dp), + ) + }, + error = { + Image( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(64.dp), + ) + } + ) + } +} diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt index 5b2513f..e025385 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt @@ -11,6 +11,7 @@ sealed interface MainState { data class Data( val name: String, + val photoUrl: String?, val bookings: List, ) : MainState diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt index 6e7a180..c04316e 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt @@ -76,6 +76,7 @@ class MainViewModel : ViewModel() { _uiState.value = MainState.Data( name = user.name, + photoUrl = user.photoUrl, bookings = bookings, ) },