Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# Android-приложение «Чек-листы»
|
||||
|
||||
Нативное приложение (Kotlin, Jetpack Compose) для техников и инженеров. Работает с API сервера (`/api/v1`),
|
||||
описание — [../docs/API.md](../docs/API.md). Распространяется APK-файлом (Google Play не нужен).
|
||||
|
||||
## Возможности
|
||||
- Вход по адресу сервера, логину и паролю; токены хранятся в шифрованном хранилище (Android Keystore),
|
||||
access-токен обновляется автоматически.
|
||||
- Список нарядов: фильтры (активные / мои / на проверку / завершённые), поиск, прогресс, срок, признак просрочки.
|
||||
- Выполнение наряда: все типы ответов (статус, список, число с контролем нормы, текст), фото с камеры
|
||||
(уменьшается до 1600 px), комментарии, «Взять в работу», «Сдать наряд».
|
||||
- Проверка для инженеров: «Принять» / «Вернуть» с комментарием.
|
||||
- **Работа без связи.** Наряды и чек-листы хранятся на устройстве (Room). Ответы, фото, комментарии и сдача
|
||||
накапливаются и уходят на сервер сами при появлении сети: сначала фото (с `Idempotency-Key`, повтор не создаёт
|
||||
дубликат), затем ответы пачкой через `POST /orders/{id}/sync`. Неотправленное помечено «⟳». Если на сервере уже
|
||||
есть более свежий ответ — побеждает он, а приложение сообщит об этом.
|
||||
- **Регламентное обслуживание.** Наряды этого типа показывают пункт «⏱ наработка машины» и пункты «🔧 узел — обслужено?» с остатком по каждому узлу. Инженеры, модераторы и админы
|
||||
открывают экран «Состояние узлов» (значок гаечного ключа): узлы со статусами Требуется / Скоро / В норме, остаток и прогноз, внесение наработки и ручной сброс узла.
|
||||
Отчёт хранится на устройстве и виден без связи; внесение наработки и сброс требуют связи.
|
||||
- Уведомления о новых нарядах, проверках и возвратах: фоновая проверка раз в ~15 минут и при открытии
|
||||
приложения (WorkManager). FCM-push можно добавить позже (нужен проект Firebase).
|
||||
|
||||
## Сборка
|
||||
Нужны JDK 17 и Android SDK 34 (путь — в `local.properties`, `sdk.dir=...`).
|
||||
|
||||
```powershell
|
||||
cd android
|
||||
$env:JAVA_HOME = "<путь к JDK 17>"
|
||||
.\gradlew.bat assembleDebug # app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### Подписанный релиз
|
||||
1. Создайте ключ (один раз, **храните его и пароли в надёжном месте** — без него приложение нельзя будет обновлять поверх):
|
||||
```
|
||||
keytool -genkeypair -v -keystore keystore/release.jks -alias checklists -keyalg RSA -keysize 2048 -validity 36500
|
||||
```
|
||||
2. Создайте `android/keystore.properties` (в git не попадает):
|
||||
```
|
||||
storeFile=keystore/release.jks
|
||||
storePassword=...
|
||||
keyAlias=checklists
|
||||
keyPassword=...
|
||||
```
|
||||
3. `.\gradlew.bat assembleRelease` → `app/build/outputs/apk/release/app-release.apk`.
|
||||
|
||||
## Установка на телефон
|
||||
1. Соберите подписанный APK (см. выше). Чтобы он раздавался с сервера, замените файл в `release/` (и передеплойте) либо положите
|
||||
его на сервер в `DATA_DIR/apk/` (без передеплоя; в Docker — том `appdata`, путь `apk/`).
|
||||
Скачивание: `https://<домен>/app.apk` (также ссылка на экране входа веб-интерфейса). Либо просто передайте файл.
|
||||
2. На телефоне откройте файл; Android попросит разрешить установку из этого источника.
|
||||
3. При первом входе введите адрес сервера (`https://…`), логин и пароль, разрешите уведомления.
|
||||
|
||||
Обновление: соберите APK с большим `versionCode` (в `app/build.gradle.kts`), **подписанный тем же ключом**, и установите поверх.
|
||||
Ключ `keystore/release.jks` и `keystore.properties` не хранятся в git — сделайте их резервную копию.
|
||||
|
||||
## Безопасность соединения
|
||||
Релизная сборка работает **только по HTTPS** (`network_security_config.xml`, на экране входа адрес `http://` отклоняется). В отладочной сборке
|
||||
открытый HTTP разрешён лишь для `10.0.2.2`, `localhost` и `127.0.0.1` (`app/src/debug/res/xml/`).
|
||||
|
||||
## Тест на эмуляторе
|
||||
Адрес сервера на хосте с эмулятора — `http://10.0.2.2:8000` (только в debug-сборке: `assembleDebug`). Для показа удобно включить на сервере демо-режим.
|
||||
@@ -0,0 +1,88 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
// Подпись релиза: android/keystore.properties (не в git). Без него release собирается неподписанным.
|
||||
val keystoreProps = Properties().apply {
|
||||
val f = rootProject.file("keystore.properties")
|
||||
if (f.exists()) f.inputStream().use { load(it) }
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.checklists.app"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ru.checklists.app"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 14
|
||||
versionName = "1.9.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystoreProps.containsKey("storeFile")) {
|
||||
create("release") {
|
||||
storeFile = rootProject.file(keystoreProps.getProperty("storeFile"))
|
||||
storePassword = keystoreProps.getProperty("storePassword")
|
||||
keyAlias = keystoreProps.getProperty("keyAlias")
|
||||
keyPassword = keystoreProps.getProperty("keyPassword")
|
||||
enableV1Signing = true // подписи v1+v2+v3: максимальная совместимость при установке
|
||||
enableV2Signing = true
|
||||
enableV3Signing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
signingConfigs.findByName("release")?.let { signingConfig = it }
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
composeOptions { kotlinCompilerExtensionVersion = "1.5.14" }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions { jvmTarget = "17" }
|
||||
packaging { resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform("androidx.compose:compose-bom:2024.06.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-core")
|
||||
implementation("androidx.compose.foundation:foundation")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.activity:activity-compose:1.9.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.2")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||
implementation("androidx.exifinterface:exifinterface:1.3.7")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
||||
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
||||
implementation("io.coil-kt:coil-compose:2.6.0")
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Отладочная сборка: HTTP разрешён только для эмулятора и локального компьютера. -->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="false"
|
||||
android:theme="@style/Theme.Checklists"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,79 @@
|
||||
package ru.checklists.app
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import ru.checklists.app.data.Prefs
|
||||
import ru.checklists.app.data.Repository
|
||||
import ru.checklists.app.data.api.ApiClient
|
||||
import ru.checklists.app.data.db.AppDb
|
||||
import ru.checklists.app.sync.SyncWorker
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class App : Application() {
|
||||
lateinit var repo: Repository
|
||||
private set
|
||||
/** Приложение на экране — тогда уведомления о новых нарядах не нужны (список обновится сам). */
|
||||
@Volatile var inForeground = false
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
val prefs = Prefs(this)
|
||||
val client = ApiClient(prefs)
|
||||
repo = Repository(this, prefs, client, AppDb.create(this), ::notifyOrder)
|
||||
Coil.setImageLoader(ImageLoader.Builder(this).okHttpClient(client.http).crossfade(true).build())
|
||||
createChannel()
|
||||
// Фоновая проверка новых нарядов раз в 15 минут (минимум для WorkManager).
|
||||
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||
"sync-periodic", ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()).build(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, "Наряды", NotificationManager.IMPORTANCE_HIGH).apply { description = "Новые наряды, проверки, возвраты" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun notifyOrder(title: String, text: String, orderId: Long) {
|
||||
if (inForeground) return
|
||||
if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
|
||||
val open = PendingIntent.getActivity(
|
||||
this, orderId.toInt(), Intent(this, MainActivity::class.java).putExtra(EXTRA_ORDER, orderId).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val n = NotificationCompat.Builder(this, CHANNEL).setSmallIcon(android.R.drawable.ic_dialog_info).setContentTitle(title)
|
||||
.setContentText(text).setStyle(NotificationCompat.BigTextStyle().bigText(text)).setAutoCancel(true).setContentIntent(open).build()
|
||||
NotificationManagerCompat.from(this).notify(orderId.toInt(), n)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "orders"
|
||||
const val EXTRA_ORDER = "order_id"
|
||||
lateinit var instance: App
|
||||
private set
|
||||
}
|
||||
}
|
||||
|
||||
val Context.repo: Repository get() = (applicationContext as App).repo
|
||||
@@ -0,0 +1,208 @@
|
||||
package ru.checklists.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.checklists.app.ui.AppDrawer
|
||||
import ru.checklists.app.ui.WorkScreen
|
||||
import android.net.Uri
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import ru.checklists.app.data.api.AppVersionDto
|
||||
import ru.checklists.app.ui.AssistantChat
|
||||
import ru.checklists.app.ui.AssistantFab
|
||||
import ru.checklists.app.ui.AssistantScreen
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import kotlinx.coroutines.delay
|
||||
import ru.checklists.app.ui.AppTheme
|
||||
import ru.checklists.app.ui.EquipmentDetailScreen
|
||||
import ru.checklists.app.ui.EquipmentScreen
|
||||
import ru.checklists.app.ui.HistoryScreen
|
||||
import ru.checklists.app.ui.LocalOpenMenu
|
||||
import ru.checklists.app.ui.RequestsScreen
|
||||
import ru.checklists.app.ui.TaskScreen
|
||||
import ru.checklists.app.ui.TasksScreen
|
||||
import ru.checklists.app.ui.ToolsScreen
|
||||
import ru.checklists.app.ui.LoginScreen
|
||||
import ru.checklists.app.ui.OrderScreen
|
||||
import ru.checklists.app.ui.OrdersScreen
|
||||
import ru.checklists.app.ui.SettingsScreen
|
||||
import ru.checklists.app.ui.StateScreen
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val openOrder = mutableStateOf<Long?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
handle(intent)
|
||||
setContent { AppTheme { Root(openOrder) } }
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handle(intent)
|
||||
}
|
||||
|
||||
private fun handle(i: Intent?) {
|
||||
val id = i?.getLongExtra(App.EXTRA_ORDER, -1L) ?: -1L
|
||||
if (id > 0) openOrder.value = id
|
||||
}
|
||||
|
||||
override fun onStart() { super.onStart(); App.instance.inForeground = true }
|
||||
override fun onStop() { super.onStop(); App.instance.inForeground = false }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Root(pending: MutableState<Long?>) {
|
||||
val repo = androidx.compose.ui.platform.LocalContext.current.repo
|
||||
val nav = rememberNavController()
|
||||
val authLost by repo.client.authLost.collectAsState()
|
||||
val start = if (repo.prefs.loggedIn) "work" else "login"
|
||||
val drawer = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
val entry by nav.currentBackStackEntryAsState()
|
||||
val route = entry?.destination?.route
|
||||
|
||||
// боковое меню вместо верхних и нижних панелей; разделы верхнего уровня открываются из него
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawer,
|
||||
gesturesEnabled = route != null && route != "login",
|
||||
drawerContent = {
|
||||
AppDrawer(route) { r ->
|
||||
scope.launch { drawer.close() }
|
||||
if (r != route) nav.navigate(r) { popUpTo("work") { inclusive = r == "work" }; launchSingleTop = true }
|
||||
}
|
||||
},
|
||||
) {
|
||||
CompositionLocalProvider(LocalOpenMenu provides { scope.launch { drawer.open() } }) {
|
||||
Box {
|
||||
NavHost(nav, startDestination = start) {
|
||||
composable("login") {
|
||||
LoginScreen(onDone = { nav.navigate("work") { popUpTo("login") { inclusive = true } } })
|
||||
}
|
||||
composable("work") {
|
||||
WorkScreen(go = { r -> nav.navigate(r) })
|
||||
}
|
||||
composable("orders") {
|
||||
OrdersScreen(onOpen = { nav.navigate("order/$it") })
|
||||
}
|
||||
composable("order/{id}", arguments = listOf(navArgument("id") { type = NavType.LongType })) { e ->
|
||||
OrderScreen(e.arguments!!.getLong("id"), onBack = { nav.popBackStack() })
|
||||
}
|
||||
composable("state") { StateScreen() }
|
||||
composable("settings") {
|
||||
SettingsScreen(onLoggedOut = { nav.navigate("login") { popUpTo(0) } })
|
||||
}
|
||||
composable("tasks") { TasksScreen(onOpen = { nav.navigate("task/$it") }) }
|
||||
composable("task/{id}", arguments = listOf(navArgument("id") { type = NavType.LongType })) { e ->
|
||||
TaskScreen(e.arguments!!.getLong("id"), onBack = { nav.popBackStack() })
|
||||
}
|
||||
composable("equipment") { EquipmentScreen(onOpen = { nav.navigate("equipment/$it") }) }
|
||||
composable("cranes") { EquipmentScreen(cranes = true, onOpen = { nav.navigate("equipment/$it") }) }
|
||||
composable("equipment/{id}", arguments = listOf(navArgument("id") { type = NavType.LongType })) { e ->
|
||||
val eid = e.arguments!!.getLong("id")
|
||||
EquipmentDetailScreen(eid, onBack = { nav.popBackStack() }, onHistory = { nav.navigate("history/$eid") })
|
||||
}
|
||||
composable("history/{id}", arguments = listOf(navArgument("id") { type = NavType.LongType })) { e ->
|
||||
HistoryScreen(e.arguments!!.getLong("id"), onBack = { nav.popBackStack() })
|
||||
}
|
||||
composable("tools") { ToolsScreen() }
|
||||
composable("requests") { RequestsScreen() }
|
||||
composable("assistant") {
|
||||
AssistantScreen(onBack = { nav.popBackStack() }, go = { r -> nav.navigate(r) { launchSingleTop = true } })
|
||||
}
|
||||
}
|
||||
// кнопка помощника — на всех экранах, если администратор его включил
|
||||
if (AssistantChat.enabled && route != null && route != "login" && route != "assistant")
|
||||
AssistantFab { nav.navigate("assistant") { launchSingleTop = true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// проверка обновления приложения при старте: на сервере лежит APK новее установленного — предлагаем скачать
|
||||
var update by remember { mutableStateOf<AppVersionDto?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
if (repo.prefs.baseUrl.isNotEmpty()) runCatching { repo.client.api().appVersion() }.getOrNull()?.let { v ->
|
||||
if (v.available && v.version != null && isNewer(v.version, BuildConfig.VERSION_NAME)) update = v
|
||||
}
|
||||
}
|
||||
update?.let { v ->
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
AlertDialog(onDismissRequest = { update = null }, title = { Text("Доступно обновление") },
|
||||
text = { Text("На сервере версия ${v.version}, у вас ${BuildConfig.VERSION_NAME}. Скачайте APK и установите его поверх — данные сохранятся." +
|
||||
if (v.size > 0) " Размер: ${"%.1f".format(v.size / 1048576.0)} МБ." else "") },
|
||||
confirmButton = { TextButton({
|
||||
update = null
|
||||
ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(repo.prefs.baseUrl + v.url)))
|
||||
}) { Text("Скачать") } },
|
||||
dismissButton = { TextButton({ update = null }) { Text("Позже") } })
|
||||
}
|
||||
|
||||
// включён ли помощник на сервере: проверяем при входе и раз в минуту
|
||||
LaunchedEffect(route == "login") {
|
||||
while (true) {
|
||||
if (repo.prefs.loggedIn) AssistantChat.enabled = runCatching { repo.client.api().assistantStatus().enabled }.getOrDefault(AssistantChat.enabled)
|
||||
else { AssistantChat.enabled = false; AssistantChat.messages.clear() }
|
||||
delay(60_000)
|
||||
}
|
||||
}
|
||||
|
||||
// пользователя перевели в демо или вернули к рабочим данным
|
||||
val modeMsg by repo.modeChanged.collectAsState()
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
LaunchedEffect(modeMsg) {
|
||||
modeMsg?.let { ru.checklists.app.ui.toast(ctx, it); repo.modeChanged.value = null }
|
||||
}
|
||||
|
||||
// refresh-токен отозван/истёк — требуется повторный вход
|
||||
LaunchedEffect(authLost) {
|
||||
if (authLost) {
|
||||
repo.me.value = null
|
||||
nav.navigate("login") { popUpTo(0) }
|
||||
}
|
||||
}
|
||||
// переход из уведомления
|
||||
LaunchedEffect(pending.value) {
|
||||
val id = pending.value
|
||||
if (id != null && repo.prefs.loggedIn) {
|
||||
nav.navigate("order/$id")
|
||||
pending.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "1.10.0" новее "1.9.2": сравниваем числа по частям. */
|
||||
fun isNewer(server: String, current: String): Boolean {
|
||||
val a = server.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val b = current.substringBefore("-").split(".").map { it.toIntOrNull() ?: 0 }
|
||||
for (i in 0 until maxOf(a.size, b.size)) {
|
||||
val x = a.getOrElse(i) { 0 }; val y = b.getOrElse(i) { 0 }
|
||||
if (x != y) return x > y
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package ru.checklists.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
/** Токены и адрес сервера. Шифруется ключом Android Keystore; если Keystore недоступен — обычные prefs. */
|
||||
class Prefs(ctx: Context) {
|
||||
private val sp: SharedPreferences = try {
|
||||
val key = MasterKey.Builder(ctx).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
||||
EncryptedSharedPreferences.create(
|
||||
ctx, "secure_prefs", key,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ctx.getSharedPreferences("plain_prefs", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
var baseUrl: String
|
||||
get() = sp.getString("base_url", "") ?: ""
|
||||
set(v) = sp.edit().putString("base_url", normalizeUrl(v)).apply()
|
||||
|
||||
var access: String?
|
||||
get() = sp.getString("access", null)
|
||||
set(v) = sp.edit().putString("access", v).apply()
|
||||
|
||||
var refresh: String?
|
||||
get() = sp.getString("refresh", null)
|
||||
set(v) = sp.edit().putString("refresh", v).apply()
|
||||
|
||||
var meJson: String?
|
||||
get() = sp.getString("me", null)
|
||||
set(v) = sp.edit().putString("me", v).apply()
|
||||
|
||||
var lastSync: Long
|
||||
get() = sp.getLong("last_sync", 0)
|
||||
set(v) = sp.edit().putLong("last_sync", v).apply()
|
||||
|
||||
/** Первая синхронизация не шлёт уведомления (иначе придёт «пачка» старых нарядов). */
|
||||
var initialSyncDone: Boolean
|
||||
get() = sp.getBoolean("initial_done", false)
|
||||
set(v) = sp.edit().putBoolean("initial_done", v).apply()
|
||||
|
||||
/** id последнего вошедшего пользователя. Не стирается при потере сессии: после повторного входа под тем же
|
||||
* пользователем неотправленные данные сохраняются, при входе под другим — локальная база очищается. */
|
||||
var lastUserId: Long
|
||||
get() = sp.getLong("last_user", -1)
|
||||
set(v) = sp.edit().putLong("last_user", v).apply()
|
||||
|
||||
/** Режим сервера при последней проверке: -1 неизвестно, 0 рабочие данные, 1 демо. */
|
||||
var lastDemo: Int
|
||||
get() = sp.getInt("last_demo", -1)
|
||||
set(v) = sp.edit().putInt("last_demo", v).apply()
|
||||
|
||||
val loggedIn get() = !refresh.isNullOrEmpty() && baseUrl.isNotEmpty()
|
||||
|
||||
/** Последний полученный отчёт «Состояние узлов» (для просмотра без связи). */
|
||||
var stateJson: String?
|
||||
get() = sp.getString("state_json", null)
|
||||
set(v) = sp.edit().putString("state_json", v).apply()
|
||||
|
||||
fun clearAuth() {
|
||||
sp.edit().remove("access").remove("refresh").remove("me").remove("last_sync").remove("initial_done").remove("state_json").apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun normalizeUrl(raw: String): String {
|
||||
var u = raw.trim().trimEnd('/')
|
||||
if (u.isEmpty()) return ""
|
||||
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
|
||||
return u
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package ru.checklists.app.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.withTransaction
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import retrofit2.HttpException
|
||||
import ru.checklists.app.data.api.*
|
||||
import ru.checklists.app.data.db.*
|
||||
import ru.checklists.app.sync.SyncWorker
|
||||
import ru.checklists.app.util.downscaleJpeg
|
||||
import ru.checklists.app.util.isoOf
|
||||
import ru.checklists.app.util.parseMillis
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class Repository(
|
||||
private val ctx: Context,
|
||||
val prefs: Prefs,
|
||||
val client: ApiClient,
|
||||
val db: AppDb,
|
||||
private val notify: (title: String, text: String, orderId: Long) -> Unit,
|
||||
) {
|
||||
val dao = db.dao()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val mutex = Mutex()
|
||||
|
||||
val syncing = MutableStateFlow(false)
|
||||
val lastSync = MutableStateFlow(prefs.lastSync.takeIf { it > 0 })
|
||||
val lastError = MutableStateFlow<String?>(null)
|
||||
val me = MutableStateFlow(prefs.meJson?.let { runCatching { appJson.decodeFromString(MeDto.serializer(), it) }.getOrNull() })
|
||||
|
||||
/** Отчёт «Состояние узлов»: последний полученный (кэш на устройстве) и признак загрузки. */
|
||||
val stateReport = MutableStateFlow(prefs.stateJson?.let { runCatching { appJson.decodeFromString(StateReportDto.serializer(), it) }.getOrNull() })
|
||||
val stateLoading = MutableStateFlow(false)
|
||||
val stateError = MutableStateFlow<String?>(null)
|
||||
|
||||
private val photosDir get() = File(ctx.filesDir, "photos").also { it.mkdirs() }
|
||||
|
||||
// ---------- вход / выход ----------
|
||||
suspend fun login(url: String, user: String, pass: String) {
|
||||
client.login(Prefs.normalizeUrl(url), user, pass)
|
||||
try {
|
||||
refreshMe()
|
||||
val id = me.value!!.id
|
||||
if (prefs.lastUserId != id) wipeLocal() // другой пользователь — чужие данные не оставляем
|
||||
prefs.lastUserId = id
|
||||
} catch (e: Exception) {
|
||||
prefs.clearAuth(); throw e
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unsyncedCount(): Int =
|
||||
dao.allOrders().count { hasUnsynced(it.id) }
|
||||
|
||||
suspend fun logout() {
|
||||
runCatching { prefs.refresh?.let { client.api().logout(RefreshReq(it)) } }
|
||||
prefs.clearAuth()
|
||||
prefs.lastUserId = -1
|
||||
prefs.lastDemo = -1
|
||||
me.value = null
|
||||
wipeLocal()
|
||||
WorkManager.getInstance(ctx).cancelAllWork()
|
||||
}
|
||||
|
||||
private suspend fun wipeLocal() = db.withTransaction {
|
||||
dao.wipeOrders(); dao.wipeItems(); dao.wipePhotos(); dao.wipeComments()
|
||||
photosDir.listFiles()?.forEach { it.delete() }
|
||||
prefs.stateJson = null; stateReport.value = null
|
||||
}
|
||||
|
||||
suspend fun refreshMe() {
|
||||
val m = client.api().me()
|
||||
val demo = if (m.demoMode) 1 else 0
|
||||
if (prefs.lastDemo != -1 && prefs.lastDemo != demo) {
|
||||
// пользователя перевели в демо или вернули: у сервера другая база, локальные наряды от неё не годятся
|
||||
wipeLocal()
|
||||
prefs.initialSyncDone = false
|
||||
prefs.lastUserId = m.id
|
||||
modeChanged.value = if (m.demoMode) "Включён демо-режим: показаны тестовые данные" else "Демо-режим выключен: рабочие данные"
|
||||
}
|
||||
prefs.lastDemo = demo
|
||||
me.value = m
|
||||
prefs.meJson = appJson.encodeToString(MeDto.serializer(), m)
|
||||
}
|
||||
|
||||
/** Сообщение о смене режима (показывается один раз). */
|
||||
val modeChanged = MutableStateFlow<String?>(null)
|
||||
|
||||
/** Выйти из демо (если включил сам или включил администратор) и вернуться к рабочим данным. */
|
||||
suspend fun leaveDemo() {
|
||||
val t = client.api().demoLeave()
|
||||
prefs.access = t.accessToken; prefs.refresh = t.refreshToken
|
||||
mutex.withLock { refreshMe() }
|
||||
syncSoon()
|
||||
}
|
||||
|
||||
// ---------- синхронизация ----------
|
||||
fun syncSoon() {
|
||||
scope.launch { if (!syncAll()) enqueueWorker() }
|
||||
}
|
||||
|
||||
fun enqueueWorker() {
|
||||
val req = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.setBackoffCriteria(androidx.work.BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS).build()
|
||||
WorkManager.getInstance(ctx).enqueueUniqueWork("sync-now", ExistingWorkPolicy.REPLACE, req)
|
||||
}
|
||||
|
||||
/** true — всё отправлено и получено; false — ошибка (причина в lastError). */
|
||||
suspend fun syncAll(): Boolean = mutex.withLock {
|
||||
if (!prefs.loggedIn) return false
|
||||
syncing.value = true
|
||||
try {
|
||||
val api = client.api()
|
||||
// профиль первым: при смене режима (боевой ↔ демо) локальные данные чужой базы не отправляем
|
||||
runCatching { refreshMe() }.onFailure { if (it is CancellationException) throw it }
|
||||
uploadPhotos(api)
|
||||
pushAnswers(api)
|
||||
pushComments(api)
|
||||
pull(api)
|
||||
prefs.lastSync = System.currentTimeMillis(); lastSync.value = prefs.lastSync
|
||||
lastError.value = null
|
||||
true
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
lastError.value = e.humanMessage()
|
||||
false
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun uploadPhotos(api: Api) {
|
||||
for (p in dao.pendingPhotos()) {
|
||||
val f = p.path?.let { File(it) }
|
||||
if (f == null || !f.exists()) { dao.deletePhoto(p.key); continue }
|
||||
try {
|
||||
val dto = api.uploadPhoto(p.orderId, p.itemId, p.key, f.asRequestBody("image/jpeg".toMediaType()))
|
||||
db.withTransaction { dao.deletePhoto(p.key); mergeOrder(dto) }
|
||||
f.delete()
|
||||
} catch (e: HttpException) {
|
||||
if (e.code() in listOf(400, 403, 404, 413, 415)) { // отклонено окончательно — повторять бессмысленно
|
||||
dao.deletePhoto(p.key); f.delete()
|
||||
dao.setSyncError(p.orderId, "Фото не принято сервером: ${e.humanMessage()}")
|
||||
} else throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pushAnswers(api: Api) {
|
||||
for (id in dao.ordersToPush()) {
|
||||
val order = dao.orderOnce(id) ?: continue
|
||||
val dirty = dao.dirtyItems(id)
|
||||
val req = SyncReq(dirty.map { SyncItemReq(it.id, it.value ?: "", it.answeredAt?.let(::isoOf)) }, order.pendingSubmit)
|
||||
try {
|
||||
val res = api.sync(id, req)
|
||||
db.withTransaction {
|
||||
res.results.forEach { dao.markClean(it.itemId) }
|
||||
dao.setPendingSubmit(id, false)
|
||||
mergeOrder(res.order)
|
||||
val notes = res.results.filter { it.status != "ok" }.map { it.detail } + listOfNotNull(res.submitError.ifBlank { null })
|
||||
dao.setSyncError(id, notes.joinToString("; ").ifBlank { null })
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e.code() == 403 || e.code() == 404) { // наряд закрыт или взят другим
|
||||
db.withTransaction {
|
||||
dao.clearDirty(id); dao.setPendingSubmit(id, false)
|
||||
dao.setSyncError(id, "Изменения не приняты: ${e.humanMessage()}")
|
||||
}
|
||||
} else throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pushComments(api: Api) {
|
||||
for (c in dao.pendingComments()) {
|
||||
try {
|
||||
api.addComment(c.orderId, CommentReq(c.text)); dao.deleteComment(c.localId)
|
||||
} catch (e: HttpException) {
|
||||
if (e.code() in listOf(403, 404, 422)) dao.deleteComment(c.localId) else throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pull(api: Api) {
|
||||
val active = api.orders("new,in_progress,review", includeItems = true)
|
||||
val recent = api.orders("completed,cancelled", updatedSince = isoOf(System.currentTimeMillis() - 3L * 86400_000))
|
||||
val before = dao.allOrders().associateBy { it.id }
|
||||
db.withTransaction {
|
||||
(active + recent).forEach { mergeOrder(it) }
|
||||
val keep = (active + recent).map { it.id }
|
||||
for (id in dao.idsNotIn(keep)) if (!hasUnsynced(id)) dropOrder(id)
|
||||
}
|
||||
if (prefs.initialSyncDone) {
|
||||
for (d in active) {
|
||||
val old = before[d.id]
|
||||
when {
|
||||
d.canExecute && old == null -> notify("Новый наряд", d.title + (d.locationName?.let { " · $it" } ?: ""), d.id)
|
||||
d.canReview && old?.canReview != true -> notify("Наряд на проверку", d.title, d.id)
|
||||
d.status == "in_progress" && d.canExecute && d.reviewComment.isNotBlank() && old != null &&
|
||||
old.reviewComment != d.reviewComment -> notify("Наряд возвращён на доработку", "${d.title}: ${d.reviewComment}", d.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
prefs.initialSyncDone = true
|
||||
}
|
||||
|
||||
private suspend fun hasUnsynced(id: Long): Boolean =
|
||||
dao.dirtyItems(id).isNotEmpty() || dao.orderOnce(id)?.pendingSubmit == true ||
|
||||
dao.photosOnce(id).any { it.pending } || dao.pendingComments().any { it.orderId == id }
|
||||
|
||||
private suspend fun dropOrder(id: Long) {
|
||||
dao.deleteOrder(id); dao.deleteItems(id); dao.deletePhotos(id); dao.deleteComments(id)
|
||||
}
|
||||
|
||||
/** Слияние серверного состояния наряда с локальным: неотправленные локальные ответы не затираются. */
|
||||
private suspend fun mergeOrder(d: OrderDto) {
|
||||
val old = dao.orderOnce(d.id)
|
||||
dao.putOrder(
|
||||
OrderEntity(
|
||||
id = d.id, title = d.title, status = d.status, isOverdue = d.isOverdue, equipmentName = d.equipmentName,
|
||||
locationName = d.locationName, assigneeName = d.assigneeName, reviewerName = d.reviewerName,
|
||||
executorId = d.executorId, executorName = d.executorName, reviewComment = d.reviewComment,
|
||||
createdAt = d.createdAt, dueAt = d.dueAt, startedAt = d.startedAt, submittedAt = d.submittedAt,
|
||||
completedAt = d.completedAt, commentsCount = d.commentsCount, canExecute = d.canExecute,
|
||||
canReview = d.canReview, hasItems = d.items != null || old?.hasItems == true,
|
||||
pendingSubmit = old?.pendingSubmit ?: false, syncError = old?.syncError,
|
||||
)
|
||||
)
|
||||
val items = d.items ?: return
|
||||
val dirty = dao.itemsOnce(d.id).filter { it.dirty }.associateBy { it.id }
|
||||
dao.putItems(items.map { i ->
|
||||
dirty[i.id] ?: ItemEntity(
|
||||
id = i.id, orderId = d.id, position = i.position, text = i.text, answerType = i.answerType,
|
||||
optionsJson = i.options?.let { appJson.encodeToString(ListSerializer(String.serializer()), it) },
|
||||
unit = i.unit, minValue = i.minValue, maxValue = i.maxValue, required = i.required, photoMode = i.photo,
|
||||
value = i.value, answeredAt = parseMillis(i.answeredAt), outOfRange = i.outOfRange,
|
||||
serviceUnitId = i.serviceUnitId, isMeter = i.isMeter,
|
||||
)
|
||||
})
|
||||
for (i in items) {
|
||||
dao.deleteServerPhotos(d.id, i.id)
|
||||
dao.putPhotos(i.photos.map { PhotoEntity("s${it.id}", d.id, i.id, it.id, null, false, it.userId, it.createdAt) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Подтянуть актуальное состояние наряда и комментарии (при открытии экрана). */
|
||||
suspend fun loadDetails(id: Long) {
|
||||
if (!prefs.loggedIn) return
|
||||
try {
|
||||
val api = client.api()
|
||||
val dto = api.order(id)
|
||||
val cms = api.comments(id)
|
||||
db.withTransaction {
|
||||
mergeOrder(dto)
|
||||
dao.deleteServerComments(id)
|
||||
dao.putComments(cms.map { CommentEntity(serverId = it.id, orderId = id, userName = it.userName, text = it.text, createdAt = it.createdAt, pending = false) })
|
||||
}
|
||||
lastError.value = null
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
lastError.value = e.humanMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- действия пользователя (работают офлайн) ----------
|
||||
suspend fun answer(item: ItemEntity, value: String?) {
|
||||
val v = value?.trim()?.ifEmpty { null }
|
||||
val oor = item.answerType == "number" && v != null && v.replace(',', '.').toDoubleOrNull()?.let { n ->
|
||||
(item.minValue != null && n < item.minValue) || (item.maxValue != null && n > item.maxValue)
|
||||
} == true
|
||||
dao.answer(item.id, v, System.currentTimeMillis(), oor)
|
||||
dao.setSyncError(item.orderId, null)
|
||||
syncSoon()
|
||||
}
|
||||
|
||||
suspend fun addPhoto(orderId: Long, itemId: Long, captured: File) {
|
||||
val dst = File(photosDir, UUID.randomUUID().toString() + ".jpg")
|
||||
downscaleJpeg(captured, dst)
|
||||
captured.delete()
|
||||
dao.putPhoto(PhotoEntity(dst.nameWithoutExtension, orderId, itemId, null, dst.path, true, me.value?.id, isoOf(System.currentTimeMillis())))
|
||||
syncSoon()
|
||||
}
|
||||
|
||||
/** Удаление фото: ожидающее отправки — локально; серверное — только онлайн. */
|
||||
suspend fun deletePhoto(p: PhotoEntity) {
|
||||
if (p.pending) {
|
||||
dao.deletePhoto(p.key); p.path?.let { File(it).delete() }
|
||||
} else {
|
||||
val dto = client.api().deletePhoto(p.serverId!!)
|
||||
db.withTransaction { mergeOrder(dto) }
|
||||
}
|
||||
}
|
||||
|
||||
/** null — принято к отправке; иначе текст ошибки для пользователя. */
|
||||
suspend fun submit(orderId: Long): String? {
|
||||
val items = dao.itemsOnce(orderId)
|
||||
val photos = dao.photosOnce(orderId)
|
||||
val missing = items.count { it.required && !it.isDone() }
|
||||
if (missing > 0) return "Не заполнены обязательные пункты: $missing"
|
||||
if (items.any { it.photoMode == "required" && photos.none { p -> p.itemId == it.id } }) return "Не приложены обязательные фото"
|
||||
dao.setPendingSubmit(orderId, true)
|
||||
dao.setSyncError(orderId, null)
|
||||
syncSoon()
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun start(orderId: Long) {
|
||||
val dto = client.api().start(orderId)
|
||||
db.withTransaction { mergeOrder(dto) }
|
||||
}
|
||||
|
||||
suspend fun review(orderId: Long, approve: Boolean, comment: String) {
|
||||
val dto = client.api().review(orderId, ReviewReq(approve, comment))
|
||||
db.withTransaction { mergeOrder(dto) }
|
||||
}
|
||||
|
||||
// ---------- регламентное обслуживание (нужна связь; последний отчёт хранится на устройстве) ----------
|
||||
suspend fun loadState() {
|
||||
if (!prefs.loggedIn || stateLoading.value) return
|
||||
stateLoading.value = true
|
||||
try {
|
||||
val r = client.api().equipmentState("")
|
||||
stateReport.value = r
|
||||
prefs.stateJson = appJson.encodeToString(StateReportDto.serializer(), r)
|
||||
stateError.value = null
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
stateError.value = if (e is HttpException && e.code() == 403) "Отчёт доступен инженеру, модератору и администратору" else e.humanMessage()
|
||||
} finally {
|
||||
stateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addMeter(equipmentId: Long, hours: Double, correction: Boolean) {
|
||||
client.api().addMeter(equipmentId, MeterReq(hours, correction))
|
||||
loadState()
|
||||
}
|
||||
|
||||
suspend fun resetUnit(unitId: Long, note: String) {
|
||||
client.api().resetUnit(unitId, ResetReq(note = note.trim()))
|
||||
loadState()
|
||||
}
|
||||
|
||||
suspend fun addComment(orderId: Long, text: String) {
|
||||
dao.putComment(CommentEntity(serverId = null, orderId = orderId, userName = me.value?.displayName, text = text.trim(), createdAt = isoOf(System.currentTimeMillis()), pending = true))
|
||||
syncSoon()
|
||||
}
|
||||
}
|
||||
|
||||
fun ItemEntity.isDone() = if (answerType == "status") value == "done" else !value.isNullOrEmpty()
|
||||
|
||||
fun ItemEntity.options(): List<String> =
|
||||
optionsJson?.let { runCatching { appJson.decodeFromString(ListSerializer(String.serializer()), it) }.getOrNull() } ?: emptyList()
|
||||
@@ -0,0 +1,273 @@
|
||||
package ru.checklists.app.data.api
|
||||
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import ru.checklists.app.data.Prefs
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
interface Api {
|
||||
@POST("auth/login")
|
||||
suspend fun login(@Body b: LoginReq): TokenRes
|
||||
|
||||
@POST("auth/logout")
|
||||
suspend fun logout(@Body b: RefreshReq)
|
||||
|
||||
@GET("me")
|
||||
suspend fun me(): MeDto
|
||||
|
||||
@GET("orders")
|
||||
suspend fun orders(
|
||||
@Query("status") status: String? = null,
|
||||
@Query("include_items") includeItems: Boolean = false,
|
||||
@Query("updated_since") updatedSince: String? = null,
|
||||
@Query("limit") limit: Int = 1000,
|
||||
): List<OrderDto>
|
||||
|
||||
@GET("orders/{id}")
|
||||
suspend fun order(@Path("id") id: Long): OrderDto
|
||||
|
||||
@POST("orders/{id}/start")
|
||||
suspend fun start(@Path("id") id: Long): OrderDto
|
||||
|
||||
@POST("orders/{id}/sync")
|
||||
suspend fun sync(@Path("id") id: Long, @Body b: SyncReq): SyncRes
|
||||
|
||||
@POST("orders/{id}/review")
|
||||
suspend fun review(@Path("id") id: Long, @Body b: ReviewReq): OrderDto
|
||||
|
||||
@GET("orders/{id}/comments")
|
||||
suspend fun comments(@Path("id") id: Long): List<CommentDto>
|
||||
|
||||
@POST("orders/{id}/comments")
|
||||
suspend fun addComment(@Path("id") id: Long, @Body b: CommentReq): CommentDto
|
||||
|
||||
@POST("orders/{id}/items/{item}/photos")
|
||||
suspend fun uploadPhoto(
|
||||
@Path("id") id: Long,
|
||||
@Path("item") item: Long,
|
||||
@Header("Idempotency-Key") key: String,
|
||||
@Body body: RequestBody,
|
||||
): OrderDto
|
||||
|
||||
@DELETE("photos/{id}")
|
||||
suspend fun deletePhoto(@Path("id") id: Long): OrderDto
|
||||
|
||||
@POST("push/device")
|
||||
suspend fun registerDevice(@Body b: DeviceReq)
|
||||
|
||||
// регламентное обслуживание (инженер / модератор / админ)
|
||||
@GET("reports/equipment-state")
|
||||
suspend fun equipmentState(@Query("state") state: String = ""): StateReportDto
|
||||
|
||||
@POST("equipment/{id}/meter")
|
||||
suspend fun addMeter(@Path("id") id: Long, @Body b: MeterReq): EquipmentDto
|
||||
|
||||
@POST("service-units/{id}/reset")
|
||||
suspend fun resetUnit(@Path("id") id: Long, @Body b: ResetReq): UnitDto
|
||||
|
||||
// ---------- портал (только онлайн) ----------
|
||||
@GET("tasks")
|
||||
suspend fun tasks(@Query("mine") mine: Boolean = true, @Query("active") active: Boolean = false, @Query("limit") limit: Int = 100,
|
||||
@Query("created_by_me") createdByMe: Boolean? = null, @Query("status") status: String? = null): List<TaskDto>
|
||||
|
||||
@GET("tasks/{id}")
|
||||
suspend fun task(@Path("id") id: Long): TaskDto
|
||||
|
||||
@POST("tasks")
|
||||
suspend fun createTask(@Body b: TaskCreateReq): TaskDto
|
||||
|
||||
@POST("tasks/{id}/status")
|
||||
suspend fun taskStatus(@Path("id") id: Long, @Body b: TaskStatusReq): TaskDto
|
||||
|
||||
@POST("tasks/{id}/photos")
|
||||
suspend fun taskPhoto(@Path("id") id: Long, @Body body: RequestBody): TaskDto
|
||||
|
||||
@GET("equipment")
|
||||
suspend fun equipment(): List<EquipmentItemDto>
|
||||
|
||||
@GET("equipment-health")
|
||||
suspend fun health(): Map<String, HealthDto>
|
||||
|
||||
@GET("equipment/{id}/service-units")
|
||||
suspend fun units(@Path("id") id: Long): List<UnitBriefDto>
|
||||
|
||||
@GET("incidents")
|
||||
suspend fun incidents(@Query("equipment_id") equipmentId: Long? = null, @Query("limit") limit: Int = 30,
|
||||
@Query("status") status: String? = null, @Query("mine") mine: Boolean? = null,
|
||||
@Query("kind") kind: String? = null): List<IncidentDto>
|
||||
|
||||
@POST("incidents/{id}/escalate")
|
||||
suspend fun escalateIncident(@Path("id") id: Long, @Body b: CloseReq): IncidentDto
|
||||
|
||||
@GET("map")
|
||||
suspend fun map(@Query("kind") kind: String = "plant"): MapDto
|
||||
|
||||
@POST("demo/leave")
|
||||
suspend fun demoLeave(): TokenRes
|
||||
|
||||
@POST("feedback")
|
||||
suspend fun feedback(@Body b: FeedbackReq): kotlinx.serialization.json.JsonObject
|
||||
|
||||
@POST("incidents")
|
||||
suspend fun createIncident(@Body b: IncidentReq): IncidentDto
|
||||
|
||||
@POST("incidents/{id}/close")
|
||||
suspend fun closeIncident(@Path("id") id: Long, @Body b: CloseReq): IncidentDto
|
||||
|
||||
@GET("me/inventory")
|
||||
suspend fun myInventory(): List<StockDto>
|
||||
|
||||
@GET("tool-requests")
|
||||
suspend fun toolRequests(@Query("mine") mine: Boolean = true): List<ToolRequestDto>
|
||||
|
||||
@POST("tool-requests")
|
||||
suspend fun createToolRequest(@Body b: ToolRequestReq): ToolRequestDto
|
||||
|
||||
@GET("app/version")
|
||||
suspend fun appVersion(): AppVersionDto
|
||||
|
||||
@GET("assistant/status")
|
||||
suspend fun assistantStatus(): AssistantStatus
|
||||
|
||||
@POST("assistant/chat")
|
||||
suspend fun assistantChat(@Body b: ChatReq): ChatRes
|
||||
|
||||
@POST("tasks/{id}/transfer")
|
||||
suspend fun transferTask(@Path("id") id: Long, @Body b: TransferReq): TaskDto
|
||||
|
||||
@GET("users")
|
||||
suspend fun people(): List<PersonDto>
|
||||
|
||||
@GET("equipment/{id}/history")
|
||||
suspend fun history(@Path("id") id: Long, @Query("types") types: String? = null, @Query("limit") limit: Int = 300): HistoryDto
|
||||
}
|
||||
|
||||
val appJson = Json { ignoreUnknownKeys = true; coerceInputValues = true; encodeDefaults = true; explicitNulls = false }
|
||||
|
||||
/** HTTP-клиент с авторизацией и прозрачным обновлением access-токена по refresh-токену. */
|
||||
class ApiClient(private val prefs: Prefs) {
|
||||
/** true, когда сервер отклонил refresh-токен — нужен повторный вход. */
|
||||
val authLost = MutableStateFlow(false)
|
||||
|
||||
private val plain = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS).readTimeout(60, TimeUnit.SECONDS).writeTimeout(60, TimeUnit.SECONDS).build()
|
||||
|
||||
val http: OkHttpClient = plain.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
val t = prefs.access
|
||||
val req = if (t != null) chain.request().newBuilder().header("Authorization", "Bearer $t").build() else chain.request()
|
||||
chain.proceed(req)
|
||||
}
|
||||
.authenticator(object : Authenticator {
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (response.request.url.encodedPath.contains("/auth/")) return null
|
||||
if (response.priorResponse != null) return null // уже пробовали
|
||||
synchronized(this@ApiClient) {
|
||||
val used = response.request.header("Authorization")?.removePrefix("Bearer ")
|
||||
val cur = prefs.access
|
||||
if (cur != null && cur != used) { // другой запрос уже обновил токен
|
||||
return response.request.newBuilder().header("Authorization", "Bearer $cur").build()
|
||||
}
|
||||
val rt = prefs.refresh ?: return null
|
||||
val body = appJson.encodeToString(RefreshReq.serializer(), RefreshReq(rt)).toRequestBody("application/json".toMediaType())
|
||||
val req = Request.Builder().url(apiBase() + "auth/refresh").post(body).build()
|
||||
return try {
|
||||
plain.newCall(req).execute().use { r ->
|
||||
when {
|
||||
r.isSuccessful -> {
|
||||
val t = appJson.decodeFromString(TokenRes.serializer(), r.body!!.string())
|
||||
prefs.access = t.accessToken
|
||||
prefs.refresh = t.refreshToken
|
||||
response.request.newBuilder().header("Authorization", "Bearer ${t.accessToken}").build()
|
||||
}
|
||||
r.code == 401 || r.code == 400 -> { // сессия отозвана/истекла
|
||||
prefs.clearAuth(); authLost.value = true; null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
null // нет сети — токены не трогаем
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.build()
|
||||
|
||||
fun apiBase(): String = prefs.baseUrl + "/api/v1/"
|
||||
fun photoUrl(id: Long) = apiBase() + "photos/$id"
|
||||
fun taskPhotoUrl(id: Long) = apiBase() + "task-photos/$id"
|
||||
fun mapImageUrl(kind: String, version: String) = apiBase() + "map/image?kind=$kind&v=$version"
|
||||
fun iconUrl(eq: EquipmentItemDto) = when {
|
||||
eq.icon.startsWith("builtin:") -> prefs.baseUrl + "/static/icons/" + eq.icon.removePrefix("builtin:") + ".svg"
|
||||
eq.icon.startsWith("file:") -> apiBase() + "equipment/${eq.id}/icon?v=" + eq.icon.drop(5).take(8)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Volatile private var cached: Pair<String, Api>? = null
|
||||
|
||||
fun api(): Api {
|
||||
val base = apiBase()
|
||||
cached?.let { if (it.first == base) return it.second }
|
||||
val retrofit = Retrofit.Builder().baseUrl(base).client(http)
|
||||
.addConverterFactory(appJson.asConverterFactory("application/json".toMediaType())).build()
|
||||
return retrofit.create(Api::class.java).also { cached = base to it }
|
||||
}
|
||||
|
||||
/** Вход: токены сохраняются только при успехе. */
|
||||
suspend fun login(baseUrl: String, user: String, pass: String): TokenRes {
|
||||
val old = prefs.baseUrl
|
||||
prefs.baseUrl = baseUrl
|
||||
prefs.access = null // чтобы не отправлять чужой токен
|
||||
return try {
|
||||
val t = api().login(LoginReq(user.trim(), pass))
|
||||
prefs.access = t.accessToken; prefs.refresh = t.refreshToken; authLost.value = false
|
||||
t
|
||||
} catch (e: Exception) {
|
||||
prefs.baseUrl = old; throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Человекочитаемое сообщение об ошибке запроса. */
|
||||
fun Throwable.humanMessage(): String = when (this) {
|
||||
is HttpException -> {
|
||||
val detail = try {
|
||||
val txt = response()?.errorBody()?.string().orEmpty()
|
||||
val el = appJson.parseToJsonElement(txt)
|
||||
val d = (el as? kotlinx.serialization.json.JsonObject)?.get("detail")
|
||||
when (d) {
|
||||
is kotlinx.serialization.json.JsonPrimitive -> d.content
|
||||
is kotlinx.serialization.json.JsonArray -> d.joinToString("; ") { (it as? kotlinx.serialization.json.JsonObject)?.get("msg")?.toString()?.trim('"') ?: "" }
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) { null }
|
||||
detail ?: "Ошибка сервера (${code()})"
|
||||
}
|
||||
is java.net.UnknownHostException -> "Сервер не найден. Проверьте адрес и сеть."
|
||||
is java.net.ConnectException, is java.net.SocketTimeoutException -> "Нет связи с сервером."
|
||||
is javax.net.ssl.SSLException -> "Ошибка защищённого соединения (SSL)."
|
||||
is IOException -> "Нет связи с сервером."
|
||||
else -> message ?: "Неизвестная ошибка"
|
||||
}
|
||||
|
||||
fun Throwable.isNetworkError() = this is IOException
|
||||
@@ -0,0 +1,178 @@
|
||||
package ru.checklists.app.data.api
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LoginReq(val username: String, val password: String, val device: String = "android")
|
||||
|
||||
@Serializable
|
||||
data class RefreshReq(@SerialName("refresh_token") val refreshToken: String, val device: String = "android")
|
||||
|
||||
@Serializable
|
||||
data class TokenRes(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("refresh_token") val refreshToken: String,
|
||||
@SerialName("expires_in") val expiresIn: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PositionDto(val id: Long, val name: String, val kind: String)
|
||||
|
||||
@Serializable
|
||||
data class MeDto(
|
||||
val id: Long,
|
||||
val username: String,
|
||||
@SerialName("full_name") val fullName: String = "",
|
||||
@SerialName("is_admin") val isAdmin: Boolean = false,
|
||||
@SerialName("is_moderator") val isModerator: Boolean = false,
|
||||
@SerialName("is_storekeeper") val isStorekeeper: Boolean = false,
|
||||
val position: PositionDto? = null,
|
||||
@SerialName("on_shift") val onShift: Boolean? = null,
|
||||
@SerialName("demo_mode") val demoMode: Boolean = false,
|
||||
@SerialName("crane_map") val craneMap: Boolean = false,
|
||||
) {
|
||||
val isEngineer get() = position?.kind == "engineer"
|
||||
val isManager get() = isAdmin || isModerator
|
||||
val displayName get() = fullName.ifBlank { username }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PhotoDto(
|
||||
val id: Long,
|
||||
@SerialName("user_id") val userId: Long? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItemDto(
|
||||
val id: Long,
|
||||
val position: Int = 0,
|
||||
val text: String,
|
||||
@SerialName("answer_type") val answerType: String,
|
||||
val options: List<String>? = null,
|
||||
val unit: String = "",
|
||||
@SerialName("min_value") val minValue: Double? = null,
|
||||
@SerialName("max_value") val maxValue: Double? = null,
|
||||
val required: Boolean = true,
|
||||
val value: String? = null,
|
||||
@SerialName("answered_at") val answeredAt: String? = null,
|
||||
@SerialName("out_of_range") val outOfRange: Boolean = false,
|
||||
val photo: String = "none",
|
||||
@SerialName("service_unit_id") val serviceUnitId: Long? = null,
|
||||
@SerialName("is_meter") val isMeter: Boolean = false,
|
||||
val photos: List<PhotoDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OrderDto(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val status: String,
|
||||
@SerialName("is_overdue") val isOverdue: Boolean = false,
|
||||
@SerialName("equipment_name") val equipmentName: String? = null,
|
||||
@SerialName("location_name") val locationName: String? = null,
|
||||
@SerialName("assignee_name") val assigneeName: String? = null,
|
||||
@SerialName("reviewer_name") val reviewerName: String? = null,
|
||||
@SerialName("executor_id") val executorId: Long? = null,
|
||||
@SerialName("executor_name") val executorName: String? = null,
|
||||
@SerialName("review_comment") val reviewComment: String = "",
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
@SerialName("due_at") val dueAt: String? = null,
|
||||
@SerialName("started_at") val startedAt: String? = null,
|
||||
@SerialName("submitted_at") val submittedAt: String? = null,
|
||||
@SerialName("completed_at") val completedAt: String? = null,
|
||||
@SerialName("comments_count") val commentsCount: Int = 0,
|
||||
@SerialName("can_execute") val canExecute: Boolean = false,
|
||||
@SerialName("can_review") val canReview: Boolean = false,
|
||||
val items: List<ItemDto>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SyncItemReq(
|
||||
@SerialName("item_id") val itemId: Long,
|
||||
val value: String?,
|
||||
@SerialName("answered_at") val answeredAt: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SyncReq(val items: List<SyncItemReq>, val submit: Boolean)
|
||||
|
||||
@Serializable
|
||||
data class SyncItemRes(
|
||||
@SerialName("item_id") val itemId: Long,
|
||||
val status: String,
|
||||
val detail: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SyncRes(
|
||||
val results: List<SyncItemRes> = emptyList(),
|
||||
val submitted: Boolean = false,
|
||||
@SerialName("submit_error") val submitError: String = "",
|
||||
val order: OrderDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReviewReq(val approve: Boolean, val comment: String = "")
|
||||
|
||||
@Serializable
|
||||
data class CommentReq(val text: String)
|
||||
|
||||
@Serializable
|
||||
data class CommentDto(
|
||||
val id: Long,
|
||||
@SerialName("user_id") val userId: Long? = null,
|
||||
@SerialName("user_name") val userName: String? = null,
|
||||
val text: String,
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeviceReq(val token: String, val platform: String = "android")
|
||||
|
||||
// ---------- регламентное обслуживание ----------
|
||||
@Serializable
|
||||
data class StateRowDto(
|
||||
@SerialName("equipment_id") val equipmentId: Long,
|
||||
val equipment: String,
|
||||
@SerialName("inventory_no") val inventoryNo: String = "",
|
||||
val location: String = "",
|
||||
@SerialName("unit_id") val unitId: Long,
|
||||
val unit: String,
|
||||
val rule: String = "",
|
||||
val state: String,
|
||||
@SerialName("state_ru") val stateRu: String = "",
|
||||
val percent: Int = 0,
|
||||
@SerialName("meter_hours") val meterHours: Double = 0.0,
|
||||
@SerialName("last_service_at") val lastServiceAt: String? = null,
|
||||
@SerialName("last_service_hours") val lastServiceHours: Double = 0.0,
|
||||
@SerialName("remaining_hours") val remainingHours: Double? = null,
|
||||
@SerialName("remaining_days") val remainingDays: Int? = null,
|
||||
@SerialName("due_date") val dueDate: String? = null,
|
||||
@SerialName("forecast_date") val forecastDate: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StateSummaryDto(val due: Int = 0, val soon: Int = 0, val ok: Int = 0, val nodata: Int = 0)
|
||||
|
||||
@Serializable
|
||||
data class StateReportDto(
|
||||
@SerialName("generated_at") val generatedAt: String? = null,
|
||||
val total: Int = 0,
|
||||
val summary: StateSummaryDto = StateSummaryDto(),
|
||||
val rows: List<StateRowDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MeterReq(val hours: Double, val correction: Boolean = false, val note: String = "")
|
||||
|
||||
@Serializable
|
||||
data class ResetReq(val hours: Double? = null, val note: String = "")
|
||||
|
||||
@Serializable
|
||||
data class EquipmentDto(val id: Long, @SerialName("meter_hours") val meterHours: Double = 0.0)
|
||||
|
||||
@Serializable
|
||||
data class UnitDto(val id: Long, val state: String = "ok")
|
||||
@@ -0,0 +1,216 @@
|
||||
package ru.checklists.app.data.api
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// ---------- портал: суточные задания, инциденты, инструмент (работают только при наличии связи) ----------
|
||||
|
||||
@Serializable
|
||||
data class TaskEventDto(
|
||||
val at: String,
|
||||
@SerialName("user_name") val userName: String? = null,
|
||||
@SerialName("from_status") val fromStatus: String = "",
|
||||
@SerialName("to_status") val toStatus: String,
|
||||
val comment: String = "",
|
||||
val kind: String = "status",
|
||||
@SerialName("from_user_name") val fromUserName: String? = null,
|
||||
@SerialName("to_user_name") val toUserName: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskDto(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val description: String = "",
|
||||
val day: String,
|
||||
@SerialName("assignee_id") val assigneeId: Long,
|
||||
@SerialName("assignee_name") val assigneeName: String? = null,
|
||||
@SerialName("created_by_name") val createdByName: String? = null,
|
||||
@SerialName("equipment_id") val equipmentId: Long? = null,
|
||||
@SerialName("equipment_name") val equipmentName: String? = null,
|
||||
@SerialName("unit_name") val unitName: String? = null,
|
||||
val status: String,
|
||||
@SerialName("last_comment") val lastComment: String = "",
|
||||
val photos: List<Long> = emptyList(),
|
||||
@SerialName("labor_hours") val laborHours: Double? = null,
|
||||
@SerialName("can_change") val canChange: Boolean = false,
|
||||
@SerialName("can_edit") val canEdit: Boolean = false,
|
||||
@SerialName("can_transfer") val canTransfer: Boolean = false,
|
||||
val events: List<TaskEventDto>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskCreateReq(
|
||||
val title: String,
|
||||
val description: String = "",
|
||||
@SerialName("equipment_id") val equipmentId: Long? = null,
|
||||
@SerialName("unit_id") val unitId: Long? = null,
|
||||
@SerialName("labor_hours") val laborHours: Double? = null,
|
||||
)
|
||||
|
||||
/** Карта завода или кранов: картинка и оборудование с координатами (% от картинки). */
|
||||
@Serializable
|
||||
data class MapDto(
|
||||
val kind: String = "plant",
|
||||
@SerialName("crane_map") val craneMap: Boolean = false,
|
||||
@SerialName("has_image") val hasImage: Boolean = false,
|
||||
@SerialName("image_version") val imageVersion: String = "",
|
||||
val equipment: List<MapEqDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MapEqDto(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val x: Double? = null,
|
||||
val y: Double? = null,
|
||||
val state: String = "ok",
|
||||
@SerialName("open_incidents") val openIncidents: Int = 0,
|
||||
)
|
||||
|
||||
/** Обратная связь по порталу (идея, ошибка, другое). */
|
||||
@Serializable
|
||||
data class FeedbackReq(val kind: String, val text: String, val page: String = "", val client: String = "android")
|
||||
|
||||
@Serializable
|
||||
data class TaskStatusReq(val status: String, val comment: String = "")
|
||||
|
||||
@Serializable
|
||||
data class EquipmentItemDto(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
@SerialName("inventory_no") val inventoryNo: String = "",
|
||||
val icon: String = "",
|
||||
@SerialName("is_active") val isActive: Boolean = true,
|
||||
@SerialName("is_crane") val isCrane: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HealthDto(
|
||||
val state: String = "ok",
|
||||
@SerialName("open_incidents") val openIncidents: Int = 0,
|
||||
@SerialName("open_faults") val openFaults: Int = 0,
|
||||
val due: Int = 0,
|
||||
val soon: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UnitBriefDto(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
@SerialName("is_active") val isActive: Boolean = true,
|
||||
val state: String = "ok",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class IncidentUnitReq(@SerialName("unit_id") val unitId: Long, val replaced: Boolean = false)
|
||||
|
||||
@Serializable
|
||||
data class IncidentReq(
|
||||
@SerialName("equipment_id") val equipmentId: Long,
|
||||
val kind: String = "incident", // incident — авария; fault — сообщение о неисправности (оборудование работает)
|
||||
val title: String,
|
||||
val description: String = "",
|
||||
val units: List<IncidentUnitReq> = emptyList(),
|
||||
val replaced: Boolean = false,
|
||||
@SerialName("replacement_note") val replacementNote: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class IncidentUnitDto(@SerialName("unit_name") val unitName: String = "", val replaced: Boolean = false)
|
||||
|
||||
@Serializable
|
||||
data class IncidentDto(
|
||||
val id: Long,
|
||||
val kind: String = "incident",
|
||||
val title: String,
|
||||
val description: String = "",
|
||||
val status: String,
|
||||
@SerialName("occurred_at") val occurredAt: String,
|
||||
@SerialName("equipment_id") val equipmentId: Long? = null,
|
||||
@SerialName("equipment_name") val equipmentName: String? = null,
|
||||
val source: String = "staff",
|
||||
@SerialName("author_id") val authorId: Long? = null,
|
||||
@SerialName("author_name") val authorName: String? = null,
|
||||
val replaced: Boolean = false,
|
||||
@SerialName("replacement_note") val replacementNote: String = "",
|
||||
@SerialName("close_note") val closeNote: String = "",
|
||||
val units: List<IncidentUnitDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CloseReq(val note: String = "")
|
||||
|
||||
@Serializable
|
||||
data class StockDto(
|
||||
@SerialName("item_name") val itemName: String,
|
||||
val category: String = "",
|
||||
val unit: String = "шт",
|
||||
@SerialName("inventory_no") val inventoryNo: String = "",
|
||||
val qty: Double,
|
||||
val since: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ToolRequestReq(val what: String, val why: String, val link: String = "", val qty: Double = 1.0)
|
||||
|
||||
@Serializable
|
||||
data class ToolRequestDto(
|
||||
val id: Long,
|
||||
val what: String,
|
||||
val why: String = "",
|
||||
val link: String = "",
|
||||
val qty: Double = 1.0,
|
||||
val status: String,
|
||||
val answer: String = "",
|
||||
@SerialName("author_name") val authorName: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TransferReq(@SerialName("assignee_id") val assigneeId: Long, val comment: String)
|
||||
|
||||
@Serializable
|
||||
data class PersonDto(
|
||||
val id: Long,
|
||||
val username: String,
|
||||
@SerialName("full_name") val fullName: String = "",
|
||||
@SerialName("is_active") val isActive: Boolean = true,
|
||||
val position: PositionDto? = null,
|
||||
) {
|
||||
val displayName get() = fullName.ifBlank { username }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class HistoryRowDto(
|
||||
val at: String,
|
||||
val type: String,
|
||||
@SerialName("type_ru") val typeRu: String = "",
|
||||
val title: String,
|
||||
val text: String = "",
|
||||
@SerialName("user_name") val userName: String? = null,
|
||||
val severity: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HistoryDto(
|
||||
val total: Int = 0,
|
||||
val counts: Map<String, Int> = emptyMap(),
|
||||
val rows: List<HistoryRowDto> = emptyList(),
|
||||
)
|
||||
|
||||
// ---------- AI-помощник ----------
|
||||
@Serializable
|
||||
data class AssistantStatus(val enabled: Boolean = false)
|
||||
|
||||
@Serializable
|
||||
data class ChatMsg(val role: String, val content: String)
|
||||
|
||||
@Serializable
|
||||
data class ChatReq(val messages: List<ChatMsg>)
|
||||
|
||||
@Serializable
|
||||
data class ChatRes(val reply: String = "", val offtopic: Boolean = false)
|
||||
|
||||
@Serializable
|
||||
data class AppVersionDto(val available: Boolean = false, val version: String? = null, val size: Long = 0, val url: String = "/app.apk")
|
||||
@@ -0,0 +1,205 @@
|
||||
package ru.checklists.app.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Database
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Entity(tableName = "orders")
|
||||
data class OrderEntity(
|
||||
@PrimaryKey val id: Long,
|
||||
val title: String,
|
||||
val status: String,
|
||||
val isOverdue: Boolean,
|
||||
val equipmentName: String?,
|
||||
val locationName: String?,
|
||||
val assigneeName: String?,
|
||||
val reviewerName: String?,
|
||||
val executorId: Long?,
|
||||
val executorName: String?,
|
||||
val reviewComment: String,
|
||||
val createdAt: String?,
|
||||
val dueAt: String?,
|
||||
val startedAt: String?,
|
||||
val submittedAt: String?,
|
||||
val completedAt: String?,
|
||||
val commentsCount: Int,
|
||||
val canExecute: Boolean,
|
||||
val canReview: Boolean,
|
||||
val hasItems: Boolean,
|
||||
/** Пользователь нажал «Сдать», но на сервер ещё не ушло (нет связи). */
|
||||
val pendingSubmit: Boolean = false,
|
||||
/** Сообщение об ошибке последней синхронизации этого наряда. */
|
||||
val syncError: String? = null,
|
||||
)
|
||||
|
||||
@Entity(tableName = "items")
|
||||
data class ItemEntity(
|
||||
@PrimaryKey val id: Long,
|
||||
val orderId: Long,
|
||||
val position: Int,
|
||||
val text: String,
|
||||
val answerType: String,
|
||||
val optionsJson: String?,
|
||||
val unit: String,
|
||||
val minValue: Double?,
|
||||
val maxValue: Double?,
|
||||
val required: Boolean,
|
||||
val photoMode: String,
|
||||
val value: String?,
|
||||
/** Когда ответ дан (мс, время устройства) — для разрешения конфликтов на сервере. */
|
||||
val answeredAt: Long?,
|
||||
val outOfRange: Boolean,
|
||||
/** Ответ изменён на устройстве и ещё не отправлен. */
|
||||
val dirty: Boolean = false,
|
||||
/** Пункт «узел обслужен?» регламентного наряда (id обслуживаемого узла). */
|
||||
val serviceUnitId: Long? = null,
|
||||
/** Пункт «наработка машины» регламентного наряда. */
|
||||
@ColumnInfo(defaultValue = "0") val isMeter: Boolean = false,
|
||||
)
|
||||
|
||||
@Entity(tableName = "photos")
|
||||
data class PhotoEntity(
|
||||
/** "s<id>" для серверных, UUID для ожидающих отправки (он же Idempotency-Key). */
|
||||
@PrimaryKey val key: String,
|
||||
val orderId: Long,
|
||||
val itemId: Long,
|
||||
val serverId: Long?,
|
||||
val path: String?,
|
||||
val pending: Boolean,
|
||||
val userId: Long?,
|
||||
val createdAt: String?,
|
||||
)
|
||||
|
||||
@Entity(tableName = "comments")
|
||||
data class CommentEntity(
|
||||
@PrimaryKey(autoGenerate = true) val localId: Long = 0,
|
||||
val serverId: Long?,
|
||||
val orderId: Long,
|
||||
val userName: String?,
|
||||
val text: String,
|
||||
val createdAt: String,
|
||||
val pending: Boolean,
|
||||
)
|
||||
|
||||
data class Progress(val orderId: Long, val total: Int, val done: Int)
|
||||
|
||||
@Dao
|
||||
interface AppDao {
|
||||
@Query("SELECT * FROM orders ORDER BY CASE WHEN dueAt IS NULL THEN 1 ELSE 0 END, dueAt, id DESC")
|
||||
fun orders(): Flow<List<OrderEntity>>
|
||||
|
||||
@Query("SELECT * FROM orders WHERE id = :id")
|
||||
fun order(id: Long): Flow<OrderEntity?>
|
||||
|
||||
@Query("SELECT * FROM orders WHERE id = :id")
|
||||
suspend fun orderOnce(id: Long): OrderEntity?
|
||||
|
||||
@Query("SELECT * FROM orders")
|
||||
suspend fun allOrders(): List<OrderEntity>
|
||||
|
||||
@Query("SELECT * FROM items WHERE orderId = :id ORDER BY position")
|
||||
fun items(id: Long): Flow<List<ItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM items WHERE orderId = :id")
|
||||
suspend fun itemsOnce(id: Long): List<ItemEntity>
|
||||
|
||||
@Query("SELECT * FROM items WHERE orderId = :id AND dirty = 1")
|
||||
suspend fun dirtyItems(id: Long): List<ItemEntity>
|
||||
|
||||
@Query("SELECT * FROM photos WHERE orderId = :id ORDER BY createdAt, key")
|
||||
fun photos(id: Long): Flow<List<PhotoEntity>>
|
||||
|
||||
@Query("SELECT * FROM photos WHERE orderId = :id")
|
||||
suspend fun photosOnce(id: Long): List<PhotoEntity>
|
||||
|
||||
@Query("SELECT * FROM photos WHERE pending = 1")
|
||||
suspend fun pendingPhotos(): List<PhotoEntity>
|
||||
|
||||
@Query("SELECT * FROM comments WHERE orderId = :id ORDER BY pending, localId")
|
||||
fun comments(id: Long): Flow<List<CommentEntity>>
|
||||
|
||||
@Query("SELECT * FROM comments WHERE pending = 1 ORDER BY localId")
|
||||
suspend fun pendingComments(): List<CommentEntity>
|
||||
|
||||
@Query(
|
||||
"SELECT orderId, COUNT(*) AS total, SUM(CASE WHEN (answerType = 'status' AND value = 'done') OR " +
|
||||
"(answerType != 'status' AND value IS NOT NULL AND value != '') THEN 1 ELSE 0 END) AS done " +
|
||||
"FROM items GROUP BY orderId"
|
||||
)
|
||||
fun progress(): Flow<List<Progress>>
|
||||
|
||||
/** Заказы, у которых есть неотправленные изменения. */
|
||||
@Query(
|
||||
"SELECT DISTINCT orderId FROM items WHERE dirty = 1 UNION SELECT id FROM orders WHERE pendingSubmit = 1 " +
|
||||
"UNION SELECT orderId FROM photos WHERE pending = 1 UNION SELECT orderId FROM comments WHERE pending = 1"
|
||||
)
|
||||
fun unsyncedOrderIds(): Flow<List<Long>>
|
||||
|
||||
@Query("SELECT DISTINCT orderId FROM items WHERE dirty = 1 UNION SELECT id FROM orders WHERE pendingSubmit = 1")
|
||||
suspend fun ordersToPush(): List<Long>
|
||||
|
||||
@Query("SELECT id FROM orders WHERE id NOT IN (:keep)")
|
||||
suspend fun idsNotIn(keep: List<Long>): List<Long>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun putOrder(o: OrderEntity)
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun putItems(list: List<ItemEntity>)
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun putPhoto(p: PhotoEntity)
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun putPhotos(list: List<PhotoEntity>)
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun putComment(c: CommentEntity): Long
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun putComments(list: List<CommentEntity>)
|
||||
|
||||
@Query("UPDATE items SET value = :value, answeredAt = :at, dirty = 1, outOfRange = :oor WHERE id = :id")
|
||||
suspend fun answer(id: Long, value: String?, at: Long, oor: Boolean)
|
||||
|
||||
@Query("UPDATE items SET dirty = 0 WHERE id = :id") suspend fun markClean(id: Long)
|
||||
@Query("UPDATE items SET dirty = 0 WHERE orderId = :orderId") suspend fun clearDirty(orderId: Long)
|
||||
@Query("UPDATE orders SET pendingSubmit = :v WHERE id = :id") suspend fun setPendingSubmit(id: Long, v: Boolean)
|
||||
@Query("UPDATE orders SET syncError = :e WHERE id = :id") suspend fun setSyncError(id: Long, e: String?)
|
||||
@Query("UPDATE orders SET status = :status WHERE id = :id") suspend fun setStatus(id: Long, status: String)
|
||||
|
||||
@Query("DELETE FROM photos WHERE orderId = :id AND pending = 0 AND itemId = :itemId")
|
||||
suspend fun deleteServerPhotos(id: Long, itemId: Long)
|
||||
|
||||
@Query("DELETE FROM photos WHERE key = :key") suspend fun deletePhoto(key: String)
|
||||
@Query("DELETE FROM comments WHERE orderId = :id AND pending = 0") suspend fun deleteServerComments(id: Long)
|
||||
@Query("DELETE FROM comments WHERE localId = :id") suspend fun deleteComment(id: Long)
|
||||
|
||||
@Query("DELETE FROM orders WHERE id = :id") suspend fun deleteOrder(id: Long)
|
||||
@Query("DELETE FROM items WHERE orderId = :id") suspend fun deleteItems(id: Long)
|
||||
@Query("DELETE FROM photos WHERE orderId = :id") suspend fun deletePhotos(id: Long)
|
||||
@Query("DELETE FROM comments WHERE orderId = :id") suspend fun deleteComments(id: Long)
|
||||
|
||||
@Query("DELETE FROM orders") suspend fun wipeOrders()
|
||||
@Query("DELETE FROM items") suspend fun wipeItems()
|
||||
@Query("DELETE FROM photos") suspend fun wipePhotos()
|
||||
@Query("DELETE FROM comments") suspend fun wipeComments()
|
||||
}
|
||||
|
||||
@Database(entities = [OrderEntity::class, ItemEntity::class, PhotoEntity::class, CommentEntity::class], version = 2, exportSchema = false)
|
||||
abstract class AppDb : RoomDatabase() {
|
||||
abstract fun dao(): AppDao
|
||||
|
||||
companion object {
|
||||
/** 1 → 2: новые поля пунктов (регламентное обслуживание). Данные на устройстве, в том числе неотправленные, сохраняются. */
|
||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE items ADD COLUMN serviceUnitId INTEGER")
|
||||
db.execSQL("ALTER TABLE items ADD COLUMN isMeter INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
fun create(ctx: Context) = Room.databaseBuilder(ctx, AppDb::class.java, "checklists.db")
|
||||
.addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration().build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.checklists.app.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import ru.checklists.app.repo
|
||||
|
||||
/** Фоновая синхронизация: отправляет накопленное офлайн и подтягивает новые наряды (с уведомлением). */
|
||||
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
val repo = applicationContext.repo
|
||||
if (!repo.prefs.loggedIn) return Result.success()
|
||||
return if (repo.syncAll()) Result.success() else Result.retry()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package ru.checklists.app.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.checklists.app.data.api.ChatMsg
|
||||
import ru.checklists.app.data.api.ChatReq
|
||||
import ru.checklists.app.data.api.humanMessage
|
||||
import ru.checklists.app.repo
|
||||
|
||||
/** Переписка с помощником живёт, пока открыто приложение. */
|
||||
object AssistantChat {
|
||||
val messages = mutableStateListOf<ChatMsg>()
|
||||
var enabled by mutableStateOf(false)
|
||||
}
|
||||
|
||||
private val LINK = Regex("""\[([^\]]{1,80})]\((#/[^\s)]*|/report|/m)\)|\*\*([^*]{1,120})\*\*""")
|
||||
|
||||
/** Ответ помощника: безопасный текст, ссылки только на разделы портала, **жирный**. */
|
||||
private fun rich(text: String, linkColor: Color): AnnotatedString = buildAnnotatedString {
|
||||
var last = 0
|
||||
for (m in LINK.findAll(text)) {
|
||||
append(text.substring(last, m.range.first))
|
||||
if (m.groupValues[1].isNotEmpty()) {
|
||||
pushStringAnnotation("link", m.groupValues[2])
|
||||
pushStyle(SpanStyle(color = linkColor, fontWeight = FontWeight.SemiBold, textDecoration = TextDecoration.Underline))
|
||||
append(m.groupValues[1]); pop(); pop()
|
||||
} else {
|
||||
pushStyle(SpanStyle(fontWeight = FontWeight.Bold)); append(m.groupValues[3]); pop()
|
||||
}
|
||||
last = m.range.last + 1
|
||||
}
|
||||
append(text.substring(last))
|
||||
}
|
||||
|
||||
/** Разделы, которые есть в приложении, открываем в нём; остальное — в полной версии сайта. */
|
||||
private fun appRoute(link: String): String? = when {
|
||||
link == "#/" || link.startsWith("#/work") -> "work"
|
||||
link.startsWith("#/orders") -> "orders"
|
||||
link.startsWith("#/tasks") || link.startsWith("#/new-task") -> "tasks"
|
||||
link.startsWith("#/task/") -> "task/" + link.removePrefix("#/task/").takeWhile { it.isDigit() }
|
||||
link.startsWith("#/order/") -> "order/" + link.removePrefix("#/order/").takeWhile { it.isDigit() }
|
||||
link.startsWith("#/equipment/") -> "equipment/" + link.removePrefix("#/equipment/").takeWhile { it.isDigit() }
|
||||
link.startsWith("#/equipment") || link.startsWith("#/incidents") -> "equipment"
|
||||
link.startsWith("#/cranes") -> "cranes"
|
||||
link.startsWith("#/my-tools") -> "tools"
|
||||
link.startsWith("#/requests") -> "requests"
|
||||
else -> null
|
||||
}?.takeIf { !it.endsWith("/") }
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun AssistantScreen(onBack: () -> Unit, go: (String) -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val repo = ctx.repo
|
||||
val scope = rememberCoroutineScope()
|
||||
val msgs = AssistantChat.messages
|
||||
var input by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val list = rememberLazyListState()
|
||||
val link = MaterialTheme.colorScheme.primary
|
||||
LaunchedEffect(msgs.size, busy) { if (msgs.isNotEmpty()) list.animateScrollToItem(msgs.size - 1 + if (busy) 1 else 0) }
|
||||
|
||||
fun ask(q: String) {
|
||||
val text = q.trim()
|
||||
if (text.isEmpty() || busy) return
|
||||
input = ""; error = null
|
||||
msgs.add(ChatMsg("user", text))
|
||||
busy = true
|
||||
scope.launch {
|
||||
try { msgs.add(ChatMsg("assistant", repo.client.api().assistantChat(ChatReq(msgs.takeLast(10))).reply)) }
|
||||
catch (e: Throwable) { error = e.humanMessage() }
|
||||
finally { busy = false }
|
||||
}
|
||||
}
|
||||
fun open(l: String) {
|
||||
appRoute(l)?.let { go(it); return }
|
||||
ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(repo.prefs.baseUrl + (if (l.startsWith("#")) "/" else "") + l)))
|
||||
}
|
||||
|
||||
Scaffold(topBar = {
|
||||
ScreenBar(title = { Text("🤖 Помощник") }, navigationIcon = { BackIcon(onBack) },
|
||||
actions = { TextButton({ msgs.clear(); error = null }) { Text("Очистить") } })
|
||||
}) { pad ->
|
||||
Column(Modifier.padding(pad).fillMaxSize().imePadding()) {
|
||||
LazyColumn(Modifier.weight(1f), state = list, contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (msgs.isEmpty()) item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Bubble(false) { Text("Здравствуйте! Подскажу, где что найти и как выполнить действие, покажу ваши задания, наряды и инструмент.") }
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf("Какие у меня задания на сегодня?", "Как зафиксировать аварию?", "Где мой инструмент?", "Как подать заявку на инструмент?")
|
||||
.forEach { q -> OutlinedButton({ ask(q) }) { Text(q, style = MaterialTheme.typography.bodySmall) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
items(msgs) { m ->
|
||||
Bubble(m.role == "user") {
|
||||
if (m.role == "user") Text(m.content, color = Color.White)
|
||||
else {
|
||||
val s = rich(m.content, link)
|
||||
ClickableText(s, style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface)) { off ->
|
||||
s.getStringAnnotations("link", off, off).firstOrNull()?.let { open(it.item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (busy) item { Bubble(false) { Text("…") } }
|
||||
error?.let { item { Text("⚠ $it", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } }
|
||||
}
|
||||
Text("Отвечает только о работе в портале и видит только ваши данные", style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(horizontal = 12.dp))
|
||||
Row(Modifier.fillMaxWidth().navigationBarsPadding().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(input, { if (it.length <= 1000) input = it }, placeholder = { Text("Ваш вопрос") }, modifier = Modifier.weight(1f), maxLines = 4)
|
||||
Button({ ask(input) }, enabled = !busy && input.isNotBlank(), modifier = Modifier.padding(start = 6.dp)) { Text("➤") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bubble(mine: Boolean, content: @Composable () -> Unit) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = if (mine) Alignment.CenterEnd else Alignment.CenterStart) {
|
||||
Box(Modifier.widthIn(max = 320.dp)
|
||||
.background(if (mine) Color(0xFF2563EB) else MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(14.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Круглая кнопка помощника в правом нижнем углу (выше кнопок экранов). */
|
||||
@Composable
|
||||
fun AssistantFab(onClick: () -> Unit) {
|
||||
Box(Modifier.fillMaxSize().navigationBarsPadding().padding(end = 16.dp, bottom = 96.dp), contentAlignment = Alignment.BottomEnd) {
|
||||
Box(Modifier.background(Color(0xFF4F46E5), RoundedCornerShape(50)).clickable(onClick = onClick).padding(14.dp)) {
|
||||
Text("🤖", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package ru.checklists.app.ui
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private val Light = lightColorScheme(primary = Color(0xFF2563EB), secondary = Color(0xFF475569), tertiary = Color(0xFF7C3AED))
|
||||
private val Dark = darkColorScheme(primary = Color(0xFF7AA7FF), secondary = Color(0xFF94A3B8), tertiary = Color(0xFFB794F6))
|
||||
|
||||
@Composable
|
||||
fun AppTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = if (isSystemInDarkTheme()) Dark else Light, content = content)
|
||||
}
|
||||
|
||||
val STATUS_RU = mapOf("new" to "Новый", "in_progress" to "В работе", "review" to "На проверке", "completed" to "Выполнен", "cancelled" to "Отменён")
|
||||
|
||||
fun statusColor(status: String) = when (status) {
|
||||
"new" -> Color(0xFF2563EB)
|
||||
"in_progress" -> Color(0xFFD97706)
|
||||
"review" -> Color(0xFF7C3AED)
|
||||
"completed" -> Color(0xFF16A34A)
|
||||
else -> Color(0xFF64748B)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Badge(text: String, color: Color) {
|
||||
Surface(shape = RoundedCornerShape(50), color = Color.Transparent, border = BorderStroke(1.dp, color)) {
|
||||
Text(text, color = color, fontSize = 12.sp, modifier = Modifier.padding(horizontal = 9.dp, vertical = 2.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatusBadges(status: String, overdue: Boolean) {
|
||||
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
|
||||
Badge(STATUS_RU[status] ?: status, statusColor(status))
|
||||
if (overdue) Badge("просрочен", Color(0xFFDC2626))
|
||||
}
|
||||
}
|
||||
|
||||
fun toast(ctx: android.content.Context, text: String) = Toast.makeText(ctx, text, Toast.LENGTH_LONG).show()
|
||||
|
||||
/** Выполняет [block] сразу и затем каждые [everyMs], пока экран на виду. */
|
||||
@Composable
|
||||
fun WhileVisible(everyMs: Long = 30_000, block: suspend () -> Unit) {
|
||||
val owner = LocalLifecycleOwner.current
|
||||
val current by rememberUpdatedState(block)
|
||||
LaunchedEffect(owner) {
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (true) {
|
||||
current()
|
||||
delay(everyMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun contextOrNull() = LocalContext.current
|
||||
@@ -0,0 +1,90 @@
|
||||
package ru.checklists.app.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.checklists.app.data.api.humanMessage
|
||||
import ru.checklists.app.repo
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(onDone: () -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val repo = ctx.repo
|
||||
val debuggable = ctx.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE != 0
|
||||
val scope = rememberCoroutineScope()
|
||||
val authLost by repo.client.authLost.collectAsState()
|
||||
var url by rememberSaveable { mutableStateOf(repo.prefs.baseUrl) }
|
||||
var user by rememberSaveable { mutableStateOf("") }
|
||||
var pass by rememberSaveable { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun go() {
|
||||
if (busy) return
|
||||
if (!debuggable && url.trim().startsWith("http://", ignoreCase = true)) {
|
||||
error = "Нужен защищённый адрес https:// — по http пароль и данные передаются открыто"
|
||||
return
|
||||
}
|
||||
busy = true; error = null
|
||||
scope.launch {
|
||||
try {
|
||||
repo.login(url, user, pass)
|
||||
onDone()
|
||||
repo.syncSoon()
|
||||
} catch (e: Throwable) {
|
||||
error = if (e is retrofit2.HttpException && e.code() == 401) "Неверный логин или пароль" else e.humanMessage()
|
||||
} finally { busy = false }
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold { pad ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(pad).imePadding().verticalScroll(rememberScrollState()).padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
Text("🔧 Чек-листы", style = MaterialTheme.typography.headlineMedium)
|
||||
Text("Обходы и обслуживание оборудования", color = MaterialTheme.colorScheme.secondary)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (authLost) Text("Сессия истекла — войдите снова.", color = MaterialTheme.colorScheme.error)
|
||||
OutlinedTextField(url, { url = it }, label = { Text("Адрес сервера") }, placeholder = { Text("https://checklists.example.ru") },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
|
||||
OutlinedTextField(user, { user = it }, label = { Text("Логин") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(pass, { pass = it }, label = { Text("Пароль") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
Button(onClick = ::go, enabled = !busy && url.isNotBlank() && user.isNotBlank() && pass.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
|
||||
if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) else Text("Войти")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package ru.checklists.app.ui
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.content.FileProvider
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.checklists.app.data.api.humanMessage
|
||||
import ru.checklists.app.data.db.CommentEntity
|
||||
import ru.checklists.app.data.db.ItemEntity
|
||||
import ru.checklists.app.data.db.OrderEntity
|
||||
import ru.checklists.app.data.db.PhotoEntity
|
||||
import ru.checklists.app.data.isDone
|
||||
import ru.checklists.app.data.options
|
||||
import ru.checklists.app.repo
|
||||
import ru.checklists.app.util.fmtFull
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun OrderScreen(orderId: Long, onBack: () -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val repo = ctx.repo
|
||||
val scope = rememberCoroutineScope()
|
||||
val order by remember(orderId) { repo.dao.order(orderId) }.collectAsState(null)
|
||||
val items by remember(orderId) { repo.dao.items(orderId) }.collectAsState(emptyList())
|
||||
val photos by remember(orderId) { repo.dao.photos(orderId) }.collectAsState(emptyList())
|
||||
val comments by remember(orderId) { repo.dao.comments(orderId) }.collectAsState(emptyList())
|
||||
val me by repo.me.collectAsState()
|
||||
val syncing by repo.syncing.collectAsState()
|
||||
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var viewPhoto by remember { mutableStateOf<PhotoEntity?>(null) }
|
||||
var capture by remember { mutableStateOf<Pair<Long, File>?>(null) } // (itemId, временный файл)
|
||||
|
||||
WhileVisible { repo.loadDetails(orderId); repo.syncAll() }
|
||||
|
||||
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
|
||||
val c = capture; capture = null
|
||||
if (c == null) return@rememberLauncherForActivityResult
|
||||
if (ok) scope.launch(Dispatchers.IO) {
|
||||
runCatching { repo.addPhoto(orderId, c.first, c.second) }.onFailure { e ->
|
||||
launch(Dispatchers.Main) { toast(ctx, "Не удалось сохранить фото: ${e.message}") }
|
||||
}
|
||||
} else c.second.delete()
|
||||
}
|
||||
fun takePhoto(itemId: Long) {
|
||||
val dir = File(ctx.cacheDir, "capture").apply { mkdirs() }
|
||||
val f = File.createTempFile("cap_", ".jpg", dir)
|
||||
capture = itemId to f
|
||||
camera.launch(FileProvider.getUriForFile(ctx, "${ctx.packageName}.files", f))
|
||||
}
|
||||
|
||||
fun run(block: suspend () -> Unit) {
|
||||
if (busy) return
|
||||
busy = true
|
||||
scope.launch { try { block() } catch (e: Throwable) { toast(ctx, e.humanMessage()) } finally { busy = false } }
|
||||
}
|
||||
|
||||
val o = order
|
||||
Scaffold(topBar = {
|
||||
ScreenBar(
|
||||
title = { Text("Наряд №$orderId") },
|
||||
navigationIcon = { IconButton(onBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Назад") } },
|
||||
actions = {
|
||||
if (syncing) Box(Modifier.size(48.dp), Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp) }
|
||||
else IconButton({ scope.launch { repo.loadDetails(orderId); repo.syncSoon() } }) { Icon(Icons.Default.Refresh, contentDescription = "Обновить") }
|
||||
},
|
||||
)
|
||||
}) { pad ->
|
||||
if (o == null) {
|
||||
Box(Modifier.padding(pad).fillMaxSize(), Alignment.Center) { CircularProgressIndicator() }
|
||||
return@Scaffold
|
||||
}
|
||||
val editable = o.canExecute && o.status in setOf("new", "in_progress") && !o.pendingSubmit
|
||||
LazyColumn(Modifier.padding(pad).imePadding(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
item { Header(o) }
|
||||
|
||||
if (o.reviewComment.isNotBlank() && o.status == "in_progress")
|
||||
item { Notice("Возвращён на доработку: ${o.reviewComment}", MaterialTheme.colorScheme.error) }
|
||||
o.syncError?.let { item { Notice("⚠ $it", MaterialTheme.colorScheme.error) } }
|
||||
if (o.pendingSubmit) item { Notice("⟳ Наряд будет сдан при появлении связи. Данные сохранены на устройстве.", MaterialTheme.colorScheme.tertiary) }
|
||||
|
||||
if (items.isEmpty()) item {
|
||||
Text(if (o.hasItems) "В чек-листе нет задач" else "Загрузка чек-листа… (нужна связь)", color = MaterialTheme.colorScheme.secondary)
|
||||
} else item {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Text("Чек-лист (${items.count { it.isDone() }}/${items.size})", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
items.forEachIndexed { n, it ->
|
||||
HorizontalDivider(Modifier.padding(vertical = 10.dp))
|
||||
ItemBlock(n + 1, it, photos.filter { p -> p.itemId == it.id }, editable, me?.id, me?.isManager == true,
|
||||
onAnswer = { v -> run { repo.answer(it, v) } }, onPhoto = { takePhoto(it.id) },
|
||||
onView = { p -> viewPhoto = p }, onDelete = { p -> run { repo.deletePhoto(p) } })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// действия исполнителя
|
||||
if (o.canExecute && o.status in setOf("new", "in_progress") && !o.pendingSubmit) item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (o.status == "new" && o.executorId == null) OutlinedButton({ run { repo.start(orderId) } }, enabled = !busy) { Text("Взять в работу") }
|
||||
Button(
|
||||
onClick = { run { repo.submit(orderId)?.let { toast(ctx, it) } } }, enabled = !busy && items.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Сдать наряд") }
|
||||
}
|
||||
}
|
||||
|
||||
// проверка (инженер)
|
||||
if (o.canReview) item { ReviewCard(busy) { approve, comment -> run { repo.review(orderId, approve, comment); onBack() } } }
|
||||
|
||||
item { CommentsCard(comments, onSend = { t -> run { repo.addComment(orderId, t) } }) }
|
||||
}
|
||||
}
|
||||
|
||||
viewPhoto?.let { p ->
|
||||
Dialog(onDismissRequest = { viewPhoto = null }) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color.Black)) {
|
||||
AsyncImage(
|
||||
model = p.path?.let { File(it) } ?: repo.client.photoUrl(p.serverId!!), contentDescription = "Фото",
|
||||
contentScale = ContentScale.Fit, modifier = Modifier.fillMaxWidth().clickable { viewPhoto = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(k: String, v: String?) {
|
||||
if (v.isNullOrBlank() || v == "—") return
|
||||
Row {
|
||||
Text(k, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.weight(0.42f))
|
||||
Text(v, modifier = Modifier.weight(0.58f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(o: OrderEntity) {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(o.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
StatusBadges(o.status, o.isOverdue)
|
||||
InfoRow("Оборудование", o.equipmentName); InfoRow("Место", o.locationName)
|
||||
InfoRow("Назначен", o.assigneeName); InfoRow("Выполняет", o.executorName); InfoRow("Проверяющий", o.reviewerName)
|
||||
InfoRow("Срок", fmtFull(o.dueAt)); InfoRow("Начат", fmtFull(o.startedAt)); InfoRow("Сдан", fmtFull(o.submittedAt)); InfoRow("Завершён", fmtFull(o.completedAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Notice(text: String, color: androidx.compose.ui.graphics.Color) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.12f))) { Text(text, color = color, modifier = Modifier.padding(12.dp)) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ItemBlock(
|
||||
n: Int, item: ItemEntity, photos: List<PhotoEntity>, editable: Boolean, myId: Long?, manager: Boolean,
|
||||
onAnswer: (String?) -> Unit, onPhoto: () -> Unit, onView: (PhotoEntity) -> Unit, onDelete: (PhotoEntity) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val mark = when { item.isMeter -> "⏱ "; item.serviceUnitId != null -> "🔧 "; else -> "" }
|
||||
Text(buildString { append("$n. $mark${item.text}"); if (!item.required) append(" (необязательно)") }, fontWeight = FontWeight.SemiBold)
|
||||
if (item.isMeter) Text("Показание счётчика машины, м/ч. Меньше текущей наработки ввести нельзя. После завершения наряда значение сохранится в карточке машины.",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
if (item.serviceUnitId != null) Text("Отметьте «Выполнено», если узел обслужен: после завершения наряда его остаток сбросится. Не обслуживали — оставьте «Не начато».",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
when (item.answerType) {
|
||||
"status" -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf(null to "Не начато", "in_progress" to "В работе", "done" to "Выполнено").forEach { (v, label) ->
|
||||
FilterChip(selected = item.value == v, enabled = editable, onClick = { onAnswer(v) }, label = { Text(label) })
|
||||
}
|
||||
}
|
||||
"choice" -> FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item.options().forEach { opt ->
|
||||
FilterChip(selected = item.value == opt, enabled = editable, onClick = { onAnswer(if (item.value == opt) null else opt) }, label = { Text(opt) })
|
||||
}
|
||||
}
|
||||
"number" -> {
|
||||
TextAnswer(item, editable, multiline = false, onCommit = onAnswer)
|
||||
val range = if (item.isMeter && item.minValue != null) "не меньше ${item.minValue.trimZero()} ${item.unit}"
|
||||
else if (item.minValue != null || item.maxValue != null) "норма: ${item.minValue?.trimZero() ?: "…"} – ${item.maxValue?.trimZero() ?: "…"} ${item.unit}" else ""
|
||||
if (range.isNotEmpty()) Text(range, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
if (item.outOfRange) Text(if (item.isMeter) "⚠ Меньше текущей наработки — показание не будет принято" else "⚠ Значение вне допустимого диапазона",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
else -> TextAnswer(item, editable, multiline = true, onCommit = onAnswer)
|
||||
}
|
||||
if (item.photoMode != "none") {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
photos.forEach { p ->
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = p.path?.let { File(it) } ?: ru.checklists.app.App.instance.repo.client.photoUrl(p.serverId!!),
|
||||
contentDescription = "Фото", contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(84.dp).clip(RoundedCornerShape(8.dp)).clickable { onView(p) },
|
||||
)
|
||||
if (p.pending) Text("⟳", modifier = Modifier.align(Alignment.BottomStart).padding(4.dp), color = androidx.compose.ui.graphics.Color.White)
|
||||
if (editable && (p.pending || p.userId == myId || manager))
|
||||
Text("✕", color = androidx.compose.ui.graphics.Color.White, modifier = Modifier.align(Alignment.TopEnd).clip(RoundedCornerShape(50))
|
||||
.clickable { onDelete(p) }.padding(horizontal = 6.dp, vertical = 1.dp))
|
||||
}
|
||||
}
|
||||
if (editable) OutlinedButton(onPhoto) { Text("📷 Фото") }
|
||||
}
|
||||
if (item.photoMode == "required" && photos.isEmpty())
|
||||
Text("Требуется фото", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Double.trimZero(): String = if (this == toLong().toDouble()) toLong().toString() else toString()
|
||||
|
||||
/** Поле с отложенной записью: ответ сохраняется через 0,7 с после ввода или при потере фокуса. */
|
||||
@Composable
|
||||
private fun TextAnswer(item: ItemEntity, editable: Boolean, multiline: Boolean, onCommit: (String?) -> Unit) {
|
||||
var text by remember(item.id) { mutableStateOf(item.value ?: "") }
|
||||
var focused by remember(item.id) { mutableStateOf(false) }
|
||||
fun norm(s: String) = s.trim().ifEmpty { null }
|
||||
LaunchedEffect(item.value) { if (!focused && norm(text) != item.value) text = item.value ?: "" }
|
||||
LaunchedEffect(text) {
|
||||
delay(700)
|
||||
if (editable && norm(text) != item.value) onCommit(norm(text))
|
||||
}
|
||||
val unitSuffix: (@Composable () -> Unit)? = if (item.unit.isNotEmpty() && item.answerType == "number") {
|
||||
{ Text(item.unit) }
|
||||
} else null
|
||||
OutlinedTextField(
|
||||
value = text, onValueChange = { text = it }, enabled = editable, singleLine = !multiline, minLines = if (multiline) 2 else 1,
|
||||
suffix = unitSuffix,
|
||||
isError = item.outOfRange,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = if (item.answerType == "number") KeyboardType.Decimal else KeyboardType.Text),
|
||||
modifier = Modifier.fillMaxWidth().onFocusChanged { f ->
|
||||
if (focused && !f.isFocused && editable && norm(text) != item.value) onCommit(norm(text))
|
||||
focused = f.isFocused
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReviewCard(busy: Boolean, onDecide: (approve: Boolean, comment: String) -> Unit) {
|
||||
var comment by rememberSaveable { mutableStateOf("") }
|
||||
var needComment by remember { mutableStateOf(false) }
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Проверка", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
OutlinedTextField(comment, { comment = it; needComment = false }, label = { Text("Комментарий (обязателен при возврате)") }, isError = needComment, modifier = Modifier.fillMaxWidth(), minLines = 2)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button({ onDecide(true, comment) }, enabled = !busy, modifier = Modifier.weight(1f)) { Text("Принять") }
|
||||
Button(
|
||||
{ if (comment.isBlank()) needComment = true else onDecide(false, comment) }, enabled = !busy,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.weight(1f),
|
||||
) { Text("Вернуть") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommentsCard(comments: List<CommentEntity>, onSend: (String) -> Unit) {
|
||||
var text by rememberSaveable { mutableStateOf("") }
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Комментарии (${comments.size})", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
comments.forEach { c ->
|
||||
Column {
|
||||
Text("${c.userName ?: "—"} · ${if (c.pending) "⟳ не отправлен" else fmtFull(c.createdAt)}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary)
|
||||
Text(c.text)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(text, { text = it }, placeholder = { Text("Написать комментарий…") }, modifier = Modifier.fillMaxWidth(), minLines = 2)
|
||||
Button({ if (text.isNotBlank()) { onSend(text); text = "" } }, enabled = text.isNotBlank()) { Text("Отправить") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package ru.checklists.app.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ru.checklists.app.data.db.OrderEntity
|
||||
import ru.checklists.app.data.db.Progress
|
||||
import ru.checklists.app.repo
|
||||
import ru.checklists.app.util.fmtMillis
|
||||
import ru.checklists.app.util.fmtShort
|
||||
|
||||
internal val ACTIVE = setOf("new", "in_progress", "review")
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OrdersScreen(onOpen: (Long) -> Unit) {
|
||||
val repo = LocalContext.current.repo
|
||||
val orders by remember { repo.dao.orders() }.collectAsState(emptyList())
|
||||
val progress by remember { repo.dao.progress() }.collectAsState(emptyList())
|
||||
val unsynced by remember { repo.dao.unsyncedOrderIds() }.collectAsState(emptyList())
|
||||
val me by repo.me.collectAsState()
|
||||
val syncing by repo.syncing.collectAsState()
|
||||
val last by repo.lastSync.collectAsState()
|
||||
val error by repo.lastError.collectAsState()
|
||||
|
||||
var filter by rememberSaveable { mutableStateOf("active") }
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var searching by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||
LaunchedEffect(Unit) { if (Build.VERSION.SDK_INT >= 33) permission.launch(Manifest.permission.POST_NOTIFICATIONS) }
|
||||
WhileVisible { repo.syncAll() }
|
||||
|
||||
val myId = me?.id
|
||||
fun mine(o: OrderEntity) = o.canExecute || (myId != null && o.executorId == myId)
|
||||
val filters = listOf(
|
||||
Triple("active", "Активные", orders.count { it.status in ACTIVE }),
|
||||
Triple("mine", "Мои", orders.count { it.status in ACTIVE && mine(it) }),
|
||||
Triple("review", "На проверку", orders.count { it.canReview }),
|
||||
Triple("done", "Завершённые", orders.count { it.status !in ACTIVE }),
|
||||
).filter { it.first != "review" || me?.isEngineer == true || me?.isManager == true }
|
||||
|
||||
val q = query.trim().lowercase()
|
||||
val shown = orders.filter {
|
||||
when (filter) {
|
||||
"mine" -> it.status in ACTIVE && mine(it)
|
||||
"review" -> it.canReview
|
||||
"done" -> it.status !in ACTIVE
|
||||
else -> it.status in ACTIVE
|
||||
} && (q.isEmpty() || it.title.lowercase().contains(q) || (it.equipmentName ?: "").lowercase().contains(q) ||
|
||||
(it.locationName ?: "").lowercase().contains(q) || q.removePrefix("#") == it.id.toString())
|
||||
}
|
||||
val prog = progress.associateBy { it.orderId }
|
||||
|
||||
Scaffold(topBar = {
|
||||
ScreenBar(
|
||||
title = {
|
||||
if (searching) {
|
||||
OutlinedTextField(query, { query = it }, singleLine = true, placeholder = { Text("Название, оборудование, №") },
|
||||
modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions.Default)
|
||||
} else {
|
||||
Column {
|
||||
Text("Наряды")
|
||||
Text(
|
||||
when {
|
||||
syncing -> "Синхронизация…"
|
||||
unsynced.isNotEmpty() -> "Не отправлено: ${unsynced.size} (отправится при появлении связи)"
|
||||
last != null -> "Обновлено ${fmtMillis(last!!).takeLast(5)}"
|
||||
else -> "Ещё не синхронизировано"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (unsynced.isNotEmpty()) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton({ searching = !searching; if (!searching) query = "" }) {
|
||||
Icon(if (searching) Icons.Default.Clear else Icons.Default.Search, contentDescription = "Поиск")
|
||||
}
|
||||
if (syncing) Box(Modifier.size(48.dp), Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp) }
|
||||
else IconButton({ repo.syncSoon() }) { Icon(Icons.Default.Refresh, contentDescription = "Обновить") }
|
||||
},
|
||||
)
|
||||
}) { pad ->
|
||||
Column(Modifier.padding(pad).fillMaxSize()) {
|
||||
DemoBanner()
|
||||
LazyRow(contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(filters) { (key, label, n) ->
|
||||
FilterChip(selected = filter == key, onClick = { filter = key }, label = { Text("$label · $n") })
|
||||
}
|
||||
}
|
||||
error?.let {
|
||||
Text("⚠ $it", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
|
||||
}
|
||||
if (shown.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), Alignment.Center) {
|
||||
Text(if (q.isNotEmpty()) "Ничего не найдено" else "Нарядов нет", color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
} else LazyColumn(contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(shown, key = { it.id }) { o -> OrderCard(o, prog[o.id], o.id in unsynced) { onOpen(o.id) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OrderCard(o: OrderEntity, p: Progress?, unsynced: Boolean, onClick: () -> Unit) {
|
||||
ElevatedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Text(o.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text("№${o.id}", color = MaterialTheme.colorScheme.secondary, style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
val where = listOfNotNull(o.equipmentName, o.locationName).joinToString(" · ")
|
||||
if (where.isNotEmpty()) Text(where, color = MaterialTheme.colorScheme.secondary, style = MaterialTheme.typography.bodyMedium)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
StatusBadges(o.status, o.isOverdue)
|
||||
if (unsynced) Text("⟳ не отправлено", color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.labelSmall)
|
||||
if (o.commentsCount > 0) Text("💬 ${o.commentsCount}", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Срок: ${fmtShort(o.dueAt)}", style = MaterialTheme.typography.bodySmall, color = if (o.isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary)
|
||||
val who = o.executorName ?: o.assigneeName
|
||||
if (who != null) Text(who, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
}
|
||||
if (p != null && p.total > 0) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
LinearProgressIndicator(progress = { p.done.toFloat() / p.total }, modifier = Modifier.weight(1f))
|
||||
Text("${p.done}/${p.total}", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
if (o.canExecute && o.status in ACTIVE) Text("▶ можно выполнять", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium)
|
||||
if (o.canReview) Text("✔ ждёт вашей проверки", color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
package ru.checklists.app.ui
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.content.FileProvider
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import ru.checklists.app.data.api.CloseReq
|
||||
import ru.checklists.app.data.api.EquipmentItemDto
|
||||
import ru.checklists.app.data.api.HealthDto
|
||||
import ru.checklists.app.data.api.IncidentDto
|
||||
import ru.checklists.app.data.api.IncidentReq
|
||||
import ru.checklists.app.data.api.IncidentUnitReq
|
||||
import ru.checklists.app.data.api.StockDto
|
||||
import ru.checklists.app.data.api.TaskCreateReq
|
||||
import ru.checklists.app.data.api.TaskDto
|
||||
import ru.checklists.app.data.api.TaskStatusReq
|
||||
import ru.checklists.app.data.api.ToolRequestDto
|
||||
import ru.checklists.app.data.api.ToolRequestReq
|
||||
import ru.checklists.app.data.api.UnitBriefDto
|
||||
import ru.checklists.app.data.api.PersonDto
|
||||
import ru.checklists.app.data.api.TransferReq
|
||||
import ru.checklists.app.data.api.humanMessage
|
||||
import ru.checklists.app.repo
|
||||
import ru.checklists.app.util.downscaleJpeg
|
||||
import ru.checklists.app.util.fmtFull
|
||||
import ru.checklists.app.util.fmtShort
|
||||
import java.io.File
|
||||
|
||||
val TASK_RU = mapOf("assigned" to "Назначен", "in_progress" to "В работе", "paused" to "Приостановлен", "done" to "Выполнен", "cancelled" to "Отменён")
|
||||
private val TASK_NEXT = mapOf(
|
||||
"assigned" to listOf("in_progress", "paused", "done", "cancelled"),
|
||||
"in_progress" to listOf("paused", "done", "cancelled"),
|
||||
"paused" to listOf("in_progress", "done", "cancelled"),
|
||||
"done" to listOf("assigned"), "cancelled" to listOf("assigned"),
|
||||
)
|
||||
private val TASK_BTN = mapOf("in_progress" to "▶ В работу", "paused" to "⏸ Приостановить", "done" to "✔ Выполнено", "cancelled" to "✕ Отменить", "assigned" to "↺ Вернуть")
|
||||
private val NEED_COMMENT = setOf("paused", "done", "cancelled")
|
||||
private val REQ_RU = mapOf("new" to "Новая", "approved" to "Одобрена", "ordered" to "Заказано", "done" to "Получено", "rejected" to "Отклонена")
|
||||
private val EQ_RU = mapOf("alarm" to "Авария", "fault" to "Неисправность", "due" to "Требуется ТО", "soon" to "Скоро ТО", "ok" to "В норме")
|
||||
|
||||
fun taskColor(s: String) = when (s) {
|
||||
"assigned" -> Color(0xFF2563EB); "in_progress" -> Color(0xFFD97706); "paused" -> Color(0xFF7C3AED)
|
||||
"done" -> Color(0xFF16A34A); else -> Color(0xFF64748B)
|
||||
}
|
||||
|
||||
fun eqColor(s: String) = when (s) {
|
||||
"alarm" -> Color(0xFFDC2626); "fault" -> Color(0xFFEA580C); "due" -> Color(0xFFD97706); "soon" -> Color(0xFFCA8A04); else -> Color(0xFF16A34A)
|
||||
}
|
||||
|
||||
internal fun dayRu(iso: String) = if (iso.length >= 10) "${iso.substring(8, 10)}.${iso.substring(5, 7)}.${iso.take(4)}" else iso
|
||||
private fun num(v: Double) = if (v == v.toLong().toDouble()) v.toLong().toString() else "%.2f".format(v).trimEnd('0').trimEnd(',', '.')
|
||||
|
||||
/** Загрузка данных экрана с сервера: состояние «загрузка / ошибка / данные» и перезапрос. */
|
||||
class Remote<T> internal constructor(private val load: suspend () -> T) {
|
||||
var data by mutableStateOf<T?>(null)
|
||||
var error by mutableStateOf<String?>(null)
|
||||
var loading by mutableStateOf(false)
|
||||
suspend fun reload() {
|
||||
loading = true
|
||||
try { data = load(); error = null } catch (e: Throwable) { error = e.humanMessage() } finally { loading = false }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun <T> rememberRemote(vararg keys: Any, load: suspend () -> T): Remote<T> {
|
||||
val r = remember(*keys) { Remote(load) }
|
||||
LaunchedEffect(r) { r.reload() }
|
||||
return r
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun RefreshAction(r: Remote<*>) {
|
||||
val scope = rememberCoroutineScope()
|
||||
if (r.loading) Box(Modifier.size(48.dp), Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp) }
|
||||
else IconButton({ scope.launch { r.reload() } }) { Icon(Icons.Default.Refresh, contentDescription = "Обновить") }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StateLine(r: Remote<*>, empty: String) {
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), Alignment.Center) {
|
||||
Text(r.error?.let { "⚠ $it" } ?: if (r.loading || r.data == null) "Загрузка…" else empty,
|
||||
color = if (r.error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================== Суточные задания ===============================
|
||||
@Composable
|
||||
fun TasksScreen(onOpen: (Long) -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val api = ctx.repo.client
|
||||
val scope = rememberCoroutineScope()
|
||||
var all by remember { mutableStateOf(false) }
|
||||
val r = rememberRemote(all) { api.api().tasks(mine = true, active = !all) }
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
WhileVisible(60_000) { r.reload() }
|
||||
|
||||
Scaffold(topBar = {
|
||||
ScreenBar(title = { Column { Text("Задачи и поручения"); Text(if (all) "Все мои" else "Мои активные", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary) } },
|
||||
actions = { TextButton({ all = !all }) { Text(if (all) "Активные" else "Все") }; RefreshAction(r) })
|
||||
}, floatingActionButton = {
|
||||
androidx.compose.material3.ExtendedFloatingActionButton(onClick = { creating = true }, text = { Text("Задание себе") }, icon = { Text("+") })
|
||||
}) { pad ->
|
||||
val list = r.data.orEmpty()
|
||||
if (list.isEmpty()) Box(Modifier.padding(pad)) { StateLine(r, "Заданий нет") }
|
||||
else LazyColumn(Modifier.padding(pad), contentPadding = PaddingValues(12.dp), verticalArrangement = Gap) {
|
||||
items(list, key = { it.id }) { t -> TaskCard(t) { onOpen(t.id) } }
|
||||
}
|
||||
}
|
||||
if (creating) NewTaskDialog(onDismiss = { creating = false }) { title, desc, hours ->
|
||||
scope.launch {
|
||||
try { val t = api.api().createTask(TaskCreateReq(title, desc, laborHours = hours)); creating = false; onOpen(t.id) }
|
||||
catch (e: Throwable) { toast(ctx, e.humanMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TaskCard(t: TaskDto, onClick: () -> Unit) {
|
||||
ElevatedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Text(t.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Badge(TASK_RU[t.status] ?: t.status, taskColor(t.status))
|
||||
}
|
||||
Text(listOfNotNull(dayRu(t.day), t.equipmentName?.let { "⚙ $it" + (t.unitName?.let { u -> " / $u" } ?: "") },
|
||||
t.laborHours?.let { "⏱ ${num(it)} ч" }, if (t.photos.isNotEmpty()) "📷 ${t.photos.size}" else null).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
if (t.lastComment.isNotBlank()) Text("💬 ${t.lastComment}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun NewTaskDialog(onDismiss: () -> Unit, onSave: (String, String, Double?) -> Unit) {
|
||||
var title by remember { mutableStateOf("") }
|
||||
var desc by remember { mutableStateOf("") }
|
||||
var hours by remember { mutableStateOf("") }
|
||||
val h = hours.replace(',', '.').trim().toDoubleOrNull()
|
||||
AlertDialog(onDismissRequest = onDismiss, title = { Text("Новое задание себе") }, text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(title, { title = it }, label = { Text("Что сделать") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(desc, { desc = it }, label = { Text("Подробности") }, minLines = 2, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(hours, { hours = it.filter { c -> c.isDigit() || c == '.' || c == ',' }.take(6) }, label = { Text("Трудоёмкость (норма), ч") },
|
||||
singleLine = true, isError = hours.isNotBlank() && h == null, modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Decimal),
|
||||
supportingText = { Text("Необязательно. Если не указать, в отчёт пойдёт фактическое время в работе.") })
|
||||
Text("Задание другим ставят инженер, модератор или администратор.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
}, confirmButton = { TextButton({ onSave(title.trim(), desc.trim(), h) }, enabled = title.trim().length >= 2 && (hours.isBlank() || h != null)) { Text("Создать") } },
|
||||
dismissButton = { TextButton(onDismiss) { Text("Отмена") } })
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun TaskScreen(id: Long, onBack: () -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val client = ctx.repo.client
|
||||
val scope = rememberCoroutineScope()
|
||||
val r = rememberRemote(id) { client.api().task(id) }
|
||||
var comment by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var view by remember { mutableStateOf<Long?>(null) }
|
||||
var capture by remember { mutableStateOf<File?>(null) }
|
||||
var transferring by remember { mutableStateOf(false) }
|
||||
|
||||
fun run(block: suspend () -> Unit) {
|
||||
if (busy) return
|
||||
busy = true
|
||||
scope.launch { try { block() } catch (e: Throwable) { toast(ctx, e.humanMessage()) } finally { busy = false } }
|
||||
}
|
||||
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
|
||||
val f = capture; capture = null
|
||||
if (f == null) return@rememberLauncherForActivityResult
|
||||
if (!ok) { f.delete(); return@rememberLauncherForActivityResult }
|
||||
run {
|
||||
val t = withContext(Dispatchers.IO) {
|
||||
val dst = File(ctx.cacheDir, "capture/task_${System.currentTimeMillis()}.jpg")
|
||||
downscaleJpeg(f, dst); f.delete()
|
||||
try { client.api().taskPhoto(id, dst.asRequestBody("image/jpeg".toMediaType())) } finally { dst.delete() }
|
||||
}
|
||||
r.data = t; toast(ctx, "Фото добавлено")
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(topBar = { ScreenBar(title = { Text("Задание №$id") }, navigationIcon = { BackIcon(onBack) }, actions = { RefreshAction(r) }) }) { pad ->
|
||||
val t = r.data
|
||||
if (t == null) { Box(Modifier.padding(pad)) { StateLine(r, "") }; return@Scaffold }
|
||||
LazyColumn(Modifier.padding(pad).imePadding(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
item {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Text(t.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Badge(TASK_RU[t.status] ?: t.status, taskColor(t.status))
|
||||
}
|
||||
Text("День: ${dayRu(t.day)} · исполнитель: ${t.assigneeName ?: "—"}", style = MaterialTheme.typography.bodyMedium)
|
||||
t.createdByName?.let { Text("Поставил: $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) }
|
||||
t.equipmentName?.let { Text("⚙ $it" + (t.unitName?.let { u -> " / $u" } ?: ""), style = MaterialTheme.typography.bodyMedium) }
|
||||
t.laborHours?.let { Text("⏱ Трудоёмкость (норма): ${num(it)} ч", style = MaterialTheme.typography.bodyMedium) }
|
||||
if (t.description.isNotBlank()) Text(t.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
val next = if (t.canChange) TASK_NEXT[t.status].orEmpty() else emptyList()
|
||||
if (next.isNotEmpty()) item {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SectionTitle("Статус")
|
||||
OutlinedTextField(comment, { comment = it }, label = { Text("Комментарий") }, minLines = 2, modifier = Modifier.fillMaxWidth(),
|
||||
supportingText = { Text("Обязателен для «приостановлен», «выполнен», «отменён»") })
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
next.forEach { s ->
|
||||
val need = s in NEED_COMMENT || t.status in setOf("done", "cancelled")
|
||||
val colors = if (s == "cancelled") ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) else ButtonDefaults.buttonColors()
|
||||
Button({
|
||||
if (need && comment.isBlank()) toast(ctx, "Напишите комментарий")
|
||||
else run { r.data = client.api().taskStatus(id, TaskStatusReq(s, comment.trim())); comment = ""; toast(ctx, TASK_RU[s] ?: s) }
|
||||
}, enabled = !busy, colors = colors) { Text(TASK_BTN[s] ?: s) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SectionTitle("Фото (${t.photos.size})")
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
t.photos.forEach { pid ->
|
||||
AsyncImage(model = client.taskPhotoUrl(pid), contentDescription = "Фото", contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(84.dp).clip(RoundedCornerShape(10.dp)).clickable { view = pid })
|
||||
}
|
||||
}
|
||||
if (t.canChange || t.canEdit) OutlinedButton({
|
||||
val dir = File(ctx.cacheDir, "capture").apply { mkdirs() }
|
||||
val f = File.createTempFile("cap_", ".jpg", dir)
|
||||
capture = f
|
||||
camera.launch(FileProvider.getUriForFile(ctx, "${ctx.packageName}.files", f))
|
||||
}, enabled = !busy) { Text("📷 Сфотографировать") }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (t.canTransfer) item {
|
||||
OutlinedButton({ transferring = true }, enabled = !busy, modifier = Modifier.fillMaxWidth()) { Text("⇄ Передать задание другому сотруднику") }
|
||||
}
|
||||
item {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SectionTitle("История")
|
||||
t.events.orEmpty().forEach { e ->
|
||||
Text("${fmtShort(e.at)} · ${e.userName ?: "—"} · " + if (e.kind == "transfer") "⇄ передано: ${e.fromUserName ?: "—"} → ${e.toUserName ?: "—"}"
|
||||
else (if (e.fromStatus.isNotEmpty()) "${TASK_RU[e.fromStatus]} → " else "") + (TASK_RU[e.toStatus] ?: e.toStatus),
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
if (e.comment.isNotBlank()) Text(e.comment, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (transferring) r.data?.let { t ->
|
||||
TransferDialog(t.assigneeId, onDismiss = { transferring = false }) { to, why ->
|
||||
run { r.data = client.api().transferTask(id, TransferReq(to, why)); transferring = false; toast(ctx, "Задание передано") }
|
||||
}
|
||||
}
|
||||
view?.let { pid ->
|
||||
Dialog(onDismissRequest = { view = null }) {
|
||||
AsyncImage(model = client.taskPhotoUrl(pid), contentDescription = "Фото", contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxWidth().clickable { view = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Передача задания: поиск сотрудника по имени/фамилии и обязательная причина. */
|
||||
@Composable
|
||||
private fun TransferDialog(current: Long, onDismiss: () -> Unit, onSave: (Long, String) -> Unit) {
|
||||
val client = LocalContext.current.repo.client
|
||||
val people = rememberRemote(Unit) { client.api().people().filter { it.isActive && it.id != current } }
|
||||
var q by remember { mutableStateOf("") }
|
||||
var why by remember { mutableStateOf("") }
|
||||
var pick by remember { mutableStateOf<PersonDto?>(null) }
|
||||
val words = q.trim().lowercase().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||
val shown = people.data.orEmpty().filter { p -> words.all { (p.displayName + " " + (p.position?.name ?: "")).lowercase().contains(it) } }.take(30)
|
||||
AlertDialog(onDismissRequest = onDismiss, title = { Text("Передать задание") }, text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(q, { q = it }, label = { Text("Имя или фамилия") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
LazyColumn(Modifier.fillMaxWidth().size(width = 400.dp, height = 220.dp)) {
|
||||
items(shown, key = { it.id }) { p ->
|
||||
Row(Modifier.fillMaxWidth().clickable { pick = p }.padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(if (pick?.id == p.id) "● " else "○ ", color = MaterialTheme.colorScheme.primary)
|
||||
Column { Text(p.displayName); p.position?.let { Text(it.name, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(why, { why = it }, label = { Text("Причина передачи") }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}, confirmButton = { TextButton({ pick?.let { onSave(it.id, why.trim()) } }, enabled = pick != null && why.isNotBlank()) { Text("Передать") } },
|
||||
dismissButton = { TextButton(onDismiss) { Text("Отмена") } })
|
||||
}
|
||||
|
||||
// =============================== Оборудование и инциденты ===============================
|
||||
@Composable
|
||||
fun EquipmentScreen(cranes: Boolean = false, onOpen: (Long) -> Unit) {
|
||||
val repo = LocalContext.current.repo
|
||||
val client = repo.client
|
||||
val me by repo.me.collectAsState()
|
||||
val separate = me?.craneMap == true // краны — отдельная служба: в «Оборудовании» их нет, у них свой раздел
|
||||
val r = rememberRemote(cranes, separate) {
|
||||
client.api().equipment().filter { it.isActive && (!separate || it.isCrane == cranes) } to client.api().health()
|
||||
}
|
||||
var q by remember { mutableStateOf("") }
|
||||
WhileVisible(60_000) { r.reload() }
|
||||
Scaffold(topBar = { ScreenBar(title = { Text(if (cranes) "Краны" else "Оборудование") }, actions = { RefreshAction(r) }) }) { pad ->
|
||||
val (eqs, health) = r.data ?: (emptyList<EquipmentItemDto>() to emptyMap())
|
||||
Column(Modifier.padding(pad).fillMaxSize()) {
|
||||
OutlinedTextField(q, { q = it }, placeholder = { Text("Поиск машины") }, singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp))
|
||||
val shown = eqs.filter { q.isBlank() || it.name.contains(q.trim(), true) || it.inventoryNo.contains(q.trim(), true) }
|
||||
if (shown.isEmpty()) StateLine(r, if (cranes) "Кранов нет" else "Оборудования нет")
|
||||
else LazyColumn(contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(shown, key = { it.id }) { e ->
|
||||
val h = health[e.id.toString()] ?: HealthDto()
|
||||
ElevatedCard(Modifier.fillMaxWidth().clickable { onOpen(e.id) }) {
|
||||
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text((if (h.state == "alarm") "❗ " else "") + e.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
if (e.inventoryNo.isNotBlank()) Text("№ ${e.inventoryNo}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
Badge(EQ_RU[h.state] ?: h.state, eqColor(h.state))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EquipmentDetailScreen(id: Long, onBack: () -> Unit, onHistory: () -> Unit = {}) {
|
||||
val ctx = LocalContext.current
|
||||
val client = ctx.repo.client
|
||||
val me = ctx.repo.me.value
|
||||
val scope = rememberCoroutineScope()
|
||||
val r = rememberRemote(id) {
|
||||
val api = client.api()
|
||||
Triple(api.equipment().first { it.id == id }, api.units(id), api.incidents(equipmentId = id)) to (api.health()[id.toString()] ?: HealthDto())
|
||||
}
|
||||
var form by remember { mutableStateOf<String?>(null) } // incident | fault
|
||||
var closing by remember { mutableStateOf<IncidentDto?>(null) }
|
||||
var escalating by remember { mutableStateOf<IncidentDto?>(null) }
|
||||
Scaffold(topBar = { ScreenBar(title = { Text(r.data?.first?.first?.name ?: "Машина") }, navigationIcon = { BackIcon(onBack) }, actions = { RefreshAction(r) }) }) { pad ->
|
||||
val d = r.data
|
||||
if (d == null) { Box(Modifier.padding(pad)) { StateLine(r, "") }; return@Scaffold }
|
||||
val (triple, h) = d
|
||||
val (eq, units, incs) = triple
|
||||
LazyColumn(Modifier.padding(pad), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
item {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(eq.name + if (eq.inventoryNo.isNotBlank()) " (№ ${eq.inventoryNo})" else "", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
||||
Badge(EQ_RU[h.state] ?: h.state, eqColor(h.state))
|
||||
}
|
||||
if (h.openIncidents > 0) Text("Открытых инцидентов: ${h.openIncidents}", color = eqColor("alarm"))
|
||||
if (h.openFaults > 0) Text("Открытых неисправностей: ${h.openFaults}", color = eqColor("fault"))
|
||||
if (h.due + h.soon > 0) Text("Узлы: требуется ТО — ${h.due}, скоро — ${h.soon}", color = eqColor("soon"))
|
||||
Button({ form = "incident" }, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth()) {
|
||||
Text("❗ Зафиксировать инцидент")
|
||||
}
|
||||
Button({ form = "fault" }, colors = ButtonDefaults.buttonColors(containerColor = eqColor("fault")), modifier = Modifier.fillMaxWidth()) {
|
||||
Text("⚠ Сообщить о неисправности")
|
||||
}
|
||||
OutlinedButton(onHistory, modifier = Modifier.fillMaxWidth()) { Text("📜 История машины") }
|
||||
}
|
||||
}
|
||||
}
|
||||
item { SectionTitle("Инциденты и неисправности (${incs.size})") }
|
||||
if (incs.isEmpty()) item { Text("Инцидентов и неисправностей не было", color = MaterialTheme.colorScheme.secondary) }
|
||||
items(incs, key = { it.id }) { i ->
|
||||
val fault = i.kind == "fault"
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Text((if (i.status == "open") (if (fault) "⚠ " else "❗ ") else "") + i.title, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Badge(if (i.status == "open") (if (fault) "неисправность" else "открыт") else "закрыт",
|
||||
if (i.status == "open") eqColor(if (fault) "fault" else "alarm") else eqColor("ok"))
|
||||
}
|
||||
Text("${fmtFull(i.occurredAt)} · ${i.authorName ?: "—"}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
if (i.description.isNotBlank()) Text(i.description)
|
||||
if (i.units.isNotEmpty()) Text("Узлы: " + i.units.joinToString { it.unitName + if (it.replaced) " (заменён)" else "" }, style = MaterialTheme.typography.bodySmall)
|
||||
if (i.replaced) Text("🔧 Была замена" + if (i.replacementNote.isNotBlank()) ": ${i.replacementNote}" else "", style = MaterialTheme.typography.bodySmall)
|
||||
if (i.closeNote.isNotBlank()) Text("Закрыт: ${i.closeNote}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
val canClose = i.status == "open" && (i.authorId == me?.id || me?.isEngineer == true || me?.isManager == true)
|
||||
if (canClose) OutlinedButton({ closing = i }) { Text(if (fault) "✔ Неисправность устранена" else "✔ Закрыть инцидент") }
|
||||
if (fault && i.status == "open") TextButton({ escalating = i }) { Text("❗ Перевести в инцидент (оборудование остановлено)", color = eqColor("alarm")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
form?.let { kind ->
|
||||
IncidentDialog(units.filter { it.isActive }, kind, onDismiss = { form = null }) { req ->
|
||||
scope.launch {
|
||||
try {
|
||||
client.api().createIncident(req.copy(equipmentId = id)); form = null
|
||||
toast(ctx, if (kind == "fault") "Неисправность зарегистрирована" else "Инцидент зафиксирован"); r.reload()
|
||||
} catch (e: Throwable) { toast(ctx, e.humanMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
closing?.let { i ->
|
||||
var note by remember { mutableStateOf("") }
|
||||
AlertDialog(onDismissRequest = { closing = null }, title = { Text(if (i.kind == "fault") "Неисправность устранена?" else "Закрыть инцидент?") }, text = {
|
||||
OutlinedTextField(note, { note = it }, label = { Text("Как устранено") }, minLines = 2, modifier = Modifier.fillMaxWidth())
|
||||
}, confirmButton = { TextButton({
|
||||
scope.launch {
|
||||
try { client.api().closeIncident(i.id, CloseReq(note.trim())); closing = null; r.reload() }
|
||||
catch (e: Throwable) { toast(ctx, e.humanMessage()) }
|
||||
}
|
||||
}) { Text("Закрыть") } }, dismissButton = { TextButton({ closing = null }) { Text("Отмена") } })
|
||||
}
|
||||
escalating?.let { i ->
|
||||
var note by remember { mutableStateOf("") }
|
||||
AlertDialog(onDismissRequest = { escalating = null }, title = { Text("Перевести в инцидент?") }, text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Оборудование остановлено — машина получит состояние «Авария».", style = MaterialTheme.typography.bodySmall)
|
||||
OutlinedTextField(note, { note = it }, label = { Text("Что случилось") }, minLines = 2, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}, confirmButton = { TextButton({
|
||||
scope.launch {
|
||||
try { client.api().escalateIncident(i.id, CloseReq(note.trim())); escalating = null; toast(ctx, "Переведено в инцидент"); r.reload() }
|
||||
catch (e: Throwable) { toast(ctx, e.humanMessage()) }
|
||||
}
|
||||
}) { Text("Перевести") } }, dismissButton = { TextButton({ escalating = null }) { Text("Отмена") } })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IncidentDialog(units: List<UnitBriefDto>, kind: String, onDismiss: () -> Unit, onSave: (IncidentReq) -> Unit) {
|
||||
val fault = kind == "fault"
|
||||
var title by remember { mutableStateOf("") }
|
||||
var desc by remember { mutableStateOf("") }
|
||||
var replaced by remember { mutableStateOf(false) }
|
||||
var note by remember { mutableStateOf("") }
|
||||
val picked = remember { mutableStateMapOf<Long, Boolean>() } // узел -> заменён
|
||||
AlertDialog(onDismissRequest = onDismiss, title = { Text(if (fault) "Сообщение о неисправности" else "Инцидент") }, text = {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (fault) item { Text("Оборудование работает, но замечена проблема. Если станет хуже — переведите сообщение в инцидент.",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) }
|
||||
item { OutlinedTextField(title, { title = it }, label = { Text(if (fault) "Что замечено (кратко)" else "Что произошло (кратко)") }, modifier = Modifier.fillMaxWidth()) }
|
||||
item { OutlinedTextField(desc, { desc = it }, label = { Text("Подробности") }, minLines = 2, modifier = Modifier.fillMaxWidth()) }
|
||||
if (units.isNotEmpty()) item { Text(if (fault) "Какой узел (если известно)" else "Затронутые узлы", fontWeight = FontWeight.SemiBold) }
|
||||
items(units, key = { it.id }) { u ->
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(picked.containsKey(u.id), { if (it) picked[u.id] = false else picked.remove(u.id) })
|
||||
Text(u.name)
|
||||
}
|
||||
if (!fault && picked.containsKey(u.id)) Row(Modifier.padding(start = 32.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(picked[u.id] == true, { picked[u.id] = it })
|
||||
Text("заменён (ресурс узла сбросится)", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fault) item {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(replaced, { replaced = it }); Text("Была замена деталей") }
|
||||
}
|
||||
if (!fault) item { OutlinedTextField(note, { note = it }, label = { Text("Что заменено") }, modifier = Modifier.fillMaxWidth()) }
|
||||
}
|
||||
}, confirmButton = {
|
||||
TextButton({ onSave(IncidentReq(0, kind, title.trim(), desc.trim(), picked.map { (k, v) -> IncidentUnitReq(k, v && !fault) }, replaced && !fault, note.trim())) },
|
||||
enabled = title.trim().length >= 2) { Text(if (fault) "Сообщить" else "Зафиксировать") }
|
||||
}, dismissButton = { TextButton(onDismiss) { Text("Отмена") } })
|
||||
}
|
||||
|
||||
// =============================== Инструмент ===============================
|
||||
@Composable
|
||||
fun ToolsScreen() {
|
||||
val client = LocalContext.current.repo.client
|
||||
val r = rememberRemote(Unit) { client.api().myInventory() }
|
||||
Scaffold(topBar = { ScreenBar(title = { Text("Мой инструмент") }, actions = { RefreshAction(r) }) }) { pad ->
|
||||
val list: List<StockDto> = r.data.orEmpty()
|
||||
if (list.isEmpty()) Box(Modifier.padding(pad)) { StateLine(r, "За вами ничего не числится") }
|
||||
else LazyColumn(Modifier.padding(pad), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item { Text("Выдано лично вам. Не хватает инструмента — оформите заявку в меню.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) }
|
||||
items(list) { s ->
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(s.itemName, fontWeight = FontWeight.SemiBold)
|
||||
Text(listOf(s.category, s.inventoryNo.let { if (it.isNotBlank()) "№ $it" else "" }, "с ${fmtShort(s.since)}").filter { it.isNotBlank() }.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
Text("${num(s.qty)} ${s.unit}", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RequestsScreen() {
|
||||
val ctx = LocalContext.current
|
||||
val client = ctx.repo.client
|
||||
val scope = rememberCoroutineScope()
|
||||
val r = rememberRemote(Unit) { client.api().toolRequests(mine = true) }
|
||||
var what by remember { mutableStateOf("") }
|
||||
var why by remember { mutableStateOf("") }
|
||||
var link by remember { mutableStateOf("") }
|
||||
var qty by remember { mutableStateOf("1") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
Scaffold(topBar = { ScreenBar(title = { Text("Заявки на инструмент") }, actions = { RefreshAction(r) }) }) { pad ->
|
||||
LazyColumn(Modifier.padding(pad).imePadding(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
item {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SectionTitle("Новая заявка")
|
||||
OutlinedTextField(what, { what = it }, label = { Text("Что купить *") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(why, { why = it }, label = { Text("Зачем нужно *") }, minLines = 2, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(link, { link = it }, label = { Text("Ссылка на товар (https://…)") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(qty, { qty = it }, label = { Text("Количество") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
Button({
|
||||
busy = true
|
||||
scope.launch {
|
||||
try {
|
||||
client.api().createToolRequest(ToolRequestReq(what.trim(), why.trim(), link.trim(), qty.replace(',', '.').toDoubleOrNull() ?: 1.0))
|
||||
what = ""; why = ""; link = ""; qty = "1"; toast(ctx, "Заявка отправлена"); r.reload()
|
||||
} catch (e: Throwable) { toast(ctx, e.humanMessage()) } finally { busy = false }
|
||||
}
|
||||
}, enabled = !busy && what.trim().length >= 2 && why.trim().length >= 2, modifier = Modifier.fillMaxWidth()) { Text("Отправить заявку") }
|
||||
}
|
||||
}
|
||||
}
|
||||
item { SectionTitle("Мои заявки") }
|
||||
val list: List<ToolRequestDto> = r.data.orEmpty()
|
||||
if (list.isEmpty()) item { Text(r.error?.let { "⚠ $it" } ?: "Заявок пока нет", color = MaterialTheme.colorScheme.secondary) }
|
||||
items(list, key = { it.id }) { q ->
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("№${q.id}. ${q.what}", fontWeight = FontWeight.SemiBold)
|
||||
Text("в количестве ${num(q.qty)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
Badge(REQ_RU[q.status] ?: q.status, if (q.status == "rejected") Color(0xFF64748B) else if (q.status == "done") Color(0xFF16A34A) else Color(0xFF2563EB))
|
||||
}
|
||||
Text(q.why, style = MaterialTheme.typography.bodySmall)
|
||||
if (q.answer.isNotBlank()) Text("Ответ: ${q.answer}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================== История машины ===============================
|
||||
private val HIST = linkedMapOf("incident" to "❗ Инциденты", "service" to "🔧 Обслуживание", "meter" to "⏱ Наработка", "order" to "📋 Наряды", "task" to "🗓 Задания")
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun HistoryScreen(id: Long, onBack: () -> Unit) {
|
||||
val client = LocalContext.current.repo.client
|
||||
var types by remember { mutableStateOf(HIST.keys.toSet()) }
|
||||
val r = rememberRemote(id, types) { client.api().history(id, types.joinToString(",")) }
|
||||
Scaffold(topBar = { ScreenBar(title = { Text("История машины") }, navigationIcon = { BackIcon(onBack) }, actions = { RefreshAction(r) }) }) { pad ->
|
||||
Column(Modifier.padding(pad).fillMaxSize()) {
|
||||
FlowRow(Modifier.padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
HIST.forEach { (k, label) ->
|
||||
androidx.compose.material3.FilterChip(selected = k in types, onClick = { types = if (k in types) types - k else types + k },
|
||||
label = { Text(label + (r.data?.counts?.get(k)?.let { " $it" } ?: "")) })
|
||||
}
|
||||
}
|
||||
val rows = r.data?.rows.orEmpty()
|
||||
if (rows.isEmpty()) StateLine(r, "Событий нет")
|
||||
else LazyColumn(contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(rows) { e ->
|
||||
val c = when (e.severity) { "bad" -> eqColor("alarm"); "ok" -> eqColor("ok"); "warn" -> eqColor("soon"); else -> MaterialTheme.colorScheme.outline }
|
||||
Row {
|
||||
Box(Modifier.size(width = 4.dp, height = 56.dp).clip(RoundedCornerShape(2.dp)).then(Modifier.padding(0.dp))) {
|
||||
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { drawRect(c) }
|
||||
}
|
||||
Column(Modifier.padding(start = 10.dp)) {
|
||||
Text(e.title, fontWeight = FontWeight.SemiBold)
|
||||
if (e.text.isNotBlank()) Text(e.text, style = MaterialTheme.typography.bodySmall)
|
||||
Text("${fmtFull(e.at)} · ${e.typeRu}" + (e.userName?.let { " · $it" } ?: ""), style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package ru.checklists.app.ui
|
||||
|
||||
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.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
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.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.checklists.app.BuildConfig
|
||||
import ru.checklists.app.repo
|
||||
import ru.checklists.app.util.fmtMillis
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(onLoggedOut: () -> Unit) {
|
||||
val repo = LocalContext.current.repo
|
||||
val scope = rememberCoroutineScope()
|
||||
val me by repo.me.collectAsState()
|
||||
val last by repo.lastSync.collectAsState()
|
||||
val syncing by repo.syncing.collectAsState()
|
||||
val error by repo.lastError.collectAsState()
|
||||
var confirmLogout by remember { mutableStateOf(false) }
|
||||
var unsynced by remember { mutableStateOf(0) }
|
||||
|
||||
Scaffold(topBar = {
|
||||
ScreenBar(title = { Text("Настройки") })
|
||||
}) { pad ->
|
||||
Column(Modifier.padding(pad).verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Пользователь", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.secondary)
|
||||
Text(me?.displayName ?: "—", style = MaterialTheme.typography.titleMedium)
|
||||
me?.position?.let { Text("${it.name} · " + if (me?.onShift == true) "на смене" else if (me?.onShift == false) "не на смене" else "") }
|
||||
if (me?.isAdmin == true) Text("Администратор") else if (me?.isModerator == true) Text("Модератор")
|
||||
if (me?.isStorekeeper == true) Text("Администратор склада")
|
||||
|
||||
Text("Сервер", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(top = 8.dp))
|
||||
Text(repo.prefs.baseUrl)
|
||||
Text(
|
||||
"Последняя синхронизация: " + (last?.let { fmtMillis(it) } ?: "ещё не было"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
error?.let { Text("⚠ $it", color = MaterialTheme.colorScheme.error) }
|
||||
OutlinedButton({ repo.syncSoon() }, enabled = !syncing, modifier = Modifier.fillMaxWidth()) { Text(if (syncing) "Синхронизация…" else "Синхронизировать сейчас") }
|
||||
|
||||
Text("Уведомления о новых нарядах приходят в фоне (проверка примерно раз в 15 минут и при открытии приложения). Разрешите уведомления в настройках Android.",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(top = 4.dp))
|
||||
|
||||
Button(
|
||||
onClick = { scope.launch { unsynced = repo.unsyncedCount(); confirmLogout = true } },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
) { Text("Выйти") }
|
||||
Text("Версия ${BuildConfig.VERSION_NAME}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmLogout) AlertDialog(
|
||||
onDismissRequest = { confirmLogout = false },
|
||||
title = { Text("Выйти из аккаунта?") },
|
||||
text = {
|
||||
Text(if (unsynced > 0) "Не отправлено нарядов: $unsynced. При выходе эти данные будут потеряны. Сначала дождитесь синхронизации."
|
||||
else "Локальные данные на устройстве будут удалены.")
|
||||
},
|
||||
confirmButton = { TextButton({ confirmLogout = false; scope.launch { repo.logout(); onLoggedOut() } }) { Text(if (unsynced > 0) "Всё равно выйти" else "Выйти") } },
|
||||
dismissButton = { TextButton({ confirmLogout = false }) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package ru.checklists.app.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.checklists.app.data.api.humanMessage
|
||||
import ru.checklists.app.repo
|
||||
|
||||
/** Открыть боковое меню (передаётся из корня приложения). */
|
||||
val LocalOpenMenu = compositionLocalOf<() -> Unit> { {} }
|
||||
|
||||
/**
|
||||
* Заголовок экрана без панели: кнопка меню (или «назад»), название и действия.
|
||||
* Навигация по разделам — только через боковое меню.
|
||||
*/
|
||||
@Composable
|
||||
fun ScreenBar(
|
||||
title: @Composable () -> Unit,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
val open = LocalOpenMenu.current
|
||||
Row(Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 4.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (navigationIcon != null) navigationIcon()
|
||||
else IconButton(open) { Icon(Icons.Default.Menu, contentDescription = "Меню") }
|
||||
Column(Modifier.weight(1f).padding(start = 4.dp)) {
|
||||
androidx.compose.material3.ProvideTextStyle(MaterialTheme.typography.titleLarge) { title() }
|
||||
}
|
||||
actions()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BackIcon(onBack: () -> Unit) = IconButton(onBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Назад") }
|
||||
|
||||
/** Содержимое бокового меню. */
|
||||
@Composable
|
||||
fun AppDrawer(current: String?, go: (String) -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val repo = ctx.repo
|
||||
val me by repo.me.collectAsState()
|
||||
val eng = me?.isEngineer == true || me?.isManager == true
|
||||
val store = me?.isAdmin == true || me?.isStorekeeper == true
|
||||
fun web(hash: String) = ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(repo.prefs.baseUrl + "/" + hash)))
|
||||
var feedback by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
|
||||
ModalDrawerSheet {
|
||||
Column(Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 16.dp)) {
|
||||
Text(me?.displayName ?: "—", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 12.dp), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(listOfNotNull(me?.position?.name, when {
|
||||
me?.isAdmin == true -> "администратор"; me?.isModerator == true -> "модератор"; me?.isStorekeeper == true -> "администратор склада"; else -> null
|
||||
}).joinToString(" · ").ifEmpty { "—" }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.padding(horizontal = 12.dp))
|
||||
if (me?.demoMode == true) Text("🎭 Демо-режим (только у вас)", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp))
|
||||
HorizontalDivider(Modifier.padding(vertical = 10.dp))
|
||||
|
||||
@Composable
|
||||
fun item(route: String, icon: String, label: String) = NavigationDrawerItem(
|
||||
label = { Text(label) }, icon = { Text(icon) }, selected = current == route, onClick = { go(route) },
|
||||
)
|
||||
@Composable
|
||||
fun group(t: String) = Text(t.uppercase(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 4.dp))
|
||||
|
||||
group("Работа")
|
||||
item("work", "🎯", "Задачи — всё в одном месте")
|
||||
item("orders", "📋", "Наряды")
|
||||
item("tasks", "🗓", "Задачи и поручения")
|
||||
group("Оборудование")
|
||||
item("equipment", "❗", "Оборудование и инциденты")
|
||||
if (eng) item("state", "🔧", "Состояние узлов")
|
||||
if (me?.craneMap == true) {
|
||||
group("Краны")
|
||||
item("cranes", "🏗", "Краны")
|
||||
NavigationDrawerItem(label = { Text("Карта кранов (сайт)") }, icon = { Text("🗺") }, selected = false, onClick = { web("#/map/cranes") })
|
||||
}
|
||||
group("Инструмент")
|
||||
item("tools", "🧰", "Мой инструмент")
|
||||
item("requests", "🛒", "Заявки на инструмент")
|
||||
group("В полной версии сайта")
|
||||
NavigationDrawerItem(label = { Text("Карта завода") }, icon = { Text("🗺") }, selected = false, onClick = { web("#/map") })
|
||||
|
||||
NavigationDrawerItem(label = { Text("Сотрудники") }, icon = { Text("👥") }, selected = false, onClick = { web("#/people") })
|
||||
if (store || eng) NavigationDrawerItem(label = { Text("Склады и учёт") }, icon = { Text("🏬") }, selected = false, onClick = { web("#/inventory") })
|
||||
if (eng) NavigationDrawerItem(label = { Text("Отчёты") }, icon = { Text("📊") }, selected = false, onClick = { web("#/reports") })
|
||||
HorizontalDivider(Modifier.padding(vertical = 10.dp))
|
||||
if (AssistantChat.enabled) item("assistant", "🤖", "Помощник")
|
||||
item("settings", "⚙", "Настройки")
|
||||
Text("Карта, сотрудники и отчёты откроются в браузере (вход — тем же логином).", style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(12.dp))
|
||||
HorizontalDivider(Modifier.padding(vertical = 6.dp))
|
||||
NavigationDrawerItem(label = { Text("Инструкция") }, icon = { Text("📘") }, selected = false,
|
||||
onClick = { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(repo.prefs.baseUrl + "/guide.pdf"))) })
|
||||
NavigationDrawerItem(label = { Text("Форма для заявки") }, icon = { Text("📨") }, selected = false,
|
||||
onClick = { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(repo.prefs.baseUrl + "/report"))) })
|
||||
// обратная связь по порталу — неприметно, в самом низу меню
|
||||
TextButton({ feedback = true }, modifier = Modifier.padding(horizontal = 4.dp)) {
|
||||
Text("💬 Предложить улучшение или сообщить об ошибке", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (feedback) FeedbackDialog(current) { feedback = false }
|
||||
}
|
||||
|
||||
/** Обратная связь по порталу: идея, ошибка или другое. Ответы видят только администраторы портала. */
|
||||
@Composable
|
||||
fun FeedbackDialog(screen: String?, onClose: () -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
var kind by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf("idea") }
|
||||
var text by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf("") }
|
||||
var busy by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
androidx.compose.material3.AlertDialog(onDismissRequest = onClose, title = { Text("Обратная связь по порталу") }, text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Сообщение получат администраторы портала.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf("idea" to "Идея", "bug" to "Ошибка", "other" to "Другое").forEach { (k, t) ->
|
||||
androidx.compose.material3.FilterChip(selected = kind == k, onClick = { kind = k }, label = { Text(t) })
|
||||
}
|
||||
}
|
||||
androidx.compose.material3.OutlinedTextField(text, { text = it.take(4000) }, label = { Text("Что улучшить или что не работает?") },
|
||||
minLines = 4, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}, confirmButton = {
|
||||
TextButton({
|
||||
busy = true
|
||||
scope.launch {
|
||||
try {
|
||||
ctx.repo.client.api().feedback(ru.checklists.app.data.api.FeedbackReq(kind, text.trim(), "app:" + (screen ?: "")))
|
||||
toast(ctx, "Спасибо! Сообщение отправлено"); onClose()
|
||||
} catch (e: Throwable) { toast(ctx, e.humanMessage()) } finally { busy = false }
|
||||
}
|
||||
}, enabled = !busy && text.trim().length >= 3) { Text("Отправить") }
|
||||
}, dismissButton = { TextButton(onClose) { Text("Отмена") } })
|
||||
}
|
||||
|
||||
/** Полоса «демо-режим» с кнопкой выхода: включил сам или включил администратор — выйти можно отсюда. */
|
||||
@Composable
|
||||
fun DemoBanner() {
|
||||
val ctx = LocalContext.current
|
||||
val repo = ctx.repo
|
||||
val me by repo.me.collectAsState()
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
var asking by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
if (me?.demoMode != true) return
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("🎭 ДЕМО-РЕЖИМ (только у вас): данные тестовые", color = MaterialTheme.colorScheme.tertiary,
|
||||
style = MaterialTheme.typography.labelMedium, modifier = Modifier.weight(1f))
|
||||
androidx.compose.material3.TextButton({ asking = true }) { Text("Выйти") }
|
||||
}
|
||||
if (asking) androidx.compose.material3.AlertDialog(onDismissRequest = { asking = false },
|
||||
title = { Text("Выйти из демо-режима?") }, text = { Text("Вы вернётесь к рабочим данным. Тестовые наряды с телефона будут удалены.") },
|
||||
confirmButton = { androidx.compose.material3.TextButton({
|
||||
asking = false
|
||||
scope.launch { try { repo.leaveDemo(); toast(ctx, "Демо-режим выключен") } catch (e: Throwable) { toast(ctx, e.humanMessage()) } }
|
||||
}) { Text("Выйти") } },
|
||||
dismissButton = { androidx.compose.material3.TextButton({ asking = false }) { Text("Отмена") } })
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionTitle(text: String) = Text(text, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 4.dp))
|
||||
|
||||
val Gap = Arrangement.spacedBy(10.dp)
|
||||
@@ -0,0 +1,222 @@
|
||||
package ru.checklists.app.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.checklists.app.data.api.StateRowDto
|
||||
import ru.checklists.app.data.api.humanMessage
|
||||
import ru.checklists.app.repo
|
||||
import ru.checklists.app.util.fmtFull
|
||||
import ru.checklists.app.util.fmtShort
|
||||
|
||||
private fun stateColor(state: String) = when (state) {
|
||||
"due" -> Color(0xFFDC2626)
|
||||
"soon" -> Color(0xFFD97706)
|
||||
"ok" -> Color(0xFF16A34A)
|
||||
else -> Color(0xFF64748B)
|
||||
}
|
||||
|
||||
private fun num(v: Double) = if (v == v.toLong().toDouble()) v.toLong().toString() else "%.1f".format(v).replace(',', '.')
|
||||
|
||||
private fun remaining(r: StateRowDto): String {
|
||||
val bits = mutableListOf<String>()
|
||||
r.remainingHours?.let { bits += if (it > 0) "осталось ${num(it)} м/ч" else "просрочено на ${num(-it)} м/ч" }
|
||||
r.remainingDays?.let { bits += if (it > 0) "осталось $it дн." else if (it < 0) "просрочено на ${-it} дн." else "срок — сегодня" }
|
||||
return bits.joinToString(" · ").ifEmpty { "—" }
|
||||
}
|
||||
|
||||
/** Состояние обслуживаемых узлов машин: что требует обслуживания сейчас и скоро. Для инженеров, модераторов и админов. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StateScreen() {
|
||||
val ctx = LocalContext.current
|
||||
val repo = ctx.repo
|
||||
val scope = rememberCoroutineScope()
|
||||
val report by repo.stateReport.collectAsState()
|
||||
val loading by repo.stateLoading.collectAsState()
|
||||
val error by repo.stateError.collectAsState()
|
||||
var filter by rememberSaveable { mutableStateOf("attention") }
|
||||
var meterFor by remember { mutableStateOf<StateRowDto?>(null) }
|
||||
var resetFor by remember { mutableStateOf<StateRowDto?>(null) }
|
||||
|
||||
WhileVisible(60_000) { repo.loadState() }
|
||||
|
||||
val rows = report?.rows.orEmpty().filter { if (filter == "attention") it.state == "due" || it.state == "soon" else true }
|
||||
val sum = report?.summary
|
||||
|
||||
Scaffold(topBar = {
|
||||
ScreenBar(
|
||||
title = {
|
||||
Column {
|
||||
Text("Состояние узлов")
|
||||
Text(
|
||||
report?.generatedAt?.let { "Данные на ${fmtShort(it)}" + if (error != null) " (нет связи)" else "" } ?: "Ещё не загружено",
|
||||
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (loading) Box(Modifier.size(48.dp), Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp) }
|
||||
else IconButton({ scope.launch { repo.loadState() } }) { Icon(Icons.Default.Refresh, contentDescription = "Обновить") }
|
||||
},
|
||||
)
|
||||
}) { pad ->
|
||||
Column(Modifier.padding(pad).fillMaxSize()) {
|
||||
LazyRow(contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item { FilterChip(selected = filter == "attention", onClick = { filter = "attention" }, label = { Text("Требуют внимания · ${(sum?.due ?: 0) + (sum?.soon ?: 0)}") }) }
|
||||
item { FilterChip(selected = filter == "all", onClick = { filter = "all" }, label = { Text("Все · ${report?.total ?: 0}") }) }
|
||||
}
|
||||
if (sum != null) Text(
|
||||
"Требуется: ${sum.due} · Скоро: ${sum.soon} · В норме: ${sum.ok}",
|
||||
style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
error?.let { Text("⚠ $it", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)) }
|
||||
if (rows.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), Alignment.Center) {
|
||||
Text(
|
||||
if (report == null) (if (loading) "Загрузка…" else "Нет данных. Нужна связь с сервером.")
|
||||
else if (filter == "attention") "Всё в норме — обслуживание не требуется" else "Обслуживаемых узлов нет",
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
} else LazyColumn(contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(rows, key = { it.unitId }) { r -> UnitCard(r, onMeter = { meterFor = r }, onReset = { resetFor = r }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
meterFor?.let { r ->
|
||||
MeterDialog(r, onDismiss = { meterFor = null }) { hours, correction ->
|
||||
scope.launch {
|
||||
try { repo.addMeter(r.equipmentId, hours, correction); toast(ctx, "Показание сохранено") }
|
||||
catch (e: Throwable) { toast(ctx, e.humanMessage()) }
|
||||
}
|
||||
meterFor = null
|
||||
}
|
||||
}
|
||||
resetFor?.let { r ->
|
||||
ResetDialog(r, onDismiss = { resetFor = null }) { note ->
|
||||
scope.launch {
|
||||
try { repo.resetUnit(r.unitId, note); toast(ctx, "Узел сброшен") }
|
||||
catch (e: Throwable) { toast(ctx, e.humanMessage()) }
|
||||
}
|
||||
resetFor = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnitCard(r: StateRowDto, onMeter: () -> Unit, onReset: () -> Unit) {
|
||||
val color = stateColor(r.state)
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(r.unit, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(listOf(r.equipment + if (r.inventoryNo.isNotBlank()) " (${r.inventoryNo})" else "", r.location).filter { it.isNotBlank() }.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
Badge(r.stateRu.ifBlank { r.state }, color)
|
||||
}
|
||||
Text(r.rule, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
LinearProgressIndicator(progress = { (r.percent / 100f).coerceIn(0f, 1f) }, color = color, modifier = Modifier.fillMaxWidth())
|
||||
Text(remaining(r), fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
"Обслужен ${fmtFull(r.lastServiceAt).take(10)} при ${num(r.lastServiceHours)} м/ч · сейчас ${num(r.meterHours)} м/ч",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
val dates = listOfNotNull(r.dueDate?.let { "срок ${it.takeLast(2)}.${it.substring(5, 7)}.${it.take(4)}" }, r.forecastDate?.let { "прогноз ${it.takeLast(2)}.${it.substring(5, 7)}.${it.take(4)}" })
|
||||
if (dates.isNotEmpty()) Text(dates.joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onMeter) { Text("Наработка") }
|
||||
OutlinedButton(onReset) { Text("↺ Сбросить") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MeterDialog(r: StateRowDto, onDismiss: () -> Unit, onSave: (Double, Boolean) -> Unit) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
var correction by remember { mutableStateOf(false) }
|
||||
val hours = text.replace(',', '.').toDoubleOrNull()
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Наработка: ${r.equipment}") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Сейчас: ${num(r.meterHours)} м/ч", color = MaterialTheme.colorScheme.secondary)
|
||||
OutlinedTextField(text, { text = it }, label = { Text("Показание счётчика, м/ч") }, singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), modifier = Modifier.fillMaxWidth())
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(correction, { correction = it })
|
||||
Text("Коррекция: счётчик заменён или обнулён", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton({ hours?.let { onSave(it, correction) } }, enabled = hours != null && hours >= 0) { Text("Сохранить") } },
|
||||
dismissButton = { TextButton(onDismiss) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResetDialog(r: StateRowDto, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||
var note by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Сбросить узел?") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("${r.unit} — ${r.equipment}. Используйте, если обслуживание или замена выполнены отдельно от наряда либо при внештатной ситуации. Остаток начнёт отсчитываться заново от текущей наработки.")
|
||||
OutlinedTextField(note, { note = it }, label = { Text("Комментарий") }, modifier = Modifier.fillMaxWidth(), minLines = 2)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton({ onConfirm(note) }) { Text("Сбросить") } },
|
||||
dismissButton = { TextButton(onDismiss) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package ru.checklists.app.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.calculatePan
|
||||
import androidx.compose.foundation.gestures.calculateZoom
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.checklists.app.data.api.IncidentDto
|
||||
import ru.checklists.app.data.api.MapDto
|
||||
import ru.checklists.app.data.api.TaskCreateReq
|
||||
import ru.checklists.app.data.api.humanMessage
|
||||
import ru.checklists.app.data.db.OrderEntity
|
||||
import ru.checklists.app.repo
|
||||
import ru.checklists.app.util.fmtShort
|
||||
|
||||
private const val SHOW = 5
|
||||
|
||||
/**
|
||||
* «Задачи» — всё, что нужно сотруднику, на одном экране: шапка с быстрыми действиями и сводкой, ниже наряды, задачи и поручения,
|
||||
* инциденты и сообщения о неисправности. Каждый блок со своими фильтрами; «Все →» открывает полный раздел.
|
||||
*/
|
||||
@Composable
|
||||
fun WorkScreen(go: (String) -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val repo = ctx.repo
|
||||
val api = repo.client
|
||||
val scope = rememberCoroutineScope()
|
||||
val me by repo.me.collectAsState()
|
||||
val orders by remember { repo.dao.orders() }.collectAsState(emptyList())
|
||||
val syncing by repo.syncing.collectAsState()
|
||||
val eng = me?.isEngineer == true || me?.isManager == true
|
||||
|
||||
var of by rememberSaveable { mutableStateOf("mine") }
|
||||
var tf by rememberSaveable { mutableStateOf("active") }
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
|
||||
val demo = me?.demoMode == true // сменился режим (боевой ↔ демо) — списки перезапрашиваем
|
||||
val tasks = rememberRemote(tf, demo) {
|
||||
when (tf) {
|
||||
"by_me" -> api.api().tasks(mine = false, active = true, createdByMe = true)
|
||||
"done" -> api.api().tasks(mine = true, status = "done", limit = 30)
|
||||
else -> api.api().tasks(mine = true, active = true)
|
||||
}
|
||||
}
|
||||
// сводка в шапке: открытые инциденты и неисправности
|
||||
val counts = rememberRemote(demo) {
|
||||
api.api().incidents(status = "open", kind = "incident", limit = 300).size to api.api().incidents(status = "open", kind = "fault", limit = 300).size
|
||||
}
|
||||
var tick by remember { mutableStateOf(0) } // раз в минуту блоки инцидентов и неисправностей перезапрашиваются
|
||||
WhileVisible(60_000) { repo.syncAll(); tasks.reload(); counts.reload(); tick++ }
|
||||
|
||||
val myId = me?.id
|
||||
fun mine(o: OrderEntity) = o.status in ACTIVE && (o.canExecute || (myId != null && o.executorId == myId))
|
||||
val oFilters = listOfNotNull(
|
||||
Triple("mine", "Мои", orders.count(::mine)),
|
||||
if (eng) Triple("review", "На проверку", orders.count { it.canReview }) else null,
|
||||
Triple("overdue", "Просроченные", orders.count { it.status in ACTIVE && it.isOverdue }),
|
||||
Triple("active", "Все активные", orders.count { it.status in ACTIVE }),
|
||||
)
|
||||
val oShown = orders.filter {
|
||||
when (of) {
|
||||
"review" -> it.canReview
|
||||
"overdue" -> it.status in ACTIVE && it.isOverdue
|
||||
"active" -> it.status in ACTIVE
|
||||
else -> mine(it)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(topBar = {
|
||||
ScreenBar(title = { Column { Text("Задачи"); Text("Наряды, поручения, инциденты, неисправности", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary) } },
|
||||
actions = { TextButton({ repo.syncSoon(); scope.launch { tasks.reload(); counts.reload() }; tick++ }, enabled = !syncing) { Text(if (syncing) "…" else "⟳") } })
|
||||
}) { pad ->
|
||||
LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
item { DemoBanner() }
|
||||
// шапка: быстрые действия и сводка
|
||||
item {
|
||||
QuickActions(go, onNewTask = { creating = true }) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Stat("${orders.count(::mine)}", "мои наряды", Modifier.weight(1f)) { of = "mine" }
|
||||
Stat(tasks.data?.let { if (tf == "active") "${it.size}" else null } ?: "·", "мои задачи", Modifier.weight(1f)) { tf = "active" }
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Stat(counts.data?.first?.toString() ?: "·", "открытые аварии", Modifier.weight(1f), eqColor("alarm")) { go("equipment") }
|
||||
Stat(counts.data?.second?.toString() ?: "·", "неисправности", Modifier.weight(1f), eqColor("fault")) { go("equipment") }
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1. наряды
|
||||
item {
|
||||
Quad("📋 Наряды", { go("orders") }, oFilters.map { Triple(it.first, "${it.second} · ${it.third}", of == it.first) }, { of = it }) {
|
||||
if (oShown.isEmpty()) Empty(if (orders.isEmpty()) "Нарядов нет (или ещё не синхронизировано)" else "Нет нарядов по фильтру")
|
||||
oShown.take(SHOW).forEach { o ->
|
||||
Line(o.title, listOfNotNull("№${o.id}", o.equipmentName, "срок ${fmtShort(o.dueAt)}").joinToString(" · "),
|
||||
if (o.isOverdue) "просрочен" else STATUS_RU[o.status] ?: o.status, if (o.isOverdue) androidx.compose.ui.graphics.Color(0xFFDC2626) else statusColor(o.status)) { go("order/${o.id}") }
|
||||
}
|
||||
More(oShown.size) { go("orders") }
|
||||
}
|
||||
}
|
||||
// 2. задачи и поручения
|
||||
item {
|
||||
Quad("🗓 Задачи и поручения", { go("tasks") },
|
||||
listOf("active" to "Мои активные", "by_me" to "Поставил я", "done" to "Выполненные").map { Triple(it.first, it.second, tf == it.first) }, { tf = it }) {
|
||||
val l = tasks.data.orEmpty()
|
||||
if (tasks.error != null) Empty("⚠ ${tasks.error}")
|
||||
else if (tasks.data == null) Empty("Загрузка…")
|
||||
else if (l.isEmpty()) Empty("Задач нет")
|
||||
l.take(SHOW).forEach { t ->
|
||||
Line(t.title, listOfNotNull(dayRu(t.day), if (tf == "by_me") t.assigneeName else null, t.equipmentName, t.laborHours?.let { "⏱ ${hrs(it)} ч" }).joinToString(" · "),
|
||||
TASK_RU[t.status] ?: t.status, taskColor(t.status)) { go("task/${t.id}") }
|
||||
}
|
||||
More(l.size) { go("tasks") }
|
||||
}
|
||||
}
|
||||
// 3. инциденты, 4. сообщения о неисправности
|
||||
item { IncQuad("incident", demo, tick, go) }
|
||||
item { IncQuad("fault", demo, tick, go) }
|
||||
}
|
||||
}
|
||||
if (creating) NewTaskDialog(onDismiss = { creating = false }) { title, desc, hours ->
|
||||
scope.launch {
|
||||
try { val t = api.api().createTask(TaskCreateReq(title, desc, laborHours = hours)); creating = false; go("task/${t.id}") }
|
||||
catch (e: Throwable) { toast(ctx, e.humanMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Четверть инцидентов (kind=incident) или сообщений о неисправности (kind=fault): фильтры, список или карта. */
|
||||
@Composable
|
||||
private fun IncQuad(kind: String, demo: Boolean, tick: Int, go: (String) -> Unit) {
|
||||
val api = LocalContext.current.repo.client
|
||||
val fault = kind == "fault"
|
||||
var f by rememberSaveable(kind) { mutableStateOf("open") }
|
||||
var onMap by rememberSaveable(kind) { mutableStateOf(false) }
|
||||
var layer by rememberSaveable(kind) { mutableStateOf("") } // пусто — первая карта, где есть нужные машины
|
||||
val r = rememberRemote(kind, f, demo, tick) {
|
||||
when (f) {
|
||||
"mine" -> api.api().incidents(mine = true, limit = 30, kind = kind)
|
||||
"all" -> api.api().incidents(limit = 30, kind = kind)
|
||||
else -> api.api().incidents(status = "open", limit = 50, kind = kind)
|
||||
}
|
||||
}
|
||||
// карты для режима «на карте»: завод и (если краны — отдельная служба) карта кранов
|
||||
val maps = rememberRemote(kind, onMap, demo) {
|
||||
if (!onMap) emptyMap() else {
|
||||
val plant = api.api().map("plant")
|
||||
buildMap { put("plant", plant); if (plant.craneMap) put("crane", api.api().map("crane")) }
|
||||
}
|
||||
}
|
||||
Quad(if (fault) "⚠ Неисправности" else "❗ Инциденты", { go("equipment") },
|
||||
listOf("open" to "Открытые", "mine" to "Мои", "all" to "Все", "map" to if (onMap) "☰ Список" else "🗺 На карте")
|
||||
.map { Triple(it.first, it.second, if (it.first == "map") onMap else f == it.first) }, { if (it == "map") onMap = !onMap else f = it }) {
|
||||
val l = r.data.orEmpty()
|
||||
if (onMap && r.error == null) {
|
||||
IncidentMap(l, maps, layer, kind, { layer = it }) { go("equipment/$it") }
|
||||
return@Quad
|
||||
}
|
||||
if (r.error != null) Empty("⚠ ${r.error}")
|
||||
else if (r.data == null) Empty("Загрузка…")
|
||||
else if (l.isEmpty()) Empty(if (f == "open") (if (fault) "Открытых неисправностей нет" else "Открытых инцидентов нет") else (if (fault) "Сообщений нет" else "Инцидентов нет"))
|
||||
l.take(SHOW).forEach { i -> IncidentLine(i) { i.equipmentId?.let { go("equipment/$it") } } }
|
||||
More(l.size) { go("equipment") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Машины с инцидентами или неисправностями (из выбранного фильтра) на карте завода или кранов. Нажатие — карточка машины. */
|
||||
@Composable
|
||||
private fun IncidentMap(list: List<IncidentDto>, maps: Remote<Map<String, MapDto>>, layer: String, kind: String, onLayer: (String) -> Unit, open: (Long) -> Unit) {
|
||||
val client = LocalContext.current.repo.client
|
||||
val byEq = list.filter { it.equipmentId != null }.groupBy { it.equipmentId!! }
|
||||
val all = maps.data
|
||||
if (maps.error != null) { Empty("⚠ ${maps.error}"); return }
|
||||
if (all == null || all.isEmpty()) { Empty("Загрузка карты…"); return }
|
||||
val cur = if (layer in all) layer else all.entries.firstOrNull { (_, v) -> v.equipment.any { it.id in byEq } }?.key ?: "plant"
|
||||
val m = all.getValue(cur)
|
||||
if (all.size > 1) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
all.forEach { (k, v) ->
|
||||
FilterChip(selected = k == cur, onClick = { onLayer(k) },
|
||||
label = { Text((if (k == "crane") "Краны" else "Завод") + " · " + v.equipment.count { it.id in byEq }) })
|
||||
}
|
||||
}
|
||||
val hit = m.equipment.filter { it.id in byEq }
|
||||
if (!m.hasImage) Empty(if (cur == "crane") "Картинка карты кранов не загружена" else "Карта завода ещё не загружена")
|
||||
else {
|
||||
var ratio by remember(m.imageVersion, cur) { mutableStateOf(1.6f) }
|
||||
// масштаб: щипок двумя пальцами, при увеличении — перетаскивание одним пальцем; кнопки + − ⤢
|
||||
var scale by remember(cur) { mutableStateOf(1f) }
|
||||
var shift by remember(cur) { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxWidth().aspectRatio(ratio)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(8.dp))) {
|
||||
val wPx = constraints.maxWidth.toFloat(); val hPx = constraints.maxHeight.toFloat()
|
||||
fun clampShift(o: androidx.compose.ui.geometry.Offset, s: Float) = androidx.compose.ui.geometry.Offset(
|
||||
o.x.coerceIn(-(s - 1) * wPx / 2, (s - 1) * wPx / 2), o.y.coerceIn(-(s - 1) * hPx / 2, (s - 1) * hPx / 2))
|
||||
val w = maxWidth; val h = maxHeight
|
||||
androidx.compose.foundation.layout.Box(Modifier.fillMaxSize()
|
||||
.pointerInput(cur) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
do {
|
||||
val ev = awaitPointerEvent()
|
||||
val fingers = ev.changes.count { it.pressed }
|
||||
if (fingers >= 2 || scale > 1f) {
|
||||
val ns = (scale * ev.calculateZoom()).coerceIn(1f, 5f)
|
||||
shift = clampShift(shift + ev.calculatePan(), ns)
|
||||
scale = ns
|
||||
if (ns == 1f) shift = androidx.compose.ui.geometry.Offset.Zero
|
||||
ev.changes.forEach { if (it.positionChanged()) it.consume() }
|
||||
}
|
||||
} while (ev.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
.graphicsLayer(scaleX = scale, scaleY = scale, translationX = shift.x, translationY = shift.y)) {
|
||||
coil.compose.AsyncImage(model = client.mapImageUrl(cur, m.imageVersion), contentDescription = "Карта",
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.FillBounds, modifier = Modifier.fillMaxSize(),
|
||||
onSuccess = { s -> val sz = s.painter.intrinsicSize; if (sz.width > 0 && sz.height > 0) ratio = sz.width / sz.height })
|
||||
hit.filter { it.x != null && it.y != null }.forEach { e ->
|
||||
val inc = byEq.getValue(e.id)
|
||||
val openN = inc.count { it.status == "open" }
|
||||
// значок не растёт вместе с картой: обратный масштаб вокруг точки машины
|
||||
Column(Modifier.offset(x = w * (e.x!! / 100).toFloat() - 40.dp, y = h * (e.y!! / 100).toFloat() - 14.dp).width(80.dp)
|
||||
.graphicsLayer(scaleX = 1 / scale, scaleY = 1 / scale, transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.25f))
|
||||
.clickable { open(e.id) },
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
androidx.compose.foundation.layout.Box(Modifier.size(28.dp).background(if (openN > 0) eqColor(if (kind == "fault") "fault" else "alarm") else androidx.compose.ui.graphics.Color(0xFF64748B), CircleShape),
|
||||
contentAlignment = Alignment.Center) {
|
||||
Text(if (inc.size > 1) "${inc.size}" else if (openN > 0) (if (kind == "fault") "⚠" else "!") else "✓", color = androidx.compose.ui.graphics.Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text(e.name, maxLines = 1, overflow = TextOverflow.Ellipsis, color = androidx.compose.ui.graphics.Color.White,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.background(androidx.compose.ui.graphics.Color(0xA6000000), androidx.compose.foundation.shape.RoundedCornerShape(4.dp)).padding(horizontal = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
// кнопки масштаба — компактно в правом нижнем углу, чтобы не закрывать значки
|
||||
@Composable
|
||||
fun zb(t: String, onClick: () -> Unit) = androidx.compose.foundation.layout.Box(Modifier.size(34.dp).clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center) { Text(t, fontWeight = FontWeight.Bold, color = androidx.compose.ui.graphics.Color(0xFF1E293B)) }
|
||||
Row(Modifier.align(Alignment.BottomEnd).padding(6.dp)
|
||||
.background(androidx.compose.ui.graphics.Color(0xDDFFFFFF), androidx.compose.foundation.shape.RoundedCornerShape(8.dp))) {
|
||||
zb("+") { val ns = (scale * 1.5f).coerceAtMost(5f); shift = clampShift(shift, ns); scale = ns }
|
||||
zb("−") { val ns = (scale / 1.5f).coerceAtLeast(1f); shift = if (ns == 1f) androidx.compose.ui.geometry.Offset.Zero else clampShift(shift, ns); scale = ns }
|
||||
if (scale > 1f) zb("⤢") { scale = 1f; shift = androidx.compose.ui.geometry.Offset.Zero }
|
||||
}
|
||||
}
|
||||
if (scale > 1f) Text("Масштаб ${(scale * 100).toInt()}%: двигайте карту пальцем, «⤢» — весь план", style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
if (hit.isEmpty()) Empty(if (kind == "fault") "На этой карте нет машин с неисправностями по фильтру" else "На этой карте нет машин с инцидентами по фильтру")
|
||||
val unplaced = hit.filter { it.x == null }
|
||||
if (unplaced.isNotEmpty()) Text("Не размещены на карте: " + unplaced.joinToString { it.name }, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Stat(n: String, label: String, modifier: Modifier, color: androidx.compose.ui.graphics.Color? = null, onClick: () -> Unit) {
|
||||
ElevatedCard(modifier.clickable(onClick = onClick)) {
|
||||
Column(Modifier.padding(vertical = 8.dp, horizontal = 4.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(n, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = if (color != null && n != "0" && n != "·") color else androidx.compose.ui.graphics.Color.Unspecified)
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary, maxLines = 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Блок-«четверть»: заголовок, фильтры-чипы, короткий список. */
|
||||
@Composable
|
||||
private fun Quad(title: String, onAll: () -> Unit, chips: List<Triple<String, String, Boolean>>, onChip: (String) -> Unit, content: @Composable () -> Unit) {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
TextButton(onAll) { Text("Все →") }
|
||||
}
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
items(chips) { (k, l, on) -> FilterChip(selected = on, onClick = { onChip(k) }, label = { Text(l) }) }
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Line(title: String, sub: String, badge: String, color: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
|
||||
HorizontalDivider()
|
||||
Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
if (sub.isNotEmpty()) Text(sub, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Badge(badge, color)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IncidentLine(i: IncidentDto, onClick: () -> Unit) = Line(
|
||||
i.title,
|
||||
listOfNotNull(i.equipmentName, fmtShort(i.occurredAt), i.authorName ?: if (i.source != "staff") "внешняя заявка" else null).joinToString(" · "),
|
||||
if (i.status == "open") (if (i.kind == "fault") "открыта" else "открыт") else (if (i.kind == "fault") "закрыта" else "закрыт"),
|
||||
if (i.status == "open") eqColor(if (i.kind == "fault") "fault" else "alarm") else eqColor("ok"), onClick,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun Empty(text: String) = Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(vertical = 6.dp))
|
||||
|
||||
@Composable
|
||||
private fun More(n: Int, onClick: () -> Unit) {
|
||||
if (n > SHOW) TextButton(onClick) { Text("Ещё ${n - SHOW} →") }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun QuickActions(go: (String) -> Unit, onNewTask: () -> Unit, stats: @Composable () -> Unit) {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
OutlinedButton(onNewTask) { Text("+ Задача себе") }
|
||||
OutlinedButton({ go("equipment") }) { Text("❗ Авария") }
|
||||
OutlinedButton({ go("equipment") }) { Text("⚠ Неисправность") }
|
||||
OutlinedButton({ go("requests") }) { Text("🛒 Заявка") }
|
||||
OutlinedButton({ go("tools") }) { Text("🧰 Инструмент") }
|
||||
}
|
||||
stats()
|
||||
Text("Авария или неисправность: выберите машину в списке оборудования и нажмите нужную кнопку.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hrs(v: Double) = if (v == v.toLong().toDouble()) v.toLong().toString() else "%.2f".format(v).trimEnd('0').trimEnd(',', '.')
|
||||
@@ -0,0 +1,51 @@
|
||||
package ru.checklists.app.util
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
private val shortFmt = DateTimeFormatter.ofPattern("dd.MM HH:mm")
|
||||
private val fullFmt = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
|
||||
|
||||
fun parseMillis(s: String?): Long? = try {
|
||||
if (s.isNullOrBlank()) null else OffsetDateTime.parse(s).toInstant().toEpochMilli()
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
fun isoOf(ms: Long): String =
|
||||
DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(Instant.ofEpochMilli(ms).atZone(ZoneId.systemDefault()))
|
||||
|
||||
fun fmtShort(s: String?): String =
|
||||
parseMillis(s)?.let { shortFmt.format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } ?: "—"
|
||||
|
||||
fun fmtFull(s: String?): String =
|
||||
parseMillis(s)?.let { fullFmt.format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } ?: "—"
|
||||
|
||||
fun fmtMillis(ms: Long): String = fullFmt.format(Instant.ofEpochMilli(ms).atZone(ZoneId.systemDefault()))
|
||||
|
||||
/** Уменьшает снимок до max px по большей стороне, поворачивает по EXIF и сохраняет JPEG. */
|
||||
fun downscaleJpeg(src: File, dst: File, max: Int = 1600, quality: Int = 85) {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(src.path, bounds)
|
||||
var sample = 1
|
||||
while (bounds.outWidth / (sample * 2) >= max || bounds.outHeight / (sample * 2) >= max) sample *= 2
|
||||
val bmp = BitmapFactory.decodeFile(src.path, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
?: error("Не удалось прочитать снимок")
|
||||
val rot = when (ExifInterface(src.path).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
|
||||
else -> 0f
|
||||
}
|
||||
val k = minOf(1f, max.toFloat() / maxOf(bmp.width, bmp.height))
|
||||
val m = Matrix().apply { postRotate(rot); postScale(k, k) }
|
||||
val out = Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, m, true)
|
||||
dst.outputStream().use { out.compress(Bitmap.CompressFormat.JPEG, quality, it) }
|
||||
if (out !== bmp) bmp.recycle()
|
||||
out.recycle()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#2563EB"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M30,56 L47,72 L79,38"
|
||||
android:strokeWidth="9"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Чек-листы</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Checklists" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowBackground">@android:color/white</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="capture" path="capture/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Релиз: только HTTPS. Открытый HTTP запрещён (токены и данные не должны идти в открытом виде). -->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.5.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false
|
||||
id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
Vendored
+249
@@ -0,0 +1,249 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,16 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "Checklists"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user