Проект пересобран без модуля desktopMain

This commit is contained in:
2025-09-11 14:30:51 +03:00
commit 94b6a6758b
63 changed files with 5030 additions and 0 deletions
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFF" android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFF" android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFF" android:pathData="M19,2h-4.18C14.4,0.84 13.3,0 12,0c-1.3,0 -2.4,0.84 -2.82,2L5,2c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,4c0,-1.1 -0.9,-2 -2,-2zM12,2c0.55,0 1,0.45 1,1s-0.45,1 -1,1 -1,-0.45 -1,-1 0.45,-1 1,-1zM19,20L5,20L5,4h2v3h10L17,4h2v16z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFF" android:pathData="M2,15.5v2h20v-2L2,15.5zM2,10.5v2h20v-2L2,10.5zM2,5.5v2h20v-2L2,5.5z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFF" android:pathData="M11,7h2v2h-2V7zM11,11h2v6h-2V11zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8s8,3.59 8,8S16.41,20 12,20z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFF" android:pathData="M10,8m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0"/>
<path android:fillColor="#FFFFFF" android:pathData="M10.67,13.02C10.45,13.01 10.23,13 10,13c-2.42,0 -4.68,0.67 -6.61,1.82C2.51,15.34 2,16.32 2,17.35V20h9.26C10.47,18.87 10,17.49 10,16C10,14.93 10.25,13.93 10.67,13.02z"/>
<path android:fillColor="#FFFFFF" android:pathData="M20.75,16c0,-0.22 -0.03,-0.42 -0.06,-0.63l1.14,-1.01l-1,-1.73l-1.45,0.49c-0.32,-0.27 -0.68,-0.48 -1.08,-0.63L18,11h-2l-0.3,1.49c-0.4,0.15 -0.76,0.36 -1.08,0.63l-1.45,-0.49l-1,1.73l1.14,1.01c-0.03,0.21 -0.06,0.41 -0.06,0.63s0.03,0.42 0.06,0.63l-1.14,1.01l1,1.73l1.45,-0.49c0.32,0.27 0.68,0.48 1.08,0.63L16,21h2l0.3,-1.49c0.4,-0.15 0.76,-0.36 1.08,-0.63l1.45,0.49l1,-1.73l-1.14,-1.01C20.72,16.42 20.75,16.22 20.75,16zM17,18c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2s2,0.9 2,2S18.1,18 17,18z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFF" android:pathData="M7.58,4.08L6.15,2.65C3.75,4.48 2.17,7.3 2.03,10.5h2c0.15,-2.65 1.51,-4.97 3.55,-6.42zM19.97,10.5h2c-0.15,-3.2 -1.73,-6.02 -4.12,-7.85l-1.42,1.43c2.02,1.45 3.39,3.77 3.54,6.42zM18,11c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2v-5zM12,22c0.14,0 0.27,-0.01 0.4,-0.04 0.65,-0.14 1.18,-0.58 1.44,-1.18 0.1,-0.24 0.15,-0.5 0.15,-0.78h-4c0.01,1.1 0.9,2 2.01,2z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFF" android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFF" android:pathData="M13,8c0,-2.21 -1.79,-4 -4,-4S5,5.79 5,8s1.79,4 4,4S13,10.21 13,8zM15,10v2h3v3h2v-3h3v-2h-3V7h-2v3H15zM1,18v2h16v-2c0,-2.66 -5.33,-4 -8,-4S1,15.34 1,18z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFF" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM11,19.93c-3.95,-0.49 -7,-3.85 -7,-7.93 0,-0.62 0.08,-1.21 0.21,-1.79L9,15v1c0,1.1 0.9,2 2,2v1.93zM17.9,17.39c-0.26,-0.81 -1,-1.39 -1.9,-1.39h-1v-3c0,-0.55 -0.45,-1 -1,-1L8,12v-2h2c0.55,0 1,-0.45 1,-1L11,7h2c1.1,0 2,-0.9 2,-2v-0.41c2.93,1.19 5,4.06 5,7.41 0,2.08 -0.8,3.97 -2.1,5.39z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="#ffffff" android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="#ffffff" android:pathData="M480,640L280,440L336,382L440,486L440,160L520,160L520,486L624,382L680,440L480,640ZM240,800Q207,800 183.5,776.5Q160,753 160,720L160,600L240,600L240,720Q240,720 240,720Q240,720 240,720L720,720Q720,720 720,720Q720,720 720,720L720,600L800,600L800,720Q800,753 776.5,776.5Q753,800 720,800L240,800Z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#ffffff" android:pathData="M11.71,15.29l2.59,-2.59c0.39,-0.39 0.39,-1.02 0,-1.41L11.71,8.7c-0.63,-0.62 -1.71,-0.18 -1.71,0.71v5.17c0,0.9 1.08,1.34 1.71,0.71z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#ffffff" android:pathData="M9,16.17L5.53,12.7c-0.39,-0.39 -1.02,-0.39 -1.41,0 -0.39,0.39 -0.39,1.02 0,1.41l4.18,4.18c0.39,0.39 1.02,0.39 1.41,0L20.29,7.71c0.39,-0.39 0.39,-1.02 0,-1.41 -0.39,-0.39 -1.02,-0.39 -1.41,0L9,16.17z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#ffffff" android:pathData="M18.3,5.71c-0.39,-0.39 -1.02,-0.39 -1.41,0L12,10.59 7.11,5.7c-0.39,-0.39 -1.02,-0.39 -1.41,0 -0.39,0.39 -0.39,1.02 0,1.41L10.59,12 5.7,16.89c-0.39,0.39 -0.39,1.02 0,1.41 0.39,0.39 1.02,0.39 1.41,0L12,13.41l4.89,4.89c0.39,0.39 1.02,0.39 1.41,0 0.39,-0.39 0.39,-1.02 0,-1.41L13.41,12l4.89,-4.89c0.38,-0.38 0.38,-1.02 0,-1.4z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#ffffff" android:pathData="M15,20H5V7c0,-0.55 -0.45,-1 -1,-1h0C3.45,6 3,6.45 3,7v13c0,1.1 0.9,2 2,2h10c0.55,0 1,-0.45 1,-1v0C16,20.45 15.55,20 15,20zM20,16V4c0,-1.1 -0.9,-2 -2,-2H9C7.9,2 7,2.9 7,4v12c0,1.1 0.9,2 2,2h9C19.1,18 20,17.1 20,16zM18,16H9V4h9V16z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#ffffff" android:pathData="M10.67,13.02C10.45,13.01 10.23,13 10,13c-2.42,0 -4.68,0.67 -6.61,1.82C2.51,15.34 2,16.32 2,17.35V19c0,0.55 0.45,1 1,1h8.26C10.47,18.87 10,17.49 10,16C10,14.93 10.25,13.93 10.67,13.02z"/>
<path android:fillColor="#ffffff" android:pathData="M10,8m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0"/>
<path android:fillColor="#ffffff" android:pathData="M20.75,16c0,-0.22 -0.03,-0.42 -0.06,-0.63l0.84,-0.73c0.18,-0.16 0.22,-0.42 0.1,-0.63l-0.59,-1.02c-0.12,-0.21 -0.37,-0.3 -0.59,-0.22l-1.06,0.36c-0.32,-0.27 -0.68,-0.48 -1.08,-0.63l-0.22,-1.09c-0.05,-0.23 -0.25,-0.4 -0.49,-0.4h-1.18c-0.24,0 -0.44,0.17 -0.49,0.4l-0.22,1.09c-0.4,0.15 -0.76,0.36 -1.08,0.63l-1.06,-0.36c-0.23,-0.08 -0.47,0.02 -0.59,0.22l-0.59,1.02c-0.12,0.21 -0.08,0.47 0.1,0.63l0.84,0.73c-0.03,0.21 -0.06,0.41 -0.06,0.63s0.03,0.42 0.06,0.63l-0.84,0.73c-0.18,0.16 -0.22,0.42 -0.1,0.63l0.59,1.02c0.12,0.21 0.37,0.3 0.59,0.22l1.06,-0.36c0.32,0.27 0.68,0.48 1.08,0.63l0.22,1.09c0.05,0.23 0.25,0.4 0.49,0.4h1.18c0.24,0 0.44,-0.17 0.49,-0.4l0.22,-1.09c0.4,-0.15 0.76,-0.36 1.08,-0.63l1.06,0.36c0.23,0.08 0.47,-0.02 0.59,-0.22l0.59,-1.02c0.12,-0.21 0.08,-0.47 -0.1,-0.63l-0.84,-0.73C20.72,16.42 20.75,16.22 20.75,16zM17,18c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2s2,0.9 2,2S18.1,18 17,18z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#ffffff" android:pathData="M19.5,12c0,-0.23 -0.01,-0.45 -0.03,-0.68l1.86,-1.41c0.4,-0.3 0.51,-0.86 0.26,-1.3l-1.87,-3.23c-0.25,-0.44 -0.79,-0.62 -1.25,-0.42l-2.15,0.91c-0.37,-0.26 -0.76,-0.49 -1.17,-0.68l-0.29,-2.31C14.8,2.38 14.37,2 13.87,2h-3.73C9.63,2 9.2,2.38 9.14,2.88L8.85,5.19c-0.41,0.19 -0.8,0.42 -1.17,0.68L5.53,4.96c-0.46,-0.2 -1,-0.02 -1.25,0.42L2.41,8.62c-0.25,0.44 -0.14,0.99 0.26,1.3l1.86,1.41C4.51,11.55 4.5,11.77 4.5,12s0.01,0.45 0.03,0.68l-1.86,1.41c-0.4,0.3 -0.51,0.86 -0.26,1.3l1.87,3.23c0.25,0.44 0.79,0.62 1.25,0.42l2.15,-0.91c0.37,0.26 0.76,0.49 1.17,0.68l0.29,2.31C9.2,21.62 9.63,22 10.13,22h3.73c0.5,0 0.93,-0.38 0.99,-0.88l0.29,-2.31c0.41,-0.19 0.8,-0.42 1.17,-0.68l2.15,0.91c0.46,0.2 1,0.02 1.25,-0.42l1.87,-3.23c0.25,-0.44 0.14,-0.99 -0.26,-1.3l-1.86,-1.41C19.49,12.45 19.5,12.23 19.5,12zM12.04,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5s1.57,-3.5 3.5,-3.5s3.5,1.57 3.5,3.5S13.97,15.5 12.04,15.5z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="#FFFFFF" android:pathData="M440,520L440,520Q440,520 440,520Q440,520 440,520L440,520L440,520Q440,520 440,520Q440,520 440,520L440,520L440,520Q440,520 440,520Q440,520 440,520L440,520Q440,520 440,520Q440,520 440,520L440,520Q440,520 440,520Q440,520 440,520L440,520L440,520ZM120,840Q87,840 63.5,816.5Q40,793 40,760L40,280Q40,247 63.5,223.5Q87,200 120,200L246,200L296,146Q307,134 322.5,127Q338,120 355,120L520,120Q537,120 548.5,131.5Q560,143 560,160L560,160Q560,177 548.5,188.5Q537,200 520,200L355,200L282,280L120,280Q120,280 120,280Q120,280 120,280L120,760Q120,760 120,760Q120,760 120,760L760,760Q760,760 760,760Q760,760 760,760L760,440Q760,423 771.5,411.5Q783,400 800,400L800,400Q817,400 828.5,411.5Q840,423 840,440L840,760Q840,793 816.5,816.5Q793,840 760,840L120,840ZM760,200L720,200Q703,200 691.5,188.5Q680,177 680,160Q680,143 691.5,131.5Q703,120 720,120L760,120L760,80Q760,63 771.5,51.5Q783,40 800,40Q817,40 828.5,51.5Q840,63 840,80L840,120L880,120Q897,120 908.5,131.5Q920,143 920,160Q920,177 908.5,188.5Q897,200 880,200L840,200L840,240Q840,257 828.5,268.5Q817,280 800,280Q783,280 771.5,268.5Q760,257 760,240L760,200ZM440,700Q515,700 567.5,647.5Q620,595 620,520Q620,445 567.5,392.5Q515,340 440,340Q365,340 312.5,392.5Q260,445 260,520Q260,595 312.5,647.5Q365,700 440,700ZM440,620Q398,620 369,591Q340,562 340,520Q340,478 369,449Q398,420 440,420Q482,420 511,449Q540,478 540,520Q540,562 511,591Q482,620 440,620Z"/>
</vector>
@@ -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<Models.User.Domain?>(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<String?>(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,
)
}
}
}
}
}
}
}
}
}
}
@@ -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<Models.User.DTO.Get>) -> 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<Models.UserRequest.DTO.Get>) -> 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<Models.Municipality.DTO.Get>) -> 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()
}
}
}
}
}
@@ -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
}
}
@@ -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<Image.Domain> = 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<Image.DTO.Get> = 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<Int>,
)
@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,
)
}
}
}
}
}
@@ -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<Nothing?>()
}
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()
}
}
@@ -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()
}
}
@@ -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<String, Boolean?>,
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,
)
},
)
}
}
}
@@ -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,
)
}
}
@@ -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 <T>MainColumnList(
modifier: Modifier = Modifier,
paddingValues: PaddingValues = PaddingValues(),
onScrollChange: ((Boolean) -> Unit)? = null,
valueList: List<T>,
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)
}
}
}
}
}
@@ -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,
),
)
}
@@ -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)
}
}
}
}
@@ -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 <T>DropdownField(
modifier: Modifier = Modifier,
valueList: List<T>,
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
}
)
}
}
}
}
@@ -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<Models.Image.Domain>,
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,
)
}
}
@@ -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,
),
)
}
@@ -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,
)
}
}
}
@@ -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()
}
}
},
)
}
@@ -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,
)
}
}
@@ -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,
)
}
}
@@ -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,
// )
// }
// }
//}
@@ -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
}
},
)
}
@@ -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,
)
}
}
}
@@ -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,
)
}
}
}
@@ -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,
)
}
}
}
}
}
@@ -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,
)
}
}
@@ -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<Models.Municipality.Domain>,
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. Скопируйте ссылку и передайте ее владельцу",
)
}
}
}
}
@@ -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<Models.Image.Domain?>(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
},
)
}
}
}
@@ -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<Models.User.Domain>()) }
var municipalityDomainList by remember { mutableStateOf(listOf<Models.Municipality.Domain>()) }
var userDomainCard by remember { mutableStateOf<Models.User.Domain?>(null) }
var userDomainDialog by remember { mutableStateOf<Models.User.Domain?>(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 = "Отправить",
)
}
}
@@ -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<Models.User.Domain?>(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(
"Не удалось применить настройки пользователя"
)
},
)
}
}
},
)
}
}
}
@@ -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<Models.Municipality.Domain>()) }
var userDomain by remember { mutableStateOf<Models.User.Domain?>(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,
)
}
}
@@ -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<Models.UserRequest.Domain>()) }
var userRequestDomain by remember { mutableStateOf<Models.UserRequest.Domain?>(null) }
var userRequestDialogParams by remember { mutableStateOf<UserRequestDialogParams?>(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<Boolean?>(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 = "Отправить",
)
}
}
@@ -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<Models.User.Domain>()) }
var municipalityDomainList by remember { mutableStateOf(listOf<Models.Municipality.Domain>()) }
var userDomain by remember { mutableStateOf<Models.User.Domain?>(null) }
var userDialogParams by remember { mutableStateOf<UserDialogParams?>(null) }
var confirmOnlyDialog by remember { mutableStateOf(false) }
var floatingActionButton by remember { mutableStateOf(true) }
var deletedFilter by remember { mutableStateOf<Boolean?>(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 = "Удалить",
)
}
}
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="ru-RU">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0" />
<meta name="color-scheme" content="dark" />
<title>Помощник пчеловода</title>
<link type="text/css" rel="stylesheet" href="/styles.css" />
<script src="https://telegram.org/js/telegram-web-app.js?57"></script>
<script>const getTelegramInitData = () => window.Telegram.WebApp.initData</script>
<script type="application/javascript" src="/composeApp.js"></script>
</head>
<body>
</body>
</html>
@@ -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;
}