main #15

Open
student-33039 wants to merge 9 commits from student-33039/NTO-2025-Android-TeamTask:main into main
10 changed files with 513 additions and 225 deletions
Showing only changes of commit 9e6ef6062f - Show all commits

View File

@@ -36,6 +36,9 @@ android {
dependencies { dependencies {
implementation("androidx.compose.material3:material3:1.4.0") 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")

View File

@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -28,8 +29,16 @@ object NetworkDataSource {
} }
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) { suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
val url = getUrl(code, Constants.AUTH_URL)
println("➡ Request URL: $url")
runCatching { runCatching {
val response = client.get(getUrl(code, Constants.AUTH_URL)) 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
HttpStatusCode.Unauthorized -> error("Код не существует") HttpStatusCode.Unauthorized -> error("Код не существует")
@@ -38,8 +47,9 @@ object NetworkDataSource {
} }
}.mapCatching { success -> }.mapCatching { success ->
success success
}.recoverCatching { _ -> }.recoverCatching { e ->
throw Exception("Не удалось соединиться с сервером") println("❌ Error: ${e.message}")
throw Exception(e.message)
} }
} }

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
@@ -16,6 +17,7 @@ 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.BookScreen
import ru.myitschool.work.ui.screen.book.BookViewModel
import ru.myitschool.work.ui.screen.main.MainScreen import ru.myitschool.work.ui.screen.main.MainScreen
@Composable @Composable
@@ -28,8 +30,8 @@ fun AppNavHost(
enterTransition = { EnterTransition.None }, enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None }, exitTransition = { ExitTransition.None },
navController = navController, navController = navController,
// startDestination = AuthScreenDestination, startDestination = AuthScreenDestination,
startDestination = MainScreenDestination, // startDestination = MainScreenDestination,
) { ) {
composable<AuthScreenDestination> { composable<AuthScreenDestination> {
AuthScreen(navController = navController) AuthScreen(navController = navController)
@@ -38,7 +40,18 @@ fun AppNavHost(
MainScreen(navController = navController) MainScreen(navController = navController)
} }
composable<BookScreenDestination> { composable<BookScreenDestination> {
BookScreen(navController = navController) val vm: BookViewModel = viewModel()
BookScreen(
vm = vm,
onBack = { navController.popBackStack() },
onSuccess = {
navController.popBackStack()
navController.navigate(MainScreenDestination) {
launchSingleTop = true
}
}
)
} }
} }
} }

View File

@@ -121,13 +121,15 @@ private fun Content(
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
(state as? AuthState.Error)?.let { errorState ->
Text( Text(
text = (state as AuthState.Error).message, text = errorState.message,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }
}
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))

View File

@@ -1,6 +1,12 @@
package ru.myitschool.work.ui.screen.book package ru.myitschool.work.ui.screen.book
import java.time.LocalDate
sealed interface BookIntent { sealed interface BookIntent {
data class Send(val text: String): BookIntent object Load : BookIntent
data class TextInput(val text: String): 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

@@ -5,67 +5,208 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme 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.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import ru.myitschool.work.ui.nav.MainScreenDestination import ru.myitschool.work.ui.nav.MainScreenDestination
@Composable /*
fun BookScreen( Экран бронирования
viewModel: BookViewModel = viewModel(), На данном экране необходимо вывести возможные даты и места для бронирования.
navController: NavController
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) { Элементы, которые должны присутствовать на экране:
viewModel.actionFlow.collect { Группа вкладок. Каждая вкладка (book_date_pos_{индекс}) содержит текстовое поле (book_date) с датой бронирования в формате dd.MM.
navController.navigate(MainScreenDestination)
}
}
/* В зависимости от выбранной даты необходимо отобразить группу с единственным выбором (пояснения на изображении ниже). Каждый элемент группы (book_place_pos_{индекс}) кликабелен и содержит:
Экран бронирования
На данном экране необходимо вывести возможные даты и места для бронирования.
Элементы, которые должны присутствовать на экране:
Группа вкладок. Каждая вкладка (book_date_pos_{индекс}) содержит текстовое поле (book_date) с датой бронирования в формате dd.MM.
В зависимости от выбранной даты необходимо отобразить группу с единственным выбором (пояснения на изображении ниже). Каждый элемент группы (book_place_pos_{индекс}) кликабелен и содержит:
Текстовое поле (book_place_text), в котором содержится место доступное для брони. Текстовое поле (book_place_text), в котором содержится место доступное для брони.
Селектор (book_place_selector), который отображает, выбран элемент или нет. У данного элемента обязательно наличие: (Modifier.selectable) Селектор (book_place_selector), который отображает, выбран элемент или нет. У данного элемента обязательно наличие: (Modifier.selectable)
Кнопка (book_book_button) для бронирования.
Кнопка (book_back_button) для возвращения на предыдущий экран.
По умолчанию неотображаемое текстовое поле с ошибкой (book_error). Отметим, что это поле не должно рендериться.
По умолчанию неотображаемая кнопка обновить (book_refresh_button).
По умолчанию неотображаемый текст “Всё забронировано” (book_empty).
Требования к компонентам: Кнопка (book_book_button) для бронирования.
По умолчанию выбирается самая ранняя доступная дата (например, из набора "5 января", "6 января", "9 января" будет показана дата "5 января"). Кнопка (book_back_button) для возвращения на предыдущий экран.
Список дат отсортирован по возрастанию. Даты без доступных мест для бронирования необходимо не отображать. По умолчанию неотображаемое текстовое поле с ошибкой (book_error). Отметим, что это поле не должно рендериться.
Если нет доступных для бронирования дат, необходимо скрыть все элементы, кроме элементов из п. 4 и 7. По умолчанию неотображаемая кнопка обновить (book_refresh_button).
В случае ошибки при получении данных о доступном бронировании в запросе api/<CODE>/booking, необходимо отобразить элемент из п. 4, 5 и 6 с возможностью обновить данные. По умолчанию неотображаемый текст “Всё забронировано” (book_empty).
При успешном бронировании нужно закрыть текущий экран и вернуться на главный, обновив его.
Требования к компонентам:
По умолчанию выбирается самая ранняя доступная дата (например, из набора "5 января", "6 января", "9 января" будет показана дата "5 января").
Список дат отсортирован по возрастанию. Даты без доступных мест для бронирования необходимо не отображать.
Если нет доступных для бронирования дат, необходимо скрыть все элементы, кроме элементов из п. 4 и 7.
В случае ошибки при получении данных о доступном бронировании в запросе api/<CODE>/booking, необходимо отобразить элемент из п. 4, 5 и 6 с возможностью обновить данные.
При успешном бронировании нужно закрыть текущий экран и вернуться на главный, обновив его.
*/ */
Column( import androidx.compose.foundation.clickable
modifier = Modifier import androidx.compose.foundation.layout.*
.fillMaxSize() import androidx.compose.material3.*
.padding(all = 24.dp), import androidx.compose.runtime.*
horizontalAlignment = Alignment.CenterHorizontally, import androidx.compose.foundation.selection.selectable
verticalArrangement = Arrangement.Center 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
Text( @Composable
text = "страница бронирования...", fun BookScreen(
style = MaterialTheme.typography.headlineSmall, vm: BookViewModel,
textAlign = TextAlign.Center 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

@@ -1,5 +1,17 @@
package ru.myitschool.work.ui.screen.book package ru.myitschool.work.ui.screen.book
import java.time.LocalDate
sealed interface BookState { data class BookState(
object Data: 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

@@ -1,39 +1,141 @@
package ru.myitschool.work.ui.screen.book package ru.myitschool.work.ui.screen.book
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
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
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() { class BookViewModel : ViewModel() {
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
private val _uiState = MutableStateFlow<BookState>(BookState.Data)
val uiState: StateFlow<BookState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow() private val _state = MutableStateFlow(BookState())
val actionFlow: SharedFlow<Unit> = _actionFlow val state = _state.asStateFlow()
fun onIntent(intent: BookIntent) { fun accept(intent: BookIntent) {
// when (intent) { when (intent) {
// is MainIntent.Send -> { is BookIntent.Load -> load()
// viewModelScope.launch(Dispatchers.Default) { is BookIntent.SelectDate -> selectDate(intent.date)
// _uiState.update { MainState.Loading } is BookIntent.SelectPlace -> selectPlace(intent.placeId)
// checkAndSaveAuthCodeUseCase.invoke("9999").fold( is BookIntent.ConfirmBooking -> book()
// onSuccess = { is BookIntent.Refresh -> load()
// _actionFlow.emit(Unit) is BookIntent.Back -> {} // обработка навигации снаружи
// }, }
// onFailure = { error -> }
// error.printStackTrace()
// _actionFlow.emit(Unit) private fun load() {
// } viewModelScope.launch {
// ) _state.value = BookState(loading = true)
// }
// } delay(400) // имитация ожидания API
// is MainIntent.TextInput -> Unit
// } 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

@@ -16,36 +16,40 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
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.BookScreenDestination
data class Booking(
val date: String,
val place: String
)
@Composable @Composable
fun MainScreen( fun BookCard(
viewModel: MainViewModel = viewModel(), date: String,
navController: NavController place: String,
modifier: Modifier = Modifier
) { ) {
val state by viewModel.uiState.collectAsState()
/* /*
По умолчанию скрытое текстовое поле с ошибкой (main_error). По умолчанию скрытое текстовое поле с ошибкой (main_error).
@@ -59,143 +63,6 @@ fun MainScreen(
Список бронирований должен быть отсортирован в порядке увеличения даты (например, 5 января -> 6 января -> 9 января). Список бронирований должен быть отсортирован в порядке увеличения даты (например, 5 января -> 6 января -> 9 января).
*/ */
val bookings = listOf(
Booking(date = "2025-12-01", place = "Аудитория 1"),
Booking(date = "2025-12-01", place = "Аудитория 2"),
Booking(date = "2025-12-02", place = "Аудитория 3"),
Booking(date = "2025-12-02", place = "Конференц-зал"),
Booking(date = "2025-12-03", place = "Аудитория с очень длинным названием. Lorem ipsum"),
Booking(date = "2025-12-03", place = "Лаборатория №101"),
Booking(date = "2025-12-04", place = "Переговорная комната"),
Booking(date = "2025-12-04", place = "Спортивный зал"),
)
LaunchedEffect(Unit) {
viewModel.actionFlow.collect {
navController.navigate(MainScreenDestination)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(
start = 20.dp,
top = 20.dp,
end = 20.dp,
bottom = 0.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = "https://palyulin.ru/netcat_files/23/21/rabotnik.jpg",
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(60.dp)
.clip(CircleShape)
.testTag(TestIds.Main.PROFILE_IMAGE)
)
Spacer(modifier = Modifier.size(16.dp))
Text(
text = "Иванов Иван Иванович",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.testTag(TestIds.Main.PROFILE_NAME)
)
}
Spacer(modifier = Modifier.size(8.dp))
Button(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Main.LOGOUT_BUTTON),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
),
onClick = {
},
) {
Text(stringResource(R.string.logout))
}
Spacer(modifier = Modifier.size(8.dp))
Button(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Main.REFRESH_BUTTON),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
),
onClick = {
},
) {
Text(stringResource(R.string.refresh))
}
Spacer(modifier = Modifier.size(8.dp))
Button(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Main.ADD_BUTTON),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF2E7D32),
contentColor = Color.White
),
onClick = {
},
) {
Text(stringResource(R.string.book_new))
}
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 8.dp,
bottom = if (index == bookings.lastIndex) 0.dp else 8.dp
)
)
}
}
}
}
}
data class Booking(
val date: String,
val place: String
)
@Composable
fun BookCard(
date: String,
place: String,
modifier: Modifier = Modifier
) {
val formattedDate = remember(date) { val formattedDate = remember(date) {
try { try {
val parts = date.split("-") val parts = date.split("-")
@@ -240,3 +107,133 @@ fun BookCard(
} }
} }
} }
@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
) {
Button(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Main.REFRESH_BUTTON),
onClick = { /* обновить данные */ }
) {
Text(stringResource(R.string.refresh))
}
Spacer(modifier = Modifier.size(8.dp))
when (state) {
is MainState.Error -> {
Text(
text = (state as MainState.Error).message,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
is MainState.Loading -> {
CircularProgressIndicator()
}
is MainState.Data -> {
MainContent(navController = navController)
}
}
}
}
@Composable
fun MainContent(navController: NavController) {
val bookings = listOf(
Booking(date = "2025-12-01", place = "Аудитория 1"),
Booking(date = "2025-12-01", place = "Аудитория 2"),
Booking(date = "2025-12-02", place = "Аудитория 3"),
Booking(date = "2025-12-02", place = "Конференц-зал"),
Booking(date = "2025-12-03", place = "Аудитория с очень длинным названием. Lorem ipsum"),
Booking(date = "2025-12-03", place = "Лаборатория №101"),
Booking(date = "2025-12-04", place = "Переговорная комната"),
Booking(date = "2025-12-04", place = "Спортивный зал"),
)
Column(
modifier = Modifier.fillMaxSize()
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = "https://palyulin.ru/netcat_files/23/21/rabotnik.jpg",
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(60.dp)
.clip(CircleShape)
)
Spacer(modifier = Modifier.size(16.dp))
Text(
text = "Иванов Иван Иванович",
style = MaterialTheme.typography.titleLarge
)
}
Spacer(modifier = Modifier.size(8.dp))
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
),
onClick = { /* выход */ }
) {
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
)
)
}
}
}
}
}

View File

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