commit 94b6a6758b0086581fa2b334fd769a1c3cc77976 Author: csasq Date: Thu Sep 11 14:30:51 2025 +0300 Проект пересобран без модуля desktopMain diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d9c0e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +*.iml +.kotlin +.gradle +**/build/ +xcuserdata +!src/**/build/ +local.properties +.idea +.DS_Store +captures +.externalNativeBuild +.cxx +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +**/xcshareddata/WorkspaceSettings.xcsettings diff --git a/README.md b/README.md new file mode 100644 index 0000000..472c3fc --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Помощник пчеловода + +![Static Badge](https://img.shields.io/badge/IntelliJ%20IDEA-2025.2-blue) +![Static Badge](https://img.shields.io/badge/JDK-24.0.2-blue) +![Static Badge](https://img.shields.io/badge/Gradle-9.0.0-blue) +![Static Badge](https://img.shields.io/badge/Compose%20Material-3%20Expressive-blue) + +## Конфигурация + +Путь до файла: `/composeApp/src/commonMain/kotlin/ru/cit71/bee_frontend/core/Config.kt` + +```kotlin +data object Config { + const val DEBUG = false + private const val TELEGRAM_INIT_DATA = "YOUR-TELEGRAM-INIT-DATA" +} +``` + +## Запуск и компиляция + +| Задача | Команда | +|--------------|-------------------------------------------| +| desktop Run | `:composeApp:run` | +| wasmJs Run | `:composeApp:wasmJsBrowserDevelopmentRun` | +| wasmJs Build | `:composeApp:wasmJsBrowserDistribution` | diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..7d61caf --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + // this is necessary to avoid the plugins to be loaded multiple times + // in each subproject's classloader + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinMultiplatform) apply false +} \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts new file mode 100644 index 0000000..2924d56 --- /dev/null +++ b/composeApp/build.gradle.kts @@ -0,0 +1,54 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig + +plugins { + // Initial + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + + // Custom + kotlin("plugin.serialization") version "2.2.10" +} + +kotlin { + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + binaries.executable() + } + + sourceSets { + commonMain.dependencies { + // Initial + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(libs.androidx.lifecycle.viewmodelCompose) + implementation(libs.androidx.lifecycle.runtimeCompose) + + // Custom + //// Ktor + implementation(libs.ktor.clientCore) + implementation(libs.ktor.clientContentNegotiation) + implementation(libs.ktor.clientLogging) + implementation(libs.ktor.serializationKotlinxJson) + + //// Coil + implementation(libs.coil.compose) + implementation(libs.coil.networkKtor) + + //// Navigation + implementation(libs.navigation.compose) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + } +} + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/baseline_check_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_check_24.xml new file mode 100644 index 0000000..72d3aab --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_check_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/baseline_close_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_close_24.xml new file mode 100644 index 0000000..34dd837 --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_close_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/baseline_content_paste_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_content_paste_24.xml new file mode 100644 index 0000000..2f6f3a0 --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_content_paste_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/baseline_dehaze_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_dehaze_24.xml new file mode 100644 index 0000000..db840e2 --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_dehaze_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/baseline_info_outline_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_info_outline_24.xml new file mode 100644 index 0000000..4e64bbc --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_info_outline_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/baseline_manage_accounts_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_manage_accounts_24.xml new file mode 100644 index 0000000..480c50b --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_manage_accounts_24.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/baseline_notifications_active_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_notifications_active_24.xml new file mode 100644 index 0000000..9890419 --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_notifications_active_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/baseline_person_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_person_24.xml new file mode 100644 index 0000000..72e0f1a --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_person_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/baseline_person_add_alt_1_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_person_add_alt_1_24.xml new file mode 100644 index 0000000..8caea13 --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_person_add_alt_1_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/baseline_public_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_public_24.xml new file mode 100644 index 0000000..8f980a5 --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/baseline_public_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/outline_add_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/outline_add_24.xml new file mode 100644 index 0000000..ba28c0f --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/outline_add_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/outline_download_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/outline_download_24.xml new file mode 100644 index 0000000..3750d7f --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/outline_download_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/round_arrow_right_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/round_arrow_right_24.xml new file mode 100644 index 0000000..7567197 --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/round_arrow_right_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/round_check_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/round_check_24.xml new file mode 100644 index 0000000..989f628 --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/round_check_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/round_close_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/round_close_24.xml new file mode 100644 index 0000000..c0285c8 --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/round_close_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/round_content_copy_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/round_content_copy_24.xml new file mode 100644 index 0000000..ccb42d1 --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/round_content_copy_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/round_manage_accounts_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/round_manage_accounts_24.xml new file mode 100644 index 0000000..c2bf476 --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/round_manage_accounts_24.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/round_settings_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/round_settings_24.xml new file mode 100644 index 0000000..dae7fed --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/round_settings_24.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/composeResources/drawable/rounded_add_a_photo_24.xml b/composeApp/src/wasmJsMain/composeResources/drawable/rounded_add_a_photo_24.xml new file mode 100644 index 0000000..b69d3e5 --- /dev/null +++ b/composeApp/src/wasmJsMain/composeResources/drawable/rounded_add_a_photo_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/App.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/App.kt new file mode 100644 index 0000000..cfcad74 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/App.kt @@ -0,0 +1,330 @@ +package ru.cit71.pchelovod71 + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import org.jetbrains.compose.ui.tooling.preview.Preview +import frontend.composeapp.generated.resources.Res +import frontend.composeapp.generated.resources.baseline_content_paste_24 +import frontend.composeapp.generated.resources.baseline_person_24 +import frontend.composeapp.generated.resources.baseline_person_add_alt_1_24 +import frontend.composeapp.generated.resources.round_settings_24 +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +import ru.cit71.pchelovod71.core.Api +import ru.cit71.pchelovod71.core.Models +import ru.cit71.pchelovod71.core.Platform +import ru.cit71.pchelovod71.ui.components.MainScaffold +import ru.cit71.pchelovod71.ui.screens.RegistrationRequestsScreen +import ru.cit71.pchelovod71.ui.screens.RegistrationUserScreen +import ru.cit71.pchelovod71.ui.screens.RejectRegistrationUserScreen +import ru.cit71.pchelovod71.ui.screens.SettingsScreen +import ru.cit71.pchelovod71.ui.screens.UserRequestsScreen +import ru.cit71.pchelovod71.ui.screens.UsersScreen +import ru.cit71.pchelovod71.ui.screens.WaitRegistrationUserScreen + +interface Screen { + val route: String + val title: String + val icon: DrawableResource +} + +enum class AdminScreen( + override val route: String, + override val title: String, + override val icon: DrawableResource, +) : Screen { + RegistrationRequests( + route = "registration-requests", + title = "Запросы на регистрацию", + icon = Res.drawable.baseline_person_add_alt_1_24, + ), + Users( + route = "users", + title = "Пользователи", + icon = Res.drawable.baseline_person_24, + ), + UserRequests( + route = "user-requests", + title = "Обращения пользователей", + icon = Res.drawable.baseline_content_paste_24, + ), +} + +enum class UserScreen( + override val route: String, + override val title: String, + override val icon: DrawableResource, +) : Screen { + MyRequests( + route = "my-requests", + title = "Мои обращения", + icon = Res.drawable.baseline_content_paste_24, + ), + Settings( + route = "settings", + title = "Настройки", + icon = Res.drawable.round_settings_24, + ), +} + +@Composable +fun AdminApp( + paddingValues: PaddingValues, + userDomain: Models.User.Domain, +) { + val navHostController = rememberNavController() + + MainScaffold( + bottomBar = { + NavigationBar { + val navBackStackEntry = navHostController.currentBackStackEntryAsState() + + AdminScreen.entries.forEach { screen -> + NavigationBarItem( + icon = { + Icon( + painter = painterResource(screen.icon), + contentDescription = null + ) + }, + label = { + Text( + text = screen.title, + textAlign = TextAlign.Center + ) + }, + selected = navBackStackEntry.value?.destination?.hierarchy?.any { + it.route == screen.route + } == true, + onClick = { + navHostController.navigate(screen.route) + }, + ) + } + } + }, + modifier = Modifier + .padding(paddingValues), + ) { mainScaffoldParams -> + NavHost( + navController = navHostController, + startDestination = AdminScreen.UserRequests.route, + ) { + composable(AdminScreen.RegistrationRequests.route) { + RegistrationRequestsScreen( + mainScaffoldParams = mainScaffoldParams, + ) + } + + composable(AdminScreen.Users.route) { + UsersScreen( + mainScaffoldParams = mainScaffoldParams, + ) + } + + composable(AdminScreen.UserRequests.route) { + UserRequestsScreen( + mainScaffoldParams = mainScaffoldParams, + userDomain = userDomain, + ) + } + } + } +} + +@Composable +fun UserApp( + paddingValues: PaddingValues, + userDomain: Models.User.Domain, +) { + val navHostController = rememberNavController() + + MainScaffold( + bottomBar = { + NavigationBar { + val navBackStackEntry = navHostController.currentBackStackEntryAsState() + + UserScreen.entries.forEach { screen -> + NavigationBarItem( + icon = { + Icon( + painter = painterResource(screen.icon), + contentDescription = null + ) + }, + label = { + Text( + text = screen.title, + textAlign = TextAlign.Center + ) + }, + selected = navBackStackEntry.value?.destination?.hierarchy?.any { + it.route == screen.route + } == true, + onClick = { + navHostController.navigate(screen.route) + }, + ) + } + } + }, + modifier = Modifier + .padding(paddingValues), + ) { mainScaffoldParams -> + NavHost( + navController = navHostController, + startDestination = UserScreen.MyRequests.route, + ) { + composable(UserScreen.MyRequests.route) { + UserRequestsScreen( + mainScaffoldParams = mainScaffoldParams, + userDomain = userDomain, + ) + } + + composable(UserScreen.Settings.route) { + SettingsScreen( + mainScaffoldParams = mainScaffoldParams, + ) + } + } + } +} + +@Composable +@Preview +fun App() { + var userDomain by remember { mutableStateOf(null) } + + MaterialTheme( + colorScheme = darkColorScheme(), + ) { + MainScaffold { mainScaffoldParams -> + suspend fun reloadUserDomain() { + Api.Users.auth( + authParams = Models.AuthParams.DTO.Send( + initData = Api.telegramInitData, + ), + onSuccess = { user -> + userDomain = user.toDomain() + }, + onError = {}, + ) + } + + LaunchedEffect(Unit) { + mainScaffoldParams.withLoadingIndicator { + Api.telegramInitData = Platform.getTelegramInitData() + reloadUserDomain() + } + } + + when (userDomain) { + null -> { + var telegramId by remember { mutableStateOf(null) } + + suspend fun reloadTelegramId() { + Api.Users.getTelegramId( + authParams = Models.AuthParams.DTO.Send( + initData = Platform.getTelegramInitData(), + ), + onSuccess = { + telegramId = it.telegramId + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось авторизоваться с помощью учетной записи Telegram" + ) + }, + ) + } + + LaunchedEffect(Unit) { + mainScaffoldParams.withLoadingIndicator { + when (val bindUuid = Platform.getBindUuid()) { + null -> { + reloadTelegramId() + } + else -> { + Api.Users.bind( + uuid = bindUuid, + onSuccess = { + reloadUserDomain() + mainScaffoldParams.showSuccessSnackbar( + "Учетная запись Telegram успешно привязана" + ) + }, + onError = { + reloadTelegramId() + mainScaffoldParams.showErrorSnackbar( + "Не удалось привязать учетную запись Telegram" + ) + }, + ) + } + } + } + } + + telegramId?.let { + RegistrationUserScreen( + mainScaffoldParams = mainScaffoldParams, + telegramId = it, + reloadUserDomain = ::reloadUserDomain, + ) + } + } + else -> { + userDomain?.let { userDomain -> + when (userDomain.deleted) { + true -> { + RejectRegistrationUserScreen( + mainScaffoldParams = mainScaffoldParams, + ) + } + false -> { + when (userDomain.accepted) { + true -> { + when (userDomain.role) { + Models.Role.Admin -> { + AdminApp( + paddingValues = mainScaffoldParams.paddingValues, + userDomain = userDomain, + ) + } + Models.Role.User -> { + UserApp( + paddingValues = mainScaffoldParams.paddingValues, + userDomain = userDomain, + ) + } + } + } + false -> { + WaitRegistrationUserScreen( + mainScaffoldParams = mainScaffoldParams, + ) + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/core/Api.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/core/Api.kt new file mode 100644 index 0000000..bdf8882 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/core/Api.kt @@ -0,0 +1,401 @@ +package ru.cit71.pchelovod71.core + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.DefaultRequest +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.delete +import io.ktor.client.request.forms.MultiPartFormDataContent +import io.ktor.client.request.forms.formData +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.clone +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import ru.cit71.pchelovod71.core.Platform.base64Encode + +object Api { + lateinit var telegramInitData: String + + private val httpClient = HttpClient { + install(ContentNegotiation) { + json() + } + + install(DefaultRequest) { + header("Authorization", "Bearer ${base64Encode(telegramInitData)}") + } + + if (Config.DEBUG) + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + println(message) + } + } + level = LogLevel.ALL + } + } + + private val apiUrlBuilder = Config.apiUrlBuilder + + object Users { + suspend fun getTelegramId( + authParams: Models.AuthParams.DTO.Send, + onSuccess: suspend (Models.AuthParams.DTO.Get) -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += listOf("auth", "telegram") + }.build() + val response = httpClient.post(url) { + contentType(ContentType.Application.Json) + setBody(authParams) + } + when (response.status) { + HttpStatusCode.OK -> { + onSuccess(response.body()) + } + HttpStatusCode.Unauthorized -> { + onError() + } + } + } + + suspend fun auth( + authParams: Models.AuthParams.DTO.Send, + onSuccess: suspend (Models.User.DTO.Get) -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += listOf("auth", "user") + }.build() + val response = httpClient.post(url) { + contentType(ContentType.Application.Json) + setBody(authParams) + } + when (response.status) { + HttpStatusCode.OK -> { + onSuccess(response.body()) + } + HttpStatusCode.Unauthorized -> { + onError() + } + } + } + + suspend fun get( + verified: Boolean? = null, + deleted: Boolean? = null, + onSuccess: suspend (List) -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += listOf("users", "") + verified?.let { + parameters.append("verified", "$it") + } + deleted?.let { + parameters.append("deleted_status", "$it") + } + }.build() + val response = httpClient.get(url) + when (response.status) { + HttpStatusCode.OK -> { + onSuccess(response.body()) + } + else -> { + onError() + } + } + } + + suspend fun create( + user: Models.User.DTO.Create, + onSuccess: suspend (Models.User.DTO.Get) -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += listOf("users", "") + }.build() + val response = httpClient.post(url) { + contentType(ContentType.Application.Json) + setBody(user) + } + when (response.status) { + HttpStatusCode.OK -> { + onSuccess(response.body()) + } + else -> { + onError() + } + } + } + + suspend fun update( + user: Models.User.DTO.Update, + onSuccess: suspend () -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += listOf("users", "${user.id}") + }.build() + val response = httpClient.patch(url) { + contentType(ContentType.Application.Json) + setBody(user) + } + when (response.status) { + HttpStatusCode.OK -> { + onSuccess() + } + else -> { + onError() + } + } + } + + suspend fun bind( + uuid: String, + onSuccess: suspend () -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += listOf("users", "binding", "") + parameters["user_uuid"] = uuid + }.build() + val response = httpClient.patch(url) + when (response.status) { + HttpStatusCode.OK -> { + onSuccess() + } + else -> { + onError() + } + } + } + + suspend fun subscribe( + id: Int, + user: Models.User.DTO.Subscribe, + onSuccess: suspend () -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += listOf("users", "$id") + }.build() + val response = httpClient.patch(url) { + contentType(ContentType.Application.Json) + setBody(user) + } + when (response.status) { + HttpStatusCode.OK -> { + onSuccess() + } + else -> { + onError() + } + } + } + + suspend fun accept( + id: Int, + user: Models.User.DTO.Accept, + onSuccess: suspend () -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += listOf("users", "$id") + }.build() + val response = httpClient.patch(url) { + contentType(ContentType.Application.Json) + setBody(user) + } + when (response.status) { + HttpStatusCode.OK -> { + onSuccess() + } + else -> { + onError() + } + } + } + + suspend fun reject( + id: Int, + user: Models.User.DTO.Reject, + onSuccess: suspend () -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += listOf("users", "delete", "$id") + }.build() + val response = httpClient.delete(url) { + contentType(ContentType.Application.Json) + setBody(user) + } + when (response.status) { + HttpStatusCode.OK -> { + onSuccess() + } + else -> { + onError() + } + } + } + + suspend fun delete( + id: Int, + onSuccess: suspend () -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += listOf("users", "delete", "$id") + }.build() + val response = httpClient.delete(url) + when (response.status) { + HttpStatusCode.OK -> { + onSuccess() + } + else -> { + onError() + } + } + } + } + + object UserRequests { + suspend fun get( + userId: Int? = null, + status: Models.RequestStatus, + onSuccess: suspend (List) -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += listOf("requests") + userId?.let { + parameters.append("user_id", "$it") + } + parameters.append("status", status.value) + }.build() + val response = httpClient.get(url) + when (response.status) { + HttpStatusCode.OK -> { + onSuccess(response.body()) + } + else -> { + onError() + } + } + } + + suspend fun create( + userRequest: Models.UserRequest.DTO.Create, + onSuccess: suspend () -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += "requests" + }.build() + val response = httpClient.post(url) { + contentType(ContentType.Application.Json) + setBody(userRequest) + } + when (response.status) { + HttpStatusCode.OK -> { + onSuccess() + } + else -> { + onError() + } + } + } + + suspend fun accept( + id: Int, + request: Models.UserRequest.DTO.Accept, + onSuccess: suspend () -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += listOf("requests", "$id") + }.build() + val response = httpClient.patch(url) { + contentType(ContentType.Application.Json) + setBody(request) + } + when (response.status) { + HttpStatusCode.OK -> { + onSuccess() + } + else -> { + onError() + } + } + } + + suspend fun uploadImage( + type: String, + data: ByteArray, + onSuccess: suspend (Models.Image.DTO.Get) -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += listOf("requests", "upload_photo") + }.build() + val response = httpClient.post(url) { + setBody( + MultiPartFormDataContent( + formData { + append( + key = "file", + value = data, + headers = Headers.build { + append(HttpHeaders.ContentType, type) + append(HttpHeaders.ContentDisposition, "form-data; name=\"file\"; filename=\"image\"") + }, + ) + } + ) + ) + } + when (response.status) { + HttpStatusCode.OK -> { + onSuccess(response.body()) + } + else -> { + onError() + } + } + } + } + + object Municipalities { + suspend fun get( + onSuccess: suspend (List) -> Unit, + onError: suspend () -> Unit, + ) { + val url = apiUrlBuilder.clone().apply { + pathSegments += listOf("districts", "") + }.build() + val response = httpClient.get(url) + when (response.status) { + HttpStatusCode.OK -> { + onSuccess(response.body()) + } + else -> { + onError() + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/core/Config.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/core/Config.kt new file mode 100644 index 0000000..3bb144f --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/core/Config.kt @@ -0,0 +1,38 @@ +package ru.cit71.pchelovod71.core + +import io.ktor.http.URLBuilder +import io.ktor.http.URLProtocol + +data object Config { + const val DEBUG = false + private const val TELEGRAM_INIT_DATA = "YOUR-TELEGRAM-INIT-DATA" + + val botUrlBuilder = URLBuilder( + protocol = URLProtocol.HTTPS, + host = "t.me", + pathSegments = listOf("pchelovod71_bot"), + ) + + val apiUrlBuilder = when (DEBUG) { + true -> { + URLBuilder( + protocol = URLProtocol.HTTP, + host = "localhost", + port = 5500, + pathSegments = listOf("api"), + ) + } + false -> { + URLBuilder( + protocol = URLProtocol.HTTPS, + host = "pchelovod71.tularegion.ru", + pathSegments = listOf("api"), + ) + } + } + + val telegramInitData = when (DEBUG) { + true -> TELEGRAM_INIT_DATA + else -> null + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/core/Models.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/core/Models.kt new file mode 100644 index 0000000..9f720a3 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/core/Models.kt @@ -0,0 +1,358 @@ +package ru.cit71.pchelovod71.core + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +object Models { + enum class Module { + DesktopMain, + WasmJsMain, + } + + enum class Role { + Admin, + User, + } + + object AuthParams { + object DTO { + @Serializable + data class Get( + @SerialName("telegram_id") val telegramId: String, + ) + + @Serializable + data class Send( + @SerialName("init_data") val initData: String, + ) + } + } + + object User { + data class Domain( + val id: Int = 0, + val role: Role = Role.User, + val fullName: String = "", + val organization: String = "", + val phoneNumber: String = "", + val telegramId: String? = null, + val municipality: Municipality.Domain, + val accepted: Boolean = false, + val subscribed: Boolean = false, + val deleted: Boolean = false, + val deleteReason: String? = null, + val uuid: String = "", + ) { + fun toCreateDTO(): DTO.Create { + return DTO.Create( + fullName = fullName, + organization = organization, + phoneNumber = phoneNumber, + telegramId = telegramId, + municipality = municipality.toGetDTO(), + accepted = accepted, + ) + } + + fun toUpdateDTO(): DTO.Update { + return DTO.Update( + id = id, + fullName = fullName, + organization = organization, + phoneNumber = phoneNumber, + telegramId = telegramId, + municipality = municipality.toGetDTO(), + accepted = accepted, + subscribed = subscribed, + ) + } + + fun toAcceptDTO(): DTO.Accept { + return DTO.Accept( + fullName = fullName, + organization = organization, + phoneNumber = phoneNumber, + telegramId = telegramId, + municipality = municipality.toGetDTO(), + accepted = true, + ) + } + + fun toSubscribeDTO(): DTO.Subscribe { + return DTO.Subscribe( + subscribed = subscribed, + ) + } + + fun toRejectDTO(): DTO.Reject { + return DTO.Reject( + deleteReason = deleteReason!!, + ) + } + } + + data object DTO { + @Serializable + data class Get( + @SerialName("id") val id: Int, + @SerialName("role_id") val roleId: Int = 2, // TODO: вынужденный костыль, т. к. при создании пользователя в returning возвращается модель отличная от стандартной модели User.DTO.Get + @SerialName("fio") val fullName: String, + @SerialName("organization") val organization: String, + @SerialName("phone_number") val phoneNumber: String, + @SerialName("telegram_id") val telegramId: String?, + @SerialName("district") val municipality: Municipality.DTO.Get, + @SerialName("verified") val accepted: Boolean, + @SerialName("processing_alert_status") val subscribed: Boolean, + @SerialName("deleted_status") val deleted: Boolean, + @SerialName("deleted_comment") val deleteReason: String?, + @SerialName("user_uuid") val uuid: String, + ) { + fun toDomain(): Domain { + return Domain( + id = id, + role = when (roleId) { + 1 -> Role.Admin + else -> Role.User + }, + fullName = fullName, + organization = organization, + phoneNumber = phoneNumber, + telegramId = telegramId, + municipality = municipality.toDomain(), + accepted = accepted, + subscribed = subscribed, + deleted = deleted, + deleteReason = deleteReason, + uuid = uuid, + ) + } + } + + @Serializable + data class Create( + @SerialName("fio") val fullName: String, + @SerialName("organization") val organization: String, + @SerialName("phone_number") val phoneNumber: String, + @SerialName("telegram_id") val telegramId: String?, + @SerialName("district") val municipality: Municipality.DTO.Get, + @SerialName("verified") val accepted: Boolean = false, // FIXME: зачем принимает verified? Любой идиот может отправить на API null значение и фронт упадет, т. к. полагается на то, что это не nullable значение (пост. Иваницкий Г.О., отв. Бондарев А.И.) + ) + + @Serializable + data class Update( + @SerialName("id") val id: Int, + @SerialName("fio") val fullName: String, + @SerialName("organization") val organization: String, + @SerialName("phone_number") val phoneNumber: String, + @SerialName("telegram_id") val telegramId: String?, + @SerialName("district") val municipality: Municipality.DTO.Get, + @SerialName("verified") val accepted: Boolean, + @SerialName("processing_alert_status") val subscribed: Boolean, + ) + + @Serializable + data class Accept( + @SerialName("fio") val fullName: String, + @SerialName("organization") val organization: String, + @SerialName("phone_number") val phoneNumber: String, + @SerialName("telegram_id") val telegramId: String?, + @SerialName("district") val municipality: Municipality.DTO.Get, + @SerialName("verified") val accepted: Boolean, + ) + + @Serializable + data class Subscribe( + @SerialName("processing_alert_status") val subscribed: Boolean, + ) + + @Serializable + data class Reject( + @SerialName("deleted_comment") val deleteReason: String, + ) + } + } + + enum class RequestStatus( + val value: String, + ) { + New("new"), + Accepted("accept"), + Rejected("reject"), + } + + object RequestType { + data class Domain( + val id: Int, + val name: String, + ) { + override fun toString(): String { + return name + } + } + + object DTO { + @Serializable + data class Get( + @SerialName("id") val id: Int, + @SerialName("request_type_name") val name: String, + ) { + fun toDomain(): Domain { + return Domain( + id = id, + name = name, + ) + } + } + } + } + + object UserRequest { + data class Domain( + val id: Int = 0, + val user: User.Domain, + val type: RequestType.Domain = RequestType.Domain( + id = 1, + name = "Фиксация гибели пчел", + ), + val address: String = "", + val text: String = "", + val imageList: List = emptyList(), + val adminComment: String? = null, + val accepted: Boolean? = null, + val rejectComment: String? = null, + ) { + fun toCreateDTO(): DTO.Create { + return DTO.Create( + userId = user.id, + typeId = type.id, + address = address, + text = text, + imageFileIdList = imageList.map { it.id } + ) + } + + fun toAcceptDTO(): DTO.Accept { + return DTO.Accept( + adminComment = adminComment, + accepted = accepted!!, + rejectComment = rejectComment, + ) + } + } + + data object DTO { + @Serializable + data class Get( + @SerialName("id") val id: Int, + @SerialName("user") val user: User.DTO.Get, + @SerialName("request_type") val type: RequestType.DTO.Get, + @SerialName("address") val address: String, + @SerialName("request_description") val text: String, + @SerialName("upload_file") val imageList: List = emptyList(), + @SerialName("admin_correction") val adminComment: String?, + @SerialName("status") val accepted: Boolean?, + @SerialName("reject_comment") val rejectComment: String?, + ) { + fun toDomain(): Domain { + return Domain( + id = id, + user = user.toDomain(), + type = type.toDomain(), + address = address, + text = text, + imageList = imageList.map { it.toDomain() }, + adminComment = adminComment, + accepted = accepted, + rejectComment = rejectComment, + ) + } + } + + @Serializable + data class Create( + @SerialName("user_id") val userId: Int, + @SerialName("request_type_id") val typeId: Int, + @SerialName("address") val address: String, + @SerialName("request_description") val text: String, + @SerialName("file_id_list") val imageFileIdList: List, + ) + + @Serializable + data class Accept( + @SerialName("admin_correction") val adminComment: String?, + @SerialName("status") val accepted: Boolean, + @SerialName("reject_comment") val rejectComment: String?, + ) + } + } + + object Image { + data class Domain( + val id: Int, + val userRequestId: Int?, + val fileName: String, + val fileSize: Int, + val fileType: String, + val uploadedAt: String, + val url: String, + ) + + object DTO { + @Serializable + data class Get( + @SerialName("id") val id: Int, + @SerialName("request_id") val userRequestId: Int?, + @SerialName("file_name") val fileName: String, + @SerialName("file_size") val fileSize: Int, + @SerialName("file_type") val fileType: String, + @SerialName("uploaded_at") val uploadedAt: String, + @SerialName("url") val url: String, + ) { + fun toDomain(): Domain { + return Domain( + id = id, + userRequestId = userRequestId, + fileName = fileName, + fileSize = fileSize, + fileType = fileType, + uploadedAt = uploadedAt, + url = url, + ) + } + } + } + } + + object Municipality { + data class Domain( + val id: Int, + val name: String, + ) { + fun toGetDTO(): DTO.Get { + return DTO.Get( + id = id, + name = name, + ) + } + + override fun toString(): String { + return name + } + } + + object DTO { + @Serializable + data class Get( + @SerialName("id") val id: Int, + @SerialName("district_name") val name: String, + ) { + fun toDomain(): Domain { + return Domain( + id = id, + name = name, + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/core/Platform.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/core/Platform.kt new file mode 100644 index 0000000..acef5f6 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/core/Platform.kt @@ -0,0 +1,177 @@ +package ru.cit71.pchelovod71.core + +import androidx.compose.ui.text.input.KeyboardType +import kotlinx.browser.document +import kotlinx.browser.window +import kotlinx.coroutines.await +import org.khronos.webgl.Uint8Array +import org.khronos.webgl.get +import org.khronos.webgl.set +import org.w3c.dom.CanvasRenderingContext2D +import org.w3c.dom.HTMLCanvasElement +import org.w3c.dom.HTMLDivElement +import org.w3c.dom.HTMLInputElement +import org.w3c.dom.HTMLTextAreaElement +import org.w3c.dom.Image +import org.w3c.files.FileReader +import org.w3c.files.get +import kotlin.math.round + +const val MAX_WIDTH = 1920.0 +const val MAX_HEIGHT = 1080.0 +const val TYPE = "image/jpeg" +val QUALITY: JsAny = js(".8") +val STARTAPP_PATTERN = Regex("^.*tgWebAppStartParam=(.*?)(?:&.*)?$") + +@JsFun("() => getTelegramInitData()") +private external fun topLevelGetTelegramInitData(): String + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +object Platform { + val module = Models.Module.WasmJsMain + + suspend fun getTelegramInitData() = Config.telegramInitData ?: topLevelGetTelegramInitData() + + suspend fun getBindUuid() = STARTAPP_PATTERN.find(window.location.search)?.groupValues[1] + + fun base64Encode( + data: String, + ) = window.btoa(data) + + suspend fun copyToClipboard( + data: String, + ) { + window.navigator.clipboard.writeText(data).await() + } + + fun initIme( + value: String, + onValueChange: (String) -> Unit, + singleLine: Boolean, + label: String, + keyboardType: KeyboardType, + ) { + val blank = document.createElement("div") as HTMLDivElement + blank.classList.add("blank") + blank.addEventListener("click", { + blank.remove() + }) + + val container = document.createElement("div") as HTMLDivElement + container.classList.add("container") + container.addEventListener("click", { event -> + event.stopPropagation() + }) + + val textField = when (singleLine) { + true -> document.createElement("input") as HTMLInputElement + false -> document.createElement("textarea") as HTMLTextAreaElement + } + textField.classList.add("text-field") + when (textField) { + is HTMLInputElement -> { + textField.value = value + textField.placeholder = label + textField.type = when (keyboardType) { + KeyboardType.Text -> "text" + KeyboardType.Number -> "number" + KeyboardType.Decimal -> "number" + KeyboardType.Phone -> "tel" + KeyboardType.Email -> "email" + KeyboardType.Password -> "password" + KeyboardType.NumberPassword -> "password" + else -> "text" + } + } + is HTMLTextAreaElement -> { + textField.value = value + textField.placeholder = label + textField.rows = 4 + } + } + + val button = document.createElement("input") as HTMLInputElement + button.classList.add("continue") + button.type = "button" + button.value = "Продолжить" + button.addEventListener("click", { + blank.remove() + }) + + container.appendChild(textField) + container.appendChild(button) + blank.appendChild(container) + document.body!!.appendChild(blank) + + textField.focus() + + textField.addEventListener("input", { + when (textField) { + is HTMLInputElement -> onValueChange(textField.value) + is HTMLTextAreaElement -> onValueChange(textField.value) + } + }) + } + + fun pickFile( + callback: (String, ByteArray) -> Unit, + ) { + val input = document.createElement("input") as HTMLInputElement + input.type = "file" + input.style.display = "none" + input.accept = "image/*" + input.setAttribute("capture", "environment") + + input.onchange = { _ -> + input.files?.let { it[0] }?.let { file -> + val fileReader = FileReader() + + fileReader.onload = { _ -> + val img = Image() + + img.onload = { + val canvas = document.createElement("canvas") as HTMLCanvasElement + var width = img.width.toDouble() + var height = img.height.toDouble() + + if (width > MAX_WIDTH || height > MAX_HEIGHT) { + if (width / MAX_WIDTH > height / MAX_HEIGHT) { + width = MAX_WIDTH + height = round((img.height * (MAX_WIDTH / img.width))) + } else { + height = MAX_HEIGHT + width = round(img.width * (MAX_HEIGHT / img.height)) + } + } + + canvas.width = width.toInt() + canvas.height = height.toInt() + val ctx = canvas.getContext("2d") as CanvasRenderingContext2D + ctx.drawImage(img, .0, .0, width, height) + val dataUrl = canvas.toDataURL(TYPE, QUALITY) + + val base64Data = dataUrl.substringAfter(",") + val binaryString = window.atob(base64Data) + val uint8Array = Uint8Array(binaryString.length) + for (i in binaryString.indices) + uint8Array[i] = binaryString[i].code.toByte() + val byteArray = ByteArray(uint8Array.length) { i -> uint8Array[i] } + callback( + file.type, + byteArray, + ) + } + + fileReader.result?.let { + img.src = "$it" + } + } + + fileReader.readAsDataURL(file) + } + } + + document.body?.appendChild(input) + input.click() + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/main.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/main.kt new file mode 100644 index 0000000..fd1756d --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/main.kt @@ -0,0 +1,12 @@ +package ru.cit71.pchelovod71 + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.ComposeViewport +import kotlinx.browser.document + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + ComposeViewport(document.body!!) { + App() + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/BooleanFilter.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/BooleanFilter.kt new file mode 100644 index 0000000..bfc2db2 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/BooleanFilter.kt @@ -0,0 +1,43 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun BooleanFilter( + modifier: Modifier = Modifier, + valueMap: Map, + value: Boolean?, + onValueChange: (Boolean?) -> Unit, +) { + SingleChoiceSegmentedButtonRow( + modifier = modifier + .padding( + vertical = 12.dp, + ), + ) { + valueMap.entries.forEachIndexed { index, entry -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = valueMap.size, + ), + onClick = { + onValueChange(entry.value) + }, + selected = value == entry.value, + label = { + Text( + text = entry.key, + ) + }, + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Button.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Button.kt new file mode 100644 index 0000000..4693cc3 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Button.kt @@ -0,0 +1,41 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun ConfirmButton( + modifier: Modifier, + onClick: () -> Unit, + label: String, +) { + Button( + modifier = Modifier + .then(modifier), + onClick = onClick, + ) { + Text( + text = label, + ) + } +} + +@Composable +fun DismissButton( + modifier: Modifier, + onClick: () -> Unit, + label: String, +) { + OutlinedButton( + modifier = Modifier + .then(modifier), + onClick = onClick, + ) { + Text( + text = label, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Column.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Column.kt new file mode 100644 index 0000000..cab721b --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Column.kt @@ -0,0 +1,95 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun MainColumn( + modifier: Modifier = Modifier, + paddingValues: PaddingValues = PaddingValues(), + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll( + state = rememberScrollState(), + ) + .then(modifier), + horizontalAlignment = horizontalAlignment, + verticalArrangement = verticalArrangement, + content = content, + ) +} + +@Composable +fun MainColumnList( + modifier: Modifier = Modifier, + paddingValues: PaddingValues = PaddingValues(), + onScrollChange: ((Boolean) -> Unit)? = null, + valueList: List, + placeholder: String, + content: @Composable (T) -> Unit, +) { + val verticalScrollState = rememberScrollState() + + LaunchedEffect(Unit) { + onScrollChange?.let { onScrollChange -> + var previousValue = 0 + snapshotFlow { + verticalScrollState.value + }.collect { + onScrollChange(it <= previousValue) + previousValue = it + } + } + } + + when (valueList.isEmpty()) { + true -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + Text( + text = placeholder, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + false -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll( + state = verticalScrollState, + ) + .then(modifier), + ) { + valueList.forEach { + content(it) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/CopyField.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/CopyField.kt new file mode 100644 index 0000000..acde4aa --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/CopyField.kt @@ -0,0 +1,83 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import frontend.composeapp.generated.resources.Res +import frontend.composeapp.generated.resources.round_content_copy_24 +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import ru.cit71.pchelovod71.core.Platform + +@Composable +fun CopyField( + mainScaffoldParams: MainScaffoldParams, + value: String, + label: String, + supportingText: String? = null, +) { + val clipboardCoroutineScope = rememberCoroutineScope() + val outlinedTextFieldDefaults = OutlinedTextFieldDefaults.colors() + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .pointerHoverIcon( + icon = PointerIcon.Default, + overrideDescendants = true, + ), + value = value, + onValueChange = {}, + enabled = false, + label = { + Text( + text = label, + ) + }, + trailingIcon = { + IconButton( + onClick = { + clipboardCoroutineScope.launch { + Platform.copyToClipboard(value) + mainScaffoldParams.showSuccessSnackbar( + "Ссылка скопирована в буфер обмена" + ) + } + }, + ) { + Icon( + painter = painterResource(Res.drawable.round_content_copy_24), + contentDescription = null, + ) + } + }, + supportingText = supportingText?.let { + { + Text( + text = it, + ) + } + }, + singleLine = true, + colors = outlinedTextFieldDefaults.copy( + disabledTextColor = outlinedTextFieldDefaults.unfocusedTextColor, + disabledContainerColor = outlinedTextFieldDefaults.unfocusedContainerColor, + disabledIndicatorColor = outlinedTextFieldDefaults.unfocusedIndicatorColor, + disabledLeadingIconColor = outlinedTextFieldDefaults.unfocusedLeadingIconColor, + disabledTrailingIconColor = outlinedTextFieldDefaults.unfocusedTrailingIconColor, + disabledLabelColor = outlinedTextFieldDefaults.unfocusedLabelColor, + disabledPlaceholderColor = outlinedTextFieldDefaults.unfocusedPlaceholderColor, + disabledSupportingTextColor = outlinedTextFieldDefaults.unfocusedSupportingTextColor, + disabledPrefixColor = outlinedTextFieldDefaults.unfocusedPrefixColor, + disabledSuffixColor = outlinedTextFieldDefaults.unfocusedSuffixColor, + ), + ) +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Dialog.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Dialog.kt new file mode 100644 index 0000000..bcead82 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Dialog.kt @@ -0,0 +1,84 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import frontend.composeapp.generated.resources.Res +import frontend.composeapp.generated.resources.baseline_close_24 +import org.jetbrains.compose.resources.painterResource + +interface MainDialogParams { + val onDismiss: () -> Unit +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainDialog( + title: String, + onDismiss: () -> Unit, + buttons: @Composable RowScope.() -> Unit, + content: @Composable (MainScaffoldParams) -> Unit, +) { + Dialog( + onDismissRequest = {}, + properties = DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + MainScaffold( + topBar = { + TopAppBar( + title = { + Text( + text = title, + overflow = TextOverflow.Ellipsis, + softWrap = false, + ) + }, + navigationIcon = { + IconButton( + onClick = onDismiss, + ) { + Icon( + painter = painterResource(Res.drawable.baseline_close_24), + contentDescription = null, + ) + } + }, + ) + }, + bottomBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + content = buttons, + ) + } + ) { mainScaffoldParams -> + Box( + modifier = Modifier + .padding( + horizontal = 16.dp, + ), + ) { + content(mainScaffoldParams) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/DropdownField.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/DropdownField.kt new file mode 100644 index 0000000..366cb5a --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/DropdownField.kt @@ -0,0 +1,85 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DropdownField( + modifier: Modifier = Modifier, + valueList: List, + value: T, + onValueChange: (T) -> Unit, + enabled: Boolean = true, + label: String, +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = enabled && expanded, + onExpandedChange = { + expanded = it + } + ) { + OutlinedTextField( + modifier = modifier + .fillMaxWidth() + .menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryEditable, + ) + .pointerHoverIcon( + icon = PointerIcon.Default, + overrideDescendants = true, + ), + enabled = enabled, + readOnly = true, + value = value.toString(), + onValueChange = {}, + label = { + Text( + text = label, + ) + }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = enabled && expanded + ) + }, + ) + + ExposedDropdownMenu( + expanded = enabled && expanded, + onDismissRequest = { + expanded = false + } + ) { + valueList.forEach { + DropdownMenuItem( + text = { + Text( + text = it.toString(), + ) + }, + onClick = { + onValueChange(it) + expanded = false + } + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/ImagePicker.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/ImagePicker.kt new file mode 100644 index 0000000..326fd6f --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/ImagePicker.kt @@ -0,0 +1,136 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.carousel.CarouselState +import androidx.compose.material3.carousel.HorizontalMultiBrowseCarousel +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import frontend.composeapp.generated.resources.Res +import frontend.composeapp.generated.resources.baseline_close_24 +import coil3.compose.rememberAsyncImagePainter +import org.jetbrains.compose.resources.painterResource +import ru.cit71.pchelovod71.core.Models + +enum class InteractionType { + Upload, + Download, +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImagePicker( + imageList: List, + onImageAdd: (() -> Unit)? = null, + onImageDrop: (Int) -> Unit, + onImageSelect: (Models.Image.Domain) -> Unit, + interactionType: InteractionType, +) { + Column { + if (interactionType == InteractionType.Upload) + onImageAdd?.let { + OutlinedButton( + modifier = Modifier + .fillMaxWidth(), + onClick = it, + ) { + Text( + text = "Добавить изображение", + ) + } + } + + when (imageList.size) { + 0 -> { + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + top = 4.dp, + ), + text = "Изображения не выбраны", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + ) + } + else -> { + HorizontalMultiBrowseCarousel( + state = remember(imageList.size) { + CarouselState( + currentItem = imageList.size, + itemCount = { + imageList.size + }, + ) + }, + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding( + vertical = 8.dp, + ), + preferredItemWidth = 186.dp, + itemSpacing = 8.dp, + ) { i -> + val image = imageList[i] + Box( + contentAlignment = Alignment.TopEnd, + ) { + Image( + modifier = Modifier + .height(205.dp) + .maskClip(MaterialTheme.shapes.extraLarge) + .clickable { + onImageSelect(image) + }, + painter = rememberAsyncImagePainter( + model = image.url, + contentScale = ContentScale.None, + ), + contentDescription = null, + contentScale = ContentScale.Crop, + ) + + if (interactionType == InteractionType.Upload) + IconButton( + onClick = { + onImageDrop(i) + }, + ) { + Icon( + painter = painterResource(Res.drawable.baseline_close_24), + contentDescription = null, + ) + } + } + } + } + } + + if (interactionType == InteractionType.Upload && imageList.size > 1) + Text( + modifier = Modifier + .fillMaxWidth(), + text = "Для прокрутки изображений на компьютерах и ноутбуках удерживайте клавишу Shift", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/LoadingIndicator.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/LoadingIndicator.kt new file mode 100644 index 0000000..fe5f99c --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/LoadingIndicator.kt @@ -0,0 +1,20 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.LoadingIndicatorDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun MainLoadingIndicator() { + LoadingIndicator( + modifier = Modifier + .size( + width = LoadingIndicatorDefaults.ContainerWidth * 3, + height = LoadingIndicatorDefaults.ContainerHeight * 3, + ), + ) +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/RegistrationRequestCard.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/RegistrationRequestCard.kt new file mode 100644 index 0000000..5516d91 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/RegistrationRequestCard.kt @@ -0,0 +1,200 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import frontend.composeapp.generated.resources.Res +import frontend.composeapp.generated.resources.round_check_24 +import frontend.composeapp.generated.resources.round_close_24 +import frontend.composeapp.generated.resources.round_manage_accounts_24 +import org.jetbrains.compose.resources.painterResource +import ru.cit71.pchelovod71.core.Models + +@Composable +fun RegistrationRequestCard( + userDomain: Models.User.Domain, + selected: Boolean, + onSelectedChange: ((Models.User.Domain)?) -> Unit, + onAccept: (Models.User.Domain) -> Unit, + onEdit: (Models.User.Domain) -> Unit, + onDecline: (Models.User.Domain) -> Unit, +) { + val backgroundColor = animateColorAsState( + targetValue = when (selected) { + true -> MaterialTheme.colorScheme.primary.copy( + alpha = 0.12f, + ) + false -> Color.Transparent + }, + ) + val horizontalPadding = animateDpAsState( + targetValue = when (selected) { + true -> 16.dp + false -> 0.dp + }, + ) + val shapeState = animateDpAsState( + targetValue = when (selected) { + true -> 28.0.dp + false -> 0.0.dp + }, + ) + val cardColors = CardDefaults.cardColors() + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontalPadding.value), + shape = RoundedCornerShape(shapeState.value), + colors = CardColors( + containerColor = backgroundColor.value, + contentColor = cardColors.contentColor, + disabledContainerColor = cardColors.disabledContainerColor, + disabledContentColor = cardColors.disabledContentColor, + ), + ) { + Column { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + onSelectedChange( + when (selected) { + true -> null + false -> userDomain + } + ) + } + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = userDomain.fullName, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.titleLarge, + ) + + Text( + text = userDomain.municipality.name, + overflow = TextOverflow.Ellipsis, + maxLines = 3, + style = MaterialTheme.typography.bodyMedium, + ) + } + + AnimatedVisibility( + visible = selected, + ) { + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + RegistrationRequestButton( + modifier = Modifier + .weight( + weight = 1f, + fill = true, + ), + title = "Принять", + painter = painterResource(Res.drawable.round_check_24), + onClick = { + onAccept(userDomain) + }, + ) + RegistrationRequestButton( + modifier = Modifier + .weight( + weight = 1f, + fill = true, + ), + title = "Изменить", + painter = painterResource(Res.drawable.round_manage_accounts_24), + onClick = { + onEdit(userDomain) + }, + ) + RegistrationRequestButton( + modifier = Modifier + .weight( + weight = 1f, + fill = true, + ), + title = "Отклонить", + painter = painterResource(Res.drawable.round_close_24), + onClick = { + onDecline(userDomain) + }, + ) + } + } + } + } +} + +@Composable +fun RegistrationRequestButton( + modifier: Modifier = Modifier, + title: String, + painter: Painter, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + modifier = modifier + .fillMaxHeight(), + shape = RectangleShape, + colors = ButtonDefaults.textButtonColors(), + contentPadding = PaddingValues(), + ) { + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + painter = painter, + contentDescription = null, + ) + + Text( + text = title, + textAlign = TextAlign.Center, + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Scaffold.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Scaffold.kt new file mode 100644 index 0000000..b7f6a73 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Scaffold.kt @@ -0,0 +1,165 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.input.pointer.pointerInput +import kotlinx.coroutines.launch + +data class MainScaffoldParams( + val paddingValues: PaddingValues, + val showSuccessSnackbar: (String) -> Unit, + val showErrorSnackbar: (String) -> Unit, + val setFloatingActionButton: ((@Composable AnimatedVisibilityScope.() -> Unit)?) -> Unit, + val withLoadingIndicator: suspend (suspend () -> Unit) -> Unit, +) + +@Composable +fun MainScaffold( + modifier: Modifier = Modifier, + topBar: @Composable () -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, + content: (@Composable (MainScaffoldParams) -> Unit)? = null, +) { + var loadingIndicator by remember { mutableStateOf(false) } + + suspend fun withLoadingIndicator( + function: suspend () -> Unit, + ) { + loadingIndicator = true + try { + function() + } finally { + loadingIndicator = false + } + } + + val snackbarCoroutineScope = rememberCoroutineScope() + val successSnackbarHostState = SnackbarHostState() + val errorSnackbarHostState = SnackbarHostState() + + fun showSuccessSnackbar( + message: String, + ) { + snackbarCoroutineScope.launch { + successSnackbarHostState.showSnackbar( + message = message, + withDismissAction = true, + ) + } + } + + fun showErrorSnackbar( + message: String, + ) { + snackbarCoroutineScope.launch { + errorSnackbarHostState.showSnackbar( + message = message, + withDismissAction = true, + ) + } + } + + var floatingActionButton by remember { mutableStateOf<(@Composable AnimatedVisibilityScope.() -> Unit)?>(null) } + var floatingActionButtonVisible by remember { mutableStateOf(false) } + + fun setFloatingActionButton( + content: (@Composable AnimatedVisibilityScope.() -> Unit)?, + ) { + when (content) { + null -> { + floatingActionButtonVisible = false + } + else -> { + floatingActionButton = content + floatingActionButtonVisible = true + } + } + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .then(modifier), + topBar = topBar, + bottomBar = bottomBar, + floatingActionButton = { + floatingActionButton?.let { + AnimatedVisibility( + visible = floatingActionButtonVisible, + enter = fadeIn() + expandHorizontally(), + exit = shrinkHorizontally() + fadeOut(), + content = it, + ) + } + }, + snackbarHost = { + MainSnackbarHost( + successSnackbarHostState = successSnackbarHostState, + errorSnackbarHostState = errorSnackbarHostState, + ) + }, + content = { paddingValues -> + content?.let { + it( + MainScaffoldParams( + paddingValues = paddingValues, + showSuccessSnackbar = ::showSuccessSnackbar, + showErrorSnackbar = ::showErrorSnackbar, + setFloatingActionButton = ::setFloatingActionButton, + withLoadingIndicator = ::withLoadingIndicator, + ), + ) + } + + AnimatedVisibility( + modifier = Modifier + .fillMaxSize(), + visible = loadingIndicator, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = when (content) { + null -> Color.Unspecified + else -> Color.Black.copy( + alpha = .5f, + ) + }, + ) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent() + } + } + }, + contentAlignment = Alignment.Center, + ) { + MainLoadingIndicator() + } + } + }, + ) +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/SettingCard.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/SettingCard.kt new file mode 100644 index 0000000..10898c6 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/SettingCard.kt @@ -0,0 +1,60 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +fun SettingCard( + name: String, + caption: String, + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit), +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + onClick = { + onCheckedChange(!checked) + }, + ) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ){ + Text( + text = name, + overflow = TextOverflow.Ellipsis, + softWrap = false, + style = MaterialTheme.typography.titleLarge, + ) + + Text( + text = caption, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/SnackbarHost.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/SnackbarHost.kt new file mode 100644 index 0000000..6d678eb --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/SnackbarHost.kt @@ -0,0 +1,35 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +@Composable +fun MainSnackbarHost( + successSnackbarHostState: SnackbarHostState, + errorSnackbarHostState: SnackbarHostState, +) { + val successContainerDark = Color(0xFF009424) + val onSuccessContainerDark = Color(0xFFD6FFEA) + + SnackbarHost(successSnackbarHostState) { + Snackbar( + snackbarData = it, + contentColor = onSuccessContainerDark, + containerColor = successContainerDark, + dismissActionContentColor = onSuccessContainerDark, + ) + } + + SnackbarHost(errorSnackbarHostState) { + Snackbar( + snackbarData = it, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + containerColor = MaterialTheme.colorScheme.errorContainer, + dismissActionContentColor = MaterialTheme.colorScheme.onErrorContainer, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Spoiler.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Spoiler.kt new file mode 100644 index 0000000..3e42b43 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/Spoiler.kt @@ -0,0 +1,88 @@ +package ru.cit71.pchelovod71.ui.components// TODO: спойлер для расширенных параметров пользователя; не пригодился, но оставлен до востребования + +//package ru.cit71.bee_frontend.ui.components +// +//import androidx.compose.animation.AnimatedVisibility +//import androidx.compose.animation.core.FastOutSlowInEasing +//import androidx.compose.animation.core.animateFloatAsState +//import androidx.compose.animation.core.tween +//import androidx.compose.foundation.layout.Arrangement +//import androidx.compose.foundation.layout.Column +//import androidx.compose.foundation.layout.ColumnScope +//import androidx.compose.foundation.layout.Row +//import androidx.compose.foundation.layout.fillMaxWidth +//import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +//import androidx.compose.material3.Icon +//import androidx.compose.material3.IconToggleButton +//import androidx.compose.material3.MaterialTheme +//import androidx.compose.material3.Text +//import androidx.compose.runtime.Composable +//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.draw.rotate +//import androidx.compose.ui.text.style.TextOverflow +//import androidx.compose.ui.unit.dp +//import frontend.composeapp.generated.resources.Res +//import frontend.composeapp.generated.resources.round_arrow_right_24 +//import org.jetbrains.compose.resources.painterResource +// +//@OptIn(ExperimentalMaterial3ExpressiveApi::class) +//@Composable +//fun Spoiler( +// name: String, +// content: @Composable ColumnScope.() -> Unit, +//) { +// var expanded by remember { mutableStateOf(false) } +// val rotation by animateFloatAsState( +// targetValue = if (expanded) 90f else 0f, +// animationSpec = tween( +// durationMillis = 300, +// easing = FastOutSlowInEasing +// ), +// ) +// +// Column( +// verticalArrangement = Arrangement.spacedBy(16.dp), +// ) { +// Row( +// horizontalArrangement = Arrangement.spacedBy(8.dp), +// verticalAlignment = Alignment.CenterVertically, +// ) { +// IconToggleButton( +// checked = expanded, +// onCheckedChange = { +// expanded = it +// }, +// ) { +// Icon( +// modifier = Modifier +// .rotate(rotation), +// painter = painterResource(Res.drawable.round_arrow_right_24), +// contentDescription = null, +// ) +// } +// +// Text( +// text = name, +// overflow = TextOverflow.Ellipsis, +// softWrap = false, +// style = MaterialTheme.typography.titleMedium, +// ) +// } +// +// AnimatedVisibility( +// visible = expanded, +// ) { +// Column( +// modifier = Modifier +// .fillMaxWidth(), +// verticalArrangement = Arrangement.spacedBy(16.dp), +// content = content, +// ) +// } +// } +//} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/TextField.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/TextField.kt new file mode 100644 index 0000000..c521ed0 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/TextField.kt @@ -0,0 +1,89 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import ru.cit71.pchelovod71.core.Models +import ru.cit71.pchelovod71.core.Platform + +@Composable +fun MainTextField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean = true, + label: String, + singleLine: Boolean = true, + minLines: Int = 1, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + keyboardType: KeyboardType = KeyboardType.Unspecified, +) { + val outlinedTextFieldDefaults = OutlinedTextFieldDefaults.colors() + + val moduleModifier = when (Platform.module) { + Models.Module.DesktopMain -> Modifier + Models.Module.WasmJsMain -> Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + if (enabled) + Platform.initIme( + value = value, + onValueChange = onValueChange, + singleLine = singleLine, + label = label, + keyboardType = keyboardType, + ) + } + } + + OutlinedTextField( + modifier = modifier + .fillMaxWidth() + .then(moduleModifier), + value = value, + onValueChange = onValueChange, + enabled = when (Platform.module) { + Models.Module.DesktopMain -> enabled + Models.Module.WasmJsMain -> false + }, + label = { + Text( + text = label, + ) + }, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + ), + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + colors = when (Platform.module) { + Models.Module.DesktopMain -> outlinedTextFieldDefaults + Models.Module.WasmJsMain -> when (enabled) { + true -> outlinedTextFieldDefaults.copy( + disabledTextColor = outlinedTextFieldDefaults.unfocusedTextColor, + disabledContainerColor = outlinedTextFieldDefaults.unfocusedContainerColor, + disabledIndicatorColor = outlinedTextFieldDefaults.unfocusedIndicatorColor, + disabledLeadingIconColor = outlinedTextFieldDefaults.unfocusedLeadingIconColor, + disabledTrailingIconColor = outlinedTextFieldDefaults.unfocusedTrailingIconColor, + disabledLabelColor = outlinedTextFieldDefaults.unfocusedLabelColor, + disabledPlaceholderColor = outlinedTextFieldDefaults.unfocusedPlaceholderColor, + disabledSupportingTextColor = outlinedTextFieldDefaults.unfocusedSupportingTextColor, + disabledPrefixColor = outlinedTextFieldDefaults.unfocusedPrefixColor, + disabledSuffixColor = outlinedTextFieldDefaults.unfocusedSuffixColor, + ) + false -> outlinedTextFieldDefaults + } + }, + ) +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/UserCard.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/UserCard.kt new file mode 100644 index 0000000..3b74348 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/UserCard.kt @@ -0,0 +1,51 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import ru.cit71.pchelovod71.core.Models + +@Composable +fun UserCard( + userDomain: Models.User.Domain, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable( + onClick = onClick, + ) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ){ + Text( + text = userDomain.fullName, + style = MaterialTheme.typography.titleLarge, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = userDomain.organization, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = userDomain.phoneNumber, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = userDomain.municipality.name, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/UserRequestCard.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/UserRequestCard.kt new file mode 100644 index 0000000..1194a8d --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/components/UserRequestCard.kt @@ -0,0 +1,55 @@ +package ru.cit71.pchelovod71.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import ru.cit71.pchelovod71.core.Models + +@Composable +fun UserRequestCard( + userRequestDomain: Models.UserRequest.Domain, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable( + onClick = onClick, + ) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ){ + Text( + text = userRequestDomain.type.name, + style = MaterialTheme.typography.titleLarge, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = userRequestDomain.user.municipality.name, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = userRequestDomain.address, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = when (userRequestDomain.accepted) { + true -> "Принято" + false -> "Отклонено" + null -> "Новое" + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/dialogs/ConfirmDialog.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/dialogs/ConfirmDialog.kt new file mode 100644 index 0000000..d8fb0bf --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/dialogs/ConfirmDialog.kt @@ -0,0 +1,119 @@ +package ru.cit71.pchelovod71.ui.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import ru.cit71.pchelovod71.ui.components.ConfirmButton +import ru.cit71.pchelovod71.ui.components.DismissButton +import ru.cit71.pchelovod71.ui.components.MainDialogParams +import ru.cit71.pchelovod71.ui.components.MainTextField + +sealed interface ConfirmDialogParams : MainDialogParams + +data class ConfirmCommentDialogParams( + override val onDismiss: () -> Unit, + val onConfirm: (String) -> Unit, +) : ConfirmDialogParams + +data class ConfirmOnlyDialogParams( + override val onDismiss: () -> Unit, + val onConfirm: () -> Unit, + val title: String, +) : ConfirmDialogParams + +@Composable +fun ConfirmDialog( + confirmDialogParams: ConfirmDialogParams, + dismissButtonText: String, + confirmButtonText: String, +) { + var comment by remember { mutableStateOf("") } + + Dialog( + onDismissRequest = confirmDialogParams.onDismiss, + properties = DialogProperties( + dismissOnClickOutside = false, + ), + ) { + Surface( + modifier = Modifier + .wrapContentSize(), + shape = MaterialTheme.shapes.medium, + ) { + Column( + modifier = Modifier + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + when (confirmDialogParams) { + is ConfirmCommentDialogParams -> { + MainTextField( + value = comment, + onValueChange = { + comment = it + }, + label = "Комментарий", + keyboardType = KeyboardType.Text, + singleLine = false, + minLines = 3, + maxLines = 6, + ) + } + is ConfirmOnlyDialogParams -> { + Text( + text = confirmDialogParams.title, + overflow = TextOverflow.Ellipsis, + softWrap = false, + style = MaterialTheme.typography.titleLarge, + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ConfirmButton( + modifier = Modifier + .weight(1f), + onClick = { + when (confirmDialogParams) { + is ConfirmCommentDialogParams -> { + confirmDialogParams.onConfirm(comment) + } + is ConfirmOnlyDialogParams -> { + confirmDialogParams.onConfirm() + } + } + }, + label = confirmButtonText, + ) + + DismissButton( + modifier = Modifier + .weight(1f), + onClick = { + confirmDialogParams.onDismiss() + }, + label = dismissButtonText, + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/dialogs/ImageViewDialog.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/dialogs/ImageViewDialog.kt new file mode 100644 index 0000000..3a46a96 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/dialogs/ImageViewDialog.kt @@ -0,0 +1,50 @@ +package ru.cit71.pchelovod71.ui.dialogs + +import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.TransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +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.compose.ui.geometry.Offset +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.window.Dialog +import coil3.compose.rememberAsyncImagePainter +import ru.cit71.pchelovod71.core.Models +import kotlin.math.max + +@Composable +fun ImageViewDialog( + image: Models.Image.Domain, + onClose: () -> Unit, +) { + var zoom by remember { mutableStateOf(1f) } + var pan by remember { mutableStateOf(Offset.Zero) } + var rotation by remember { mutableStateOf(0f) } + + Dialog( + onDismissRequest = onClose, + ) { + Image( + modifier = Modifier + .wrapContentSize() + .transformable( + state = TransformableState { zoomChange, panChange, rotationChange -> + zoom = max(zoom * zoomChange, 1f) + pan += panChange + rotation += rotationChange + }, + ), + painter = rememberAsyncImagePainter( + model = image.url, + contentScale = ContentScale.None, + ), + contentDescription = null, + contentScale = ContentScale.Fit, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/dialogs/UserDialog.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/dialogs/UserDialog.kt new file mode 100644 index 0000000..7d6197d --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/dialogs/UserDialog.kt @@ -0,0 +1,171 @@ +package ru.cit71.pchelovod71.ui.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.runtime.Composable +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.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import io.ktor.http.clone +import ru.cit71.pchelovod71.core.Config +import ru.cit71.pchelovod71.core.Models +import ru.cit71.pchelovod71.ui.components.ConfirmButton +import ru.cit71.pchelovod71.ui.components.CopyField +import ru.cit71.pchelovod71.ui.components.DismissButton +import ru.cit71.pchelovod71.ui.components.MainColumn +import ru.cit71.pchelovod71.ui.components.MainDialog +import ru.cit71.pchelovod71.ui.components.MainDialogParams +import ru.cit71.pchelovod71.ui.components.DropdownField +import ru.cit71.pchelovod71.ui.components.MainTextField + +sealed interface UserDialogParams : MainDialogParams + +data class CreateUserDialogParams( + override val onDismiss: () -> Unit, + val onCreate: ((Models.User.Domain) -> Unit), +) : UserDialogParams + +data class UpdateUserDialogParams( + override val onDismiss: () -> Unit, + val onSave: ((Models.User.Domain) -> Unit), + val onDelete: ((Models.User.Domain) -> Unit), +) : UserDialogParams + +data class AcceptUserDialogParams( + override val onDismiss: () -> Unit, + val onAccept: ((Models.User.Domain) -> Unit), + val onDecline: ((Models.User.Domain) -> Unit), +) : UserDialogParams + +@Composable +fun UserDialog( + userDomain: Models.User.Domain, + municipalityDomainList: List, + userDialogParams: UserDialogParams, +) { + var outUserDomain by remember { mutableStateOf(userDomain) } + + MainDialog( + title = when (userDialogParams) { + is CreateUserDialogParams -> "Добавить пользователя" + is UpdateUserDialogParams -> "Изменить пользователя" + is AcceptUserDialogParams -> "Принять заявку на регистрацию" + }, + onDismiss = userDialogParams.onDismiss, + buttons = { + when (userDialogParams) { + is CreateUserDialogParams -> { + ConfirmButton( + modifier = Modifier + .weight(1f), + onClick = { + userDialogParams.onCreate(outUserDomain) + }, + label = "Добавить", + ) + } + is UpdateUserDialogParams -> { + ConfirmButton( + modifier = Modifier + .weight(1f), + onClick = { + userDialogParams.onSave(outUserDomain) + }, + label = "Сохранить", + ) + DismissButton( + modifier = Modifier + .weight(1f), + onClick = { + userDialogParams.onDelete(outUserDomain) + }, + label = "Удалить", + ) + } + is AcceptUserDialogParams -> { + ConfirmButton( + modifier = Modifier + .weight(1f), + onClick = { + userDialogParams.onAccept(outUserDomain) + }, + label = "Принять", + ) + DismissButton( + modifier = Modifier + .weight(1f), + onClick = { + userDialogParams.onDecline(outUserDomain) + }, + label = "Отклонить", + ) + } + } + }, + ) { mainScaffoldParams -> + MainColumn( + paddingValues = mainScaffoldParams.paddingValues, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + MainTextField( + value = outUserDomain.fullName, + onValueChange = { + outUserDomain = outUserDomain.copy( + fullName = it, + ) + }, + label = "ФИО", + keyboardType = KeyboardType.Text, + ) + + MainTextField( + value = outUserDomain.organization, + onValueChange = { + outUserDomain = outUserDomain.copy( + organization = it, + ) + }, + label = "Организация", + keyboardType = KeyboardType.Text, + ) + + MainTextField( + value = outUserDomain.phoneNumber, + onValueChange = { + outUserDomain = outUserDomain.copy( + phoneNumber = it, + ) + }, + label = "Номер телефона", + keyboardType = KeyboardType.Phone, + ) + + DropdownField( + valueList = municipalityDomainList, + value = outUserDomain.municipality, + onValueChange = { + outUserDomain = outUserDomain.copy( + municipality = it, + ) + }, + label = "Муниципальное образование", + ) + + if (userDialogParams is UpdateUserDialogParams && userDomain.telegramId == null) { + val inviteUrl = Config.botUrlBuilder.clone().apply { + parameters["startapp"] = userDomain.uuid + }.buildString() + + CopyField( + mainScaffoldParams = mainScaffoldParams, + value = inviteUrl, + label = "Пригласительная ссылка", + supportingText = "Пользователь не привязан к учетной записи Telegram. Скопируйте ссылку и передайте ее владельцу", + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/dialogs/UserRequestDialog.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/dialogs/UserRequestDialog.kt new file mode 100644 index 0000000..04c70a4 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/dialogs/UserRequestDialog.kt @@ -0,0 +1,263 @@ +package ru.cit71.pchelovod71.ui.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import ru.cit71.pchelovod71.core.Api +import ru.cit71.pchelovod71.core.Models +import ru.cit71.pchelovod71.core.Platform +import ru.cit71.pchelovod71.ui.components.ConfirmButton +import ru.cit71.pchelovod71.ui.components.DismissButton +import ru.cit71.pchelovod71.ui.components.DropdownField +import ru.cit71.pchelovod71.ui.components.ImagePicker +import ru.cit71.pchelovod71.ui.components.InteractionType +import ru.cit71.pchelovod71.ui.components.MainColumn +import ru.cit71.pchelovod71.ui.components.MainDialog +import ru.cit71.pchelovod71.ui.components.MainDialogParams +import ru.cit71.pchelovod71.ui.components.MainTextField + +sealed interface UserRequestDialogParams : MainDialogParams + +data class ShowUserRequestDialogParams( + override val onDismiss: () -> Unit, +) : UserRequestDialogParams + +data class CreateUserRequestDialogParams( + override val onDismiss: () -> Unit, + val onCreate: (Models.UserRequest.Domain) -> Unit, +) : UserRequestDialogParams + +data class AcceptUserRequestDialogParams( + override val onDismiss: () -> Unit, + val onAccept: ((Models.UserRequest.Domain) -> Unit), + val onDecline: ((Models.UserRequest.Domain) -> Unit), +) : UserRequestDialogParams + +@Composable +fun UserRequestDialog( + userRequestDomain: Models.UserRequest.Domain, + userRequestDialogParams: UserRequestDialogParams, +) { + val apiCoroutineScope = rememberCoroutineScope() + + var outUserRequestDomain by remember { mutableStateOf(userRequestDomain) } + + var imageView by remember { mutableStateOf(null) } + + MainDialog( + title = when (userRequestDialogParams) { + is ShowUserRequestDialogParams -> "Просмотреть обращение" + is CreateUserRequestDialogParams -> "Добавить обращение" + is AcceptUserRequestDialogParams -> "Принять обращение" + }, + onDismiss = userRequestDialogParams.onDismiss, + buttons = { + when (userRequestDialogParams) { + is ShowUserRequestDialogParams -> {} + is CreateUserRequestDialogParams -> { + ConfirmButton( + modifier = Modifier + .weight(1f), + onClick = { + userRequestDialogParams.onCreate(outUserRequestDomain) + }, + label = "Добавить", + ) + } + is AcceptUserRequestDialogParams -> { + if (userRequestDomain.accepted == null) { + ConfirmButton( + modifier = Modifier + .weight(1f), + onClick = { + userRequestDialogParams.onAccept(outUserRequestDomain) + }, + label = "Принять", + ) + DismissButton( + modifier = Modifier + .weight(1f), + onClick = { + userRequestDialogParams.onDecline(outUserRequestDomain) + }, + label = "Отклонить", + ) + } + } + } + }, + ) { mainScaffoldParams -> + MainColumn( + modifier = Modifier + .padding( + bottom = 16.dp, + ), + paddingValues = mainScaffoldParams.paddingValues, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + MainTextField( + value = userRequestDomain.user.fullName, + onValueChange = {}, + enabled = false, + label = "ФИО", + ) + + MainTextField( + value = userRequestDomain.user.organization, + onValueChange = {}, + enabled = false, + label = "Организация", + ) + + MainTextField( + value = userRequestDomain.user.phoneNumber, + onValueChange = {}, + enabled = false, + label = "Номер телефона", + ) + + MainTextField( + value = outUserRequestDomain.user.municipality.name, + onValueChange = {}, + enabled = false, + label = "Муниципальное образование", + ) + + val enabledField = when (userRequestDialogParams) { + is ShowUserRequestDialogParams -> false + is AcceptUserRequestDialogParams -> false + is CreateUserRequestDialogParams -> true + } + + val requestTypeList = listOf( // TODO: по хорошему, нужно стягивать список по API + Models.RequestType.Domain( + id = 1, + name = "Фиксация гибели пчел", + ), + Models.RequestType.Domain( + id = 2, + name = "Фиксация факта нарушения сроков обработки полей", + ), + ) + + DropdownField( + valueList = requestTypeList, + value = outUserRequestDomain.type, + onValueChange = { + outUserRequestDomain = outUserRequestDomain.copy( + type = it, + ) + }, + enabled = enabledField, + label = "Тип обращения", + ) + + MainTextField( + value = outUserRequestDomain.address, + onValueChange = { + outUserRequestDomain = outUserRequestDomain.copy( + address = it, + ) + }, + enabled = enabledField, + label = "Адрес", + keyboardType = KeyboardType.Text, + ) + + MainTextField( + value = outUserRequestDomain.text, + onValueChange = { + outUserRequestDomain = outUserRequestDomain.copy( + text = it, + ) + }, + enabled = enabledField, + label = "Сутьевая часть", + keyboardType = KeyboardType.Text, + singleLine = false, + minLines = 3, + maxLines = 6, + ) + + if (userRequestDialogParams is AcceptUserRequestDialogParams) + MainTextField( + value = outUserRequestDomain.adminComment ?: "", + onValueChange = { + outUserRequestDomain = outUserRequestDomain.copy( + adminComment = it, + ) + }, + enabled = userRequestDomain.accepted == null, + label = "Уточнение администратора", + keyboardType = KeyboardType.Text, + singleLine = false, + minLines = 3, + maxLines = 6, + ) + + ImagePicker( + imageList = outUserRequestDomain.imageList, + onImageAdd = { + Platform.pickFile { type, data -> + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + Api.UserRequests.uploadImage( + type = type, + data = data, + onSuccess = { imageGetDTO -> + val imageList = outUserRequestDomain.imageList.toMutableList() + imageList.add(imageGetDTO.toDomain()) + outUserRequestDomain = outUserRequestDomain.copy( + imageList = imageList, + ) + mainScaffoldParams.showSuccessSnackbar( + "Файл успешно добавлен", + ) + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось добавить файл", + ) + }, + ) + } + } + } + }, + onImageDrop = { i -> + val imageList = outUserRequestDomain.imageList.toMutableList() + imageList.removeAt(i) + outUserRequestDomain = outUserRequestDomain.copy( + imageList = imageList, + ) + }, + onImageSelect = { + imageView = it + }, + interactionType = when (userRequestDialogParams) { + is ShowUserRequestDialogParams -> InteractionType.Download + is CreateUserRequestDialogParams -> InteractionType.Upload + is AcceptUserRequestDialogParams -> InteractionType.Download + }, + ) + } + + imageView?.let { + ImageViewDialog( + image = it, + onClose = { + imageView = null + }, + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/RegistrationRequestsScreen.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/RegistrationRequestsScreen.kt new file mode 100644 index 0000000..fce2111 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/RegistrationRequestsScreen.kt @@ -0,0 +1,181 @@ +package ru.cit71.pchelovod71.ui.screens + +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import kotlinx.coroutines.launch +import ru.cit71.pchelovod71.core.Api +import ru.cit71.pchelovod71.core.Models +import ru.cit71.pchelovod71.ui.components.MainColumnList +import ru.cit71.pchelovod71.ui.components.MainScaffoldParams +import ru.cit71.pchelovod71.ui.components.RegistrationRequestCard +import ru.cit71.pchelovod71.ui.dialogs.AcceptUserDialogParams +import ru.cit71.pchelovod71.ui.dialogs.ConfirmCommentDialogParams +import ru.cit71.pchelovod71.ui.dialogs.ConfirmDialog +import ru.cit71.pchelovod71.ui.dialogs.UserDialog + +@Composable +fun RegistrationRequestsScreen( + mainScaffoldParams: MainScaffoldParams, +) { + mainScaffoldParams.setFloatingActionButton(null) + + val apiCoroutineScope = rememberCoroutineScope() + + var userDomainList by remember { mutableStateOf(listOf()) } + var municipalityDomainList by remember { mutableStateOf(listOf()) } + + var userDomainCard by remember { mutableStateOf(null) } + var userDomainDialog by remember { mutableStateOf(null) } + + var confirmCommentDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + mainScaffoldParams.withLoadingIndicator { + Api.Municipalities.get( + onSuccess = { municipalityDTOList -> + municipalityDomainList = municipalityDTOList.map { it.toDomain() } + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось загрузить список муниципальных образований" + ) + }, + ) + } + } + + suspend fun updateUserDomainList() { + Api.Users.get( + verified = false, + onSuccess = { userDTOList -> + userDomainList = userDTOList.map { it.toDomain() } + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось загрузить список запросов на регистрацию", + ) + }, + ) + } + + LaunchedEffect(Unit) { + mainScaffoldParams.withLoadingIndicator { + updateUserDomainList() + } + } + + suspend fun onAccept( + userDomain: Models.User.Domain, + ) { + Api.Users.accept( + id = userDomain.id, + user = userDomain.toAcceptDTO(), + onSuccess = { + mainScaffoldParams.showSuccessSnackbar( + "Пользователь успешно принят", + ) + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось принять пользователя", + ) + }, + ) + updateUserDomainList() + } + + MainColumnList( + paddingValues = mainScaffoldParams.paddingValues, + valueList = userDomainList, + placeholder = "Список заявок на регистрацию пуст", + ) { + RegistrationRequestCard( + userDomain = it, + selected = it == userDomainCard, + onSelectedChange = { + userDomainCard = it + }, + onAccept = { outUserDomain -> + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + onAccept(outUserDomain) + } + } + }, + onEdit = { + userDomainDialog = it + }, + onDecline = { outUserDomain -> + userDomainCard = outUserDomain + confirmCommentDialog = true + }, + ) + } + + userDomainDialog?.let { inUserDomain -> + UserDialog( + userDomain = inUserDomain, + municipalityDomainList = municipalityDomainList, + userDialogParams = AcceptUserDialogParams( + onDismiss = { + userDomainDialog = null + }, + onAccept = { outUserDomain -> + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + onAccept(outUserDomain) + } + } + userDomainDialog = null + }, + onDecline = { outUserDomain -> + userDomainCard = outUserDomain + confirmCommentDialog = true + }, + ), + ) + } + + userDomainCard?.let { inUserDomain -> + if (confirmCommentDialog) + ConfirmDialog( + confirmDialogParams = ConfirmCommentDialogParams( + onDismiss = { + confirmCommentDialog = false + }, + onConfirm = { comment -> + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + Api.Users.reject( + id = inUserDomain.id, + user = inUserDomain.copy( + deleteReason = comment, + ).toRejectDTO(), + onSuccess = { + confirmCommentDialog = false + userDomainDialog = null + mainScaffoldParams.showSuccessSnackbar( + "Пользователь успешно отклонен", + ) + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось отклонить пользователя", + ) + }, + ) + updateUserDomainList() + } + } + }, + ), + dismissButtonText = "Отмена", + confirmButtonText = "Отправить", + ) + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/SettingsScreen.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..4294f59 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/SettingsScreen.kt @@ -0,0 +1,78 @@ +package ru.cit71.pchelovod71.ui.screens + +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import kotlinx.coroutines.launch +import ru.cit71.pchelovod71.core.Api +import ru.cit71.pchelovod71.core.Models +import ru.cit71.pchelovod71.core.Platform +import ru.cit71.pchelovod71.ui.components.MainColumn +import ru.cit71.pchelovod71.ui.components.MainScaffoldParams +import ru.cit71.pchelovod71.ui.components.SettingCard + +@Composable +fun SettingsScreen( + mainScaffoldParams: MainScaffoldParams, +) { + val apiCoroutineScope = rememberCoroutineScope() + + var userDomain by remember { mutableStateOf(null) } + + mainScaffoldParams.setFloatingActionButton(null) + + LaunchedEffect(Unit) { + mainScaffoldParams.withLoadingIndicator { + Api.Users.auth( + authParams = Models.AuthParams.DTO.Send( + initData = Platform.getTelegramInitData(), + ), + onSuccess = { + userDomain = it.toDomain() + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось загрузить текущие настройки пользователя" + ) + }, + ) + } + } + + MainColumn { + userDomain?.let { + SettingCard( + name = "Уведомления", + caption = "Подписаться на уведомления по инцидентам вашего муниципального образования", + checked = userDomain!!.subscribed, + onCheckedChange = { + userDomain = userDomain!!.copy( + subscribed = it + ) + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + Api.Users.subscribe( + id = userDomain!!.id, + user = userDomain!!.toSubscribeDTO(), + onSuccess = { + mainScaffoldParams.showSuccessSnackbar( + "Настройки пользователя успешно применены" + ) + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось применить настройки пользователя" + ) + }, + ) + } + } + }, + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/UserRegistrationScreen.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/UserRegistrationScreen.kt new file mode 100644 index 0000000..0ed45ac --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/UserRegistrationScreen.kt @@ -0,0 +1,230 @@ +package ru.cit71.pchelovod71.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import ru.cit71.pchelovod71.core.Api +import ru.cit71.pchelovod71.core.Models +import ru.cit71.pchelovod71.ui.components.MainColumn +import ru.cit71.pchelovod71.ui.components.DropdownField +import ru.cit71.pchelovod71.ui.components.MainScaffoldParams +import ru.cit71.pchelovod71.ui.components.MainTextField + +@Composable +fun RegistrationUserScreen( + mainScaffoldParams: MainScaffoldParams, + telegramId: String, + reloadUserDomain: suspend () -> Unit, +) { + val apiCoroutineScope = rememberCoroutineScope() + + var municipalityDomainList by remember { mutableStateOf(listOf()) } + var userDomain by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + mainScaffoldParams.withLoadingIndicator { + Api.Municipalities.get( + onSuccess = { municipalityDTOList -> + municipalityDomainList = municipalityDTOList.map { it.toDomain() } + + userDomain = Models.User.Domain( + telegramId = telegramId, + municipality = municipalityDomainList[5], + ) + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось загрузить список муниципальных образований" + ) + }, + ) + } + } + + userDomain?.let { + MainColumn( + modifier = Modifier + .padding( + horizontal = 16.dp, + vertical = 32.dp, + ), + paddingValues = mainScaffoldParams.paddingValues, + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth(), + text = "Помощник пчеловода", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.displayMedium, + ) + + Text( + modifier = Modifier + .fillMaxWidth(), + text = "Заявка на регистрацию", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineSmall, + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + MainTextField( + value = userDomain!!.fullName, + onValueChange = { + userDomain = userDomain!!.copy( + fullName = it, + ) + }, + label = "ФИО", + keyboardType = KeyboardType.Text, + ) + + DropdownField( + valueList = municipalityDomainList, + value = userDomain!!.municipality, + onValueChange = { + userDomain = userDomain!!.copy( + municipality = it, + ) + }, + label = "Муниципальное образование", + ) + + MainTextField( + value = userDomain!!.organization, + onValueChange = { + userDomain = userDomain!!.copy( + organization = it, + ) + }, + label = "Организация", + keyboardType = KeyboardType.Text, + ) + + MainTextField( + value = userDomain!!.phoneNumber, + onValueChange = { + userDomain = userDomain!!.copy( + phoneNumber = it, + ) + }, + label = "Номер телефона", + keyboardType = KeyboardType.Phone, + ) + } + + Button( + modifier = Modifier + .fillMaxWidth(), + onClick = { + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + Api.Users.create( + user = userDomain!!.toCreateDTO(), + onSuccess = { + reloadUserDomain() + mainScaffoldParams.showSuccessSnackbar( + "Заявка на регистрацию успешно отправлена!" + ) + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось отправить заявку на регистрацию" + ) + }, + ) + } + } + }, + ) { + Text( + text = "Отправить", + ) + } + } + } +} + +@Composable +fun WaitRegistrationUserScreen( + mainScaffoldParams: MainScaffoldParams, +) { + MainColumn( + modifier = Modifier + .padding( + horizontal = 16.dp, + vertical = 32.dp, + ), + paddingValues = mainScaffoldParams.paddingValues, + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth(), + text = "Помощник пчеловода", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.displayMedium, + ) + + Text( + modifier = Modifier + .fillMaxWidth(), + text = "Заявка на регистрацию находится на рассмотрении сотрудников министерства сельского хозяйства, природных ресурсов и экологии Тульской области. После одобрения вам поступит сообщение от чат-бота.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +fun RejectRegistrationUserScreen( + mainScaffoldParams: MainScaffoldParams, +) { + MainColumn( + modifier = Modifier + .padding( + horizontal = 16.dp, + vertical = 32.dp, + ), + paddingValues = mainScaffoldParams.paddingValues, + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth(), + text = "Помощник пчеловода", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.displayMedium, + ) + + Text( + modifier = Modifier + .fillMaxWidth(), + text = "Заявка на регистрацию была отклонена сотрудником министерства сельского хозяйства, природных ресурсов и экологии Тульской области.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/UserRequestsScreen.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/UserRequestsScreen.kt new file mode 100644 index 0000000..56a658b --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/UserRequestsScreen.kt @@ -0,0 +1,279 @@ +package ru.cit71.pchelovod71.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import frontend.composeapp.generated.resources.Res +import frontend.composeapp.generated.resources.outline_add_24 +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import ru.cit71.pchelovod71.core.Api +import ru.cit71.pchelovod71.core.Models +import ru.cit71.pchelovod71.ui.components.BooleanFilter +import ru.cit71.pchelovod71.ui.components.MainColumnList +import ru.cit71.pchelovod71.ui.components.MainScaffoldParams +import ru.cit71.pchelovod71.ui.components.UserRequestCard +import ru.cit71.pchelovod71.ui.dialogs.AcceptUserRequestDialogParams +import ru.cit71.pchelovod71.ui.dialogs.ConfirmCommentDialogParams +import ru.cit71.pchelovod71.ui.dialogs.ConfirmDialog +import ru.cit71.pchelovod71.ui.dialogs.CreateUserRequestDialogParams +import ru.cit71.pchelovod71.ui.dialogs.ShowUserRequestDialogParams +import ru.cit71.pchelovod71.ui.dialogs.UserRequestDialog +import ru.cit71.pchelovod71.ui.dialogs.UserRequestDialogParams + +@Composable +fun UserRequestsScreen( + mainScaffoldParams: MainScaffoldParams, + userDomain: Models.User.Domain, +) { + mainScaffoldParams.setFloatingActionButton(null) + + val apiCoroutineScope = rememberCoroutineScope() + + var userRequestDomainList by remember { mutableStateOf(listOf()) } + + var userRequestDomain by remember { mutableStateOf(null) } + var userRequestDialogParams by remember { mutableStateOf(null) } + + var confirmCommentDialog by remember { mutableStateOf(false) } + + var floatingActionButton by remember { mutableStateOf( + when (userDomain.role) { + Models.Role.Admin -> false + Models.Role.User -> true + } + ) } + + var acceptedFilter by remember { mutableStateOf(null) } + + suspend fun updateUserRequestDomainList() { + Api.UserRequests.get( + userId = when (userDomain.role) { + Models.Role.Admin -> null + Models.Role.User -> userDomain.id + }, + status = when (acceptedFilter) { + null -> Models.RequestStatus.New + true -> Models.RequestStatus.Accepted + false -> Models.RequestStatus.Rejected + }, + onSuccess = { userRequestList -> + userRequestDomainList = userRequestList.map { it.toDomain() } + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось загрузить список обращений", + ) + }, + ) + } + + LaunchedEffect(Unit) { + mainScaffoldParams.withLoadingIndicator { + updateUserRequestDomainList() + } + } + + fun onDismiss() { + userRequestDomain = null + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + updateUserRequestDomainList() + } + } + } + + when (userDomain.role) { + Models.Role.Admin -> { + mainScaffoldParams.setFloatingActionButton(null) + } + Models.Role.User -> { + mainScaffoldParams.setFloatingActionButton { + ExtendedFloatingActionButton( + text = { + Text( + text = "Добавить обращение", + ) + }, + icon = { + Icon( + painter = painterResource(Res.drawable.outline_add_24), + contentDescription = null, + ) + }, + onClick = { + userRequestDialogParams = CreateUserRequestDialogParams( + onDismiss = ::onDismiss, + onCreate = { outUserRequestDomain -> + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + Api.UserRequests.create( + userRequest = outUserRequestDomain.toCreateDTO(), + onSuccess = { + userRequestDomain = null + mainScaffoldParams.showSuccessSnackbar( + "Обращение успешно добавлено", + ) + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось добавить обращение", + ) + }, + ) + updateUserRequestDomainList() + } + } + }, + ) + userRequestDomain = Models.UserRequest.Domain( + user = userDomain, + ) + }, + expanded = floatingActionButton, + ) + } + } + } + + Column( + modifier = Modifier + .padding(mainScaffoldParams.paddingValues), + ) { + BooleanFilter( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 16.dp, + ), + valueMap = mapOf( + "Принято" to true, + "Новые" to null, + "Отклонено" to false, + ), + value = acceptedFilter, + onValueChange = { + acceptedFilter = it + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + updateUserRequestDomainList() + } + } + }, + ) + + MainColumnList( + onScrollChange = { + floatingActionButton = it + }, + valueList = userRequestDomainList, + placeholder = "Список обращений пуст", + ) { + UserRequestCard( + userRequestDomain = it, + onClick = { + when (userDomain.role) { + Models.Role.Admin -> { + userRequestDialogParams = AcceptUserRequestDialogParams( + onDismiss = ::onDismiss, + onAccept = { outUserRequestDomain -> + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + Api.UserRequests.accept( + id = it.id, + request = outUserRequestDomain.copy( + accepted = true, + ).toAcceptDTO(), + onSuccess = { + userRequestDomain = null + mainScaffoldParams.showSuccessSnackbar( + "Обращение успешно принято", + ) + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось принять обращение", + ) + }, + ) + updateUserRequestDomainList() + } + } + }, + onDecline = { outUserRequestDomain -> + userRequestDomain = outUserRequestDomain + confirmCommentDialog = true + }, + ) + } + Models.Role.User -> { + userRequestDialogParams = ShowUserRequestDialogParams( + onDismiss = ::onDismiss, + ) + } + } + + userRequestDomain = it + }, + ) + } + } + + userRequestDomain?.let { inUserRequestDomain -> + userRequestDialogParams?.let { userRequestDialogParams -> + UserRequestDialog( + userRequestDomain = inUserRequestDomain, + userRequestDialogParams = userRequestDialogParams, + ) + } + + if (confirmCommentDialog) + ConfirmDialog( + confirmDialogParams = ConfirmCommentDialogParams( + onDismiss = { + confirmCommentDialog = false + }, + onConfirm = { comment -> + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + Api.UserRequests.accept( + id = inUserRequestDomain.id, + request = inUserRequestDomain.copy( + accepted = false, + rejectComment = comment, + ).toAcceptDTO(), + onSuccess = { + confirmCommentDialog = false + userRequestDomain = null + mainScaffoldParams.showSuccessSnackbar( + "Обращение успешно отклонено", + ) + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось отклонить обращение", + ) + }, + ) + updateUserRequestDomainList() + } + } + }, + ), + dismissButtonText = "Отмена", + confirmButtonText = "Отправить", + ) + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/UsersScreen.kt b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/UsersScreen.kt new file mode 100644 index 0000000..517a5dd --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/ru/cit71/pchelovod71/ui/screens/UsersScreen.kt @@ -0,0 +1,275 @@ +package ru.cit71.pchelovod71.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import frontend.composeapp.generated.resources.Res +import frontend.composeapp.generated.resources.outline_add_24 +import io.ktor.http.clone +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import ru.cit71.pchelovod71.core.Api +import ru.cit71.pchelovod71.core.Config +import ru.cit71.pchelovod71.core.Models +import ru.cit71.pchelovod71.core.Platform +import ru.cit71.pchelovod71.ui.components.BooleanFilter +import ru.cit71.pchelovod71.ui.components.MainColumnList +import ru.cit71.pchelovod71.ui.components.MainScaffoldParams +import ru.cit71.pchelovod71.ui.components.UserCard +import ru.cit71.pchelovod71.ui.dialogs.ConfirmDialog +import ru.cit71.pchelovod71.ui.dialogs.ConfirmOnlyDialogParams +import ru.cit71.pchelovod71.ui.dialogs.CreateUserDialogParams +import ru.cit71.pchelovod71.ui.dialogs.UpdateUserDialogParams +import ru.cit71.pchelovod71.ui.dialogs.UserDialog +import ru.cit71.pchelovod71.ui.dialogs.UserDialogParams + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun UsersScreen( + mainScaffoldParams: MainScaffoldParams, +) { + val apiCoroutineScope = rememberCoroutineScope() + + var userDomainList by remember { mutableStateOf(listOf()) } + var municipalityDomainList by remember { mutableStateOf(listOf()) } + + var userDomain by remember { mutableStateOf(null) } + var userDialogParams by remember { mutableStateOf(null) } + + var confirmOnlyDialog by remember { mutableStateOf(false) } + + var floatingActionButton by remember { mutableStateOf(true) } + + var deletedFilter by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + mainScaffoldParams.withLoadingIndicator { + Api.Municipalities.get( + onSuccess = { municipalityDTOList -> + municipalityDomainList = municipalityDTOList.map { it.toDomain() } + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось загрузить список муниципальных образований" + ) + }, + ) + } + } + + suspend fun updateUserDomainList() { + Api.Users.get( + verified = if (deletedFilter != false) null else true, + deleted = deletedFilter, + onSuccess = { userDTOList -> + userDomainList = userDTOList.mapNotNull { userDTO -> + when (userDTO.roleId) { + 1 -> null + else -> userDTO.toDomain() + } + } + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось загрузить список пользователей", + ) + }, + ) + } + + LaunchedEffect(Unit) { + mainScaffoldParams.withLoadingIndicator { + updateUserDomainList() + } + } + + fun onDismiss() { + userDomain = null + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + updateUserDomainList() + } + } + } + + mainScaffoldParams.setFloatingActionButton { + ExtendedFloatingActionButton( + text = { + Text( + text = "Добавить пользователя", + ) + }, + icon = { + Icon( + painter = painterResource(Res.drawable.outline_add_24), + contentDescription = null, + ) + }, + onClick = { + if (municipalityDomainList.isNotEmpty()) { + userDialogParams = CreateUserDialogParams( + onDismiss = ::onDismiss, + onCreate = { outUserDomain -> + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + Api.Users.create( + user = outUserDomain.toCreateDTO(), + onSuccess = { + userDomain = null + + val inviteUrl = Config.botUrlBuilder.clone().apply { + parameters["startapp"] = it.uuid + }.buildString() + Platform.copyToClipboard(inviteUrl) + + mainScaffoldParams.showSuccessSnackbar( + "Пригласительная ссылка скопирована в буфер обмена", + ) + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось добавить пользователя", + ) + }, + ) + updateUserDomainList() + } + } + }, + ) + userDomain = Models.User.Domain( + municipality = municipalityDomainList[0], + accepted = true, + ) + } + }, + expanded = floatingActionButton, + ) + } + + Column( + modifier = Modifier + .padding(mainScaffoldParams.paddingValues), + ) { + BooleanFilter( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 16.dp, + ), + valueMap = mapOf( + "Принято" to false, + "Все" to null, + "Отклонено" to true, + ), + value = deletedFilter, + onValueChange = { + deletedFilter = it + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + updateUserDomainList() + } + } + }, + ) + + MainColumnList( + onScrollChange = { + floatingActionButton = it + }, + valueList = userDomainList, + placeholder = "Список пользователей пуст", + ) { + UserCard( + userDomain = it, + onClick = { + userDialogParams = UpdateUserDialogParams( + onDismiss = ::onDismiss, + onSave = { outUserDomain -> + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + Api.Users.update( + user = outUserDomain.toUpdateDTO(), + onSuccess = { + userDomain = null + mainScaffoldParams.showSuccessSnackbar( + "Пользователь успешно изменен", + ) + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось изменить пользователя", + ) + }, + ) + updateUserDomainList() + } + } + }, + onDelete = { + confirmOnlyDialog = true + } + ) + userDomain = it + }, + ) + } + } + + userDomain?.let { inUserDomain -> + userDialogParams?.let { userDialogParams -> + UserDialog( + userDomain = inUserDomain, + municipalityDomainList = municipalityDomainList, + userDialogParams = userDialogParams, + ) + } + + if (confirmOnlyDialog) + ConfirmDialog( + confirmDialogParams = ConfirmOnlyDialogParams( + onDismiss = { + confirmOnlyDialog = false + }, + onConfirm = { + apiCoroutineScope.launch { + mainScaffoldParams.withLoadingIndicator { + Api.Users.delete( + id = inUserDomain.id, + onSuccess = { + confirmOnlyDialog = false + userDomain = null + mainScaffoldParams.showSuccessSnackbar( + "Пользователь успешно удален", + ) + }, + onError = { + mainScaffoldParams.showErrorSnackbar( + "Не удалось удалить пользователя", + ) + }, + ) + updateUserDomainList() + } + } + }, + title = "Удаление пользователя", + ), + dismissButtonText = "Отмена", + confirmButtonText = "Удалить", + ) + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/resources/index.html b/composeApp/src/wasmJsMain/resources/index.html new file mode 100644 index 0000000..5a33f7b --- /dev/null +++ b/composeApp/src/wasmJsMain/resources/index.html @@ -0,0 +1,15 @@ + + + + + + + Помощник пчеловода + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/resources/styles.css b/composeApp/src/wasmJsMain/resources/styles.css new file mode 100644 index 0000000..ed3dd2e --- /dev/null +++ b/composeApp/src/wasmJsMain/resources/styles.css @@ -0,0 +1,74 @@ +html { + margin: 0 !important; + padding: 0 !important; + width: 100% !important; + height: 100% !important; + overflow: hidden !important; + color-scheme: dark !important; +} + +body { + position: fixed !important; + top: 0 !important; + right: 0 !important; + bottom: 0 !important; + left: 0 !important; + margin: 0 !important; + z-index: 9999 !important; +} + +canvas { + outline: none !important; +} + +div.blank { + position: fixed !important; + top: 0 !important; + right: 0 !important; + bottom: 0 !important; + left: 0 !important; + display: flex !important; + justify-content: center !important; + align-items: center !important; + backdrop-filter: brightness(.5) !important; + z-index: 99999 !important; + user-select: none !important; +} + +div.container { + display: flex !important; + gap: 1.5rem !important; + flex-direction: column !important; + margin: 1rem !important; + padding: 1.25rem 1rem !important; + width: 100% !important; + max-width: 34.25rem !important; + border-radius: 1rem !important; + background-color: #141218 !important; +} + +input.text-field, +textarea.text-field { + box-sizing: border-box !important; + padding: 1.125rem !important; + width: 100% !important; + border: solid .0625rem #938F99 !important; + border-radius: .25rem !important; + outline: none !important; + background-color: transparent !important; + color: #E6E0E9 !important; + font-family: 'Roboto', 'Helvetica', 'Arial', 'Verdana', sans-serif !important; + font-size: 1rem !important; +} + +input[type="button"] { + padding: .875rem !important; + font-family: 'Roboto', 'Helvetica', 'Arial', 'Verdana', sans-serif !important; + font-size: .875rem !important; + font-weight: normal !important; + border-radius: 99rem !important; + border: none !important; + outline: none !important; + background: #D0BCFF !important; + color: #381E72 !important; +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..41d680b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,8 @@ +#Kotlin +kotlin.code.style=official +kotlin.daemon.jvmargs=-Xmx3072M + +#Gradle +org.gradle.jvmargs=-Xmx3072M -Dfile.encoding=UTF-8 +org.gradle.configuration-cache=true +org.gradle.caching=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..248253f --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,51 @@ +[versions] +# Initial +androidx-lifecycle = "2.9.1" +composeMultiplatform = "1.9.0-alpha03" +junit = "4.13.2" +kotlin = "2.2.10" + +# Custom +ktor = "3.2.3" +coil = "3.3.0" +navigation = "2.9.0-beta04" + +[libraries] +# Initial +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +junit = { module = "junit:junit", version.ref = "junit" } +androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } + +# Custom +## Ktor +ktor-clientCore = { module = "io.ktor:ktor-client-core-wasm-js", version.ref = "ktor" } +ktor-clientContentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-clientLogging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-serializationKotlinxJson = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } + +## Coil +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-networkKtor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } + +# Compose Multiplatform +compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } +compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } +compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMultiplatform" } +compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } +compose-ui-util = { module = "org.jetbrains.compose.ui:ui-util", version.ref = "composeMultiplatform" } +compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } +compose-components-ui-tooling-preview = { module = "org.jetbrains.compose.components:components-ui-tooling-preview", version.ref = "composeMultiplatform" } + +## Navigation +navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation" } + +[plugins] +# Initial +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } + +# Custom +ktor = { id = "io.ktor.plugin", version.ref = "ktor" } \ 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..1b33c55 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..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# 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/HEAD/platforms/jvm/plugins-application/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 + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# 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="\\\"\\\"" + + +# 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 + if ! command -v java >/dev/null 2>&1 + then + 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 +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + 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 + + +# 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"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# 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..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@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=. +@rem This is normally unused +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% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 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! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..816dedf --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,31 @@ +rootProject.name = "frontend" +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +include(":composeApp") \ No newline at end of file