From d8416283aec5f99e1c1fd26ffec2506754e6bb4a Mon Sep 17 00:00:00 2001 From: solovushka56 <75mehanik@gmail.com> Date: Thu, 11 Dec 2025 01:51:17 +0300 Subject: [PATCH] base functional added --- app/src/main/java/ru/myitschool/work/App.kt | 22 +- .../myitschool/work/data/dtos/BookingDto.kt | 6 + .../work/data/model/BookingInfoResponse.kt | 14 + .../work/data/model/UserInfoResponse.kt | 41 +++ .../work/data/repo/AuthRepository.kt | 72 ++++- .../work/data/repo/BookRepository.kt | 25 ++ .../work/data/repo/MainRepository.kt | 16 + .../work/data/source/NetworkDataSource.kt | 155 +++++++++- .../work/ui/screen/auth/AuthScreen.kt | 123 +++++--- .../work/ui/screen/auth/AuthState.kt | 7 +- .../work/ui/screen/auth/AuthViewModel.kt | 62 +++- .../work/ui/screen/book/BookIntent.kt | 9 + .../work/ui/screen/book/BookScreen.kt | 205 +++++++++++++ .../work/ui/screen/book/BookState.kt | 24 ++ .../work/ui/screen/book/BookViewModel.kt | 182 ++++++++++++ .../work/ui/screen/main/MainIntent.kt | 8 + .../work/ui/screen/main/MainScreen.kt | 280 ++++++++++++++++++ .../work/ui/screen/main/MainState.kt | 24 ++ .../work/ui/screen/main/MainViewModel.kt | 101 +++++++ app/src/main/res/drawable/actions_icon.xml | 27 ++ .../main/res/drawable/app_start_button.xml | 19 ++ app/src/main/res/drawable/back.xml | 13 + app/src/main/res/drawable/branch_opened.xml | 13 + app/src/main/res/drawable/cpu.xml | 13 + app/src/main/res/drawable/editor.xml | 13 + app/src/main/res/drawable/file.xml | 13 + app/src/main/res/drawable/file_plus.xml | 13 + app/src/main/res/drawable/folder.xml | 13 + app/src/main/res/drawable/folder_filled.xml | 9 + app/src/main/res/drawable/folder_plus.xml | 13 + app/src/main/res/drawable/git_commit.xml | 13 + app/src/main/res/drawable/git_icon.xml | 9 + app/src/main/res/drawable/git_pull.xml | 13 + app/src/main/res/drawable/github.xml | 13 + app/src/main/res/drawable/ic_arrow.xml | 13 + app/src/main/res/drawable/ic_arrow_common.xml | 13 + app/src/main/res/drawable/ic_checkbox_no.xml | 9 + app/src/main/res/drawable/ic_checkbox_yes.xml | 9 + app/src/main/res/drawable/ic_file.xml | 19 ++ app/src/main/res/drawable/ic_folder.xml | 9 + .../main/res/drawable/ic_git_clone_mini.xml | 16 + app/src/main/res/drawable/ic_paste.xml | 13 + .../res/drawable/ic_project_create_mini.xml | 19 ++ app/src/main/res/drawable/ic_recreate.xml | 13 + app/src/main/res/drawable/ic_reset.xml | 17 ++ app/src/main/res/drawable/ic_run_mini.xml | 13 + app/src/main/res/drawable/ic_star.xml | 12 + app/src/main/res/drawable/icon.xml | 13 + app/src/main/res/drawable/img_icon_back.xml | 20 ++ app/src/main/res/drawable/img_icon_folder.xml | 16 + app/src/main/res/drawable/img_icon_git.xml | 24 ++ .../main/res/drawable/img_icon_options.xml | 16 + app/src/main/res/drawable/img_icon_run.xml | 20 ++ app/src/main/res/drawable/img_icon_save.xml | 20 ++ app/src/main/res/drawable/play_icon.xml | 13 + app/src/main/res/drawable/search.xml | 13 + app/src/main/res/drawable/search_1.xml | 13 + app/src/main/res/drawable/settings_icon.xml | 13 + app/src/main/res/drawable/settings_menu.xml | 13 + app/src/main/res/drawable/trash.xml | 13 + app/src/main/res/drawable/tutorials.xml | 13 + app/src/main/res/drawable/tutorials_menu.xml | 13 + app/src/main/res/drawable/undo.xml | 13 + app/src/main/res/drawable/x_icon.xml | 13 + app/src/main/res/values/strings.xml | 9 + 65 files changed, 1954 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/ru/myitschool/work/data/dtos/BookingDto.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/model/BookingInfoResponse.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/model/UserInfoResponse.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/repo/MainRepository.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt create mode 100644 app/src/main/res/drawable/actions_icon.xml create mode 100644 app/src/main/res/drawable/app_start_button.xml create mode 100644 app/src/main/res/drawable/back.xml create mode 100644 app/src/main/res/drawable/branch_opened.xml create mode 100644 app/src/main/res/drawable/cpu.xml create mode 100644 app/src/main/res/drawable/editor.xml create mode 100644 app/src/main/res/drawable/file.xml create mode 100644 app/src/main/res/drawable/file_plus.xml create mode 100644 app/src/main/res/drawable/folder.xml create mode 100644 app/src/main/res/drawable/folder_filled.xml create mode 100644 app/src/main/res/drawable/folder_plus.xml create mode 100644 app/src/main/res/drawable/git_commit.xml create mode 100644 app/src/main/res/drawable/git_icon.xml create mode 100644 app/src/main/res/drawable/git_pull.xml create mode 100644 app/src/main/res/drawable/github.xml create mode 100644 app/src/main/res/drawable/ic_arrow.xml create mode 100644 app/src/main/res/drawable/ic_arrow_common.xml create mode 100644 app/src/main/res/drawable/ic_checkbox_no.xml create mode 100644 app/src/main/res/drawable/ic_checkbox_yes.xml create mode 100644 app/src/main/res/drawable/ic_file.xml create mode 100644 app/src/main/res/drawable/ic_folder.xml create mode 100644 app/src/main/res/drawable/ic_git_clone_mini.xml create mode 100644 app/src/main/res/drawable/ic_paste.xml create mode 100644 app/src/main/res/drawable/ic_project_create_mini.xml create mode 100644 app/src/main/res/drawable/ic_recreate.xml create mode 100644 app/src/main/res/drawable/ic_reset.xml create mode 100644 app/src/main/res/drawable/ic_run_mini.xml create mode 100644 app/src/main/res/drawable/ic_star.xml create mode 100644 app/src/main/res/drawable/icon.xml create mode 100644 app/src/main/res/drawable/img_icon_back.xml create mode 100644 app/src/main/res/drawable/img_icon_folder.xml create mode 100644 app/src/main/res/drawable/img_icon_git.xml create mode 100644 app/src/main/res/drawable/img_icon_options.xml create mode 100644 app/src/main/res/drawable/img_icon_run.xml create mode 100644 app/src/main/res/drawable/img_icon_save.xml create mode 100644 app/src/main/res/drawable/play_icon.xml create mode 100644 app/src/main/res/drawable/search.xml create mode 100644 app/src/main/res/drawable/search_1.xml create mode 100644 app/src/main/res/drawable/settings_icon.xml create mode 100644 app/src/main/res/drawable/settings_menu.xml create mode 100644 app/src/main/res/drawable/trash.xml create mode 100644 app/src/main/res/drawable/tutorials.xml create mode 100644 app/src/main/res/drawable/tutorials_menu.xml create mode 100644 app/src/main/res/drawable/undo.xml create mode 100644 app/src/main/res/drawable/x_icon.xml diff --git a/app/src/main/java/ru/myitschool/work/App.kt b/app/src/main/java/ru/myitschool/work/App.kt index aa33483..a878087 100644 --- a/app/src/main/java/ru/myitschool/work/App.kt +++ b/app/src/main/java/ru/myitschool/work/App.kt @@ -2,14 +2,24 @@ package ru.myitschool.work import android.app.Application import android.content.Context +import ru.myitschool.work.data.repo.AuthRepository -class App: Application() { - override fun onCreate() { - super.onCreate() - context = this - } +class App : Application() { companion object { - lateinit var context: Context + @Volatile + private var instance: App? = null + + fun getAppContext(): Context { + return instance?.applicationContext ?: throw IllegalStateException( + "app not initialized") + } + } + + override fun onCreate() { + super.onCreate() + instance = this + + AuthRepository.init(applicationContext) } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/dtos/BookingDto.kt b/app/src/main/java/ru/myitschool/work/data/dtos/BookingDto.kt new file mode 100644 index 0000000..08cce25 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/dtos/BookingDto.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.data.dtos + +class BookingDto { + + +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/model/BookingInfoResponse.kt b/app/src/main/java/ru/myitschool/work/data/model/BookingInfoResponse.kt new file mode 100644 index 0000000..5baf4b2 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/model/BookingInfoResponse.kt @@ -0,0 +1,14 @@ +package ru.myitschool.work.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class BookingInfoResponse( + val dates: Map> +) + +@Serializable +data class PlaceInfo( + val id: Int, + val place: String +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/model/UserInfoResponse.kt b/app/src/main/java/ru/myitschool/work/data/model/UserInfoResponse.kt new file mode 100644 index 0000000..10f14cc --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/model/UserInfoResponse.kt @@ -0,0 +1,41 @@ +package ru.myitschool.work.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserInfoResponse( + @SerialName("name") + val name: String, + + @SerialName("photoUrl") + val photoUrl: String?, + + @SerialName("booking") + val bookingMap: Map = emptyMap() +) { + val bookings: List + get() = bookingMap.map { (date, info) -> + BookingResponse( + date = date, + place = info.place, + bookingId = info.id + ) + } +} + +@Serializable +data class BookingInfo( + @SerialName("id") + val id: Int, + + @SerialName("place") + val place: String +) + +@Serializable +data class BookingResponse( + val date: String, + val place: String, + val bookingId: Int +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt index 3ef28f1..2a54875 100644 --- a/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt +++ b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt @@ -1,16 +1,86 @@ package ru.myitschool.work.data.repo +import android.content.Context +import android.content.Context.MODE_PRIVATE +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import ru.myitschool.work.data.source.NetworkDataSource object AuthRepository { + private const val PREFS_NAME = "auth_prefs" + private const val KEY_CODE = "auth_code" + private const val KEY_NAME = "user_name" + private const val KEY_PHOTO = "user_photo" + private var _context: Context? = null private var codeCache: String? = null + private var userCache: UserCache? = null + + private val _isAuthorized = MutableStateFlow(false) + val isAuthorized: StateFlow = _isAuthorized.asStateFlow() + + fun init(context: Context) { + if (_context == null) { + _context = context.applicationContext + + val prefs = _context!!.getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + codeCache = prefs.getString(KEY_CODE, null) + val name = prefs.getString(KEY_NAME, null) + val photo = prefs.getString(KEY_PHOTO, null) + + if (codeCache != null && name != null) { + userCache = UserCache(name, photo) + _isAuthorized.value = true + } + } + } + + private fun requireContext(): Context { + return _context ?: throw IllegalStateException( + "AuthRepository not inited" + ) + } + + private fun getPrefs() = requireContext().getSharedPreferences(PREFS_NAME, MODE_PRIVATE) suspend fun checkAndSave(text: String): Result { return NetworkDataSource.checkAuth(text).onSuccess { success -> if (success) { codeCache = text + _isAuthorized.value = true + getPrefs().edit() + .putString(KEY_CODE, text) + .apply() } + }.onFailure { exception -> + println("Auth error: ${exception.message}") } } -} \ No newline at end of file + + fun getCurrentCode(): String? = codeCache + + fun saveUserInfo(name: String, photo: String?) { + userCache = UserCache(name, photo) + getPrefs().edit() + .putString(KEY_NAME, name) + .putString(KEY_PHOTO, photo) + .apply() + } + + fun getUserInfo(): UserCache? = userCache + + fun clear() { + codeCache = null + userCache = null + _isAuthorized.value = false + getPrefs().edit() + .clear() + .apply() + } +} + +data class UserCache( + val name: String, + val photo: String? +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt new file mode 100644 index 0000000..f8049bd --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt @@ -0,0 +1,25 @@ +package ru.myitschool.work.data.repo + +import ru.myitschool.work.data.model.BookingInfoResponse +import ru.myitschool.work.data.source.AuthException +import ru.myitschool.work.data.source.NetworkDataSource + +object BookRepository { + suspend fun getAvailableBookings(): Result { + val code = AuthRepository.getCurrentCode() + return if (code != null) { + NetworkDataSource.getBookingInfo(code) + } else { + Result.failure(AuthException("user not authorized")) + } + } + + suspend fun book(date: String, placeId: Int): Result { + val code = AuthRepository.getCurrentCode() + return if (code != null) { + NetworkDataSource.book(code, date, placeId) + } else { + Result.failure(AuthException("user not authorized")) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/repo/MainRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/MainRepository.kt new file mode 100644 index 0000000..9108be3 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/MainRepository.kt @@ -0,0 +1,16 @@ +package ru.myitschool.work.data.repo + +import ru.myitschool.work.data.model.UserInfoResponse +import ru.myitschool.work.data.source.AuthException +import ru.myitschool.work.data.source.NetworkDataSource + +object MainRepository { + suspend fun getUserInfo(): Result { + val code = AuthRepository.getCurrentCode() + return if (code != null) { + NetworkDataSource.getInfo(code) + } else { + Result.failure(AuthException("user not pass auth")) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt index fbdfef5..f7c9300 100644 --- a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt +++ b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt @@ -1,42 +1,183 @@ package ru.myitschool.work.data.source import io.ktor.client.HttpClient +import io.ktor.client.call.body import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.HttpRequestTimeoutException +import io.ktor.client.plugins.HttpResponseValidator import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsText +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import ru.myitschool.work.core.Constants +import ru.myitschool.work.data.model.BookingInfoResponse +import ru.myitschool.work.data.model.UserInfoResponse +import java.net.ConnectException +import java.net.SocketTimeoutException + +@Serializable +data class BookRequest( + val date: String, + val placeId: Int +) object NetworkDataSource { private val client by lazy { HttpClient(CIO) { + engine { + requestTimeout = 10000 + } + + HttpResponseValidator { + validateResponse { response -> + val statusCode = response.status.value + when (statusCode) { + in 400..499 -> { + when (response.status) { + HttpStatusCode.Unauthorized -> { + throw AuthException("Неверный код авторизации") + } + HttpStatusCode.NotFound -> { + throw NotFoundException("Ресурс не найден") + } + HttpStatusCode.Conflict -> { + throw ConflictException("Место уже забронировано") + } + HttpStatusCode.BadRequest -> { + throw BadRequestException("Некорректный запрос") + } + else -> { + val exceptionMessage = response.body() + throw ClientRequestException( + response, + exceptionMessage.ifBlank { + "Клиентская ошибка: $statusCode" + } + ) + } + } + } + in 500..599 -> { + throw ServerException("Ошибка сервера: $statusCode") + } + } + } + + handleResponseExceptionWithRequest { exception, _ -> + when (exception) { + is SocketTimeoutException -> { + throw NetworkException("Таймаут соединения") + } + is ConnectException -> { + throw NetworkException("Не удалось подключиться к серверу") + } + is HttpRequestTimeoutException -> { + throw NetworkException("Таймаут запроса") + } + } + } + } + install(ContentNegotiation) { json( Json { isLenient = true ignoreUnknownKeys = true - explicitNulls = true + explicitNulls = false encodeDefaults = true } ) } + + defaultRequest { + contentType(ContentType.Application.Json) + } } } suspend fun checkAuth(code: String): Result = withContext(Dispatchers.IO) { return@withContext runCatching { val response = client.get(getUrl(code, Constants.AUTH_URL)) - when (response.status) { - HttpStatusCode.OK -> true - else -> error(response.bodyAsText()) + response.status == HttpStatusCode.OK + }.recoverCatching { throwable -> + when (throwable) { + is AuthException -> false + else -> throw throwable } } } - private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl" -} \ No newline at end of file + suspend fun getInfo(code: String): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.get(getUrl(code, Constants.INFO_URL)) + + when (response.status) { + HttpStatusCode.OK -> response.body() + else -> { + val errorMessage = response.body().ifBlank { + "Ошибка: ${response.status}" + } + throw RuntimeException(errorMessage) + } + } + } + } + + suspend fun getBookingInfo(code: String): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.get(getUrl(code, Constants.BOOKING_URL)) + + when (response.status) { + HttpStatusCode.OK -> response.body() + HttpStatusCode.NoContent -> BookingInfoResponse(emptyMap()) + else -> { + val errorMsg = response.body().ifBlank { + "Ошибка загрузки данных: ${response.status}" + } + throw RuntimeException(errorMsg) + } + } + } + } + + suspend fun book(code: String, date: String, placeId: Int): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.post(getUrl(code, Constants.BOOK_URL)) { + setBody(BookRequest(date, placeId)) + } + + when (response.status) { + HttpStatusCode.OK -> Unit + HttpStatusCode.Created -> Unit + HttpStatusCode.Conflict -> throw ConflictException("Место уже забронировано") + else -> { + val errorMsg = response.body().ifBlank { + "Ошибка бронирования: ${response.status}" + } + throw RuntimeException(errorMsg) + } + } + } + } + + private fun getUrl(code: String, targetUrl: String): String { + return "${Constants.HOST}/api/$code$targetUrl" + } +} + +class NetworkException(message: String) : Exception(message) +class AuthException(message: String) : Exception(message) +class NotFoundException(message: String) : Exception(message) +class ConflictException(message: String) : Exception(message) +class ServerException(message: String) : Exception(message) +class BadRequestException(message: String) : Exception(message) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt index f99978e..82a8afc 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt @@ -1,10 +1,12 @@ package ru.myitschool.work.ui.screen.auth import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button @@ -16,12 +18,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -38,31 +38,54 @@ fun AuthScreen( navController: NavController ) { val state by viewModel.uiState.collectAsState() + val keyboardController = LocalSoftwareKeyboardController.current LaunchedEffect(Unit) { - viewModel.actionFlow.collect { - navController.navigate(MainScreenDestination) + viewModel.actionFlow.collect { action -> + when (action) { + is AuthAction.NavigateToMain -> { + navController.navigate(MainScreenDestination) { + popUpTo(0) + } + } + } } } - Column( + Box( modifier = Modifier .fillMaxSize() .padding(all = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + contentAlignment = Alignment.Center ) { - Text( - 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 -> { - CircularProgressIndicator( - modifier = Modifier.size(64.dp) - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.auth_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 32.dp) + ) + + when (val currentState = state) { + is AuthState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.size(64.dp) + ) + } + is AuthState.Data -> { + Content( + state = currentState, + // ?????? bug fix + onTextChange = { viewModel.onIntent(AuthIntent.TextInput(it)) }, + onSendClick = { + keyboardController?.hide() + viewModel.onIntent(AuthIntent.Send(it)) + } + ) + } } } } @@ -70,28 +93,48 @@ fun AuthScreen( @Composable private fun Content( - viewModel: AuthViewModel, - state: AuthState.Data + state: AuthState.Data, + onTextChange: (String) -> Unit, + onSendClick: (String) -> Unit ) { - var inputText by remember { mutableStateOf("") } - Spacer(modifier = Modifier.size(16.dp)) - TextField( - modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(), - value = inputText, - onValueChange = { - inputText = it - viewModel.onIntent(AuthIntent.TextInput(it)) - }, - label = { Text(stringResource(R.string.auth_label)) } - ) - Spacer(modifier = Modifier.size(16.dp)) - Button( - modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(), - onClick = { - viewModel.onIntent(AuthIntent.Send(inputText)) - }, - enabled = true + val isButtonEnabled = state.code.length == 4 && + state.code.matches(Regex("^[a-zA-Z0-9]{4}$")) + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text(stringResource(R.string.auth_sign_in)) + if (state.error != null) { + Text( + text = state.error, + color = Color.Red, + modifier = Modifier + .testTag(TestIds.Auth.ERROR) + .padding(bottom = 16.dp) + ) + } + + TextField( + modifier = Modifier + .testTag(TestIds.Auth.CODE_INPUT) + .fillMaxWidth(), + value = state.code, + onValueChange = onTextChange, + label = { Text(stringResource(R.string.auth_label)) }, + singleLine = true, + isError = state.error != null + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + modifier = Modifier + .testTag(TestIds.Auth.SIGN_BUTTON) + .fillMaxWidth(), + onClick = { onSendClick(state.code) }, + enabled = isButtonEnabled + ) { + Text(stringResource(R.string.auth_sign_in)) + } } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt index a06ba76..0f116ad 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt @@ -1,6 +1,9 @@ package ru.myitschool.work.ui.screen.auth sealed interface AuthState { - object Loading: AuthState - object Data: AuthState + object Loading : AuthState + data class Data( + val code: String = "", + val error: String? = null + ) : AuthState } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt index 3153640..e24e8e3 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt @@ -2,7 +2,6 @@ package ru.myitschool.work.ui.screen.auth import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -13,31 +12,62 @@ import kotlinx.coroutines.launch import ru.myitschool.work.data.repo.AuthRepository import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase +private val authRepo = AuthRepository +private val checkAndSaveAuthCodeUseCase = CheckAndSaveAuthCodeUseCase(authRepo) + class AuthViewModel : ViewModel() { - private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) } - private val _uiState = MutableStateFlow(AuthState.Data) + private val _uiState = MutableStateFlow(AuthState.Data()) val uiState: StateFlow = _uiState.asStateFlow() - private val _actionFlow: MutableSharedFlow = MutableSharedFlow() - val actionFlow: SharedFlow = _actionFlow + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow fun onIntent(intent: AuthIntent) { when (intent) { is AuthIntent.Send -> { - viewModelScope.launch(Dispatchers.Default) { - _uiState.update { AuthState.Loading } - checkAndSaveAuthCodeUseCase.invoke("9999").fold( - onSuccess = { - _actionFlow.emit(Unit) - }, - onFailure = { error -> - error.printStackTrace() - _actionFlow.emit(Unit) - } + if (validateCode(intent.text)) { + viewModelScope.launch { + _uiState.update { AuthState.Loading } + + checkAndSaveAuthCodeUseCase.invoke(intent.text).fold( + onSuccess = { + _actionFlow.emit(AuthAction.NavigateToMain) + }, + onFailure = { error -> + _uiState.update { + AuthState.Data( + code = intent.text, + error = error.message ?: "error" + ) + } + } + ) + } + } else { + _uiState.update { + AuthState.Data( + code = intent.text, + error = "wrong" + ) + } + } + } + is AuthIntent.TextInput -> { + _uiState.update { + AuthState.Data( + code = intent.text, + error = null ) } } - is AuthIntent.TextInput -> Unit } } + + private fun validateCode(code: String): Boolean { + return code.length == 4 && code.matches(Regex("^[a-zA-Z0-9]{4}$")) + } +} + +sealed interface AuthAction { + object NavigateToMain : AuthAction } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt new file mode 100644 index 0000000..bd2f09a --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.ui.screen.book + +sealed interface BookIntent { + object Refresh : BookIntent + object Back : BookIntent + object Book : BookIntent + data class SelectDate(val index: Int) : BookIntent + data class SelectPlace(val index: Int) : BookIntent +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt new file mode 100644 index 0000000..1c7f08a --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt @@ -0,0 +1,205 @@ +package ru.myitschool.work.ui.screen.book + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.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 +import androidx.compose.material3.Icon +import androidx.compose.ui.res.painterResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BookScreen( + viewModel: BookViewModel = viewModel(), + navController: NavController +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actionFlow.collect { action -> + when (action) { + BookAction.NavigateBack -> { + navController.popBackStack() + } + BookAction.NavigateBackWithRefresh -> { + navController.previousBackStackEntry?.savedStateHandle?.set( + "shouldRefresh", true) + navController.popBackStack() + } + } + } + } + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.book_title)) }, + navigationIcon = { + IconButton( + onClick = { viewModel.onIntent(BookIntent.Back) }, + modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON) + ) { + Icon( + // TODO + painter = painterResource(id = R.drawable.back), + contentDescription = null + ) + } + } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + when (val currentState = state) { + BookState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + BookState.Empty -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.book_empty), + modifier = Modifier.testTag(TestIds.Book.EMPTY) + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { viewModel.onIntent(BookIntent.Back) }, + modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON) + ) { + Text(stringResource(R.string.book_back)) + } + } + } + is BookState.Data -> { + if (currentState.error != null) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = currentState.error, + modifier = Modifier.testTag(TestIds.Book.ERROR) + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { viewModel.onIntent(BookIntent.Refresh) }, + modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON) + ) { + Text(stringResource(R.string.book_refresh)) + } + } + } else { + Content( + state = currentState, + onDateSelect = { viewModel.onIntent(BookIntent.SelectDate(it)) }, + onPlaceSelect = { viewModel.onIntent(BookIntent.SelectPlace(it)) }, + onBookClick = { viewModel.onIntent(BookIntent.Book) } + ) + } + } + } + } + } +} + +@Composable +private fun Content( + state: BookState.Data, + onDateSelect: (Int) -> Unit, + onPlaceSelect: (Int) -> Unit, + onBookClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + ScrollableTabRow( + selectedTabIndex = state.selectedDateIndex, + modifier = Modifier.fillMaxWidth() + ) { + state.dates.forEachIndexed { index, dateItem -> + Tab( + selected = state.selectedDateIndex == index, + onClick = { onDateSelect(index) }, + modifier = Modifier.testTag(TestIds.Book.getIdDateItemByPosition(index)), + text = { + Text( + text = dateItem.displayDate, + modifier = Modifier.testTag(TestIds.Book.ITEM_DATE) + ) + } + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + val selectedDate = state.dates[state.selectedDateIndex] + Column { + selectedDate.places.forEachIndexed { index, placeItem -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = state.selectedPlaceIndex == index, + onClick = { onPlaceSelect(index) } + ) + .padding(16.dp) + .testTag(TestIds.Book.getIdPlaceItemByPosition(index)) + ) { + RadioButton( + selected = state.selectedPlaceIndex == index, + onClick = { onPlaceSelect(index) }, + modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_SELECTOR) + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = placeItem.name, + modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_TEXT) + ) + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onBookClick, + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Book.BOOK_BUTTON), + enabled = state.selectedPlaceIndex != null + ) { + Text(stringResource(R.string.book_book)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt new file mode 100644 index 0000000..ebf897e --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt @@ -0,0 +1,24 @@ +package ru.myitschool.work.ui.screen.book + +sealed interface BookState { + object Loading : BookState + data class Data( + val dates: List = emptyList(), + val selectedDateIndex: Int = 0, + val selectedPlaceIndex: Int? = null, + val error: String? = null + ) : BookState + object Empty : BookState +} + +data class DateItem( + val id: String, + val displayDate: String, // dd.MM + val rawDate: String, // for api + val places: List +) + +data class PlaceItem( + val id: String, + val name: String +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt new file mode 100644 index 0000000..536143c --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt @@ -0,0 +1,182 @@ +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.flow.update +import kotlinx.coroutines.launch +import ru.myitschool.work.data.repo.BookRepository +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val bookRepo = BookRepository + +class BookViewModel : ViewModel() { + private val _uiState = MutableStateFlow(BookState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow + + init { + loadData() + } + + fun onIntent(intent: BookIntent) { + when (intent) { + BookIntent.Refresh -> { + loadData() + } + BookIntent.Back -> { + viewModelScope.launch { + _actionFlow.emit(BookAction.NavigateBack) + } + } + BookIntent.Book -> { + bookSelected() + } + is BookIntent.SelectDate -> { + _uiState.update { state -> + if (state is BookState.Data) { + state.copy( + selectedDateIndex = intent.index, + selectedPlaceIndex = null + ) + } else { + state + } + } + } + is BookIntent.SelectPlace -> { + _uiState.update { state -> + if (state is BookState.Data) { + state.copy(selectedPlaceIndex = intent.index) + } else { + state + } + } + } + } + } + + private fun loadData() { + viewModelScope.launch { + _uiState.update { BookState.Loading } + + bookRepo.getAvailableBookings().fold( + onSuccess = { response -> + val dateItems = response.dates.entries + .filter { (_, places) -> places.isNotEmpty() } + .sortedBy { (date, _) -> parseDate(date) } + .map { (dateString, places) -> + DateItem( + id = dateString, + displayDate = formatDateForDisplay(dateString), + rawDate = dateString, + places = places.map { placeInfo -> + PlaceItem( + id = placeInfo.id.toString(), + name = placeInfo.place + ) + } + ) + } + + if (dateItems.isEmpty()) { + _uiState.update { BookState.Empty } + } else { + _uiState.update { + BookState.Data( + dates = dateItems, + selectedDateIndex = 0 + ) + } + } + }, + onFailure = { error -> + _uiState.update { + BookState.Data( + error = error.message ?: "data load err" + ) + } + } + ) + } + } + + private fun bookSelected() { + viewModelScope.launch { + val state = _uiState.value + if (state is BookState.Data) { + val selectedDate = state.dates.getOrNull(state.selectedDateIndex) + val selectedPlaceIndex = state.selectedPlaceIndex + + if (selectedDate != null && selectedPlaceIndex != null) { + val selectedPlace = selectedDate.places.getOrNull(selectedPlaceIndex) + + if (selectedPlace != null) { + _uiState.update { BookState.Loading } + + bookRepo.book( + date = selectedDate.rawDate, + placeId = selectedPlace.id.toInt() + ).fold( + onSuccess = { + _actionFlow.emit(BookAction.NavigateBackWithRefresh) + }, + onFailure = { error -> + _uiState.update { + state.copy( + error = error.message ?: "book error" + ) + } + } + ) + } else { + _uiState.update { + state.copy( + error = "place !selected" + ) + } + } + } else { + _uiState.update { + state.copy( + error = "select place for booking" + ) + } + } + } + } + } + + private fun parseDate(dateString: String): Long { + return try { + val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + format.parse(dateString)?.time ?: 0L + } catch (e: Exception) { + 0L + } + } + + private fun formatDateForDisplay(dateString: String): String { + return try { + val inputFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val outputFormat = SimpleDateFormat("dd.MM", Locale.getDefault()) + val date = inputFormat.parse(dateString) + date?.let { outputFormat.format(it) } ?: dateString + } catch (e: Exception) { + dateString + } + } +} + +sealed interface BookAction { + object NavigateBack : BookAction + object NavigateBackWithRefresh : BookAction +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt new file mode 100644 index 0000000..354c37d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt @@ -0,0 +1,8 @@ +package ru.myitschool.work.ui.screen.main + +sealed interface MainIntent { + object Logout : MainIntent + object Refresh : MainIntent + object AddBooking : MainIntent + data class ItemClick(val position: Int) : MainIntent +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt new file mode 100644 index 0000000..2f9d4a7 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt @@ -0,0 +1,280 @@ +package ru.myitschool.work.ui.screen.main + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +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 coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +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 + +@Composable +fun MainScreen( + viewModel: MainViewModel = viewModel(), + navController: NavController +) { + val state by viewModel.uiState.collectAsState() + + val shouldRefresh by navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow("shouldRefresh", false) + ?.collectAsState() ?: remember { mutableStateOf(false) } + + LaunchedEffect(shouldRefresh) { + if (shouldRefresh) { + viewModel.onIntent(MainIntent.Refresh) + navController.currentBackStackEntry?.savedStateHandle?.remove("shouldRefresh") + } + } + + LaunchedEffect(Unit) { + viewModel.actionFlow.collect { action -> + when (action) { + is MainAction.NavigateToAuth -> { + navController.navigate(AuthScreenDestination) { + popUpTo(0) + } + } + is MainAction.NavigateToBooking -> { + navController.navigate(BookScreenDestination) + } + } + } + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + when (val currentState = state) { + is MainState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + is MainState.Data -> { + if (currentState.error != null) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = currentState.error, + color = Color.Red, + modifier = Modifier.testTag(TestIds.Main.ERROR) + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { viewModel.onIntent(MainIntent.Refresh) }, + modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON) + ) { + Text(stringResource(R.string.main_refresh)) + } + } + } else { + Column( + modifier = Modifier.fillMaxSize() + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (!currentState.userPhotoUrl.isNullOrEmpty()) { + Image( + painter = rememberAsyncImagePainter( + ImageRequest.Builder(LocalContext.current) + .data(currentState.userPhotoUrl) + .build() + ), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(64.dp) + .testTag(TestIds.Main.PROFILE_IMAGE) + ) + } else { + Icon( + painter = painterResource(id = R.drawable.github), + contentDescription = null, + modifier = Modifier + .size(64.dp) + .testTag(TestIds.Main.PROFILE_IMAGE) + ) + } + + Spacer(modifier = Modifier.size(16.dp)) + + Column { + Text( + text = currentState.userName, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.testTag(TestIds.Main.PROFILE_NAME) + ) + if (currentState.bookings.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Забронировано мест: ${currentState.bookings.size}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + // Кнопки действий + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { viewModel.onIntent(MainIntent.Logout) }, + modifier = Modifier + .weight(1f) + .testTag(TestIds.Main.LOGOUT_BUTTON) + ) { + Text(stringResource(R.string.main_logout)) + } + + Button( + onClick = { viewModel.onIntent(MainIntent.Refresh) }, + modifier = Modifier + .weight(1f) + .testTag(TestIds.Main.REFRESH_BUTTON) + ) { + Text(stringResource(R.string.main_refresh)) + } + + Button( + onClick = { viewModel.onIntent(MainIntent.AddBooking) }, + modifier = Modifier + .weight(1f) + .testTag(TestIds.Main.ADD_BUTTON) + ) { + Text(stringResource(R.string.main_add_booking)) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Заголовок списка бронирований + if (currentState.bookings.isNotEmpty()) { + Text( + text = "Мои бронирования:", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "У вас нет активных бронирований", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Список бронирований + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + itemsIndexed(currentState.bookings) { index, booking -> + BookingItem( + booking = booking, + position = index, + modifier = Modifier.testTag( + TestIds.Main.getIdItemByPosition(index) + ) + ) + } + } + } + } + } + } + } +} + +@Composable +private fun BookingItem( + booking: BookingItem, + position: Int, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = booking.place, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE) + ) + Text( + text = booking.getFormattedDate(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(TestIds.Main.ITEM_DATE) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt new file mode 100644 index 0000000..7ccb5be --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt @@ -0,0 +1,24 @@ +package ru.myitschool.work.ui.screen.main + +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +sealed interface MainState { + object Loading : MainState + data class Data( + val userName: String = "", + val userPhotoUrl: String? = null, + val bookings: List = emptyList(), + val error: String? = null + ) : MainState +} + +data class BookingItem( + val id: String, + val date: LocalDate, + val place: String +) { + fun getFormattedDate(): String { + return date.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt new file mode 100644 index 0000000..7e576a4 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt @@ -0,0 +1,101 @@ +package ru.myitschool.work.ui.screen.main + +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.flow.update +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.data.repo.MainRepository + +private val authRepo = AuthRepository +private val mainRepo = MainRepository + +class MainViewModel : ViewModel() { + private val _uiState = MutableStateFlow(MainState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow + + private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + init { + loadData() + } + + fun onIntent(intent: MainIntent) { + when (intent) { + MainIntent.Logout -> { + val currentState = _uiState.value + if (currentState is MainState.Data) { + authRepo.saveUserInfo(currentState.userName, currentState.userPhotoUrl) + } + authRepo.clear() + viewModelScope.launch { + _actionFlow.emit(MainAction.NavigateToAuth) + } + } + MainIntent.Refresh -> { + loadData() + } + MainIntent.AddBooking -> { + viewModelScope.launch { + _actionFlow.emit(MainAction.NavigateToBooking) + } + } + is MainIntent.ItemClick -> { + + } + } + } + + private fun loadData() { + viewModelScope.launch { + _uiState.update { MainState.Loading } + mainRepo.getUserInfo().fold( + onSuccess = { userInfo -> + val bookings = userInfo.bookings.mapNotNull { bookingResponse -> + try { + BookingItem( + id = bookingResponse.bookingId.toString(), + date = LocalDate.parse(bookingResponse.date, dateFormatter), + place = bookingResponse.place + ) + } catch (e: Exception) { + null + } + }.sortedBy { it.date } + + authRepo.saveUserInfo(userInfo.name, userInfo.photoUrl) + + _uiState.update { + MainState.Data( + userName = userInfo.name, + userPhotoUrl = userInfo.photoUrl, + bookings = bookings + ) + } + }, + onFailure = { error -> + _uiState.update { + MainState.Data( + error = error.message ?: "data load err" + ) + } + } + ) + } + } +} + +sealed interface MainAction { + object NavigateToAuth : MainAction + object NavigateToBooking : MainAction +} \ No newline at end of file diff --git a/app/src/main/res/drawable/actions_icon.xml b/app/src/main/res/drawable/actions_icon.xml new file mode 100644 index 0000000..8614e3c --- /dev/null +++ b/app/src/main/res/drawable/actions_icon.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/app_start_button.xml b/app/src/main/res/drawable/app_start_button.xml new file mode 100644 index 0000000..4347509 --- /dev/null +++ b/app/src/main/res/drawable/app_start_button.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/drawable/back.xml b/app/src/main/res/drawable/back.xml new file mode 100644 index 0000000..1feccf7 --- /dev/null +++ b/app/src/main/res/drawable/back.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/branch_opened.xml b/app/src/main/res/drawable/branch_opened.xml new file mode 100644 index 0000000..621447e --- /dev/null +++ b/app/src/main/res/drawable/branch_opened.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/cpu.xml b/app/src/main/res/drawable/cpu.xml new file mode 100644 index 0000000..5fb11eb --- /dev/null +++ b/app/src/main/res/drawable/cpu.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/editor.xml b/app/src/main/res/drawable/editor.xml new file mode 100644 index 0000000..30055b6 --- /dev/null +++ b/app/src/main/res/drawable/editor.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/file.xml b/app/src/main/res/drawable/file.xml new file mode 100644 index 0000000..cf0654f --- /dev/null +++ b/app/src/main/res/drawable/file.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/file_plus.xml b/app/src/main/res/drawable/file_plus.xml new file mode 100644 index 0000000..0a9e8cb --- /dev/null +++ b/app/src/main/res/drawable/file_plus.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/folder.xml b/app/src/main/res/drawable/folder.xml new file mode 100644 index 0000000..a2a3fca --- /dev/null +++ b/app/src/main/res/drawable/folder.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/folder_filled.xml b/app/src/main/res/drawable/folder_filled.xml new file mode 100644 index 0000000..ab8508a --- /dev/null +++ b/app/src/main/res/drawable/folder_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/folder_plus.xml b/app/src/main/res/drawable/folder_plus.xml new file mode 100644 index 0000000..a1d9665 --- /dev/null +++ b/app/src/main/res/drawable/folder_plus.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/git_commit.xml b/app/src/main/res/drawable/git_commit.xml new file mode 100644 index 0000000..808e506 --- /dev/null +++ b/app/src/main/res/drawable/git_commit.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/git_icon.xml b/app/src/main/res/drawable/git_icon.xml new file mode 100644 index 0000000..9d2121b --- /dev/null +++ b/app/src/main/res/drawable/git_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/git_pull.xml b/app/src/main/res/drawable/git_pull.xml new file mode 100644 index 0000000..7c7dd61 --- /dev/null +++ b/app/src/main/res/drawable/git_pull.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/github.xml b/app/src/main/res/drawable/github.xml new file mode 100644 index 0000000..c936738 --- /dev/null +++ b/app/src/main/res/drawable/github.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow.xml b/app/src/main/res/drawable/ic_arrow.xml new file mode 100644 index 0000000..6964e47 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_common.xml b/app/src/main/res/drawable/ic_arrow_common.xml new file mode 100644 index 0000000..5f53885 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_common.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_checkbox_no.xml b/app/src/main/res/drawable/ic_checkbox_no.xml new file mode 100644 index 0000000..d2ba3a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_checkbox_no.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_checkbox_yes.xml b/app/src/main/res/drawable/ic_checkbox_yes.xml new file mode 100644 index 0000000..8be0c91 --- /dev/null +++ b/app/src/main/res/drawable/ic_checkbox_yes.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_file.xml b/app/src/main/res/drawable/ic_file.xml new file mode 100644 index 0000000..070d4e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_file.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_folder.xml b/app/src/main/res/drawable/ic_folder.xml new file mode 100644 index 0000000..f1e3c49 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_git_clone_mini.xml b/app/src/main/res/drawable/ic_git_clone_mini.xml new file mode 100644 index 0000000..1c2440c --- /dev/null +++ b/app/src/main/res/drawable/ic_git_clone_mini.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_paste.xml b/app/src/main/res/drawable/ic_paste.xml new file mode 100644 index 0000000..d5639ec --- /dev/null +++ b/app/src/main/res/drawable/ic_paste.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_project_create_mini.xml b/app/src/main/res/drawable/ic_project_create_mini.xml new file mode 100644 index 0000000..6ad9810 --- /dev/null +++ b/app/src/main/res/drawable/ic_project_create_mini.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_recreate.xml b/app/src/main/res/drawable/ic_recreate.xml new file mode 100644 index 0000000..8abafd8 --- /dev/null +++ b/app/src/main/res/drawable/ic_recreate.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_reset.xml b/app/src/main/res/drawable/ic_reset.xml new file mode 100644 index 0000000..a362ba0 --- /dev/null +++ b/app/src/main/res/drawable/ic_reset.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_run_mini.xml b/app/src/main/res/drawable/ic_run_mini.xml new file mode 100644 index 0000000..abda9b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_run_mini.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_star.xml b/app/src/main/res/drawable/ic_star.xml new file mode 100644 index 0000000..e7b2801 --- /dev/null +++ b/app/src/main/res/drawable/ic_star.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/icon.xml b/app/src/main/res/drawable/icon.xml new file mode 100644 index 0000000..db35309 --- /dev/null +++ b/app/src/main/res/drawable/icon.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/img_icon_back.xml b/app/src/main/res/drawable/img_icon_back.xml new file mode 100644 index 0000000..4117e06 --- /dev/null +++ b/app/src/main/res/drawable/img_icon_back.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/drawable/img_icon_folder.xml b/app/src/main/res/drawable/img_icon_folder.xml new file mode 100644 index 0000000..4b210b8 --- /dev/null +++ b/app/src/main/res/drawable/img_icon_folder.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/img_icon_git.xml b/app/src/main/res/drawable/img_icon_git.xml new file mode 100644 index 0000000..94dd52d --- /dev/null +++ b/app/src/main/res/drawable/img_icon_git.xml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_icon_options.xml b/app/src/main/res/drawable/img_icon_options.xml new file mode 100644 index 0000000..20df953 --- /dev/null +++ b/app/src/main/res/drawable/img_icon_options.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/img_icon_run.xml b/app/src/main/res/drawable/img_icon_run.xml new file mode 100644 index 0000000..4f58616 --- /dev/null +++ b/app/src/main/res/drawable/img_icon_run.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/drawable/img_icon_save.xml b/app/src/main/res/drawable/img_icon_save.xml new file mode 100644 index 0000000..3187fe1 --- /dev/null +++ b/app/src/main/res/drawable/img_icon_save.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/drawable/play_icon.xml b/app/src/main/res/drawable/play_icon.xml new file mode 100644 index 0000000..920deaf --- /dev/null +++ b/app/src/main/res/drawable/play_icon.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/search.xml b/app/src/main/res/drawable/search.xml new file mode 100644 index 0000000..6e9ebbe --- /dev/null +++ b/app/src/main/res/drawable/search.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/search_1.xml b/app/src/main/res/drawable/search_1.xml new file mode 100644 index 0000000..6e9ebbe --- /dev/null +++ b/app/src/main/res/drawable/search_1.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/settings_icon.xml b/app/src/main/res/drawable/settings_icon.xml new file mode 100644 index 0000000..843eda8 --- /dev/null +++ b/app/src/main/res/drawable/settings_icon.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/settings_menu.xml b/app/src/main/res/drawable/settings_menu.xml new file mode 100644 index 0000000..7a92dab --- /dev/null +++ b/app/src/main/res/drawable/settings_menu.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/trash.xml b/app/src/main/res/drawable/trash.xml new file mode 100644 index 0000000..db35309 --- /dev/null +++ b/app/src/main/res/drawable/trash.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/tutorials.xml b/app/src/main/res/drawable/tutorials.xml new file mode 100644 index 0000000..9ff2f8f --- /dev/null +++ b/app/src/main/res/drawable/tutorials.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/tutorials_menu.xml b/app/src/main/res/drawable/tutorials_menu.xml new file mode 100644 index 0000000..2559dda --- /dev/null +++ b/app/src/main/res/drawable/tutorials_menu.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/undo.xml b/app/src/main/res/drawable/undo.xml new file mode 100644 index 0000000..723fec4 --- /dev/null +++ b/app/src/main/res/drawable/undo.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/x_icon.xml b/app/src/main/res/drawable/x_icon.xml new file mode 100644 index 0000000..c9fb161 --- /dev/null +++ b/app/src/main/res/drawable/x_icon.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fa8bda6..e2ff56b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,4 +4,13 @@ Привет! Введи код для авторизации Код Войти + Мои бронирования + Выйти + Обновить + Забронировать + Бронирование + Назад + Забронировать + Повторить + Всё забронировано \ No newline at end of file