This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.myitschool.work.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BookingAvailabilityDto(
|
||||
val entries: Map<String, List<BookingOptionDto>>
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.myitschool.work.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BookingOptionDto(
|
||||
val id: Int,
|
||||
val place: String
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package ru.myitschool.work.ui.nav
|
||||
|
||||
const val BOOKING_RESULT_KEY = "booking_result"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package ru.myitschool.work.ui.screen.auth
|
||||
|
||||
sealed interface AuthAction {
|
||||
data object NavigateToMain : AuthAction
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.myitschool.work.ui.screen.book
|
||||
|
||||
sealed interface BookingAction {
|
||||
data object CloseWithSuccess : BookingAction
|
||||
data object NavigateToAuth : BookingAction
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.myitschool.work.ui.screen.main
|
||||
|
||||
sealed interface MainAction {
|
||||
data object NavigateToAuth : MainAction
|
||||
data object NavigateToBooking : MainAction
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user