diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a5ccda1..57eaa73 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -35,6 +35,10 @@ 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")
@@ -48,4 +52,5 @@ dependencies {
implementation("io.ktor:ktor-client-content-negotiation:$ktor")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
+ implementation("io.coil-kt:coil-compose:2.6.0")
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a2c02bd..0eb09d5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -19,10 +19,9 @@
android:name=".ui.root.RootActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
- android:label="@string/title_activity_root">
+ android:theme="@style/Theme.Work">
-
diff --git a/app/src/main/java/ru/myitschool/work/core/Constants.kt b/app/src/main/java/ru/myitschool/work/core/Constants.kt
index a8b7cc5..13f7e39 100644
--- a/app/src/main/java/ru/myitschool/work/core/Constants.kt
+++ b/app/src/main/java/ru/myitschool/work/core/Constants.kt
@@ -1,7 +1,7 @@
package ru.myitschool.work.core
object Constants {
- const val HOST = "http://10.0.2.2:8080"
+ const val HOST = "http://192.168.0.111:8080"
const val AUTH_URL = "/auth"
const val INFO_URL = "/info"
const val BOOKING_URL = "/booking"
diff --git a/app/src/main/java/ru/myitschool/work/data/models/BookingInfo.kt b/app/src/main/java/ru/myitschool/work/data/models/BookingInfo.kt
new file mode 100644
index 0000000..d915ec1
--- /dev/null
+++ b/app/src/main/java/ru/myitschool/work/data/models/BookingInfo.kt
@@ -0,0 +1,9 @@
+package ru.myitschool.work.data.models
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BookingInfo(
+ val id: Int,
+ val place: String
+)
diff --git a/app/src/main/java/ru/myitschool/work/data/models/UserInfo.kt b/app/src/main/java/ru/myitschool/work/data/models/UserInfo.kt
new file mode 100644
index 0000000..699b399
--- /dev/null
+++ b/app/src/main/java/ru/myitschool/work/data/models/UserInfo.kt
@@ -0,0 +1,10 @@
+package ru.myitschool.work.data.models
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class UserInfo(
+ val name: String,
+ val photoUrl: String,
+ val booking: Map
+)
diff --git a/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt
index 3ef28f1..57bbc44 100644
--- a/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt
+++ b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt
@@ -5,6 +5,14 @@ import ru.myitschool.work.data.source.NetworkDataSource
object AuthRepository {
private var codeCache: String? = null
+ fun clearCode() {
+ codeCache = null
+ }
+
+ fun getCode(): String? {
+ return codeCache
+ }
+
suspend fun checkAndSave(text: String): Result {
return NetworkDataSource.checkAuth(text).onSuccess { success ->
diff --git a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt
index fbdfef5..a4afdc5 100644
--- a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt
+++ b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt
@@ -11,32 +11,75 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import ru.myitschool.work.core.Constants
+import ru.myitschool.work.data.models.UserInfo
object NetworkDataSource {
+ private val json = Json {
+ isLenient = true
+ ignoreUnknownKeys = true
+ explicitNulls = true
+ encodeDefaults = true
+ }
+
private val client by lazy {
HttpClient(CIO) {
install(ContentNegotiation) {
- json(
- Json {
- isLenient = true
- ignoreUnknownKeys = true
- explicitNulls = true
- encodeDefaults = true
- }
- )
+ json(json)
}
}
}
+ suspend fun getUserInfo(code: String): Result = withContext(Dispatchers.IO) {
+ val url = getUrl(code, "/info")
+
+ println("➡ Request URL: $url")
+
+ runCatching {
+ val response = client.get(url)
+
+ println("⬅ Response status: ${response.status}")
+
+ when (response.status) {
+ HttpStatusCode.OK -> {
+ val body = response.bodyAsText()
+ println("⬅ Response body: $body")
+ json.decodeFromString(body)
+ }
+ HttpStatusCode.Unauthorized -> error("Код не существует")
+ HttpStatusCode.BadRequest -> error("Что-то пошло не так")
+ else -> error("Неизвестная ошибка: ${response.status}")
+ }
+ }.recoverCatching { e ->
+ println("❌ Error: ${e.message}")
+ throw Exception(e.message)
+ }
+ }
+
suspend fun checkAuth(code: String): Result = withContext(Dispatchers.IO) {
- return@withContext runCatching {
- val response = client.get(getUrl(code, Constants.AUTH_URL))
+ val url = getUrl(code, Constants.AUTH_URL)
+
+ println("➡ Request URL: $url")
+
+ runCatching {
+ val response = client.get(url)
+
+ println("⬅ Response status: ${response.status}")
+ println("⬅ Response body: ${response.bodyAsText()}")
+
when (response.status) {
HttpStatusCode.OK -> true
- else -> error(response.bodyAsText())
+ HttpStatusCode.Unauthorized -> error("Код не существует")
+ HttpStatusCode.BadRequest -> error("Что-то пошло не так")
+ else -> error("Неизвестная ошибка: ${response.status}")
}
+ }.mapCatching { success ->
+ success
+ }.recoverCatching { e ->
+ println("❌ Error: ${e.message}")
+ throw Exception(e.message)
}
}
+
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/ru/myitschool/work/domain/GetUserInfoUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/GetUserInfoUseCase.kt
new file mode 100644
index 0000000..536c16d
--- /dev/null
+++ b/app/src/main/java/ru/myitschool/work/domain/GetUserInfoUseCase.kt
@@ -0,0 +1,11 @@
+package ru.myitschool.work.domain
+
+import ru.myitschool.work.data.repo.AuthRepository
+import ru.myitschool.work.data.source.NetworkDataSource
+
+class GetUserInfoUseCase {
+ suspend operator fun invoke() = runCatching {
+ val code = AuthRepository.getCode() ?: error("Вы не авторизованы")
+ NetworkDataSource.getUserInfo(code).getOrThrow()
+ }
+}
diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt b/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt
index 01b0f32..34e7d6b 100644
--- a/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt
+++ b/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt
@@ -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
@@ -15,6 +16,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.book.BookViewModel
+import ru.myitschool.work.ui.screen.main.MainScreen
@Composable
fun AppNavHost(
@@ -27,23 +31,27 @@ fun AppNavHost(
exitTransition = { ExitTransition.None },
navController = navController,
startDestination = AuthScreenDestination,
+// startDestination = MainScreenDestination,
) {
composable {
AuthScreen(navController = navController)
}
composable {
- Box(
- contentAlignment = Alignment.Center
- ) {
- Text(text = "Hello")
- }
+ MainScreen(navController = navController)
}
composable {
- Box(
- contentAlignment = Alignment.Center
- ) {
- Text(text = "Hello")
- }
+ val vm: BookViewModel = viewModel()
+
+ BookScreen(
+ vm = vm,
+ onBack = { navController.popBackStack() },
+ onSuccess = {
+ navController.popBackStack()
+ navController.navigate(MainScreenDestination) {
+ launchSingleTop = true
+ }
+ }
+ )
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt
index f99978e..4e5eaf1 100644
--- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt
+++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt
@@ -31,6 +31,13 @@ import androidx.navigation.NavController
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.MainScreenDestination
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
@Composable
fun AuthScreen(
@@ -48,49 +55,92 @@ fun AuthScreen(
Column(
modifier = Modifier
.fillMaxSize()
- .padding(all = 24.dp),
+ .imePadding()
+ .padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
- Text(
- text = stringResource(R.string.auth_title),
- style = MaterialTheme.typography.headlineSmall,
- textAlign = TextAlign.Center
- )
- when (val currentState = state) {
- is AuthState.Data -> Content(viewModel, currentState)
+ when (state) {
is AuthState.Loading -> {
- CircularProgressIndicator(
- modifier = Modifier.size(64.dp)
- )
+ CircularProgressIndicator(modifier = Modifier.size(64.dp))
+ }
+
+ else -> {
+ Content(viewModel, state)
}
}
}
}
+
@Composable
private fun Content(
viewModel: AuthViewModel,
- state: AuthState.Data
+ state: AuthState
) {
var inputText by remember { mutableStateOf("") }
+
+ val isButtonEnabled =
+ inputText.length == 4 && inputText.all { it.isLetterOrDigit() }
+
+ Text(
+ text = stringResource(R.string.auth_title),
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center
+ )
+
Spacer(modifier = Modifier.size(16.dp))
+
TextField(
- modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestIds.Auth.CODE_INPUT),
value = inputText,
onValueChange = {
- inputText = it
+ if (it.length <= 4 && it.all { ch -> ch.isLetterOrDigit() }) {
+ inputText = it
+ }
viewModel.onIntent(AuthIntent.TextInput(it))
},
+ keyboardOptions = KeyboardOptions.Default.copy(
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ viewModel.onIntent(AuthIntent.Send(inputText))
+ }
+ ),
+ singleLine = true,
label = { Text(stringResource(R.string.auth_label)) }
)
+
+ Spacer(modifier = Modifier.size(12.dp))
+
+ AnimatedVisibility(
+ visible = state is AuthState.Error,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ (state as? AuthState.Error)?.let { errorState ->
+ Text(
+ text = errorState.message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+
Spacer(modifier = Modifier.size(16.dp))
+
Button(
- modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestIds.Auth.SIGN_BUTTON),
onClick = {
viewModel.onIntent(AuthIntent.Send(inputText))
},
- enabled = true
+ enabled = isButtonEnabled
) {
Text(stringResource(R.string.auth_sign_in))
}
diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt
index a06ba76..a4c1793 100644
--- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt
+++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt
@@ -1,6 +1,7 @@
package ru.myitschool.work.ui.screen.auth
-sealed interface AuthState {
- object Loading: AuthState
- object Data: AuthState
+sealed class AuthState {
+ data object Data : AuthState()
+ data object Loading : AuthState()
+ data class Error(val message: String) : AuthState()
}
\ No newline at end of file
diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt
index 3153640..289896d 100644
--- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt
+++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt
@@ -13,31 +13,39 @@ import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
-class AuthViewModel : ViewModel() {
- private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
+class AuthViewModel() : ViewModel() {
+ private val checkAndSaveAuthCodeUseCase by lazy {
+ CheckAndSaveAuthCodeUseCase(AuthRepository)
+ }
+
private val _uiState = MutableStateFlow(AuthState.Data)
val uiState: StateFlow = _uiState.asStateFlow()
- private val _actionFlow: MutableSharedFlow = MutableSharedFlow()
+ private val _actionFlow = MutableSharedFlow()
val actionFlow: SharedFlow = _actionFlow
fun onIntent(intent: AuthIntent) {
when (intent) {
+
is AuthIntent.Send -> {
- viewModelScope.launch(Dispatchers.Default) {
- _uiState.update { AuthState.Loading }
- checkAndSaveAuthCodeUseCase.invoke("9999").fold(
+ viewModelScope.launch(Dispatchers.IO) {
+ _uiState.value = AuthState.Loading
+
+ checkAndSaveAuthCodeUseCase.invoke(intent.text).fold(
onSuccess = {
_actionFlow.emit(Unit)
},
- onFailure = { error ->
- error.printStackTrace()
- _actionFlow.emit(Unit)
+ onFailure = { throwable ->
+ val errorMessage = throwable.message ?: "Неизвестная ошибка"
+ _uiState.value = AuthState.Error(errorMessage)
}
)
}
}
- is AuthIntent.TextInput -> Unit
+
+ is AuthIntent.TextInput -> {
+ _uiState.value = AuthState.Data
+ }
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt
new file mode 100644
index 0000000..55a6a3c
--- /dev/null
+++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt
@@ -0,0 +1,12 @@
+package ru.myitschool.work.ui.screen.book
+
+import java.time.LocalDate
+
+sealed interface 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
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt
new file mode 100644
index 0000000..2043de2
--- /dev/null
+++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt
@@ -0,0 +1,212 @@
+package ru.myitschool.work.ui.screen.book
+
+import androidx.compose.foundation.layout.Arrangement
+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.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.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
+
+/*
+Экран бронирования
+На данном экране необходимо вывести возможные даты и места для бронирования.
+
+Элементы, которые должны присутствовать на экране:
+Группа вкладок. Каждая вкладка (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). Отметим, что это поле не должно рендериться.
+По умолчанию неотображаемая кнопка обновить (book_refresh_button).
+По умолчанию неотображаемый текст “Всё забронировано” (book_empty).
+
+Требования к компонентам:
+По умолчанию выбирается самая ранняя доступная дата (например, из набора "5 января", "6 января", "9 января" будет показана дата "5 января").
+Список дат отсортирован по возрастанию. Даты без доступных мест для бронирования необходимо не отображать.
+Если нет доступных для бронирования дат, необходимо скрыть все элементы, кроме элементов из п. 4 и 7.
+В случае ошибки при получении данных о доступном бронировании в запросе api//booking, необходимо отобразить элемент из п. 4, 5 и 6 с возможностью обновить данные.
+При успешном бронировании нужно закрыть текущий экран и вернуться на главный, обновив его.
+ */
+
+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
+
+@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("Назад")
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt
new file mode 100644
index 0000000..e0c3e9e
--- /dev/null
+++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt
@@ -0,0 +1,17 @@
+package ru.myitschool.work.ui.screen.book
+import java.time.LocalDate
+
+data class BookState(
+ val loading: Boolean = true,
+ val error: Boolean = false,
+ val dates: List = emptyList(),
+ val selectedDate: LocalDate? = null,
+ val selectedPlaceId: String? = null,
+ val bookingSuccess: Boolean = false
+) {
+ val availablePlaces: List
+ get() = dates.firstOrNull { it.date == selectedDate }?.places ?: emptyList()
+
+ val isEmpty: Boolean
+ get() = dates.isEmpty()
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt
new file mode 100644
index 0000000..2128950
--- /dev/null
+++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt
@@ -0,0 +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
+)
+
+data class BookingPlace(
+ val id: String,
+ val title: String
+)
+
+class BookViewModel : ViewModel() {
+
+ private val _state = MutableStateFlow(BookState())
+ val state = _state.asStateFlow()
+
+ 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 {
+ 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() }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt
new file mode 100644
index 0000000..30678a3
--- /dev/null
+++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt
@@ -0,0 +1,6 @@
+package ru.myitschool.work.ui.screen.main
+
+sealed interface MainIntent {
+ data class Send(val text: String): MainIntent
+ data class TextInput(val text: String): MainIntent
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt
new file mode 100644
index 0000000..eba5738
--- /dev/null
+++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt
@@ -0,0 +1,238 @@
+package ru.myitschool.work.ui.screen.main
+
+import androidx.compose.foundation.layout.Arrangement
+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.imePadding
+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.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+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.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 coil3.compose.AsyncImage
+import ru.myitschool.work.R
+import ru.myitschool.work.core.TestIds
+import ru.myitschool.work.data.models.UserInfo
+import ru.myitschool.work.data.repo.AuthRepository
+import ru.myitschool.work.ui.nav.AuthScreenDestination
+import ru.myitschool.work.ui.nav.BookScreenDestination
+
+@Composable
+fun MainScreen(
+ viewModel: MainViewModel = viewModel(),
+ navController: NavController
+) {
+ val state by viewModel.uiState.collectAsState()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .imePadding()
+ .padding(20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Top
+ ) {
+
+ when (val s = state) {
+ is MainState.Error -> {
+ Text(
+ text = s.message,
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestIds.Main.ERROR)
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Button(
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestIds.Main.REFRESH_BUTTON),
+ onClick = viewModel::onRefresh
+ ) {
+ Text(stringResource(R.string.refresh))
+ }
+ }
+ is MainState.Loading -> {
+ CircularProgressIndicator()
+ }
+ is MainState.Data -> {
+ MainContent(
+ userInfo = s.userInfo,
+ navController = navController,
+ onRefresh = viewModel::onRefresh
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun MainContent(
+ userInfo: UserInfo,
+ navController: NavController,
+ onRefresh: () -> Unit
+) {
+ val bookings = remember {
+ userInfo.booking.entries.sortedBy { it.key }.map { Booking(it.key, it.value.place) }
+ }
+
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Button(
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestIds.Main.REFRESH_BUTTON),
+ onClick = onRefresh
+ ) {
+ Text(stringResource(R.string.refresh))
+ }
+
+ Spacer(modifier = Modifier.size(8.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ AsyncImage(
+ model = userInfo.photoUrl,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(60.dp)
+ .clip(CircleShape)
+ )
+ Spacer(modifier = Modifier.size(16.dp))
+ Text(
+ text = userInfo.name,
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+
+ Spacer(modifier = Modifier.size(8.dp))
+
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error
+ ),
+ onClick = {
+ AuthRepository.clearCode()
+ navController.navigate(AuthScreenDestination)
+ },
+ ) {
+ Text(stringResource(R.string.logout))
+ }
+
+ Spacer(modifier = Modifier.size(8.dp))
+
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = { navController.navigate(BookScreenDestination) }
+ ) {
+ Text(stringResource(R.string.book_new))
+ }
+
+ Spacer(modifier = Modifier.size(8.dp))
+
+ Scaffold(
+ modifier = Modifier.fillMaxSize()
+ ) { paddingValues ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ itemsIndexed(
+ items = bookings,
+ key = { index, item -> "main_book_pos_$index" }
+ ) { index, booking ->
+ BookCard(
+ date = booking.date,
+ place = booking.place,
+ modifier = Modifier.padding(
+ top = if (index == 0) 0.dp else 4.dp,
+ bottom = if (index == bookings.lastIndex) 0.dp else 4.dp
+ )
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun BookCard(
+ date: String,
+ place: String,
+ modifier: Modifier = Modifier
+) {
+ val formattedDate = remember(date) {
+ runCatching {
+ java.time.LocalDate.parse(date).format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy"))
+ }.getOrElse { date }
+ }
+
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ modifier = modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Text(
+ text = "Бронь на $formattedDate",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .testTag(TestIds.Main.ITEM_DATE)
+ )
+
+ Spacer(modifier = Modifier.size(8.dp))
+
+ Text(
+ text = place,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .testTag(TestIds.Main.ITEM_PLACE)
+ )
+ }
+ }
+}
+
+data class Booking(
+ val date: String,
+ val place: String
+)
diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt
new file mode 100644
index 0000000..b02d4c6
--- /dev/null
+++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt
@@ -0,0 +1,9 @@
+package ru.myitschool.work.ui.screen.main
+
+import ru.myitschool.work.data.models.UserInfo
+
+sealed interface MainState {
+ object Loading : MainState
+ data class Error(val message: String) : MainState
+ data class Data(val userInfo: UserInfo) : MainState
+}
diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt
new file mode 100644
index 0000000..63c359c
--- /dev/null
+++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt
@@ -0,0 +1,35 @@
+package ru.myitschool.work.ui.screen.main
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import ru.myitschool.work.domain.GetUserInfoUseCase
+
+class MainViewModel : ViewModel() {
+
+ private val _uiState = MutableStateFlow(MainState.Loading)
+ val uiState = _uiState.asStateFlow()
+
+ private val getUserInfoUseCase = GetUserInfoUseCase()
+
+ init {
+ loadData()
+ }
+
+ fun onRefresh() {
+ loadData()
+ }
+
+ private fun loadData() {
+ viewModelScope.launch {
+ _uiState.value = MainState.Loading
+ getUserInfoUseCase().onSuccess {
+ _uiState.value = MainState.Data(it)
+ }.onFailure {
+ _uiState.value = MainState.Error(it.message ?: "Unknown error")
+ }
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index fa8bda6..1a695a3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,7 +1,9 @@
Work
- RootActivity
Привет! Введи код для авторизации
Код
Войти
+ Выйти
+ Обновить данные
+ Новая бронь
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..07e5fc2
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file