This commit is contained in:
2025-12-01 14:03:20 +06:00
parent 99c100929a
commit 04ac941ba4
10 changed files with 264 additions and 77 deletions

View File

@@ -3,52 +3,97 @@ package ru.myitschool.work.data.repo
import java.time.LocalDate import java.time.LocalDate
import ru.myitschool.work.core.DateUtils import ru.myitschool.work.core.DateUtils
import ru.myitschool.work.data.source.NetworkDataSource 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.data.source.dto.UserDto
import ru.myitschool.work.domain.entities.BookingEntity import ru.myitschool.work.domain.entities.BookingEntity
import ru.myitschool.work.domain.entities.UserEntity import ru.myitschool.work.domain.entities.UserEntity
object UserRepository { object UserRepository {
/**
* Получение информации о пользователе через GET /api/<CODE>/info
*/
suspend fun getUserInfo(): Result<UserEntity> { suspend fun getUserInfo(): Result<UserEntity> {
val code = AuthRepository.getSavedCode() val code = AuthRepository.getSavedCode()
?: return Result.failure(IllegalStateException("Auth code is not saved")) ?: return Result.failure(IllegalStateException("Auth code is not saved"))
return NetworkDataSource.getUserInfo(code).map { dto -> return NetworkDataSource
val bookings = dto.booking .getUserInfo(code)
.map { it.toDomain() } .map { dto -> dto.toDomainUser() }
.sortedWith(
compareBy<BookingEntity> { booking ->
DateUtils.parseDate(booking.time) ?: LocalDate.MAX
}.thenBy { it.time }
)
UserEntity(
name = dto.name,
bookings = bookings,
)
}
} }
/**
* Доступные слоты бронирования через GET /api/<CODE>/booking
*/
suspend fun getAvailableBookings(): Result<List<BookingEntity>> { suspend fun getAvailableBookings(): Result<List<BookingEntity>> {
val code = AuthRepository.getSavedCode() val code = AuthRepository.getSavedCode()
?: return Result.failure(IllegalStateException("Auth code is not saved")) ?: return Result.failure(IllegalStateException("Auth code is not saved"))
return NetworkDataSource.getAvailableBookings(code).map { list -> return NetworkDataSource
list.map { it.toDomain() } .getAvailableBookings(code)
} .map { map -> map.toDomainBookings() }
} }
suspend fun book(room: String, time: String): Result<Unit> { /**
* Создание нового бронирования через POST /api/<CODE>/book
*/
suspend fun book(date: String, placeId: Int): Result<Unit> {
val code = AuthRepository.getSavedCode() val code = AuthRepository.getSavedCode()
?: return Result.failure(IllegalStateException("Auth code is not saved")) ?: 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 = // -------------------- Маппинг DTO -> domain --------------------
BookingEntity(
roomName = room, private fun UserDto.toDomainUser(): UserEntity {
time = time, 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<String, List<AvailablePlaceDto>>.toDomainBookings(): List<BookingEntity> {
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(),
)
}
} }

View File

@@ -4,8 +4,6 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 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.get
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
@@ -16,14 +14,19 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
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.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 import ru.myitschool.work.data.source.dto.UserDto
object NetworkDataSource { object NetworkDataSource {
/** Поставь false, когда поднимешь настоящий бэкенд */ /**
private const val USE_STUB = false * Поставь false, когда поднимешь настоящий бэкенд.
* При true используются локальные заглушки.
*/
private const val USE_STUB: Boolean = false
// Реальный клиент Ktor (для будущего)
private val client by lazy { private val client by lazy {
HttpClient(CIO) { HttpClient(CIO) {
install(ContentNegotiation) { install(ContentNegotiation) {
@@ -31,7 +34,7 @@ object NetworkDataSource {
Json { Json {
isLenient = true isLenient = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
explicitNulls = true explicitNulls = false
encodeDefaults = true encodeDefaults = true
} }
) )
@@ -39,35 +42,44 @@ object NetworkDataSource {
} }
} }
// ----------------- Заглушечные данные ----------------- // --------- Заглушечные данные ---------
private val stubUser = UserDto( private val stubUser = UserDto(
name = "Тестовый пользователь", name = "Тестовый пользователь",
booking = listOf( photoUrl = null,
UserDto.BookingDto( booking = mapOf(
room = "Опенспейс 1", "2025-01-05" to BookedPlaceDto(id = 1, place = "102"),
time = "2025-01-05" "2025-01-06" to BookedPlaceDto(id = 2, place = "209.13"),
), "2025-01-09" to BookedPlaceDto(id = 3, place = "Зона 51. 50"),
UserDto.BookingDto( ),
room = "Опенспейс 2",
time = "2025-01-06"
),
)
) )
private val stubAvailableBookings: List<UserDto.BookingDto> = listOf( private val stubAvailableBookings: Map<String, List<AvailablePlaceDto>> = mapOf(
UserDto.BookingDto(room = "Опенспейс 1", time = "2025-01-10"), "2025-01-05" to listOf(
UserDto.BookingDto(room = "Опенспейс 1", time = "2025-01-11"), AvailablePlaceDto(id = 1, place = "102"),
UserDto.BookingDto(room = "Опенспейс 2", time = "2025-01-10"), AvailablePlaceDto(id = 2, place = "209.13"),
UserDto.BookingDto(room = "Опенспейс 3", time = "2025-01-12"), ),
"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/<CODE>/auth
*/
suspend fun checkAuth(code: String): Result<Boolean> { suspend fun checkAuth(code: String): Result<Boolean> {
if (USE_STUB) { if (USE_STUB) {
// Примитивная проверка заглушки: 4 символа → ок // Примитивная проверка заглушки: 4+ символа → ок
return Result.success(code.length == 4) return Result.success(code.length >= 4)
} }
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
@@ -75,12 +87,17 @@ object NetworkDataSource {
val response = client.get(getUrl(code, Constants.AUTH_URL)) val response = client.get(getUrl(code, Constants.AUTH_URL))
when (response.status) { when (response.status) {
HttpStatusCode.OK -> true HttpStatusCode.OK -> true
HttpStatusCode.BadRequest,
HttpStatusCode.Unauthorized -> error(response.bodyAsText())
else -> error(response.bodyAsText()) else -> error(response.bodyAsText())
} }
} }
} }
} }
/**
* Получение информации о пользователе через GET /api/<CODE>/info
*/
suspend fun getUserInfo(code: String): Result<UserDto> { suspend fun getUserInfo(code: String): Result<UserDto> {
if (USE_STUB) { if (USE_STUB) {
return Result.success(stubUser) return Result.success(stubUser)
@@ -91,15 +108,20 @@ object NetworkDataSource {
val response = client.get(getUrl(code, Constants.INFO_URL)) val response = client.get(getUrl(code, Constants.INFO_URL))
when (response.status) { when (response.status) {
HttpStatusCode.OK -> response.body<UserDto>() HttpStatusCode.OK -> response.body<UserDto>()
HttpStatusCode.BadRequest,
HttpStatusCode.Unauthorized -> error(response.bodyAsText())
else -> error(response.bodyAsText()) else -> error(response.bodyAsText())
} }
} }
} }
} }
/**
* Получение доступных слотов бронирования через GET /api/<CODE>/booking
*/
suspend fun getAvailableBookings( suspend fun getAvailableBookings(
code: String code: String,
): Result<List<UserDto.BookingDto>> { ): Result<Map<String, List<AvailablePlaceDto>>> {
if (USE_STUB) { if (USE_STUB) {
return Result.success(stubAvailableBookings) return Result.success(stubAvailableBookings)
} }
@@ -108,20 +130,28 @@ object NetworkDataSource {
runCatching { runCatching {
val response = client.get(getUrl(code, Constants.BOOKING_URL)) val response = client.get(getUrl(code, Constants.BOOKING_URL))
when (response.status) { when (response.status) {
HttpStatusCode.OK -> response.body<List<UserDto.BookingDto>>() HttpStatusCode.OK ->
response.body<Map<String, List<AvailablePlaceDto>>>()
HttpStatusCode.BadRequest,
HttpStatusCode.Unauthorized -> error(response.bodyAsText())
else -> error(response.bodyAsText()) else -> error(response.bodyAsText())
} }
} }
} }
} }
/**
* Создание нового бронирования через POST /api/<CODE>/book
*
* Тело: { "date": "2025-01-05", "placeID": 1 }
*/
suspend fun book( suspend fun book(
code: String, code: String,
room: String, date: String,
time: String placeId: Int,
): Result<Unit> { ): Result<Unit> {
if (USE_STUB) { if (USE_STUB) {
// Типа всё хорошо // В режиме заглушки считаем, что всё прошло успешно
return Result.success(Unit) return Result.success(Unit)
} }
@@ -129,22 +159,24 @@ object NetworkDataSource {
runCatching { runCatching {
val response = client.post(getUrl(code, Constants.BOOK_URL)) { val response = client.post(getUrl(code, Constants.BOOK_URL)) {
setBody( setBody(
MultiPartFormDataContent( BookRequestDto(
formData { date = date,
append("room", room) placeId = placeId,
append("time", time)
}
) )
) )
} }
when (response.status) { when (response.status) {
HttpStatusCode.Created,
HttpStatusCode.OK -> Unit HttpStatusCode.OK -> Unit
HttpStatusCode.Conflict -> error("Уже забронировано")
HttpStatusCode.BadRequest,
HttpStatusCode.Unauthorized -> error(response.bodyAsText())
else -> 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" "${Constants.HOST}/api/$code$targetUrl"
} }

View File

@@ -3,18 +3,67 @@ package ru.myitschool.work.data.source.dto
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/**
* DTO для ответа GET /api/<CODE>/info
*
* Пример:
* {
* "name":"Иванов Петр Федорович",
* "photoUrl":"https://...",
* "booking":{
* "2025-01-05": {"id":1,"place":"102"},
* "2025-01-06": {"id":2,"place":"209.13"}
* }
* }
*/
@Serializable @Serializable
data class UserDto( data class UserDto(
@SerialName("name") @SerialName("name")
val name: String = "Administrator", val name: String,
@SerialName("photoUrl")
val photoUrl: String? = null,
@SerialName("booking") @SerialName("booking")
val booking: List<BookingDto> val booking: Map<String, BookedPlaceDto> = emptyMap(),
) { )
@Serializable
data class BookingDto( /**
@SerialName("room") * Элемент бронирования в ответе /info и /booking.
val room: String, */
@SerialName("time") @Serializable
val time: String, data class BookedPlaceDto(
) @SerialName("id")
} val id: Int,
@SerialName("place")
val place: String,
)
/**
* DTO для доступных мест в ответе GET /api/<CODE>/booking:
*
* {
* "2025-01-05": [{"id": 1, "place": "102"}, ...]
* }
*/
@Serializable
data class AvailablePlaceDto(
@SerialName("id")
val id: Int,
@SerialName("place")
val place: String,
)
/**
* Тело запроса POST /api/<CODE>/book:
*
* {
* "date": "2025-01-05",
* "placeID": 1
* }
*/
@Serializable
data class BookRequestDto(
@SerialName("date")
val date: String,
@SerialName("placeID")
val placeId: Int,
)

View File

@@ -2,10 +2,16 @@ package ru.myitschool.work.domain.booking
import ru.myitschool.work.data.repo.UserRepository import ru.myitschool.work.data.repo.UserRepository
/**
* Юзкейс для создания бронирования.
*
* @param date дата бронирования в формате yyyy-MM-dd
* @param placeId идентификатор места (placeID из бэкенда)
*/
class BookPlaceUseCase( class BookPlaceUseCase(
private val repository: UserRepository private val repository: UserRepository,
) { ) {
suspend operator fun invoke(room: String, time: String): Result<Unit> { suspend operator fun invoke(date: String, placeId: Int): Result<Unit> {
return repository.book(room, time) return repository.book(date, placeId)
} }
} }

View File

@@ -1,6 +1,7 @@
package ru.myitschool.work.domain.entities package ru.myitschool.work.domain.entities
data class BookingEntity( data class BookingEntity(
val id: Int,
val roomName: String, val roomName: String,
val time: String, val time: String,
) )

View File

@@ -2,5 +2,6 @@ package ru.myitschool.work.domain.entities
data class UserEntity( data class UserEntity(
val name: String, val name: String,
val photoUrl: String?,
val bookings: List<BookingEntity>, val bookings: List<BookingEntity>,
) )

View File

@@ -129,7 +129,7 @@ class BookViewModel : ViewModel() {
val group = groups[index] val group = groups[index]
val places = group.slots.mapIndexed { idx, slot -> val places = group.slots.mapIndexed { idx, slot ->
BookPlaceItem( BookPlaceItem(
id = idx, id = slot.id,
roomName = slot.roomName, roomName = slot.roomName,
time = slot.time, time = slot.time,
isSelected = false, isSelected = false,
@@ -162,7 +162,7 @@ class BookViewModel : ViewModel() {
val place = current.places.firstOrNull { it.id == placeId } ?: return val place = current.places.firstOrNull { it.id == placeId } ?: return
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
bookPlaceUseCase(place.roomName, place.time) bookPlaceUseCase(place.roomName, place.id)
.fold( .fold(
onSuccess = { onSuccess = {
_actionFlow.emit(Action.CloseWithSuccess) _actionFlow.emit(Action.CloseWithSuccess)

View File

@@ -1,5 +1,6 @@
package ru.myitschool.work.ui.screen.main package ru.myitschool.work.ui.screen.main
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed 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.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.SegmentedButtonDefaults.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import coil3.compose.SubcomposeAsyncImage
import ru.myitschool.work.core.TestIds import ru.myitschool.work.core.TestIds
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
@@ -131,7 +139,11 @@ private fun MainDataContent(
.testTag(TestIds.Main.PROFILE_IMAGE), .testTag(TestIds.Main.PROFILE_IMAGE),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text("🙂") UserAvatar(
photoUrl = state.photoUrl,
modifier = Modifier
.size(64.dp)
)
} }
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Text( 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),
)
}
)
}
}

View File

@@ -11,6 +11,7 @@ sealed interface MainState {
data class Data( data class Data(
val name: String, val name: String,
val photoUrl: String?,
val bookings: List<MainBookingItem>, val bookings: List<MainBookingItem>,
) : MainState ) : MainState

View File

@@ -76,6 +76,7 @@ class MainViewModel : ViewModel() {
_uiState.value = MainState.Data( _uiState.value = MainState.Data(
name = user.name, name = user.name,
photoUrl = user.photoUrl,
bookings = bookings, bookings = bookings,
) )
}, },