Compare commits

..

5 Commits

Author SHA1 Message Date
b40749faa1 UI/UX solution:
fix POST booking request
2025-12-05 22:19:51 +07:00
dbe735e541 UI/UX solution:
Added clickable date tabs for booking
2025-12-05 21:28:06 +07:00
40a8428a19 Merge remote-tracking branch 'origin/main' 2025-12-05 05:01:00 +07:00
9e603f87e6 UI/UX solution 2025-12-05 05:00:33 +07:00
beb48ab41e With avatar 2025-12-01 21:45:54 +06:00
25 changed files with 487 additions and 205 deletions

View File

@@ -0,0 +1,76 @@
kotlin version: 2.0.21
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing C:/Users/Admin/AndroidStudioProjects/NTO-2025-Android-TeamTask/app/src/main/java/ru/myitschool/work/App.kt:7:5: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)
Caused by: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
... 33 more

View File

@@ -1,4 +1,4 @@
# НТО 2025. II отборочный этап. Командные задания — Android
# НТО 2025. II отборочный этап. Командные задания — Android
## 📖 Предыстория

View File

@@ -11,7 +11,6 @@ object DateUtils {
private val bookFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("dd.MM")
/** Пытаемся распарсить дату в нескольких форматах */
fun parseDate(raw: String): LocalDate? {
return runCatching { LocalDate.parse(raw) }.getOrElse {
runCatching { OffsetDateTime.parse(raw).toLocalDate() }.getOrElse {
@@ -22,13 +21,13 @@ object DateUtils {
}
}
/** Формат для главного экрана: dd.MM.yyyy */
// Формат для главного экрана: dd.MM.yyyy
fun formatForMain(raw: String): String {
val date = parseDate(raw) ?: return raw
return date.format(mainFormatter)
}
/** Формат для экрана бронирования: dd.MM */
// Формат для экрана бронирования: dd.MM
fun formatForBook(raw: String): String {
val date = parseDate(raw) ?: return raw
return date.format(bookFormatter)

View File

@@ -1,4 +1,4 @@
package ru.myitschool.work.data.repo
package ru.myitschool.work.data.repo
import android.content.Context
import androidx.datastore.preferences.preferencesDataStore

View File

@@ -13,7 +13,7 @@ object AuthRepository {
private val KEY_CODE = stringPreferencesKey("auth_code")
private var codeCache: String? = null
// Проверка кода на сервере и сохранение при успехе
suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success ->
if (success) {
@@ -25,7 +25,7 @@ object AuthRepository {
}
}
/** Сохранённый код (из кэша или DataStore) */
suspend fun getSavedCode(): String? {
codeCache?.let { return it }
@@ -37,7 +37,7 @@ object AuthRepository {
return code
}
/** Полная очистка данных авторизации (для logout) */
suspend fun clear() {
codeCache = null
dataStore.edit { prefs ->

View File

@@ -11,9 +11,7 @@ import ru.myitschool.work.domain.entities.UserEntity
object UserRepository {
/**
* Получение информации о пользователе через GET /api/<CODE>/info
*/
suspend fun getUserInfo(): Result<UserEntity> {
val code = AuthRepository.getSavedCode()
?: return Result.failure(IllegalStateException("Auth code is not saved"))
@@ -23,9 +21,7 @@ object UserRepository {
.map { dto -> dto.toDomainUser() }
}
/**
* Доступные слоты бронирования через GET /api/<CODE>/booking
*/
suspend fun getAvailableBookings(): Result<List<BookingEntity>> {
val code = AuthRepository.getSavedCode()
?: return Result.failure(IllegalStateException("Auth code is not saved"))
@@ -35,9 +31,7 @@ object UserRepository {
.map { map -> map.toDomainBookings() }
}
/**
* Создание нового бронирования через POST /api/<CODE>/book
*/
suspend fun book(date: String, placeId: Int): Result<Unit> {
val code = AuthRepository.getSavedCode()
?: return Result.failure(IllegalStateException("Auth code is not saved"))
@@ -49,7 +43,6 @@ object UserRepository {
)
}
// -------------------- Маппинг DTO -> domain --------------------
private fun UserDto.toDomainUser(): UserEntity {
val bookings = booking

View File

@@ -8,7 +8,10 @@ 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.client.utils.EmptyContent.contentType
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
@@ -21,10 +24,7 @@ import ru.myitschool.work.data.source.dto.UserDto
object NetworkDataSource {
/**
* Поставь false, когда поднимешь настоящий бэкенд.
* При true используются локальные заглушки.
*/
private const val USE_STUB: Boolean = false
private val client by lazy {
@@ -73,9 +73,7 @@ object NetworkDataSource {
// ----------------- Публичные методы -----------------
/**
* Проверка кода авторизации через GET /api/<CODE>/auth
*/
suspend fun checkAuth(code: String): Result<Boolean> {
if (USE_STUB) {
// Примитивная проверка заглушки: 4+ символа → ок
@@ -95,9 +93,7 @@ object NetworkDataSource {
}
}
/**
* Получение информации о пользователе через GET /api/<CODE>/info
*/
suspend fun getUserInfo(code: String): Result<UserDto> {
if (USE_STUB) {
return Result.success(stubUser)
@@ -116,9 +112,7 @@ object NetworkDataSource {
}
}
/**
* Получение доступных слотов бронирования через GET /api/<CODE>/booking
*/
suspend fun getAvailableBookings(
code: String,
): Result<Map<String, List<AvailablePlaceDto>>> {
@@ -140,11 +134,7 @@ object NetworkDataSource {
}
}
/**
* Создание нового бронирования через POST /api/<CODE>/book
*
* Тело: { "date": "2025-01-05", "placeID": 1 }
*/
suspend fun book(
code: String,
date: String,
@@ -158,6 +148,7 @@ object NetworkDataSource {
return withContext(Dispatchers.IO) {
runCatching {
val response = client.post(getUrl(code, Constants.BOOK_URL)) {
contentType(ContentType.Application.Json)
setBody(
BookRequestDto(
date = date,

View File

@@ -3,19 +3,6 @@ package ru.myitschool.work.data.source.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* DTO для ответа GET /api/<CODE>/info
*
* Пример:
* {
* "name":"Иванов Петр Федорович",
* "photoUrl":"https://...",
* "booking":{
* "2025-01-05": {"id":1,"place":"102"},
* "2025-01-06": {"id":2,"place":"209.13"}
* }
* }
*/
@Serializable
data class UserDto(
@SerialName("name")
@@ -26,9 +13,7 @@ data class UserDto(
val booking: Map<String, BookedPlaceDto> = emptyMap(),
)
/**
* Элемент бронирования в ответе /info и /booking.
*/
@Serializable
data class BookedPlaceDto(
@SerialName("id")
@@ -37,13 +22,7 @@ data class BookedPlaceDto(
val place: String,
)
/**
* DTO для доступных мест в ответе GET /api/<CODE>/booking:
*
* {
* "2025-01-05": [{"id": 1, "place": "102"}, ...]
* }
*/
@Serializable
data class AvailablePlaceDto(
@SerialName("id")
@@ -52,14 +31,7 @@ data class AvailablePlaceDto(
val place: String,
)
/**
* Тело запроса POST /api/<CODE>/book:
*
* {
* "date": "2025-01-05",
* "placeID": 1
* }
*/
@Serializable
data class BookRequestDto(
@SerialName("date")

View File

@@ -2,16 +2,12 @@ package ru.myitschool.work.domain.booking
import ru.myitschool.work.data.repo.UserRepository
/**
* Юзкейс для создания бронирования.
*
* @param date дата бронирования в формате yyyy-MM-dd
* @param placeId идентификатор места (placeID из бэкенда)
*/
class BookPlaceUseCase(
private val repository: UserRepository,
) {
suspend operator fun invoke(date: String, placeId: Int): Result<Unit> {
return repository.book(date, placeId)
suspend operator fun invoke(roomName: String, placeId: Int): Result<Unit> {
// Нужно получить дату из какого-то источника
// Либо изменить логику в ViewModel
return repository.book(roomName, placeId) // ← но repository.book ожидает date, а не roomName
}
}

View File

@@ -15,6 +15,7 @@ class RootActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
actionBar?.hide()
setContent {
WorkTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->

View File

@@ -40,7 +40,7 @@ fun AppNavHost(
}
}
// Пока не знаем стартовый экран — ничего не рисуем (можно сюда потом воткнуть Splash)
val destination = startDestination ?: return
NavHost(

View File

@@ -2,6 +2,7 @@ package ru.myitschool.work.ui.screen.auth
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
@@ -9,7 +10,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
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
@@ -20,11 +23,14 @@ 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.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import io.ktor.websocket.Frame
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.MainScreenDestination
@@ -50,8 +56,20 @@ fun AuthScreen(
.fillMaxSize()
.padding(all = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Top
) {
Spacer(modifier = Modifier.size(48.dp))
Text(
text = stringResource(R.string.auth_name),
style = MaterialTheme.typography.displaySmall.copy(
fontWeight = FontWeight.Bold
),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.size(48.dp))
Text(
text = stringResource(R.string.auth_title),
style = MaterialTheme.typography.headlineSmall,
@@ -60,7 +78,7 @@ fun AuthScreen(
when (val currentState = state) {
is AuthState.Data -> Content(viewModel, currentState)
is AuthState.Loading -> {
Spacer(modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.size(24.dp))
CircularProgressIndicator(
modifier = Modifier.size(64.dp)
)
@@ -76,28 +94,31 @@ private fun Content(
) {
Spacer(modifier = Modifier.size(16.dp))
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("Введите код сотрудника") }
)
Spacer(modifier = Modifier.size(8.dp))
if (state.isErrorVisible) {
Text(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Auth.ERROR),
text = "Неверный код или ошибка сервера",
color = Color.Red,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.size(8.dp))
}
TextField(
modifier = Modifier
.testTag(TestIds.Auth.CODE_INPUT)
.fillMaxWidth(),
value = state.code,
onValueChange = { viewModel.onIntent(AuthIntent.TextInput(it)) },
label = { Text(stringResource(R.string.auth_label)) }
)
Spacer(modifier = Modifier.size(16.dp))
Button(
@@ -108,7 +129,17 @@ private fun Content(
viewModel.onIntent(AuthIntent.Send(state.code))
},
enabled = state.isButtonEnabled
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.auth_sign_in))
Spacer(modifier = Modifier.size(8.dp))
Icon(
painter = painterResource(R.drawable.ic_arrow_right),
contentDescription = "Войти"
)
}
}
}

View File

@@ -27,12 +27,11 @@ class AuthViewModel : ViewModel() {
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data())
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
// единичное событие навигации на главный экран
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow
init {
// Если код уже сохранён — сразу уходим на главный экран
viewModelScope.launch(Dispatchers.Default) {
val saved = getSavedAuthCodeUseCase()
if (saved != null) {

View File

@@ -1,5 +1,6 @@
package ru.myitschool.work.ui.screen.book
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -13,9 +14,11 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
@@ -26,10 +29,14 @@ 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.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
@Composable
@@ -112,14 +119,34 @@ private fun BookErrorContent(
modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON),
onClick = onRefresh
) {
Text("Обновить")
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = stringResource(R.string.main_screen_refresh))
Spacer(modifier = Modifier.size(8.dp))
Icon(
painter = painterResource(R.drawable.ic_refresh),
contentDescription = "Обновить"
)
}
}
Spacer(modifier = Modifier.size(8.dp))
Button(
modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON),
onClick = onBack
) {
Text("Назад")
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_back),
contentDescription = "Назад"
)
Spacer(modifier = Modifier.size(8.dp))
Text(text = stringResource(R.string.book_screen_back))
}
}
}
}
@@ -145,14 +172,34 @@ private fun BookEmptyContent(
modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON),
onClick = onRefresh
) {
Text("Обновить")
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = stringResource(R.string.main_screen_refresh))
Spacer(modifier = Modifier.size(8.dp))
Icon(
painter = painterResource(R.drawable.ic_refresh),
contentDescription = "Обновить"
)
}
}
Spacer(modifier = Modifier.size(8.dp))
Button(
modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON),
onClick = onBack
) {
Text("Назад")
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_back),
contentDescription = "Назад"
)
Spacer(modifier = Modifier.size(8.dp))
Text(text = stringResource(R.string.book_screen_back))
}
}
}
}
@@ -179,34 +226,31 @@ private fun BookDataContent(
modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON),
onClick = onBack
) {
Text("Назад")
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_back),
contentDescription = "Назад"
)
Spacer(modifier = Modifier.size(8.dp))
Text(text = stringResource(R.string.book_screen_back))
}
}
Button(
modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON),
onClick = onRefresh
) {
Text("Обновить")
}
}
Spacer(modifier = Modifier.size(16.dp))
// Вкладки с датами
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
itemsIndexed(state.dates) { index, dateLabel ->
Surface(
modifier = Modifier
.testTag(TestIds.Book.getIdDateItemByPosition(index)),
tonalElevation = if (index == state.selectedDateIndex) 4.dp else 0.dp
) {
Text(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 8.dp)
.testTag(TestIds.Book.ITEM_DATE),
text = dateLabel,
style = MaterialTheme.typography.bodyMedium
Text(text = stringResource(R.string.main_screen_refresh))
Spacer(modifier = Modifier.size(8.dp))
Icon(
painter = painterResource(R.drawable.ic_refresh),
contentDescription = "Обновить"
)
}
}
@@ -214,7 +258,42 @@ private fun BookDataContent(
Spacer(modifier = Modifier.size(16.dp))
// Список мест
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(state.dates) { index, dateLabel ->
Surface(
modifier = Modifier
.testTag(TestIds.Book.getIdDateItemByPosition(index))
.clickable { onSelectDate(index) }
.clip(RoundedCornerShape(8.dp)),
tonalElevation = if (index == state.selectedDateIndex) 4.dp else 0.dp,
color = if (index == state.selectedDateIndex) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
}
) {
Text(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 8.dp)
.testTag(TestIds.Book.ITEM_DATE),
text = dateLabel,
style = MaterialTheme.typography.bodyMedium,
color = if (index == state.selectedDateIndex) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
)
}
}
}
Spacer(modifier = Modifier.size(16.dp))
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
@@ -262,7 +341,17 @@ private fun BookDataContent(
onClick = onBook,
enabled = state.places.any { it.isSelected }
) {
Text("Забронировать")
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = stringResource(R.string.book_screen_book))
Spacer(modifier = Modifier.size(8.dp))
Icon(
painter = painterResource(R.drawable.ic_arrow_right),
contentDescription = "Забронировать"
)
}
}
}
}

View File

@@ -20,6 +20,5 @@ sealed interface BookState {
val message: String,
) : BookState
/** Нет доступных дат — показываем "Всё забронировано" */
object Empty : BookState
}

View File

@@ -37,7 +37,7 @@ class BookViewModel : ViewModel() {
private var allGroups: List<DateGroup> = emptyList()
private var selectedDateIndex: Int = 0
private var selectedPlaceId: Int? = null
private var selectedSlotId: Int? = null
fun onIntent(intent: BookIntent) {
when (intent) {
@@ -51,7 +51,7 @@ class BookViewModel : ViewModel() {
}
is BookIntent.SelectPlace -> {
selectedPlaceId = intent.id
selectedSlotId = intent.id
val current = _uiState.value
if (current is BookState.Data) {
val updatedPlaces = current.places.map { item ->
@@ -77,7 +77,7 @@ class BookViewModel : ViewModel() {
if (bookings.isEmpty()) {
_uiState.value = BookState.Empty
allGroups = emptyList()
selectedPlaceId = null
selectedSlotId = null
selectedDateIndex = 0
return@fold
}
@@ -86,18 +86,18 @@ class BookViewModel : ViewModel() {
if (allGroups.isEmpty()) {
_uiState.value = BookState.Empty
selectedPlaceId = null
selectedSlotId = null
selectedDateIndex = 0
return@fold
}
selectedDateIndex = 0
selectedPlaceId = null
selectedSlotId = null
val firstGroup = allGroups[0]
val places = firstGroup.slots.mapIndexed { index, slot ->
val places = firstGroup.slots.map { slot ->
BookPlaceItem(
id = index,
id = slot.id,
roomName = slot.roomName,
time = slot.time,
isSelected = false,
@@ -124,10 +124,9 @@ class BookViewModel : ViewModel() {
if (index !in groups.indices) return
selectedDateIndex = index
selectedPlaceId = null
selectedSlotId = null
val group = groups[index]
val places = group.slots.mapIndexed { idx, slot ->
val places = group.slots.map { slot ->
BookPlaceItem(
id = slot.id,
roomName = slot.roomName,
@@ -135,7 +134,6 @@ class BookViewModel : ViewModel() {
isSelected = false,
)
}
val datesLabels = groups.map { it.label }
val current = _uiState.value
@@ -158,11 +156,19 @@ class BookViewModel : ViewModel() {
val current = _uiState.value
if (current !is BookState.Data) return
val placeId = selectedPlaceId ?: return
val place = current.places.firstOrNull { it.id == placeId } ?: return
val slotId = selectedSlotId ?: return
allGroups
.flatMap { it.slots }
.firstOrNull { it.id == slotId }
?: return
val selectedDate = allGroups[selectedDateIndex].date.toString()
viewModelScope.launch(Dispatchers.Default) {
bookPlaceUseCase(place.roomName, place.id)
bookPlaceUseCase(selectedDate, slotId)
.fold(
onSuccess = {
_actionFlow.emit(Action.CloseWithSuccess)

View File

@@ -16,10 +16,12 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.SegmentedButtonDefaults.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -28,12 +30,19 @@ 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.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil3.compose.SubcomposeAsyncImage
import org.intellij.lang.annotations.JdkConstants
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.BookScreenDestination
@@ -130,6 +139,8 @@ private fun MainDataContent(
.padding(16.dp)
) {
Spacer(modifier = Modifier.size(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
@@ -147,38 +158,47 @@ private fun MainDataContent(
}
Spacer(modifier = Modifier.size(16.dp))
Text(
modifier = Modifier.testTag(TestIds.Main.PROFILE_NAME),
modifier = Modifier
.testTag(TestIds.Main.PROFILE_NAME)
.weight(1f),
text = state.name,
style = MaterialTheme.typography.titleMedium
)
}
Spacer(modifier = Modifier.size(16.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
modifier = Modifier.testTag(TestIds.Main.LOGOUT_BUTTON),
onClick = onLogout
modifier = Modifier
.testTag(TestIds.Main.LOGOUT_BUTTON),
onClick = onLogout,
colors = ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colorScheme.errorContainer,
containerColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Выход")
}
Button(
modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON),
onClick = onRefresh
) {
Text("Обновить")
}
Button(
modifier = Modifier.testTag(TestIds.Main.ADD_BUTTON),
onClick = onAddBooking
) {
Text("Бронь")
}
Spacer(modifier = Modifier.size(8.dp))
Icon(
painter = painterResource(R.drawable.ic_logout),
contentDescription = "Выйти"
)
}
Spacer(modifier = Modifier.size(16.dp))
}
}
Spacer(modifier = Modifier.size(24.dp))
Text(
text = stringResource(R.string.main_screen_title),
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold
)
)
Spacer(modifier = Modifier.size(20.dp))
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
@@ -192,21 +212,86 @@ private fun MainDataContent(
Column(
modifier = Modifier.padding(12.dp)
) {
Row(modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.main_screen_date),
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Bold
)
)
Text(
modifier = Modifier.testTag(TestIds.Main.ITEM_DATE),
text = item.dateLabel,
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Bold
)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.main_screen_place),
style = MaterialTheme.typography.bodyMedium
)
Text(
modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE),
text = item.roomName,
style = MaterialTheme.typography.bodySmall
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
Spacer(modifier = Modifier.size(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Button(
modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON),
onClick = onRefresh
) { Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = stringResource(R.string.main_screen_refresh))
Spacer(modifier = Modifier.size(8.dp))
Icon(
painter = painterResource(R.drawable.ic_refresh),
contentDescription = "Обновить"
)
}
}
Button(
modifier = Modifier.testTag(TestIds.Main.ADD_BUTTON),
onClick = onAddBooking
) { Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = stringResource(R.string.main_screen_new_book))
Spacer(modifier = Modifier.size(8.dp))
Icon(
painter = painterResource(R.drawable.ic_add),
contentDescription = "Новая бронь"
)
}
}
}
}
}
@Composable
private fun UserAvatar(

View File

@@ -32,13 +32,13 @@ private val LightColorScheme = lightColorScheme(
*/
)
@Composable
fun WorkTheme(
@Composable
fun WorkTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
@@ -54,4 +54,4 @@ fun WorkTheme(
typography = Typography,
content = content
)
}
}

View File

@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:fillColor="#FF000000"
android:pathData="M0.5,8.5v-1h15v1Z"
android:fillType="evenOdd"/>
<path
android:fillColor="#FF000000"
android:pathData="M8.5,15.5h-1V0.5h1Z"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="16dp" android:viewportHeight="24" android:viewportWidth="24" android:width="16dp">
<path android:fillColor="#FF000000" android:pathData="M11.293,4.707l6.293,6.293l-13.586,0l0,2l13.586,0l-6.293,6.293l1.414,1.414l8.707,-8.707l-8.707,-8.707l-1.414,1.414z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:fillColor="#FF000000"
android:pathData="M11.62,3.81l-4.19,4.19l4.19,4.19l-1.53,1.52l-5.71,-5.71l5.71,-5.71l1.53,1.52z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="16dp" android:viewportHeight="32" android:viewportWidth="32" android:width="16dp">
<path android:fillColor="#FF000000" android:pathData="M27.9,2.58a0.86,0.86 0,0 0,-0.07 -0.1,0.71 0.71,0 0,0 -0.19,-0.23l0,0 -0.09,0a1.12,1.12 0,0 0,-0.25 -0.11L27.1,2 27,2H12a1,1 0,0 0,-1 1V9a1,1 0,0 0,2 0V4h7.19L16.71,5A1,1 0,0 0,16 6V25H13V22a1,1 0,0 0,-2 0v4a1,1 0,0 0,1 1h4v2a1,1 0,0 0,0.4 0.8,1 1,0 0,0 0.6,0.2 1,1 0,0 0,0.29 0l10,-3A1,1 0,0 0,28 26V3A1,1 0,0 0,27.9 2.58ZM26,25.26l-8,2.4V6.74l8,-2.4Z"/>
<path android:fillColor="#FF000000" android:pathData="M7.41,17H14a1,1 0,0 0,0 -2H7.41l1.3,-1.29a1,1 0,0 0,-1.42 -1.42l-3,3a1,1 0,0 0,-0.21 0.33,1 1,0 0,0 0,0.76 1,1 0,0 0,0.21 0.33l3,3a1,1 0,0 0,1.42 0,1 1,0 0,0 0,-1.42Z"/>
<path android:fillColor="#FF000000" android:pathData="M20,17a1,1 0,0 0,0 -2h0a1,1 0,1 0,0 2Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="16dp" android:viewportHeight="24" android:viewportWidth="24" android:width="16dp">
<path android:fillColor="#FF000000" android:pathData="M12,3A8.959,8.959 0,0 0,5 6.339V4H3v6H9V8H6.274A6.982,6.982 0,1 1,5.22 13.751l-1.936,0.5A9,9 0,1 0,12 3Z"/>
</vector>

View File

@@ -1,7 +1,15 @@
<resources>
<string name="app_name">Work</string>
<string name="title_activity_root">RootActivity</string>
<string name="auth_title">Привет! Введи код для авторизации</string>
<string name="auth_name">Система бронирования</string>
<string name="auth_title">Вход в систему</string>
<string name="auth_label">Код</string>
<string name="auth_sign_in">Войти</string>
<string name="main_screen_date">Дата:</string>
<string name="main_screen_place">Место:</string>
<string name="main_screen_title">Мои бронирования</string>
<string name="main_screen_refresh">Обновить</string>
<string name="main_screen_new_book">Новая бронь</string>
<string name="book_screen_book">Забронировать</string>
<string name="book_screen_back">Назад</string>
</resources>

View File

@@ -1,21 +1,16 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
## For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
#
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# Default value: -Xmx1024m -XX:MaxPermSize=256m
# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
#
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
#Thu Dec 04 14:16:52 GMT+07:00 2025
android.nonTransitiveRClass=true
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" -Dfile.encoding\=UTF-8