feat: формат даты/времени и pull-to-refresh на Обзоре (0.5.1)
Локальный вывод дат (по умолчанию ДД/ММ/ГГГГ ЧЧ:мм:сс), выбор формата в Устройство, дата напротив событий на Обзоре, исправление compile и жеста обновления.
This commit is contained in:
@@ -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 ->
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user