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