diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 96a1cf7..d5f68f3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,7 @@ plugins { + composeCompiler kotlinAndroid + kotlinSerialization version Version.Kotlin.language androidApplication } @@ -32,6 +34,14 @@ android { } dependencies { - defaultLibrary() + defaultComposeLibrary() + val ktor = "3.3.1" + implementation("io.ktor:ktor-client-core:$ktor") + implementation("io.ktor:ktor-client-cio:$ktor") + 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 new file mode 100755 index 0000000..7774de8 --- /dev/null +++ b/app/src/androidTest/java/ru/myitschool/work/Tests.kt @@ -0,0 +1,229 @@ +@file:OptIn(ExperimentalTestApi::class) + +package ru.myitschool.work + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.junit4.createAndroidComposeRule +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.MainScreen +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 java.nio.charset.Charset + +@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) { + serverRule.mockResponses( + "/user" to Response(assetFile = "profile.json"), + "/user" to Response(assetFile = "profile2.json") + ) + onComposeScreen(composeTestRule) { + step("Нажимаем на кнопку загрузки данных") { + loadButton { + assertIsDisplayed() + performClick() + } + } + + step("Проверяем корректное заполнение контента") { + nameText { + assertIsDisplayed() + assertTextEquals("Test Testovich") + } + list { + firstChild { + roomText.assertTextEquals("row 1") + timeText.assertTextEquals("end row 1") + } + childAt(1) { + roomText.assertTextEquals("row 2") + timeText.assertTextEquals("end row 2") + } + } + } + + step("Обновляем страницу") { + loadButton { + assertIsDisplayed() + performClick() + } + } + + step("Проверяем корректное заполнение контента") { + nameText { + assertIsDisplayed() + assertTextEquals("Ivan Ivanov") + } + list { + firstChild { + roomText.assertTextEquals("row 3") + timeText.assertTextEquals("end row 3") + } + } + } + } + } + + @Test + fun bПроверка_добавления_данных() = runWithInit(1) { + val postResponseRoom = "Content-Disposition: form-data; name=room\nContent-Length: 8\n\nRoomTest" + val postResponseTime = "Content-Disposition: form-data; name=time\nContent-Length: 8\n\nTimeTest" + var requestComplete = false + serverRule.mockResponses( + "/user" to Response(assetFile = "profile.json"), + "/book" to Response(statusCode = 200), + "/user" to Response(assetFile = "profile2.json"), + ) + serverRule.setRecorderListener { request -> + if (request.path == "/book") { + val text = request.body.readString(Charset.defaultCharset()) + .replace("\r", "") + println(text) + assert(text.contains(postResponseRoom)) { "Значение Room не найдено" } + assert(text.contains(postResponseTime)) { "Значение Time не найдено" } + requestComplete = true + } + } + onComposeScreen(composeTestRule) { + step("Нажимаем на кнопку загрузки данных") { + loadButton { + assertIsDisplayed() + performClick() + } + } + + step("Проверяем корректное заполнение контента") { + nameText { + assertIsDisplayed() + assertTextEquals("Test Testovich") + } + list { + firstChild { + roomText.assertTextEquals("row 1") + timeText.assertTextEquals("end row 1") + } + childAt(1) { + roomText.assertTextEquals("row 2") + timeText.assertTextEquals("end row 2") + } + } + } + + step("Заполняем поля для ввода") { + roomInput { + assertIsDisplayed() + performTextInput("RoomTest") + } + timeInput { + assertIsDisplayed() + performTextInput("TimeTest") + } + addButton { + assertIsDisplayed() + performClick() + } + } + flakySafely(timeoutMs = 2_000, intervalMs = 100) { + assert(requestComplete) { "/book запроса не было" } + } + + step("Проверяем обработку результата") { + nameText { + assertIsDisplayed() + assertTextEquals("Ivan Ivanov") + } + } + } + } + + @Test + fun cПроверка_обработки_ошибок() = runWithInit(1) { + serverRule.mockResponses( + "/user" to Response(assetFile = "error.json", statusCode = 400), + "/user" to Response(assetFile = "error2.json", statusCode = 500), + "/user" to Response(assetFile = "profile.json"), + "/book" to Response(assetFile = "error.json", statusCode = 400) + ) + onComposeScreen(composeTestRule) { + step("Нажимаем на кнопку загрузки данных") { + loadButton { + assertIsDisplayed() + performClick() + } + } + + step("Проверяем ошибку и повторно выполняем запрос") { + errorContent { + assertTextEquals("TEST Error 1 TEST") + } + loadButton { + assertIsDisplayed() + performClick() + } + errorContent { + assertTextEquals("TEST Error 2 TEST") + } + loadButton { + assertIsDisplayed() + performClick() + } + } + + step("Проверяем корректное заполнение контента") { + nameText { + assertIsDisplayed() + assertTextEquals("Test Testovich") + } + list { + firstChild { + roomText.assertTextEquals("row 1") + timeText.assertTextEquals("end row 1") + } + childAt(1) { + roomText.assertTextEquals("row 2") + timeText.assertTextEquals("end row 2") + } + } + } + + step("Заполняем поля для ввода") { + roomInput { + assertIsDisplayed() + performTextInput("RoomTest") + } + timeInput { + assertIsDisplayed() + performTextInput("TimeTest") + } + addButton { + assertIsDisplayed() + performClick() + } + } + + step("Проверяем обработку ошибки") { + errorSend { + assertTextEquals("TEST Error 1 TEST") + } + } + } + } +} \ 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 new file mode 100755 index 0000000..1f627ba --- /dev/null +++ b/app/src/androidTest/java/ru/myitschool/work/screens/MainScreen.kt @@ -0,0 +1,55 @@ +package ru.myitschool.work.screens + +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.hasTestTag +import com.kaspersky.components.composesupport.core.KNode +import io.github.kakaocup.compose.node.element.lazylist.KLazyListItemNode +import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode + +class MainScreen( + semanticsProvider: SemanticsNodeInteractionsProvider +) : UiBaseScreen(semanticsProvider) { + val loadButton = child { + hasTestTag("load_button") + } + val nameText: KNode = child { + hasTestTag("name") + } + val roomInput: KNode = child { + hasTestTag("input_room") + } + val timeInput: KNode = child { + hasTestTag("input_time") + } + val addButton: KNode = child { + hasTestTag("add") + } + val errorContent: KNode = child { + hasTestTag("error_text_content") + } + val errorSend: KNode = child { + hasTestTag("error_text_send") + } + + val list = KLazyListNode( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag("booking") }, + itemTypeBuilder = { + itemType(::ListItem) + }, + positionMatcher = { position -> hasTestTag("position=$position") } + ) + + class ListItem( + semanticsNode: SemanticsNode, + semanticsProvider: SemanticsNodeInteractionsProvider, + ) : KLazyListItemNode(semanticsNode, semanticsProvider) { + val roomText: KNode = child { + hasTestTag("room") + } + val timeText: KNode = child { + hasTestTag("time") + } + } +} \ 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 new file mode 100755 index 0000000..1a690b7 --- /dev/null +++ b/app/src/androidTest/java/ru/myitschool/work/screens/UiBaseScreen.kt @@ -0,0 +1,10 @@ +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 new file mode 100755 index 0000000..791cebe --- /dev/null +++ b/app/src/androidTest/java/ru/myitschool/work/utils/LocaleRule.kt @@ -0,0 +1,27 @@ +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 new file mode 100755 index 0000000..bfdce62 --- /dev/null +++ b/app/src/androidTest/java/ru/myitschool/work/utils/MockWebServerRule.kt @@ -0,0 +1,86 @@ +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/error.json b/app/src/androidTest/resources/error.json new file mode 100755 index 0000000..ea06ac0 --- /dev/null +++ b/app/src/androidTest/resources/error.json @@ -0,0 +1,3 @@ +{ + "error": "TEST Error 1 TEST" +} \ No newline at end of file diff --git a/app/src/androidTest/resources/error2.json b/app/src/androidTest/resources/error2.json new file mode 100755 index 0000000..155850b --- /dev/null +++ b/app/src/androidTest/resources/error2.json @@ -0,0 +1,3 @@ +{ + "error": "TEST Error 2 TEST" +} \ No newline at end of file diff --git a/app/src/androidTest/resources/profile.json b/app/src/androidTest/resources/profile.json new file mode 100755 index 0000000..39249b9 --- /dev/null +++ b/app/src/androidTest/resources/profile.json @@ -0,0 +1,13 @@ +{ + "name": "Test Testovich", + "booking": [ + { + "room": "row 1", + "time": "end row 1" + }, + { + "room": "row 2", + "time": "end row 2" + } + ] +} \ No newline at end of file diff --git a/app/src/androidTest/resources/profile2.json b/app/src/androidTest/resources/profile2.json new file mode 100755 index 0000000..1467fee --- /dev/null +++ b/app/src/androidTest/resources/profile2.json @@ -0,0 +1,9 @@ +{ + "name": "Ivan Ivanov", + "booking": [ + { + "room": "row 3", + "time": "end row 3" + } + ] +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9ee5c40..5531bab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + + android:usesCleartextTraffic="true" + tools:targetApi="31"> + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/core/Constants.kt b/app/src/main/java/ru/myitschool/work/core/Constants.kt new file mode 100644 index 0000000..07707db --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/core/Constants.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.core + +object Constants { + const val HOST = "http://localhost:8090" + const val USER_URL = "/user" + const val FULL_USER_URL = "$HOST$USER_URL" + const val BOOK_URL = "/book" + const val FULL_BOOK_URL = "$HOST$BOOK_URL" +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/entities/BookingEntity.kt b/app/src/main/java/ru/myitschool/work/domain/entities/BookingEntity.kt new file mode 100644 index 0000000..0bf603d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/entities/BookingEntity.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.domain.entities + +data class BookingEntity( + val roomName: String, + val time: String, +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/entities/UserEntity.kt b/app/src/main/java/ru/myitschool/work/domain/entities/UserEntity.kt new file mode 100644 index 0000000..86a9851 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/entities/UserEntity.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.domain.entities + +data class UserEntity( + val name: String, + val bookingList: List +) \ 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..cc35f34 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt @@ -0,0 +1,198 @@ +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.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +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.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +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 -> + Screen( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) + } + } + } + } +} + +@Composable +fun Screen( + modifier: Modifier = Modifier, + viewModel: RootViewModel = viewModel() +) { + val state by viewModel.uiState.collectAsState() + + when (val currentState = state) { + is RootState.Content -> { + Column( + modifier = modifier, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.testTag("name").weight(1f), + text = currentState.userEntity.name, + style = MaterialTheme.typography.headlineSmall + ) + ButtonGetData(viewModel) + } + LazyColumn( + modifier = Modifier.testTag("booking").weight(1f) + ) { + itemsIndexed(currentState.userEntity.bookingList) { index, book -> + Row( + modifier = Modifier + .testTag("position=$index") + .padding(vertical = 16.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.testTag("room").weight(1f), + text = book.roomName, + style = MaterialTheme.typography.bodyLarge + ) + Text( + modifier = Modifier.testTag("time"), + text = book.time, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + Row( + modifier = Modifier.padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + var roomText by remember { mutableStateOf("") } + var timeText by remember { mutableStateOf("") } + + Column(modifier = Modifier.weight(1f)) { + TextField( + modifier = Modifier.testTag("input_room").fillMaxWidth(), + value = roomText, + onValueChange = { roomText = it }, + label = { Text("Room") } + ) + TextField( + modifier = Modifier + .testTag("input_time") + .padding(top = 8.dp) + .fillMaxWidth(), + value = timeText, + onValueChange = { timeText = it }, + label = { Text("Time") } + ) + } + Column( + modifier = Modifier.padding(start = 8.dp), + ) { + Button( + modifier = Modifier.testTag("add"), + onClick = { + viewModel.onIntent( + RootIntent.AddBook(room = roomText, time = timeText) + ) + roomText = "" + timeText = "" + } + ) { + Text(text = "Add") + } + if (currentState.errorText != null) { + Text( + modifier = Modifier.testTag("error_text_send"), + text = currentState.errorText + ) + } + } + } + } + + } + + is RootState.Error -> { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.testTag("error_text_content"), + text = currentState.message + ) + ButtonGetData(viewModel) + } + } + + is RootState.Loading -> { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(64.dp)) + } + } + + is RootState.NotLoaded -> { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + ButtonGetData(viewModel) + } + + } + } +} + +@Composable +private fun ButtonGetData( + viewModel: RootViewModel +) { + Button( + modifier = Modifier.testTag("load_button"), + onClick = { viewModel.onIntent(RootIntent.LoadData) } + ) { + Text( + text = "Get load" + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/root/RootIntent.kt b/app/src/main/java/ru/myitschool/work/ui/root/RootIntent.kt new file mode 100644 index 0000000..9804e07 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/root/RootIntent.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.ui.root + +sealed interface RootIntent { + data object LoadData: RootIntent + data class AddBook( + val room: String, + val time: String + ): RootIntent +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/root/RootState.kt b/app/src/main/java/ru/myitschool/work/ui/root/RootState.kt new file mode 100644 index 0000000..1c5d32f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/root/RootState.kt @@ -0,0 +1,13 @@ +package ru.myitschool.work.ui.root + +import ru.myitschool.work.domain.entities.UserEntity + +sealed interface RootState { + data object NotLoaded: RootState + data object Loading: RootState + data class Error(val message: String): RootState + data class Content( + val userEntity: UserEntity, + val errorText: String?, + ): RootState +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/root/RootViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/root/RootViewModel.kt new file mode 100644 index 0000000..d95ac89 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/root/RootViewModel.kt @@ -0,0 +1,68 @@ +package ru.myitschool.work.ui.root + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.myitschool.work.data.AppRepository +import ru.myitschool.work.domain.AddBookUseCase +import ru.myitschool.work.domain.GetUserDataUseCase + +class RootViewModel : ViewModel() { + private val getUserDataUseCase by lazy { + GetUserDataUseCase( + repository = AppRepository + ) + } + private val addBookUseCase by lazy { + AddBookUseCase( + repository = AppRepository + ) + } + private val _uiState = MutableStateFlow(RootState.NotLoaded) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onIntent(intent: RootIntent) { + when (intent) { + is RootIntent.LoadData -> loadData() + is RootIntent.AddBook -> addBook(intent) + } + } + + private fun loadData() { + viewModelScope.launch { + _uiState.emit(RootState.Loading) + getUserDataUseCase.invoke().fold( + onSuccess = { value -> + _uiState.emit(RootState.Content(userEntity = value, errorText = null)) + }, + onFailure = { error -> + _uiState.emit(RootState.Error(error.message.orEmpty())) + } + ) + } + } + + private fun addBook(intent: RootIntent.AddBook) { + viewModelScope.launch { + addBookUseCase.invoke( + room = intent.room, + time = intent.time + ).fold( + onSuccess = { + loadData() + }, + onFailure = { error -> + _uiState.update { state -> + (state as? RootState.Content)?.copy( + errorText = error.message + ) ?: state + } + } + ) + } + } +} \ 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/values/strings.xml b/app/src/main/res/values/strings.xml index 96034ac..e88fc68 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ Work + NTO-2025 \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml deleted file mode 100644 index 89e63d4..0000000 --- a/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 64d8748..572c00e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,6 @@ plugins { androidApplication version Version.agp apply false kotlinJvm version Version.Kotlin.language apply false + kotlinAndroid version Version.Kotlin.language apply false + composeCompiler version Version.Kotlin.language apply false } \ No newline at end of file diff --git a/buildSrc b/buildSrc index d959060..d302d63 160000 --- a/buildSrc +++ b/buildSrc @@ -1 +1 @@ -Subproject commit d9590600045906edeb852eaa3f0b9bf7d1875813 +Subproject commit d302d630f12102c7159e586b57c88cfedd17e65e diff --git a/gradle b/gradle index d115144..a28ff9e 160000 --- a/gradle +++ b/gradle @@ -1 +1 @@ -Subproject commit d11514433239954cc4fa8b336c0348daf8ff7268 +Subproject commit a28ff9e07699e093bfecb2b1fab86e07cb768915 diff --git a/testLib b/testLib index 6301091..b6291e4 160000 --- a/testLib +++ b/testLib @@ -1 +1 @@ -Subproject commit 63010918ee3ab9aa7d446f742b35164823116ecc +Subproject commit b6291e4770c62ef8a9c23520a77aebf7f2678aa5