From e4ba6d6136a7905ea5632222367f769fc88fb08b Mon Sep 17 00:00:00 2001
From: Phil Oliver <3497406+poliver@users.noreply.github.com>
Date: Wed, 22 Oct 2025 16:10:09 -0400
Subject: [PATCH] Generate a POSIX timezone string from a ZoneID (#3514)
---
app/detekt-baseline.xml | 8 --
build.gradle.kts | 1 +
core/strings/src/main/res/values/strings.xml | 1 +
core/ui/build.gradle.kts | 3 +
.../core/ui/component/DropDownPreference.kt | 2 +-
.../core/ui/component/SwitchPreference.kt | 2 +-
.../core/ui/timezone/ZoneIdExtensions.kt | 134 ++++++++++++++++++
.../core/ui/timezone/ZoneIdExtensionsTest.kt | 55 +++++++
feature/settings/detekt-baseline.xml | 4 +-
.../radio/component/DeviceConfigItemList.kt | 71 +++++++++-
.../radio/component/RadioConfigScreenList.kt | 7 +-
11 files changed, 271 insertions(+), 17 deletions(-)
create mode 100644 core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt
create mode 100644 core/ui/src/test/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt
diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
index 2f527c472..fb1d6c77d 100644
--- a/app/detekt-baseline.xml
+++ b/app/detekt-baseline.xml
@@ -28,7 +28,6 @@
FinalNewline:BLEException.kt$com.geeksville.mesh.service.BLEException.kt
FinalNewline:BluetoothInterfaceFactory.kt$com.geeksville.mesh.repository.radio.BluetoothInterfaceFactory.kt
FinalNewline:BluetoothRepositoryModule.kt$com.geeksville.mesh.repository.bluetooth.BluetoothRepositoryModule.kt
- FinalNewline:CoroutineDispatchers.kt$com.geeksville.mesh.CoroutineDispatchers.kt
FinalNewline:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt
FinalNewline:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt
FinalNewline:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt
@@ -74,10 +73,8 @@
MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790
MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114
MagicNumber:SafeBluetooth.kt$SafeBluetooth$10
- MagicNumber:SafeBluetooth.kt$SafeBluetooth$100
MagicNumber:SafeBluetooth.kt$SafeBluetooth$1000
MagicNumber:SafeBluetooth.kt$SafeBluetooth$2500
- MagicNumber:SafeBluetooth.kt$SafeBluetooth.<no name provided>$2500
MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200
MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200
MagicNumber:ServiceClient.kt$ServiceClient$500
@@ -161,7 +158,6 @@
NewLineAtEndOfFile:BLEException.kt$com.geeksville.mesh.service.BLEException.kt
NewLineAtEndOfFile:BluetoothInterfaceFactory.kt$com.geeksville.mesh.repository.radio.BluetoothInterfaceFactory.kt
NewLineAtEndOfFile:BluetoothRepositoryModule.kt$com.geeksville.mesh.repository.bluetooth.BluetoothRepositoryModule.kt
- NewLineAtEndOfFile:CoroutineDispatchers.kt$com.geeksville.mesh.CoroutineDispatchers.kt
NewLineAtEndOfFile:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt
NewLineAtEndOfFile:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt
NewLineAtEndOfFile:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt
@@ -195,8 +191,6 @@
SwallowedException:MeshService.kt$MeshService$ex: BLEException
SwallowedException:MeshService.kt$MeshService$ex: CancellationException
SwallowedException:NsdManager.kt$ex: IllegalArgumentException
- SwallowedException:SafeBluetooth.kt$SafeBluetooth$ex: DeadObjectException
- SwallowedException:SafeBluetooth.kt$SafeBluetooth$ex: NullPointerException
SwallowedException:ServiceClient.kt$ServiceClient$ex: IllegalArgumentException
SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException
TooGenericExceptionCaught:BTScanModel.kt$BTScanModel$ex: Throwable
@@ -206,8 +200,6 @@
TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception
TooGenericExceptionCaught:MeshService.kt$MeshService.<no name provided>$ex: Exception
TooGenericExceptionCaught:MeshServiceStarter.kt$ServiceStarter$ex: Exception
- TooGenericExceptionCaught:SafeBluetooth.kt$SafeBluetooth$ex: Exception
- TooGenericExceptionCaught:SafeBluetooth.kt$SafeBluetooth$ex: NullPointerException
TooGenericExceptionCaught:SyncContinuation.kt$Continuation$ex: Throwable
TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable
TooGenericExceptionThrown:MeshService.kt$MeshService$throw Exception("Can't set user without a NodeInfo")
diff --git a/build.gradle.kts b/build.gradle.kts
index 6d1947e60..e137f7e11 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -86,6 +86,7 @@ dependencies {
kover(projects.core.navigation)
kover(projects.core.network)
kover(projects.core.prefs)
+ kover(projects.core.ui)
kover(projects.feature.intro)
kover(projects.feature.messaging)
kover(projects.feature.map)
diff --git a/core/strings/src/main/res/values/strings.xml b/core/strings/src/main/res/values/strings.xml
index fd29e2750..2dd634898 100644
--- a/core/strings/src/main/res/values/strings.xml
+++ b/core/strings/src/main/res/values/strings.xml
@@ -110,6 +110,7 @@
Send a position on the primary channel when the user button is triple clicked.
Controls the blinking LED on the device. For most devices this will control one of the up to 4 LEDs, the charger and GPS LEDs are not controllable.
Time zone for dates on the device screen and log.
+ Use phone time zone
Whether in addition to sending it to MQTT and the PhoneAPI, our NeighborInfo should be transmitted over LoRa. Not available on a channel with default key and name.
How long the screen remains on after the user button is pressed or messages are received.
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index 78d30c693..1789ede6f 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -19,6 +19,7 @@ plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.library.compose)
alias(libs.plugins.meshtastic.hilt)
+ alias(libs.plugins.kover)
}
android { namespace = "org.meshtastic.core.ui" }
@@ -49,4 +50,6 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.test.runner)
+
+ testImplementation(libs.junit)
}
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
index a3e30c60a..17eb242a3 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
@@ -105,7 +105,7 @@ fun DropDownPreference(
enabled = enabled,
supportingText =
if (summary != null) {
- { Text(text = summary, modifier = Modifier.padding(bottom = 8.dp)) }
+ { Text(text = summary) }
} else {
null
},
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SwitchPreference.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SwitchPreference.kt
index 01ae79bba..e2366ec64 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SwitchPreference.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SwitchPreference.kt
@@ -82,7 +82,7 @@ fun SwitchPreference(
},
supportingContent = {
if (summary.isNotEmpty()) {
- Text(text = summary, modifier = Modifier.padding(bottom = 16.dp))
+ Text(text = summary)
}
},
headlineContent = { Text(text = title) },
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt
new file mode 100644
index 000000000..1e26a2be0
--- /dev/null
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon")
+
+package org.meshtastic.core.ui.timezone
+
+import java.time.DayOfWeek
+import java.time.Instant
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.time.temporal.ChronoField
+import java.time.temporal.WeekFields
+import java.util.Locale
+import kotlin.math.abs
+
+/**
+ * Generates a POSIX time zone string from a [ZoneId]. Uses the specification found
+ * [here](https://www.postgresql.org/docs/current/datetime-posix-timezone-specs.html).
+ */
+fun ZoneId.toPosixString(): String {
+ val now = Instant.now()
+ val upcomingTransition = rules.nextTransition(now)
+
+ // No upcoming transition means this time zone does not support DST.
+ if (upcomingTransition == null) {
+ with(now.asZonedDateTime()) {
+ return "${timeZoneShortName()}${formattedOffsetString()}"
+ }
+ }
+
+ val upcomingInstant = upcomingTransition.instant
+ val followingTransition = rules.nextTransition(upcomingInstant)
+
+ val (stdTransition, dstTransition) =
+ if (rules.isDaylightSavings(upcomingInstant)) {
+ followingTransition to upcomingTransition
+ } else {
+ upcomingTransition to followingTransition
+ }
+
+ val stdDate = stdTransition.instant.asZonedDateTime()
+ val dstDate = dstTransition.instant.asZonedDateTime()
+
+ return buildString {
+ append(stdDate.timeZoneShortName())
+ append(stdDate.formattedOffsetString())
+ append(dstDate.timeZoneShortName())
+
+ // Don't append the DST offset if it is only 1 hour off.
+ @Suppress("MagicNumber")
+ if (abs(stdDate.offset.totalSeconds - dstDate.offset.totalSeconds) != 3600) {
+ append(dstDate.formattedOffsetString())
+ }
+
+ append(dstTransition.dateTimeBefore.transitionRuleString())
+ append(stdTransition.dateTimeBefore.transitionRuleString())
+ }
+}
+
+/** Returns the time zone short. e.g. "EST" or "EDT". */
+private fun ZonedDateTime.timeZoneShortName(): String {
+ val formatter = DateTimeFormatter.ofPattern("zzz", Locale.ENGLISH)
+ val shortName = format(formatter)
+ return if (shortName.startsWith("GMT")) "GMT" else shortName
+}
+
+/**
+ * Returns the time zone offset string with the format "::". Minutes and seconds are only shown
+ * if they are non-zero.
+ */
+@Suppress("MagicNumber")
+private fun ZonedDateTime.formattedOffsetString(): String {
+ val offsetSeconds = -offset.totalSeconds
+
+ val hours = offsetSeconds / 3600
+ val remainingSeconds = abs(offsetSeconds) % 3600
+ val minutes = remainingSeconds / 60
+ val seconds = remainingSeconds % 60
+
+ return buildString {
+ append(hours)
+ appendMinSec(minutes = minutes, seconds = seconds) { ":%02d".format(Locale.ENGLISH, it) }
+ }
+}
+
+/**
+ * Returns a transition rule string with the format
+ * ",M../::". Time is omitted if it is 2:00:00, since that
+ * is the spec default. Otherwise, append time with non-zero values.
+ */
+@Suppress("MagicNumber")
+private fun LocalDateTime.transitionRuleString() = buildString {
+ val weekOfMonth = get(ChronoField.ALIGNED_WEEK_OF_MONTH)
+ val dayOfWeek = get(WeekFields.of(DayOfWeek.SUNDAY, 7).dayOfWeek()) - 1
+ append(",M$monthValue.$weekOfMonth.$dayOfWeek")
+
+ when {
+ // No-op for spec default
+ hour == 2 && minute == 0 && second == 0 -> Unit
+ else -> {
+ append("/$hour")
+ appendMinSec(minutes = minute, seconds = second) { ":$it" }
+ }
+ }
+}
+
+private inline fun StringBuilder.appendMinSec(minutes: Int, seconds: Int, format: (Int) -> String) {
+ if (minutes != 0 || seconds != 0) {
+ // This covers both "30m:30s" and "00m:30s"
+ append(format(minutes))
+ // This prevents "30m:00s"
+ if (seconds != 0) append(format(seconds))
+ }
+}
+
+context(zoneId: ZoneId)
+private fun Instant.asZonedDateTime() = ZonedDateTime.ofInstant(this, zoneId)
diff --git a/core/ui/src/test/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt b/core/ui/src/test/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt
new file mode 100644
index 000000000..5d2a97262
--- /dev/null
+++ b/core/ui/src/test/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.meshtastic.core.ui.timezone
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.time.ZoneId
+
+class ZoneIdExtensionsTest {
+
+ @Test
+ fun `test POSIX string generation`() {
+ val zoneMap =
+ mapOf(
+ "US/Hawaii" to "HST10",
+ "US/Alaska" to "AKST9AKDT,M3.2.0,M11.1.0",
+ "US/Pacific" to "PST8PDT,M3.2.0,M11.1.0",
+ "US/Arizona" to "MST7",
+ "US/Mountain" to "MST7MDT,M3.2.0,M11.1.0",
+ "US/Central" to "CST6CDT,M3.2.0,M11.1.0",
+ "US/Eastern" to "EST5EDT,M3.2.0,M11.1.0",
+ "America/Sao_Paulo" to "BRT3",
+ "UTC" to "UTC0",
+ "Europe/London" to "GMT0BST,M3.5.0/1,M10.4.0",
+ "Europe/Lisbon" to "WET0WEST,M3.5.0/1,M10.4.0",
+ "Europe/Budapest" to "CET-1CEST,M3.5.0,M10.4.0/3",
+ "Europe/Kiev" to "EET-2EEST,M3.5.0/3,M10.4.0/4",
+ "Africa/Cairo" to "EET-2EEST,M4.4.5/0,M10.5.5/0",
+ "Asia/Kolkata" to "IST-5:30",
+ "Asia/Hong_Kong" to "HKT-8",
+ "Asia/Tokyo" to "JST-9",
+ "Australia/Perth" to "AWST-8",
+ "Australia/Adelaide" to "ACST-9:30ACDT,M10.1.0,M4.1.0/3",
+ "Australia/Sydney" to "AEST-10AEDT,M10.1.0,M4.1.0/3",
+ "Pacific/Auckland" to "NZST-12NZDT,M9.4.0,M4.1.0/3",
+ )
+
+ zoneMap.forEach { (tz, expected) -> assertEquals(expected, ZoneId.of(tz).toPosixString()) }
+ }
+}
diff --git a/feature/settings/detekt-baseline.xml b/feature/settings/detekt-baseline.xml
index e8e0f2dbf..45086dd7c 100644
--- a/feature/settings/detekt-baseline.xml
+++ b/feature/settings/detekt-baseline.xml
@@ -17,13 +17,14 @@
LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)
LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)
LongMethod:DetectionSensorConfigItemList.kt$@Composable fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)
- LongMethod:DeviceConfigItemList.kt$@Composable fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)
+ LongMethod:DeviceConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)
LongMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)
LongMethod:ExternalNotificationConfigItemList.kt$@Composable fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)
LongMethod:LoRaConfigItemList.kt$@Composable fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
LongMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)
LongMethod:PositionConfigItemList.kt$@OptIn(ExperimentalPermissionsApi::class) @Composable fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)
LongMethod:PowerConfigItemList.kt$@Composable fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)
+ LongMethod:RadioConfigScreenList.kt$@Composable fun <T : MessageLite> RadioConfigScreenList( title: String, onBack: () -> Unit, responseState: ResponseState<Any>, onDismissPacketResponse: () -> Unit, configState: ConfigState<T>, enabled: Boolean, onSave: (T) -> Unit, content: LazyListScope.() -> Unit, )
LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)
LongMethod:SecurityConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)
LongMethod:SerialConfigItemList.kt$@Composable fun SerialConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)
@@ -35,6 +36,7 @@
MagicNumber:EditChannelDialog.kt$32
MagicNumber:PacketResponseStateDialog.kt$100
ModifierMissing:CleanNodeDatabaseScreen.kt$CleanNodeDatabaseScreen
+ ModifierMissing:DeviceConfigItemList.kt$DeviceConfigScreen
ModifierMissing:MapReportingPreference.kt$MapReportingPreference
ModifierMissing:NetworkConfigItemList.kt$NetworkConfigScreen
ModifierMissing:PositionConfigItemList.kt$PositionConfigScreen
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt
index 64ad6a813..ed3bbe2b1 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt
@@ -17,27 +17,43 @@
package org.meshtastic.feature.settings.radio.component
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Clear
+import androidx.compose.material.icons.rounded.PhoneAndroid
import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults.MediumContainerHeight
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
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.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
@@ -47,19 +63,23 @@ import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
+import org.meshtastic.core.ui.component.InsetDivider
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
+import org.meshtastic.core.ui.timezone.toPosixString
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.ConfigProtos.Config.DeviceConfig
import org.meshtastic.proto.config
import org.meshtastic.proto.copy
+import java.time.ZoneId
private val DeviceConfig.Role.description: Int
get() =
@@ -91,6 +111,7 @@ private val DeviceConfig.RebroadcastMode.description: Int
else -> R.string.unrecognized
}
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
@@ -131,6 +152,7 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
onItemSelected = { selectedRole = it },
summary = stringResource(id = formState.value.role.description),
)
+
HorizontalDivider()
DropDownPreference(
@@ -140,6 +162,7 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
onItemSelected = { formState.value = formState.value.copy { rebroadcastMode = it } },
summary = stringResource(id = formState.value.rebroadcastMode.description),
)
+
HorizontalDivider()
val nodeInfoBroadcastIntervals = remember { IntervalConfiguration.NODE_INFO_BROADCAST.allowedIntervals }
@@ -152,6 +175,7 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
)
}
}
+
item {
TitledCard(title = stringResource(R.string.hardware)) {
SwitchPreference(
@@ -162,7 +186,8 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
onCheckedChange = { formState.value = formState.value.copy { doubleTapAsButtonPress = it } },
containerColor = CardDefaults.cardColors().containerColor,
)
- HorizontalDivider()
+
+ InsetDivider()
SwitchPreference(
title = stringResource(R.string.triple_click_adhoc_ping),
@@ -172,7 +197,9 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
onCheckedChange = { formState.value = formState.value.copy { disableTripleClick = !it } },
containerColor = CardDefaults.cardColors().containerColor,
)
- HorizontalDivider()
+
+ InsetDivider()
+
SwitchPreference(
title = stringResource(R.string.led_heartbeat),
summary = stringResource(id = R.string.config_device_ledHeartbeatEnabled_summary),
@@ -181,13 +208,27 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
onCheckedChange = { formState.value = formState.value.copy { ledHeartbeatDisabled = !it } },
containerColor = CardDefaults.cardColors().containerColor,
)
- HorizontalDivider()
}
}
item {
- TitledCard(title = stringResource(R.string.debug)) {
+ TitledCard(title = stringResource(R.string.time_zone)) {
+ val context = LocalContext.current
+ val appTzPosixString by
+ produceState(initialValue = ZoneId.systemDefault().toPosixString()) {
+ val receiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == Intent.ACTION_TIMEZONE_CHANGED) {
+ value = ZoneId.systemDefault().toPosixString()
+ }
+ }
+ }
+ context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
+ awaitDispose { context.unregisterReceiver(receiver) }
+ }
+
EditTextPreference(
- title = stringResource(R.string.time_zone),
+ title = "",
value = formState.value.tzdef,
summary = stringResource(id = R.string.config_device_tzdef_summary),
maxSize = 64, // tzdef max_size:65
@@ -197,7 +238,27 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { tzdef = it } },
+ trailingIcon = {
+ IconButton(onClick = { formState.value = formState.value.copy { tzdef = "" } }) {
+ Icon(imageVector = Icons.Rounded.Clear, contentDescription = null)
+ }
+ },
)
+
+ HorizontalDivider()
+
+ TextButton(
+ modifier = Modifier.height(MediumContainerHeight).fillMaxWidth(),
+ enabled = state.connected,
+ shape = RectangleShape,
+ onClick = { formState.value = formState.value.copy { tzdef = appTzPosixString } },
+ ) {
+ Icon(imageVector = Icons.Rounded.PhoneAndroid, contentDescription = null)
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Text(text = stringResource(R.string.config_device_use_phone_tz))
+ }
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt
index dc0df3340..ea7e5f207 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt
@@ -24,6 +24,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
@@ -78,7 +79,11 @@ fun RadioConfigScreenList(
val showFooterButtons = configState.isDirty
Box(modifier = Modifier.padding(innerPadding)) {
- LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp)) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
content()
item {