forked from Olympic/NTO-2025-Android-TeamTask
No ava
This commit is contained in:
@@ -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/<CODE>/info
|
||||
*/
|
||||
suspend fun getUserInfo(): Result<UserEntity> {
|
||||
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<BookingEntity> { 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/<CODE>/booking
|
||||
*/
|
||||
suspend fun getAvailableBookings(): Result<List<BookingEntity>> {
|
||||
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<Unit> {
|
||||
/**
|
||||
* Создание нового бронирования через POST /api/<CODE>/book
|
||||
*/
|
||||
suspend fun book(date: String, placeId: Int): Result<Unit> {
|
||||
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<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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UserDto.BookingDto> = 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<String, List<AvailablePlaceDto>> = 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/<CODE>/auth
|
||||
*/
|
||||
suspend fun checkAuth(code: String): Result<Boolean> {
|
||||
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/<CODE>/info
|
||||
*/
|
||||
suspend fun getUserInfo(code: String): Result<UserDto> {
|
||||
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<UserDto>()
|
||||
HttpStatusCode.BadRequest,
|
||||
HttpStatusCode.Unauthorized -> error(response.bodyAsText())
|
||||
else -> error(response.bodyAsText())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение доступных слотов бронирования через GET /api/<CODE>/booking
|
||||
*/
|
||||
suspend fun getAvailableBookings(
|
||||
code: String
|
||||
): Result<List<UserDto.BookingDto>> {
|
||||
code: String,
|
||||
): Result<Map<String, List<AvailablePlaceDto>>> {
|
||||
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<List<UserDto.BookingDto>>()
|
||||
HttpStatusCode.OK ->
|
||||
response.body<Map<String, List<AvailablePlaceDto>>>()
|
||||
HttpStatusCode.BadRequest,
|
||||
HttpStatusCode.Unauthorized -> error(response.bodyAsText())
|
||||
else -> error(response.bodyAsText())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание нового бронирования через POST /api/<CODE>/book
|
||||
*
|
||||
* Тело: { "date": "2025-01-05", "placeID": 1 }
|
||||
*/
|
||||
suspend fun book(
|
||||
code: String,
|
||||
room: String,
|
||||
time: String
|
||||
date: String,
|
||||
placeId: Int,
|
||||
): Result<Unit> {
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -3,18 +3,67 @@ package ru.myitschool.work.data.source.dto
|
||||
import kotlinx.serialization.SerialName
|
||||
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
|
||||
data class UserDto(
|
||||
@SerialName("name")
|
||||
val name: String = "Administrator",
|
||||
val name: String,
|
||||
@SerialName("photoUrl")
|
||||
val photoUrl: String? = null,
|
||||
@SerialName("booking")
|
||||
val booking: List<BookingDto>
|
||||
) {
|
||||
@Serializable
|
||||
data class BookingDto(
|
||||
@SerialName("room")
|
||||
val room: String,
|
||||
@SerialName("time")
|
||||
val time: String,
|
||||
)
|
||||
}
|
||||
val booking: Map<String, BookedPlaceDto> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Элемент бронирования в ответе /info и /booking.
|
||||
*/
|
||||
@Serializable
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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<Unit> {
|
||||
return repository.book(room, time)
|
||||
suspend operator fun invoke(date: String, placeId: Int): Result<Unit> {
|
||||
return repository.book(date, placeId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.myitschool.work.domain.entities
|
||||
|
||||
data class BookingEntity(
|
||||
val id: Int,
|
||||
val roomName: String,
|
||||
val time: String,
|
||||
)
|
||||
@@ -2,5 +2,6 @@ package ru.myitschool.work.domain.entities
|
||||
|
||||
data class UserEntity(
|
||||
val name: String,
|
||||
val photoUrl: String?,
|
||||
val bookings: List<BookingEntity>,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ sealed interface MainState {
|
||||
|
||||
data class Data(
|
||||
val name: String,
|
||||
val photoUrl: String?,
|
||||
val bookings: List<MainBookingItem>,
|
||||
) : MainState
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ class MainViewModel : ViewModel() {
|
||||
|
||||
_uiState.value = MainState.Data(
|
||||
name = user.name,
|
||||
photoUrl = user.photoUrl,
|
||||
bookings = bookings,
|
||||
)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user