This commit is contained in:
ilya
2025-12-12 00:20:12 +03:00
parent 2219cdc3df
commit 42c8013d1b
21 changed files with 1065 additions and 22 deletions

View File

@@ -4,7 +4,8 @@ import ru.myitschool.work.data.source.NetworkDataSource
object AuthRepository {
private var codeCache: String? = null
var codeCache: String? = null
private set
suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success ->
@@ -13,4 +14,8 @@ object AuthRepository {
}
}
}
fun clear() {
codeCache = null
}
}

View File

@@ -0,0 +1,8 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.source.NetworkDataSource
class BookingRepository {
suspend fun getBookingInfo() = NetworkDataSource.getBookingInfo()
suspend fun bookPlace(date: String, placeId: Int) = NetworkDataSource.bookPlace(date, placeId)
}

View File

@@ -0,0 +1,7 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.source.NetworkDataSource
class InfoRepository {
suspend fun getInfo() = NetworkDataSource.getInfo()
}

View File

@@ -4,13 +4,37 @@ import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
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.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.myitschool.work.core.Constants
import ru.myitschool.work.data.repo.AuthRepository
data class Info(
val name: String,
val photoUrl: String,
val bookings: List<Booking>
)
data class Booking(
val date: String,
val place: String
)
typealias BookingInfo = JsonObject
object NetworkDataSource {
private val client by lazy {
@@ -33,10 +57,97 @@ object NetworkDataSource {
val response = client.get(getUrl(code, Constants.AUTH_URL))
when (response.status) {
HttpStatusCode.OK -> true
HttpStatusCode.BadRequest -> error("Bad request")
HttpStatusCode.Unauthorized -> error("Unauthorized")
else -> error(response.bodyAsText())
}
}
}
suspend fun getInfo(): Result<Info> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val code = AuthRepository.codeCache ?: error("No auth code")
val response = client.get(getUrl(code, Constants.INFO_URL))
when (response.status) {
HttpStatusCode.OK -> {
val json = response.bodyAsText()
val jsonElement = Json.parseToJsonElement(json)
if (jsonElement !is JsonObject) {
error("Response is not a JSON object")
}
val name = jsonElement["name"]?.jsonPrimitive?.content ?: ""
val photoUrl = jsonElement["photoUrl"]?.jsonPrimitive?.content ?: ""
val bookingsElement = jsonElement["booking"]
val bookings = mutableListOf<Booking>()
if (bookingsElement is JsonObject) {
for ((isoDate, bookingElement) in bookingsElement) {
if (bookingElement is JsonObject) {
val date = formatDate(isoDate)
val place = bookingElement["place"]?.jsonPrimitive?.content ?: ""
bookings.add(Booking(date, place))
}
}
}
Info(name, photoUrl, bookings)
}
HttpStatusCode.BadRequest -> error("Bad request")
HttpStatusCode.Unauthorized -> error("Unauthorized")
else -> error(response.bodyAsText())
}
}
}
private fun formatDate(isoDate: String): String {
return try {
val parts = isoDate.split("-")
"${parts[2]}.${parts[1]}.${parts[0]}"
} catch (e: Exception) {
isoDate
}
}
suspend fun getBookingInfo(): Result<BookingInfo> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val code = AuthRepository.codeCache ?: error("No auth code")
val response = client.get(getUrl(code, Constants.BOOKING_URL))
when (response.status) {
HttpStatusCode.OK -> {
val json = response.bodyAsText()
val jsonElement = Json.parseToJsonElement(json)
if (jsonElement is JsonObject) {
jsonElement
} else {
error("Response is not a JSON object")
}
}
HttpStatusCode.BadRequest -> error("Bad request")
HttpStatusCode.Unauthorized -> error("Unauthorized")
else -> error(response.bodyAsText())
}
}
}
suspend fun bookPlace(date: String, placeId: Int): Result<Unit> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val code = AuthRepository.codeCache ?: error("No auth code")
val response = client.post(getUrl(code, Constants.BOOK_URL)) {
setBody(BookRequest(date, placeId))
contentType(io.ktor.http.ContentType.Application.Json)
}
when (response.status) {
HttpStatusCode.Created -> Unit
HttpStatusCode.Conflict -> error("Already booked")
HttpStatusCode.BadRequest -> error("Bad request")
HttpStatusCode.Unauthorized -> error("Unauthorized")
else -> error(response.bodyAsText())
}
}
}
data class BookRequest(
val date: String,
val placeId: Int
)
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
}

View File

@@ -9,7 +9,7 @@ class CheckAndSaveAuthCodeUseCase(
text: String
): Result<Unit> {
return repository.checkAndSave(text).mapCatching { success ->
if (!success) error("Code is Incorrect")
if (!success) error("Code is incorrect")
}
}
}

View File

@@ -0,0 +1,76 @@
package ru.myitschool.work.domain.booking
import ru.myitschool.work.data.repo.BookingRepository
import ru.myitschool.work.data.source.BookingInfo as SourceBookingInfo
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
data class PlaceInfo(
val id: Int,
val name: String
)
data class BookingInfo(
val availableDates: List<String>,
val availablePlaces: Map<String, List<PlaceInfo>>
)
class GetBookingInfoUseCase(
private val repository: BookingRepository
) {
private fun formatDate(isoDate: String): String {
return try {
val parts = isoDate.split("-")
"${parts[2]}.${parts[1]}.${parts[0]}"
} catch (e: Exception) {
isoDate
}
}
suspend operator fun invoke(): Result<BookingInfo> {
return repository.getBookingInfo().map { sourceInfo ->
val availableDates = mutableListOf<String>()
val availablePlaces = mutableMapOf<String, List<PlaceInfo>>()
// Итерируемся по всем элементам JSON объекта
for ((isoDate, placesElement) in sourceInfo) {
val date = formatDate(isoDate)
availableDates.add(date)
if (placesElement is JsonArray) {
val places = placesElement.mapNotNull { placeElement ->
if (placeElement is JsonObject) {
val id = placeElement["id"]?.jsonPrimitive?.int ?: 0
val placeName = placeElement["place"]?.jsonPrimitive?.content ?: ""
if (id != null && placeName != null) {
PlaceInfo(id = id, name = placeName)
} else {
null
}
} else {
null
}
}
availablePlaces[date] = places
}
}
BookingInfo(
availableDates = availableDates.sorted(),
availablePlaces = availablePlaces
)
}
}
}
class BookPlaceUseCase(
private val repository: BookingRepository
) {
suspend operator fun invoke(date: String, placeId: Int): Result<Unit> {
return repository.bookPlace(date, placeId)
}
}

View File

@@ -0,0 +1,35 @@
package ru.myitschool.work.domain.info
import ru.myitschool.work.data.repo.InfoRepository
import ru.myitschool.work.data.source.Info as SourceInfo
import ru.myitschool.work.data.source.Booking as SourceBooking
data class Info(
val name: String,
val photoUrl: String,
val bookings: List<Booking>
)
data class Booking(
val date: String,
val place: String
)
class GetInfoUseCase(
private val repository: InfoRepository
) {
suspend operator fun invoke(): Result<Info> {
return repository.getInfo().map { sourceInfo ->
Info(
name = sourceInfo.name,
photoUrl = sourceInfo.photoUrl,
bookings = sourceInfo.bookings.map { sourceBooking ->
Booking(
date = sourceBooking.date,
place = sourceBooking.place
)
}
)
}
}
}

View File

@@ -1,3 +1,14 @@
package ru.myitschool.work.ui.nav
sealed interface AppDestination
import androidx.navigation.NavDestination
sealed interface AppDestination {
val route: String
get() = javaClass.simpleName
}
val AppDestination.asRoute: String
get() = route
fun AppDestination.matches(destination: NavDestination?): Boolean =
destination?.route?.startsWith(route) == true

View File

@@ -10,6 +10,12 @@ import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import ru.myitschool.work.ui.screen.AppNavHost
import ru.myitschool.work.ui.theme.WorkTheme
import androidx.navigation.compose.rememberNavController
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.ui.nav.asRoute
class RootActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -21,7 +27,20 @@ class RootActivity : ComponentActivity() {
AppNavHost(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(innerPadding),
navController = rememberNavController().apply {
addOnDestinationChangedListener { controller, destination, arguments ->
if (destination.route == MainScreenDestination.route && AuthRepository.codeCache == null) {
controller.navigate(AuthScreenDestination) {
popUpTo(controller.graph.startDestinationId) { inclusive = true }
}
} else if (destination.route == AuthScreenDestination.route && AuthRepository.codeCache != null) {
controller.navigate(MainScreenDestination) {
popUpTo(controller.graph.startDestinationId) { inclusive = true }
}
}
}
}
)
}
}

View File

@@ -15,6 +15,9 @@ import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.BookScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.screen.auth.AuthScreen
import ru.myitschool.work.ui.screen.book.BookScreen
import ru.myitschool.work.ui.screen.main.MainScreen
@Composable
fun AppNavHost(
@@ -32,18 +35,10 @@ fun AppNavHost(
AuthScreen(navController = navController)
}
composable<MainScreenDestination> {
Box(
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
}
MainScreen(navController = navController)
}
composable<BookScreenDestination> {
Box(
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
}
BookScreen(navController = navController)
}
}
}

View File

@@ -73,7 +73,7 @@ private fun Content(
viewModel: AuthViewModel,
state: AuthState.Data
) {
var inputText by remember { mutableStateOf("") }
var inputText by remember { mutableStateOf(state.inputCode) }
Spacer(modifier = Modifier.size(16.dp))
TextField(
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
@@ -82,15 +82,24 @@ private fun Content(
inputText = it
viewModel.onIntent(AuthIntent.TextInput(it))
},
label = { Text(stringResource(R.string.auth_label)) }
label = { Text(stringResource(R.string.auth_label)) },
isError = state.error != null
)
if (state.error != null) {
Spacer(modifier = Modifier.size(8.dp))
Text(
text = state.error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag(TestIds.Auth.ERROR)
)
}
Spacer(modifier = Modifier.size(16.dp))
Button(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
onClick = {
viewModel.onIntent(AuthIntent.Send(inputText))
},
enabled = true
enabled = inputText.isNotBlank() && inputText.length == 4 && inputText.matches(Regex("^[a-zA-Z0-9]*$"))
) {
Text(stringResource(R.string.auth_sign_in))
}

View File

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

View File

@@ -15,29 +15,62 @@ import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
class AuthViewModel : ViewModel() {
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data)
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 {
// Проверяем, есть ли уже сохранённый код авторизации
if (AuthRepository.codeCache != null) {
viewModelScope.launch {
_actionFlow.emit(Unit)
}
}
}
fun onIntent(intent: AuthIntent) {
when (intent) {
is AuthIntent.Send -> {
val code = intent.text
if (code.isEmpty()) {
_uiState.update { AuthState.Data(error = "Код не может быть пустым") }
return
}
if (code.length != 4) {
_uiState.update { AuthState.Data(error = "Код должен содержать 4 символа") }
return
}
if (!code.matches(Regex("^[a-zA-Z0-9]*$"))) {
_uiState.update { AuthState.Data(error = "Код может содержать только латинские буквы и цифры") }
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()
_uiState.update { AuthState.Data(error = "Неверный код авторизации", inputCode = code) }
_actionFlow.emit(Unit)
}
)
}
}
is AuthIntent.TextInput -> Unit
is AuthIntent.TextInput -> {
_uiState.update { currentState ->
when (currentState) {
is AuthState.Data -> currentState.copy(inputCode = intent.text, error = null)
else -> currentState
}
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
package ru.myitschool.work.ui.screen.book
import ru.myitschool.work.domain.booking.PlaceInfo
sealed interface BookIntent {
data class SelectDate(val date: String) : BookIntent
data class SelectPlace(val place: PlaceInfo) : BookIntent
object Book : BookIntent
object Refresh : BookIntent
object Back : BookIntent
}

View File

@@ -0,0 +1,264 @@
package ru.myitschool.work.ui.screen.book
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
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.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.domain.booking.PlaceInfo
@Composable
fun BookScreen(
viewModel: BookViewModel = viewModel(),
navController: NavController
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.actionFlow.collect {
// При успешном бронировании или возврате - возвращаемся на главный экран
navController.navigateUp()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(all = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
// Кнопка возврата
Button(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Book.BACK_BUTTON),
onClick = {
viewModel.onIntent(BookIntent.Back)
}
) {
Text("Назад")
}
Spacer(modifier = Modifier.size(16.dp))
when (val currentState = state) {
is BookState.Loading -> LoadingContent()
is BookState.Data -> DataContent(currentState, viewModel)
is BookState.Error -> ErrorContent(viewModel)
is BookState.Empty -> EmptyContent()
}
}
}
@Composable
private fun LoadingContent() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(64.dp)
)
}
}
@Composable
private fun DataContent(
state: BookState.Data,
viewModel: BookViewModel
) {
// Вкладки с датами
LazyRow(
modifier = Modifier.fillMaxWidth()
) {
items(state.availableDates.size) { index ->
val date = state.availableDates[index]
DateTab(
date = date,
isSelected = state.selectedDate == date,
position = index
) {
viewModel.onIntent(BookIntent.SelectDate(date))
}
if (index < state.availableDates.size - 1) {
Spacer(modifier = Modifier.size(8.dp))
}
}
}
Spacer(modifier = Modifier.size(24.dp))
// Список мест для выбранной даты
if (state.selectedDate != null) {
val places = state.availablePlaces[state.selectedDate] ?: emptyList()
if (places.isEmpty()) {
Text("Нет доступных мест")
} else {
Text(
text = "Доступные места на ${state.selectedDate}",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.size(16.dp))
Column(
modifier = Modifier.fillMaxWidth()
) {
places.forEachIndexed { index, place ->
PlaceItem(
place = place,
isSelected = state.selectedPlace == place,
position = index
) {
viewModel.onIntent(BookIntent.SelectPlace(place))
}
if (index < places.size - 1) {
Spacer(modifier = Modifier.size(8.dp))
}
}
}
}
}
Spacer(modifier = Modifier.size(24.dp))
// Кнопка бронирования
Button(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Book.BOOK_BUTTON),
onClick = {
viewModel.onIntent(BookIntent.Book)
},
enabled = state.selectedDate != null && state.selectedPlace != null
) {
Text("Забронировать")
}
}
@Composable
private fun DateTab(
date: String,
isSelected: Boolean,
position: Int,
onClick: () -> Unit
) {
Column(
modifier = Modifier
.testTag(TestIds.Book.getIdDateItemByPosition(position))
.selectable(
selected = isSelected,
onClick = onClick,
role = Role.Tab
),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = date,
modifier = Modifier.testTag(TestIds.Book.ITEM_DATE),
style = if (isSelected) {
MaterialTheme.typography.titleMedium
} else {
MaterialTheme.typography.bodyMedium
}
)
}
}
@Composable
private fun PlaceItem(
place: PlaceInfo,
isSelected: Boolean,
position: Int,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Book.getIdPlaceItemByPosition(position))
.selectable(
selected = isSelected,
onClick = onClick
),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = place.name,
modifier = Modifier
.weight(1f)
.testTag(TestIds.Book.ITEM_PLACE_TEXT)
)
RadioButton(
selected = isSelected,
modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_SELECTOR),
onClick = null // null because onClick handled by Modifier.selectable
)
}
}
@Composable
private fun ErrorContent(
viewModel: BookViewModel
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Произошла ошибка при загрузке данных",
modifier = Modifier.testTag(TestIds.Book.ERROR)
)
Spacer(modifier = Modifier.size(16.dp))
Button(
modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON),
onClick = {
viewModel.onIntent(BookIntent.Refresh)
}
) {
Text("Обновить")
}
}
}
@Composable
private fun EmptyContent() {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Всё забронировано",
modifier = Modifier.testTag(TestIds.Book.EMPTY)
)
}
}

View File

@@ -0,0 +1,18 @@
package ru.myitschool.work.ui.screen.book
import ru.myitschool.work.ui.screen.main.Booking
import ru.myitschool.work.domain.booking.PlaceInfo
sealed interface BookState {
object Loading : BookState
data class Data(
val availableDates: List<String>,
val availablePlaces: Map<String, List<PlaceInfo>>,
val selectedDate: String? = null,
val selectedPlace: PlaceInfo? = null
) : BookState
object Error : BookState
object Empty : BookState
}
// Используем тот же Booking из MainState для единообразия

View File

@@ -0,0 +1,124 @@
package ru.myitschool.work.ui.screen.book
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.flow.update
import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.BookingRepository
import ru.myitschool.work.domain.booking.GetBookingInfoUseCase
import ru.myitschool.work.domain.booking.BookPlaceUseCase
import ru.myitschool.work.domain.booking.PlaceInfo
import java.time.LocalDate
class BookViewModel : ViewModel() {
private val getBookingInfoUseCase by lazy { GetBookingInfoUseCase(BookingRepository()) }
private val bookPlaceUseCase by lazy { BookPlaceUseCase(BookingRepository()) }
private val _uiState = MutableStateFlow<BookState>(BookState.Loading)
val uiState: StateFlow<BookState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow
init {
loadBookingInfo()
}
fun onIntent(intent: BookIntent) {
when (intent) {
is BookIntent.SelectDate -> {
_uiState.update { currentState ->
when (currentState) {
is BookState.Data -> {
currentState.copy(selectedDate = intent.date, selectedPlace = null)
}
else -> currentState
}
}
}
is BookIntent.SelectPlace -> {
_uiState.update { currentState ->
when (currentState) {
is BookState.Data -> {
currentState.copy(selectedPlace = intent.place)
}
else -> currentState
}
}
}
BookIntent.Book -> {
bookSelectedPlace()
}
BookIntent.Refresh -> {
loadBookingInfo()
}
BookIntent.Back -> {
viewModelScope.launch {
_actionFlow.emit(Unit)
}
}
}
}
private fun loadBookingInfo() {
viewModelScope.launch(Dispatchers.Default) {
_uiState.update { BookState.Loading }
getBookingInfoUseCase.invoke().fold(
onSuccess = { bookingInfo ->
if (bookingInfo.availableDates.isEmpty()) {
_uiState.update { BookState.Empty }
} else {
_uiState.update {
BookState.Data(
availableDates = bookingInfo.availableDates.sorted(),
availablePlaces = bookingInfo.availablePlaces,
selectedDate = bookingInfo.availableDates.minOrNull()
)
}
}
},
onFailure = { error ->
error.printStackTrace()
_uiState.update { BookState.Error }
}
)
}
}
private fun bookSelectedPlace() {
viewModelScope.launch(Dispatchers.Default) {
val currentState = _uiState.value
if (currentState is BookState.Data &&
currentState.selectedDate != null &&
currentState.selectedPlace != null) {
_uiState.update { BookState.Loading }
// Используем реальный placeId из выбранного места
val placeId = currentState.selectedPlace.id
bookPlaceUseCase.invoke(currentState.selectedDate, placeId).fold(
onSuccess = {
// Успешное бронирование
_actionFlow.emit(Unit)
},
onFailure = { error ->
error.printStackTrace()
// При ошибке не переходим в состояние Error, а остаемся на экране
// и показываем ошибку в интерфейсе
_uiState.update {
currentState.copy(selectedDate = currentState.selectedDate)
}
}
)
}
}
}
}

View File

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

View File

@@ -0,0 +1,223 @@
package ru.myitschool.work.ui.screen.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.BookScreenDestination
@Composable
fun MainScreen(
viewModel: MainViewModel = viewModel(),
navController: NavController
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.actionFlow.collect { action ->
when (action) {
// Переход к экрану бронирования
else -> navController.navigate(BookScreenDestination)
}
}
}
LaunchedEffect(Unit) {
viewModel.onIntent(MainIntent.Refresh)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(all = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
when (val currentState = state) {
is MainState.Loading -> LoadingContent()
is MainState.Data -> DataContent(currentState, viewModel)
is MainState.Error -> ErrorContent(viewModel)
}
}
}
@Composable
private fun LoadingContent() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(64.dp)
)
}
}
@Composable
private fun DataContent(
state: MainState.Data,
viewModel: MainViewModel
) {
// Кнопка выхода
Button(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Main.LOGOUT_BUTTON),
onClick = {
viewModel.onIntent(MainIntent.Logout)
}
) {
Text("Выйти")
}
Spacer(modifier = Modifier.size(16.dp))
// Информация о пользователе
Text(
text = "Привет, ${state.name}!",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier.testTag(TestIds.Main.PROFILE_NAME)
)
Spacer(modifier = Modifier.size(16.dp))
// Фото пользователя
// В реальном приложении здесь будет загрузка изображения
Box(
modifier = Modifier
.size(120.dp)
.testTag(TestIds.Main.PROFILE_IMAGE),
contentAlignment = Alignment.Center
) {
Text("Фото")
}
Spacer(modifier = Modifier.size(24.dp))
// Кнопка обновления
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Текущие бронирования",
style = MaterialTheme.typography.titleMedium
)
Button(
modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON),
onClick = {
viewModel.onIntent(MainIntent.Refresh)
}
) {
Text("Обновить")
}
}
Spacer(modifier = Modifier.size(16.dp))
// Список бронирований
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
items(state.bookings.size) { index ->
val booking = state.bookings[index]
BookingItem(
booking = booking,
position = index
)
if (index < state.bookings.size - 1) {
Spacer(modifier = Modifier.size(8.dp))
}
}
}
Spacer(modifier = Modifier.size(24.dp))
// Кнопка добавления бронирования
Button(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Main.ADD_BUTTON),
onClick = {
viewModel.onIntent(MainIntent.AddBooking)
}
) {
Text("Забронировать место")
}
}
@Composable
private fun BookingItem(
booking: Booking,
position: Int
) {
Column(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Main.getIdItemByPosition(position))
) {
Text(
text = booking.date,
modifier = Modifier.testTag(TestIds.Main.ITEM_DATE)
)
Spacer(modifier = Modifier.size(4.dp))
Text(
text = booking.place,
modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE)
)
}
}
@Composable
private fun ErrorContent(
viewModel: MainViewModel
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Произошла ошибка при загрузке данных",
modifier = Modifier.testTag(TestIds.Main.ERROR)
)
Spacer(modifier = Modifier.size(16.dp))
Button(
modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON),
onClick = {
viewModel.onIntent(MainIntent.Refresh)
}
) {
Text("Обновить")
}
}
}
}

View File

@@ -0,0 +1,16 @@
package ru.myitschool.work.ui.screen.main
sealed interface MainState {
object Loading : MainState
data class Data(
val name: String,
val photoUrl: String,
val bookings: List<Booking>
) : MainState
object Error : MainState
}
data class Booking(
val date: String,
val place: String
)

View File

@@ -0,0 +1,68 @@
package ru.myitschool.work.ui.screen.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.flow.update
import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.InfoRepository
import ru.myitschool.work.domain.info.GetInfoUseCase
import ru.myitschool.work.data.repo.AuthRepository
class MainViewModel : ViewModel() {
private val getInfoUseCase by lazy { GetInfoUseCase(InfoRepository()) }
private val _uiState = MutableStateFlow<MainState>(MainState.Loading)
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow
init {
loadInfo()
}
fun onIntent(intent: MainIntent) {
when (intent) {
MainIntent.Refresh -> loadInfo()
MainIntent.Logout -> {
// Очистка данных авторизации
AuthRepository.clear()
// Навигация на экран авторизации
viewModelScope.launch {
_actionFlow.emit(Unit)
}
}
MainIntent.AddBooking -> {
viewModelScope.launch {
_actionFlow.emit(Unit)
}
}
}
}
private fun loadInfo() {
viewModelScope.launch(Dispatchers.Default) {
_uiState.update { MainState.Loading }
getInfoUseCase.invoke().fold(
onSuccess = { info ->
_uiState.update {
MainState.Data(
name = info.name,
photoUrl = info.photoUrl,
bookings = info.bookings.sortedBy { it.date }.map { Booking(it.date, it.place) }
)
}
},
onFailure = { error ->
error.printStackTrace()
_uiState.update { MainState.Error }
}
)
}
}
}