diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt index d3074454d..336e463da 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt @@ -176,7 +176,7 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva private fun createNotificationChannel(type: NotificationType) { if (notificationManager.getNotificationChannel(type.channelId) != null) return - val channelName = context.getString(type.channelNameRes) + val channelName = getString(type.channelNameRes) val channel = NotificationChannel(type.channelId, channelName, type.importance).apply { lightColor = NOTIFICATION_LIGHT_COLOR @@ -259,7 +259,7 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva null } - cachedMessage = message ?: cachedMessage ?: context.getString(Res.string.no_local_stats) + cachedMessage = message ?: cachedMessage ?: getString(Res.string.no_local_stats) val notification = createServiceStateNotification( @@ -295,7 +295,7 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva override fun showClientNotification(clientNotification: MeshProtos.ClientNotification) { val notification = - createClientNotification(context.getString(Res.string.client_notification), clientNotification.message) + createClientNotification(getString(Res.string.client_notification), clientNotification.message) notificationManager.notify(clientNotification.toString().hashCode(), notification) } @@ -375,7 +375,7 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva } private fun createNewNodeSeenNotification(name: String, message: String?): Notification { - val title = context.getString(Res.string.new_node_seen).format(name) + val title = getString(Res.string.new_node_seen).format(name) val builder = commonBuilder(NotificationType.NewNode) .setCategory(Notification.CATEGORY_STATUS) @@ -393,9 +393,8 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva private fun createLowBatteryNotification(node: NodeEntity, isRemote: Boolean): Notification { val type = if (isRemote) NotificationType.LowBatteryRemote else NotificationType.LowBatteryLocal - val title = context.getString(Res.string.low_battery_title).format(node.shortName) - val message = - context.getString(Res.string.low_battery_message).format(node.longName, node.deviceMetrics.batteryLevel) + val title = getString(Res.string.low_battery_title).format(node.shortName) + val message = getString(Res.string.low_battery_message).format(node.longName, node.deviceMetrics.batteryLevel) return commonBuilder(type) .setCategory(Notification.CATEGORY_STATUS) @@ -445,7 +444,7 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva } private fun createReplyAction(contactKey: String): NotificationCompat.Action { - val replyLabel = context.getString(Res.string.reply) + val replyLabel = getString(Res.string.reply) val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).setLabel(replyLabel).build() val replyIntent = diff --git a/core/strings/src/androidMain/kotlin/com/meshtastic/core/strings/ContextExt.kt b/core/strings/src/androidMain/kotlin/com/meshtastic/core/strings/ContextExt.kt index dbd8709a8..9361e7633 100644 --- a/core/strings/src/androidMain/kotlin/com/meshtastic/core/strings/ContextExt.kt +++ b/core/strings/src/androidMain/kotlin/com/meshtastic/core/strings/ContextExt.kt @@ -17,23 +17,13 @@ package com.meshtastic.core.strings -import android.content.Context -import android.content.res.Resources import kotlinx.coroutines.runBlocking import org.jetbrains.compose.resources.StringResource -fun Context.getString(stringResource: StringResource): String = runBlocking { +fun getString(stringResource: StringResource): String = runBlocking { org.jetbrains.compose.resources.getString(stringResource) } -fun Context.getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking { - org.jetbrains.compose.resources.getString(stringResource, *formatArgs) -} - -fun Resources.getString(stringResource: StringResource): String = runBlocking { - org.jetbrains.compose.resources.getString(stringResource) -} - -fun Resources.getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking { +fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking { org.jetbrains.compose.resources.getString(stringResource, *formatArgs) } diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 4c29a0338..61402f2ce 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -23,7 +23,7 @@ 繁體中文 SKH - hey I found the cache, it is over here next to the big tiger. I\'m kinda scared. + hey I found the cache, it is over here next to the big tiger. I'm kinda scared. mqtt.meshtastic.org @@ -99,7 +99,7 @@ Local Only Ignores observed messages from foreign meshes that are open or those which it cannot decrypt. Only rebroadcasts message on the nodes local primary / secondary channels. Known Only - Ignores observed messages from foreign meshes like LOCAL ONLY, but takes it step further by also ignoring messages from nodes not already in the node\'s known list. + Ignores observed messages from foreign meshes like LOCAL ONLY, but takes it step further by also ignoring messages from nodes not already in the node's known list. None Only permitted for SENSOR, TRACKER and TAK_TRACKER roles, this will inhibit all rebroadcasts, not unlike CLIENT_MUTE role. Core Portnums Only @@ -147,7 +147,7 @@ How often should we try to get a GPS position (<10sec keeps GPS on). Optional fields to include when assembling position messages. the more fields are included, the larger the message will be - leading to longer airtime and a higher risk of packet loss. - Will sleep everything as much as possible, for the tracker and sensor role this will also include the lora radio. Don\'t use this setting if you want to use your device with the phone apps or are using a device without a user button. + Will sleep everything as much as possible, for the tracker and sensor role this will also include the lora radio. Don't use this setting if you want to use your device with the phone apps or are using a device without a user button. Generated from your public key and sent out to other nodes on the mesh to allow them to compute a shared secret key. Used to create a shared key with a remote device. @@ -181,7 +181,7 @@ application icon Unknown Username Send - You haven\'t yet paired a Meshtastic compatible radio with this phone. Please pair a device and set your username.\n\nThis open-source application is in development, if you find problems please post on our forum: https://github.com/orgs/meshtastic/discussions.\n\nFor more information see our web page - www.meshtastic.org. + You haven't yet paired a Meshtastic compatible radio with this phone. Please pair a device and set your username.\n\nThis open-source application is in development, if you find problems please post on our forum: https://github.com/orgs/meshtastic/discussions.\n\nFor more information see our web page - www.meshtastic.org. You Allow analytics and crash reporting. Accept @@ -198,14 +198,14 @@ Pairing failed, please select again Location access is turned off, can not provide position to mesh. Share - New Node Seen: %s + New Node Seen: %1$s Disconnected Device sleeping Connected: %1$s online IP Address: Port: Connected - Connected to radio (%s) + Connected to radio (%1$s) Current connections: Wifi IP: Ethernet IP: @@ -246,7 +246,7 @@ The radio firmware is too old to talk to this application. For more information on this see our Firmware Installation guide. OK You must set a region! - Couldn\'t change channel, because radio is not yet connected. Please try again. + Couldn't change channel, because radio is not yet connected. Please try again. Export rangetest packets Export all packets Reset @@ -263,7 +263,7 @@ Provide phone location to mesh Delete message? - Delete %s messages? + Delete %1$s messages? Delete Delete for everyone @@ -297,15 +297,15 @@ Bluetooth is disabled. Please enable it in your device settings. Open settings Firmware version: %1$s - Meshtastic needs \"Nearby devices\" permissions enabled to find and connect to devices via Bluetooth. You can disable when not in use. + Meshtastic needs "Nearby devices" permissions enabled to find and connect to devices via Bluetooth. You can disable when not in use. Direct Message NodeDB reset Delivery confirmed Error Ignore Remove from ignored - Add \'%s\' to ignore list? - Remove \'%s\' from ignore list? + Add '%1$s' to ignore list? + Remove '%1$s' from ignore list? Select download region Tile download estimate: Start Download @@ -318,10 +318,10 @@ Calculating… Offline Manager Current Cache size - Cache Capacity: %1$.2f MB\nCache Usage: %2$.2f MB + Cache Capacity: %1$d MB\nCache Usage: %2$d MB Clear Downloaded Tiles Tile Source - SQL Cache purged for %s + SQL Cache purged for %1$s SQL Cache purge failed, see logcat for details Cache Manager Download complete! @@ -331,7 +331,7 @@ Edit waypoint Delete waypoint? New waypoint - Received waypoint: %s + Received waypoint: %1$s Duty Cycle limit reached. Cannot send messages right now, please try again later. Remove This node will be removed from your list until your node receives data from it again. @@ -412,8 +412,8 @@ Favorite Add to favorites Remove from favorites - Add \'%s\' as a favorite node? - Remove \'%s\' as a favorite node? + Add '%1$s' as a favorite node? + Remove '%1$s' as a favorite node? Power Metrics Log Channel 1 Channel 2 @@ -422,10 +422,10 @@ Voltage Are you sure? Device Role Documentation and the blog post about Choosing The Right Device Role.]]> - I know what I\'m doing. + I know what I'm doing. Node %1$s has a low battery (%2$d%%) Low battery notifications - Low battery: %s + Low battery: %1$s Low battery notifications (favorite nodes) Barometric Pressure Enabled @@ -866,7 +866,7 @@ Select packets sent as critical will ignore the mute switch and Do Not Disturb settings in the OS notification center. Configure notification permissions Phone Location - Meshtastic uses your phone\'s location to enable a number of features. You can update your location permissions at any time from settings. + Meshtastic uses your phone's location to enable a number of features. You can update your location permissions at any time from settings. Share Location Use your phone GPS to send locations to your node to instead of using a hardware GPS on your node. Distance Measurements @@ -922,7 +922,7 @@ Channel Features Location Sharing Periodic position broadcast - Messages from the mesh will be sent to the public internet through any node\'s configured gateway. + Messages from the mesh will be sent to the public internet through any node's configured gateway. Messages from a public internet gateway are forwarded to the local mesh. Due to the zero-hop policy, traffic from the default MQTT server will not propagate further than this device. Icon Meanings Disabling position on the primary channel allows periodic position broadcasts on the first secondary channel with the position enabled, otherwise manual position request required. @@ -935,7 +935,7 @@ 8 Hours 24 Hours 48 Hours - Filter by Last Heard time: %s + Filter by Last Heard time: %1$s %1$d dBm No application available to handle link. System Settings @@ -949,6 +949,6 @@ For more information, see our privacy policy. " https://meshtastic.org/docs/legal/privacy/" Unset - 0 - Relayed by: %s + Relayed by: %1$s Preserve Favorites? diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 5a7dce196..3c1464d06 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -18,16 +18,20 @@ package org.meshtastic.feature.map import android.Manifest // Added for Accompanist -import android.content.Context import androidx.appcompat.content.res.AppCompatResources import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Lens import androidx.compose.material.icons.filled.LocationDisabled @@ -36,17 +40,26 @@ import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.MyLocation import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -57,7 +70,6 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -65,8 +77,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi // Added for Accompanist import com.google.accompanist.permissions.rememberMultiplePermissionsState // Added for Accompanist import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.meshtastic.core.strings.getString import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.gpsDisabled @@ -106,6 +118,8 @@ import org.meshtastic.core.strings.show_waypoints import org.meshtastic.core.strings.toggle_my_position import org.meshtastic.core.strings.waypoint_delete import org.meshtastic.core.strings.you +import org.meshtastic.core.ui.component.BasicListItem +import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.util.showToast import org.meshtastic.feature.map.cluster.RadiusMarkerClusterer import org.meshtastic.feature.map.component.CacheLayout @@ -194,41 +208,6 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) } } -private fun Context.purgeTileSource(onResult: (String) -> Unit) { - val cache = SqlTileWriterExt() - val builder = MaterialAlertDialogBuilder(this) - builder.setTitle(getString(Res.string.map_tile_source)) - val sources = cache.sources - val sourceList = mutableListOf() - for (i in sources.indices) { - sourceList.add(sources[i].source as String) - } - val selected: BooleanArray? = null - val selectedList = mutableListOf() - builder.setMultiChoiceItems(sourceList.toTypedArray(), selected) { _, i, b -> - if (b) { - selectedList.add(i) - } else { - selectedList.remove(i) - } - } - builder.setPositiveButton(getString(Res.string.clear)) { _, _ -> - for (x in selectedList) { - val item = sources[x] - val b = cache.purgeCache(item.source) - onResult( - if (b) { - getString(Res.string.map_purge_success, item.source.toString()) - } else { - getString(Res.string.map_purge_fail) - }, - ) - } - } - builder.setNegativeButton(getString(Res.string.cancel)) { dialog, _ -> dialog.cancel() } - builder.show() -} - /** * Main composable for displaying the map view, including nodes, waypoints, and user location. It handles user * interactions for map manipulation, filtering, and offline caching. @@ -255,11 +234,13 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: var showDownloadButton: Boolean by remember { mutableStateOf(false) } var showEditWaypointDialog by remember { mutableStateOf(null) } + var showCacheManagerDialog by remember { mutableStateOf(false) } var showCurrentCacheInfo by remember { mutableStateOf(false) } + var showPurgeTileSourceDialog by remember { mutableStateOf(false) } + var showMapStyleDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val context = LocalContext.current - val resources = LocalResources.current val density = LocalDensity.current val haptic = LocalHapticFeedback.current @@ -360,7 +341,7 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: id = u.id title = u.longName snippet = - resources.getString( + com.meshtastic.core.strings.getString( Res.string.map_node_popup_details, node.gpsString(), formatAgo(node.lastHeard), @@ -369,7 +350,11 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: ) ourNode?.distanceStr(node, displayUnits)?.let { dist -> subDescription = - resources.getString(Res.string.map_subDescription, ourNode.bearing(node).toString(), dist) + com.meshtastic.core.strings.getString( + Res.string.map_subDescription, + ourNode.bearing(node).toString(), + dist, + ) } setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) position = nodePosition @@ -390,16 +375,16 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: fun showDeleteMarkerDialog(waypoint: Waypoint) { val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(resources.getString(Res.string.waypoint_delete)) - builder.setNeutralButton(resources.getString(Res.string.cancel)) { _, _ -> + builder.setTitle(com.meshtastic.core.strings.getString(Res.string.waypoint_delete)) + builder.setNeutralButton(com.meshtastic.core.strings.getString(Res.string.cancel)) { _, _ -> Timber.d("User canceled marker delete dialog") } - builder.setNegativeButton(resources.getString(Res.string.delete_for_me)) { _, _ -> + builder.setNegativeButton(com.meshtastic.core.strings.getString(Res.string.delete_for_me)) { _, _ -> Timber.d("User deleted waypoint ${waypoint.id} for me") mapViewModel.deleteWaypoint(waypoint.id) } if (waypoint.lockedTo in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) { - builder.setPositiveButton(resources.getString(Res.string.delete_for_everyone)) { _, _ -> + builder.setPositiveButton(com.meshtastic.core.strings.getString(Res.string.delete_for_everyone)) { _, _ -> Timber.d("User deleted waypoint ${waypoint.id} for everyone") mapViewModel.sendWaypoint(waypoint.copy { expire = 1 }) mapViewModel.deleteWaypoint(waypoint.id) @@ -434,7 +419,7 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: } fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL) { - resources.getString(Res.string.you) + com.meshtastic.core.strings.getString(Res.string.you) } else { mapViewModel.getUser(id).longName } @@ -485,30 +470,6 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: } } - LaunchedEffect(showCurrentCacheInfo) { - if (!showCurrentCacheInfo) return@LaunchedEffect - context.showToast(Res.string.calculating) - val cacheManager = CacheManager(map) - val cacheCapacity = cacheManager.cacheCapacity() - val currentCacheUsage = cacheManager.currentCacheUsage() - - val mapCacheInfoText = - getString( - Res.string.map_cache_info, - cacheCapacity / (1024.0 * 1024.0), - currentCacheUsage / (1024.0 * 1024.0), - ) - - MaterialAlertDialogBuilder(context) - .setTitle(resources.getString(Res.string.map_cache_manager)) - .setMessage(mapCacheInfoText) - .setPositiveButton(resources.getString(Res.string.close)) { dialog, _ -> - showCurrentCacheInfo = false - dialog.dismiss() - } - .show() - } - val mapEventsReceiver = object : MapEventsReceiver { override fun singleTapConfirmedHelper(p: GeoPoint): Boolean { @@ -564,7 +525,7 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: val tileCount: Int = CacheManager(this) .possibleTilesInArea(downloadRegionBoundingBox, zoomLevelMin.toInt(), zoomLevelMax.toInt()) - cacheEstimate = resources.getString(Res.string.map_cache_tiles, tileCount) + cacheEstimate = com.meshtastic.core.strings.getString(Res.string.map_cache_tiles, tileCount) } val boxOverlayListener = @@ -612,49 +573,9 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: } } - fun showMapStyleDialog() { - val builder = MaterialAlertDialogBuilder(context) - val mapStyles: Array = CustomTileSource.mTileSources.values.toTypedArray() - - val mapStyleInt = mapViewModel.mapStyleId - builder.setSingleChoiceItems(mapStyles, mapStyleInt) { dialog, which -> - Timber.d("Set mapStyleId pref to $which") - mapViewModel.mapStyleId = which - dialog.dismiss() - map.setTileSource(loadOnlineTileSourceBase()) - } - val dialog = builder.create() - dialog.show() - } - - fun Context.showCacheManagerDialog() { - MaterialAlertDialogBuilder(this) - .setTitle(resources.getString(Res.string.map_offline_manager)) - .setItems( - arrayOf( - getString(Res.string.map_cache_size), - getString(Res.string.map_download_region), - getString(Res.string.map_clear_tiles), - getString(Res.string.cancel), - ), - ) { dialog, which -> - when (which) { - 0 -> showCurrentCacheInfo = true - 1 -> { - map.generateBoxOverlay() - dialog.dismiss() - } - - 2 -> purgeTileSource { scope.launch { context.showToast(it) } } - else -> dialog.dismiss() - } - } - .show() - } - Scaffold( floatingActionButton = { - DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { context.showCacheManagerDialog() } + DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true } }, ) { innerPadding -> Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { @@ -685,7 +606,7 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: verticalArrangement = Arrangement.spacedBy(8.dp), ) { MapButton( - onClick = ::showMapStyleDialog, + onClick = { showMapStyleDialog = true }, icon = Icons.Outlined.Layers, contentDescription = Res.string.map_style_selection, ) @@ -800,6 +721,44 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: } } + if (showMapStyleDialog) { + MapStyleDialog( + selectedMapStyle = mapViewModel.mapStyleId, + onDismiss = { showMapStyleDialog = false }, + onSelectMapStyle = { + mapViewModel.mapStyleId = it + map.setTileSource(loadOnlineTileSourceBase()) + }, + ) + } + + if (showCacheManagerDialog) { + CacheManagerDialog( + onClickOption = { option -> + when (option) { + CacheManagerOption.CurrentCacheSize -> { + scope.launch { context.showToast(Res.string.calculating) } + showCurrentCacheInfo = true + } + CacheManagerOption.DownloadRegion -> map.generateBoxOverlay() + + CacheManagerOption.ClearTiles -> showPurgeTileSourceDialog = true + CacheManagerOption.Cancel -> Unit + } + showCacheManagerDialog = false + }, + onDismiss = { showCacheManagerDialog = false }, + ) + } + + if (showCurrentCacheInfo) { + CacheInfoDialog(mapView = map, onDismiss = { showCurrentCacheInfo = false }) + } + + if (showPurgeTileSourceDialog) { + PurgeTileSourceDialog(onDismiss = { showPurgeTileSourceDialog = false }) + } + if (showEditWaypointDialog != null) { EditWaypointDialog( waypoint = showEditWaypointDialog ?: return, // Safe call @@ -828,3 +787,159 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: ) } } + +@Composable +private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) { + val selected = remember { mutableStateOf(selectedMapStyle) } + + MapsDialog(onDismiss = onDismiss) { + CustomTileSource.mTileSources.values.forEachIndexed { index, style -> + ListItem( + text = style, + trailingIcon = if (index == selected.value) Icons.Rounded.Check else null, + onClick = { + selected.value = index + onSelectMapStyle(index) + onDismiss() + }, + ) + } + } +} + +private enum class CacheManagerOption(val label: StringResource) { + CurrentCacheSize(label = Res.string.map_cache_size), + DownloadRegion(label = Res.string.map_download_region), + ClearTiles(label = Res.string.map_clear_tiles), + Cancel(label = Res.string.cancel), +} + +@Composable +private fun CacheManagerDialog(onClickOption: (CacheManagerOption) -> Unit, onDismiss: () -> Unit) { + MapsDialog(title = stringResource(Res.string.map_offline_manager), onDismiss = onDismiss) { + CacheManagerOption.entries.forEach { option -> + ListItem(text = stringResource(option.label), trailingIcon = null) { + onClickOption(option) + onDismiss() + } + } + } +} + +@Composable +private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) { + val (cacheCapacity, currentCacheUsage) = + remember(mapView) { + val cacheManager = CacheManager(mapView) + cacheManager.cacheCapacity() to cacheManager.currentCacheUsage() + } + + MapsDialog( + title = stringResource(Res.string.map_cache_manager), + onDismiss = onDismiss, + negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } }, + ) { + Text( + modifier = Modifier.padding(16.dp), + text = + stringResource( + Res.string.map_cache_info, + cacheCapacity / (1024.0 * 1024.0), + currentCacheUsage / (1024.0 * 1024.0), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun PurgeTileSourceDialog(onDismiss: () -> Unit) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val cache = SqlTileWriterExt() + + val sourceList by derivedStateOf { cache.sources.map { it.source as String } } + + val selected = remember { mutableStateListOf() } + + MapsDialog( + title = stringResource(Res.string.map_tile_source), + positiveButton = { + TextButton( + enabled = selected.isNotEmpty(), + onClick = { + selected.forEach { selectedIndex -> + val source = sourceList[selectedIndex] + scope.launch { + context.showToast( + if (cache.purgeCache(source)) { + getString(Res.string.map_purge_success, source) + } else { + getString(Res.string.map_purge_fail) + }, + ) + } + } + + onDismiss() + }, + ) { + Text(text = stringResource(Res.string.clear)) + } + }, + negativeButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.cancel)) } }, + onDismiss = onDismiss, + ) { + sourceList.forEachIndexed { index, source -> + val isSelected = selected.contains(index) + BasicListItem( + text = source, + trailingContent = { Checkbox(checked = isSelected, onCheckedChange = {}) }, + onClick = { + if (isSelected) { + selected.remove(index) + } else { + selected.add(index) + } + }, + ) {} + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MapsDialog( + title: String? = null, + onDismiss: () -> Unit, + positiveButton: (@Composable () -> Unit)? = null, + negativeButton: (@Composable () -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.wrapContentWidth().wrapContentHeight(), + shape = MaterialTheme.shapes.large, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column { + title?.let { + Text( + modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp), + text = it, + style = MaterialTheme.typography.titleLarge, + ) + } + + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { content() } + if (positiveButton != null || negativeButton != null) { + Row(Modifier.align(Alignment.End)) { + positiveButton?.invoke() + negativeButton?.invoke() + } + } + } + } + } +} diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 174e3f6bc..eedd39bfb 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -29,7 +29,10 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity.RESULT_OK import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -42,7 +45,13 @@ import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material.icons.rounded.Memory import androidx.compose.material.icons.rounded.Output import androidx.compose.material.icons.rounded.WavingHand +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -53,16 +62,15 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState -import com.meshtastic.core.strings.getString import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.gpsDisabled import org.meshtastic.core.database.DatabaseConstants @@ -95,7 +103,6 @@ import org.meshtastic.core.strings.theme_system import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.component.MultipleChoiceAlertDialog import org.meshtastic.core.ui.component.SwitchListItem import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.core.ui.theme.MODE_DYNAMIC @@ -106,7 +113,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.radio.component.EditDeviceProfileDialog import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog import org.meshtastic.feature.settings.util.LanguageUtils -import org.meshtastic.feature.settings.util.LanguageUtils.getLanguageMap +import org.meshtastic.feature.settings.util.LanguageUtils.languageMap import org.meshtastic.proto.ClientOnlyProtos.DeviceProfile import java.text.SimpleDateFormat import java.util.Date @@ -260,7 +267,6 @@ fun SettingsScreen( onNavigate = onNavigate, ) - val scope = rememberCoroutineScope() val context = LocalContext.current TitledCard(title = stringResource(Res.string.app_settings), modifier = Modifier.padding(top = 16.dp)) { @@ -470,39 +476,54 @@ private fun AppVersionButton( @Composable private fun LanguagePickerDialog(onDismiss: () -> Unit) { - val context = LocalContext.current - val choices = remember { - context - .getLanguageMap() - .map { (languageTag, languageName) -> languageName to { LanguageUtils.setAppLocale(languageTag) } } - .toMap() + SettingsDialog(title = stringResource(Res.string.preferences_language), onDismiss = onDismiss) { + languageMap().forEach { (languageTag, languageName) -> + ListItem(text = languageName, trailingIcon = null) { + LanguageUtils.setAppLocale(languageTag) + onDismiss() + } + } } +} - MultipleChoiceAlertDialog( - title = stringResource(Res.string.preferences_language), - message = "", - choices = choices, - onDismissRequest = onDismiss, - ) +private enum class ThemeOption(val label: StringResource, val mode: Int) { + DYNAMIC(label = Res.string.dynamic, mode = MODE_DYNAMIC), + LIGHT(label = Res.string.theme_light, mode = AppCompatDelegate.MODE_NIGHT_NO), + DARK(label = Res.string.theme_dark, mode = AppCompatDelegate.MODE_NIGHT_YES), + SYSTEM(label = Res.string.theme_system, mode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM), } @Composable private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) { - val resources = LocalResources.current - val themeMap = remember { - mapOf( - resources.getString(Res.string.dynamic) to MODE_DYNAMIC, - resources.getString(Res.string.theme_light) to AppCompatDelegate.MODE_NIGHT_NO, - resources.getString(Res.string.theme_dark) to AppCompatDelegate.MODE_NIGHT_YES, - resources.getString(Res.string.theme_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, - ) - .mapValues { (_, value) -> { onClickTheme(value) } } + SettingsDialog(title = stringResource(Res.string.choose_theme), onDismiss = onDismiss) { + ThemeOption.entries.forEach { option -> + ListItem(text = stringResource(option.label), trailingIcon = null) { + onClickTheme(option.mode) + onDismiss() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingsDialog(title: String, onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.wrapContentWidth().wrapContentHeight(), + shape = MaterialTheme.shapes.large, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column { + Text( + modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp), + text = title, + style = MaterialTheme.typography.titleLarge, + ) + + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { content() } + } + } } - - MultipleChoiceAlertDialog( - title = stringResource(Res.string.choose_theme), - message = "", - choices = themeMap, - onDismissRequest = onDismiss, - ) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/util/LanguageUtils.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/util/LanguageUtils.kt index 812254f97..c8822ea88 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/util/LanguageUtils.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/util/LanguageUtils.kt @@ -17,10 +17,12 @@ package org.meshtastic.feature.settings.util -import android.content.Context import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalResources import androidx.core.os.LocaleListCompat -import com.meshtastic.core.strings.getString +import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.fr_HT import org.meshtastic.core.strings.preferences_system_default @@ -47,33 +49,38 @@ object LanguageUtils { /** Using locales_config.xml, maps language tags to their localized language names (e.g.: "en" -> "English") */ @Suppress("CyclomaticComplexMethod") - fun Context.getLanguageMap(): Map { - val languageTags = buildList { - add(SYSTEM_DEFAULT) + @Composable + fun languageMap(): Map { + val resources = LocalResources.current + val languageTags = + remember(resources) { + buildList { + add(SYSTEM_DEFAULT) - try { - resources.getXml(org.meshtastic.feature.settings.R.xml.locales_config).use { parser -> - while (parser.eventType != XmlPullParser.END_DOCUMENT) { - if (parser.eventType == XmlPullParser.START_TAG && parser.name == "locale") { - val languageTag = - parser.getAttributeValue("http://schemas.android.com/apk/res/android", "name") - languageTag?.let { add(it) } + try { + resources.getXml(org.meshtastic.feature.settings.R.xml.locales_config).use { parser -> + while (parser.eventType != XmlPullParser.END_DOCUMENT) { + if (parser.eventType == XmlPullParser.START_TAG && parser.name == "locale") { + val languageTag = + parser.getAttributeValue("http://schemas.android.com/apk/res/android", "name") + languageTag?.let { add(it) } + } + parser.next() + } } - parser.next() + } catch (e: Exception) { + Timber.e("Error parsing locale_config.xml: ${e.message}") } } - } catch (e: Exception) { - Timber.e("Error parsing locale_config.xml: ${e.message}") } - } return languageTags.associateWith { languageTag -> when (languageTag) { - SYSTEM_DEFAULT -> getString(Res.string.preferences_system_default) - "fr-HT" -> getString(Res.string.fr_HT) - "pt-BR" -> getString(Res.string.pt_BR) - "zh-CN" -> getString(Res.string.zh_CN) - "zh-TW" -> getString(Res.string.zh_TW) + SYSTEM_DEFAULT -> stringResource(Res.string.preferences_system_default) + "fr-HT" -> stringResource(Res.string.fr_HT) + "pt-BR" -> stringResource(Res.string.pt_BR) + "zh-CN" -> stringResource(Res.string.zh_CN) + "zh-TW" -> stringResource(Res.string.zh_TW) else -> { Locale.forLanguageTag(languageTag).let { locale -> locale.getDisplayLanguage(locale).replaceFirstChar { char ->