Generate a POSIX timezone string from a ZoneID (#3514)

This commit is contained in:
Phil Oliver
2025-10-22 16:10:09 -04:00
committed by GitHub
parent 58eeef38a9
commit e4ba6d6136
11 changed files with 271 additions and 17 deletions

View File

@@ -28,7 +28,6 @@
<ID>FinalNewline:BLEException.kt$com.geeksville.mesh.service.BLEException.kt</ID>
<ID>FinalNewline:BluetoothInterfaceFactory.kt$com.geeksville.mesh.repository.radio.BluetoothInterfaceFactory.kt</ID>
<ID>FinalNewline:BluetoothRepositoryModule.kt$com.geeksville.mesh.repository.bluetooth.BluetoothRepositoryModule.kt</ID>
<ID>FinalNewline:CoroutineDispatchers.kt$com.geeksville.mesh.CoroutineDispatchers.kt</ID>
<ID>FinalNewline:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt</ID>
<ID>FinalNewline:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt</ID>
<ID>FinalNewline:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt</ID>
@@ -74,10 +73,8 @@
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114</ID>
<ID>MagicNumber:SafeBluetooth.kt$SafeBluetooth$10</ID>
<ID>MagicNumber:SafeBluetooth.kt$SafeBluetooth$100</ID>
<ID>MagicNumber:SafeBluetooth.kt$SafeBluetooth$1000</ID>
<ID>MagicNumber:SafeBluetooth.kt$SafeBluetooth$2500</ID>
<ID>MagicNumber:SafeBluetooth.kt$SafeBluetooth.&lt;no name provided&gt;$2500</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200</ID>
<ID>MagicNumber:ServiceClient.kt$ServiceClient$500</ID>
@@ -161,7 +158,6 @@
<ID>NewLineAtEndOfFile:BLEException.kt$com.geeksville.mesh.service.BLEException.kt</ID>
<ID>NewLineAtEndOfFile:BluetoothInterfaceFactory.kt$com.geeksville.mesh.repository.radio.BluetoothInterfaceFactory.kt</ID>
<ID>NewLineAtEndOfFile:BluetoothRepositoryModule.kt$com.geeksville.mesh.repository.bluetooth.BluetoothRepositoryModule.kt</ID>
<ID>NewLineAtEndOfFile:CoroutineDispatchers.kt$com.geeksville.mesh.CoroutineDispatchers.kt</ID>
<ID>NewLineAtEndOfFile:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt</ID>
<ID>NewLineAtEndOfFile:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt</ID>
<ID>NewLineAtEndOfFile:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt</ID>
@@ -195,8 +191,6 @@
<ID>SwallowedException:MeshService.kt$MeshService$ex: BLEException</ID>
<ID>SwallowedException:MeshService.kt$MeshService$ex: CancellationException</ID>
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
<ID>SwallowedException:SafeBluetooth.kt$SafeBluetooth$ex: DeadObjectException</ID>
<ID>SwallowedException:SafeBluetooth.kt$SafeBluetooth$ex: NullPointerException</ID>
<ID>SwallowedException:ServiceClient.kt$ServiceClient$ex: IllegalArgumentException</ID>
<ID>SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException</ID>
<ID>TooGenericExceptionCaught:BTScanModel.kt$BTScanModel$ex: Throwable</ID>
@@ -206,8 +200,6 @@
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService.&lt;no name provided&gt;$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshServiceStarter.kt$ServiceStarter$ex: Exception</ID>
<ID>TooGenericExceptionCaught:SafeBluetooth.kt$SafeBluetooth$ex: Exception</ID>
<ID>TooGenericExceptionCaught:SafeBluetooth.kt$SafeBluetooth$ex: NullPointerException</ID>
<ID>TooGenericExceptionCaught:SyncContinuation.kt$Continuation$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable</ID>
<ID>TooGenericExceptionThrown:MeshService.kt$MeshService$throw Exception("Can't set user without a NodeInfo")</ID>

View File

@@ -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)

View File

@@ -110,6 +110,7 @@
<string name="config_device_tripleClickAsAdHocPing_summary">Send a position on the primary channel when the user button is triple clicked.</string>
<string name="config_device_ledHeartbeatEnabled_summary">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.</string>
<string name="config_device_tzdef_summary">Time zone for dates on the device screen and log.</string>
<string name="config_device_use_phone_tz">Use phone time zone</string>
<string name="config_device_transmitOverLora_summary">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.</string>
<string name="config_display_screen_on_secs_summary">How long the screen remains on after the user button is pressed or messages are received.</string>

View File

@@ -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)
}

View File

@@ -105,7 +105,7 @@ fun <T> DropDownPreference(
enabled = enabled,
supportingText =
if (summary != null) {
{ Text(text = summary, modifier = Modifier.padding(bottom = 8.dp)) }
{ Text(text = summary) }
} else {
null
},

View File

@@ -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) },

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
@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 "<HOURS>:<MINUTES>:<SECONDS>". 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<MONTH>.<WEEK_OF_MONTH>.<DAY_OF_WEEK>/<HOURS>:<MINUTES>:<SECONDS>". 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)

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()) }
}
}

View File

@@ -17,13 +17,14 @@
<ID>LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:DetectionSensorConfigItemList.kt$@Composable fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:DeviceConfigItemList.kt$@Composable fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:DeviceConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:ExternalNotificationConfigItemList.kt$@Composable fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:LoRaConfigItemList.kt$@Composable fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -&gt; Unit)</ID>
<ID>LongMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:PositionConfigItemList.kt$@OptIn(ExperimentalPermissionsApi::class) @Composable fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:PowerConfigItemList.kt$@Composable fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:RadioConfigScreenList.kt$@Composable fun &lt;T : MessageLite&gt; RadioConfigScreenList( title: String, onBack: () -&gt; Unit, responseState: ResponseState&lt;Any&gt;, onDismissPacketResponse: () -&gt; Unit, configState: ConfigState&lt;T&gt;, enabled: Boolean, onSave: (T) -&gt; Unit, content: LazyListScope.() -&gt; Unit, )</ID>
<ID>LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)</ID>
<ID>LongMethod:SecurityConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:SerialConfigItemList.kt$@Composable fun SerialConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
@@ -35,6 +36,7 @@
<ID>MagicNumber:EditChannelDialog.kt$32</ID>
<ID>MagicNumber:PacketResponseStateDialog.kt$100</ID>
<ID>ModifierMissing:CleanNodeDatabaseScreen.kt$CleanNodeDatabaseScreen</ID>
<ID>ModifierMissing:DeviceConfigItemList.kt$DeviceConfigScreen</ID>
<ID>ModifierMissing:MapReportingPreference.kt$MapReportingPreference</ID>
<ID>ModifierMissing:NetworkConfigItemList.kt$NetworkConfigScreen</ID>
<ID>ModifierMissing:PositionConfigItemList.kt$PositionConfigScreen</ID>

View File

@@ -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))
}
}
}

View File

@@ -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 <T : MessageLite> 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 {