Compare commits

..

2 Commits

Author SHA1 Message Date
0a5803765e Merge remote-tracking branch 'origin/main'
# Conflicts:
#	app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt
2025-12-11 22:19:47 +03:00
9e6ef6062f feat: add query's to backend 2025-12-11 22:14:19 +03:00
10 changed files with 375 additions and 84 deletions

View File

@@ -36,6 +36,9 @@ android {
dependencies {
implementation("androidx.compose.material3:material3:1.4.0")
implementation("androidx.compose.runtime:runtime:1.10.0")
implementation("androidx.compose.foundation:foundation-layout:1.10.0")
implementation("androidx.compose.foundation:foundation:1.10.0")
defaultComposeLibrary()
implementation("androidx.datastore:datastore-preferences:1.1.7")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")

View File

@@ -4,6 +4,7 @@ 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.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Dispatchers
@@ -28,8 +29,16 @@ object NetworkDataSource {
}
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
val url = getUrl(code, Constants.AUTH_URL)
println("➡ Request URL: $url")
runCatching {
val response = client.get(getUrl(code, Constants.AUTH_URL))
val response = client.get(url)
println("⬅ Response status: ${response.status}")
println("⬅ Response body: ${response.bodyAsText()}")
when (response.status) {
HttpStatusCode.OK -> true
HttpStatusCode.Unauthorized -> error("Код не существует")
@@ -38,8 +47,9 @@ object NetworkDataSource {
}
}.mapCatching { success ->
success
}.recoverCatching { _ ->
throw Exception("Не удалось соединиться с сервером")
}.recoverCatching { e ->
println("❌ Error: ${e.message}")
throw Exception(e.message)
}
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@@ -16,6 +17,7 @@ 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.book.BookViewModel
import ru.myitschool.work.ui.screen.main.MainScreen
@Composable
@@ -28,8 +30,8 @@ fun AppNavHost(
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
navController = navController,
// startDestination = AuthScreenDestination,
startDestination = MainScreenDestination,
startDestination = AuthScreenDestination,
// startDestination = MainScreenDestination,
) {
composable<AuthScreenDestination> {
AuthScreen(navController = navController)
@@ -38,7 +40,18 @@ fun AppNavHost(
MainScreen(navController = navController)
}
composable<BookScreenDestination> {
BookScreen(navController = navController)
val vm: BookViewModel = viewModel()
BookScreen(
vm = vm,
onBack = { navController.popBackStack() },
onSuccess = {
navController.popBackStack()
navController.navigate(MainScreenDestination) {
launchSingleTop = true
}
}
)
}
}
}

View File

@@ -121,13 +121,15 @@ private fun Content(
enter = fadeIn(),
exit = fadeOut()
) {
(state as? AuthState.Error)?.let { errorState ->
Text(
text = (state as AuthState.Error).message,
text = errorState.message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
}
}
Spacer(modifier = Modifier.size(16.dp))

View File

@@ -1,6 +1,12 @@
package ru.myitschool.work.ui.screen.book
import java.time.LocalDate
sealed interface BookIntent {
data class Send(val text: String): BookIntent
data class TextInput(val text: String): BookIntent
object Load : BookIntent
data class SelectDate(val date: LocalDate) : BookIntent
data class SelectPlace(val placeId: String) : BookIntent
object ConfirmBooking : BookIntent
object Refresh : BookIntent
object Back : BookIntent
}

View File

@@ -5,40 +5,32 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.ui.nav.MainScreenDestination
@Composable
fun BookScreen(
viewModel: BookViewModel = viewModel(),
navController: NavController
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.actionFlow.collect {
navController.navigate(MainScreenDestination)
}
}
/*
Экран бронирования
На данном экране необходимо вывести возможные даты и места для бронирования.
Элементы, которые должны присутствовать на экране:
Группа вкладок. Каждая вкладка (book_date_pos_{индекс}) содержит текстовое поле (book_date) с датой бронирования в формате dd.MM.
В зависимости от выбранной даты необходимо отобразить группу с единственным выбором (пояснения на изображении ниже). Каждый элемент группы (book_place_pos_{индекс}) кликабелен и содержит:
Текстовое поле (book_place_text), в котором содержится место доступное для брони.
Селектор (book_place_selector), который отображает, выбран элемент или нет. У данного элемента обязательно наличие: (Modifier.selectable)
Кнопка (book_book_button) для бронирования.
Кнопка (book_back_button) для возвращения на предыдущий экран.
По умолчанию неотображаемое текстовое поле с ошибкой (book_error). Отметим, что это поле не должно рендериться.
@@ -53,19 +45,168 @@ fun BookScreen(
При успешном бронировании нужно закрыть текущий экран и вернуться на главный, обновив его.
*/
Column(
modifier = Modifier
.fillMaxSize()
.padding(all = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.Composable
import androidx.compose.ui.draw.clip
import ru.myitschool.work.ui.screen.auth.AuthIntent
import java.time.format.DateTimeFormatter
Text(
text = "страница бронирования...",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
@Composable
fun BookScreen(
vm: BookViewModel,
onBack: () -> Unit,
onSuccess: () -> Unit
) {
val state by vm.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
vm.accept(BookIntent.Load)
}
LaunchedEffect(state.bookingSuccess) {
if (state.bookingSuccess) {
onSuccess()
}
}
when {
state.loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
state.error -> ErrorBlock(
onRefresh = { vm.accept(BookIntent.Refresh) },
onBack = { onBack() }
)
state.isEmpty -> EmptyBlock(
onBack = { onBack() }
)
else -> ContentBlock(
state = state,
onDateClick = { vm.accept(BookIntent.SelectDate(it)) },
onPlaceClick = { vm.accept(BookIntent.SelectPlace(it)) },
onConfirm = { vm.accept(BookIntent.ConfirmBooking) },
onBack = { onBack() }
)
}
}
@Composable
private fun ContentBlock(
state: BookState,
onDateClick: (java.time.LocalDate) -> Unit,
onPlaceClick: (String) -> Unit,
onConfirm: () -> Unit,
onBack: () -> Unit
) {
val f = DateTimeFormatter.ofPattern("dd.MM")
Column(Modifier.fillMaxSize().padding(16.dp)) {
PrimaryScrollableTabRow(
selectedTabIndex = state.dates.indexOfFirst { it.date == state.selectedDate }
) {
state.dates.forEachIndexed { index, item ->
Tab(
selected = state.selectedDate == item.date,
onClick = { onDateClick(item.date) },
text = { Text(item.date.format(f)) }
)
}
}
Spacer(Modifier.height(16.dp))
// группа единственного выбора
Column(Modifier.selectableGroup()) {
state.availablePlaces.forEach { place ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.selectable(
selected = state.selectedPlaceId == place.id,
onClick = { onPlaceClick(place.id) }
)
.padding(horizontal = 8.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier
.padding(horizontal = 4.dp, vertical = 0.dp),
text = place.title
)
RadioButton(
selected = state.selectedPlaceId == place.id,
onClick = { onPlaceClick(place.id) }
)
}
}
}
Spacer(Modifier.height(24.dp))
Button(
enabled = state.selectedPlaceId != null,
onClick = onConfirm,
modifier = Modifier.fillMaxWidth()
) {
Text("Бронировать")
}
Spacer(Modifier.height(12.dp))
TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
Text("Назад")
}
}
}
@Composable
fun ErrorBlock(onRefresh: () -> Unit, onBack: () -> Unit) {
Column(
Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center
) {
Text("Ошибка загрузки", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(12.dp))
Button(onClick = onRefresh, modifier = Modifier.fillMaxWidth()) {
Text("Обновить")
}
Spacer(Modifier.height(12.dp))
TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
Text("Назад")
}
}
}
@Composable
fun EmptyBlock(onBack: () -> Unit) {
Column(
Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center
) {
Text("Всё забронировано", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(16.dp))
TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
Text("Назад")
}
}
}

View File

@@ -1,5 +1,17 @@
package ru.myitschool.work.ui.screen.book
import java.time.LocalDate
sealed interface BookState {
object Data: BookState
data class BookState(
val loading: Boolean = true,
val error: Boolean = false,
val dates: List<BookingDate> = emptyList(),
val selectedDate: LocalDate? = null,
val selectedPlaceId: String? = null,
val bookingSuccess: Boolean = false
) {
val availablePlaces: List<BookingPlace>
get() = dates.firstOrNull { it.date == selectedDate }?.places ?: emptyList()
val isEmpty: Boolean
get() = dates.isEmpty()
}

View File

@@ -1,39 +1,141 @@
package ru.myitschool.work.ui.screen.book
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
import java.time.LocalDate
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class BookingDate(
val date: LocalDate,
val places: List<BookingPlace>
)
data class BookingPlace(
val id: String,
val title: String
)
class BookViewModel : ViewModel() {
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
private val _uiState = MutableStateFlow<BookState>(BookState.Data)
val uiState: StateFlow<BookState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow
private val _state = MutableStateFlow(BookState())
val state = _state.asStateFlow()
fun onIntent(intent: BookIntent) {
// when (intent) {
// is MainIntent.Send -> {
// viewModelScope.launch(Dispatchers.Default) {
// _uiState.update { MainState.Loading }
// checkAndSaveAuthCodeUseCase.invoke("9999").fold(
// onSuccess = {
// _actionFlow.emit(Unit)
// },
// onFailure = { error ->
// error.printStackTrace()
// _actionFlow.emit(Unit)
// }
// )
// }
// }
// is MainIntent.TextInput -> Unit
// }
fun accept(intent: BookIntent) {
when (intent) {
is BookIntent.Load -> load()
is BookIntent.SelectDate -> selectDate(intent.date)
is BookIntent.SelectPlace -> selectPlace(intent.placeId)
is BookIntent.ConfirmBooking -> book()
is BookIntent.Refresh -> load()
is BookIntent.Back -> {} // обработка навигации снаружи
}
}
private fun load() {
viewModelScope.launch {
_state.value = BookState(loading = true)
delay(400) // имитация ожидания API
val result = fakeApi()
if (result.isEmpty()) {
_state.value = BookState(
dates = emptyList(),
loading = false
)
return@launch
}
val earliest = result.minBy { it.date }
_state.value = BookState(
dates = result,
selectedDate = earliest.date,
loading = false
)
}
}
private fun selectDate(date: LocalDate) {
_state.value = _state.value.copy(
selectedDate = date,
selectedPlaceId = null
)
}
private fun selectPlace(placeId: String) {
_state.value = _state.value.copy(selectedPlaceId = placeId)
}
private fun book() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true)
delay(300) // фейковое бронирование
_state.value = _state.value.copy(
bookingSuccess = true,
loading = false
)
}
}
private fun fakeApi(): List<BookingDate> {
return listOf(
BookingDate(
LocalDate.of(2025, 1, 5),
listOf(
BookingPlace("1", "Окно №1"),
BookingPlace("2", "Окно №3")
)
),
BookingDate(
LocalDate.of(2025, 1, 6),
listOf(
BookingPlace("3", "Окно №2")
)
),
BookingDate(
LocalDate.of(2025, 1, 9),
emptyList() // будет скрыто
),
BookingDate(
LocalDate.of(2025, 1, 10),
listOf(
BookingPlace("3", "Окно №2")
)
),
BookingDate(
LocalDate.of(2025, 1, 11),
listOf(
BookingPlace("3", "Окно №2")
)
),
BookingDate(
LocalDate.of(2025, 1, 12),
listOf(
BookingPlace("3", "Окно №2")
)
),
BookingDate(
LocalDate.of(2025, 1, 13),
listOf(
BookingPlace("3", "Окно №2")
)
),
).filter { it.places.isNotEmpty() }
}
}

View File

@@ -37,9 +37,9 @@ import androidx.navigation.NavController
import coil3.compose.AsyncImage
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination
@Composable
fun MainScreen(
@@ -179,8 +179,8 @@ fun MainScreen(
date = booking.date,
place = booking.place,
modifier = Modifier.padding(
top = if (index == 0) 0.dp else 8.dp,
bottom = if (index == bookings.lastIndex) 0.dp else 8.dp
top = if (index == 0) 0.dp else 4.dp,
bottom = if (index == bookings.lastIndex) 0.dp else 4.dp
)
)
}

View File

@@ -1,5 +1,7 @@
package ru.myitschool.work.ui.screen.main
sealed interface MainState {
object Data: MainState
data object Data : MainState
data object Loading : MainState
data class Error(val message: String) : MainState
}