Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для 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
+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>