From 180403068e81dc8688c22c17230c8f23217cb2f0 Mon Sep 17 00:00:00 2001 From: PTah Date: Sun, 14 Jun 2026 17:48:48 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=20?= =?UTF-8?q?=D0=B4=D0=B0=D1=82=D1=8B/=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=20=D0=B8=20pull-to-refresh=20=D0=BD=D0=B0=20=D0=9E=D0=B1?= =?UTF-8?q?=D0=B7=D0=BE=D1=80=D0=B5=20(0.5.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Локальный вывод дат (по умолчанию ДД/ММ/ГГГГ ЧЧ:мм:сс), выбор формата в Устройство, дата напротив событий на Обзоре, исправление compile и жеста обновления. --- app/build.gradle.kts | 4 +- .../java/ru/kalinamall/seaca/MainActivity.kt | 1 + .../seaca/data/DateTimeDisplayMode.kt | 18 ++++++ .../ru/kalinamall/seaca/data/SessionStore.kt | 12 ++++ .../seaca/ui/screens/DetailScreens.kt | 64 +++++++++++++++++-- .../seaca/ui/screens/ListScreens.kt | 49 +++++++++++--- .../kalinamall/seaca/ui/util/SacDateTimes.kt | 46 +++++++++++++ app/src/main/res/values/strings.xml | 1 + 8 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/ru/kalinamall/seaca/data/DateTimeDisplayMode.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/util/SacDateTimes.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4a0da85..30ef0c8 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 = 14 - versionName = "0.5.0" + versionCode = 15 + versionName = "0.5.1" } buildTypes { diff --git a/app/src/main/java/ru/kalinamall/seaca/MainActivity.kt b/app/src/main/java/ru/kalinamall/seaca/MainActivity.kt index 16006b3..66ff925 100644 --- a/app/src/main/java/ru/kalinamall/seaca/MainActivity.kt +++ b/app/src/main/java/ru/kalinamall/seaca/MainActivity.kt @@ -30,6 +30,7 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue diff --git a/app/src/main/java/ru/kalinamall/seaca/data/DateTimeDisplayMode.kt b/app/src/main/java/ru/kalinamall/seaca/data/DateTimeDisplayMode.kt new file mode 100644 index 0000000..4411338 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/data/DateTimeDisplayMode.kt @@ -0,0 +1,18 @@ +package ru.kalinamall.seaca.data + +enum class DateTimeDisplayMode( + val storageKey: String, + val label: String, + val pattern: String?, +) { + SLASH("slash", "ДД/ММ/ГГГГ ЧЧ:мм:сс", "dd/MM/yyyy HH:mm:ss"), + DASH("dash", "ДД-ММ-ГГГГ ЧЧ:мм:сс", "dd-MM-yyyy HH:mm:ss"), + DOT("dot", "ДД.ММ.ГГГГ ЧЧ:мм:сс", "dd.MM.yyyy HH:mm:ss"), + LOCALE("locale", "Как в системе телефона", null), + ; + + companion object { + fun fromStorage(value: String?): DateTimeDisplayMode = + entries.firstOrNull { it.storageKey == value } ?: SLASH + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt b/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt index 1741155..87051ce 100644 --- a/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt +++ b/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt @@ -36,13 +36,24 @@ class SessionStore(private val context: Context) { NotificationMode.fromStorage(prefs[KEY_NOTIFICATION_MODE]) } + val dateTimeDisplayMode: Flow = context.dataStore.data.map { prefs -> + DateTimeDisplayMode.fromStorage(prefs[KEY_DATE_TIME_FORMAT]) + } + suspend fun getNotificationMode(): NotificationMode = NotificationMode.fromStorage(context.dataStore.data.first()[KEY_NOTIFICATION_MODE]) + suspend fun getDateTimeDisplayMode(): DateTimeDisplayMode = + DateTimeDisplayMode.fromStorage(context.dataStore.data.first()[KEY_DATE_TIME_FORMAT]) + suspend fun setNotificationMode(mode: NotificationMode) { context.dataStore.edit { it[KEY_NOTIFICATION_MODE] = mode.storageKey } } + suspend fun setDateTimeDisplayMode(mode: DateTimeDisplayMode) { + context.dataStore.edit { it[KEY_DATE_TIME_FORMAT] = mode.storageKey } + } + suspend fun getBaseUrl(): String? = context.dataStore.data.first()[KEY_BASE_URL] suspend fun trustAllCerts(): Boolean = context.dataStore.data.first()[KEY_TRUST_ALL] ?: false @@ -102,6 +113,7 @@ class SessionStore(private val context: Context) { private val KEY_DEVICE_UUID = stringPreferencesKey("device_uuid") private val KEY_TRUST_ALL = booleanPreferencesKey("trust_all_certs") private val KEY_NOTIFICATION_MODE = stringPreferencesKey("notification_mode") + private val KEY_DATE_TIME_FORMAT = stringPreferencesKey("date_time_format") private const val KEY_ACCESS = "access_token" private const val KEY_REFRESH = "refresh_token" private const val KEY_DEVICE_ID = "device_id" 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 3940d42..a4381dc 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 @@ -16,6 +16,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface @@ -42,11 +43,14 @@ import ru.kalinamall.seaca.R import ru.kalinamall.seaca.data.EventSummary import ru.kalinamall.seaca.data.EventDetail import ru.kalinamall.seaca.data.HostDetail +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ru.kalinamall.seaca.data.DateTimeDisplayMode import ru.kalinamall.seaca.data.NotificationMode import ru.kalinamall.seaca.data.ProblemDetail import ru.kalinamall.seaca.data.SacRepository import ru.kalinamall.seaca.ui.components.BackArrowButton import ru.kalinamall.seaca.ui.components.ForwardArrowButton +import ru.kalinamall.seaca.ui.util.SacDateTimes import ru.kalinamall.seaca.ui.util.dailyReportTypeLabel import ru.kalinamall.seaca.ui.util.formatEventDetails import ru.kalinamall.seaca.ui.util.inventoryFieldLines @@ -63,6 +67,9 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { var data by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } + val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) LaunchedEffect(id) { loading = true @@ -83,7 +90,7 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { if (isDailyReportType(d.type)) dailyReportTypeLabel(d.type) else d.type, ) Field("Хост", d.hostname) - Field("Время", d.occurredAt) + Field("Время", SacDateTimes.format(d.occurredAt, dateMode)) Field("Кратко", d.summary) if (isDailyReportType(d.type)) { reportTextFromDetails(d.details)?.let { MultilineField("Отчёт", it) } @@ -126,6 +133,9 @@ fun ProblemDetailScreen( var loading by remember { mutableStateOf(true) } var acting by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) fun load() { scope.launch { @@ -192,7 +202,11 @@ fun ProblemDetailScreen( if (d.events.isNotEmpty()) { 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, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + "${e.severity} · ${e.title}\n${SacDateTimes.format(e.occurredAt, dateMode)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } @@ -209,6 +223,9 @@ fun HostDetailScreen( var error by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } var selectedTab by remember { mutableIntStateOf(HostDetailTab.Info.ordinal) } + val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) LaunchedEffect(id) { loading = true @@ -264,7 +281,7 @@ fun HostDetailScreen( } } when (HostDetailTab.entries[selectedTab]) { - HostDetailTab.Info -> HostInfoContent(host) + HostDetailTab.Info -> HostInfoContent(host, dateMode) HostDetailTab.Events -> HostEventsContent( repo = repo, hostname = host.hostname, @@ -276,7 +293,7 @@ fun HostDetailScreen( } @Composable -private fun HostInfoContent(host: HostDetail) { +private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) { Column( Modifier .fillMaxSize() @@ -288,7 +305,7 @@ private fun HostInfoContent(host: HostDetail) { Field("Версия", host.productVersion ?: "—") Field("ОС", "${host.osFamily} ${host.osVersion.orEmpty()}".trim()) Field("Агент", host.agentStatus) - Field("Последний контакт", host.lastSeenAt) + Field("Последний контакт", SacDateTimes.format(host.lastSeenAt, dateMode)) host.inventory?.let { inv -> val lines = inventoryFieldLines(inv) if (lines.isEmpty()) { @@ -301,6 +318,7 @@ private fun HostInfoContent(host: HostDetail) { } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun HostEventsContent( repo: SacRepository, @@ -314,6 +332,9 @@ private fun HostEventsContent( var refreshing by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) fun load(showRefreshIndicator: Boolean = false) { scope.launch { @@ -369,7 +390,7 @@ private fun HostEventsContent( color = MaterialTheme.colorScheme.onSurface, ) Text( - event.occurredAt, + SacDateTimes.format(event.occurredAt, dateMode), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -398,6 +419,9 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) { var role by remember { mutableStateOf(null) } var deviceId by remember { mutableStateOf(0) } val notificationMode by repo.session.notificationMode.collectAsState(initial = NotificationMode.PUSH_SOUND) + val dateTimeMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) val scope = rememberCoroutineScope() LaunchedEffect(Unit) { @@ -458,6 +482,34 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) { } } + Text( + stringResource(R.string.date_time_format_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp), + ) + DateTimeDisplayMode.entries.forEach { mode -> + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = dateTimeMode == mode, + onClick = { + scope.launch { repo.session.setDateTimeDisplayMode(mode) } + }, + ) + Text( + mode.label, + modifier = Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + Button( onClick = { scope.launch { 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 ea8f49b..6e07855 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 @@ -1,6 +1,8 @@ package ru.kalinamall.seaca.ui.screens import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -33,8 +35,10 @@ 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.collectAsStateWithLifecycle import androidx.lifecycle.compose.LifecycleResumeEffect import kotlinx.coroutines.launch +import ru.kalinamall.seaca.data.DateTimeDisplayMode import ru.kalinamall.seaca.data.DashboardSummary import ru.kalinamall.seaca.data.EventListResponse import ru.kalinamall.seaca.data.EventSummary @@ -43,6 +47,7 @@ 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 +import ru.kalinamall.seaca.ui.util.SacDateTimes private val REPORT_TYPE_OPTIONS = listOf( "report.daily.ssh" to "Отчёты ssh клиентов", @@ -57,6 +62,10 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) { var loading by remember { mutableStateOf(false) } var refreshing by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) + val scrollState = rememberScrollState() fun load(showRefreshIndicator: Boolean = false) { scope.launch { @@ -89,7 +98,13 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) { onRefresh = { load(showRefreshIndicator = true) }, modifier = Modifier.fillMaxSize(), ) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { error?.let { Text(it, color = MaterialTheme.colorScheme.error) } data?.let { d -> StatCard("События 24ч", d.eventsLast24h.toString()) @@ -101,11 +116,23 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) { color = MaterialTheme.colorScheme.onSurface, ) d.recentEvents.take(8).forEach { e -> - Text( - dashboardRecentEventLine(e), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + dashboardRecentEventLine(e), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Text( + SacDateTimes.format(e.occurredAt, dateMode), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } if (data == null && loading) { @@ -144,6 +171,9 @@ private fun StatCard(label: String, value: String) { @Composable fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { var hostname by remember { mutableStateOf("") } + val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) PagedListScreen( title = "События", @@ -160,7 +190,7 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { }, itemKey = EventSummary::id, itemLabel = { "${it.severity} · ${it.title}" }, - itemSub = { "${it.hostname} · ${it.occurredAt}" }, + itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" }, onOpen = onOpen, severities = listOf(null, "info", "warning", "high", "critical"), hostnameFilter = hostname, @@ -224,6 +254,9 @@ fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { var refreshing by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) fun load(showRefreshIndicator: Boolean = false) { scope.launch { @@ -274,7 +307,7 @@ fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { items(data?.items.orEmpty(), key = { it.id }) { item -> ListRow( title = item.summary, - subtitle = "${item.hostname} · ${item.occurredAt}", + subtitle = "${item.hostname} · ${SacDateTimes.format(item.occurredAt, dateMode)}", onClick = { onOpen(item.id) }, ) } diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/util/SacDateTimes.kt b/app/src/main/java/ru/kalinamall/seaca/ui/util/SacDateTimes.kt new file mode 100644 index 0000000..1cc759f --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/util/SacDateTimes.kt @@ -0,0 +1,46 @@ +package ru.kalinamall.seaca.ui.util + +import ru.kalinamall.seaca.data.DateTimeDisplayMode +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +object SacDateTimes { + fun format(raw: String?, mode: DateTimeDisplayMode): String { + if (raw.isNullOrBlank()) return "—" + val zoned = parse(raw) ?: return raw.trim() + val local = zoned.withZoneSameInstant(ZoneId.systemDefault()) + return when (mode) { + DateTimeDisplayMode.LOCALE -> { + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.MEDIUM) + .withLocale(Locale.getDefault()) + .format(local) + } + else -> { + val pattern = mode.pattern ?: DateTimeDisplayMode.SLASH.pattern!! + DateTimeFormatter.ofPattern(pattern, Locale.getDefault()).format(local) + } + } + } + + private fun parse(raw: String): ZonedDateTime? { + val trimmed = raw.trim() + val normalised = trimmed.replace(Regex(" ([+-]\\d{2}:\\d{2})$"), "$1") + val candidates = listOf(normalised, trimmed) + for (candidate in candidates) { + runCatching { return OffsetDateTime.parse(candidate).toZonedDateTime() } + runCatching { return ZonedDateTime.parse(candidate) } + runCatching { return Instant.parse(candidate).atZone(ZoneId.systemDefault()) } + runCatching { + return LocalDateTime.parse(candidate, DateTimeFormatter.ISO_LOCAL_DATE_TIME) + .atZone(ZoneId.systemDefault()) + } + } + return null + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3736660..050dd82 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,4 +8,5 @@ SAC: важные (без звука) SAC: критические (без звука) Push-уведомления + Формат даты и времени