diff --git a/app/build.gradle b/app/build.gradle index 409280430..2eeec08df 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -27,11 +27,11 @@ android { storePassword keystoreProperties['storePassword'] } } - compileSdk 35 + compileSdk 36 defaultConfig { applicationId "com.geeksville.mesh" minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works) - targetSdk 34 + targetSdk 36 versionCode 30525 // format is Mmmss (where M is 1+the numeric major number versionName "2.5.25" testInstrumentationRunner "com.geeksville.mesh.TestRunner" @@ -103,6 +103,7 @@ android { } lint { abortOnError false + disable += "MissingTranslation" } sourceSets { // Adds exported schema location as test app assets. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 84634560c..3b87420d9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -135,10 +135,9 @@ @@ -151,6 +150,19 @@ + + + + + + + + + + + + + select all - selectedList.clear() - selectedList.addAll(contacts.map { it.contactKey }) - actionMode?.title = contacts.size.toString() - } - } - } - return true - } - - override fun onDestroyActionMode(mode: ActionMode) { - selectedList.clear() - actionMode = null - } - } -} - -@Composable -fun ContactListView( - contacts: List, - selectedList: List, - onClick: (Contact) -> Unit, - onLongClick: (Contact) -> Unit, -) { - val haptics = LocalHapticFeedback.current - LazyColumn( - modifier = Modifier - .fillMaxSize(), - contentPadding = PaddingValues(6.dp), - ) { - items(contacts, key = { it.contactKey }) { contact -> - val selected by remember { derivedStateOf { selectedList.contains(contact.contactKey) } } - - ContactItem( - contact = contact, - selected = selected, - onClick = { onClick(contact) }, - onLongClick = { - onLongClick(contact) - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - }, - ) - } - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/Debug.kt similarity index 81% rename from app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt rename to app/src/main/java/com/geeksville/mesh/ui/Debug.kt index 3d1666007..d0bfd28ad 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Debug.kt @@ -17,10 +17,6 @@ package com.geeksville.mesh.ui -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -47,8 +43,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -61,40 +55,18 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.fragment.app.Fragment import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.R import com.geeksville.mesh.model.DebugViewModel import com.geeksville.mesh.model.DebugViewModel.UiMeshLog -import com.geeksville.mesh.ui.components.BaseScaffold import com.geeksville.mesh.ui.theme.AppTheme -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class DebugFragment : Fragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - AppTheme { - DebugScreen { parentFragmentManager.popBackStack() } - } - } - } - } -} private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE) @Composable internal fun DebugScreen( viewModel: DebugViewModel = hiltViewModel(), - navigateUp: () -> Unit ) { val listState = rememberLazyListState() val logs by viewModel.meshLog.collectAsStateWithLifecycle() @@ -108,26 +80,16 @@ internal fun DebugScreen( } } - BaseScaffold( - title = stringResource(id = R.string.debug_panel), - navigateUp = navigateUp, - actions = { - Button(onClick = viewModel::deleteAllLogs) { - Text(text = stringResource(R.string.clear)) - } - } - ) { - SelectionContainer { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - ) { - items(logs, key = { it.uuid }) { log -> - DebugItem( - modifier = Modifier.animateItem(), - log = log, - ) - } + SelectionContainer { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + ) { + items(logs, key = { it.uuid }) { log -> + DebugItem( + modifier = Modifier.animateItem(), + log = log + ) } } } @@ -236,3 +198,16 @@ private fun DebugScreenPreview() { ) } } + +@Composable +fun DebugMenuActions( + viewModel: DebugViewModel = hiltViewModel(), + modifier: Modifier = Modifier, +) { + Button( + onClick = viewModel::deleteAllLogs, + modifier = modifier, + ) { + Text(text = stringResource(R.string.clear)) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/LinkedCoordinates.kt b/app/src/main/java/com/geeksville/mesh/ui/LinkedCoordinates.kt index d8d4311f8..216233c98 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/LinkedCoordinates.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/LinkedCoordinates.kt @@ -18,14 +18,17 @@ package com.geeksville.mesh.ui import android.content.ActivityNotFoundException +import android.content.ClipData import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ClipboardManager -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -39,6 +42,7 @@ import com.geeksville.mesh.android.BuildUtils.debug import com.geeksville.mesh.ui.theme.AppTheme import com.geeksville.mesh.ui.theme.HyperlinkBlue import com.geeksville.mesh.util.GPSFormat +import kotlinx.coroutines.launch import java.net.URLEncoder @OptIn(ExperimentalFoundationApi::class) @@ -77,7 +81,8 @@ fun LinkedCoordinates( } pop() } - val clipboardManager: ClipboardManager = LocalClipboardManager.current + val clipboard: Clipboard = LocalClipboard.current + val coroutineScope = rememberCoroutineScope() Text( modifier = modifier.combinedClickable( onClick = { @@ -94,8 +99,10 @@ fun LinkedCoordinates( } }, onLongClick = { - clipboardManager.setText(annotatedString) - debug("Copied to clipboard") + coroutineScope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("", annotatedString))) + debug("Copied to clipboard") + } } ), text = annotatedString diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt new file mode 100644 index 000000000..50bc6a779 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -0,0 +1,339 @@ +/* + * 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 com.geeksville.mesh.ui + +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material.BottomNavigation +import androidx.compose.material.BottomNavigationItem +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.SnackbarHost +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.twotone.Chat +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.twotone.CloudDone +import androidx.compose.material.icons.twotone.CloudOff +import androidx.compose.material.icons.twotone.CloudUpload +import androidx.compose.material.icons.twotone.Contactless +import androidx.compose.material.icons.twotone.Map +import androidx.compose.material.icons.twotone.People +import androidx.compose.material.icons.twotone.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.geeksville.mesh.R +import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.navigation.NavGraph +import com.geeksville.mesh.navigation.Route +import com.geeksville.mesh.navigation.showLongNameTitle +import com.geeksville.mesh.service.MeshService +import com.geeksville.mesh.ui.TopLevelDestination.Companion.isTopLevel +import com.geeksville.mesh.ui.components.ScannedQrCodeDialog + +enum class TopLevelDestination(val label: String, val icon: ImageVector, val route: Route) { + Contacts("Contacts", Icons.AutoMirrored.TwoTone.Chat, Route.Contacts), + Nodes("Nodes", Icons.TwoTone.People, Route.Nodes), + Map("Map", Icons.TwoTone.Map, Route.Map), + Channels("Channels", Icons.TwoTone.Contactless, Route.Channels), + Settings("Settings", Icons.TwoTone.Settings, Route.Settings), + ; + + companion object { + fun NavDestination.isTopLevel(): Boolean = entries.any { hasRoute(it.route::class) } + + fun fromNavDestination(destination: NavDestination?): TopLevelDestination? = entries + .find { dest -> destination?.hierarchy?.any { it.hasRoute(dest.route::class) } == true } + } +} + +@Composable +fun MainScreen( + viewModel: UIViewModel = hiltViewModel(), + onAction: (MainMenuAction) -> Unit +) { + val navController = rememberNavController() + val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val localConfig by viewModel.localConfig.collectAsStateWithLifecycle() + val requestChannelSet by viewModel.requestChannelSet.collectAsStateWithLifecycle() + + if (connectionState.isConnected()) { + requestChannelSet?.let { newChannelSet -> + ScannedQrCodeDialog(viewModel, newChannelSet) + } + } + val title by viewModel.title.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + MainAppBar( + title = title, + isManaged = localConfig.security.isManaged, + connectionState = connectionState, + navController = navController, + ) { action -> + when (action) { + MainMenuAction.DEBUG -> navController.navigate(Route.DebugPanel) + MainMenuAction.RADIO_CONFIG -> navController.navigate(Route.RadioConfig()) + MainMenuAction.QUICK_CHAT -> navController.navigate(Route.QuickChat) + else -> onAction(action) + } + } + }, + bottomBar = { + BottomNavigation( + navController = navController, + ) + }, + snackbarHost = { SnackbarHost(hostState = viewModel.snackbarState) } + ) { innerPadding -> + NavGraph( + modifier = Modifier.padding(innerPadding), + uIViewModel = viewModel, + navController = navController, + ) + } +} + +enum class MainMenuAction(@StringRes val stringRes: Int) { + DEBUG(R.string.debug_panel), + RADIO_CONFIG(R.string.device_settings), + EXPORT_MESSAGES(R.string.save_messages), + THEME(R.string.theme), + LANGUAGE(R.string.preferences_language), + SHOW_INTRO(R.string.intro_show), + QUICK_CHAT(R.string.quick_chat), + ABOUT(R.string.about), +} + +@Suppress("LongMethod") +@Composable +private fun MainAppBar( + title: String, + isManaged: Boolean, + connectionState: MeshService.ConnectionState, + navController: NavHostController, + modifier: Modifier = Modifier, + onAction: (MainMenuAction) -> Unit +) { + val backStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = backStackEntry?.destination + val canNavigateBack = navController.previousBackStackEntry != null + val isTopLevelRoute = currentDestination?.isTopLevel() == true + val navigateUp: () -> Unit = navController::navigateUp + TopAppBar( + title = { + when { + currentDestination == null || isTopLevelRoute -> { + Text( + text = stringResource(id = R.string.app_name), + ) + } + + currentDestination.hasRoute() -> + Text( + stringResource(id = R.string.debug_panel), + ) + + currentDestination.hasRoute() -> + Text( + stringResource(id = R.string.quick_chat), + ) + + currentDestination.hasRoute() -> + Text( + stringResource(id = R.string.share_to), + ) + + currentDestination.showLongNameTitle() -> { + Text( + title, + ) + } + } + }, + modifier = modifier, + navigationIcon = if (canNavigateBack && !isTopLevelRoute) { + { + IconButton(onClick = navigateUp) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.navigate_back), + ) + } + } + } else { + { + IconButton( + enabled = false, + onClick = { }, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.app_icon), + contentDescription = stringResource(id = R.string.application_icon), + ) + } + } + }, + actions = { + when { + currentDestination == null || isTopLevelRoute -> + MainMenuActions(isManaged, connectionState, onAction) + + currentDestination.hasRoute() -> + DebugMenuActions() + + else -> {} + } + }, + ) +} + +@Composable +private fun MainMenuActions( + isManaged: Boolean, + connectionState: MeshService.ConnectionState, + onAction: (MainMenuAction) -> Unit +) { + val context = LocalContext.current + val (image, tooltip) = when (connectionState) { + MeshService.ConnectionState.CONNECTED -> Icons.TwoTone.CloudDone to R.string.connected + MeshService.ConnectionState.DEVICE_SLEEP -> Icons.TwoTone.CloudUpload to R.string.device_sleeping + MeshService.ConnectionState.DISCONNECTED -> Icons.TwoTone.CloudOff to R.string.disconnected + } + + var showMenu by remember { mutableStateOf(false) } + IconButton( + onClick = { + Toast.makeText(context, tooltip, Toast.LENGTH_SHORT).show() + }, + ) { + Icon( + imageVector = image, + contentDescription = stringResource(id = tooltip), + ) + } + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Overflow menu", + ) + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + modifier = Modifier.background(MaterialTheme.colors.background.copy(alpha = 1f)), + ) { + MainMenuAction.entries.forEach { action -> + DropdownMenuItem( + onClick = { + onAction(action) + showMenu = false + }, + enabled = when (action) { + MainMenuAction.RADIO_CONFIG -> !isManaged + else -> true + }, + ) { Text(stringResource(id = action.stringRes)) } + } + } +} + +@Composable +private fun BottomNavigation( + navController: NavController, +) { + val currentDestination = navController.currentBackStackEntryAsState().value?.destination + val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination) + + AnimatedVisibility( + visible = topLevelDestination != null, + enter = slideInVertically( + initialOffsetY = { it / 2 }, + animationSpec = tween(durationMillis = 200), + ), + exit = slideOutVertically( + targetOffsetY = { it / 2 }, + animationSpec = tween(durationMillis = 200), + ), + ) { + BottomNavigation { + TopLevelDestination.entries.forEach { + val isSelected = it == topLevelDestination + BottomNavigationItem( + icon = { + Icon( + imageVector = it.icon, + contentDescription = it.name, + ) + }, + // label = { Text(it.label) }, + selected = isSelected, + onClick = { + if (!isSelected) { + navController.navigate(it.route) { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + // on the back stack as users select items + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } + } + } + ) + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt index 4f0942990..882a68282 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt @@ -95,6 +95,7 @@ import com.geeksville.mesh.model.DeviceHardware import com.geeksville.mesh.model.MetricsState import com.geeksville.mesh.model.MetricsViewModel import com.geeksville.mesh.model.Node +import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.navigation.Route import com.geeksville.mesh.ui.components.PreferenceCategory import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider @@ -125,7 +126,8 @@ private enum class LogsType( fun NodeDetailScreen( modifier: Modifier = Modifier, viewModel: MetricsViewModel = hiltViewModel(), - onNavigate: (Route) -> Unit, + uiViewModel: UIViewModel = hiltViewModel(), + onNavigate: (Route) -> Unit = {}, ) { val state by viewModel.state.collectAsStateWithLifecycle() val environmentState by viewModel.environmentState.collectAsStateWithLifecycle() @@ -139,11 +141,13 @@ fun NodeDetailScreen( environmentState.hasEnvironmentMetrics(), state.hasSignalMetrics(), state.hasPowerMetrics(), - state.hasTracerouteLogs()) + state.hasTracerouteLogs() + ) } if (state.node != null) { val node = state.node ?: return + uiViewModel.setTitle(node.user.longName) NodeDetailList( node = node, metricsState = state, diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt similarity index 67% rename from app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt rename to app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt index 150c8db1d..fada31f76 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt @@ -17,10 +17,6 @@ package com.geeksville.mesh.ui -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.compose.animation.animateContentSize import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -34,68 +30,21 @@ import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.unit.dp -import androidx.fragment.app.activityViewModels import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.DataPacket -import com.geeksville.mesh.android.Logging -import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.UIViewModel -import com.geeksville.mesh.navigation.navigateToNavGraph import com.geeksville.mesh.ui.components.NodeFilterTextField import com.geeksville.mesh.ui.components.NodeMenuAction import com.geeksville.mesh.ui.components.rememberTimeTickWithLifecycle -import com.geeksville.mesh.ui.message.navigateToMessages -import com.geeksville.mesh.ui.theme.AppTheme -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class UsersFragment : ScreenFragment("Users"), Logging { - - private val model: UIViewModel by activityViewModels() - - private fun navigateToMessages(node: Node) = node.user.let { user -> - val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC // TODO use meta.hasPKC - val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel - val contactKey = "$channel${user.id}" - info("calling MessagesFragment filter: $contactKey") - parentFragmentManager.navigateToMessages(contactKey) - } - - private fun navigateToNodeDetails(nodeNum: Int) { - info("calling NodeDetails --> destNum: $nodeNum") - parentFragmentManager.navigateToNavGraph(nodeNum, "NodeDetails") - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - AppTheme { - NodesScreen( - model = model, - navigateToMessages = ::navigateToMessages, - navigateToNodeDetails = ::navigateToNodeDetails, - ) - } - } - } - } -} @OptIn(ExperimentalFoundationApi::class) -@Composable @Suppress("LongMethod") -fun NodesScreen( +@Composable +fun NodeScreen( model: UIViewModel = hiltViewModel(), - navigateToMessages: (Node) -> Unit, + navigateToMessages: (String) -> Unit, navigateToNodeDetails: (Int) -> Unit, ) { val state by model.nodesUiState.collectAsStateWithLifecycle() @@ -142,7 +91,11 @@ fun NodesScreen( is NodeMenuAction.Remove -> model.removeNode(node.num) is NodeMenuAction.Ignore -> model.ignoreNode(node) is NodeMenuAction.Favorite -> model.favoriteNode(node) - is NodeMenuAction.DirectMessage -> navigateToMessages(node) + is NodeMenuAction.DirectMessage -> { + val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC + val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel + navigateToMessages("$channel${node.user.id}") + } is NodeMenuAction.RequestUserInfo -> model.requestUserInfo(node.num) is NodeMenuAction.RequestPosition -> model.requestPosition(node.num) is NodeMenuAction.TraceRoute -> model.requestTraceroute(node.num) diff --git a/app/src/main/java/com/geeksville/mesh/ui/QuickChatSettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/QuickChat.kt similarity index 90% rename from app/src/main/java/com/geeksville/mesh/ui/QuickChatSettingsFragment.kt rename to app/src/main/java/com/geeksville/mesh/ui/QuickChat.kt index 5e38905cf..6cb0f83c9 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/QuickChatSettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/QuickChat.kt @@ -17,10 +17,6 @@ package com.geeksville.mesh.ui -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -54,6 +50,8 @@ import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FastForward import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -66,10 +64,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusEvent -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -79,49 +74,16 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.R -import com.geeksville.mesh.android.Logging import com.geeksville.mesh.database.entity.QuickChatAction import com.geeksville.mesh.model.UIViewModel -import com.geeksville.mesh.ui.components.BaseScaffold import com.geeksville.mesh.ui.components.dragContainer import com.geeksville.mesh.ui.components.dragDropItemsIndexed import com.geeksville.mesh.ui.components.rememberDragDropState import com.geeksville.mesh.ui.theme.AppTheme -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class QuickChatSettingsFragment : ScreenFragment("Quick Chat Settings"), Logging { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - AppTheme { - QuickChatScreen { parentFragmentManager.popBackStack() } - } - } - } - } -} @Composable internal fun QuickChatScreen( - viewModel: UIViewModel = hiltViewModel(), - navigateUp: () -> Unit -) { - BaseScaffold( - title = stringResource(id = R.string.quick_chat), - navigateUp = navigateUp, - ) { - QuickChatContent(viewModel) - } -} - -@Composable -private fun QuickChatContent( + modifier: Modifier = Modifier, viewModel: UIViewModel = hiltViewModel(), ) { val actions by viewModel.quickChatActions.collectAsStateWithLifecycle() @@ -133,7 +95,7 @@ private fun QuickChatContent( viewModel.updateActionPositions(list) } - Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = modifier.fillMaxSize()) { if (showActionDialog != null) { val action = showActionDialog ?: return EditQuickChatDialog( @@ -399,12 +361,12 @@ private fun QuickChatItem( modifier = Modifier.size(48.dp) ) { Icon( - painter = painterResource(id = R.drawable.ic_baseline_edit_24), + imageVector = Icons.Default.Edit, contentDescription = stringResource(id = R.string.quick_chat_edit), ) } Icon( - painter = painterResource(id = R.drawable.ic_baseline_drag_handle_24), + imageVector = Icons.Default.DragHandle, contentDescription = stringResource(id = R.string.quick_chat), ) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/ScreenFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ScreenFragment.kt deleted file mode 100644 index 496075ac6..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/ScreenFragment.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 com.geeksville.mesh.ui - -import androidx.fragment.app.Fragment -import com.geeksville.mesh.android.GeeksvilleApplication - -/** - * A fragment that represents a current 'screen' in our app. - * - * Useful for tracking analytics - */ -open class ScreenFragment(private val screenName: String) : Fragment() { - - override fun onResume() { - super.onResume() - GeeksvilleApplication.analytics.sendScreenView(screenName) - } - - override fun onPause() { - GeeksvilleApplication.analytics.endScreenView() - super.onPause() - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/Settings.kt b/app/src/main/java/com/geeksville/mesh/ui/Settings.kt new file mode 100644 index 000000000..b87c238bb --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/Settings.kt @@ -0,0 +1,617 @@ +/* + * 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 com.geeksville.mesh.ui + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.net.InetAddresses +import android.os.Build +import android.util.Patterns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.Checkbox +import androidx.compose.material.FloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.RadioButton +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.hilt.navigation.compose.hiltViewModel +import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.R +import com.geeksville.mesh.android.BuildUtils.debug +import com.geeksville.mesh.android.BuildUtils.info +import com.geeksville.mesh.android.BuildUtils.reportError +import com.geeksville.mesh.android.BuildUtils.warn +import com.geeksville.mesh.android.GeeksvilleApplication +import com.geeksville.mesh.android.getBluetoothPermissions +import com.geeksville.mesh.android.getLocationPermissions +import com.geeksville.mesh.android.gpsDisabled +import com.geeksville.mesh.android.hasLocationPermission +import com.geeksville.mesh.android.isGooglePlayAvailable +import com.geeksville.mesh.android.permissionMissing +import com.geeksville.mesh.model.BTScanModel +import com.geeksville.mesh.model.BluetoothViewModel +import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.service.MeshService +import kotlinx.coroutines.delay + +fun String?.isIPAddress(): Boolean { + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + @Suppress("DEPRECATION") + this != null && Patterns.IP_ADDRESS.matcher(this).matches() + } else { + InetAddresses.isNumericAddress(this.toString()) + } +} + +@Suppress("CyclomaticComplexMethod", "LongMethod") +@Composable +fun SettingsScreen( + uiViewModel: UIViewModel = hiltViewModel(), + scanModel: BTScanModel = hiltViewModel(), + bluetoothViewModel: BluetoothViewModel = hiltViewModel(), + onSetRegion: () -> Unit, +) { + val currentRegion = uiViewModel.region + val regionUnset = currentRegion == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET + val scrollState = rememberScrollState() + val scanStatusText by scanModel.errorText.observeAsState("") + val connectionState by uiViewModel.connectionState.collectAsState(MeshService.ConnectionState.DISCONNECTED) + val devices by scanModel.devices.observeAsState(emptyMap()) + val scanning by scanModel.spinner.observeAsState(false) + val receivingLocationUpdates by uiViewModel.receivingLocationUpdates.collectAsState(false) + val context = LocalContext.current + val app = (context.applicationContext as GeeksvilleApplication) + val isGooglePlayAvailable = context.isGooglePlayAvailable() + val info by uiViewModel.myNodeInfo.collectAsState() + + val isAnalyticsAllowed = app.isAnalyticsAllowed + val selectedDevice = scanModel.selectedNotNull + val bluetoothEnabled by bluetoothViewModel.enabled.observeAsState() + + val isGpsDisabled = context.gpsDisabled() + LaunchedEffect(isGpsDisabled) { + if (isGpsDisabled) { + uiViewModel.showSnackbar(context.getString(R.string.location_disabled)) + } + } + LaunchedEffect(bluetoothEnabled) { + if (bluetoothEnabled == false) { + uiViewModel.showSnackbar(context.getString(R.string.bluetooth_disabled)) + } + } + // when scanning is true - wait 10000ms and then stop scanning + LaunchedEffect(scanning) { + if (scanning) { + delay(SCAN_PERIOD) + scanModel.stopScan() + } + } + + // State for manual IP address input + var manualIpAddress by remember { mutableStateOf("") } + + // State for the device scan dialog + var showScanDialog by remember { mutableStateOf(false) } + val scanResults by scanModel.scanResult.observeAsState(emptyMap()) + + // State for the location permission rationale dialog + var showLocationRationaleDialog by remember { mutableStateOf(false) } + + // State for the Bluetooth permission rationale dialog + var showBluetoothRationaleDialog by remember { mutableStateOf(false) } + + // State for the Report Bug dialog + var showReportBugDialog by remember { mutableStateOf(false) } + + // Remember the permission launchers + val requestLocationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + onResult = { permissions -> + if (permissions.entries.all { it.value }) { + uiViewModel.provideLocation.value = true + uiViewModel.meshService?.startProvideLocation() + } else { + debug("User denied location permission") + uiViewModel.showSnackbar(context.getString(R.string.why_background_required)) + } + bluetoothViewModel.permissionsUpdated() + } + ) + + val requestBluetoothPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + onResult = { permissions -> + if (permissions.entries.all { it.value }) { + info("Bluetooth permissions granted") + // We need to call the scan function which is in the Fragment + // Since we can't directly call scanLeDevice() from Composable, + // we might need to rethink how scanning is triggered or + // pass the scan trigger as a lambda. + // For now, let's assume we trigger the scan outside the Composable + // after permissions are granted. We can add a callback to the ViewModel. + scanModel.startScan() + } else { + warn("Bluetooth permissions denied") + uiViewModel.showSnackbar(context.permissionMissing) + } + bluetoothViewModel.permissionsUpdated() + } + ) + + // Observe scan results to show the dialog + if (scanResults.isNotEmpty()) { + showScanDialog = true + } + + LaunchedEffect(connectionState, regionUnset) { + when (connectionState) { + MeshService.ConnectionState.CONNECTED -> + // Include region unset warning in status string if applicable + if (regionUnset) R.string.must_set_region else R.string.connected_to + + MeshService.ConnectionState.DISCONNECTED -> R.string.not_connected + MeshService.ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping + else -> null + }.let { + val firmwareString = + info?.firmwareString ?: context.getString(R.string.unknown) + if (it != null) { + scanModel.setErrorText(context.getString(it, firmwareString)) + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(scrollState) + ) { + // Scan Status Text + Text( + text = scanStatusText.orEmpty(), + fontSize = 14.sp, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Set Region Button + val isConnected = connectionState == MeshService.ConnectionState.CONNECTED + if (isConnected && regionUnset) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + onSetRegion() + } + ) { + Text(stringResource(R.string.set_region)) + } + Spacer(modifier = Modifier.height(16.dp)) + } + + // Device List and Manual Input + Text( + text = stringResource(R.string.device), + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(vertical = 8.dp) + ) + + // Progress bar while scanning + if (scanning) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth() + ) + } + + Column(modifier = Modifier.selectableGroup()) { + devices.values.forEach { device -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = (device.fullAddress == selectedDevice), + onClick = { + if (device.fullAddress == "n") { + uiViewModel.showSnackbar("Demo Mode enabled") + scanModel.showMockInterface() + } + if (!device.bonded) { + uiViewModel.showSnackbar(context.getString(R.string.starting_pairing)) + } + scanModel.onSelected(device) + } + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (device.fullAddress == selectedDevice), + onClick = { + if (device.fullAddress == "n") { + uiViewModel.showSnackbar("Demo Mode enabled") + scanModel.showMockInterface() + } + if (!device.bonded) { + uiViewModel.showSnackbar(context.getString(R.string.starting_pairing)) + } + scanModel.onSelected(device) + } + ) + Text( + text = device.name, + style = MaterialTheme.typography.body1, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + + // Manual IP Address Input + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = ("t$manualIpAddress" == selectedDevice), + onClick = { + if (manualIpAddress.isIPAddress()) { + scanModel.onSelected( + BTScanModel.DeviceListEntry( + "", + "t$manualIpAddress", + true + ) + ) + } else { + // Optionally show a warning for invalid IP + } + } + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = ("t$manualIpAddress" == selectedDevice), + onClick = { + if (manualIpAddress.isIPAddress()) { + scanModel.onSelected( + BTScanModel.DeviceListEntry( + "", + "t$manualIpAddress", + true + ) + ) + } else { + // Optionally show a warning for invalid IP + } + } + ) + OutlinedTextField( + value = manualIpAddress, + onValueChange = { manualIpAddress = it }, + label = { Text(stringResource(R.string.ip_address)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Provide Location Checkbox + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = !isGpsDisabled + ) { + val isChecked = !receivingLocationUpdates // Toggle the state + uiViewModel.provideLocation.value = isChecked + if (isChecked && !context.hasLocationPermission()) { + showLocationRationaleDialog = true // Show the Compose dialog + } + if (isChecked) { + uiViewModel.meshService?.startProvideLocation() + } else { + uiViewModel.meshService?.stopProvideLocation() + } + } + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = receivingLocationUpdates, + onCheckedChange = { isChecked -> + uiViewModel.provideLocation.value = isChecked + if (isChecked && !context.hasLocationPermission()) { + showLocationRationaleDialog = true + } + if (isChecked) { + uiViewModel.meshService?.startProvideLocation() + } else { + uiViewModel.meshService?.stopProvideLocation() + } + }, + enabled = !isGpsDisabled // Disable if GPS is disabled + ) + Text( + text = stringResource(R.string.provide_location_to_mesh), + style = MaterialTheme.typography.body1, + modifier = Modifier.padding(start = 16.dp) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Warning Not Paired + val showWarningNotPaired = !devices.any { it.value.bonded } + if (showWarningNotPaired) { + Text( + text = stringResource(R.string.warning_not_paired), + color = MaterialTheme.colors.error, + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + if (isAnalyticsAllowed) { + // Analytics Okay Checkbox + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = isGooglePlayAvailable, + ) { + val app = (context.applicationContext as GeeksvilleApplication) + app.isAnalyticsAllowed = !app.isAnalyticsAllowed // Toggle the MutableState + } + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = isAnalyticsAllowed, + onCheckedChange = { isChecked -> + debug("User changed analytics to $isChecked") + (context.applicationContext as GeeksvilleApplication).isAnalyticsAllowed = + isChecked + }, + enabled = isGooglePlayAvailable + ) + Text( + text = stringResource(R.string.analytics_okay), + style = MaterialTheme.typography.body1, + modifier = Modifier.padding(start = 16.dp) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Report Bug Button + Button( + onClick = { showReportBugDialog = true }, // Set state to show Report Bug dialog + enabled = isAnalyticsAllowed, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text(stringResource(R.string.report_bug)) + } + } + // Floating Action Button (Change Radio) + Box(modifier = Modifier.fillMaxSize()) { + FloatingActionButton( + onClick = { + val bluetoothPermissions = context.getBluetoothPermissions() + if (bluetoothPermissions.isEmpty()) { + // If no permissions needed, trigger the scan directly (or via ViewModel) + scanModel.startScan() + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + context.findActivity() + .shouldShowRequestPermissionRationale(bluetoothPermissions.first()) + ) { + showBluetoothRationaleDialog = true + } else { + requestBluetoothPermissionLauncher.launch(bluetoothPermissions) + } + } + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + ) { + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.change_radio)) + } + } + } + + // Compose Device Scan Dialog + if (showScanDialog) { + Dialog(onDismissRequest = { + showScanDialog = false + scanModel.clearScanResults() + }) { + Surface(shape = MaterialTheme.shapes.medium) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Select a Bluetooth device", + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(bottom = 16.dp) + ) + Column(modifier = Modifier.selectableGroup()) { + scanResults.values.forEach { device -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = false, // No pre-selection in this dialog + onClick = { + scanModel.onSelected(device) + scanModel.clearScanResults() + showScanDialog = false + } + ) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = device.name) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + TextButton(onClick = { + scanModel.clearScanResults() + showScanDialog = false + }) { + Text(stringResource(R.string.cancel)) + } + } + } + } + } + + // Compose Location Permission Rationale Dialog + if (showLocationRationaleDialog) { + AlertDialog( + onDismissRequest = { showLocationRationaleDialog = false }, + title = { Text(stringResource(R.string.background_required)) }, + text = { Text(stringResource(R.string.why_background_required)) }, + confirmButton = { + Button(onClick = { + showLocationRationaleDialog = false + if (!context.hasLocationPermission()) { + requestLocationPermissionLauncher.launch(context.getLocationPermissions()) + } + }) { + Text(stringResource(R.string.accept)) + } + }, + dismissButton = { + Button(onClick = { showLocationRationaleDialog = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + // Compose Bluetooth Permission Rationale Dialog + if (showBluetoothRationaleDialog) { + val bluetoothPermissions = context.getBluetoothPermissions() + AlertDialog( + onDismissRequest = { showBluetoothRationaleDialog = false }, + title = { Text(stringResource(R.string.required_permissions)) }, + text = { Text(stringResource(R.string.permission_missing_31)) }, + confirmButton = { + Button(onClick = { + showBluetoothRationaleDialog = false + if (bluetoothPermissions.isNotEmpty()) { + requestBluetoothPermissionLauncher.launch(bluetoothPermissions) + } else { + // If somehow no permissions are required, just scan + scanModel.startScan() + } + }) { + Text(stringResource(R.string.okay)) + } + }, + dismissButton = { + Button(onClick = { showBluetoothRationaleDialog = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + // Compose Report Bug Dialog + if (showReportBugDialog) { + AlertDialog( + onDismissRequest = { showReportBugDialog = false }, + title = { Text(stringResource(R.string.report_a_bug)) }, + text = { Text(stringResource(R.string.report_bug_text)) }, + confirmButton = { + Button(onClick = { + showReportBugDialog = false + reportError("Clicked Report A Bug") + uiViewModel.showSnackbar("Bug report sent!") + }) { + Text(stringResource(R.string.report)) + } + }, + dismissButton = { + Button(onClick = { + showReportBugDialog = false + debug("Decided not to report a bug") + }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } +} + +private tailrec fun Context.findActivity(): Activity = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> error("No activity found") +} + +private const val SCAN_PERIOD: Long = 10000 // 10 seconds diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt deleted file mode 100644 index 4edcb3c2a..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ /dev/null @@ -1,519 +0,0 @@ -/* - * 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 com.geeksville.mesh.ui - -import android.net.InetAddresses -import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.text.Editable -import android.util.Patterns -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.RadioButton -import android.widget.TextView -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import androidx.core.widget.doAfterTextChanged -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.asLiveData -import com.geeksville.mesh.ConfigProtos -import com.geeksville.mesh.R -import com.geeksville.mesh.android.* -import com.geeksville.mesh.databinding.SettingsFragmentBinding -import com.geeksville.mesh.model.BTScanModel -import com.geeksville.mesh.model.BluetoothViewModel -import com.geeksville.mesh.model.RegionInfo -import com.geeksville.mesh.model.UIViewModel -import com.geeksville.mesh.repository.location.LocationRepository -import com.geeksville.mesh.service.MeshService -import com.geeksville.mesh.util.exceptionToSnackbar -import com.geeksville.mesh.util.onEditorAction -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint -class SettingsFragment : ScreenFragment("Settings"), Logging { - private var _binding: SettingsFragmentBinding? = null - - // This property is only valid between onCreateView and onDestroyView. - private val binding get() = _binding!! - - private val scanModel: BTScanModel by activityViewModels() - private val bluetoothViewModel: BluetoothViewModel by activityViewModels() - private val model: UIViewModel by activityViewModels() - - @Inject - internal lateinit var locationRepository: LocationRepository - - private val hasGps by lazy { requireContext().hasGps() } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = SettingsFragmentBinding.inflate(inflater, container, false) - return binding.root - } - - /** - * Pull the latest device info from the model and into the GUI - */ - private fun updateNodeInfo() { - val connectionState = model.connectionState.value - val isConnected = connectionState == MeshService.ConnectionState.CONNECTED - - binding.nodeSettings.visibility = if (isConnected) View.VISIBLE else View.GONE - binding.provideLocationCheckbox.visibility = if (isConnected) View.VISIBLE else View.GONE - - binding.usernameEditText.isEnabled = isConnected && !model.isManaged - - if (hasGps) { - binding.provideLocationCheckbox.isEnabled = true - } else { - binding.provideLocationCheckbox.isChecked = false - binding.provideLocationCheckbox.isEnabled = false - } - - // update the region selection from the device - val region = model.region - val spinner = binding.regionSpinner - spinner.onItemSelectedListener = null - - debug("current region is $region") - var regionIndex = regions.indexOfFirst { it.regionCode == region } - if (regionIndex == -1) { // Not found, probably because the device has a region our app doesn't yet understand. Punt and say Unset - regionIndex = ConfigProtos.Config.LoRaConfig.RegionCode.UNSET_VALUE - } - - // We don't want to be notified of our own changes, so turn off listener while making them - spinner.setSelection(regionIndex, false) - spinner.onItemSelectedListener = regionSpinnerListener - spinner.isEnabled = !model.isManaged - - // Update the status string (highest priority messages first) - val regionUnset = region == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET - val info = model.myNodeInfo.value - when (connectionState) { - MeshService.ConnectionState.CONNECTED -> - if (regionUnset) R.string.must_set_region else R.string.connected_to - MeshService.ConnectionState.DISCONNECTED -> R.string.not_connected - MeshService.ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping - else -> null - }?.let { - val firmwareString = info?.firmwareString ?: getString(R.string.unknown) - scanModel.setErrorText(getString(it, firmwareString)) - } - } - - private val regionSpinnerListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected( - parent: AdapterView<*>, - view: View, - position: Int, - id: Long - ) { - val item = RegionInfo.entries[position] - val asProto = item.regionCode - exceptionToSnackbar(requireView()) { - debug("regionSpinner onItemSelected $asProto") - if (asProto != model.region) model.region = asProto - } - updateNodeInfo() // We might have just changed Unset to set - } - - override fun onNothingSelected(parent: AdapterView<*>) { - // TODO("Not yet implemented") - } - } - - private val regions = RegionInfo.entries - - private fun initCommonUI() { - - val requestLocationPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> - if (permissions.entries.all { it.value }) { - model.provideLocation.value = true - model.meshService?.startProvideLocation() - } else { - debug("User denied location permission") - model.showSnackbar(getString(R.string.why_background_required)) - } - bluetoothViewModel.permissionsUpdated() - } - - // init our region spinner - val spinner = binding.regionSpinner - val regionAdapter = object : ArrayAdapter( - requireContext(), - android.R.layout.simple_spinner_item, - regions - ) { - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = super.getView(position, convertView, parent) - (view as? TextView)?.text = regions[position].name - return view - } - - override fun getDropDownView( - position: Int, - convertView: View?, - parent: ViewGroup - ): View { - val view = super.getDropDownView(position, convertView, parent) - (view as? TextView)?.text = regions[position].description - return view - } - } - regionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - spinner.adapter = regionAdapter - - model.ourNodeInfo.asLiveData().observe(viewLifecycleOwner) { node -> - binding.usernameEditText.setText(node?.user?.longName.orEmpty()) - } - - scanModel.devices.observe(viewLifecycleOwner) { devices -> - updateDevicesButtons(devices) - } - - // Only let user edit their name or set software update while connected to a radio - model.connectionState.asLiveData().observe(viewLifecycleOwner) { - updateNodeInfo() - } - - model.localConfig.asLiveData().observe(viewLifecycleOwner) { - if (model.isConnected()) updateNodeInfo() - } - - // Also watch myNodeInfo because it might change later - model.myNodeInfo.asLiveData().observe(viewLifecycleOwner) { - updateNodeInfo() - } - - scanModel.errorText.observe(viewLifecycleOwner) { errMsg -> - if (errMsg != null) { - binding.scanStatusText.text = errMsg - } - } - - var scanDialog: AlertDialog? = null - scanModel.scanResult.observe(viewLifecycleOwner) { results -> - val devices = results.values.ifEmpty { return@observe } - scanDialog?.dismiss() - scanDialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle("Select a Bluetooth device") - .setSingleChoiceItems( - devices.map { it.name }.toTypedArray(), - -1 - ) { dialog, position -> - val selectedDevice = devices.elementAt(position) - scanModel.onSelected(selectedDevice) - scanModel.clearScanResults() - dialog.dismiss() - scanDialog = null - } - .setPositiveButton(R.string.cancel) { dialog, _ -> - scanModel.clearScanResults() - dialog.dismiss() - scanDialog = null - } - .show() - } - - // show the spinner when [spinner] is true - scanModel.spinner.observe(viewLifecycleOwner) { show -> - binding.changeRadioButton.isEnabled = !show - binding.scanProgressBar.visibility = if (show) View.VISIBLE else View.GONE - } - - binding.usernameEditText.onEditorAction(EditorInfo.IME_ACTION_DONE) { - debug("received IME_ACTION_DONE") - val n = binding.usernameEditText.text.toString().trim() - if (n.isNotEmpty()) model.setOwner(n) - requireActivity().hideKeyboard() - } - - // Observe receivingLocationUpdates state and update provideLocationCheckbox - locationRepository.receivingLocationUpdates.asLiveData().observe(viewLifecycleOwner) { - binding.provideLocationCheckbox.isChecked = it - } - - binding.provideLocationCheckbox.setOnCheckedChangeListener { view, isChecked -> - // Don't check the box until the system setting changes - view.isChecked = isChecked && requireContext().hasLocationPermission() - - if (view.isPressed) { // We want to ignore changes caused by code (as opposed to the user) - debug("User changed location tracking to $isChecked") - model.provideLocation.value = isChecked - if (isChecked && !view.isChecked) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.background_required) - .setMessage(R.string.why_background_required) - .setNeutralButton(R.string.cancel) { _, _ -> - debug("User denied background permission") - } - .setPositiveButton(getString(R.string.accept)) { _, _ -> - // Make sure we have location permission (prerequisite) - if (!requireContext().hasLocationPermission()) { - requestLocationPermissionLauncher.launch(requireContext().getLocationPermissions()) - } - } - .show() - } - } - if (view.isChecked) { - checkLocationEnabled(getString(R.string.location_disabled)) - model.meshService?.startProvideLocation() - } else { - model.meshService?.stopProvideLocation() - } - } - - val app = (requireContext().applicationContext as GeeksvilleApplication) - val isGooglePlayAvailable = requireContext().isGooglePlayAvailable() - val isAnalyticsAllowed = app.isAnalyticsAllowed && isGooglePlayAvailable - - // Set analytics checkbox - binding.analyticsOkayCheckbox.isEnabled = isGooglePlayAvailable - binding.analyticsOkayCheckbox.isChecked = isAnalyticsAllowed - - binding.analyticsOkayCheckbox.setOnCheckedChangeListener { _, isChecked -> - debug("User changed analytics to $isChecked") - app.isAnalyticsAllowed = isChecked - binding.reportBugButton.isEnabled = isAnalyticsAllowed - } - - // report bug button only enabled if analytics is allowed - binding.reportBugButton.isEnabled = isAnalyticsAllowed - binding.reportBugButton.setOnClickListener(::showReportBugDialog) - } - - @Suppress("UNUSED_PARAMETER") - private fun showReportBugDialog(view: View) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.report_a_bug) - .setMessage(getString(R.string.report_bug_text)) - .setNeutralButton(R.string.cancel) { _, _ -> - debug("Decided not to report a bug") - } - .setPositiveButton(getString(R.string.report)) { _, _ -> - reportError("Clicked Report A Bug") - model.showSnackbar("Bug report sent!") - } - .show() - } - - private var tapCount = 0 - private var lastTapTime: Long = 0 - - private fun addDeviceButton(device: BTScanModel.DeviceListEntry, enabled: Boolean) { - val b = RadioButton(requireActivity()) - b.text = device.name - b.id = View.generateViewId() - b.isEnabled = enabled - b.isChecked = device.fullAddress == scanModel.selectedNotNull - binding.deviceRadioGroup.addView(b) - - b.setOnClickListener { - if (device.fullAddress == "n") { - val currentTapTime = System.currentTimeMillis() - if (currentTapTime - lastTapTime > TAP_THRESHOLD) { - tapCount = 0 - } - lastTapTime = currentTapTime - tapCount++ - - if (tapCount >= TAP_TRIGGER) { - model.showSnackbar("Demo Mode enabled") - scanModel.showMockInterface() - } - } - if (!device.bonded) { // If user just clicked on us, try to bond - binding.scanStatusText.setText(R.string.starting_pairing) - } - b.isChecked = scanModel.onSelected(device) - } - } - - private fun addManualDeviceButton() { - val deviceSelectIPAddress = binding.radioButtonManual - val inputIPAddress = binding.editManualAddress - - deviceSelectIPAddress.isEnabled = inputIPAddress.text.isIPAddress() - deviceSelectIPAddress.setOnClickListener { - deviceSelectIPAddress.isChecked = scanModel.onSelected(BTScanModel.DeviceListEntry("", "t" + inputIPAddress.text, true)) - } - - binding.deviceRadioGroup.addView(deviceSelectIPAddress) - binding.deviceRadioGroup.addView(inputIPAddress) - - inputIPAddress.doAfterTextChanged { - deviceSelectIPAddress.isEnabled = inputIPAddress.text.isIPAddress() - } - } - - private fun updateDevicesButtons(devices: MutableMap?) { - // Remove the old radio buttons and repopulate - binding.deviceRadioGroup.removeAllViews() - - if (devices == null) return - - var hasShownOurDevice = false - devices.values.forEach { device -> - if (device.fullAddress == scanModel.selectedNotNull) { - hasShownOurDevice = true - } - addDeviceButton(device, true) - } - - // The selected device is not in the scan; it is either offline, or it doesn't advertise - // itself (most BLE devices don't advertise when connected). - // Show it in the list, greyed out based on connection status. - if (!hasShownOurDevice) { - // Note: we pull this into a tempvar, because otherwise some other thread can change selectedAddress after our null check - // and before use - val curAddr = scanModel.selectedAddress - if (curAddr != null) { - val curDevice = BTScanModel.DeviceListEntry(curAddr.substring(1), curAddr, false) - addDeviceButton(curDevice, model.isConnected()) - } - } - - addManualDeviceButton() - - // get rid of the warning text once at least one device is paired. - // If we are running on an emulator, always leave this message showing so we can test the worst case layout - val curRadio = scanModel.selectedAddress - - if (curRadio != null && curRadio != "m") { - binding.warningNotPaired.visibility = View.GONE - } else if (bluetoothViewModel.enabled.value == true) { - binding.warningNotPaired.visibility = View.VISIBLE - scanModel.setErrorText(getString(R.string.not_paired_yet)) - } - } - - // per https://developer.android.com/guide/topics/connectivity/bluetooth/find-ble-devices - private var scanning = false - private fun scanLeDevice() { - if (!checkBTEnabled()) return - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) checkLocationEnabled() - - if (!scanning) { // Stops scanning after a pre-defined scan period. - Handler(Looper.getMainLooper()).postDelayed({ - scanning = false - scanModel.stopScan() - }, SCAN_PERIOD) - scanning = true - scanModel.startScan() - } else { - scanning = false - scanModel.stopScan() - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - initCommonUI() - - val requestPermissionAndScanLauncher = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> - if (permissions.entries.all { it.value }) { - info("Bluetooth permissions granted") - scanLeDevice() - } else { - warn("Bluetooth permissions denied") - model.showSnackbar(requireContext().permissionMissing) - } - bluetoothViewModel.permissionsUpdated() - } - - binding.changeRadioButton.setOnClickListener { - debug("User clicked changeRadioButton") - val bluetoothPermissions = requireContext().getBluetoothPermissions() - if (bluetoothPermissions.isEmpty()) { - scanLeDevice() - } else { - requireContext().rationaleDialog( - shouldShowRequestPermissionRationale(bluetoothPermissions) - ) { - requestPermissionAndScanLauncher.launch(bluetoothPermissions) - } - } - } - } - - // If the user has not turned on location access throw up a warning - private fun checkLocationEnabled( - // Default warning valid only for classic bluetooth scan - warningReason: String = getString(R.string.location_disabled_warning) - ) { - if (requireContext().gpsDisabled()) { - warn("Telling user we need location access") - model.showSnackbar(warningReason) - } - } - - private fun checkBTEnabled(): Boolean = (bluetoothViewModel.enabled.value == true).also { enabled -> - if (!enabled) { - warn("Telling user bluetooth is disabled") - model.showSnackbar(R.string.bluetooth_disabled) - } - } - - override fun onResume() { - super.onResume() - - // Warn user if BLE device is selected but BLE disabled - if (scanModel.selectedBluetooth) checkBTEnabled() - - // Warn user if provide location is selected but location disabled - if (binding.provideLocationCheckbox.isChecked) { - checkLocationEnabled(getString(R.string.location_disabled)) - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - companion object { - const val SCAN_PERIOD: Long = 10000 // Stops scanning after 10 seconds - private const val TAP_TRIGGER: Int = 7 - private const val TAP_THRESHOLD: Long = 500 // max 500 ms between taps - } - - private fun Editable.isIPAddress(): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - InetAddresses.isNumericAddress(this.toString()) - } else { - @Suppress("DEPRECATION") - Patterns.IP_ADDRESS.matcher(this).matches() - } - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/Share.kt similarity index 60% rename from app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt rename to app/src/main/java/com/geeksville/mesh/ui/Share.kt index 40340a631..99e9e8ee6 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Share.kt @@ -17,10 +17,6 @@ package com.geeksville.mesh.ui -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth @@ -34,97 +30,39 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp -import androidx.core.os.bundleOf -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.activityViewModels import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.R -import com.geeksville.mesh.android.Logging import com.geeksville.mesh.model.Contact import com.geeksville.mesh.model.UIViewModel -import com.geeksville.mesh.ui.components.BaseScaffold -import com.geeksville.mesh.ui.message.navigateToMessages import com.geeksville.mesh.ui.theme.AppTheme -import dagger.hilt.android.AndroidEntryPoint - -internal fun FragmentManager.navigateToShareMessage(message: String) { - val shareFragment = ShareFragment().apply { - arguments = bundleOf("message" to message) - } - beginTransaction() - .add(R.id.mainActivityLayout, shareFragment) - .addToBackStack(null) - .commit() -} - -@AndroidEntryPoint -class ShareFragment : ScreenFragment("ShareFragment"), Logging { - private val model: UIViewModel by activityViewModels() - - private fun shareMessage(contactKey: String) { - debug("calling MessagesFragment filter:$contactKey") - parentFragmentManager.navigateToMessages( - contactKey, - arguments?.getString("message").toString() - ) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - AppTheme { - ShareScreen( - viewModel = model, - navigateUp = parentFragmentManager::popBackStack, - onConfirm = ::shareMessage - ) - } - } - } - } -} @Composable -internal fun ShareScreen( +fun ShareScreen( viewModel: UIViewModel = hiltViewModel(), - navigateUp: () -> Unit, onConfirm: (String) -> Unit ) { val contactList by viewModel.contactList.collectAsStateWithLifecycle() - BaseScaffold( - title = stringResource(R.string.share_to), - canNavigateBack = true, - navigateUp = navigateUp, - ) { - ShareContent( - contacts = contactList, - onConfirm = onConfirm, - ) - } + ShareScreen( + contacts = contactList, + onConfirm = onConfirm, + ) } @Composable -private fun ShareContent( +fun ShareScreen( contacts: List, - onConfirm: (String) -> Unit = {} + onConfirm: (String) -> Unit ) { - var selectedContact by rememberSaveable { mutableStateOf("") } + var selectedContact by remember { mutableStateOf("") } Column { LazyColumn( @@ -161,9 +99,9 @@ private fun ShareContent( @PreviewScreenSizes @Composable -private fun ShareContentPreview() { +private fun ShareScreenPreview() { AppTheme { - ShareContent( + ShareScreen( contacts = listOf( Contact( contactKey = "0^all", @@ -176,6 +114,7 @@ private fun ShareContentPreview() { isMuted = true, ), ), + onConfirm = {}, ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/BaseScaffold.kt b/app/src/main/java/com/geeksville/mesh/ui/components/BaseScaffold.kt deleted file mode 100644 index 8688943e8..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/components/BaseScaffold.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * 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 com.geeksville.mesh.ui.components - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.padding -import androidx.compose.material.FabPosition -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.contentColorFor -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.dropUnlessResumed -import com.geeksville.mesh.R - -@Composable -internal fun BaseScaffold( - title: String, - modifier: Modifier = Modifier, - canNavigateBack: Boolean = true, - navigateUp: (() -> Unit)? = null, - actions: @Composable (RowScope.() -> Unit)? = null, - floatingActionButton: @Composable () -> Unit = {}, - floatingActionButtonPosition: FabPosition = FabPosition.End, - content: @Composable () -> Unit, -) { - BaseScaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { Text(text = title) }, - navigationIcon = if (canNavigateBack) { - { - IconButton(onClick = dropUnlessResumed { navigateUp?.invoke() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(id = R.string.navigate_back), - modifier = Modifier - ) - } - } - } else { - null - }, - actions = { actions?.invoke(this) }, - ) - }, - floatingActionButton = floatingActionButton, - floatingActionButtonPosition = floatingActionButtonPosition, - content = content - ) -} - -@Composable -internal fun BaseScaffold( - modifier: Modifier = Modifier, - topBar: @Composable () -> Unit = {}, - bottomBar: @Composable () -> Unit = {}, - snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, - floatingActionButton: @Composable () -> Unit = {}, - floatingActionButtonPosition: FabPosition = FabPosition.End, - backgroundColor: Color = MaterialTheme.colors.background, - contentColor: Color = contentColorFor(backgroundColor), - content: @Composable () -> Unit, -) { - Scaffold( - modifier = modifier, - topBar = topBar, - bottomBar = bottomBar, - snackbarHost = snackbarHost, - floatingActionButton = floatingActionButton, - floatingActionButtonPosition = floatingActionButtonPosition, - backgroundColor = backgroundColor, - contentColor = contentColor, - ) { innerPadding -> - Box(modifier = Modifier.padding(innerPadding)) { - content() - } - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/CopyIconButton.kt b/app/src/main/java/com/geeksville/mesh/ui/components/CopyIconButton.kt index 05a9b0126..95986c90c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/CopyIconButton.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CopyIconButton.kt @@ -23,11 +23,13 @@ import androidx.compose.material.IconButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.twotone.ContentCopy import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ClipEntry -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource import com.geeksville.mesh.R +import kotlinx.coroutines.launch @Composable fun CopyIconButton( @@ -35,13 +37,16 @@ fun CopyIconButton( modifier: Modifier = Modifier, label: String = stringResource(id = R.string.copy), ) { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current + val coroutineScope = rememberCoroutineScope() IconButton( modifier = modifier, onClick = { - val clipData = ClipData.newPlainText(label, valueToCopy) - val clipEntry = ClipEntry(clipData) - clipboardManager.setClip(clipEntry) + coroutineScope.launch { + val clipData = ClipData.newPlainText(label, valueToCopy) + val clipEntry = ClipEntry(clipData) + clipboardManager.setClipEntry(clipEntry) + } } ) { Icon( diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/EditPasswordPreference.kt b/app/src/main/java/com/geeksville/mesh/ui/components/EditPasswordPreference.kt index eb30e3c7e..620ccd877 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/EditPasswordPreference.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/EditPasswordPreference.kt @@ -21,13 +21,14 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Icon import androidx.compose.material.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.VisibilityOff import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -66,8 +67,7 @@ fun EditPasswordPreference( trailingIcon = { IconButton(onClick = { isPasswordVisible = !isPasswordVisible }) { Icon( - painter = if (isPasswordVisible) painterResource(R.drawable.ic_twotone_visibility_off_24) - else painterResource(R.drawable.ic_twotone_visibility_24), + imageVector = if (isPasswordVisible) Icons.TwoTone.VisibilityOff else Icons.TwoTone.VisibilityOff, contentDescription = if (isPasswordVisible) { stringResource(R.string.hide_password) } else { diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/NodeFilterTextField.kt b/app/src/main/java/com/geeksville/mesh/ui/components/NodeFilterTextField.kt index 8586cd1cd..3f83b01b0 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/NodeFilterTextField.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/NodeFilterTextField.kt @@ -36,6 +36,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Search @@ -47,10 +48,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusEvent -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -161,7 +160,7 @@ private fun NodeSortButton( IconButton(onClick = { expanded = true }) { Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_twotone_sort_24), + imageVector = Icons.AutoMirrored.Filled.Sort, contentDescription = stringResource(R.string.node_sort_button), modifier = Modifier.heightIn(max = 48.dp), tint = MaterialTheme.colors.onSurface diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/ScannedQrCodeDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/ScannedQrCodeDialog.kt index cff710a74..060d845ef 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/ScannedQrCodeDialog.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/ScannedQrCodeDialog.kt @@ -49,13 +49,30 @@ import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.AppOnlyProtos.ChannelSet import com.geeksville.mesh.R import com.geeksville.mesh.channelSet import com.geeksville.mesh.copy import com.geeksville.mesh.model.Channel +import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.radioconfig.components.ChannelSelection +@Composable +fun ScannedQrCodeDialog( + viewModel: UIViewModel, + incoming: ChannelSet, +) { + val channels by viewModel.channels.collectAsStateWithLifecycle() + + ScannedQrCodeDialog( + channels = channels, + incoming = incoming, + onDismiss = viewModel::clearRequestChannelUrl, + onConfirm = viewModel::setChannels, + ) +} + /** * Enables the user to select which channels to accept after scanning a QR code. */ diff --git a/app/src/main/java/com/geeksville/mesh/ui/compose/SatelliteCountInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/compose/SatelliteCountInfo.kt index 95fab4dd9..79c0d5989 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/compose/SatelliteCountInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/compose/SatelliteCountInfo.kt @@ -23,14 +23,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.SatelliteAlt import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import com.geeksville.mesh.R import com.geeksville.mesh.ui.theme.AppTheme @Composable @@ -45,7 +44,7 @@ fun SatelliteCountInfo( ) { Icon( modifier = Modifier.size(18.dp), - imageVector = ImageVector.vectorResource(id = R.drawable.ic_satellite), + imageVector = Icons.TwoTone.SatelliteAlt, contentDescription = null, tint = MaterialTheme.colors.onSurface, ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/EditWaypointDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/map/EditWaypointDialog.kt index 2833f0d98..bbf44d43d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/EditWaypointDialog.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/EditWaypointDialog.kt @@ -39,6 +39,8 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Switch import androidx.compose.material.Text import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -47,7 +49,6 @@ 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -79,106 +80,109 @@ internal fun EditWaypointDialog( val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon var showEmojiPickerView by remember { mutableStateOf(false) } - if (!showEmojiPickerView) AlertDialog( - onDismissRequest = onDismissRequest, - shape = RoundedCornerShape(16.dp), - backgroundColor = MaterialTheme.colors.background, - text = { - Column(modifier = modifier.fillMaxWidth()) { - Text( - text = stringResource(title), - style = MaterialTheme.typography.h6.copy( - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - ), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - ) - EditTextPreference( - title = stringResource(R.string.name), - value = waypointInput.name, - maxSize = 29, // name max_size:30 - enabled = true, - isError = false, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Text, imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions(onDone = { /*TODO*/ }), - onValueChanged = { waypointInput = waypointInput.copy { name = it } }, - trailingIcon = { - IconButton(onClick = { showEmojiPickerView = true }) { - Text( - text = String(Character.toChars(emoji)), - modifier = Modifier - .background(MaterialTheme.colors.background, CircleShape) - .padding(4.dp), - fontSize = 24.sp, - color = Color.Unspecified.copy(alpha = 1f), - ) - } - }, - ) - EditTextPreference(title = stringResource(R.string.description), - value = waypointInput.description, - maxSize = 99, // description max_size:100 - enabled = true, - isError = false, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Text, imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions(onDone = { /*TODO*/ }), - onValueChanged = { waypointInput = waypointInput.copy { description = it } } - ) - Row( - modifier = Modifier - .fillMaxWidth() - .size(48.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Image( - painter = painterResource(R.drawable.ic_twotone_lock_24), - contentDescription = stringResource(R.string.locked), - ) - Text(stringResource(R.string.locked)) - Switch( + if (!showEmojiPickerView) { + AlertDialog( + onDismissRequest = onDismissRequest, + shape = RoundedCornerShape(16.dp), + backgroundColor = MaterialTheme.colors.background, + text = { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.h6.copy( + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ), modifier = Modifier .fillMaxWidth() - .wrapContentWidth(Alignment.End), - checked = waypointInput.lockedTo != 0, - onCheckedChange = { - waypointInput = - waypointInput.copy { lockedTo = if (it) 1 else 0 } - } + .padding(bottom = 16.dp), ) + EditTextPreference( + title = stringResource(R.string.name), + value = waypointInput.name, + maxSize = 29, // name max_size:30 + enabled = true, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { }), + onValueChanged = { waypointInput = waypointInput.copy { name = it } }, + trailingIcon = { + IconButton(onClick = { showEmojiPickerView = true }) { + Text( + text = String(Character.toChars(emoji)), + modifier = Modifier + .background(MaterialTheme.colors.background, CircleShape) + .padding(4.dp), + fontSize = 24.sp, + color = Color.Unspecified.copy(alpha = 1f), + ) + } + }, + ) + EditTextPreference( + title = stringResource(R.string.description), + value = waypointInput.description, + maxSize = 99, // description max_size:100 + enabled = true, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { }), + onValueChanged = { waypointInput = waypointInput.copy { description = it } } + ) + Row( + modifier = Modifier + .fillMaxWidth() + .size(48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + imageVector = Icons.Default.Lock, + contentDescription = stringResource(R.string.locked), + ) + Text(stringResource(R.string.locked)) + Switch( + modifier = Modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.End), + checked = waypointInput.lockedTo != 0, + onCheckedChange = { + waypointInput = + waypointInput.copy { lockedTo = if (it) 1 else 0 } + } + ) + } } - } - }, - buttons = { - FlowRow( - modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.Center, - ) { - TextButton( - modifier = modifier.weight(1f), - onClick = onDismissRequest - ) { Text(stringResource(R.string.cancel)) } - if (waypoint.id != 0) { + }, + buttons = { + FlowRow( + modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.Center, + ) { + TextButton( + modifier = modifier.weight(1f), + onClick = onDismissRequest + ) { Text(stringResource(R.string.cancel)) } + if (waypoint.id != 0) { + Button( + modifier = modifier.weight(1f), + onClick = { onDeleteClicked(waypointInput) }, + enabled = waypointInput.name.isNotEmpty(), + ) { Text(stringResource(R.string.delete)) } + } Button( modifier = modifier.weight(1f), - onClick = { onDeleteClicked(waypointInput) }, + onClick = { onSendClicked(waypointInput) }, enabled = waypointInput.name.isNotEmpty(), - ) { Text(stringResource(R.string.delete)) } + ) { Text(stringResource(R.string.send)) } } - Button( - modifier = modifier.weight(1f), - onClick = { onSendClicked(waypointInput) }, - enabled = waypointInput.name.isNotEmpty(), - ) { Text(stringResource(R.string.send)) } - } - }, - ) else { + }, + ) + } else { EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { showEmojiPickerView = false waypointInput = waypointInput.copy { icon = it.codePointAt(0) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapView.kt similarity index 94% rename from app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt rename to app/src/main/java/com/geeksville/mesh/ui/map/MapView.kt index ed92a8701..b062edf2c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapView.kt @@ -18,10 +18,6 @@ package com.geeksville.mesh.ui.map import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.content.res.AppCompatResources @@ -45,22 +41,18 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.fragment.app.activityViewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MeshProtos.Waypoint import com.geeksville.mesh.R import com.geeksville.mesh.android.BuildUtils.debug -import com.geeksville.mesh.android.Logging import com.geeksville.mesh.android.getLocationPermissions import com.geeksville.mesh.android.gpsDisabled import com.geeksville.mesh.android.hasGps @@ -72,8 +64,6 @@ import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.model.map.CustomTileSource import com.geeksville.mesh.model.map.MarkerWithLabel import com.geeksville.mesh.model.map.clustering.RadiusMarkerClusterer -import com.geeksville.mesh.ui.ScreenFragment -import com.geeksville.mesh.ui.theme.AppTheme import com.geeksville.mesh.util.SqlTileWriterExt import com.geeksville.mesh.util.addCopyright import com.geeksville.mesh.util.addScaleBarOverlay @@ -82,7 +72,6 @@ import com.geeksville.mesh.util.formatAgo import com.geeksville.mesh.util.zoomIn import com.geeksville.mesh.waypoint import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable import org.osmdroid.config.Configuration import org.osmdroid.events.MapEventsReceiver @@ -105,27 +94,6 @@ import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay import java.io.File import java.text.DateFormat -@AndroidEntryPoint -class MapFragment : ScreenFragment("Map Fragment"), Logging { - - private val model: UIViewModel by activityViewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - AppTheme { - MapView(model) - } - } - } - } -} - @Composable private fun MapView.UpdateMarkers( nodeMarkers: List, @@ -326,7 +294,8 @@ fun MapView( ).apply { id = u.id title = u.longName - snippet = context.getString(R.string.map_node_popup_details, + snippet = context.getString( + R.string.map_node_popup_details, node.gpsString(gpsFormat), formatAgo(node.lastHeard), formatAgo(p.time), @@ -373,7 +342,10 @@ fun MapView( androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL, androidx.appcompat.app.AlertDialog.BUTTON_NEGATIVE, androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE - )) with(dialog.getButton(button)) { textSize = 12F; isAllCaps = false } + )) with(dialog.getButton(button)) { + textSize = 12F + isAllCaps = false + } } fun showMarkerLongPressDialog(id: Int) { @@ -449,10 +421,12 @@ fun MapView( performHapticFeedback() val enabled = model.isConnected() && downloadRegionBoundingBox == null - if (enabled) showEditWaypointDialog = waypoint { + if (enabled) { + showEditWaypointDialog = waypoint { latitudeI = (p.latitude * 1e7).toInt() longitudeI = (p.longitude * 1e7).toInt() } + } return true } } @@ -611,7 +585,8 @@ fun MapView( modifier = Modifier.fillMaxSize(), update = { map -> map.drawOverlays() }, ) - if (downloadRegionBoundingBox != null) CacheLayout( + if (downloadRegionBoundingBox != null) { + CacheLayout( cacheEstimate = cacheEstimate, onExecuteJob = { startDownload() }, onCancelDownload = { @@ -620,7 +595,8 @@ fun MapView( map.invalidate() }, modifier = Modifier.align(Alignment.BottomCenter) - ) else { + ) + } else { Column( modifier = Modifier .padding(top = 16.dp, end = 16.dp) @@ -658,11 +634,13 @@ fun MapView( onSendClicked = { waypoint -> debug("User clicked send waypoint ${waypoint.id}") showEditWaypointDialog = null - model.sendWaypoint(waypoint.copy { + model.sendWaypoint( + waypoint.copy { if (id == 0) id = model.generatePacketId() ?: return@EditWaypointDialog expire = Int.MAX_VALUE // TODO add expire picker lockedTo = if (waypoint.lockedTo != 0) model.myNodeNum ?: 0 else 0 - }) + } + ) }, onDeleteClicked = { waypoint -> debug("User clicked delete waypoint ${waypoint.id}") diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt index 271ba71f9..897a0b7e7 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt @@ -17,21 +17,15 @@ package com.geeksville.mesh.ui.message -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import android.content.ClipData import androidx.compose.foundation.background 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.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.AlertDialog @@ -40,9 +34,11 @@ import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextButton -import androidx.compose.material.TextField import androidx.compose.material.TextFieldDefaults import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons @@ -62,13 +58,11 @@ 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.draw.scale import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -79,111 +73,48 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.activityViewModels import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.DataPacket import com.geeksville.mesh.R -import com.geeksville.mesh.android.Logging import com.geeksville.mesh.database.entity.QuickChatAction -import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.model.getChannel -import com.geeksville.mesh.navigation.navigateToNavGraph -import com.geeksville.mesh.ui.components.BaseScaffold import com.geeksville.mesh.ui.components.NodeKeyStatusIcon import com.geeksville.mesh.ui.components.NodeMenuAction import com.geeksville.mesh.ui.message.components.MessageList import com.geeksville.mesh.ui.theme.AppTheme -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch private const val MESSAGE_CHARACTER_LIMIT = 200 -internal fun FragmentManager.navigateToMessages(contactKey: String, message: String = "") { - val messagesFragment = MessagesFragment().apply { - arguments = bundleOf("contactKey" to contactKey, "message" to message) - } - beginTransaction() - .add(R.id.mainActivityLayout, messagesFragment) - .addToBackStack(null) - .commit() -} - -@AndroidEntryPoint -class MessagesFragment : Fragment(), Logging { - private val model: UIViewModel by activityViewModels() - - private fun navigateToMessages(node: Node) = node.user.let { user -> - val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC // TODO use meta.hasPKC - val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel - val contactKey = "$channel${user.id}" - info("calling MessagesFragment filter: $contactKey") - parentFragmentManager.navigateToMessages(contactKey) - } - - private fun navigateToNodeDetails(nodeNum: Int) { - info("calling NodeDetails --> destNum: $nodeNum") - parentFragmentManager.navigateToNavGraph(nodeNum, "NodeDetails") - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val contactKey = arguments?.getString("contactKey").toString() - val message = arguments?.getString("message").toString() - - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - AppTheme { - MessageScreen( - contactKey = contactKey, - message = message, - viewModel = model, - navigateToMessages = ::navigateToMessages, - navigateToNodeDetails = ::navigateToNodeDetails, - ) { parentFragmentManager.popBackStack() } - } - } - } - } -} - -sealed class MessageMenuAction { - data object ClipboardCopy : MessageMenuAction() - data object Delete : MessageMenuAction() - data object Dismiss : MessageMenuAction() - data object SelectAll : MessageMenuAction() -} - @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable internal fun MessageScreen( contactKey: String, message: String, viewModel: UIViewModel = hiltViewModel(), - navigateToMessages: (Node) -> Unit, + navigateToMessages: (String) -> Unit, navigateToNodeDetails: (Int) -> Unit, onNavigateBack: () -> Unit ) { val coroutineScope = rememberCoroutineScope() - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current val channelIndex = contactKey[0].digitToIntOrNull() val nodeId = contactKey.substring(1) - val channelName = channelIndex?.let { viewModel.channels.value.getChannel(it)?.name } - ?: "Unknown Channel" + val channels by viewModel.channels.collectAsStateWithLifecycle() + val channelName by remember(channelIndex) { + derivedStateOf { + channelIndex?.let { channels.getChannel(it)?.name } ?: "Unknown Channel" + } + } val title = when (nodeId) { DataPacket.ID_BROADCAST -> channelName else -> viewModel.getUser(nodeId).longName } + viewModel.setTitle(title) val mismatchKey = DataPacket.PKC_CHANNEL_INDEX == channelIndex && viewModel.getNode(nodeId).mismatchKey @@ -215,7 +146,7 @@ internal fun MessageScreen( ) } - BaseScaffold( + Scaffold( topBar = { if (inSelectionMode) { ActionModeTopBar(selectedIds.value) { action -> @@ -225,7 +156,8 @@ internal fun MessageScreen( .filter { it.uuid in selectedIds.value } .joinToString("\n") { it.text } - clipboardManager.setText(AnnotatedString(copiedText)) + val clipData = ClipData.newPlainText("", AnnotatedString(copiedText)) + clipboardManager.setClipEntry(ClipEntry(clipData)) selectedIds.value = emptySet() } @@ -274,9 +206,10 @@ internal fun MessageScreen( TextInput(isConnected, messageInput) { viewModel.sendMessage(it, contactKey) } } } - ) { + ) { padding -> if (messages.isNotEmpty()) { MessageList( + modifier = Modifier.padding(padding), messages = messages, selectedIds = selectedIds, onUnreadChanged = { viewModel.clearUnreadCount(contactKey, it) }, @@ -286,7 +219,14 @@ internal fun MessageScreen( is NodeMenuAction.Remove -> viewModel.removeNode(action.node.num) is NodeMenuAction.Ignore -> viewModel.ignoreNode(action.node) is NodeMenuAction.Favorite -> viewModel.favoriteNode(action.node) - is NodeMenuAction.DirectMessage -> navigateToMessages(action.node) + is NodeMenuAction.DirectMessage -> { + val hasPKC = + viewModel.ourNodeInfo.value?.hasPKC == true && action.node.hasPKC + val channel = + if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else action.node.channel + navigateToMessages("$channel${action.node.user.id}") + } + is NodeMenuAction.RequestUserInfo -> viewModel.requestUserInfo(action.node.num) is NodeMenuAction.RequestPosition -> viewModel.requestPosition(action.node.num) is NodeMenuAction.TraceRoute -> viewModel.requestTraceroute(action.node.num) @@ -329,6 +269,13 @@ private fun DeleteMessageDialog( ) } +sealed class MessageMenuAction { + data object ClipboardCopy : MessageMenuAction() + data object Delete : MessageMenuAction() + data object Dismiss : MessageMenuAction() + data object SelectAll : MessageMenuAction() +} + @Composable private fun ActionModeTopBar( selectedList: Set, @@ -433,53 +380,48 @@ private fun TextInput( ) = Column(modifier) { val focusManager = LocalFocusManager.current var isFocused by remember { mutableStateOf(false) } - - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - TextField( - value = message.value, - onValueChange = { - if (it.text.toByteArray().size <= maxSize) { - message.value = it - } - }, - modifier = Modifier - .weight(1f) - .onFocusEvent { isFocused = it.isFocused }, - enabled = enabled, - placeholder = { Text(stringResource(id = R.string.send_text)) }, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences, - ), - maxLines = 3, - shape = RoundedCornerShape(24.dp), - colors = TextFieldDefaults.textFieldColors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ) - ) - Spacer(Modifier.width(8.dp)) - Button( - onClick = { - val str = message.value.text.trim() - if (str.isNotEmpty()) { - focusManager.clearFocus() - onClick(str) - message.value = TextFieldValue("") - } - }, - modifier = Modifier.size(48.dp), - enabled = enabled, - shape = CircleShape, - ) { - Icon( - imageVector = Icons.AutoMirrored.Default.Send, - contentDescription = stringResource(id = R.string.send_text), - modifier = Modifier.scale(scale = 1.5f), - ) + OutlinedTextField( + value = message.value, + onValueChange = { + if (it.text.toByteArray().size <= maxSize) { + message.value = it + } + }, + modifier = Modifier + .weight(1f) + .onFocusEvent { isFocused = it.isFocused }, + enabled = enabled, + placeholder = { Text(stringResource(id = R.string.send_text)) }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), + maxLines = 3, + shape = RoundedCornerShape(24.dp), + colors = TextFieldDefaults.textFieldColors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + trailingIcon = { + IconButton( + onClick = { + val str = message.value.text.trim() + if (str.isNotEmpty()) { + focusManager.clearFocus() + onClick(str) + message.value = TextFieldValue("") + } + }, + modifier = Modifier.size(48.dp), + enabled = enabled, + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.Send, + contentDescription = stringResource(id = R.string.send_text), + tint = MaterialTheme.colors.primary + ) + } } - } + ) if (isFocused) { Text( text = "${message.value.text.toByteArray().size}/$maxSize", @@ -495,9 +437,18 @@ private fun TextInput( @Composable private fun TextInputPreview() { AppTheme { - TextInput( - enabled = true, - message = remember { mutableStateOf(TextFieldValue("")) }, - ) + Surface { + Column { + TextInput( + enabled = true, + message = remember { mutableStateOf(TextFieldValue("")) }, + ) + Spacer(Modifier.size(16.dp)) + TextInput( + enabled = true, + message = remember { mutableStateOf(TextFieldValue("Hello")) }, + ) + } + } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt index 75cac4a56..98516e980 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt @@ -50,6 +50,7 @@ import kotlinx.coroutines.flow.debounce @Suppress("LongMethod") @Composable internal fun MessageList( + modifier: Modifier = Modifier, messages: List, selectedIds: MutableState>, onUnreadChanged: (Long) -> Unit, @@ -84,7 +85,7 @@ internal fun MessageList( } LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), state = listState, reverseLayout = true, ) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/AdminRoute.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/AdminRoute.kt deleted file mode 100644 index 1ff2bd348..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/AdminRoute.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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 com.geeksville.mesh.ui.radioconfig - -import androidx.annotation.StringRes -import com.geeksville.mesh.R - -enum class AdminRoute(@StringRes val title: Int) { - REBOOT(R.string.reboot), - SHUTDOWN(R.string.shutdown), - FACTORY_RESET(R.string.factory_reset), - NODEDB_RESET(R.string.nodedb_reset), -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/ConfigRoute.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/ConfigRoute.kt deleted file mode 100644 index 6ce1ca1a8..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/ConfigRoute.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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 com.geeksville.mesh.ui.radioconfig - -import androidx.annotation.StringRes -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.List -import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material.icons.filled.CellTower -import androidx.compose.material.icons.filled.DisplaySettings -import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Power -import androidx.compose.material.icons.filled.Router -import androidx.compose.material.icons.filled.Security -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.ui.graphics.vector.ImageVector -import com.geeksville.mesh.MeshProtos.DeviceMetadata -import com.geeksville.mesh.R -import com.geeksville.mesh.navigation.Route - -@Suppress("MagicNumber") -// Config (type = AdminProtos.AdminMessage.ConfigType) -enum class ConfigRoute(@StringRes val title: Int, val route: Route, val icon: ImageVector?, val type: Int = 0) { - USER(R.string.user, Route.User, Icons.Default.Person, 0), - CHANNELS(R.string.channels, Route.ChannelConfig, Icons.AutoMirrored.Default.List, 0), - DEVICE(R.string.device, Route.Device, Icons.Default.Router, 0), - POSITION(R.string.position, Route.Position, Icons.Default.LocationOn, 1), - POWER(R.string.power, Route.Power, Icons.Default.Power, 2), - NETWORK(R.string.network, Route.Network, Icons.Default.Wifi, 3), - DISPLAY(R.string.display, Route.Display, Icons.Default.DisplaySettings, 4), - LORA(R.string.lora, Route.LoRa, Icons.Default.CellTower, 5), - BLUETOOTH(R.string.bluetooth, Route.Bluetooth, Icons.Default.Bluetooth, 6), - SECURITY(R.string.security, Route.Security, Icons.Default.Security, 7), - ; - - companion object { - fun filterExcludedFrom(metadata: DeviceMetadata?): List = entries.filter { - when { - metadata == null -> true - it == BLUETOOTH -> metadata.hasBluetooth - it == NETWORK -> metadata.hasWifi || metadata.hasEthernet - else -> true // Include all other routes by default - } - } - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/ModuleRoute.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/ModuleRoute.kt deleted file mode 100644 index dba84283c..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/ModuleRoute.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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 com.geeksville.mesh.ui.radioconfig - -import androidx.annotation.StringRes -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Forward -import androidx.compose.material.icons.automirrored.filled.Message -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.Cloud -import androidx.compose.material.icons.filled.DataUsage -import androidx.compose.material.icons.filled.LightMode -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.People -import androidx.compose.material.icons.filled.PermScanWifi -import androidx.compose.material.icons.filled.Sensors -import androidx.compose.material.icons.filled.SettingsRemote -import androidx.compose.material.icons.filled.Speed -import androidx.compose.material.icons.filled.Usb -import androidx.compose.ui.graphics.vector.ImageVector -import com.geeksville.mesh.MeshProtos.DeviceMetadata -import com.geeksville.mesh.R -import com.geeksville.mesh.navigation.Route - -@Suppress("MagicNumber") -// ModuleConfig (type = AdminProtos.AdminMessage.ModuleConfigType) -enum class ModuleRoute(@StringRes val title: Int, val route: Route, val icon: ImageVector?, val type: Int = 0) { - MQTT(R.string.mqtt, Route.MQTT, Icons.Default.Cloud, 0), - SERIAL(R.string.serial, Route.Serial, Icons.Default.Usb, 1), - EXT_NOTIFICATION(R.string.external_notification, Route.ExtNotification, Icons.Default.Notifications, 2), - STORE_FORWARD(R.string.store_forward, Route.StoreForward, Icons.AutoMirrored.Default.Forward, 3), - RANGE_TEST(R.string.range_test, Route.RangeTest, Icons.Default.Speed, 4), - TELEMETRY(R.string.telemetry, Route.Telemetry, Icons.Default.DataUsage, 5), - CANNED_MESSAGE(R.string.canned_message, Route.CannedMessage, Icons.AutoMirrored.Default.Message, 6), - AUDIO(R.string.audio, Route.Audio, Icons.AutoMirrored.Default.VolumeUp, 7), - REMOTE_HARDWARE(R.string.remote_hardware, Route.RemoteHardware, Icons.Default.SettingsRemote, 8), - NEIGHBOR_INFO(R.string.neighbor_info, Route.NeighborInfo, Icons.Default.People, 9), - AMBIENT_LIGHTING(R.string.ambient_lighting, Route.AmbientLighting, Icons.Default.LightMode, 10), - DETECTION_SENSOR(R.string.detection_sensor, Route.DetectionSensor, Icons.Default.Sensors, 11), - PAXCOUNTER(R.string.paxcounter, Route.Paxcounter, Icons.Default.PermScanWifi, 12), - ; - - val bitfield: Int get() = 1 shl ordinal - - companion object { - fun filterExcludedFrom(metadata: DeviceMetadata?): List = entries.filter { - when (metadata) { - null -> true - else -> metadata.excludedModules and it.bitfield == 0 - } - } - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt index ba65bf051..86cc7098a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt @@ -65,6 +65,10 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile import com.geeksville.mesh.R +import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.navigation.AdminRoute +import com.geeksville.mesh.navigation.ConfigRoute +import com.geeksville.mesh.navigation.ModuleRoute import com.geeksville.mesh.navigation.Route import com.geeksville.mesh.ui.components.PreferenceCategory import com.geeksville.mesh.ui.radioconfig.components.EditDeviceProfileDialog @@ -79,13 +83,19 @@ private fun getNavRouteFrom(routeName: String): Route? { @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun RadioConfigScreen( - viewModel: RadioConfigViewModel = hiltViewModel(), modifier: Modifier = Modifier, + viewModel: RadioConfigViewModel = hiltViewModel(), + uiViewModel: UIViewModel = hiltViewModel(), onNavigate: (Route) -> Unit = {} ) { + val node by viewModel.destNode.collectAsStateWithLifecycle() + val nodeName: String? = node?.user?.longName + nodeName?.let { + uiViewModel.setTitle(it) + } + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() var isWaiting by remember { mutableStateOf(false) } - if (isWaiting) { PacketResponseStateDialog( state = state.responseState, @@ -155,8 +165,8 @@ fun RadioConfigScreen( } RadioConfigItemList( - state = state, modifier = modifier, + state = state, onRouteClick = { route -> isWaiting = true viewModel.setResponseStateLoading(route) @@ -246,7 +256,8 @@ private fun NavButton(@StringRes title: Int, enabled: Boolean, onClick: () -> Un modifier = Modifier.padding(end = 8.dp) ) Text( - text = "${stringResource(title)}?\n") + text = "${stringResource(title)}?\n" + ) Icon( imageVector = Icons.TwoTone.Warning, contentDescription = "warning", @@ -305,12 +316,20 @@ private fun RadioConfigItemList( ) { item { PreferenceCategory(stringResource(R.string.device_settings)) } items(ConfigRoute.filterExcludedFrom(state.metadata)) { - NavCard(title = stringResource(it.title), icon = it.icon, enabled = enabled) { onRouteClick(it) } + NavCard( + title = stringResource(it.title), + icon = it.icon, + enabled = enabled + ) { onRouteClick(it) } } item { PreferenceCategory(stringResource(R.string.module_settings)) } items(ModuleRoute.filterExcludedFrom(state.metadata)) { - NavCard(title = stringResource(it.title), icon = it.icon, enabled = enabled) { onRouteClick(it) } + NavCard( + title = stringResource(it.title), + icon = it.icon, + enabled = enabled + ) { onRouteClick(it) } } if (state.isLocal) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfigViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfigViewModel.kt index e01fd7523..e08ff5ccd 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfigViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfigViewModel.kt @@ -44,9 +44,12 @@ import com.geeksville.mesh.model.getChannelList import com.geeksville.mesh.model.getStringResFrom import com.geeksville.mesh.model.toChannelSet import com.geeksville.mesh.moduleConfig -import com.geeksville.mesh.navigation.Route import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.service.MeshService.ConnectionState +import com.geeksville.mesh.navigation.AdminRoute +import com.geeksville.mesh.navigation.ConfigRoute +import com.geeksville.mesh.navigation.ModuleRoute +import com.geeksville.mesh.navigation.Route import com.geeksville.mesh.util.UiText import com.google.protobuf.MessageLite import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/app/src/main/java/com/geeksville/mesh/ui/theme/Color.kt b/app/src/main/java/com/geeksville/mesh/ui/theme/Color.kt index a60a3a293..1fa9d80d0 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/theme/Color.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/theme/Color.kt @@ -19,20 +19,8 @@ package com.geeksville.mesh.ui.theme import androidx.compose.ui.graphics.Color -val Purple200 = Color(0xFFBB86FC) -val Purple500 = Color(0xFF6200EE) -val Purple700 = Color(0xFF3700B3) -val Teal200 = Color(0xFF03DAC5) - -val LightGray = Color(0xFFFAFAFA) -val LightSkyBlue = Color(0x99A6D1E6) -val LightBlue = Color(0xFFA6D1E6) -val SkyBlue = Color(0xFF57AEFF) -val LightPink = Color(0xFFFFE6E6) -val LightGreen = Color(0xFFCFE8A9) -val LightRed = Color(0xFFFFB3B3) - val MeshtasticGreen = Color(0xFF67EA94) +val MeshtasticAlt = Color(0xFF2C2D3C) val HyperlinkBlue = Color(0xFF43C3B0) val InfantryBlue = Color(red = 75, green = 119, blue = 190) diff --git a/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt b/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt index f92940895..3be0d2126 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt @@ -25,23 +25,14 @@ import androidx.compose.runtime.Composable private val DarkColorPalette = darkColors( primary = MeshtasticGreen, - primaryVariant = Purple700, - secondary = Teal200, + primaryVariant = MeshtasticGreen, + secondary = MeshtasticGreen, ) private val LightColorPalette = lightColors( primary = MeshtasticGreen, - primaryVariant = LightSkyBlue, - secondary = Teal200, - - /* Other default colors to override - background = Color.White, - surface = Color.White, - onPrimary = Color.White, - onSecondary = Color.Black, - onBackground = Color.Black, - onSurface = Color.Black, - */ + primaryVariant = MeshtasticGreen, + secondary = MeshtasticGreen, ) @Composable diff --git a/app/src/main/res/color/tab_color_selector.xml b/app/src/main/res/color/tab_color_selector.xml deleted file mode 100644 index 86f51da68..000000000 --- a/app/src/main/res/color/tab_color_selector.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/cloud_off.xml b/app/src/main/res/drawable/cloud_off.xml deleted file mode 100644 index 92b0aa54f..000000000 --- a/app/src/main/res/drawable/cloud_off.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/cloud_on.xml b/app/src/main/res/drawable/cloud_on.xml deleted file mode 100644 index 0c9bcd4d5..000000000 --- a/app/src/main/res/drawable/cloud_on.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml b/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml deleted file mode 100644 index e13f29fd3..000000000 --- a/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_edit_24.xml b/app/src/main/res/drawable/ic_baseline_edit_24.xml deleted file mode 100644 index 2cda9c112..000000000 --- a/app/src/main/res/drawable/ic_baseline_edit_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_satellite.xml b/app/src/main/res/drawable/ic_satellite.xml deleted file mode 100644 index b9bbd50aa..000000000 --- a/app/src/main/res/drawable/ic_satellite.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_twotone_add_24.xml b/app/src/main/res/drawable/ic_twotone_add_24.xml deleted file mode 100644 index eb232541d..000000000 --- a/app/src/main/res/drawable/ic_twotone_add_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_twotone_cloud_upload_24.xml b/app/src/main/res/drawable/ic_twotone_cloud_upload_24.xml deleted file mode 100644 index 8982a35a8..000000000 --- a/app/src/main/res/drawable/ic_twotone_cloud_upload_24.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_twotone_contactless_24.xml b/app/src/main/res/drawable/ic_twotone_contactless_24.xml deleted file mode 100644 index 8a999651b..000000000 --- a/app/src/main/res/drawable/ic_twotone_contactless_24.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/ic_twotone_delete_24.xml b/app/src/main/res/drawable/ic_twotone_delete_24.xml deleted file mode 100644 index b77afdc91..000000000 --- a/app/src/main/res/drawable/ic_twotone_delete_24.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_twotone_lock_24.xml b/app/src/main/res/drawable/ic_twotone_lock_24.xml deleted file mode 100644 index 2c454424f..000000000 --- a/app/src/main/res/drawable/ic_twotone_lock_24.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_twotone_map_24.xml b/app/src/main/res/drawable/ic_twotone_map_24.xml deleted file mode 100644 index bd96aeb9f..000000000 --- a/app/src/main/res/drawable/ic_twotone_map_24.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_twotone_message_24.xml b/app/src/main/res/drawable/ic_twotone_message_24.xml deleted file mode 100644 index 9ad22351e..000000000 --- a/app/src/main/res/drawable/ic_twotone_message_24.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_twotone_people_24.xml b/app/src/main/res/drawable/ic_twotone_people_24.xml deleted file mode 100644 index 74a346d8f..000000000 --- a/app/src/main/res/drawable/ic_twotone_people_24.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_twotone_select_all_24.xml b/app/src/main/res/drawable/ic_twotone_select_all_24.xml deleted file mode 100644 index c997121c7..000000000 --- a/app/src/main/res/drawable/ic_twotone_select_all_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_twotone_send_24.xml b/app/src/main/res/drawable/ic_twotone_send_24.xml deleted file mode 100644 index ee3e89f79..000000000 --- a/app/src/main/res/drawable/ic_twotone_send_24.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_twotone_settings_applications_24.xml b/app/src/main/res/drawable/ic_twotone_settings_applications_24.xml deleted file mode 100644 index e2368d7a8..000000000 --- a/app/src/main/res/drawable/ic_twotone_settings_applications_24.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_twotone_sort_24.xml b/app/src/main/res/drawable/ic_twotone_sort_24.xml deleted file mode 100644 index a58bd6881..000000000 --- a/app/src/main/res/drawable/ic_twotone_sort_24.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_twotone_visibility_24.xml b/app/src/main/res/drawable/ic_twotone_visibility_24.xml deleted file mode 100644 index 507c5fdb2..000000000 --- a/app/src/main/res/drawable/ic_twotone_visibility_24.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_twotone_visibility_off_24.xml b/app/src/main/res/drawable/ic_twotone_visibility_off_24.xml deleted file mode 100644 index a19e1d4b4..000000000 --- a/app/src/main/res/drawable/ic_twotone_visibility_off_24.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_twotone_volume_off_24.xml b/app/src/main/res/drawable/ic_twotone_volume_off_24.xml deleted file mode 100644 index a62bb87b1..000000000 --- a/app/src/main/res/drawable/ic_twotone_volume_off_24.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_twotone_volume_up_24.xml b/app/src/main/res/drawable/ic_twotone_volume_up_24.xml deleted file mode 100644 index 94f1e4c67..000000000 --- a/app/src/main/res/drawable/ic_twotone_volume_up_24.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 5ab60f6b9..000000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/settings_fragment.xml b/app/src/main/res/layout/settings_fragment.xml deleted file mode 100644 index d9b53222c..000000000 --- a/app/src/main/res/layout/settings_fragment.xml +++ /dev/null @@ -1,211 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml deleted file mode 100644 index 4b9d7df4d..000000000 --- a/app/src/main/res/menu/menu_main.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/menu_messages.xml b/app/src/main/res/menu/menu_messages.xml deleted file mode 100644 index ecc0750d5..000000000 --- a/app/src/main/res/menu/menu_messages.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ff5830f7d..665308bb4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -605,4 +605,6 @@ Heading Sats Alt + Set Region + Unmute diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 91704a0f1..9eb6b2e2a 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -19,78 +19,7 @@ - - - - - - - - - - - - - - - - - - - - - - - +