feat: формат даты/времени и pull-to-refresh на Обзоре (0.5.1)

Локальный вывод дат (по умолчанию ДД/ММ/ГГГГ ЧЧ:мм:сс), выбор формата в Устройство,
дата напротив событий на Обзоре, исправление compile и жеста обновления.
This commit is contained in:
2026-06-14 17:48:48 +10:00
parent 6af2a96c38
commit 180403068e
8 changed files with 179 additions and 16 deletions
+2 -2
View File
@@ -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 {
@@ -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
@@ -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
}
}
@@ -36,13 +36,24 @@ class SessionStore(private val context: Context) {
NotificationMode.fromStorage(prefs[KEY_NOTIFICATION_MODE])
}
val dateTimeDisplayMode: Flow<DateTimeDisplayMode> = 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"
@@ -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<EventDetail?>(null) }
var error by remember { mutableStateOf<String?>(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<String?>(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<String?>(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<String?>(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 {
@@ -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<String?>(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) },
)
}
@@ -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
}
}
+1
View File
@@ -8,4 +8,5 @@
<string name="channel_high_silent">SAC: важные (без звука)</string>
<string name="channel_critical_silent">SAC: критические (без звука)</string>
<string name="notification_mode_title">Push-уведомления</string>
<string name="date_time_format_title">Формат даты и времени</string>
</resources>