From 6af2a96c385660272382ad338dd5f31c106df1a2 Mon Sep 17 00:00:00 2001 From: PTah Date: Sun, 14 Jun 2026 15:22:28 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B1=D0=B8=D0=BE=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B5,=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=D0=B7=D0=BE=D1=80=D0=B0?= =?UTF-8?q?,=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20=D0=B8=20UI=20?= =?UTF-8?q?(0.5.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Блокировка UI до отпечатка на cold start и resume, автообновление дашборда, фильтр событий по хосту, вкладка событий на экране хоста, контраст карточек и навигация стрелками вместо текстовых кнопок. --- app/build.gradle.kts | 4 +- .../java/ru/kalinamall/seaca/MainActivity.kt | 61 +++-- .../seaca/ui/components/NavButtons.kt | 48 ++++ .../seaca/ui/screens/DetailScreens.kt | 211 ++++++++++++++++-- .../seaca/ui/screens/ListScreens.kt | 147 +++++++++--- .../ru/kalinamall/seaca/ui/theme/Theme.kt | 16 +- 6 files changed, 415 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/components/NavButtons.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 823f5a8..4a0da85 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ru.kalinamall.seaca" minSdk = 26 targetSdk = 35 - versionCode = 13 - versionName = "0.4.9" + versionCode = 14 + versionName = "0.5.0" } buildTypes { diff --git a/app/src/main/java/ru/kalinamall/seaca/MainActivity.kt b/app/src/main/java/ru/kalinamall/seaca/MainActivity.kt index a08c9ca..16006b3 100644 --- a/app/src/main/java/ru/kalinamall/seaca/MainActivity.kt +++ b/app/src/main/java/ru/kalinamall/seaca/MainActivity.kt @@ -8,6 +8,7 @@ import android.os.Build import android.os.Bundle import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Computer @@ -16,7 +17,9 @@ import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold @@ -24,13 +27,13 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf as composeMutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.mutableIntStateOf 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.core.app.ActivityCompat @@ -60,8 +63,8 @@ import ru.kalinamall.seaca.ui.theme.SeacaTheme class MainActivity : FragmentActivity() { private lateinit var repo: SacRepository - private var needsBiometric = false - private var biometricPassed = true + private val biometricUnlocked = mutableStateOf(true) + private var pendingBiometric = false private val deepLinkState = composeMutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { @@ -69,12 +72,17 @@ class MainActivity : FragmentActivity() { repo = SacRepository(applicationContext) requestNotificationPermission() deepLinkState.value = extractDeepLink(intent) + if (shouldRequireBiometric()) { + biometricUnlocked.value = false + pendingBiometric = true + } setContent { + val unlocked by biometricUnlocked SeacaApp( repo = repo, deepLink = deepLinkState.value, onDeepLinkConsumed = { deepLinkState.value = null }, - biometricUnlocked = biometricPassed || !needsBiometric, + biometricUnlocked = unlocked, ) } } @@ -87,26 +95,27 @@ class MainActivity : FragmentActivity() { override fun onStop() { super.onStop() - if (repo.session.getAccessToken() != null) { - needsBiometric = true - biometricPassed = false + if (shouldRequireBiometric()) { + biometricUnlocked.value = false + pendingBiometric = true } } override fun onResume() { super.onResume() - if (needsBiometric && repo.session.getAccessToken() != null && !biometricPassed) { + if (pendingBiometric && !biometricUnlocked.value && repo.session.getAccessToken() != null) { + pendingBiometric = false BiometricGate.authenticate( activity = this, - onSuccess = { - biometricPassed = true - needsBiometric = false - }, + onSuccess = { biometricUnlocked.value = true }, onFailure = { finish() }, ) } } + private fun shouldRequireBiometric(): Boolean = + repo.session.getAccessToken() != null && BiometricGate.canAuthenticate(this) + private fun extractDeepLink(intent: Intent?): String? { intent?.getStringExtra("deep_link")?.let { return it } val uri: Uri = intent?.data ?: return null @@ -165,6 +174,7 @@ private fun SeacaApp( role == "operator" || role == "admin" } var mainTabIndex by rememberSaveable { mutableIntStateOf(MainTab.Dashboard.ordinal) } + var dashboardRefreshTick by rememberSaveable { mutableIntStateOf(0) } val mainTab = MainTab.entries[mainTabIndex.coerceIn(MainTab.entries.indices)] SeacaTheme { @@ -179,7 +189,12 @@ private fun SeacaApp( } if (!biometricUnlocked) { - Text("Подтвердите доступ…") + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } return@SeacaTheme } @@ -188,7 +203,13 @@ private fun SeacaApp( MainShell( repo = repo, selectedTab = mainTab, - onTabSelected = { mainTabIndex = it.ordinal }, + dashboardRefreshTick = dashboardRefreshTick, + onTabSelected = { tab -> + mainTabIndex = tab.ordinal + if (tab == MainTab.Dashboard) { + dashboardRefreshTick++ + } + }, onOpenEvent = { nav.navigate("event/$it") }, onOpenProblem = { nav.navigate("problem/$it") }, onOpenHost = { nav.navigate("host/$it") }, @@ -217,7 +238,12 @@ private fun SeacaApp( "host/{id}", arguments = listOf(navArgument("id") { type = NavType.LongType }), ) { entry -> - HostDetailScreen(repo, entry.arguments?.getLong("id") ?: 0L) { nav.popBackStack() } + HostDetailScreen( + repo = repo, + id = entry.arguments?.getLong("id") ?: 0L, + onOpenEvent = { nav.navigate("event/$it") }, + onBack = { nav.popBackStack() }, + ) } composable("device") { DeviceScreen(repo) { nav.popBackStack("main", false) } @@ -238,6 +264,7 @@ private enum class MainTab(val label: String) { private fun MainShell( repo: SacRepository, selectedTab: MainTab, + dashboardRefreshTick: Int, onTabSelected: (MainTab) -> Unit, onOpenEvent: (Long) -> Unit, onOpenProblem: (Long) -> Unit, @@ -277,7 +304,7 @@ private fun MainShell( ) { padding -> Box(Modifier.padding(padding)) { when (selectedTab) { - MainTab.Dashboard -> DashboardScreen(repo) + MainTab.Dashboard -> DashboardScreen(repo, refreshKey = dashboardRefreshTick) MainTab.Events -> EventsScreen(repo, onOpenEvent) MainTab.Problems -> ProblemsScreen(repo, onOpenProblem) MainTab.Hosts -> HostsScreen(repo, onOpenHost) diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/components/NavButtons.kt b/app/src/main/java/ru/kalinamall/seaca/ui/components/NavButtons.kt new file mode 100644 index 0000000..ff0e45e --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/components/NavButtons.kt @@ -0,0 +1,48 @@ +package ru.kalinamall.seaca.ui.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun BackArrowButton( + onClick: () -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier, +) { + IconButton(onClick = onClick, enabled = enabled, modifier = modifier) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Назад", + tint = if (enabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } +} + +@Composable +fun ForwardArrowButton( + onClick: () -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier, +) { + IconButton(onClick = onClick, enabled = enabled, modifier = modifier) { + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Вперёд", + tint = if (enabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt index 9e2b56b..3940d42 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt @@ -1,5 +1,6 @@ package ru.kalinamall.seaca.ui.screens +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -7,18 +8,26 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -29,18 +38,26 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonElement +import ru.kalinamall.seaca.R +import ru.kalinamall.seaca.data.EventSummary import ru.kalinamall.seaca.data.EventDetail import ru.kalinamall.seaca.data.HostDetail import ru.kalinamall.seaca.data.NotificationMode import ru.kalinamall.seaca.data.ProblemDetail import ru.kalinamall.seaca.data.SacRepository -import ru.kalinamall.seaca.R +import ru.kalinamall.seaca.ui.components.BackArrowButton +import ru.kalinamall.seaca.ui.components.ForwardArrowButton import ru.kalinamall.seaca.ui.util.dailyReportTypeLabel import ru.kalinamall.seaca.ui.util.formatEventDetails import ru.kalinamall.seaca.ui.util.inventoryFieldLines import ru.kalinamall.seaca.ui.util.isDailyReportType import ru.kalinamall.seaca.ui.util.reportTextFromDetails +private enum class HostDetailTab(val label: String) { + Info("Информация"), + Events("События"), +} + @Composable fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { var data by remember { mutableStateOf(null) } @@ -173,19 +190,25 @@ fun ProblemDetailScreen( } } if (d.events.isNotEmpty()) { - Text("Связанные события", style = MaterialTheme.typography.titleMedium) + Text("Связанные события", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) d.events.forEach { e -> - Text("${e.severity} · ${e.title}\n${e.occurredAt}", style = MaterialTheme.typography.bodySmall) + Text("${e.severity} · ${e.title}\n${e.occurredAt}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } } @Composable -fun HostDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { +fun HostDetailScreen( + repo: SacRepository, + id: Long, + onOpenEvent: (Long) -> Unit, + onBack: () -> Unit, +) { var data by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } + var selectedTab by remember { mutableIntStateOf(HostDetailTab.Info.ordinal) } LaunchedEffect(id) { loading = true @@ -198,14 +221,75 @@ fun HostDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { } } - DetailFrame(title = data?.hostname ?: "Хост", loading, error, onBack) { - val d = data ?: return@DetailFrame - Field("Продукт", d.product) - Field("Версия", d.productVersion ?: "—") - Field("ОС", "${d.osFamily} ${d.osVersion.orEmpty()}".trim()) - Field("Агент", d.agentStatus) - Field("Последний контакт", d.lastSeenAt) - d.inventory?.let { inv -> + Column( + Modifier + .fillMaxSize() + .navigationBarsPadding(), + ) { + Row( + Modifier + .fillMaxWidth() + .padding(start = 4.dp, end = 16.dp, top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BackArrowButton(onClick = onBack) + Text( + data?.hostname ?: "Хост", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 4.dp), + ) + } + error?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + if (loading) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(16.dp), + ) + } else { + val host = data ?: return@Column + TabRow(selectedTabIndex = selectedTab) { + HostDetailTab.entries.forEachIndexed { index, tab -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(tab.label) }, + ) + } + } + when (HostDetailTab.entries[selectedTab]) { + HostDetailTab.Info -> HostInfoContent(host) + HostDetailTab.Events -> HostEventsContent( + repo = repo, + hostname = host.hostname, + onOpenEvent = onOpenEvent, + ) + } + } + } +} + +@Composable +private fun HostInfoContent(host: HostDetail) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Field("Продукт", host.product) + Field("Версия", host.productVersion ?: "—") + Field("ОС", "${host.osFamily} ${host.osVersion.orEmpty()}".trim()) + Field("Агент", host.agentStatus) + Field("Последний контакт", host.lastSeenAt) + host.inventory?.let { inv -> val lines = inventoryFieldLines(inv) if (lines.isEmpty()) { Field("Inventory", "—") @@ -217,6 +301,96 @@ fun HostDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { } } +@Composable +private fun HostEventsContent( + repo: SacRepository, + hostname: String, + onOpenEvent: (Long) -> Unit, +) { + var page by remember { mutableIntStateOf(1) } + var items by remember { mutableStateOf>(emptyList()) } + var total by remember { mutableIntStateOf(0) } + var loading by remember { mutableStateOf(true) } + var refreshing by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + fun load(showRefreshIndicator: Boolean = false) { + scope.launch { + if (showRefreshIndicator) refreshing = true else loading = true + error = null + try { + val res = repo.withAuth { + it.listEvents(page = page, pageSize = 30, hostname = hostname) + } + items = res.items + total = res.total + } catch (e: Exception) { + error = e.message + } finally { + loading = false + refreshing = false + } + } + } + + LaunchedEffect(page, hostname) { load() } + + Column( + Modifier + .fillMaxSize() + .padding(16.dp), + ) { + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { load(showRefreshIndicator = true) }, + modifier = Modifier.weight(1f), + ) { + if (items.isEmpty() && loading) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } else { + LazyColumn { + items(items, key = { it.id }) { event -> + Card( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { onOpenEvent(event.id) }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Column(Modifier.padding(12.dp)) { + Text( + "${event.severity} · ${event.title}", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + event.occurredAt, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + BackArrowButton(onClick = { if (page > 1) page-- }, enabled = page > 1) + Text("Стр. $page · всего $total", color = MaterialTheme.colorScheme.onSurface) + ForwardArrowButton(onClick = { page++ }, enabled = page * 30 < total) + } + } +} + @Composable fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) { var baseUrl by remember { mutableStateOf(null) } @@ -313,11 +487,18 @@ private fun DetailFrame( .navigationBarsPadding(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Button(onClick = onBack) { Text("Назад") } - Text(title, style = MaterialTheme.typography.titleLarge) + Row(verticalAlignment = Alignment.CenterVertically) { + BackArrowButton(onClick = onBack) + Text( + title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 4.dp), + ) + } error?.let { Text(it, color = MaterialTheme.colorScheme.error) } if (loading) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) } else { content() } diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt index c1ac7e8..ea8f49b 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt @@ -9,16 +9,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.FilterChip +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -30,17 +30,19 @@ 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.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect import kotlinx.coroutines.launch import ru.kalinamall.seaca.data.DashboardSummary import ru.kalinamall.seaca.data.EventListResponse import ru.kalinamall.seaca.data.EventSummary -import ru.kalinamall.seaca.data.HostListResponse import ru.kalinamall.seaca.data.HostSummary -import ru.kalinamall.seaca.data.ProblemListResponse import ru.kalinamall.seaca.data.ProblemSummary import ru.kalinamall.seaca.data.SacRepository +import ru.kalinamall.seaca.ui.components.BackArrowButton +import ru.kalinamall.seaca.ui.components.ForwardArrowButton private val REPORT_TYPE_OPTIONS = listOf( "report.daily.ssh" to "Отчёты ssh клиентов", @@ -49,15 +51,20 @@ private val REPORT_TYPE_OPTIONS = listOf( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DashboardScreen(repo: SacRepository) { +fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) { var data by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } - var loading by remember { mutableStateOf(true) } + var loading by remember { mutableStateOf(false) } + var refreshing by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - fun load() { + fun load(showRefreshIndicator: Boolean = false) { scope.launch { - loading = true + if (showRefreshIndicator) { + refreshing = true + } else { + loading = true + } error = null try { data = repo.withAuth { it.dashboardSummary() } @@ -65,20 +72,34 @@ fun DashboardScreen(repo: SacRepository) { error = e.message } finally { loading = false + refreshing = false } } } - LaunchedEffect(Unit) { load() } + LaunchedEffect(refreshKey) { load() } - PullToRefreshBox(isRefreshing = loading, onRefresh = { load() }, modifier = Modifier.fillMaxSize()) { + LifecycleResumeEffect(refreshKey) { + load(showRefreshIndicator = data != null) + onPauseOrDispose { } + } + + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { load(showRefreshIndicator = true) }, + modifier = Modifier.fillMaxSize(), + ) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { error?.let { Text(it, color = MaterialTheme.colorScheme.error) } data?.let { d -> StatCard("События 24ч", d.eventsLast24h.toString()) StatCard("Проблемы open", d.problemsOpen.toString()) StatCard("Хосты", "${d.hostsTotal} (stale: ${d.hostsStale})") - Text("Последние события", style = MaterialTheme.typography.titleMedium) + Text( + "Последние события", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) d.recentEvents.take(8).forEach { e -> Text( dashboardRecentEventLine(e), @@ -88,7 +109,7 @@ fun DashboardScreen(repo: SacRepository) { } } if (data == null && loading) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) } } } @@ -105,10 +126,16 @@ private fun dashboardRecentEventLine(event: EventSummary): String { @Composable private fun StatCard(label: String, value: String) { - Card(Modifier.fillMaxWidth()) { + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { Column(Modifier.padding(12.dp)) { - Text(label, style = MaterialTheme.typography.labelMedium) - Text(value, style = MaterialTheme.typography.headlineSmall) + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface) } } } @@ -116,10 +143,19 @@ private fun StatCard(label: String, value: String) { @OptIn(ExperimentalMaterial3Api::class) @Composable fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { + var hostname by remember { mutableStateOf("") } + PagedListScreen( title = "События", - loadPage = { page, severity -> - val res = repo.withAuth { it.listEvents(page = page, pageSize = 30, severity = severity) } + loadPage = { page, severity, host -> + val res = repo.withAuth { + it.listEvents( + page = page, + pageSize = 30, + severity = severity, + hostname = host, + ) + } res.items to res.total }, itemKey = EventSummary::id, @@ -127,6 +163,17 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { itemSub = { "${it.hostname} · ${it.occurredAt}" }, onOpen = onOpen, severities = listOf(null, "info", "warning", "high", "critical"), + hostnameFilter = hostname, + headerContent = { + OutlinedTextField( + value = hostname, + onValueChange = { hostname = it }, + label = { Text("Хост") }, + placeholder = { Text("Фильтр по имени хоста") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + }, ) } @@ -135,7 +182,7 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { fun ProblemsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { PagedListScreen( title = "Проблемы", - loadPage = { page, filter -> + loadPage = { page, filter, _ -> val res = repo.withAuth { it.listProblems(page = page, pageSize = 30, status = filter ?: "open") } @@ -154,7 +201,7 @@ fun ProblemsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { fun HostsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { PagedListScreen( title = "Хосты", - loadPage = { page, _ -> + loadPage = { page, _, _ -> val res = repo.withAuth { it.listHosts(page = page, pageSize = 30) } res.items to res.total }, @@ -174,18 +221,20 @@ fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { var page by remember { mutableIntStateOf(1) } var data by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } + var refreshing by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() - fun load() { + fun load(showRefreshIndicator: Boolean = false) { scope.launch { - loading = true + if (showRefreshIndicator) refreshing = true else loading = true try { data = repo.withAuth { it.listEvents(page = page, pageSize = 30, type = reportType) } } catch (e: Exception) { error = e.message } finally { loading = false + refreshing = false } } } @@ -216,7 +265,11 @@ fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { } } error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - PullToRefreshBox(isRefreshing = loading, onRefresh = { load() }, modifier = Modifier.weight(1f)) { + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { load(showRefreshIndicator = true) }, + modifier = Modifier.weight(1f), + ) { LazyColumn { items(data?.items.orEmpty(), key = { it.id }) { item -> ListRow( @@ -235,41 +288,51 @@ fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { @Composable private fun PagedListScreen( title: String, - loadPage: suspend (Int, String?) -> Pair, Int>, + loadPage: suspend (Int, String?, String?) -> Pair, Int>, itemKey: (T) -> Long, itemLabel: (T) -> String, itemSub: (T) -> String, onOpen: (Long) -> Unit, severities: List, filterLabel: String = "Severity", + hostnameFilter: String = "", + headerContent: @Composable () -> Unit = {}, ) { var page by remember { mutableIntStateOf(1) } var filter by remember { mutableStateOf(severities.firstOrNull()) } var items by remember { mutableStateOf>(emptyList()) } var total by remember { mutableIntStateOf(0) } var loading by remember { mutableStateOf(true) } + var refreshing by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val hostQuery = hostnameFilter.trim().ifBlank { null } - fun load() { + fun load(showRefreshIndicator: Boolean = false) { scope.launch { - loading = true + if (showRefreshIndicator) refreshing = true else loading = true try { - val (pageItems, pageTotal) = loadPage(page, filter) + val (pageItems, pageTotal) = loadPage(page, filter, hostQuery) items = pageItems total = pageTotal } catch (e: Exception) { error = e.message } finally { loading = false + refreshing = false } } } - LaunchedEffect(page, filter) { load() } + LaunchedEffect(hostQuery) { + if (page != 1) page = 1 + } + + LaunchedEffect(page, filter, hostQuery) { load() } Column(Modifier.fillMaxSize().padding(16.dp)) { - Text(title, style = MaterialTheme.typography.titleLarge) + Text(title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface) + headerContent() if (severities.isNotEmpty()) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { severities.forEach { s -> @@ -282,7 +345,11 @@ private fun PagedListScreen( } } error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - PullToRefreshBox(isRefreshing = loading, onRefresh = { load() }, modifier = Modifier.weight(1f)) { + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { load(showRefreshIndicator = true) }, + modifier = Modifier.weight(1f), + ) { LazyColumn { items(items, key = { itemKey(it) }) { item -> ListRow(itemLabel(item), itemSub(item)) { onOpen(itemKey(item)) } @@ -300,19 +367,27 @@ private fun ListRow(title: String, subtitle: String, onClick: () -> Unit) { .fillMaxWidth() .padding(vertical = 4.dp) .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ), ) { Column(Modifier.padding(12.dp)) { - Text(title, style = MaterialTheme.typography.bodyLarge) - Text(subtitle, style = MaterialTheme.typography.bodySmall) + Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } @Composable private fun PagerBar(page: Int, total: Int, pageSize: Int, onPrev: () -> Unit, onNext: () -> Unit) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Button(onClick = onPrev, enabled = page > 1) { Text("Назад") } - Text("Стр. $page · всего $total") - Button(onClick = onNext, enabled = page * pageSize < total) { Text("Вперёд") } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + BackArrowButton(onClick = onPrev, enabled = page > 1) + Text("Стр. $page · всего $total", color = MaterialTheme.colorScheme.onSurface) + ForwardArrowButton(onClick = onNext, enabled = page * pageSize < total) } } diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/theme/Theme.kt b/app/src/main/java/ru/kalinamall/seaca/ui/theme/Theme.kt index 8afab40..07e7113 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/theme/Theme.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/theme/Theme.kt @@ -9,17 +9,29 @@ import androidx.compose.ui.graphics.Color private val SacDark = darkColorScheme( primary = Color(0xFF3DB8FF), + onPrimary = Color(0xFF002A42), background = Color(0xFF0F1419), - surface = Color(0xFF15202B), onBackground = Color(0xFFC5D0DC), - onSurface = Color(0xFFC5D0DC), + surface = Color(0xFF15202B), + onSurface = Color(0xFFE8EEF4), + surfaceContainer = Color(0xFF1C2834), + surfaceContainerLow = Color(0xFF182330), + surfaceContainerHigh = Color(0xFF243240), + onSurfaceVariant = Color(0xFFB0BEC9), error = Color(0xFFFF8A8A), ) private val SacLight = lightColorScheme( primary = Color(0xFF1A6FA0), + onPrimary = Color(0xFFFFFFFF), background = Color(0xFFF4F6F8), + onBackground = Color(0xFF1A1F24), surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF1A1F24), + surfaceContainer = Color(0xFFEEF2F5), + surfaceContainerLow = Color(0xFFF4F6F8), + surfaceContainerHigh = Color(0xFFE4E9ED), + onSurfaceVariant = Color(0xFF4A5560), ) @Composable