solve
Some checks failed
Android Test / validate-and-test (pull_request) Has been cancelled

This commit is contained in:
bot-tg-simple
2025-12-09 22:49:58 +03:00
parent 945b9d347d
commit 69943c8079
30 changed files with 1230 additions and 82 deletions

View File

@@ -19,6 +19,7 @@
android:name=".ui.root.RootActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:screenOrientation="portrait"
android:label="@string/title_activity_root">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class BookRequestDto(
@SerialName("date")
val date: String,
@SerialName("placeId")
val placeId: Int
)

View File

@@ -0,0 +1,8 @@
package ru.myitschool.work.data.model
import kotlinx.serialization.Serializable
@Serializable
data class BookingAvailabilityDto(
val entries: Map<String, List<BookingOptionDto>>
)

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.data.model
import kotlinx.serialization.Serializable
@Serializable
data class BookingOptionDto(
val id: Int,
val place: String
)

View File

@@ -0,0 +1,18 @@
package ru.myitschool.work.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UserInfoDto(
val name: String,
@SerialName("photoUrl")
val photoUrl: String,
val booking: Map<String, UserBookingDto> = emptyMap()
)
@Serializable
data class UserBookingDto(
val id: Int,
val place: String
)

View File

@@ -1,16 +1,48 @@
package ru.myitschool.work.data.repo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ru.myitschool.work.data.source.NetworkDataSource
import ru.myitschool.work.data.storage.AuthLocalDataSource
object AuthRepository {
private var codeCache: String? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _codeState: MutableStateFlow<String?> = MutableStateFlow(null)
val codeState: StateFlow<String?> = _codeState.asStateFlow()
suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success ->
if (success) {
codeCache = text
init {
scope.launch {
AuthLocalDataSource.codeFlow.collect { storedCode ->
_codeState.value = storedCode
}
}
}
suspend fun checkAndSave(text: String): Result<Unit> {
return NetworkDataSource.checkAuth(text).onSuccess {
AuthLocalDataSource.saveCode(text)
_codeState.value = text
}
}
suspend fun getCurrentCode(): String? {
val cached = _codeState.value
if (cached != null) return cached
val loaded = AuthLocalDataSource.currentCode()
if (loaded != null) {
_codeState.value = loaded
}
return loaded
}
suspend fun clear() {
AuthLocalDataSource.clear()
_codeState.value = null
}
}

View File

@@ -0,0 +1,16 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.model.BookRequestDto
import ru.myitschool.work.data.model.BookingOptionDto
import ru.myitschool.work.data.source.NetworkDataSource
object BookingRepository {
suspend fun fetchAvailability(code: String): Result<Map<String, List<BookingOptionDto>>> {
return NetworkDataSource.fetchBookingAvailability(code)
}
suspend fun createBooking(code: String, date: String, placeId: Int): Result<Unit> {
return NetworkDataSource.createBooking(code, BookRequestDto(date = date, placeId = placeId))
}
}

View File

@@ -0,0 +1,10 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.model.UserInfoDto
import ru.myitschool.work.data.source.NetworkDataSource
object UserRepository {
suspend fun fetchUserInfo(code: String): Result<UserInfoDto> {
return NetworkDataSource.fetchUserInfo(code)
}
}

View File

@@ -1,16 +1,24 @@
package ru.myitschool.work.data.source
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.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 ru.myitschool.work.core.Constants
import ru.myitschool.work.data.model.BookRequestDto
import ru.myitschool.work.data.model.BookingOptionDto
import ru.myitschool.work.data.model.UserInfoDto
object NetworkDataSource {
private val client by lazy {
@@ -28,11 +36,44 @@ object NetworkDataSource {
}
}
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
suspend fun checkAuth(code: String): Result<Unit> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get(getUrl(code, Constants.AUTH_URL))
when (response.status) {
HttpStatusCode.OK -> true
HttpStatusCode.OK -> Unit
else -> error(response.bodyAsText())
}
}
}
suspend fun fetchUserInfo(code: String): Result<UserInfoDto> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get(getUrl(code, Constants.INFO_URL))
when (response.status) {
HttpStatusCode.OK -> response.body()
else -> error(response.bodyAsText())
}
}
}
suspend fun fetchBookingAvailability(code: String): Result<Map<String, List<BookingOptionDto>>> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get(getUrl(code, Constants.BOOKING_URL))
when (response.status) {
HttpStatusCode.OK -> response.body()
else -> error(response.bodyAsText())
}
}
}
suspend fun createBooking(code: String, request: BookRequestDto): Result<Unit> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.post(getUrl(code, Constants.BOOK_URL)) {
contentType(ContentType.Application.Json)
setBody(request)
}
when (response.status) {
HttpStatusCode.Created -> Unit
else -> error(response.bodyAsText())
}
}

View File

@@ -0,0 +1,33 @@
package ru.myitschool.work.data.storage
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
object AuthLocalDataSource {
private val codeKey = stringPreferencesKey("auth_code")
val codeFlow: Flow<String?> = SettingsDataStore.dataStore.data
.map { preferences -> preferences[codeKey] }
suspend fun saveCode(code: String) {
SettingsDataStore.dataStore.edit { preferences ->
preferences[codeKey] = code
}
}
suspend fun clear() {
SettingsDataStore.dataStore.edit { preferences ->
preferences.remove(codeKey)
}
}
suspend fun currentCode(): String? {
return SettingsDataStore.dataStore.data
.map { it[codeKey] }
.firstOrNull()
}
}

View File

@@ -0,0 +1,15 @@
package ru.myitschool.work.data.storage
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import ru.myitschool.work.App
private val Context.authDataStore by preferencesDataStore(name = "auth_settings")
object SettingsDataStore {
val dataStore: DataStore<Preferences> by lazy {
App.context.authDataStore
}
}

View File

@@ -5,11 +5,7 @@ import ru.myitschool.work.data.repo.AuthRepository
class CheckAndSaveAuthCodeUseCase(
private val repository: AuthRepository
) {
suspend operator fun invoke(
text: String
): Result<Unit> {
return repository.checkAndSave(text).mapCatching { success ->
if (!success) error("Code is incorrect")
}
suspend operator fun invoke(text: String): Result<Unit> {
return repository.checkAndSave(text)
}
}

View File

@@ -0,0 +1,3 @@
package ru.myitschool.work.ui.nav
const val BOOKING_RESULT_KEY = "booking_result"

View File

@@ -2,10 +2,7 @@ package ru.myitschool.work.ui.screen
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
@@ -15,6 +12,8 @@ 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 +31,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

@@ -0,0 +1,5 @@
package ru.myitschool.work.ui.screen.auth
sealed interface AuthAction {
data object NavigateToMain : AuthAction
}

View File

@@ -1,6 +1,6 @@
package ru.myitschool.work.ui.screen.auth
sealed interface AuthIntent {
data class Send(val text: String): AuthIntent
data class TextInput(val text: String): AuthIntent
data object Send: AuthIntent
}

View File

@@ -10,28 +10,30 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.KeyboardOptions
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import kotlinx.coroutines.flow.collectLatest
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.MainScreenDestination
private val CODE_REGEX = Regex("^[A-Za-z0-9]{4}$")
@Composable
fun AuthScreen(
viewModel: AuthViewModel = viewModel(),
@@ -40,11 +42,22 @@ fun AuthScreen(
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.actionFlow.collect {
navController.navigate(MainScreenDestination)
viewModel.actionFlow.collectLatest {
when (it) {
AuthAction.NavigateToMain -> {
navController.navigate(MainScreenDestination) {
popUpTo(MainScreenDestination) { inclusive = true }
}
}
}
}
}
if (state.isCheckingSavedCode) {
BoxLoading()
return
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -57,41 +70,66 @@ fun AuthScreen(
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
when (val currentState = state) {
is AuthState.Data -> Content(viewModel, currentState)
is AuthState.Loading -> {
CircularProgressIndicator(
modifier = Modifier.size(64.dp)
)
}
}
Spacer(modifier = Modifier.size(24.dp))
Content(viewModel = viewModel, state = state)
}
}
@Composable
private fun Content(
viewModel: AuthViewModel,
state: AuthState.Data
state: AuthUiState
) {
var inputText by remember { mutableStateOf("") }
Spacer(modifier = Modifier.size(16.dp))
TextField(
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
value = inputText,
onValueChange = {
inputText = it
viewModel.onIntent(AuthIntent.TextInput(it))
},
label = { Text(stringResource(R.string.auth_label)) }
val isCodeValidForButton = state.code.length == 4 && CODE_REGEX.matches(state.code)
OutlinedTextField(
modifier = Modifier
.testTag(TestIds.Auth.CODE_INPUT)
.fillMaxWidth(),
value = state.code,
onValueChange = { viewModel.onIntent(AuthIntent.TextInput(it)) },
label = { Text(stringResource(R.string.auth_label)) },
placeholder = { Text(stringResource(R.string.auth_label)) },
singleLine = true,
enabled = !state.isLoading,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Done
)
)
if (state.showError) {
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(R.string.auth_error_text),
modifier = Modifier.testTag(TestIds.Auth.ERROR),
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.size(16.dp))
Button(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
onClick = {
viewModel.onIntent(AuthIntent.Send(inputText))
},
enabled = true
modifier = Modifier
.testTag(TestIds.Auth.SIGN_BUTTON)
.fillMaxWidth(),
onClick = { viewModel.onIntent(AuthIntent.Send) },
enabled = !state.isLoading && isCodeValidForButton
) {
Text(stringResource(R.string.auth_sign_in))
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
} else {
Text(stringResource(R.string.auth_sign_in))
}
}
}
@Composable
private fun BoxLoading() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(modifier = Modifier.size(48.dp))
}
}

View File

@@ -1,6 +1,8 @@
package ru.myitschool.work.ui.screen.auth
sealed interface AuthState {
object Loading: AuthState
object Data: AuthState
}
data class AuthUiState(
val code: String = "",
val isLoading: Boolean = false,
val showError: Boolean = false,
val isCheckingSavedCode: Boolean = true
)

View File

@@ -12,32 +12,69 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
import ru.myitschool.work.ui.screen.auth.AuthIntent.Send
import ru.myitschool.work.ui.screen.auth.AuthIntent.TextInput
class AuthViewModel : ViewModel() {
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data)
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow
private val _actionFlow: MutableSharedFlow<AuthAction> = MutableSharedFlow()
val actionFlow: SharedFlow<AuthAction> = _actionFlow
init {
viewModelScope.launch(Dispatchers.IO) {
val savedCode = AuthRepository.getCurrentCode()
if (savedCode != null) {
_actionFlow.emit(AuthAction.NavigateToMain)
} else {
_uiState.update { it.copy(isCheckingSavedCode = false) }
}
}
}
fun onIntent(intent: AuthIntent) {
when (intent) {
is AuthIntent.Send -> {
viewModelScope.launch(Dispatchers.Default) {
_uiState.update { AuthState.Loading }
checkAndSaveAuthCodeUseCase.invoke("9999").fold(
onSuccess = {
_actionFlow.emit(Unit)
},
onFailure = { error ->
error.printStackTrace()
_actionFlow.emit(Unit)
}
)
}
}
is AuthIntent.TextInput -> Unit
is TextInput -> onTextChanged(intent.text)
Send -> onSendClicked()
}
}
private fun onTextChanged(text: String) {
_uiState.update {
it.copy(
code = text,
showError = false
)
}
}
private fun onSendClicked() {
val code = _uiState.value.code
if (!isCodeValid(code)) {
_uiState.update { it.copy(showError = true) }
return
}
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { it.copy(isLoading = true) }
checkAndSaveAuthCodeUseCase.invoke(code).fold(
onSuccess = {
_uiState.update { state -> state.copy(isLoading = false, showError = false) }
_actionFlow.emit(AuthAction.NavigateToMain)
},
onFailure = {
_uiState.update { state -> state.copy(isLoading = false, showError = true) }
}
)
}
}
private fun isCodeValid(code: String): Boolean {
if (code.length != 4) return false
if (code.isBlank()) return false
val regex = "^[A-Za-z0-9]{4}$".toRegex()
return regex.matches(code)
}
}

View File

@@ -0,0 +1,279 @@
package ru.myitschool.work.ui.screen.book
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
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.ScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import kotlinx.coroutines.flow.collectLatest
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.BOOKING_RESULT_KEY
import androidx.compose.ui.res.stringResource
@Composable
fun BookScreen(
navController: NavController,
viewModel: BookingViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.actionFlow.collectLatest { action ->
when (action) {
BookingAction.NavigateToAuth -> {
navController.navigate(AuthScreenDestination) {
popUpTo(AuthScreenDestination) { inclusive = true }
}
}
BookingAction.CloseWithSuccess -> {
navController.previousBackStackEntry
?.savedStateHandle
?.set(BOOKING_RESULT_KEY, true)
navController.popBackStack()
}
}
}
}
when {
state.isLoading -> BookLoading()
state.showError -> BookError(
onRefresh = { viewModel.onIntent(BookingIntent.Refresh) },
onBack = { navController.popBackStack() }
)
state.showEmpty -> BookEmpty(onBack = { navController.popBackStack() })
else -> BookContent(
state = state,
onSelectDate = { viewModel.onIntent(BookingIntent.SelectDate(it)) },
onSelectPlace = { viewModel.onIntent(BookingIntent.SelectPlace(it)) },
onBook = { viewModel.onIntent(BookingIntent.Book) },
onBack = { navController.popBackStack() }
)
}
}
@Composable
private fun BookLoading() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
}
}
@Composable
private fun BookError(onRefresh: () -> Unit, onBack: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
modifier = Modifier.testTag(TestIds.Book.ERROR),
text = stringResource(R.string.book_error_text),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(16.dp))
Button(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Book.REFRESH_BUTTON),
onClick = onRefresh
) {
Text(text = stringResource(R.string.book_refresh))
}
Spacer(modifier = Modifier.height(8.dp))
Button(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Book.BACK_BUTTON),
onClick = onBack
) {
Text(text = stringResource(R.string.book_back))
}
}
}
@Composable
private fun BookEmpty(onBack: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
modifier = Modifier.testTag(TestIds.Book.EMPTY),
text = stringResource(R.string.book_empty),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(16.dp))
Button(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Book.BACK_BUTTON),
onClick = onBack
) {
Text(text = stringResource(R.string.book_back))
}
}
}
@Composable
private fun BookContent(
state: BookingUiState,
onSelectDate: (Int) -> Unit,
onSelectPlace: (Int) -> Unit,
onBook: () -> Unit,
onBack: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
ScrollableTabRow(selectedTabIndex = state.selectedDateIndex) {
state.dates.forEachIndexed { index, date ->
Tab(
selected = state.selectedDateIndex == index,
onClick = { onSelectDate(index) }
) {
Column(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 8.dp)
.testTag(TestIds.Book.getIdDateItemByPosition(index)),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
modifier = Modifier.testTag(TestIds.Book.ITEM_DATE),
text = date.displayDate,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
val places = state.dates.getOrNull(state.selectedDateIndex)?.places.orEmpty()
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(places) { index, place ->
PlaceItem(
index = index,
title = place.title,
selected = index == state.selectedPlaceIndex,
onClick = { onSelectPlace(index) }
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextButton(
modifier = Modifier
.weight(1f)
.testTag(TestIds.Book.BACK_BUTTON),
onClick = onBack
) {
Text(text = stringResource(R.string.book_back))
}
Button(
modifier = Modifier
.weight(1f)
.testTag(TestIds.Book.BOOK_BUTTON),
enabled = state.canBook,
onClick = onBook
) {
if (state.isBooking) {
CircularProgressIndicator(modifier = Modifier.height(16.dp))
} else {
Text(text = stringResource(R.string.book_book))
}
}
}
}
}
@Composable
private fun PlaceItem(
index: Int,
title: String,
selected: Boolean,
onClick: () -> Unit
) {
Surface(
tonalElevation = if (selected) 4.dp else 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Book.getIdPlaceItemByPosition(index))
.selectable(
selected = selected,
role = Role.RadioButton,
onClick = onClick
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_SELECTOR),
selected = selected,
onClick = null
)
Spacer(modifier = Modifier.width(12.dp))
Text(
modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_TEXT),
text = title,
style = MaterialTheme.typography.bodyLarge
)
}
}
}

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.ui.screen.book
sealed interface BookingAction {
data object CloseWithSuccess : BookingAction
data object NavigateToAuth : BookingAction
}

View File

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

View File

@@ -0,0 +1,31 @@
package ru.myitschool.work.ui.screen.book
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class BookingUiState(
val isLoading: Boolean = true,
val showError: Boolean = false,
val showEmpty: Boolean = false,
val dates: ImmutableList<BookingDateUi> = persistentListOf(),
val selectedDateIndex: Int = 0,
val selectedPlaceIndex: Int = -1,
val isBooking: Boolean = false
) {
val canBook: Boolean
get() = dates.isNotEmpty() &&
selectedDateIndex in dates.indices &&
selectedPlaceIndex in dates[selectedDateIndex].places.indices &&
!isBooking
}
data class BookingDateUi(
val isoDate: String,
val displayDate: String,
val places: ImmutableList<BookingPlaceUi>
)
data class BookingPlaceUi(
val id: Int,
val title: String
)

View File

@@ -0,0 +1,141 @@
package ru.myitschool.work.ui.screen.book
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.toImmutableList
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.AuthRepository
import ru.myitschool.work.data.repo.BookingRepository
import java.time.LocalDate
import java.time.format.DateTimeFormatter
class BookingViewModel : ViewModel() {
private val _uiState = MutableStateFlow(BookingUiState())
val uiState: StateFlow<BookingUiState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<BookingAction> = MutableSharedFlow()
val actionFlow: SharedFlow<BookingAction> = _actionFlow
private val isoFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
private val displayFormatter = DateTimeFormatter.ofPattern("dd.MM")
init {
refresh()
}
fun onIntent(intent: BookingIntent) {
when (intent) {
BookingIntent.Refresh -> refresh()
is BookingIntent.SelectDate -> selectDate(intent.index)
is BookingIntent.SelectPlace -> selectPlace(intent.index)
BookingIntent.Book -> book()
BookingIntent.Back -> Unit
}
}
private fun refresh() {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update {
it.copy(
isLoading = true,
showError = false,
showEmpty = false,
isBooking = false
)
}
val code = AuthRepository.getCurrentCode()
if (code == null) {
_actionFlow.emit(BookingAction.NavigateToAuth)
return@launch
}
BookingRepository.fetchAvailability(code).fold(
onSuccess = { map ->
val dates = map.entries
.filter { it.value.isNotEmpty() }
.sortedBy { LocalDate.parse(it.key, isoFormatter) }
.map { entry ->
BookingDateUi(
isoDate = entry.key,
displayDate = LocalDate.parse(entry.key, isoFormatter).format(displayFormatter),
places = entry.value.map { option ->
BookingPlaceUi(
id = option.id,
title = option.place
)
}.toImmutableList()
)
}
.toImmutableList()
val showEmpty = dates.isEmpty()
_uiState.update {
it.copy(
isLoading = false,
showError = false,
showEmpty = showEmpty,
dates = dates,
selectedDateIndex = 0,
selectedPlaceIndex = if (!showEmpty && dates[0].places.isNotEmpty()) 0 else -1
)
}
},
onFailure = {
_uiState.update { state ->
state.copy(isLoading = false, showError = true)
}
}
)
}
}
private fun selectDate(index: Int) {
_uiState.update { state ->
if (index !in state.dates.indices) state
else state.copy(
selectedDateIndex = index,
selectedPlaceIndex = if (state.dates[index].places.isNotEmpty()) 0 else -1
)
}
}
private fun selectPlace(index: Int) {
_uiState.update { state ->
val currentPlaces = state.dates.getOrNull(state.selectedDateIndex)?.places
?: return@update state
if (index !in currentPlaces.indices) return@update state
state.copy(selectedPlaceIndex = index)
}
}
private fun book() {
val currentState = _uiState.value
if (!currentState.canBook) return
viewModelScope.launch(Dispatchers.IO) {
val code = AuthRepository.getCurrentCode()
if (code == null) {
_actionFlow.emit(BookingAction.NavigateToAuth)
return@launch
}
val date = currentState.dates[currentState.selectedDateIndex]
val place = date.places[currentState.selectedPlaceIndex]
_uiState.update { it.copy(isBooking = true) }
BookingRepository.createBooking(code, date.isoDate, place.id).fold(
onSuccess = {
_uiState.update { it.copy(isBooking = false) }
_actionFlow.emit(BookingAction.CloseWithSuccess)
},
onFailure = {
_uiState.update { it.copy(isBooking = false, showError = true) }
}
)
}
}
}

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.ui.screen.main
sealed interface MainAction {
data object NavigateToAuth : MainAction
data object NavigateToBooking : MainAction
}

View File

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

View File

@@ -0,0 +1,264 @@
package ru.myitschool.work.ui.screen.main
import androidx.compose.foundation.background
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
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.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 kotlinx.coroutines.flow.collectLatest
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.BOOKING_RESULT_KEY
import ru.myitschool.work.ui.nav.BookScreenDestination
import ru.myitschool.work.ui.screen.main.MainAction.NavigateToAuth
import ru.myitschool.work.ui.screen.main.MainAction.NavigateToBooking
@Composable
private fun ProfileAvatar(
name: String,
photoUrl: String?
) {
val modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.testTag(TestIds.Main.PROFILE_IMAGE)
if (photoUrl.isNullOrBlank()) {
Box(
modifier = modifier.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
val placeholder = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
Text(
text = placeholder,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary
)
}
} else {
AsyncImage(
modifier = modifier,
model = photoUrl,
contentDescription = null,
contentScale = ContentScale.Crop
)
}
}
@Composable
fun MainScreen(
navController: NavController,
viewModel: MainViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.actionFlow.collectLatest { action ->
when (action) {
NavigateToAuth -> {
navController.navigate(AuthScreenDestination) {
popUpTo(AuthScreenDestination) { inclusive = true }
}
}
NavigateToBooking -> {
navController.navigate(BookScreenDestination)
}
}
}
}
val refreshFlow = navController.currentBackStackEntry
?.savedStateHandle
?.getStateFlow(BOOKING_RESULT_KEY, false)
LaunchedEffect(refreshFlow) {
refreshFlow?.collectLatest { shouldRefresh ->
if (shouldRefresh) {
viewModel.onIntent(MainIntent.Refresh)
navController.currentBackStackEntry?.savedStateHandle?.set(BOOKING_RESULT_KEY, false)
}
}
}
when {
state.isLoading -> MainLoading()
state.showError -> MainError(onRetry = { viewModel.onIntent(MainIntent.Refresh) })
else -> MainContent(
state = state,
onRefresh = { viewModel.onIntent(MainIntent.Refresh) },
onAddBooking = { viewModel.onIntent(MainIntent.AddBooking) },
onLogout = { viewModel.onIntent(MainIntent.Logout) }
)
}
}
@Composable
private fun MainLoading() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
@Composable
private fun MainError(onRetry: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
modifier = Modifier.testTag(TestIds.Main.ERROR),
text = stringResource(R.string.main_error_text),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Main.REFRESH_BUTTON),
onClick = onRetry
) {
Text(text = stringResource(R.string.main_refresh))
}
}
}
@Composable
private fun MainContent(
state: MainUiState,
onRefresh: () -> Unit,
onAddBooking: () -> Unit,
onLogout: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
ProfileAvatar(
name = state.name,
photoUrl = state.photoUrl
)
Spacer(modifier = Modifier.width(16.dp))
Text(
modifier = Modifier.testTag(TestIds.Main.PROFILE_NAME),
text = state.name,
style = MaterialTheme.typography.headlineSmall
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
modifier = Modifier
.weight(1f)
.testTag(TestIds.Main.ADD_BUTTON),
onClick = onAddBooking
) {
Text(text = stringResource(R.string.main_add))
}
Button(
modifier = Modifier
.weight(1f)
.testTag(TestIds.Main.REFRESH_BUTTON),
onClick = onRefresh
) {
Text(text = stringResource(R.string.main_refresh))
}
Button(
modifier = Modifier
.weight(1f)
.testTag(TestIds.Main.LOGOUT_BUTTON),
onClick = onLogout
) {
Text(text = stringResource(R.string.main_logout))
}
}
Text(
text = stringResource(R.string.main_bookings_title),
style = MaterialTheme.typography.titleMedium
)
if (state.bookings.isEmpty()) {
Text(
text = stringResource(R.string.main_empty_list),
style = MaterialTheme.typography.bodyMedium
)
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(state.bookings) { index, booking ->
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Main.getIdItemByPosition(index)),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
modifier = Modifier.testTag(TestIds.Main.ITEM_DATE),
text = booking.dateDisplay,
style = MaterialTheme.typography.titleMedium
)
Text(
modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE),
text = booking.place,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,21 @@
package ru.myitschool.work.ui.screen.main
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class MainUiState(
val isLoading: Boolean = true,
val showError: Boolean = false,
val name: String = "",
val photoUrl: String? = null,
val bookings: ImmutableList<MainBookingUi> = persistentListOf()
) {
val hasContent: Boolean get() = !isLoading && !showError
}
data class MainBookingUi(
val id: Int,
val dateIso: String,
val dateDisplay: String,
val place: String
)

View File

@@ -0,0 +1,102 @@
package ru.myitschool.work.ui.screen.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.toImmutableList
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.core.Constants
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.data.repo.UserRepository
import ru.myitschool.work.ui.screen.main.MainAction.NavigateToAuth
import ru.myitschool.work.ui.screen.main.MainAction.NavigateToBooking
import java.time.LocalDate
import java.time.format.DateTimeFormatter
class MainViewModel : ViewModel() {
private val _uiState = MutableStateFlow(MainUiState())
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<MainAction> = MutableSharedFlow()
val actionFlow: SharedFlow<MainAction> = _actionFlow
private val dateFormatterServer = DateTimeFormatter.ofPattern("yyyy-MM-dd")
private val dateFormatterDisplay = DateTimeFormatter.ofPattern("dd.MM.yyyy")
init {
refresh()
}
fun onIntent(intent: MainIntent) {
when (intent) {
MainIntent.Refresh -> refresh()
MainIntent.Logout -> logout()
MainIntent.AddBooking -> openBooking()
}
}
private fun refresh() {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { it.copy(isLoading = true, showError = false) }
val code = AuthRepository.getCurrentCode()
if (code == null) {
_actionFlow.emit(NavigateToAuth)
return@launch
}
UserRepository.fetchUserInfo(code).fold(
onSuccess = { info ->
val bookings = info.booking.entries
.sortedBy { LocalDate.parse(it.key, dateFormatterServer) }
.mapIndexed { index, entry ->
val date = LocalDate.parse(entry.key, dateFormatterServer)
MainBookingUi(
id = index,
dateIso = entry.key,
dateDisplay = date.format(dateFormatterDisplay),
place = entry.value.place
)
}
.toImmutableList()
val resolvedPhoto = info.photoUrl.let { url ->
if (url.isBlank()) null
else if (url.startsWith("http")) url
else "${Constants.HOST}$url"
}
_uiState.update {
it.copy(
isLoading = false,
showError = false,
name = info.name,
photoUrl = resolvedPhoto,
bookings = bookings
)
}
},
onFailure = {
_uiState.update { state ->
state.copy(isLoading = false, showError = true)
}
}
)
}
}
private fun openBooking() {
viewModelScope.launch {
_actionFlow.emit(NavigateToBooking)
}
}
private fun logout() {
viewModelScope.launch(Dispatchers.IO) {
AuthRepository.clear()
_actionFlow.emit(NavigateToAuth)
}
}
}

View File

@@ -4,4 +4,19 @@
<string name="auth_title">Привет! Введи код для авторизации</string>
<string name="auth_label">Код</string>
<string name="auth_sign_in">Войти</string>
<string name="auth_error_text">Неверный код или произошла ошибка. Попробуйте снова.</string>
<string name="main_logout">Выйти</string>
<string name="main_refresh">Обновить</string>
<string name="main_add">Забронировать</string>
<string name="main_error_text">Не удалось загрузить данные. Попробуйте обновить.</string>
<string name="main_bookings_title">Ваши бронирования</string>
<string name="main_empty_list">Нет активных бронирований</string>
<string name="book_title">Бронирование</string>
<string name="book_book">Забронировать</string>
<string name="book_back">Назад</string>
<string name="book_error_text">Не удалось получить данные. Повторите попытку.</string>
<string name="book_refresh">Обновить</string>
<string name="book_select_date">Выберите дату</string>
<string name="book_select_place">Выберите место</string>
<string name="book_empty">Всё забронировано</string>
</resources>