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 {