Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit 67e411eea8
246 changed files with 23152 additions and 0 deletions
+61
View File
@@ -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`). Для показа удобно включить на сервере демо-режим.
+88
View File
@@ -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>
+39
View File
@@ -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>
+6
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
Binary file not shown.
+7
View File
@@ -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
+249
View File
@@ -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" "$@"
+92
View File
@@ -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
+16
View File
@@ -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")