main #15

Open
student-33039 wants to merge 9 commits from student-33039/NTO-2025-Android-TeamTask:main into main
22 changed files with 887 additions and 55 deletions

View File

@@ -35,6 +35,10 @@ android {
} }
dependencies { 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() defaultComposeLibrary()
implementation("androidx.datastore:datastore-preferences:1.1.7") implementation("androidx.datastore:datastore-preferences:1.1.7")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0") 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-client-content-negotiation:$ktor")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("io.coil-kt:coil-compose:2.6.0")
} }

View File

@@ -19,10 +19,9 @@
android:name=".ui.root.RootActivity" android:name=".ui.root.RootActivity"
android:exported="true" android:exported="true"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:label="@string/title_activity_root"> android:theme="@style/Theme.Work">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>

View File

@@ -1,7 +1,7 @@
package ru.myitschool.work.core package ru.myitschool.work.core
object Constants { 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 AUTH_URL = "/auth"
const val INFO_URL = "/info" const val INFO_URL = "/info"
const val BOOKING_URL = "/booking" const val BOOKING_URL = "/booking"

View File

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

View File

@@ -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<String, BookingInfo>
)

View File

@@ -5,6 +5,14 @@ import ru.myitschool.work.data.source.NetworkDataSource
object AuthRepository { object AuthRepository {
private var codeCache: String? = null private var codeCache: String? = null
fun clearCode() {
codeCache = null
}
fun getCode(): String? {
return codeCache
}
suspend fun checkAndSave(text: String): Result<Boolean> { suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success -> return NetworkDataSource.checkAuth(text).onSuccess { success ->

View File

@@ -11,32 +11,75 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.myitschool.work.core.Constants import ru.myitschool.work.core.Constants
import ru.myitschool.work.data.models.UserInfo
object NetworkDataSource { object NetworkDataSource {
private val json = Json {
isLenient = true
ignoreUnknownKeys = true
explicitNulls = true
encodeDefaults = true
}
private val client by lazy { private val client by lazy {
HttpClient(CIO) { HttpClient(CIO) {
install(ContentNegotiation) { install(ContentNegotiation) {
json( json(json)
Json {
isLenient = true
ignoreUnknownKeys = true
explicitNulls = true
encodeDefaults = true
}
)
} }
} }
} }
suspend fun getUserInfo(code: String): Result<UserInfo> = 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<UserInfo>(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<Boolean> = withContext(Dispatchers.IO) { suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching { val url = getUrl(code, Constants.AUTH_URL)
val response = client.get(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) { when (response.status) {
HttpStatusCode.OK -> true 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" private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
} }

View File

@@ -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()
}
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable 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.BookScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.screen.auth.AuthScreen 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 @Composable
fun AppNavHost( fun AppNavHost(
@@ -27,23 +31,27 @@ fun AppNavHost(
exitTransition = { ExitTransition.None }, exitTransition = { ExitTransition.None },
navController = navController, navController = navController,
startDestination = AuthScreenDestination, startDestination = AuthScreenDestination,
// startDestination = MainScreenDestination,
) { ) {
composable<AuthScreenDestination> { composable<AuthScreenDestination> {
AuthScreen(navController = navController) AuthScreen(navController = navController)
} }
composable<MainScreenDestination> { composable<MainScreenDestination> {
Box( MainScreen(navController = navController)
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
}
} }
composable<BookScreenDestination> { composable<BookScreenDestination> {
Box( val vm: BookViewModel = viewModel()
contentAlignment = Alignment.Center
) { BookScreen(
Text(text = "Hello") vm = vm,
} onBack = { navController.popBackStack() },
onSuccess = {
navController.popBackStack()
navController.navigate(MainScreenDestination) {
launchSingleTop = true
}
}
)
} }
} }
} }

View File

@@ -31,6 +31,13 @@ import androidx.navigation.NavController
import ru.myitschool.work.R import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.MainScreenDestination 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 @Composable
fun AuthScreen( fun AuthScreen(
@@ -48,49 +55,92 @@ fun AuthScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(all = 24.dp), .imePadding()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text( when (state) {
text = stringResource(R.string.auth_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
when (val currentState = state) {
is AuthState.Data -> Content(viewModel, currentState)
is AuthState.Loading -> { is AuthState.Loading -> {
CircularProgressIndicator( CircularProgressIndicator(modifier = Modifier.size(64.dp))
modifier = Modifier.size(64.dp) }
)
else -> {
Content(viewModel, state)
} }
} }
} }
} }
@Composable @Composable
private fun Content( private fun Content(
viewModel: AuthViewModel, viewModel: AuthViewModel,
state: AuthState.Data state: AuthState
) { ) {
var inputText by remember { mutableStateOf("") } 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)) Spacer(modifier = Modifier.size(16.dp))
TextField( TextField(
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Auth.CODE_INPUT),
value = inputText, value = inputText,
onValueChange = { onValueChange = {
inputText = it if (it.length <= 4 && it.all { ch -> ch.isLetterOrDigit() }) {
inputText = it
}
viewModel.onIntent(AuthIntent.TextInput(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)) } 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)) Spacer(modifier = Modifier.size(16.dp))
Button( Button(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Auth.SIGN_BUTTON),
onClick = { onClick = {
viewModel.onIntent(AuthIntent.Send(inputText)) viewModel.onIntent(AuthIntent.Send(inputText))
}, },
enabled = true enabled = isButtonEnabled
) { ) {
Text(stringResource(R.string.auth_sign_in)) Text(stringResource(R.string.auth_sign_in))
} }

View File

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

View File

@@ -13,31 +13,39 @@ import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
class AuthViewModel : ViewModel() { class AuthViewModel() : ViewModel() {
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) } private val checkAndSaveAuthCodeUseCase by lazy {
CheckAndSaveAuthCodeUseCase(AuthRepository)
}
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data) private val _uiState = MutableStateFlow<AuthState>(AuthState.Data)
val uiState: StateFlow<AuthState> = _uiState.asStateFlow() val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow() private val _actionFlow = MutableSharedFlow<Unit>()
val actionFlow: SharedFlow<Unit> = _actionFlow val actionFlow: SharedFlow<Unit> = _actionFlow
fun onIntent(intent: AuthIntent) { fun onIntent(intent: AuthIntent) {
when (intent) { when (intent) {
is AuthIntent.Send -> { is AuthIntent.Send -> {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.IO) {
_uiState.update { AuthState.Loading } _uiState.value = AuthState.Loading
checkAndSaveAuthCodeUseCase.invoke("9999").fold(
checkAndSaveAuthCodeUseCase.invoke(intent.text).fold(
onSuccess = { onSuccess = {
_actionFlow.emit(Unit) _actionFlow.emit(Unit)
}, },
onFailure = { error -> onFailure = { throwable ->
error.printStackTrace() val errorMessage = throwable.message ?: "Неизвестная ошибка"
_actionFlow.emit(Unit) _uiState.value = AuthState.Error(errorMessage)
} }
) )
} }
} }
is AuthIntent.TextInput -> Unit
is AuthIntent.TextInput -> {
_uiState.value = AuthState.Data
}
} }
} }
} }

View File

@@ -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
}

View File

@@ -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/<CODE>/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("Назад")
}
}
}

View File

@@ -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<BookingDate> = emptyList(),
val selectedDate: LocalDate? = null,
val selectedPlaceId: String? = null,
val bookingSuccess: Boolean = false
) {
val availablePlaces: List<BookingPlace>
get() = dates.firstOrNull { it.date == selectedDate }?.places ?: emptyList()
val isEmpty: Boolean
get() = dates.isEmpty()
}

View File

@@ -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<BookingPlace>
)
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<BookingDate> {
return listOf(
BookingDate(
LocalDate.of(2025, 1, 5),
listOf(
BookingPlace("1", "Окно №1"),
BookingPlace("2", "Окно №3")
)
),
BookingDate(
LocalDate.of(2025, 1, 6),
listOf(
BookingPlace("3", "Окно №2")
)
),
BookingDate(
LocalDate.of(2025, 1, 9),
emptyList() // будет скрыто
),
BookingDate(
LocalDate.of(2025, 1, 10),
listOf(
BookingPlace("3", "Окно №2")
)
),
BookingDate(
LocalDate.of(2025, 1, 11),
listOf(
BookingPlace("3", "Окно №2")
)
),
BookingDate(
LocalDate.of(2025, 1, 12),
listOf(
BookingPlace("3", "Окно №2")
)
),
BookingDate(
LocalDate.of(2025, 1, 13),
listOf(
BookingPlace("3", "Окно №2")
)
),
).filter { it.places.isNotEmpty() }
}
}

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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
}

View File

@@ -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>(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")
}
}
}
}

View File

@@ -1,7 +1,9 @@
<resources> <resources>
<string name="app_name">Work</string> <string name="app_name">Work</string>
<string name="title_activity_root">RootActivity</string>
<string name="auth_title">Привет! Введи код для авторизации</string> <string name="auth_title">Привет! Введи код для авторизации</string>
<string name="auth_label">Код</string> <string name="auth_label">Код</string>
<string name="auth_sign_in">Войти</string> <string name="auth_sign_in">Войти</string>
<string name="logout">Выйти</string>
<string name="refresh">Обновить данные</string>
<string name="book_new">Новая бронь</string>
</resources> </resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Work" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
</resources>