From 60e8c8f923eef6ca5b1052b64106d36d1597857e Mon Sep 17 00:00:00 2001 From: vladimir-shperling Date: Mon, 24 Nov 2025 19:53:00 +0300 Subject: [PATCH] Initial commit --- .gitmodules | 5 +- app/build.gradle.kts | 4 - .../java/ru/myitschool/work/Tests.kt | 474 ------------------ .../ru/myitschool/work/screens/AuthScreen.kt | 19 - .../ru/myitschool/work/screens/BookScreen.kt | 33 -- .../ru/myitschool/work/screens/MainScreen.kt | 33 -- .../myitschool/work/screens/UiBaseScreen.kt | 10 - .../ru/myitschool/work/utils/LocaleRule.kt | 27 - .../work/utils/MockWebServerRule.kt | 86 ---- app/src/androidTest/resources/booking.json | 6 - app/src/androidTest/resources/booking2.json | 6 - app/src/androidTest/resources/booking3.json | 1 - app/src/androidTest/resources/profile.json | 9 - app/src/androidTest/resources/profile2.json | 9 - .../work/data/dto/BookRequestDto.kt | 12 + .../ru/myitschool/work/data/dto/PlaceDto.kt | 12 + .../ru/myitschool/work/data/dto/UserDto.kt | 15 + .../work/data/repo/AuthRepository.kt | 51 ++ .../work/data/repo/BookRepository.kt | 53 ++ .../work/data/source/NetworkDataSource.kt | 89 ++++ .../auth/CheckAndSaveAuthCodeUseCase.kt | 15 + .../domain/auth/CheckCodeFormatUseCase.kt | 12 + .../work/domain/auth/GetCodeUseCase.kt | 11 + .../work/domain/auth/LogoutUseCase.kt | 11 + .../work/domain/book/GetBookingDataUseCase.kt | 19 + .../domain/book/SendBookRequestUseCase.kt | 14 + .../domain/book/entities/BookRequestData.kt | 6 + .../work/domain/book/entities/BookingData.kt | 11 + .../work/domain/main/GetMainDataUseCase.kt | 19 + .../domain/main/entities/MainInfoEntity.kt | 12 + .../myitschool/work/ui/nav/AppDestination.kt | 3 + .../work/ui/nav/AuthScreenDestination.kt | 6 + .../work/ui/nav/BookScreenDestination.kt | 6 + .../work/ui/nav/MainScreenDestination.kt | 6 + .../myitschool/work/ui/root/RootActivity.kt | 30 ++ .../work/ui/screen/NavigationGraph.kt | 60 +++ .../work/ui/screen/auth/AuthAction.kt | 7 + .../work/ui/screen/auth/AuthIntent.kt | 6 + .../work/ui/screen/auth/AuthScreen.kt | 108 ++++ .../work/ui/screen/auth/AuthState.kt | 9 + .../work/ui/screen/auth/AuthViewModel.kt | 66 +++ .../work/ui/screen/book/BookAction.kt | 6 + .../work/ui/screen/book/BookIntent.kt | 9 + .../work/ui/screen/book/BookScreen.kt | 312 ++++++++++++ .../work/ui/screen/book/BookState.kt | 28 ++ .../work/ui/screen/book/BookViewModel.kt | 93 ++++ .../work/ui/screen/main/MainAction.kt | 11 + .../work/ui/screen/main/MainIntent.kt | 7 + .../work/ui/screen/main/MainResult.kt | 5 + .../work/ui/screen/main/MainScreen.kt | 232 +++++++++ .../work/ui/screen/main/MainState.kt | 20 + .../work/ui/screen/main/MainViewModel.kt | 95 ++++ .../java/ru/myitschool/work/ui/theme/Color.kt | 11 + .../java/ru/myitschool/work/ui/theme/Theme.kt | 57 +++ .../java/ru/myitschool/work/ui/theme/Type.kt | 34 ++ app/src/main/res/drawable/ic_add.xml | 5 + app/src/main/res/drawable/ic_back.xml | 5 + app/src/main/res/drawable/ic_check.xml | 5 + app/src/main/res/drawable/ic_logout.xml | 5 + app/src/main/res/drawable/ic_refresh.xml | 5 + app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 15 + testLib | 1 - 63 files changed, 1640 insertions(+), 722 deletions(-) delete mode 100755 app/src/androidTest/java/ru/myitschool/work/Tests.kt delete mode 100755 app/src/androidTest/java/ru/myitschool/work/screens/AuthScreen.kt delete mode 100755 app/src/androidTest/java/ru/myitschool/work/screens/BookScreen.kt delete mode 100755 app/src/androidTest/java/ru/myitschool/work/screens/MainScreen.kt delete mode 100755 app/src/androidTest/java/ru/myitschool/work/screens/UiBaseScreen.kt delete mode 100755 app/src/androidTest/java/ru/myitschool/work/utils/LocaleRule.kt delete mode 100755 app/src/androidTest/java/ru/myitschool/work/utils/MockWebServerRule.kt delete mode 100755 app/src/androidTest/resources/booking.json delete mode 100755 app/src/androidTest/resources/booking2.json delete mode 100755 app/src/androidTest/resources/booking3.json delete mode 100755 app/src/androidTest/resources/profile.json delete mode 100755 app/src/androidTest/resources/profile2.json create mode 100644 app/src/main/java/ru/myitschool/work/data/dto/BookRequestDto.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/dto/PlaceDto.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/dto/UserDto.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.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/source/NetworkDataSource.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/auth/CheckCodeFormatUseCase.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/auth/GetCodeUseCase.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/auth/LogoutUseCase.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/book/GetBookingDataUseCase.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/book/SendBookRequestUseCase.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/book/entities/BookRequestData.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/book/entities/BookingData.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/main/GetMainDataUseCase.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/main/entities/MainInfoEntity.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/nav/AuthScreenDestination.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthAction.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthIntent.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/book/BookAction.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/MainAction.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/MainResult.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/java/ru/myitschool/work/ui/theme/Color.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/theme/Theme.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/theme/Type.kt create mode 100644 app/src/main/res/drawable/ic_add.xml create mode 100644 app/src/main/res/drawable/ic_back.xml create mode 100644 app/src/main/res/drawable/ic_check.xml create mode 100644 app/src/main/res/drawable/ic_logout.xml create mode 100644 app/src/main/res/drawable/ic_refresh.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml delete mode 160000 testLib diff --git a/.gitmodules b/.gitmodules index e271acd..a0d87af 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,7 +3,4 @@ url = https://git.sicampus.ru/core/gradle.git [submodule "buildSrc"] path = buildSrc - url = https://git.sicampus.ru/core/dependecies.git -[submodule "test-lib"] - path = testLib - url = https://git.sicampus.ru/core/test-lib + url = https://git.sicampus.ru/core/dependecies.git \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 97059dd..a70cefb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,8 +47,4 @@ dependencies { implementation("io.ktor:ktor-client-content-negotiation:$ktor") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") - - androidTestImplementation(project(path = ":testLib")) - androidTestImplementation("io.github.kakaocup:compose:1.0.0") - androidTestImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") } \ No newline at end of file diff --git a/app/src/androidTest/java/ru/myitschool/work/Tests.kt b/app/src/androidTest/java/ru/myitschool/work/Tests.kt deleted file mode 100755 index c998ce8..0000000 --- a/app/src/androidTest/java/ru/myitschool/work/Tests.kt +++ /dev/null @@ -1,474 +0,0 @@ -@file:OptIn(ExperimentalTestApi::class) - -package ru.myitschool.work - -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.test.espresso.Espresso.pressBack -import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen -import org.junit.FixMethodOrder -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.MethodSorters -import ru.myitschool.work.screens.AuthScreen -import ru.myitschool.work.screens.BookScreen -import ru.myitschool.work.screens.MainScreen -import ru.myitschool.work.screens.getDateText -import ru.myitschool.work.screens.getPlaceText -import ru.myitschool.work.ui.root.RootActivity -import ru.myitschool.work.utils.MockWebServerRule -import ru.myitschool.work.utils.Response -import ru.samsung.test.core.core.BaseTest -import ru.samsung.test.core.utils.warmUpCompose - -@RunWith(AndroidJUnit4::class) -@FixMethodOrder(MethodSorters.NAME_ASCENDING) -class Tests : BaseTest( - clazz = RootActivity::class.java, - isEnabledCompose = true, -) { - @get:Rule - val composeTestRule = createAndroidComposeRule() - - @get:Rule - val serverRule = MockWebServerRule(8090) - - @Test - fun aПроверка_экрана_авторизации_валидация() = runWithInit(1) { - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - errorText.assertIsNotDisplayed() - signButton.assertIsDisplayed() - codeInput.assertIsDisplayed() - codeInput.assertTextEquals("Код", includeEditableText = false) - } - - step("Проверка состояния при пустом поле ввода") { - signButton.assertIsNotEnabled() - } - - step("Проверка состояния при вводе неверной комбинации 1") { - codeInput.performTextReplacement("1234567890") - signButton.assertIsNotEnabled() - } - - step("Проверка состояния при вводе верной комбинации 1") { - codeInput.performTextReplacement("1230") - signButton.assertIsEnabled() - } - - step("Проверка состояния при вводе неверной комбинации 2") { - codeInput.performTextReplacement("прив") - signButton.assertIsNotEnabled() - } - - step("Проверка состояния при вводе верной комбинации 2") { - codeInput.performTextReplacement("aAzZ") - signButton.assertIsEnabled() - } - - step("Проверка состояния при вводе неверной комбинации 3") { - codeInput.performTextReplacement("1пLK") - signButton.assertIsNotEnabled() - } - } - } - - @Test - fun bПроверка_экрана_авторизации_статусы() = runWithInit(1) { - serverRule.mockResponses( - "/api/abcd/auth" to Response(statusCode = 401), - "/api/1234/auth" to Response(statusCode = 400), - "/api/4321/auth" to Response(statusCode = 500), - "/api/abc1/auth" to Response(statusCode = 200), - "/api/abc1/info" to Response(statusCode = 400), - ) - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - errorText.assertIsNotDisplayed() - signButton.assertIsDisplayed() - codeInput.assertIsDisplayed() - } - - listOf( - "abcd", - "1234", - "4321" - ).forEachIndexed { index, code -> - warmUpCompose(composeTestRule) - step("Проверка статуса ошибки $index") { - codeInput.performTextReplacement(code) - flakySafely(timeoutMs = 500L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - errorText.assertIsNotDisplayed() - } - signButton.performClick() - flakySafely(timeoutMs = 500L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - errorText.assertIsDisplayed() - } - } - } - - step("Проверка верного статуса") { - codeInput.performTextReplacement("abc1") - errorText.assertIsNotDisplayed() - signButton.assertIsEnabled() - signButton.performClick() - } - } - onComposeScreen(composeTestRule) { - step("Проверка перехода на главный экран") { - errorText.assertIsDisplayed() - refreshButton.assertIsDisplayed() - } - } - } - - @Test - fun cПроверка_главного_экрана() = runWithInit(1) { - serverRule.mockResponses( - "/api/abc1/info" to Response(assetFile = "profile.json"), - "/api/abc1/info" to Response(assetFile = "profile2.json"), - ) - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - flakySafely(timeoutMs = 5000L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - errorText.assertIsNotDisplayed() - addButton.assertIsDisplayed() - refreshButton.assertIsDisplayed() - logoutButton.assertIsDisplayed() - profileNameText.assertIsDisplayed() - } - } - - step("Проверка контента на экране") { - profileNameText.assertTextEquals("Иван А") - getItemByPosition(0).invoke { - getDateText().assertTextEquals("05.01.2025") - getPlaceText().assertTextEquals("102") - } - getItemByPosition(1).invoke { - getDateText().assertTextEquals("06.01.2025") - getPlaceText().assertTextEquals("209.13") - } - getItemByPosition(2).invoke { - getDateText().assertTextEquals("09.01.2025") - getPlaceText().assertTextEquals("Зона 51. 50") - } - } - - step("Обновляем контент и проверяем повторно") { - refreshButton.performClick() - flakySafely(timeoutMs = 5000L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - profileNameText.assertTextEquals("Вова Б") - } - getItemByPosition(0).invoke { - getDateText().assertTextEquals("01.01.2001") - getPlaceText().assertTextEquals("1") - } - getItemByPosition(1).invoke { - getDateText().assertTextEquals("01.01.2002") - getPlaceText().assertTextEquals("2") - } - getItemByPosition(2).invoke { - getDateText().assertTextEquals("01.01.2003") - getPlaceText().assertTextEquals("3") - } - getItemByPosition(3).assertIsNotDisplayed() - } - } - } - - @Test - fun dПроверка_кнопки_выхода() = runWithInit(1) { - serverRule.mockResponses( - "/api/abc1/info" to Response(assetFile = "profile.json"), - ) - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - flakySafely(timeoutMs = 5000L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - errorText.assertIsNotDisplayed() - addButton.assertIsDisplayed() - refreshButton.assertIsDisplayed() - logoutButton.assertIsDisplayed() - profileNameText.assertIsDisplayed() - } - } - - step("Нажимаем на кнопку выхода") { - logoutButton.performClick() - } - } - - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - errorText.assertIsNotDisplayed() - signButton.assertIsDisplayed() - codeInput.assertIsDisplayed() - codeInput.assertTextEquals("Код", includeEditableText = false) - } - } - } - - @Test - fun eПроверка_навигации_брони() = runWithInit(1) { - serverRule.mockResponses( - "/api/abc1/auth" to Response(statusCode = 200), - "/api/abc1/info" to Response(assetFile = "profile.json"), - "/api/abc1/booking" to Response(statusCode = 400), - ) - - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - errorText.assertIsNotDisplayed() - signButton.assertIsDisplayed() - codeInput.assertIsDisplayed() - } - - step("Вход") { - codeInput.performTextReplacement("abc1") - errorText.assertIsNotDisplayed() - signButton.assertIsEnabled() - signButton.performClick() - } - } - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - flakySafely(timeoutMs = 5000L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - errorText.assertIsNotDisplayed() - addButton.assertIsDisplayed() - refreshButton.assertIsDisplayed() - logoutButton.assertIsDisplayed() - profileNameText.assertIsDisplayed() - } - } - - step("Нажимаем на кнопку добавления") { - addButton.performClick() - } - } - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - flakySafely(timeoutMs = 5000L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - errorText.assertIsNotDisplayed() - refreshButton.assertIsDisplayed() - } - pressBack() - } - } - onComposeScreen(composeTestRule) { - step("Проверка контента на экране (обратная навигация)") { - profileNameText.assertTextEquals("Иван А") - getItemByPosition(0).invoke { - getDateText().assertTextEquals("05.01.2025") - getPlaceText().assertTextEquals("102") - } - getItemByPosition(1).invoke { - getDateText().assertTextEquals("06.01.2025") - getPlaceText().assertTextEquals("209.13") - } - getItemByPosition(2).invoke { - getDateText().assertTextEquals("09.01.2025") - getPlaceText().assertTextEquals("Зона 51. 50") - } - } - } - } - - @Test - fun fПроверка_контента_брони() = runWithInit(1) { - serverRule.mockResponses( - "/api/abc1/info" to Response(assetFile = "profile.json"), - "/api/abc1/booking" to Response(statusCode = 400), - "/api/abc1/booking" to Response(assetFile = "booking.json"), - ) - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - flakySafely(timeoutMs = 5000L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - errorText.assertIsNotDisplayed() - addButton.assertIsDisplayed() - refreshButton.assertIsDisplayed() - logoutButton.assertIsDisplayed() - profileNameText.assertIsDisplayed() - } - } - - step("Нажимаем на кнопку добавления") { - addButton.performClick() - } - } - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - flakySafely(timeoutMs = 5000L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - errorText.assertIsNotDisplayed() - refreshButton.assertIsDisplayed() - } - refreshButton.performClick() - } - step("Проверка контента") { - getNodeDateByPosition(0).invoke { - assertTextEquals("05.01") - assertIsSelected() - } - getNodeDateByPosition(1).assertTextEquals("06.01") - getNodeDateByPosition(2).assertTextEquals("07.02") - getNodeDateByPosition(3).assertTextEquals("08.03") - getNodePlaceByPosition(0).invoke { - assertTextEquals("102") - assertIsSelected() - } - getNodePlaceByPosition(1).assertTextEquals("666") - getNodePlaceByPosition(2).assertIsNotDisplayed() - } - step("Проверка выбора переговорок") { - getNodePlaceByPosition(1).invoke { - performClick() - assertIsSelected() - } - getNodePlaceByPosition(0).assertIsNotSelected() - } - step("Проверка переключения дат") { - getNodeDateByPosition(2).invoke { - performClick() - assertIsSelected() - } - getNodePlaceByPosition(0).invoke { - assertTextEquals("102") - assertIsSelected() - } - getNodePlaceByPosition(1).assertTextEquals("209.13") - getNodePlaceByPosition(2).assertIsNotDisplayed() - } - } - } - - @Test - fun gПроверка_возможности_брони() = runWithInit(1) { - serverRule.mockResponses( - "/api/abc1/info" to Response(assetFile = "profile.json"), - "/api/abc1/booking" to Response(assetFile = "booking.json"), - "/api/abc1/book" to Response(statusCode = 201), - "/api/abc1/info" to Response(assetFile = "profile2.json"), - ) - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - flakySafely(timeoutMs = 5000L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - errorText.assertIsNotDisplayed() - addButton.assertIsDisplayed() - refreshButton.assertIsDisplayed() - logoutButton.assertIsDisplayed() - profileNameText.assertIsDisplayed() - } - } - - step("Нажимаем на кнопку добавления") { - addButton.performClick() - } - } - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - flakySafely(timeoutMs = 5000L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - backButton.assertIsDisplayed() - bookButton.assertIsDisplayed() - } - } - step("Оформление брони") { - bookButton.performClick() - } - } - onComposeScreen(composeTestRule) { - step("Проверяем данные с главного экрана") { - flakySafely(timeoutMs = 5000L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - profileNameText.assertTextEquals("Вова Б") - } - getItemByPosition(0).invoke { - getDateText().assertTextEquals("01.01.2001") - getPlaceText().assertTextEquals("1") - } - getItemByPosition(1).invoke { - getDateText().assertTextEquals("01.01.2002") - getPlaceText().assertTextEquals("2") - } - getItemByPosition(2).invoke { - getDateText().assertTextEquals("01.01.2003") - getPlaceText().assertTextEquals("3") - } - getItemByPosition(3).assertIsNotDisplayed() - } - } - } - - @Test - fun hПроверка_пустых_слотов() = runWithInit(1) { - serverRule.mockResponses( - "/api/abc1/info" to Response(assetFile = "profile.json"), - "/api/abc1/booking" to Response(assetFile = "booking2.json"), - "/api/abc1/booking" to Response(assetFile = "booking3.json") - ) - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - flakySafely(timeoutMs = 5000L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - errorText.assertIsNotDisplayed() - addButton.assertIsDisplayed() - refreshButton.assertIsDisplayed() - logoutButton.assertIsDisplayed() - profileNameText.assertIsDisplayed() - } - } - - step("Нажимаем на кнопку добавления") { - addButton.performClick() - } - } - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - flakySafely(timeoutMs = 5000L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - backButton.assertIsDisplayed() - bookButton.assertIsDisplayed() - } - } - step("Проверка пустого состояния") { - emptyText.assertIsNotDisplayed() - getNodeDateByPosition(0).assertIsDisplayed() - getNodeDateByPosition(1).assertIsDisplayed() - getNodeDateByPosition(2).assertIsDisplayed() - getNodeDateByPosition(3).assertIsNotDisplayed() - } - step("Возврат на главную") { - backButton.performClick() - } - } - onComposeScreen(composeTestRule) { - step("Нажимаем на кнопку добавления") { - addButton.performClick() - } - } - onComposeScreen(composeTestRule) { - step("Проверка наличия всех элементов на экране") { - flakySafely(timeoutMs = 5000L, intervalMs = 100L) { - warmUpCompose(composeTestRule) - backButton.assertIsDisplayed() - bookButton.assertIsNotDisplayed() - } - } - step("Проверка пустого состояния") { - emptyText.assertIsDisplayed() - } - } - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/ru/myitschool/work/screens/AuthScreen.kt b/app/src/androidTest/java/ru/myitschool/work/screens/AuthScreen.kt deleted file mode 100755 index a15496c..0000000 --- a/app/src/androidTest/java/ru/myitschool/work/screens/AuthScreen.kt +++ /dev/null @@ -1,19 +0,0 @@ -package ru.myitschool.work.screens - -import androidx.compose.ui.test.SemanticsNodeInteractionsProvider -import com.kaspersky.components.composesupport.core.KNode -import ru.myitschool.work.core.TestIds - -class AuthScreen( - semanticsProvider: SemanticsNodeInteractionsProvider -) : UiBaseScreen(semanticsProvider) { - val errorText = child { - hasTestTag(TestIds.Auth.ERROR) - } - val signButton: KNode = child { - hasTestTag(TestIds.Auth.SIGN_BUTTON) - } - val codeInput: KNode = child { - hasTestTag(TestIds.Auth.CODE_INPUT) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/ru/myitschool/work/screens/BookScreen.kt b/app/src/androidTest/java/ru/myitschool/work/screens/BookScreen.kt deleted file mode 100755 index 8fcccba..0000000 --- a/app/src/androidTest/java/ru/myitschool/work/screens/BookScreen.kt +++ /dev/null @@ -1,33 +0,0 @@ -package ru.myitschool.work.screens - -import androidx.compose.ui.test.SemanticsNodeInteractionsProvider -import com.kaspersky.components.composesupport.core.KNode -import ru.myitschool.work.core.TestIds - -class BookScreen( - semanticsProvider: SemanticsNodeInteractionsProvider -) : UiBaseScreen(semanticsProvider) { - val errorText = child { - hasTestTag(TestIds.Book.ERROR) - } - val emptyText: KNode = child { - hasTestTag(TestIds.Book.EMPTY) - } - val refreshButton: KNode = child { - hasTestTag(TestIds.Book.REFRESH_BUTTON) - } - val backButton: KNode = child { - hasTestTag(TestIds.Book.BACK_BUTTON) - } - val bookButton: KNode = child { - hasTestTag(TestIds.Book.BOOK_BUTTON) - } - - fun getNodeDateByPosition(position: Int) = child { - hasTestTag(TestIds.Book.getIdDateItemByPosition(position)) - } - - fun getNodePlaceByPosition(position: Int) = child { - hasTestTag(TestIds.Book.getIdPlaceItemByPosition(position)) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/ru/myitschool/work/screens/MainScreen.kt b/app/src/androidTest/java/ru/myitschool/work/screens/MainScreen.kt deleted file mode 100755 index 60d6dc8..0000000 --- a/app/src/androidTest/java/ru/myitschool/work/screens/MainScreen.kt +++ /dev/null @@ -1,33 +0,0 @@ -package ru.myitschool.work.screens - -import androidx.compose.ui.test.SemanticsNodeInteractionsProvider -import com.kaspersky.components.composesupport.core.KNode -import io.github.kakaocup.compose.node.core.BaseNode -import ru.myitschool.work.core.TestIds - -class MainScreen( - semanticsProvider: SemanticsNodeInteractionsProvider -) : UiBaseScreen(semanticsProvider) { - val errorText = child { - hasTestTag(TestIds.Main.ERROR) - } - val addButton: KNode = child { - hasTestTag(TestIds.Main.ADD_BUTTON) - } - val refreshButton: KNode = child { - hasTestTag(TestIds.Main.REFRESH_BUTTON) - } - val logoutButton: KNode = child { - hasTestTag(TestIds.Main.LOGOUT_BUTTON) - } - val profileNameText: KNode = child { - hasTestTag(TestIds.Main.PROFILE_NAME) - } - - fun getItemByPosition(position: Int) = child { - hasTestTag(TestIds.Main.getIdItemByPosition(position)) - } -} -fun KNode.getDateText() = child { hasTestTag(TestIds.Main.ITEM_DATE) } - -fun KNode.getPlaceText() = child { hasTestTag(TestIds.Main.ITEM_PLACE) } \ No newline at end of file diff --git a/app/src/androidTest/java/ru/myitschool/work/screens/UiBaseScreen.kt b/app/src/androidTest/java/ru/myitschool/work/screens/UiBaseScreen.kt deleted file mode 100755 index 1a690b7..0000000 --- a/app/src/androidTest/java/ru/myitschool/work/screens/UiBaseScreen.kt +++ /dev/null @@ -1,10 +0,0 @@ -package ru.myitschool.work.screens - -import androidx.compose.ui.test.SemanticsNodeInteractionsProvider -import io.github.kakaocup.compose.node.element.ComposeScreen - -open class UiBaseScreen>( - semanticsProvider: SemanticsNodeInteractionsProvider -) : ComposeScreen( - semanticsProvider = semanticsProvider -) \ No newline at end of file diff --git a/app/src/androidTest/java/ru/myitschool/work/utils/LocaleRule.kt b/app/src/androidTest/java/ru/myitschool/work/utils/LocaleRule.kt deleted file mode 100755 index 791cebe..0000000 --- a/app/src/androidTest/java/ru/myitschool/work/utils/LocaleRule.kt +++ /dev/null @@ -1,27 +0,0 @@ -package ru.myitschool.work.utils - -import android.app.LocaleManager -import android.os.Build -import android.os.LocaleList -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.os.LocaleListCompat -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.rules.ExternalResource -import java.util.Locale - -class LocaleRule( - private val locale: Locale, -) : ExternalResource() { - - override fun before() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.getSystemService(LocaleManager::class.java) - .applicationLocales = LocaleList(locale) - } else { - AppCompatDelegate.setApplicationLocales( - LocaleListCompat.create(locale) - ) - } - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/ru/myitschool/work/utils/MockWebServerRule.kt b/app/src/androidTest/java/ru/myitschool/work/utils/MockWebServerRule.kt deleted file mode 100755 index bfdce62..0000000 --- a/app/src/androidTest/java/ru/myitschool/work/utils/MockWebServerRule.kt +++ /dev/null @@ -1,86 +0,0 @@ -package ru.myitschool.work.utils - -import okhttp3.mockwebserver.Dispatcher -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.RecordedRequest -import org.junit.rules.TestRule -import org.junit.runner.Description -import org.junit.runners.model.Statement -import java.net.HttpURLConnection -import java.util.LinkedList -import java.util.Queue - -class MockWebServerRule(private val port: Int) : TestRule { - val server: MockWebServer get() = _server - - private lateinit var _server: MockWebServer - private val dispatcher = object : Dispatcher() { - private val responses = mutableMapOf>() - var requestListener: ((RecordedRequest) -> Unit)? = null - - override fun dispatch(request: RecordedRequest): MockResponse { - requestListener?.invoke(request) - val response = responses[request.path]?.poll() - ?: return MockResponse().apply { - setResponseCode(HttpURLConnection.HTTP_NOT_FOUND) - } - return MockResponse().apply { - setResponseCode(response.statusCode) - if (response.assetFile != null) { - val resource = this.javaClass.classLoader - ?.getResourceAsStream(response.assetFile) - ?: throw IllegalStateException("File not found") - setBody(String(resource.readBytes())) - response.contentType?.let { setHeader("Content-Type", it) } - } - } - } - - fun addMockResponse(requestUrl: String, response: Response) { - val queue = responses.getOrPut(requestUrl) { LinkedList() } - queue.add(response) - } - } - - override fun apply(base: Statement, description: Description): Statement { - return object : Statement() { - @Throws(Throwable::class) - override fun evaluate() { - _server = MockWebServer() - _server.dispatcher = dispatcher - _server.start(port = port) - try { - base.evaluate() - } finally { - try { - _server.dispatcher.shutdown() - _server.shutdown() - } catch (e: Throwable) { - e.printStackTrace() - } - } - } - } - } - - fun setRecorderListener(listener: (RecordedRequest) -> Unit) { - dispatcher.requestListener = listener - } - - fun removeRecorderListener() { - dispatcher.requestListener = null - } - - fun mockResponses(vararg pairs: Pair) { - pairs.forEach { (request, response) -> - dispatcher.addMockResponse(request, response) - } - } -} - -data class Response( - val assetFile: String? = null, - val contentType: String? = "application/json", - val statusCode: Int = HttpURLConnection.HTTP_OK, -) \ No newline at end of file diff --git a/app/src/androidTest/resources/booking.json b/app/src/androidTest/resources/booking.json deleted file mode 100755 index 803db0d..0000000 --- a/app/src/androidTest/resources/booking.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "2025-01-05": [{"id": 1, "place": "102"},{"id": 2, "place": "666"}], - "2025-01-06": [{"id": 3, "place": "Зона 51. 50"}], - "2025-02-07": [{"id": 1, "place": "102"},{"id": 2, "place": "209.13"}], - "2025-03-08": [{"id": 2, "place": "209.13"}] -} \ No newline at end of file diff --git a/app/src/androidTest/resources/booking2.json b/app/src/androidTest/resources/booking2.json deleted file mode 100755 index f1855d8..0000000 --- a/app/src/androidTest/resources/booking2.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "2000-01-08": [{"id": 1, "place": "102"},{"id": 2, "place": "666"}], - "2000-01-07": [{"id": 3, "place": "Зона 51. 50"}], - "2000-01-05": [{"id": 1, "place": "102"},{"id": 2, "place": "209.13"}], - "2000-01-01": [] -} \ No newline at end of file diff --git a/app/src/androidTest/resources/booking3.json b/app/src/androidTest/resources/booking3.json deleted file mode 100755 index 9e26dfe..0000000 --- a/app/src/androidTest/resources/booking3.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/app/src/androidTest/resources/profile.json b/app/src/androidTest/resources/profile.json deleted file mode 100755 index c6b5620..0000000 --- a/app/src/androidTest/resources/profile.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "Иван А", - "photoUrl": "https://funnyducks.ru/upload/iblock/0cd/0cdeb7ec3ed6fddda0f90fccee05557d.jpg", - "booking": { - "2025-01-05": {"id": 1, "place": "102"}, - "2025-01-06": {"id": 2, "place": "209.13"}, - "2025-01-09": {"id": 3, "place": "Зона 51. 50"} - } -} \ No newline at end of file diff --git a/app/src/androidTest/resources/profile2.json b/app/src/androidTest/resources/profile2.json deleted file mode 100755 index 34bcb37..0000000 --- a/app/src/androidTest/resources/profile2.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "Вова Б", - "photoUrl": "https://funnyducks.ru/upload/iblock/0cd/0cdeb7ec3ed6fddda0f90fccee05557d.jpg", - "booking": { - "2003-01-01": {"id": 4, "place": "3"}, - "2002-01-01": {"id": 5, "place": "2"}, - "2001-01-01": {"id": 6, "place": "1"} - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/dto/BookRequestDto.kt b/app/src/main/java/ru/myitschool/work/data/dto/BookRequestDto.kt new file mode 100644 index 0000000..759ffc5 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/dto/BookRequestDto.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BookRequestDto( + @SerialName("date") + val date: String, + @SerialName("placeId") + val placeId: String, +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/dto/PlaceDto.kt b/app/src/main/java/ru/myitschool/work/data/dto/PlaceDto.kt new file mode 100644 index 0000000..381a1f4 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/dto/PlaceDto.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PlaceDto( + @SerialName("id") + val id: String?, + @SerialName("place") + val place: String?, +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/dto/UserDto.kt b/app/src/main/java/ru/myitschool/work/data/dto/UserDto.kt new file mode 100644 index 0000000..e5e968b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/dto/UserDto.kt @@ -0,0 +1,15 @@ +package ru.myitschool.work.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserDto( + @SerialName("name") + val name: String?, + @SerialName("photoUrl") + val photoUrl: String?, + @SerialName("booking") + val booking: Map? +) { +} \ 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 new file mode 100644 index 0000000..e4126dd --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt @@ -0,0 +1,51 @@ +package ru.myitschool.work.data.repo + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.firstOrNull +import ru.myitschool.work.App +import ru.myitschool.work.data.source.NetworkDataSource + +object AuthRepository { + private const val STORE = "AUTH-STORE" + private const val CODE_KEY = "CODE" + + private var codeCache: String? = null + + suspend fun checkAndSave(text: String): Result { + return NetworkDataSource.checkAuth(text).onSuccess { success -> + if (success) { + codeCache = text + App.context.userDataStore.edit { preferences -> + val prefKey = stringPreferencesKey(CODE_KEY) + preferences[prefKey] = text + } + } + } + } + + suspend fun getCode(): String? { + if (codeCache == null) { + codeCache = App.context.userDataStore.data + .firstOrNull() + ?.let { preferences -> + preferences[stringPreferencesKey(CODE_KEY)] + } + } + return codeCache + } + + suspend fun logout() { + codeCache = null + App.context.userDataStore.edit { preferences -> + val prefKey = stringPreferencesKey(CODE_KEY) + preferences.remove(prefKey) + } + } + + private val Context.userDataStore: DataStore by preferencesDataStore(name = STORE) +} \ 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..ea1c581 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt @@ -0,0 +1,53 @@ +package ru.myitschool.work.data.repo + +import ru.myitschool.work.data.dto.BookRequestDto +import ru.myitschool.work.data.source.NetworkDataSource +import ru.myitschool.work.domain.book.entities.BookRequestData +import ru.myitschool.work.domain.book.entities.BookingData +import ru.myitschool.work.domain.main.entities.MainInfoEntity + +class BookRepository( + private val authRepository: AuthRepository +) { + suspend fun getInfo(): Result { + val code = authRepository.getCode() ?: return getNoAuthResult() + return NetworkDataSource.getInfo(code).mapCatching { dto -> + MainInfoEntity( + name = dto.name ?: error("Name is null"), + photoUrl = dto.photoUrl ?: error("Photo url is null"), + book = dto.booking?.mapNotNull { (date, place) -> + MainInfoEntity.Book( + date = date, + place = place.place ?: return@mapNotNull null + ) + } ?: listOf() + ) + } + } + + suspend fun getBookingInfo(): Result> { + val code = authRepository.getCode() ?: return getNoAuthResult() + return NetworkDataSource.getBooking(code).mapCatching { dto -> + dto?.map { (date, places) -> + BookingData( + date = date, + places = places.mapNotNull { place -> + BookingData.Place( + id = place.id ?: return@mapNotNull null, + name = place.place ?: return@mapNotNull null + ) + } + ) + } ?: error("map is null") + } + } + + suspend fun sendBook(data: BookRequestData): Result { + val code = authRepository.getCode() ?: return getNoAuthResult() + val dto = BookRequestDto(data.date, data.placeId) + return NetworkDataSource.addBook(code, dto) + } + private fun getNoAuthResult() = Result.failure( + IllegalStateException("No 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 new file mode 100644 index 0000000..85387ac --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt @@ -0,0 +1,89 @@ +package ru.myitschool.work.data.source + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import ru.myitschool.work.core.Constants +import ru.myitschool.work.data.dto.PlaceDto +import ru.myitschool.work.data.dto.BookRequestDto +import ru.myitschool.work.data.dto.UserDto + +object NetworkDataSource { + private val client by lazy { + HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + isLenient = true + ignoreUnknownKeys = true + explicitNulls = true + encodeDefaults = true + } + ) + } + } + } + + 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 -> false + } + } + } + + suspend fun getInfo(code: String): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + println("!!!!!!!!!!!!!! getInfo $code") + val response = client.get(getUrl(code, Constants.INFO_URL)) + if (response.status == HttpStatusCode.OK) { + println("!!!!!!!!!!!!!! getInfo OK ${response.bodyAsText()}") + response.body() + } else { + println("!!!!!!!!!!!!!! getInfo ERROR ${response.bodyAsText()}") + error(response.bodyAsText()) + } + } + } + + suspend fun getBooking(code: String): Result>?> = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.get(getUrl(code, Constants.BOOKING_URL)) + if (response.status == HttpStatusCode.OK) { + response.body>>() + } else { + error(response.bodyAsText()) + } + } + } + + suspend fun addBook(code: String, data: BookRequestDto): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.post(getUrl(code, Constants.BOOK_URL)) { + contentType(ContentType.Application.Json) + setBody(data) + } + when (response.status) { + HttpStatusCode.Created -> true + HttpStatusCode.Conflict -> false + else -> error(response.bodyAsText()) + } + } + } + + private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl" +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt new file mode 100644 index 0000000..012fb6f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt @@ -0,0 +1,15 @@ +package ru.myitschool.work.domain.auth + +import ru.myitschool.work.data.repo.AuthRepository + +class CheckAndSaveAuthCodeUseCase( + private val repository: AuthRepository +) { + suspend operator fun invoke( + text: String + ): Result { + return repository.checkAndSave(text).mapCatching { success -> + if (!success) error("Code is incorrect") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/CheckCodeFormatUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/CheckCodeFormatUseCase.kt new file mode 100644 index 0000000..fe291a0 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/auth/CheckCodeFormatUseCase.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.domain.auth + +class CheckCodeFormatUseCase { + operator fun invoke( + text: String + ): Boolean { + return text.length == 4 && text.all { char -> + char.isLetterOrDigit() && + ((char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char.isDigit()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/GetCodeUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/GetCodeUseCase.kt new file mode 100644 index 0000000..a3c22b8 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/auth/GetCodeUseCase.kt @@ -0,0 +1,11 @@ +package ru.myitschool.work.domain.auth + +import ru.myitschool.work.data.repo.AuthRepository + +class GetCodeUseCase( + private val repository: AuthRepository +) { + suspend operator fun invoke(): String? { + return repository.getCode() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/LogoutUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/LogoutUseCase.kt new file mode 100644 index 0000000..6468efb --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/auth/LogoutUseCase.kt @@ -0,0 +1,11 @@ +package ru.myitschool.work.domain.auth + +import ru.myitschool.work.data.repo.AuthRepository + +class LogoutUseCase( + private val repository: AuthRepository +) { + suspend operator fun invoke() { + repository.logout() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/book/GetBookingDataUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/book/GetBookingDataUseCase.kt new file mode 100644 index 0000000..af52ccf --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/GetBookingDataUseCase.kt @@ -0,0 +1,19 @@ +package ru.myitschool.work.domain.book + +import ru.myitschool.work.data.repo.BookRepository +import ru.myitschool.work.domain.book.entities.BookingData +import java.time.LocalDate + +class GetBookingDataUseCase( + private val repository: BookRepository +) { + suspend operator fun invoke(): Result> { + return repository.getBookingInfo().map { data -> + data + .sortedBy { book -> + LocalDate.parse(book.date) + } + .filter { it.places.isNotEmpty() } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/book/SendBookRequestUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/book/SendBookRequestUseCase.kt new file mode 100644 index 0000000..010effe --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/SendBookRequestUseCase.kt @@ -0,0 +1,14 @@ +package ru.myitschool.work.domain.book + +import ru.myitschool.work.data.repo.BookRepository +import ru.myitschool.work.domain.book.entities.BookRequestData + +class SendBookRequestUseCase( + private val repository: BookRepository +) { + suspend operator fun invoke(data: BookRequestData): Result { + return repository.sendBook(data).mapCatching { success -> + if (!success) error("Book error") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/book/entities/BookRequestData.kt b/app/src/main/java/ru/myitschool/work/domain/book/entities/BookRequestData.kt new file mode 100644 index 0000000..431a9ad --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/entities/BookRequestData.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.domain.book.entities + +data class BookRequestData( + val date: String, + val placeId: String +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/book/entities/BookingData.kt b/app/src/main/java/ru/myitschool/work/domain/book/entities/BookingData.kt new file mode 100644 index 0000000..5546649 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/entities/BookingData.kt @@ -0,0 +1,11 @@ +package ru.myitschool.work.domain.book.entities + +data class BookingData( + val date: String, + val places: List +) { + data class Place( + val id: String, + val name: String + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/main/GetMainDataUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/main/GetMainDataUseCase.kt new file mode 100644 index 0000000..7beebeb --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/main/GetMainDataUseCase.kt @@ -0,0 +1,19 @@ +package ru.myitschool.work.domain.main + +import ru.myitschool.work.data.repo.BookRepository +import ru.myitschool.work.domain.main.entities.MainInfoEntity +import java.time.LocalDate + +class GetMainDataUseCase( + private val repository: BookRepository +) { + suspend operator fun invoke(): Result { + return repository.getInfo().map { main -> + main.copy( + book = main.book.sortedBy { book -> + LocalDate.parse(book.date) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/main/entities/MainInfoEntity.kt b/app/src/main/java/ru/myitschool/work/domain/main/entities/MainInfoEntity.kt new file mode 100644 index 0000000..3a0cc42 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/main/entities/MainInfoEntity.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.domain.main.entities + +data class MainInfoEntity( + val name: String, + val photoUrl: String, + val book: List +) { + data class Book( + val date: String, + val place: String, + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt new file mode 100644 index 0000000..557b893 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt @@ -0,0 +1,3 @@ +package ru.myitschool.work.ui.nav + +sealed interface AppDestination \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/AuthScreenDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/AuthScreenDestination.kt new file mode 100644 index 0000000..52660b1 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/nav/AuthScreenDestination.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.nav + +import kotlinx.serialization.Serializable + +@Serializable +data object AuthScreenDestination: AppDestination \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt new file mode 100644 index 0000000..9a33073 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.nav + +import kotlinx.serialization.Serializable + +@Serializable +data object BookScreenDestination: AppDestination \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt new file mode 100644 index 0000000..deca45f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.nav + +import kotlinx.serialization.Serializable + +@Serializable +data object MainScreenDestination: AppDestination \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt b/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt new file mode 100644 index 0000000..54b156d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt @@ -0,0 +1,30 @@ +package ru.myitschool.work.ui.root + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.ui.Modifier +import ru.myitschool.work.ui.screen.AppNavHost +import ru.myitschool.work.ui.theme.WorkTheme + +class RootActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + WorkTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + AppNavHost( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt b/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt new file mode 100644 index 0000000..3590d24 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt @@ -0,0 +1,60 @@ +package ru.myitschool.work.ui.screen + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import kotlinx.coroutines.delay +import ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.domain.auth.GetCodeUseCase +import ru.myitschool.work.ui.nav.AppDestination +import ru.myitschool.work.ui.nav.AuthScreenDestination +import ru.myitschool.work.ui.nav.BookScreenDestination +import ru.myitschool.work.ui.nav.MainScreenDestination +import ru.myitschool.work.ui.screen.auth.AuthScreen +import ru.myitschool.work.ui.screen.book.BookScreen +import ru.myitschool.work.ui.screen.main.MainScreen + +@Composable +fun AppNavHost( + modifier: Modifier = Modifier, + navController: NavHostController = rememberNavController() +) { + var destination by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + val code = GetCodeUseCase(AuthRepository).invoke() + destination = if (code == null) { + AuthScreenDestination + } else { + MainScreenDestination + } + } + if (destination != null) { + NavHost( + modifier = modifier, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + navController = navController, + startDestination = destination as AppDestination, + ) { + composable { + AuthScreen(navController = navController) + } + composable { + MainScreen(navController = navController) + } + composable { + BookScreen(navController = navController) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthAction.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthAction.kt new file mode 100644 index 0000000..a661897 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthAction.kt @@ -0,0 +1,7 @@ +package ru.myitschool.work.ui.screen.auth + +import ru.myitschool.work.ui.nav.AppDestination + +sealed interface AuthAction { + class Open(val destination: AppDestination): AuthAction +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthIntent.kt new file mode 100644 index 0000000..74f200a --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthIntent.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.screen.auth + +sealed interface AuthIntent { + data class Send(val text: String): AuthIntent + data class TextInput(val text: String): AuthIntent +} \ 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 new file mode 100644 index 0000000..4b91b98 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt @@ -0,0 +1,108 @@ +package ru.myitschool.work.ui.screen.auth + +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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 +fun AuthScreen( + viewModel: AuthViewModel = viewModel(), + navController: NavController +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actionFlow.collect { action -> + when (action) { + is AuthAction.Open -> navController.navigate(action.destination) { + popUpTo(0) + } + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(all = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.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) + ) + } + } + } +} + +@Composable +private fun Content( + viewModel: AuthViewModel, + state: AuthState.Data +) { + 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 = state.isEnabledSend + ) { + Text(stringResource(R.string.auth_sign_in)) + } + if (state.error != null) { + Text( + modifier = Modifier.testTag(TestIds.Auth.ERROR), + text = state.error, + style = MaterialTheme.typography.bodyMedium, + color = Color.Red, + ) + } +} \ 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 new file mode 100644 index 0000000..f33b9d8 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.ui.screen.auth + +sealed interface AuthState { + object Loading: AuthState + data class Data( + val isEnabledSend: Boolean, + val error: String? + ): 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 new file mode 100644 index 0000000..c28f5cd --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt @@ -0,0 +1,66 @@ +package ru.myitschool.work.ui.screen.auth + +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.AuthRepository +import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase +import ru.myitschool.work.domain.auth.CheckCodeFormatUseCase +import ru.myitschool.work.ui.nav.MainScreenDestination + +class AuthViewModel : ViewModel() { + private val checkCodeFormatUseCase by lazy { CheckCodeFormatUseCase() } + private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) } + private val _uiState = MutableStateFlow( + AuthState.Data( + isEnabledSend = false, + error = null + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow + + fun onIntent(intent: AuthIntent) { + when (intent) { + is AuthIntent.Send -> { + viewModelScope.launch { + checkAndSaveAuthCodeUseCase.invoke(intent.text).fold( + onSuccess = { + _actionFlow.emit(AuthAction.Open(MainScreenDestination)) + }, + onFailure = { error -> + updateStateIfData { oldState -> + oldState.copy( + error = error.message + ) + } + } + ) + } + } + is AuthIntent.TextInput -> { + updateStateIfData { oldState -> + oldState.copy( + isEnabledSend = checkCodeFormatUseCase.invoke(intent.text), + error = null + ) + } + } + } + } + + private fun updateStateIfData(lambda: (AuthState.Data) -> AuthState) { + _uiState.update { state -> + (state as? AuthState.Data)?.let { lambda.invoke(it) } ?: state + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookAction.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookAction.kt new file mode 100644 index 0000000..b7fbf07 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookAction.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.screen.book + +sealed interface BookAction { + object Back: BookAction + object BackWithSuccess: BookAction +} \ 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..9269095 --- /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 { + data object Refresh: BookIntent + data class Add( + val date: String, + val placeId: String + ): 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..60842f3 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt @@ -0,0 +1,312 @@ +package ru.myitschool.work.ui.screen.book + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.Image +import androidx.compose.foundation.interaction.MutableInteractionSource +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.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Tab +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.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import kotlinx.serialization.Serializable +import ru.myitschool.work.R +import ru.myitschool.work.core.TestIds +import ru.myitschool.work.ui.screen.main.MainResult +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@Composable +fun BookScreen( + viewModel: BookViewModel = viewModel(), + navController: NavController +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actionFlow.collect { action -> + when (action) { + is BookAction.Back -> navController.popBackStack() + is BookAction.BackWithSuccess -> { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(MainResult.REFRESH_KEY, true) + navController.popBackStack() + } + } + } + } + + Column { + IconButton( + modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON), + interactionSource = remember { MutableInteractionSource() }, + onClick = { + navController.popBackStack() + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_back), + contentDescription = stringResource(R.string.book_back) + ) + } + + when (val currentState = state) { + is BookState.Data -> ContentState(viewModel, currentState) + is BookState.Error -> ErrorState(viewModel, currentState) + is BookState.Loading -> LoadingState() + is BookState.Empty -> EmptyState() + } + } +} + +@Composable +private fun LoadingState() { + Box( + Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp) + ) + } +} + +@Composable +private fun EmptyState() { + Box( + Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + modifier = Modifier.testTag(TestIds.Book.EMPTY), + text = stringResource(R.string.book_empty), + style = MaterialTheme.typography.headlineSmall, + color = Color.Black, + ) + } +} + +@Composable +private fun ErrorState( + viewModel: BookViewModel, + state: BookState.Error +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(all = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + modifier = Modifier.testTag(TestIds.Book.ERROR), + text = state.error, + style = MaterialTheme.typography.headlineSmall, + color = Color.Black, + ) + Spacer(modifier = Modifier.size(16.dp)) + Button( + modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON).fillMaxWidth(), + onClick = { + viewModel.onIntent(BookIntent.Refresh) + }, + ) { + Text(stringResource(R.string.main_refresh)) + } + } +} + +@Composable +private fun ContentState( + viewModel: BookViewModel, + state: BookState.Data +) { + val navController = rememberNavController() + val startDestination = SelectedTabDestination(index = 0) + var selectedDestination by rememberSaveable { + mutableIntStateOf(startDestination.index) + } + var selectedPlaceId by rememberSaveable { + mutableStateOf(null) + } + Box { + Column { + PrimaryTabRow( + modifier = Modifier, + selectedTabIndex = selectedDestination, + ) { + state.items.forEachIndexed { index, destination -> + Tab( + modifier = Modifier + .testTag(TestIds.Book.getIdDateItemByPosition(index)), + selected = selectedDestination == index, + onClick = { + navController.navigate( + route = SelectedTabDestination(index = index) + ) { + launchSingleTop = true + } + selectedDestination = index + }, + text = { + Text( + modifier = Modifier.testTag(TestIds.Book.ITEM_DATE), + text = LocalDate.parse(destination.date) + .format( + DateTimeFormatter.ofPattern( + "dd.MM" + ) + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + TabNavHost( + modifier = Modifier.fillMaxSize(), + navController = navController, + startDestination = startDestination, + state = state, + onPlaceSelected = { id -> + selectedPlaceId = id + } + ) + } + Box( + modifier = Modifier + .padding(all = 24.dp) + .align(Alignment.BottomEnd), + ) { + FloatingActionButton( + modifier = Modifier.testTag(TestIds.Book.BOOK_BUTTON), + onClick = { + val id = selectedPlaceId + if (id != null) { + viewModel.onIntent( + BookIntent.Add( + date = state.items[selectedDestination].date, + placeId = id + ) + ) + } + } + ) { + Image( + painter = painterResource(R.drawable.ic_check), + contentDescription = stringResource(R.string.book_add) + ) + } + } + } +} + +@Composable +fun TabNavHost( + modifier: Modifier = Modifier, + navController: NavHostController, + startDestination: SelectedTabDestination, + state: BookState.Data, + onPlaceSelected: (String) -> Unit, +) { + NavHost( + modifier = modifier, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + navController = navController, + startDestination = startDestination, + ) { + composable { + val index = it.toRoute().index + val data = state.items[index] + val (selectedOption, onOptionSelected) = remember { + mutableStateOf(data.places[0].id) + } + onPlaceSelected.invoke(selectedOption) + + Column(modifier.selectableGroup()) { + data.places.forEachIndexed { index, place -> + val isSelected = place.id == selectedOption + Row( + Modifier + .testTag(TestIds.Book.getIdPlaceItemByPosition(index)) + .fillMaxWidth() + .height(56.dp) + .selectable( + selected = isSelected, + onClick = { + onPlaceSelected(place.id) + onOptionSelected(place.id) + }, + role = Role.RadioButton + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_SELECTOR), + selected = isSelected, + onClick = null + ) + Text( + modifier = Modifier + .testTag(TestIds.Book.ITEM_PLACE_TEXT) + .padding(start = 16.dp), + text = place.name, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + } + } +} + +@Serializable +data class SelectedTabDestination( + val index: Int +) \ 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..b6da4fb --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt @@ -0,0 +1,28 @@ +package ru.myitschool.work.ui.screen.book + +import kotlinx.collections.immutable.PersistentList +import kotlinx.serialization.Serializable + +sealed interface BookState { + data object Loading : BookState + + data object Empty : BookState + data class Error( + val error: String + ) : BookState + + data class Data( + val items: PersistentList + ) : BookState { + + data class Item( + val date: String, + val places: PersistentList, + ) + + data class Place( + 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..c92fefe --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt @@ -0,0 +1,93 @@ +package ru.myitschool.work.ui.screen.book + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.data.repo.BookRepository +import ru.myitschool.work.domain.book.GetBookingDataUseCase +import ru.myitschool.work.domain.book.SendBookRequestUseCase +import ru.myitschool.work.domain.book.entities.BookRequestData +import kotlin.getValue + +class BookViewModel : ViewModel() { + private val bookRepository by lazy { BookRepository(AuthRepository) } + private val getBookingDataUseCase by lazy { GetBookingDataUseCase(bookRepository) } + private val sendBookRequestUseCase by lazy { SendBookRequestUseCase(bookRepository) } + private val _uiState = MutableStateFlow(BookState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow + + init { + refresh() + } + + fun onIntent(intent: BookIntent) { + when (intent) { + is BookIntent.Refresh -> { + refresh() + } + + is BookIntent.Add -> { + viewModelScope.launch { + sendBookRequestUseCase.invoke( + BookRequestData( + date = intent.date, + placeId = intent.placeId + ) + ).fold( + onSuccess = { + _actionFlow.emit(BookAction.BackWithSuccess) + }, + onFailure = { error -> + error.printStackTrace() + } + ) + } + } + } + } + + private fun refresh() { + viewModelScope.launch { + _uiState.update { BookState.Loading } + _uiState.update { + getBookingDataUseCase.invoke().fold( + onSuccess = { data -> + if (data.isEmpty()) { + BookState.Empty + } else { + BookState.Data( + items = data.map { item -> + BookState.Data.Item( + date = item.date, + places = item.places.map { place -> + BookState.Data.Place( + id = place.id, + name = place.name + ) + }.toPersistentList() + ) + }.toPersistentList() + ) + } + }, + onFailure = { error -> + BookState.Error( + error = error.message.orEmpty() + ) + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainAction.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainAction.kt new file mode 100644 index 0000000..be2af7e --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainAction.kt @@ -0,0 +1,11 @@ +package ru.myitschool.work.ui.screen.main + +import ru.myitschool.work.ui.nav.AppDestination +import ru.myitschool.work.ui.screen.book.BookIntent + +sealed interface MainAction { + class Open( + val destination: AppDestination, + val clearBackStack: Boolean = false + ): MainAction +} \ 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..b53503d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt @@ -0,0 +1,7 @@ +package ru.myitschool.work.ui.screen.main + +sealed interface MainIntent { + data object Refresh: MainIntent + data object Logout: MainIntent + data object Add: MainIntent +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainResult.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainResult.kt new file mode 100644 index 0000000..b447243 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainResult.kt @@ -0,0 +1,5 @@ +package ru.myitschool.work.ui.screen.main + +object MainResult { + const val REFRESH_KEY = "refresh" +} \ 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..19b9ff7 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt @@ -0,0 +1,232 @@ +package ru.myitschool.work.ui.screen.main + +import androidx.compose.foundation.Image +import androidx.compose.foundation.interaction.MutableInteractionSource +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.remember +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.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.AsyncImage +import coil3.request.ImageRequest +import ru.myitschool.work.R +import ru.myitschool.work.core.TestIds + +@Composable +fun MainScreen( + viewModel: MainViewModel = viewModel(), + navController: NavController +) { + val isRefreshNeeded = navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow(MainResult.REFRESH_KEY, false) + ?.collectAsState() + ?.value + ?: false + + LaunchedEffect(isRefreshNeeded) { + if (isRefreshNeeded) { + println("!!!!!!!! refresh after book") + navController.currentBackStackEntry + ?.savedStateHandle + ?.remove(MainResult.REFRESH_KEY) + viewModel.onIntent(MainIntent.Refresh) + } + } + + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actionFlow.collect { action -> + when (action) { + is MainAction.Open -> { + navController.navigate(action.destination) { + if (action.clearBackStack) { + popUpTo(0) + } + } + } + } + } + } + + when (val currentState = state) { + is MainState.Data -> ContentState(viewModel, currentState) + is MainState.Error -> ErrorState(viewModel, currentState) + is MainState.Loading -> LoadingState() + } +} + +@Composable +private fun LoadingState() { + Box( + Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp) + ) + } +} + +@Composable +private fun ErrorState( + viewModel: MainViewModel, + state: MainState.Error +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(all = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + modifier = Modifier.testTag(TestIds.Main.ERROR), + text = state.error, + style = MaterialTheme.typography.headlineSmall, + color = Color.Black, + ) + Spacer(modifier = Modifier.size(16.dp)) + Button( + modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON).fillMaxWidth(), + onClick = { + println("!!!!!!!! refresh on click error") + viewModel.onIntent(MainIntent.Refresh) + }, + ) { + Text(stringResource(R.string.main_refresh)) + } + } +} + +@Composable +private fun ContentState( + viewModel: MainViewModel, + state: MainState.Data +) { + Box( + modifier = Modifier.padding(all = 24.dp) + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + modifier = Modifier + .testTag(TestIds.Main.PROFILE_IMAGE) + .size(64.dp) + .clip(CircleShape), + model = ImageRequest.Builder(LocalContext.current) + .data(state.photoUrl) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + ) + Text( + modifier = Modifier + .testTag(TestIds.Main.PROFILE_NAME) + .padding(horizontal = 4.dp), + text = state.name, + style = MaterialTheme.typography.headlineSmall, + color = Color.Black, + ) + Spacer(Modifier.weight(1f)) + IconButton( + modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON), + interactionSource = remember { MutableInteractionSource() }, + onClick = { + println("!!!!!!!! refresh on click main") + viewModel.onIntent(MainIntent.Refresh) + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_refresh), + contentDescription = stringResource(R.string.main_refresh) + ) + } + IconButton( + modifier = Modifier.testTag(TestIds.Main.LOGOUT_BUTTON), + interactionSource = remember { MutableInteractionSource() }, + onClick = { + viewModel.onIntent(MainIntent.Logout) + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_logout), + contentDescription = stringResource(R.string.main_logout) + ) + } + } + LazyColumn { + itemsIndexed(state.books) { index, book -> + Row( + modifier = Modifier + .testTag(TestIds.Main.getIdItemByPosition(index)) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE), + text = book.place, + style = MaterialTheme.typography.bodyMedium, + color = Color.Black, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier.testTag(TestIds.Main.ITEM_DATE), + text = book.date, + style = MaterialTheme.typography.bodySmall, + color = Color.Black, + ) + } + } + } + } + + FloatingActionButton( + modifier = Modifier.testTag(TestIds.Main.ADD_BUTTON).align(Alignment.BottomEnd), + onClick = { + viewModel.onIntent(MainIntent.Add) + } + ) { + Image( + painter = painterResource(R.drawable.ic_add), + contentDescription = stringResource(R.string.book_add) + ) + } + } +} \ 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..f7e5494 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt @@ -0,0 +1,20 @@ +package ru.myitschool.work.ui.screen.main + +import kotlinx.collections.immutable.PersistentList + +sealed interface MainState { + data object Loading: MainState + data class Error( + val error: String + ): MainState + data class Data( + val name: String, + val photoUrl: String, + val books: PersistentList + ): MainState { + data class Book( + val date: String, + val place: String, + ) + } +} \ 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..d04ec0b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt @@ -0,0 +1,95 @@ +package ru.myitschool.work.ui.screen.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.data.repo.BookRepository +import ru.myitschool.work.domain.auth.LogoutUseCase +import ru.myitschool.work.domain.main.GetMainDataUseCase +import ru.myitschool.work.ui.nav.AuthScreenDestination +import ru.myitschool.work.ui.nav.BookScreenDestination +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import kotlin.getValue + +class MainViewModel : ViewModel() { + private val getMainDataUseCase by lazy { + GetMainDataUseCase(BookRepository(AuthRepository)) + } + private val logoutUseCase by lazy { + LogoutUseCase(AuthRepository) + } + private val _uiState = MutableStateFlow(MainState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow + + init { + println("!!!!!!!! refresh init") + refresh() + } + + fun onIntent(intent: MainIntent) { + when (intent) { + is MainIntent.Add -> { + viewModelScope.launch { + _actionFlow.emit(MainAction.Open(BookScreenDestination)) + } + } + is MainIntent.Refresh -> { + refresh() + } + is MainIntent.Logout -> { + viewModelScope.launch { + logoutUseCase.invoke() + _actionFlow.emit(MainAction.Open(AuthScreenDestination, true)) + } + } + } + } + + private fun refresh() { + println("!!!!!!!! refresh") + viewModelScope.launch { + _uiState.update { MainState.Loading } + _uiState.update { + getMainDataUseCase.invoke().fold( + onSuccess = { data -> + MainState.Data( + name = data.name, + photoUrl = data.photoUrl, + books = data.book.map { book -> + MainState.Data.Book( + date = LocalDate + .parse(book.date) + .format( + DateTimeFormatter.ofPattern(DATE_FORMAT) + ), + place = book.place + ) + }.toPersistentList() + ) + }, + onFailure = { error -> + MainState.Error( + error = error.message?.takeIf { it.isNotBlank() } ?: "Unknown error" + ) + } + ) + } + } + } + + private companion object { + const val DATE_FORMAT = "dd.MM.yyyy" + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/theme/Color.kt b/app/src/main/java/ru/myitschool/work/ui/theme/Color.kt new file mode 100644 index 0000000..22226f4 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package ru.myitschool.work.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/theme/Theme.kt b/app/src/main/java/ru/myitschool/work/ui/theme/Theme.kt new file mode 100644 index 0000000..d9cc58f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/theme/Theme.kt @@ -0,0 +1,57 @@ +package ru.myitschool.work.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@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 + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/theme/Type.kt b/app/src/main/java/ru/myitschool/work/ui/theme/Type.kt new file mode 100644 index 0000000..61b2923 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package ru.myitschool.work.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..9f83b8f --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..075e95d --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..356e998 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_logout.xml b/app/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..c22a96f --- /dev/null +++ b/app/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..86504d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a9273cf --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,15 @@ + + Work + RootActivity + Привет! Введи код для авторизации + Код + Войти + + Обновить + Выйти + Добавить + + Забронировать + Назад + Всё забронировано + \ No newline at end of file diff --git a/testLib b/testLib deleted file mode 160000 index b6291e4..0000000 --- a/testLib +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b6291e4770c62ef8a9c23520a77aebf7f2678aa5