Initial commit
Some checks failed
Merge core/template-android-project-checking to this repo / merge-if-needed (push) Failing after 2s
Merge core/template-android-project to this repo / merge-if-needed (push) Has been cancelled

This commit is contained in:
2025-11-24 19:53:00 +03:00
parent 3c1be2d6ce
commit 60e8c8f923
63 changed files with 1640 additions and 722 deletions

3
.gitmodules vendored
View File

@@ -4,6 +4,3 @@
[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

View File

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

View File

@@ -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<RootActivity>(
clazz = RootActivity::class.java,
isEnabledCompose = true,
) {
@get:Rule
val composeTestRule = createAndroidComposeRule<RootActivity>()
@get:Rule
val serverRule = MockWebServerRule(8090)
@Test
fun роверка_экрана_авторизацииалидация() = runWithInit(1) {
onComposeScreen<AuthScreen>(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 роверка_экрана_авторизации_статусы() = 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<AuthScreen>(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<MainScreen>(composeTestRule) {
step("Проверка перехода на главный экран") {
errorText.assertIsDisplayed()
refreshButton.assertIsDisplayed()
}
}
}
@Test
fun роверка_главного_экрана() = runWithInit(1) {
serverRule.mockResponses(
"/api/abc1/info" to Response(assetFile = "profile.json"),
"/api/abc1/info" to Response(assetFile = "profile2.json"),
)
onComposeScreen<MainScreen>(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 роверка_кнопки_выхода() = runWithInit(1) {
serverRule.mockResponses(
"/api/abc1/info" to Response(assetFile = "profile.json"),
)
onComposeScreen<MainScreen>(composeTestRule) {
step("Проверка наличия всех элементов на экране") {
flakySafely(timeoutMs = 5000L, intervalMs = 100L) {
warmUpCompose(composeTestRule)
errorText.assertIsNotDisplayed()
addButton.assertIsDisplayed()
refreshButton.assertIsDisplayed()
logoutButton.assertIsDisplayed()
profileNameText.assertIsDisplayed()
}
}
step("Нажимаем на кнопку выхода") {
logoutButton.performClick()
}
}
onComposeScreen<AuthScreen>(composeTestRule) {
step("Проверка наличия всех элементов на экране") {
errorText.assertIsNotDisplayed()
signButton.assertIsDisplayed()
codeInput.assertIsDisplayed()
codeInput.assertTextEquals("Код", includeEditableText = false)
}
}
}
@Test
fun роверкаавигации_брони() = 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<AuthScreen>(composeTestRule) {
step("Проверка наличия всех элементов на экране") {
errorText.assertIsNotDisplayed()
signButton.assertIsDisplayed()
codeInput.assertIsDisplayed()
}
step("Вход") {
codeInput.performTextReplacement("abc1")
errorText.assertIsNotDisplayed()
signButton.assertIsEnabled()
signButton.performClick()
}
}
onComposeScreen<MainScreen>(composeTestRule) {
step("Проверка наличия всех элементов на экране") {
flakySafely(timeoutMs = 5000L, intervalMs = 100L) {
warmUpCompose(composeTestRule)
errorText.assertIsNotDisplayed()
addButton.assertIsDisplayed()
refreshButton.assertIsDisplayed()
logoutButton.assertIsDisplayed()
profileNameText.assertIsDisplayed()
}
}
step("Нажимаем на кнопку добавления") {
addButton.performClick()
}
}
onComposeScreen<BookScreen>(composeTestRule) {
step("Проверка наличия всех элементов на экране") {
flakySafely(timeoutMs = 5000L, intervalMs = 100L) {
warmUpCompose(composeTestRule)
errorText.assertIsNotDisplayed()
refreshButton.assertIsDisplayed()
}
pressBack()
}
}
onComposeScreen<MainScreen>(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 роверкаонтента_брони() = 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<MainScreen>(composeTestRule) {
step("Проверка наличия всех элементов на экране") {
flakySafely(timeoutMs = 5000L, intervalMs = 100L) {
warmUpCompose(composeTestRule)
errorText.assertIsNotDisplayed()
addButton.assertIsDisplayed()
refreshButton.assertIsDisplayed()
logoutButton.assertIsDisplayed()
profileNameText.assertIsDisplayed()
}
}
step("Нажимаем на кнопку добавления") {
addButton.performClick()
}
}
onComposeScreen<BookScreen>(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 роверкаозможности_брони() = 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<MainScreen>(composeTestRule) {
step("Проверка наличия всех элементов на экране") {
flakySafely(timeoutMs = 5000L, intervalMs = 100L) {
warmUpCompose(composeTestRule)
errorText.assertIsNotDisplayed()
addButton.assertIsDisplayed()
refreshButton.assertIsDisplayed()
logoutButton.assertIsDisplayed()
profileNameText.assertIsDisplayed()
}
}
step("Нажимаем на кнопку добавления") {
addButton.performClick()
}
}
onComposeScreen<BookScreen>(composeTestRule) {
step("Проверка наличия всех элементов на экране") {
flakySafely(timeoutMs = 5000L, intervalMs = 100L) {
warmUpCompose(composeTestRule)
backButton.assertIsDisplayed()
bookButton.assertIsDisplayed()
}
}
step("Оформление брони") {
bookButton.performClick()
}
}
onComposeScreen<MainScreen>(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 роверка_пустых_слотов() = 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<MainScreen>(composeTestRule) {
step("Проверка наличия всех элементов на экране") {
flakySafely(timeoutMs = 5000L, intervalMs = 100L) {
warmUpCompose(composeTestRule)
errorText.assertIsNotDisplayed()
addButton.assertIsDisplayed()
refreshButton.assertIsDisplayed()
logoutButton.assertIsDisplayed()
profileNameText.assertIsDisplayed()
}
}
step("Нажимаем на кнопку добавления") {
addButton.performClick()
}
}
onComposeScreen<BookScreen>(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<MainScreen>(composeTestRule) {
step("Нажимаем на кнопку добавления") {
addButton.performClick()
}
}
onComposeScreen<BookScreen>(composeTestRule) {
step("Проверка наличия всех элементов на экране") {
flakySafely(timeoutMs = 5000L, intervalMs = 100L) {
warmUpCompose(composeTestRule)
backButton.assertIsDisplayed()
bookButton.assertIsNotDisplayed()
}
}
step("Проверка пустого состояния") {
emptyText.assertIsDisplayed()
}
}
}
}

View File

@@ -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<AuthScreen>(semanticsProvider) {
val errorText = child<KNode> {
hasTestTag(TestIds.Auth.ERROR)
}
val signButton: KNode = child<KNode> {
hasTestTag(TestIds.Auth.SIGN_BUTTON)
}
val codeInput: KNode = child<KNode> {
hasTestTag(TestIds.Auth.CODE_INPUT)
}
}

View File

@@ -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<BookScreen>(semanticsProvider) {
val errorText = child<KNode> {
hasTestTag(TestIds.Book.ERROR)
}
val emptyText: KNode = child<KNode> {
hasTestTag(TestIds.Book.EMPTY)
}
val refreshButton: KNode = child<KNode> {
hasTestTag(TestIds.Book.REFRESH_BUTTON)
}
val backButton: KNode = child<KNode> {
hasTestTag(TestIds.Book.BACK_BUTTON)
}
val bookButton: KNode = child<KNode> {
hasTestTag(TestIds.Book.BOOK_BUTTON)
}
fun getNodeDateByPosition(position: Int) = child<KNode> {
hasTestTag(TestIds.Book.getIdDateItemByPosition(position))
}
fun getNodePlaceByPosition(position: Int) = child<KNode> {
hasTestTag(TestIds.Book.getIdPlaceItemByPosition(position))
}
}

View File

@@ -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<MainScreen>(semanticsProvider) {
val errorText = child<KNode> {
hasTestTag(TestIds.Main.ERROR)
}
val addButton: KNode = child<KNode> {
hasTestTag(TestIds.Main.ADD_BUTTON)
}
val refreshButton: KNode = child<KNode> {
hasTestTag(TestIds.Main.REFRESH_BUTTON)
}
val logoutButton: KNode = child<KNode> {
hasTestTag(TestIds.Main.LOGOUT_BUTTON)
}
val profileNameText: KNode = child<KNode> {
hasTestTag(TestIds.Main.PROFILE_NAME)
}
fun getItemByPosition(position: Int) = child<KNode> {
hasTestTag(TestIds.Main.getIdItemByPosition(position))
}
}
fun KNode.getDateText() = child<KNode> { hasTestTag(TestIds.Main.ITEM_DATE) }
fun KNode.getPlaceText() = child<KNode> { hasTestTag(TestIds.Main.ITEM_PLACE) }

View File

@@ -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<out T : ComposeScreen<T>>(
semanticsProvider: SemanticsNodeInteractionsProvider
) : ComposeScreen<T>(
semanticsProvider = semanticsProvider
)

View File

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

View File

@@ -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<String, Queue<Response>>()
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<String, Response>) {
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,
)

View File

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

View File

@@ -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": []
}

View File

@@ -1 +0,0 @@
{}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String, PlaceDto>?
) {
}

View File

@@ -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<Boolean> {
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<Preferences> by preferencesDataStore(name = STORE)
}

View File

@@ -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<MainInfoEntity> {
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<List<BookingData>> {
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<Boolean> {
val code = authRepository.getCode() ?: return getNoAuthResult()
val dto = BookRequestDto(data.date, data.placeId)
return NetworkDataSource.addBook(code, dto)
}
private fun <T> getNoAuthResult() = Result.failure<T>(
IllegalStateException("No auth")
)
}

View File

@@ -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<Boolean> = 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<UserDto> = 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<UserDto>()
} else {
println("!!!!!!!!!!!!!! getInfo ERROR ${response.bodyAsText()}")
error(response.bodyAsText())
}
}
}
suspend fun getBooking(code: String): Result<Map<String, List<PlaceDto>>?> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get(getUrl(code, Constants.BOOKING_URL))
if (response.status == HttpStatusCode.OK) {
response.body<Map<String, List<PlaceDto>>>()
} else {
error(response.bodyAsText())
}
}
}
suspend fun addBook(code: String, data: BookRequestDto): Result<Boolean> = 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"
}

View File

@@ -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<Unit> {
return repository.checkAndSave(text).mapCatching { success ->
if (!success) error("Code is incorrect")
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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<List<BookingData>> {
return repository.getBookingInfo().map { data ->
data
.sortedBy { book ->
LocalDate.parse(book.date)
}
.filter { it.places.isNotEmpty() }
}
}
}

View File

@@ -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<Unit> {
return repository.sendBook(data).mapCatching { success ->
if (!success) error("Book error")
}
}
}

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.domain.book.entities
data class BookRequestData(
val date: String,
val placeId: String
)

View File

@@ -0,0 +1,11 @@
package ru.myitschool.work.domain.book.entities
data class BookingData(
val date: String,
val places: List<Place>
) {
data class Place(
val id: String,
val name: String
)
}

View File

@@ -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<MainInfoEntity> {
return repository.getInfo().map { main ->
main.copy(
book = main.book.sortedBy { book ->
LocalDate.parse(book.date)
}
)
}
}
}

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.domain.main.entities
data class MainInfoEntity(
val name: String,
val photoUrl: String,
val book: List<Book>
) {
data class Book(
val date: String,
val place: String,
)
}

View File

@@ -0,0 +1,3 @@
package ru.myitschool.work.ui.nav
sealed interface AppDestination

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.ui.nav
import kotlinx.serialization.Serializable
@Serializable
data object AuthScreenDestination: AppDestination

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.ui.nav
import kotlinx.serialization.Serializable
@Serializable
data object BookScreenDestination: AppDestination

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.ui.nav
import kotlinx.serialization.Serializable
@Serializable
data object MainScreenDestination: AppDestination

View File

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

View File

@@ -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<AppDestination?>(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<AuthScreenDestination> {
AuthScreen(navController = navController)
}
composable<MainScreenDestination> {
MainScreen(navController = navController)
}
composable<BookScreenDestination> {
BookScreen(navController = navController)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>(
AuthState.Data(
isEnabledSend = false,
error = null
)
)
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<AuthAction> = MutableSharedFlow()
val actionFlow: SharedFlow<AuthAction> = _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
}
}
}

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.ui.screen.book
sealed interface BookAction {
object Back: BookAction
object BackWithSuccess: BookAction
}

View File

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

View File

@@ -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<String?>(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<SelectedTabDestination> {
val index = it.toRoute<SelectedTabDestination>().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
)

View File

@@ -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<Item>
) : BookState {
data class Item(
val date: String,
val places: PersistentList<Place>,
)
data class Place(
val id: String,
val name: String,
)
}
}

View File

@@ -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>(BookState.Loading)
val uiState: StateFlow<BookState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<BookAction> = MutableSharedFlow()
val actionFlow: SharedFlow<BookAction> = _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()
)
}
)
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package ru.myitschool.work.ui.screen.main
object MainResult {
const val REFRESH_KEY = "refresh"
}

View File

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

View File

@@ -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<Book>
): MainState {
data class Book(
val date: String,
val place: String,
)
}
}

View File

@@ -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>(MainState.Loading)
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<MainAction> = MutableSharedFlow()
val actionFlow: SharedFlow<MainAction> = _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"
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M17,7l-1.41,1.41L18.17,11H8v2h10.17l-2.58,2.58L17,17l5,-5zM4,5h8V3H4c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h8v-2H4V5z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,15 @@
<resources>
<string name="app_name">Work</string>
<string name="title_activity_root">RootActivity</string>
<string name="auth_title">Привет! Введи код для авторизации</string>
<string name="auth_label">Код</string>
<string name="auth_sign_in">Войти</string>
<string name="main_refresh">Обновить</string>
<string name="main_logout">Выйти</string>
<string name="main_add">Добавить</string>
<string name="book_add">Забронировать</string>
<string name="book_back">Назад</string>
<string name="book_empty">Всё забронировано</string>
</resources>

Submodule testLib deleted from b6291e4770