Before test crutches

This commit is contained in:
2025-11-30 17:36:19 +06:00
parent 371d09995c
commit 01cf2c1439
21 changed files with 696 additions and 12 deletions

View File

@@ -0,0 +1,36 @@
package ru.myitschool.work.core
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
object DateUtils {
private val mainFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("dd.MM.yyyy")
private val bookFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("dd.MM")
/** Пытаемся распарсить дату в нескольких форматах */
fun parseDate(raw: String): LocalDate? {
return runCatching { LocalDate.parse(raw) }.getOrElse {
runCatching { OffsetDateTime.parse(raw).toLocalDate() }.getOrElse {
runCatching {
LocalDate.parse(raw, DateTimeFormatter.ofPattern("dd.MM.yyyy"))
}.getOrNull()
}
}
}
/** Формат для главного экрана: dd.MM.yyyy */
fun formatForMain(raw: String): String {
val date = parseDate(raw) ?: return raw
return date.format(mainFormatter)
}
/** Формат для экрана бронирования: dd.MM */
fun formatForBook(raw: String): String {
val date = parseDate(raw) ?: return raw
return date.format(bookFormatter)
}
}

View File

@@ -1,16 +1,52 @@
package ru.myitschool.work.data.repo
import androidx.datastore.preferences.core.stringPreferencesKey
import ru.myitschool.work.App
import ru.myitschool.work.data.source.NetworkDataSource
object AuthRepository {
private val dataStore get() = App.context.authDataStore
private val KEY_CODE = stringPreferencesKey("auth_code")
private var codeCache: String? = null
suspend fun checkAndSave(text: String): Result<Boolean> {
// suspend fun checkAndSave(text: String): Result<Boolean> {
// return NetworkDataSource.checkAuth(text).onSuccess { success ->
// if (success) {
// codeCache = text
// }
// }
// }
suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success ->
if (success) {
codeCache = text
dataStore.edit { prefs ->
prefs[KEY_CODE] = text
}
}
}
}
/** Сохранённый код (из кэша или DataStore) */
suspend fun getSavedCode(): String? {
codeCache?.let { return it }
val code = dataStore.data
.map { prefs -> prefs[KEY_CODE] }
.first()
codeCache = code
return code
}
/** Полная очистка данных авторизации (для logout) */
suspend fun clear() {
codeCache = null
dataStore.edit { prefs ->
prefs.clear()
}
}
}

View File

@@ -0,0 +1,54 @@
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.UserDto
import ru.myitschool.work.domain.entities.BookingEntity
import ru.myitschool.work.domain.entities.UserEntity
object UserRepository {
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,
)
}
}
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() }
}
}
suspend fun book(room: String, time: String): Result<Unit> {
val code = AuthRepository.getSavedCode()
?: return Result.failure(IllegalStateException("Auth code is not saved"))
return NetworkDataSource.book(code, room, time)
}
private fun UserDto.BookingDto.toDomain(): BookingEntity =
BookingEntity(
roomName = room,
time = time,
)
}

View File

@@ -1,9 +1,14 @@
package ru.myitschool.work.data.source
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
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
@@ -11,6 +16,7 @@ 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.UserDto
object NetworkDataSource {
private val client by lazy {
@@ -38,5 +44,52 @@ object NetworkDataSource {
}
}
suspend fun getUserInfo(code: String): Result<UserDto> = withContext(Dispatchers.IO) {
runCatching {
val response = client.get(getUrl(code, Constants.INFO_URL))
when (response.status) {
HttpStatusCode.OK -> response.body<UserDto>()
else -> error(response.bodyAsText())
}
}
}
suspend fun getAvailableBookings(
code: String
): Result<List<UserDto.BookingDto>> = withContext(Dispatchers.IO) {
runCatching {
val response = client.get(getUrl(code, Constants.BOOKING_URL))
when (response.status) {
HttpStatusCode.OK -> response.body<List<UserDto.BookingDto>>()
else -> error(response.bodyAsText())
}
}
}
suspend fun book(
code: String,
room: String,
time: String
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val response = client.post(getUrl(code, Constants.BOOK_URL)) {
setBody(
MultiPartFormDataContent(
formData {
append("room", room)
append("time", time)
}
)
)
}
when (response.status) {
HttpStatusCode.OK -> Unit
else -> error(response.bodyAsText())
}
}
}
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
}

View File

@@ -0,0 +1,20 @@
package ru.myitschool.work.data.source.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UserDto(
@SerialName("name")
val name: String = "Administrator",
@SerialName("booking")
val booking: List<BookingDto>
) {
@Serializable
data class BookingDto(
@SerialName("room")
val room: String,
@SerialName("time")
val time: String,
)
}

View File

@@ -0,0 +1,11 @@
package ru.myitschool.work.domain.auth
import ru.myitschool.work.data.repo.AuthRepository
class ClearAuthDataUseCase(
private val repository: AuthRepository
) {
suspend operator fun invoke() {
repository.clear()
}
}

View File

@@ -0,0 +1,11 @@
package ru.myitschool.work.domain.auth
import ru.myitschool.work.data.repo.AuthRepository
class GetSavedAuthCodeUseCase(
private val repository: AuthRepository
) {
suspend operator fun invoke(): String? {
return repository.getSavedCode()
}
}

View File

@@ -0,0 +1,11 @@
package ru.myitschool.work.domain.booking
import ru.myitschool.work.data.repo.UserRepository
class BookPlaceUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(room: String, time: String): Result<Unit> {
return repository.book(room, time)
}
}

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.domain.booking
import ru.myitschool.work.data.repo.UserRepository
import ru.myitschool.work.domain.entities.BookingEntity
class GetAvailableBookingsUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(): Result<List<BookingEntity>> {
return repository.getAvailableBookings()
}
}

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
package ru.myitschool.work.domain.user
class GetAvailableBookingsUseCase {
}

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.domain.user
import ru.myitschool.work.data.repo.UserRepository
import ru.myitschool.work.domain.entities.UserEntity
class GetUserInfoUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(): Result<UserEntity> {
return repository.getUserInfo()
}
}

View File

@@ -1,6 +1,11 @@
package ru.myitschool.work.ui.screen.auth
sealed interface AuthState {
object Loading: AuthState
object Data: AuthState
object Loading : AuthState
data class Data(
val code: String = "",
val isButtonEnabled: Boolean = false,
val isErrorVisible: Boolean = false,
) : AuthState
}

View File

@@ -12,32 +12,83 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
import ru.myitschool.work.domain.auth.GetSavedAuthCodeUseCase
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 getSavedAuthCodeUseCase by lazy {
GetSavedAuthCodeUseCase(AuthRepository)
}
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data())
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
// единичное событие навигации на главный экран
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow
init {
// Если код уже сохранён — сразу уходим на главный экран
viewModelScope.launch(Dispatchers.Default) {
val saved = getSavedAuthCodeUseCase()
if (saved != null) {
_actionFlow.emit(Unit)
}
}
}
fun onIntent(intent: AuthIntent) {
when (intent) {
is AuthIntent.TextInput -> {
val newCode = intent.text
val isValid = isValidCode(newCode)
_uiState.update {
AuthState.Data(
code = newCode,
isButtonEnabled = isValid,
isErrorVisible = false,
)
}
}
is AuthIntent.Send -> {
val currentState = _uiState.value
if (currentState !is AuthState.Data) return
val code = currentState.code
if (!isValidCode(code)) return
viewModelScope.launch(Dispatchers.Default) {
_uiState.update { AuthState.Loading }
checkAndSaveAuthCodeUseCase.invoke("9999").fold(
checkAndSaveAuthCodeUseCase.invoke(code).fold(
onSuccess = {
// успешная авторизация → навигация на главный экран
_actionFlow.emit(Unit)
},
onFailure = { error ->
error.printStackTrace()
_actionFlow.emit(Unit)
_uiState.update {
AuthState.Data(
code = code,
isButtonEnabled = true,
isErrorVisible = true,
)
}
}
)
}
}
is AuthIntent.TextInput -> Unit
}
}
private fun isValidCode(text: String): Boolean {
if (text.length != 4) return false
return text.all { ch ->
ch.isDigit() || (ch in 'a'..'z') || (ch in 'A'..'Z')
}
}
}

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.ui.screen.book
sealed interface BookIntent {
data object Load : BookIntent
data object Refresh : BookIntent
data class SelectDate(val index: Int) : BookIntent
data class SelectPlace(val id: Int) : BookIntent
data object Book : BookIntent
}

View File

@@ -0,0 +1,25 @@
package ru.myitschool.work.ui.screen.book
data class BookPlaceItem(
val id: Int,
val roomName: String,
val time: String,
val isSelected: Boolean,
)
sealed interface BookState {
object Loading : BookState
data class Data(
val dates: List<String>,
val selectedDateIndex: Int,
val places: List<BookPlaceItem>,
) : BookState
data class Error(
val message: String,
) : BookState
/** Нет доступных дат — показываем "Всё забронировано" */
object Empty : BookState
}

View File

@@ -0,0 +1,204 @@
package ru.myitschool.work.ui.screen.book
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.time.LocalDate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ru.myitschool.work.core.DateUtils
import ru.myitschool.work.data.repo.UserRepository
import ru.myitschool.work.domain.booking.BookPlaceUseCase
import ru.myitschool.work.domain.booking.GetAvailableBookingsUseCase
import ru.myitschool.work.domain.entities.BookingEntity
class BookViewModel : ViewModel() {
private val getAvailableBookingsUseCase by lazy {
GetAvailableBookingsUseCase(UserRepository)
}
private val bookPlaceUseCase by lazy {
BookPlaceUseCase(UserRepository)
}
private val _uiState = MutableStateFlow<BookState>(BookState.Loading)
val uiState: StateFlow<BookState> = _uiState.asStateFlow()
sealed interface Action {
data object CloseWithSuccess : Action
}
private val _actionFlow = MutableSharedFlow<Action>()
val actionFlow: SharedFlow<Action> = _actionFlow
private var allGroups: List<DateGroup> = emptyList()
private var selectedDateIndex: Int = 0
private var selectedPlaceId: Int? = null
fun onIntent(intent: BookIntent) {
when (intent) {
BookIntent.Load,
BookIntent.Refresh -> {
load()
}
is BookIntent.SelectDate -> {
selectDate(intent.index)
}
is BookIntent.SelectPlace -> {
selectedPlaceId = intent.id
val current = _uiState.value
if (current is BookState.Data) {
val updatedPlaces = current.places.map { item ->
item.copy(isSelected = item.id == intent.id)
}
_uiState.value = current.copy(places = updatedPlaces)
}
}
BookIntent.Book -> {
book()
}
}
}
private fun load() {
viewModelScope.launch(Dispatchers.Default) {
_uiState.value = BookState.Loading
getAvailableBookingsUseCase()
.fold(
onSuccess = { bookings ->
if (bookings.isEmpty()) {
_uiState.value = BookState.Empty
allGroups = emptyList()
selectedPlaceId = null
selectedDateIndex = 0
return@fold
}
allGroups = groupByDate(bookings)
if (allGroups.isEmpty()) {
_uiState.value = BookState.Empty
selectedPlaceId = null
selectedDateIndex = 0
return@fold
}
selectedDateIndex = 0
selectedPlaceId = null
val firstGroup = allGroups[0]
val places = firstGroup.slots.mapIndexed { index, slot ->
BookPlaceItem(
id = index,
roomName = slot.roomName,
time = slot.time,
isSelected = false,
)
}
_uiState.value = BookState.Data(
dates = allGroups.map { it.label },
selectedDateIndex = 0,
places = places,
)
},
onFailure = { error ->
_uiState.value = BookState.Error(
message = error.message ?: "Ошибка загрузки бронирований",
)
}
)
}
}
private fun selectDate(index: Int) {
val groups = allGroups
if (index !in groups.indices) return
selectedDateIndex = index
selectedPlaceId = null
val group = groups[index]
val places = group.slots.mapIndexed { idx, slot ->
BookPlaceItem(
id = idx,
roomName = slot.roomName,
time = slot.time,
isSelected = false,
)
}
val datesLabels = groups.map { it.label }
val current = _uiState.value
_uiState.value = if (current is BookState.Data) {
current.copy(
dates = datesLabels,
selectedDateIndex = index,
places = places,
)
} else {
BookState.Data(
dates = datesLabels,
selectedDateIndex = index,
places = places,
)
}
}
private fun book() {
val current = _uiState.value
if (current !is BookState.Data) return
val placeId = selectedPlaceId ?: return
val place = current.places.firstOrNull { it.id == placeId } ?: return
viewModelScope.launch(Dispatchers.Default) {
bookPlaceUseCase(place.roomName, place.time)
.fold(
onSuccess = {
_actionFlow.emit(Action.CloseWithSuccess)
},
onFailure = { error ->
_uiState.value = BookState.Error(
message = error.message ?: "Ошибка бронирования",
)
}
)
}
}
private data class DateGroup(
val date: LocalDate,
val label: String,
val slots: List<BookingEntity>,
)
private fun groupByDate(bookings: List<BookingEntity>): List<DateGroup> {
val grouped: Map<LocalDate, List<BookingEntity>> =
bookings
.mapNotNull { booking ->
val date = DateUtils.parseDate(booking.time) ?: return@mapNotNull null
date to booking
}
.groupBy({ it.first }, { it.second })
return grouped.entries
.sortedBy { it.key }
.map { (date, slots) ->
DateGroup(
date = date,
label = DateUtils.formatForBook(date.toString()),
slots = slots,
)
}
}
}

View File

@@ -0,0 +1,8 @@
package ru.myitschool.work.ui.screen.main
sealed interface MainIntent {
data object Load : MainIntent
data object Refresh : MainIntent
data object Logout : MainIntent
data object AddBooking : MainIntent
}

View File

@@ -0,0 +1,20 @@
package ru.myitschool.work.ui.screen.main
data class MainBookingItem(
val id: Int,
val dateLabel: String,
val roomName: String,
)
sealed interface MainState {
object Loading : MainState
data class Data(
val name: String,
val bookings: List<MainBookingItem>,
) : MainState
data class Error(
val message: String,
) : MainState
}

View File

@@ -0,0 +1,90 @@
package ru.myitschool.work.ui.screen.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.time.LocalDate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ru.myitschool.work.core.DateUtils
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.data.repo.UserRepository
import ru.myitschool.work.domain.auth.ClearAuthDataUseCase
import ru.myitschool.work.domain.user.GetUserInfoUseCase
class MainViewModel : ViewModel() {
private val getUserInfoUseCase by lazy { GetUserInfoUseCase(UserRepository) }
private val clearAuthDataUseCase by lazy { ClearAuthDataUseCase(AuthRepository) }
private val _uiState = MutableStateFlow<MainState>(MainState.Loading)
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
sealed interface Action {
data object NavigateToAuth : Action
data object NavigateToBooking : Action
}
private val _actionFlow = MutableSharedFlow<Action>()
val actionFlow: SharedFlow<Action> = _actionFlow
fun onIntent(intent: MainIntent) {
when (intent) {
MainIntent.Load,
MainIntent.Refresh -> {
load()
}
MainIntent.Logout -> {
viewModelScope.launch(Dispatchers.Default) {
clearAuthDataUseCase()
_actionFlow.emit(Action.NavigateToAuth)
}
}
MainIntent.AddBooking -> {
viewModelScope.launch {
_actionFlow.emit(Action.NavigateToBooking)
}
}
}
}
private fun load() {
viewModelScope.launch(Dispatchers.Default) {
_uiState.value = MainState.Loading
getUserInfoUseCase()
.fold(
onSuccess = { user ->
val bookings = user.bookings
.sortedWith(
compareBy { booking ->
DateUtils.parseDate(booking.time) ?: LocalDate.MAX
}
)
.mapIndexed { index, booking ->
MainBookingItem(
id = index,
dateLabel = DateUtils.formatForMain(booking.time),
roomName = booking.roomName,
)
}
_uiState.value = MainState.Data(
name = user.name,
bookings = bookings,
)
},
onFailure = { error ->
_uiState.value = MainState.Error(
message = error.message ?: "Ошибка загрузки данных",
)
}
)
}
}
}