commit 6cb08e11beeb95cb0abf2aa6241a4f1eed8e7c04 Author: vladimir-shperling Date: Tue Feb 24 15:06:35 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87525d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.idea +/.gradle +/build \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..a70cefb --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + composeCompiler + kotlinAndroid + kotlinSerialization version Version.Kotlin.language + androidApplication +} + +val packageName = "ru.myitschool.work" + +android { + namespace = packageName + compileSdk = Version.Android.Sdk.compile + + defaultConfig { + applicationId = packageName + minSdk = Version.Android.Sdk.min + targetSdk = Version.Android.Sdk.target + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures.viewBinding = true + + compileOptions { + sourceCompatibility = Version.Kotlin.javaSource + targetCompatibility = Version.Kotlin.javaSource + } + + kotlinOptions { + jvmTarget = Version.Kotlin.jvmTarget + } +} + +dependencies { + defaultComposeLibrary() + implementation("androidx.datastore:datastore-preferences:1.1.7") + implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0") + implementation("androidx.navigation:navigation-compose:2.9.6") + val coil = "3.3.0" + implementation("io.coil-kt.coil3:coil-compose:$coil") + implementation("io.coil-kt.coil3:coil-network-ktor3:$coil") + 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") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a2c02bd --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/App.kt b/app/src/main/java/ru/myitschool/work/App.kt new file mode 100644 index 0000000..aa33483 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/App.kt @@ -0,0 +1,15 @@ +package ru.myitschool.work + +import android.app.Application +import android.content.Context + +class App: Application() { + override fun onCreate() { + super.onCreate() + context = this + } + + companion object { + lateinit var context: Context + } +} \ 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..cd36239 --- /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 AUTH_URL = "/auth" + const val INFO_URL = "/info" + const val BOOKING_URL = "/booking" + const val BOOK_URL = "/book" +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/core/TestIds.kt b/app/src/main/java/ru/myitschool/work/core/TestIds.kt new file mode 100644 index 0000000..d67b884 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/core/TestIds.kt @@ -0,0 +1,35 @@ +package ru.myitschool.work.core + +object TestIds { + object Auth { + const val ERROR = "auth_error" + const val SIGN_BUTTON = "auth_sign_button" + const val CODE_INPUT = "auth_code_input" + } + object Main { + const val ERROR = "main_error" + const val ADD_BUTTON = "main_add_button" + const val REFRESH_BUTTON = "main_refresh_button" + const val LOGOUT_BUTTON = "main_logout_button" + const val PROFILE_IMAGE = "main_image" + const val PROFILE_NAME = "main_name" + const val ITEM_PLACE = "main_item_place" + const val ITEM_DATE = "main_item_date" + + fun getIdItemByPosition(position: Int) = "main_book_pos_$position" + } + + object Book { + const val ERROR = "book_error" + const val EMPTY = "book_empty" + const val REFRESH_BUTTON = "book_refresh_button" + const val BACK_BUTTON = "book_back_button" + const val BOOK_BUTTON = "book_book_button" + const val ITEM_DATE = "book_date" + const val ITEM_PLACE_TEXT = "book_place_text" + const val ITEM_PLACE_SELECTOR = "book_place_selector" + + fun getIdDateItemByPosition(position: Int) = "book_date_pos_$position" + fun getIdPlaceItemByPosition(position: Int) = "book_place_pos_$position" + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/dto/BookRequestDto.kt b/app/src/main/java/ru/myitschool/work/data/dto/BookRequestDto.kt new file mode 100644 index 0000000..759ffc5 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/dto/BookRequestDto.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BookRequestDto( + @SerialName("date") + val date: String, + @SerialName("placeId") + val placeId: String, +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/dto/PlaceDto.kt b/app/src/main/java/ru/myitschool/work/data/dto/PlaceDto.kt new file mode 100644 index 0000000..381a1f4 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/dto/PlaceDto.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PlaceDto( + @SerialName("id") + val id: String?, + @SerialName("place") + val place: String?, +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/dto/UserDto.kt b/app/src/main/java/ru/myitschool/work/data/dto/UserDto.kt new file mode 100644 index 0000000..e5e968b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/dto/UserDto.kt @@ -0,0 +1,15 @@ +package ru.myitschool.work.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserDto( + @SerialName("name") + val name: String?, + @SerialName("photoUrl") + val photoUrl: String?, + @SerialName("booking") + val booking: Map? +) { +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt new file mode 100644 index 0000000..e4126dd --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt @@ -0,0 +1,51 @@ +package ru.myitschool.work.data.repo + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.firstOrNull +import ru.myitschool.work.App +import ru.myitschool.work.data.source.NetworkDataSource + +object AuthRepository { + private const val STORE = "AUTH-STORE" + private const val CODE_KEY = "CODE" + + private var codeCache: String? = null + + suspend fun checkAndSave(text: String): Result { + return NetworkDataSource.checkAuth(text).onSuccess { success -> + if (success) { + codeCache = text + App.context.userDataStore.edit { preferences -> + val prefKey = stringPreferencesKey(CODE_KEY) + preferences[prefKey] = text + } + } + } + } + + suspend fun getCode(): String? { + if (codeCache == null) { + codeCache = App.context.userDataStore.data + .firstOrNull() + ?.let { preferences -> + preferences[stringPreferencesKey(CODE_KEY)] + } + } + return codeCache + } + + suspend fun logout() { + codeCache = null + App.context.userDataStore.edit { preferences -> + val prefKey = stringPreferencesKey(CODE_KEY) + preferences.remove(prefKey) + } + } + + private val Context.userDataStore: DataStore by preferencesDataStore(name = STORE) +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt new file mode 100644 index 0000000..ea1c581 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt @@ -0,0 +1,53 @@ +package ru.myitschool.work.data.repo + +import ru.myitschool.work.data.dto.BookRequestDto +import ru.myitschool.work.data.source.NetworkDataSource +import ru.myitschool.work.domain.book.entities.BookRequestData +import ru.myitschool.work.domain.book.entities.BookingData +import ru.myitschool.work.domain.main.entities.MainInfoEntity + +class BookRepository( + private val authRepository: AuthRepository +) { + suspend fun getInfo(): Result { + val code = authRepository.getCode() ?: return getNoAuthResult() + return NetworkDataSource.getInfo(code).mapCatching { dto -> + MainInfoEntity( + name = dto.name ?: error("Name is null"), + photoUrl = dto.photoUrl ?: error("Photo url is null"), + book = dto.booking?.mapNotNull { (date, place) -> + MainInfoEntity.Book( + date = date, + place = place.place ?: return@mapNotNull null + ) + } ?: listOf() + ) + } + } + + suspend fun getBookingInfo(): Result> { + val code = authRepository.getCode() ?: return getNoAuthResult() + return NetworkDataSource.getBooking(code).mapCatching { dto -> + dto?.map { (date, places) -> + BookingData( + date = date, + places = places.mapNotNull { place -> + BookingData.Place( + id = place.id ?: return@mapNotNull null, + name = place.place ?: return@mapNotNull null + ) + } + ) + } ?: error("map is null") + } + } + + suspend fun sendBook(data: BookRequestData): Result { + val code = authRepository.getCode() ?: return getNoAuthResult() + val dto = BookRequestDto(data.date, data.placeId) + return NetworkDataSource.addBook(code, dto) + } + private fun getNoAuthResult() = Result.failure( + IllegalStateException("No auth") + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt new file mode 100644 index 0000000..85387ac --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt @@ -0,0 +1,89 @@ +package ru.myitschool.work.data.source + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import ru.myitschool.work.core.Constants +import ru.myitschool.work.data.dto.PlaceDto +import ru.myitschool.work.data.dto.BookRequestDto +import ru.myitschool.work.data.dto.UserDto + +object NetworkDataSource { + private val client by lazy { + HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + isLenient = true + ignoreUnknownKeys = true + explicitNulls = true + encodeDefaults = true + } + ) + } + } + } + + suspend fun checkAuth(code: String): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.get(getUrl(code, Constants.AUTH_URL)) + when (response.status) { + HttpStatusCode.OK -> true + else -> false + } + } + } + + suspend fun getInfo(code: String): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + println("!!!!!!!!!!!!!! getInfo $code") + val response = client.get(getUrl(code, Constants.INFO_URL)) + if (response.status == HttpStatusCode.OK) { + println("!!!!!!!!!!!!!! getInfo OK ${response.bodyAsText()}") + response.body() + } else { + println("!!!!!!!!!!!!!! getInfo ERROR ${response.bodyAsText()}") + error(response.bodyAsText()) + } + } + } + + suspend fun getBooking(code: String): Result>?> = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.get(getUrl(code, Constants.BOOKING_URL)) + if (response.status == HttpStatusCode.OK) { + response.body>>() + } else { + error(response.bodyAsText()) + } + } + } + + suspend fun addBook(code: String, data: BookRequestDto): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.post(getUrl(code, Constants.BOOK_URL)) { + contentType(ContentType.Application.Json) + setBody(data) + } + when (response.status) { + HttpStatusCode.Created -> true + HttpStatusCode.Conflict -> false + else -> error(response.bodyAsText()) + } + } + } + + private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl" +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt new file mode 100644 index 0000000..012fb6f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt @@ -0,0 +1,15 @@ +package ru.myitschool.work.domain.auth + +import ru.myitschool.work.data.repo.AuthRepository + +class CheckAndSaveAuthCodeUseCase( + private val repository: AuthRepository +) { + suspend operator fun invoke( + text: String + ): Result { + return repository.checkAndSave(text).mapCatching { success -> + if (!success) error("Code is incorrect") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/CheckCodeFormatUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/CheckCodeFormatUseCase.kt new file mode 100644 index 0000000..fe291a0 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/auth/CheckCodeFormatUseCase.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.domain.auth + +class CheckCodeFormatUseCase { + operator fun invoke( + text: String + ): Boolean { + return text.length == 4 && text.all { char -> + char.isLetterOrDigit() && + ((char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char.isDigit()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/GetCodeUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/GetCodeUseCase.kt new file mode 100644 index 0000000..a3c22b8 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/auth/GetCodeUseCase.kt @@ -0,0 +1,11 @@ +package ru.myitschool.work.domain.auth + +import ru.myitschool.work.data.repo.AuthRepository + +class GetCodeUseCase( + private val repository: AuthRepository +) { + suspend operator fun invoke(): String? { + return repository.getCode() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/LogoutUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/LogoutUseCase.kt new file mode 100644 index 0000000..6468efb --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/auth/LogoutUseCase.kt @@ -0,0 +1,11 @@ +package ru.myitschool.work.domain.auth + +import ru.myitschool.work.data.repo.AuthRepository + +class LogoutUseCase( + private val repository: AuthRepository +) { + suspend operator fun invoke() { + repository.logout() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/book/GetBookingDataUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/book/GetBookingDataUseCase.kt new file mode 100644 index 0000000..af52ccf --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/GetBookingDataUseCase.kt @@ -0,0 +1,19 @@ +package ru.myitschool.work.domain.book + +import ru.myitschool.work.data.repo.BookRepository +import ru.myitschool.work.domain.book.entities.BookingData +import java.time.LocalDate + +class GetBookingDataUseCase( + private val repository: BookRepository +) { + suspend operator fun invoke(): Result> { + return repository.getBookingInfo().map { data -> + data + .sortedBy { book -> + LocalDate.parse(book.date) + } + .filter { it.places.isNotEmpty() } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/book/SendBookRequestUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/book/SendBookRequestUseCase.kt new file mode 100644 index 0000000..010effe --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/SendBookRequestUseCase.kt @@ -0,0 +1,14 @@ +package ru.myitschool.work.domain.book + +import ru.myitschool.work.data.repo.BookRepository +import ru.myitschool.work.domain.book.entities.BookRequestData + +class SendBookRequestUseCase( + private val repository: BookRepository +) { + suspend operator fun invoke(data: BookRequestData): Result { + return repository.sendBook(data).mapCatching { success -> + if (!success) error("Book error") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/book/entities/BookRequestData.kt b/app/src/main/java/ru/myitschool/work/domain/book/entities/BookRequestData.kt new file mode 100644 index 0000000..431a9ad --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/entities/BookRequestData.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.domain.book.entities + +data class BookRequestData( + val date: String, + val placeId: String +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/book/entities/BookingData.kt b/app/src/main/java/ru/myitschool/work/domain/book/entities/BookingData.kt new file mode 100644 index 0000000..5546649 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/entities/BookingData.kt @@ -0,0 +1,11 @@ +package ru.myitschool.work.domain.book.entities + +data class BookingData( + val date: String, + val places: List +) { + data class Place( + val id: String, + val name: String + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/main/GetMainDataUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/main/GetMainDataUseCase.kt new file mode 100644 index 0000000..7beebeb --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/main/GetMainDataUseCase.kt @@ -0,0 +1,19 @@ +package ru.myitschool.work.domain.main + +import ru.myitschool.work.data.repo.BookRepository +import ru.myitschool.work.domain.main.entities.MainInfoEntity +import java.time.LocalDate + +class GetMainDataUseCase( + private val repository: BookRepository +) { + suspend operator fun invoke(): Result { + return repository.getInfo().map { main -> + main.copy( + book = main.book.sortedBy { book -> + LocalDate.parse(book.date) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/main/entities/MainInfoEntity.kt b/app/src/main/java/ru/myitschool/work/domain/main/entities/MainInfoEntity.kt new file mode 100644 index 0000000..3a0cc42 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/main/entities/MainInfoEntity.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.domain.main.entities + +data class MainInfoEntity( + val name: String, + val photoUrl: String, + val book: List +) { + data class Book( + val date: String, + val place: String, + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt new file mode 100644 index 0000000..557b893 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt @@ -0,0 +1,3 @@ +package ru.myitschool.work.ui.nav + +sealed interface AppDestination \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/AuthScreenDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/AuthScreenDestination.kt new file mode 100644 index 0000000..52660b1 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/nav/AuthScreenDestination.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.nav + +import kotlinx.serialization.Serializable + +@Serializable +data object AuthScreenDestination: AppDestination \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt new file mode 100644 index 0000000..9a33073 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.nav + +import kotlinx.serialization.Serializable + +@Serializable +data object BookScreenDestination: AppDestination \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt new file mode 100644 index 0000000..deca45f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.nav + +import kotlinx.serialization.Serializable + +@Serializable +data object MainScreenDestination: AppDestination \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt b/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt new file mode 100644 index 0000000..54b156d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt @@ -0,0 +1,30 @@ +package ru.myitschool.work.ui.root + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.ui.Modifier +import ru.myitschool.work.ui.screen.AppNavHost +import ru.myitschool.work.ui.theme.WorkTheme + +class RootActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + WorkTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + AppNavHost( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt b/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt new file mode 100644 index 0000000..3590d24 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt @@ -0,0 +1,60 @@ +package ru.myitschool.work.ui.screen + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import kotlinx.coroutines.delay +import ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.domain.auth.GetCodeUseCase +import ru.myitschool.work.ui.nav.AppDestination +import ru.myitschool.work.ui.nav.AuthScreenDestination +import ru.myitschool.work.ui.nav.BookScreenDestination +import ru.myitschool.work.ui.nav.MainScreenDestination +import ru.myitschool.work.ui.screen.auth.AuthScreen +import ru.myitschool.work.ui.screen.book.BookScreen +import ru.myitschool.work.ui.screen.main.MainScreen + +@Composable +fun AppNavHost( + modifier: Modifier = Modifier, + navController: NavHostController = rememberNavController() +) { + var destination by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + val code = GetCodeUseCase(AuthRepository).invoke() + destination = if (code == null) { + AuthScreenDestination + } else { + MainScreenDestination + } + } + if (destination != null) { + NavHost( + modifier = modifier, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + navController = navController, + startDestination = destination as AppDestination, + ) { + composable { + AuthScreen(navController = navController) + } + composable { + MainScreen(navController = navController) + } + composable { + BookScreen(navController = navController) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthAction.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthAction.kt new file mode 100644 index 0000000..a661897 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthAction.kt @@ -0,0 +1,7 @@ +package ru.myitschool.work.ui.screen.auth + +import ru.myitschool.work.ui.nav.AppDestination + +sealed interface AuthAction { + class Open(val destination: AppDestination): AuthAction +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthIntent.kt new file mode 100644 index 0000000..74f200a --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthIntent.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.screen.auth + +sealed interface AuthIntent { + data class Send(val text: String): AuthIntent + data class TextInput(val text: String): AuthIntent +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt new file mode 100644 index 0000000..4b91b98 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt @@ -0,0 +1,108 @@ +package ru.myitschool.work.ui.screen.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import ru.myitschool.work.R +import ru.myitschool.work.core.TestIds + +@Composable +fun AuthScreen( + viewModel: AuthViewModel = viewModel(), + navController: NavController +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actionFlow.collect { action -> + when (action) { + is AuthAction.Open -> navController.navigate(action.destination) { + popUpTo(0) + } + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(all = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.auth_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + when (val currentState = state) { + is AuthState.Data -> Content(viewModel, currentState) + is AuthState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.size(64.dp) + ) + } + } + } +} + +@Composable +private fun Content( + viewModel: AuthViewModel, + state: AuthState.Data +) { + var inputText by remember { mutableStateOf("") } + Spacer(modifier = Modifier.size(16.dp)) + TextField( + modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(), + value = inputText, + onValueChange = { + inputText = it + viewModel.onIntent(AuthIntent.TextInput(it)) + }, + label = { Text(stringResource(R.string.auth_label)) } + ) + Spacer(modifier = Modifier.size(16.dp)) + Button( + modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(), + onClick = { + viewModel.onIntent(AuthIntent.Send(inputText)) + }, + enabled = state.isEnabledSend + ) { + Text(stringResource(R.string.auth_sign_in)) + } + if (state.error != null) { + Text( + modifier = Modifier.testTag(TestIds.Auth.ERROR), + text = state.error, + style = MaterialTheme.typography.bodyMedium, + color = Color.Red, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt new file mode 100644 index 0000000..f33b9d8 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.ui.screen.auth + +sealed interface AuthState { + object Loading: AuthState + data class Data( + val isEnabledSend: Boolean, + val error: String? + ): AuthState +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt new file mode 100644 index 0000000..c28f5cd --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt @@ -0,0 +1,66 @@ +package ru.myitschool.work.ui.screen.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase +import ru.myitschool.work.domain.auth.CheckCodeFormatUseCase +import ru.myitschool.work.ui.nav.MainScreenDestination + +class AuthViewModel : ViewModel() { + private val checkCodeFormatUseCase by lazy { CheckCodeFormatUseCase() } + private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) } + private val _uiState = MutableStateFlow( + AuthState.Data( + isEnabledSend = false, + error = null + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow + + fun onIntent(intent: AuthIntent) { + when (intent) { + is AuthIntent.Send -> { + viewModelScope.launch { + checkAndSaveAuthCodeUseCase.invoke(intent.text).fold( + onSuccess = { + _actionFlow.emit(AuthAction.Open(MainScreenDestination)) + }, + onFailure = { error -> + updateStateIfData { oldState -> + oldState.copy( + error = error.message + ) + } + } + ) + } + } + is AuthIntent.TextInput -> { + updateStateIfData { oldState -> + oldState.copy( + isEnabledSend = checkCodeFormatUseCase.invoke(intent.text), + error = null + ) + } + } + } + } + + private fun updateStateIfData(lambda: (AuthState.Data) -> AuthState) { + _uiState.update { state -> + (state as? AuthState.Data)?.let { lambda.invoke(it) } ?: state + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookAction.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookAction.kt new file mode 100644 index 0000000..b7fbf07 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookAction.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.screen.book + +sealed interface BookAction { + object Back: BookAction + object BackWithSuccess: BookAction +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt new file mode 100644 index 0000000..9269095 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.ui.screen.book + +sealed interface BookIntent { + data object Refresh: BookIntent + data class Add( + val date: String, + val placeId: String + ): BookIntent +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt new file mode 100644 index 0000000..60842f3 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt @@ -0,0 +1,312 @@ +package ru.myitschool.work.ui.screen.book + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.Image +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import kotlinx.serialization.Serializable +import ru.myitschool.work.R +import ru.myitschool.work.core.TestIds +import ru.myitschool.work.ui.screen.main.MainResult +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@Composable +fun BookScreen( + viewModel: BookViewModel = viewModel(), + navController: NavController +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actionFlow.collect { action -> + when (action) { + is BookAction.Back -> navController.popBackStack() + is BookAction.BackWithSuccess -> { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(MainResult.REFRESH_KEY, true) + navController.popBackStack() + } + } + } + } + + Column { + IconButton( + modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON), + interactionSource = remember { MutableInteractionSource() }, + onClick = { + navController.popBackStack() + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_back), + contentDescription = stringResource(R.string.book_back) + ) + } + + when (val currentState = state) { + is BookState.Data -> ContentState(viewModel, currentState) + is BookState.Error -> ErrorState(viewModel, currentState) + is BookState.Loading -> LoadingState() + is BookState.Empty -> EmptyState() + } + } +} + +@Composable +private fun LoadingState() { + Box( + Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp) + ) + } +} + +@Composable +private fun EmptyState() { + Box( + Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + modifier = Modifier.testTag(TestIds.Book.EMPTY), + text = stringResource(R.string.book_empty), + style = MaterialTheme.typography.headlineSmall, + color = Color.Black, + ) + } +} + +@Composable +private fun ErrorState( + viewModel: BookViewModel, + state: BookState.Error +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(all = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + modifier = Modifier.testTag(TestIds.Book.ERROR), + text = state.error, + style = MaterialTheme.typography.headlineSmall, + color = Color.Black, + ) + Spacer(modifier = Modifier.size(16.dp)) + Button( + modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON).fillMaxWidth(), + onClick = { + viewModel.onIntent(BookIntent.Refresh) + }, + ) { + Text(stringResource(R.string.main_refresh)) + } + } +} + +@Composable +private fun ContentState( + viewModel: BookViewModel, + state: BookState.Data +) { + val navController = rememberNavController() + val startDestination = SelectedTabDestination(index = 0) + var selectedDestination by rememberSaveable { + mutableIntStateOf(startDestination.index) + } + var selectedPlaceId by rememberSaveable { + mutableStateOf(null) + } + Box { + Column { + PrimaryTabRow( + modifier = Modifier, + selectedTabIndex = selectedDestination, + ) { + state.items.forEachIndexed { index, destination -> + Tab( + modifier = Modifier + .testTag(TestIds.Book.getIdDateItemByPosition(index)), + selected = selectedDestination == index, + onClick = { + navController.navigate( + route = SelectedTabDestination(index = index) + ) { + launchSingleTop = true + } + selectedDestination = index + }, + text = { + Text( + modifier = Modifier.testTag(TestIds.Book.ITEM_DATE), + text = LocalDate.parse(destination.date) + .format( + DateTimeFormatter.ofPattern( + "dd.MM" + ) + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + TabNavHost( + modifier = Modifier.fillMaxSize(), + navController = navController, + startDestination = startDestination, + state = state, + onPlaceSelected = { id -> + selectedPlaceId = id + } + ) + } + Box( + modifier = Modifier + .padding(all = 24.dp) + .align(Alignment.BottomEnd), + ) { + FloatingActionButton( + modifier = Modifier.testTag(TestIds.Book.BOOK_BUTTON), + onClick = { + val id = selectedPlaceId + if (id != null) { + viewModel.onIntent( + BookIntent.Add( + date = state.items[selectedDestination].date, + placeId = id + ) + ) + } + } + ) { + Image( + painter = painterResource(R.drawable.ic_check), + contentDescription = stringResource(R.string.book_add) + ) + } + } + } +} + +@Composable +fun TabNavHost( + modifier: Modifier = Modifier, + navController: NavHostController, + startDestination: SelectedTabDestination, + state: BookState.Data, + onPlaceSelected: (String) -> Unit, +) { + NavHost( + modifier = modifier, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + navController = navController, + startDestination = startDestination, + ) { + composable { + val index = it.toRoute().index + val data = state.items[index] + val (selectedOption, onOptionSelected) = remember { + mutableStateOf(data.places[0].id) + } + onPlaceSelected.invoke(selectedOption) + + Column(modifier.selectableGroup()) { + data.places.forEachIndexed { index, place -> + val isSelected = place.id == selectedOption + Row( + Modifier + .testTag(TestIds.Book.getIdPlaceItemByPosition(index)) + .fillMaxWidth() + .height(56.dp) + .selectable( + selected = isSelected, + onClick = { + onPlaceSelected(place.id) + onOptionSelected(place.id) + }, + role = Role.RadioButton + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_SELECTOR), + selected = isSelected, + onClick = null + ) + Text( + modifier = Modifier + .testTag(TestIds.Book.ITEM_PLACE_TEXT) + .padding(start = 16.dp), + text = place.name, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + } + } +} + +@Serializable +data class SelectedTabDestination( + val index: Int +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt new file mode 100644 index 0000000..b6da4fb --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt @@ -0,0 +1,28 @@ +package ru.myitschool.work.ui.screen.book + +import kotlinx.collections.immutable.PersistentList +import kotlinx.serialization.Serializable + +sealed interface BookState { + data object Loading : BookState + + data object Empty : BookState + data class Error( + val error: String + ) : BookState + + data class Data( + val items: PersistentList + ) : BookState { + + data class Item( + val date: String, + val places: PersistentList, + ) + + data class Place( + val id: String, + val name: String, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt new file mode 100644 index 0000000..c92fefe --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt @@ -0,0 +1,93 @@ +package ru.myitschool.work.ui.screen.book + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.data.repo.BookRepository +import ru.myitschool.work.domain.book.GetBookingDataUseCase +import ru.myitschool.work.domain.book.SendBookRequestUseCase +import ru.myitschool.work.domain.book.entities.BookRequestData +import kotlin.getValue + +class BookViewModel : ViewModel() { + private val bookRepository by lazy { BookRepository(AuthRepository) } + private val getBookingDataUseCase by lazy { GetBookingDataUseCase(bookRepository) } + private val sendBookRequestUseCase by lazy { SendBookRequestUseCase(bookRepository) } + private val _uiState = MutableStateFlow(BookState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow + + init { + refresh() + } + + fun onIntent(intent: BookIntent) { + when (intent) { + is BookIntent.Refresh -> { + refresh() + } + + is BookIntent.Add -> { + viewModelScope.launch { + sendBookRequestUseCase.invoke( + BookRequestData( + date = intent.date, + placeId = intent.placeId + ) + ).fold( + onSuccess = { + _actionFlow.emit(BookAction.BackWithSuccess) + }, + onFailure = { error -> + error.printStackTrace() + } + ) + } + } + } + } + + private fun refresh() { + viewModelScope.launch { + _uiState.update { BookState.Loading } + _uiState.update { + getBookingDataUseCase.invoke().fold( + onSuccess = { data -> + if (data.isEmpty()) { + BookState.Empty + } else { + BookState.Data( + items = data.map { item -> + BookState.Data.Item( + date = item.date, + places = item.places.map { place -> + BookState.Data.Place( + id = place.id, + name = place.name + ) + }.toPersistentList() + ) + }.toPersistentList() + ) + } + }, + onFailure = { error -> + BookState.Error( + error = error.message.orEmpty() + ) + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainAction.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainAction.kt new file mode 100644 index 0000000..be2af7e --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainAction.kt @@ -0,0 +1,11 @@ +package ru.myitschool.work.ui.screen.main + +import ru.myitschool.work.ui.nav.AppDestination +import ru.myitschool.work.ui.screen.book.BookIntent + +sealed interface MainAction { + class Open( + val destination: AppDestination, + val clearBackStack: Boolean = false + ): MainAction +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt new file mode 100644 index 0000000..b53503d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt @@ -0,0 +1,7 @@ +package ru.myitschool.work.ui.screen.main + +sealed interface MainIntent { + data object Refresh: MainIntent + data object Logout: MainIntent + data object Add: MainIntent +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainResult.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainResult.kt new file mode 100644 index 0000000..b447243 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainResult.kt @@ -0,0 +1,5 @@ +package ru.myitschool.work.ui.screen.main + +object MainResult { + const val REFRESH_KEY = "refresh" +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt new file mode 100644 index 0000000..19b9ff7 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt @@ -0,0 +1,232 @@ +package ru.myitschool.work.ui.screen.main + +import androidx.compose.foundation.Image +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import ru.myitschool.work.R +import ru.myitschool.work.core.TestIds + +@Composable +fun MainScreen( + viewModel: MainViewModel = viewModel(), + navController: NavController +) { + val isRefreshNeeded = navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow(MainResult.REFRESH_KEY, false) + ?.collectAsState() + ?.value + ?: false + + LaunchedEffect(isRefreshNeeded) { + if (isRefreshNeeded) { + println("!!!!!!!! refresh after book") + navController.currentBackStackEntry + ?.savedStateHandle + ?.remove(MainResult.REFRESH_KEY) + viewModel.onIntent(MainIntent.Refresh) + } + } + + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actionFlow.collect { action -> + when (action) { + is MainAction.Open -> { + navController.navigate(action.destination) { + if (action.clearBackStack) { + popUpTo(0) + } + } + } + } + } + } + + when (val currentState = state) { + is MainState.Data -> ContentState(viewModel, currentState) + is MainState.Error -> ErrorState(viewModel, currentState) + is MainState.Loading -> LoadingState() + } +} + +@Composable +private fun LoadingState() { + Box( + Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp) + ) + } +} + +@Composable +private fun ErrorState( + viewModel: MainViewModel, + state: MainState.Error +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(all = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + modifier = Modifier.testTag(TestIds.Main.ERROR), + text = state.error, + style = MaterialTheme.typography.headlineSmall, + color = Color.Black, + ) + Spacer(modifier = Modifier.size(16.dp)) + Button( + modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON).fillMaxWidth(), + onClick = { + println("!!!!!!!! refresh on click error") + viewModel.onIntent(MainIntent.Refresh) + }, + ) { + Text(stringResource(R.string.main_refresh)) + } + } +} + +@Composable +private fun ContentState( + viewModel: MainViewModel, + state: MainState.Data +) { + Box( + modifier = Modifier.padding(all = 24.dp) + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + modifier = Modifier + .testTag(TestIds.Main.PROFILE_IMAGE) + .size(64.dp) + .clip(CircleShape), + model = ImageRequest.Builder(LocalContext.current) + .data(state.photoUrl) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + ) + Text( + modifier = Modifier + .testTag(TestIds.Main.PROFILE_NAME) + .padding(horizontal = 4.dp), + text = state.name, + style = MaterialTheme.typography.headlineSmall, + color = Color.Black, + ) + Spacer(Modifier.weight(1f)) + IconButton( + modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON), + interactionSource = remember { MutableInteractionSource() }, + onClick = { + println("!!!!!!!! refresh on click main") + viewModel.onIntent(MainIntent.Refresh) + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_refresh), + contentDescription = stringResource(R.string.main_refresh) + ) + } + IconButton( + modifier = Modifier.testTag(TestIds.Main.LOGOUT_BUTTON), + interactionSource = remember { MutableInteractionSource() }, + onClick = { + viewModel.onIntent(MainIntent.Logout) + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_logout), + contentDescription = stringResource(R.string.main_logout) + ) + } + } + LazyColumn { + itemsIndexed(state.books) { index, book -> + Row( + modifier = Modifier + .testTag(TestIds.Main.getIdItemByPosition(index)) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE), + text = book.place, + style = MaterialTheme.typography.bodyMedium, + color = Color.Black, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier.testTag(TestIds.Main.ITEM_DATE), + text = book.date, + style = MaterialTheme.typography.bodySmall, + color = Color.Black, + ) + } + } + } + } + + FloatingActionButton( + modifier = Modifier.testTag(TestIds.Main.ADD_BUTTON).align(Alignment.BottomEnd), + onClick = { + viewModel.onIntent(MainIntent.Add) + } + ) { + Image( + painter = painterResource(R.drawable.ic_add), + contentDescription = stringResource(R.string.book_add) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt new file mode 100644 index 0000000..f7e5494 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt @@ -0,0 +1,20 @@ +package ru.myitschool.work.ui.screen.main + +import kotlinx.collections.immutable.PersistentList + +sealed interface MainState { + data object Loading: MainState + data class Error( + val error: String + ): MainState + data class Data( + val name: String, + val photoUrl: String, + val books: PersistentList + ): MainState { + data class Book( + val date: String, + val place: String, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt new file mode 100644 index 0000000..d04ec0b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt @@ -0,0 +1,95 @@ +package ru.myitschool.work.ui.screen.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.data.repo.BookRepository +import ru.myitschool.work.domain.auth.LogoutUseCase +import ru.myitschool.work.domain.main.GetMainDataUseCase +import ru.myitschool.work.ui.nav.AuthScreenDestination +import ru.myitschool.work.ui.nav.BookScreenDestination +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import kotlin.getValue + +class MainViewModel : ViewModel() { + private val getMainDataUseCase by lazy { + GetMainDataUseCase(BookRepository(AuthRepository)) + } + private val logoutUseCase by lazy { + LogoutUseCase(AuthRepository) + } + private val _uiState = MutableStateFlow(MainState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow + + init { + println("!!!!!!!! refresh init") + refresh() + } + + fun onIntent(intent: MainIntent) { + when (intent) { + is MainIntent.Add -> { + viewModelScope.launch { + _actionFlow.emit(MainAction.Open(BookScreenDestination)) + } + } + is MainIntent.Refresh -> { + refresh() + } + is MainIntent.Logout -> { + viewModelScope.launch { + logoutUseCase.invoke() + _actionFlow.emit(MainAction.Open(AuthScreenDestination, true)) + } + } + } + } + + private fun refresh() { + println("!!!!!!!! refresh") + viewModelScope.launch { + _uiState.update { MainState.Loading } + _uiState.update { + getMainDataUseCase.invoke().fold( + onSuccess = { data -> + MainState.Data( + name = data.name, + photoUrl = data.photoUrl, + books = data.book.map { book -> + MainState.Data.Book( + date = LocalDate + .parse(book.date) + .format( + DateTimeFormatter.ofPattern(DATE_FORMAT) + ), + place = book.place + ) + }.toPersistentList() + ) + }, + onFailure = { error -> + MainState.Error( + error = error.message?.takeIf { it.isNotBlank() } ?: "Unknown error" + ) + } + ) + } + } + } + + private companion object { + const val DATE_FORMAT = "dd.MM.yyyy" + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/theme/Color.kt b/app/src/main/java/ru/myitschool/work/ui/theme/Color.kt new file mode 100644 index 0000000..22226f4 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package ru.myitschool.work.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/theme/Theme.kt b/app/src/main/java/ru/myitschool/work/ui/theme/Theme.kt new file mode 100644 index 0000000..d9cc58f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/theme/Theme.kt @@ -0,0 +1,57 @@ +package ru.myitschool.work.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun WorkTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/theme/Type.kt b/app/src/main/java/ru/myitschool/work/ui/theme/Type.kt new file mode 100644 index 0000000..61b2923 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package ru.myitschool.work.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..9f83b8f --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..075e95d --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..356e998 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_logout.xml b/app/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..c22a96f --- /dev/null +++ b/app/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..86504d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a9273cf --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,15 @@ + + Work + RootActivity + Привет! Введи код для авторизации + Код + Войти + + Обновить + Выйти + Добавить + + Забронировать + Назад + Всё забронировано + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..572c00e --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,7 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +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/.gitignore b/buildSrc/.gitignore new file mode 100644 index 0000000..6fbe8a4 --- /dev/null +++ b/buildSrc/.gitignore @@ -0,0 +1,2 @@ +/.gradle +/build \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..4866817 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + `kotlin-dsl` +} + +repositories { + google() + mavenCentral() +} \ No newline at end of file diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt new file mode 100644 index 0000000..0bd0208 --- /dev/null +++ b/buildSrc/src/main/java/Dependencies.kt @@ -0,0 +1,237 @@ +data class Dependency( + val name: String, + val version: String, +) { + val fullPath get() = "$name:$version" +} + +object Dependencies { + + /** + * Type-safe HTTP client for Android and Java by Square, Inc. + * + * [Documentation](http://square.github.io/retrofit/) + * + * [Github](https://github.com/square/retrofit) + * + * [Apache License 2.0](https://github.com/square/retrofit/blob/master/LICENSE.txt) + * + * [Changelog](https://github.com/square/retrofit/blob/master/CHANGELOG.md) + */ + object Retrofit { + private const val version = "2.9.0" + + val library = Dependency("com.squareup.retrofit2:retrofit", version) + val gsonConverter = Dependency("com.squareup.retrofit2:converter-gson", version) + } + + + /** + * [Documentation](https://developer.android.com/jetpack/androidx) + * + * [Releases](https://developer.android.com/jetpack/androidx/versions). + */ + object AndroidX { + /** + * [androidx.tech](https://androidx.tech/artifacts/core/core-ktx/) + * + * [Changelog](https://developer.android.com/jetpack/androidx/releases/core) + */ + val core = Dependency("androidx.core:core-ktx", "1.17.0") + + /** + * [androidx.tech](https://androidx.tech/artifacts/appcompat/appcompat/) + * + * [Changelog](https://developer.android.com/jetpack/androidx/releases/appcompat) + */ + val appcompat = Dependency("androidx.appcompat:appcompat", "1.6.1") + + /** + * [androidx.tech](https://androidx.tech/artifacts/recyclerview/recyclerview/) + * + * [Changelog](https://developer.android.com/jetpack/androidx/releases/recyclerview) + */ + val recyclerView = Dependency("androidx.recyclerview:recyclerview", "1.3.2") + + /** + * [androidx.tech](https://androidx.tech/artifacts/cardview/cardview/) + * + * [Changelog](https://developer.android.com/jetpack/androidx/releases/cardview) + */ + val cardView = Dependency("androidx.cardview:cardview", "1.0.0") + + /** + * [androidx.tech](https://androidx.tech/artifacts/gridlayout/gridlayout/) + * + * [Changelog](https://developer.android.com/jetpack/androidx/releases/gridlayout) + */ + val gridLayout = Dependency("androidx.gridlayout:gridlayout", "1.0.0") + + /** + * A ConstraintLayout is a ViewGroup which allows you to position and size widgets in a flexible way. + * + * [Documentation](https://developer.android.com/reference/android/support/constraint/ConstraintLayout) + * + * [androidx.tech](https://androidx.tech/artifacts/constraintlayout/constraintlayout/) + * + * [Changelog](https://developer.android.com/jetpack/androidx/releases/constraintlayout) + */ + val constraintLayout = Dependency("androidx.constraintlayout:constraintlayout", "2.1.4") + + /** + * CoordinatorLayout is a super-powered FrameLayout. + * CoordinatorLayout is intended for two primary use cases: + * 1. As a top-level application decor or chrome layout + * 2. As a container for a specific interaction with one or more child views + * + * [Documentation](https://developer.android.com/jetpack/androidx/releases/coordinatorlayout) + * + * [androidx.tech](https://androidx.tech/artifacts/coordinatorlayout/coordinatorlayout/) + * + * [Changelog](https://developer.android.com/jetpack/androidx/releases/coordinatorlayout) + */ + val coordinatorLayout = Dependency("androidx.coordinatorlayout:coordinatorlayout", "1.2.0") + + /** + * The SwipeRefreshLayout should be used whenever the user + * can refresh the contents of a view via a vertical swipe gesture. + * + * [Documentation](https://developer.android.com/jetpack/androidx/releases/swiperefreshlayout) + * + * [Changelog](https://developer.android.com/jetpack/androidx/releases/swiperefreshlayout) + */ + val swipeRefreshLayout = Dependency("androidx.swiperefreshlayout:swiperefreshlayout", "1.1.0") + + /** + * [Changelog](https://developer.android.com/jetpack/androidx/releases/test/) + */ + object Testing { + + /** + * [androidx.tech](https://androidx.tech/artifacts/test/core/) + * + * [Documentation](https://developer.android.com/training/testing) + */ + val core = Dependency("androidx.test:core", "1.7.0") + + /** + * [androidx.tech](https://androidx.tech/artifacts/test/core/) + * + * [Documentation](https://developer.android.com/training/testing) + */ + val junit = Dependency("androidx.test.ext:junit-ktx", "1.3.0") + + /** + * [androidx.tech](https://androidx.tech/artifacts/test.espresso/espresso-core/) + * + * [Documentation](https://developer.android.com/training/testing/espresso) + */ + object Espresso { + private const val version = "3.7.0" + val core = Dependency("androidx.test.espresso:espresso-core", version) + val intents = Dependency("androidx.test.espresso:espresso-intents", version) + val contrib = Dependency("androidx.test.espresso:espresso-contrib", version) + } + + /** + * [androidx.tech](https://androidx.tech/artifacts/test/runner/) + * + * [Documentation](https://developer.android.com/training/testing/junit-runner) + */ + val runner = Dependency("androidx.test:runner", "1.7.0") + + /** + * [androidx.tech](https://androidx.tech/artifacts/test/rules/) + * + * [Documentation](https://developer.android.com/training/testing/junit-rules) + */ + val rules = Dependency("androidx.test:rules", "1.7.0") + + /** + * [androidx.tech](https://androidx.tech/artifacts/test/rules/) + * + * [Documentation](https://developer.android.com/training/testing/junit-rules) + */ + val compose = Dependency("androidx.compose.ui:ui-test-junit4", "1.9.3") + + /** + * [androidx.tech](https://androidx.tech/artifacts/test/orchestrator/) + * + * [Documentation](https://developer.android.com/training/testing/junit-runner#using-android-test-orchestrator) + */ + val orchestrator = Dependency("androidx.test:orchestrator", "1.4.2") + + /** + * [androidx.tech](https://androidx.tech/artifacts/test.uiautomator/uiautomator/) + * + * [Documentation](https://developer.android.com/training/testing/ui-automator) + */ + val uiAutomator = Dependency("androidx.test.uiautomator:uiautomator", "2.2.0") + } + + /** + * [Documentation](https://material.io/develop/android/) + * + * [Github](https://github.com/material-components/material-components-android) + * + * [Changelog](https://github.com/material-components/material-components-android/releases) + */ + val materialDesign = Dependency("com.google.android.material:material", "1.13.0") + + /** + * [androidx.tech](https://androidx.tech/artifacts/lifecycle/lifecycle-viewmodel/) + */ + object Lifecycle { + private const val version = "2.9.4" + + val viewModel = Dependency("androidx.lifecycle:lifecycle-viewmodel-ktx", version) + val common = Dependency("androidx.lifecycle:lifecycle-common", version) + } + } + + /** + * JUnit is a simple framework to write repeatable tests. + * + * [Documentation](https://junit.org/junit4/) + * + * [Github](https://github.com/junit-team/junit4) + * + * [Eclipse Public License 1.0](https://github.com/junit-team/junit4/blob/master/LICENSE-junit.txt) + * + * [Changelog](https://github.com/junit-team/junit4/wiki) + */ + val junit = Dependency("junit:junit", "6.00") + + /** + * Truth makes your test assertions and failure messages more readable. + * Similar to AssertJ, it natively supports many JDK and Guava types, + * and it is extensible to others. + * + * [Documentation](https://truth.dev/) + * + * [Github](https://github.com/google/truth) + * + * [Apache License 2.0](https://github.com/google/truth/blob/master/LICENSE) + * + * [Changelog](https://github.com/google/truth/releases) + */ + val truth = Dependency("com.google.truth:truth", "1.4.5") + + /** + * Kaspresso is a framework for Android UI testing. Based on Espresso and UI Automator. + * + * [Documentation](https://kasperskylab.github.io/Kaspresso/) + * + * [Github](https://github.com/KasperskyLab/Kaspresso) + * + * [Apache License 2.0](https://github.com/KasperskyLab/Kaspresso/blob/master/LICENSE.txt) + * + * [Changelog](https://github.com/KasperskyLab/Kaspresso/releases) + */ + object Kaspresso { + private const val version = "1.6.0" + val core = Dependency("com.kaspersky.android-components:kaspresso", version) + val composeSupport = Dependency("com.kaspersky.android-components:kaspresso-compose-support", version) + } +} + diff --git a/buildSrc/src/main/java/DependencyHandlerExtensions.kt b/buildSrc/src/main/java/DependencyHandlerExtensions.kt new file mode 100644 index 0000000..da2bd37 --- /dev/null +++ b/buildSrc/src/main/java/DependencyHandlerExtensions.kt @@ -0,0 +1,63 @@ +import org.gradle.api.artifacts.dsl.DependencyHandler + +fun DependencyHandler.implementation(dependency: Dependency) { + add(Type.IMPLEMENTATION, dependency.fullPath) +} + +fun DependencyHandler.implementation(dependency: Any) { + add(Type.IMPLEMENTATION, dependency) +} + +fun DependencyHandler.testImplementation(dependency: Dependency) { + add(Type.TEST_IMPLEMENTATION, dependency.fullPath) +} + +fun DependencyHandler.androidTestImplementation(dependency: Dependency) { + add(Type.ANDROID_TEST_IMPLEMENTATION, dependency.fullPath) +} + +fun DependencyHandler.androidTestImplementation(dependency: Any) { + add(Type.ANDROID_TEST_IMPLEMENTATION, dependency) +} + +fun DependencyHandler.api(dependency: Dependency) { + add(Type.API, dependency.fullPath) +} + +fun DependencyHandler.kapt(dependency: Dependency) { + add(Type.KAPT, dependency.fullPath) +} + +fun DependencyHandler.ksp(dependency: Dependency) { + add(Type.KSP, dependency.fullPath) +} + +fun DependencyHandler.defaultLibrary() { + api(Dependencies.AndroidX.core) + api(Dependencies.AndroidX.appcompat) + api(Dependencies.AndroidX.materialDesign) +} + +fun DependencyHandler.defaultComposeLibrary() { + defaultLibrary() + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1") + implementation("androidx.activity:activity-compose:1.8.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.5") + + val composeBom = platform("androidx.compose:compose-bom:2025.10.00") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.ui:ui") +} + +private object Type { + const val IMPLEMENTATION = "implementation" + const val TEST_IMPLEMENTATION = "testImplementation" + const val ANDROID_TEST_IMPLEMENTATION = "androidTestImplementation" + const val API = "api" + const val KAPT = "kapt" + const val KSP = "ksp" +} diff --git a/buildSrc/src/main/java/Plugin.kt b/buildSrc/src/main/java/Plugin.kt new file mode 100644 index 0000000..0f5552c --- /dev/null +++ b/buildSrc/src/main/java/Plugin.kt @@ -0,0 +1,68 @@ +import org.gradle.kotlin.dsl.kotlin +import org.gradle.plugin.use.PluginDependenciesSpec +import org.gradle.plugin.use.PluginDependencySpec + +val PluginDependenciesSpec.androidApplication: PluginDependencySpec + get() = id(Plugin.Id.Android.application) +val PluginDependenciesSpec.androidLibrary: PluginDependencySpec + get() = id(Plugin.Id.Android.library) +val PluginDependenciesSpec.kotlinJvm: PluginDependencySpec + get() = id(Plugin.Id.Kotlin.jvm) +val PluginDependenciesSpec.composeCompiler: PluginDependencySpec + get() = id(Plugin.Id.Android.compose) +val PluginDependenciesSpec.kotlinAndroid: PluginDependencySpec + get() = id(Plugin.Id.Kotlin.android) +val PluginDependenciesSpec.kotlinParcelize: PluginDependencySpec + get() = id(Plugin.Id.Kotlin.parcelize) +val PluginDependenciesSpec.kotlinAnnotationProcessor: PluginDependencySpec + get() = id(Plugin.Id.Kotlin.annotationProcessor) +val PluginDependenciesSpec.kotlinSerialization: PluginDependencySpec + get() = kotlin(Plugin.Id.Kotlin.serialization) + +object Plugin { + object Id { + object Android { + /** + * [Documentation](https://google.github.io/android-gradle-dsl/current/) + * [Changelog](https://developer.android.com/studio/releases/gradle-plugin) + */ + const val application = "com.android.application" + /** + * [Documentation](https://google.github.io/android-gradle-dsl/current/) + * [Changelog](https://developer.android.com/studio/releases/gradle-plugin) + */ + const val library = "com.android.library" + /** + * [Documentation](https://google.github.io/android-gradle-dsl/current/) + * [Changelog](https://developer.android.com/studio/releases/gradle-plugin) + */ + const val compose = "org.jetbrains.kotlin.plugin.compose" + } + + object Kotlin { + /** + * Plugin published in https://plugins.gradle.org/ + */ + const val jvm = "org.jetbrains.kotlin.jvm" + /** + * Plugin published in https://plugins.gradle.org/ + */ + const val android = "org.jetbrains.kotlin.android" + + /** + * Plugin published in https://plugins.gradle.org/ + */ + const val parcelize = "kotlin-parcelize" + + /** + * Plugin published in https://plugins.gradle.org/ + */ + const val annotationProcessor = "kapt" + + /** + * Plugin published in https://plugins.gradle.org/ + */ + const val serialization = "plugin.serialization" + } + } +} diff --git a/buildSrc/src/main/java/Version.kt b/buildSrc/src/main/java/Version.kt new file mode 100644 index 0000000..62f38bf --- /dev/null +++ b/buildSrc/src/main/java/Version.kt @@ -0,0 +1,42 @@ +import org.gradle.api.JavaVersion + +object Version { + + /** + * Gradle is an open-source build automation tool focused on flexibility and performance. + * + * [Documentation](https://docs.gradle.org/current/userguide/userguide.html) + * + * [Github](https://github.com/gradle/gradle) + * + * [Apache 2.0 License](https://github.com/gradle/gradle/blob/master/LICENSE) + * + * [Changelog](https://gradle.org/releases/) + */ + const val agp = "8.13.0" + + object Kotlin { + + /** + * [Documentation](https://kotlinlang.org/) + * + * [Source Code](https://github.com/JetBrains/kotlin/) + * + * [Apache 2.0 License](https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt) + * + * [Changelog](https://kotlinlang.org/releases.html) + */ + const val language = "2.2.20" + + val javaSource = JavaVersion.VERSION_21 + const val jvmTarget = "21" + } + + object Android { + object Sdk { + const val min = 26 + const val compile = 36 + const val target = 36 + } + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3e927b1 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/README.md b/gradle/README.md new file mode 100644 index 0000000..de403e9 --- /dev/null +++ b/gradle/README.md @@ -0,0 +1,11 @@ +# Файл для управления дистрибутивом Gradle + +Данный репозиторий необходим для поддержки актуальности дистрибутива Gradle в проектах. Данный проект необходимо подключать подмодулём и использовать во всех проектах. + +## Как обвновлять дистрибутив + +Перед обновлением необходимо удостоверится, что версия самого Gradle установлена не ниже, чем в дистрибутиве. Сделать это можно [здесь](https://sicampus.ru/gitea/core/dependecies/src/branch/main/src/main/java/Version.kt#L16). + +Процесс обновления выглядит следуюющим образом: +1. В начале обновляем саму версию Gradle ([в этом репозитории](https://sicampus.ru/gitea/core/dependecies)). +2. Обновлем ссылку на дестрибутив и проверяем совместимость. \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..84d4fca --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu Jan 04 22:32:26 NOVT 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..07b551c --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Work" +include(":app") +include(":testLib")