refactor: migrate to Compose navigation (#1835)

Co-authored-by: andrekir <andrekir@pm.me>
This commit is contained in:
James Rich
2025-05-15 08:05:30 -05:00
committed by GitHub
parent 79c77ab1d5
commit 8cde47bdf9
74 changed files with 2576 additions and 3427 deletions

View File

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

View File

@@ -135,10 +135,9 @@
<activity
android:name="com.geeksville.mesh.MainActivity"
android:launchMode="singleInstance"
android:launchMode="standard"
android:screenOrientation="unspecified"
android:windowSoftInputMode="stateAlwaysHidden"
android:theme="@style/Theme.App.Starting"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -151,6 +150,19 @@
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="meshtastic" />
<data android:host="meshtastic" />
<data android:pathPrefix="/messages" />
<data android:pathPrefix="/share" />
<data android:pathPrefix="/settings" />
</intent-filter>
<intent-filter android:autoVerify="true">
<!-- The QR codes to share channel settings are shared as meshtastic URLS

View File

@@ -17,9 +17,9 @@
package com.geeksville.mesh
import android.app.Activity
import android.app.PendingIntent
import android.app.TaskStackBuilder
import android.bluetooth.BluetoothAdapter
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
@@ -27,34 +27,26 @@ import android.hardware.usb.UsbManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.RemoteException
import android.provider.Settings
import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.widget.TextView
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.widget.Toolbar
import androidx.compose.runtime.getValue
import androidx.core.content.ContextCompat
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.ui.Modifier
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.setPadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
import androidx.lifecycle.asLiveData
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.geeksville.mesh.android.BindFailedException
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.Logging
@@ -68,96 +60,30 @@ import com.geeksville.mesh.android.permissionMissing
import com.geeksville.mesh.android.rationaleDialog
import com.geeksville.mesh.android.shouldShowRequestPermissionRationale
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.databinding.ActivityMainBinding
import com.geeksville.mesh.model.BluetoothViewModel
import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.navigateToNavGraph
import com.geeksville.mesh.navigation.DEEP_LINK_BASE_URI
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.MeshServiceNotifications
import com.geeksville.mesh.service.ServiceRepository
import com.geeksville.mesh.service.startService
import com.geeksville.mesh.ui.ChannelFragment
import com.geeksville.mesh.ui.ContactsFragment
import com.geeksville.mesh.ui.DebugFragment
import com.geeksville.mesh.ui.QuickChatSettingsFragment
import com.geeksville.mesh.ui.SettingsFragment
import com.geeksville.mesh.ui.UsersFragment
import com.geeksville.mesh.ui.components.ScannedQrCodeDialog
import com.geeksville.mesh.ui.map.MapFragment
import com.geeksville.mesh.ui.message.navigateToMessages
import com.geeksville.mesh.ui.navigateToShareMessage
import com.geeksville.mesh.ui.MainMenuAction
import com.geeksville.mesh.ui.MainScreen
import com.geeksville.mesh.ui.theme.AppTheme
import com.geeksville.mesh.util.Exceptions
import com.geeksville.mesh.util.LanguageUtils
import com.geeksville.mesh.util.getPackageInfoCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import java.text.DateFormat
import java.util.Date
import javax.inject.Inject
/*
UI design
material setup instructions: https://material.io/develop/android/docs/getting-started/
dark theme (or use system eventually) https://material.io/develop/android/theming/dark/
NavDrawer is a standard draw which can be dragged in from the left or the menu icon inside the app
title.
Fragments:
SettingsFragment shows "Settings"
username
shortname
bluetooth pairing list
(eventually misc device settings that are not channel related)
Channel fragment "Channel"
qr code, copy link button
ch number
misc other settings
(eventually a way of choosing between past channels)
ChatFragment "Messages"
a text box to enter new texts
a scrolling list of rows. each row is a text and a sender info layout
NodeListFragment "Users"
a node info row for every node
ViewModels:
BTScanModel starts/stops bt scan and provides list of devices (manages entire scan lifecycle)
MeshModel contains: (manages entire service relationship)
current received texts
current radio macaddr
current node infos (updated dynamically)
eventually use bottom navigation bar to switch between, Members, Chat, Channel, Settings. https://material.io/develop/android/components/bottom-navigation-view/
use numbers of # chat messages and # of members in the badges.
(per this recommendation to not use top tabs: https://ux.stackexchange.com/questions/102439/android-ux-when-to-use-bottom-navigation-and-when-to-use-tabs )
eventually:
make a custom theme: https://github.com/material-components/material-components-android/tree/master/material-theme-builder
*/
@AndroidEntryPoint
class MainActivity : AppCompatActivity(), Logging {
private lateinit var binding: ActivityMainBinding
// Used to schedule a coroutine in the GUI thread
private val mainScope = CoroutineScope(Dispatchers.Main + Job())
@@ -173,7 +99,7 @@ class MainActivity : AppCompatActivity(), Logging {
info("Bluetooth permissions granted")
} else {
warn("Bluetooth permissions denied")
showSnackbar(permissionMissing)
model.showSnackbar(permissionMissing)
}
requestedEnable = false
bluetoothViewModel.permissionsUpdated()
@@ -186,46 +112,12 @@ class MainActivity : AppCompatActivity(), Logging {
checkAlertDnD()
} else {
warn("Notification permissions denied")
showSnackbar(getString(R.string.notification_denied), Snackbar.LENGTH_SHORT)
model.showSnackbar(getString(R.string.notification_denied))
}
}
data class TabInfo(@StringRes val textResId: Int, val icon: Int, val content: Fragment)
private val tabInfos = arrayOf(
TabInfo(
R.string.main_tab_messages,
R.drawable.ic_twotone_message_24,
ContactsFragment()
),
TabInfo(
R.string.main_tab_users,
R.drawable.ic_twotone_people_24,
UsersFragment()
),
TabInfo(
R.string.main_tab_map,
R.drawable.ic_twotone_map_24,
MapFragment()
),
TabInfo(
R.string.main_tab_channel,
R.drawable.ic_twotone_contactless_24,
ChannelFragment()
),
TabInfo(
R.string.main_tab_settings,
R.drawable.ic_twotone_settings_applications_24,
SettingsFragment()
)
)
private val tabsAdapter = object : FragmentStateAdapter(supportFragmentManager, lifecycle) {
override fun getItemCount(): Int = tabInfos.size
override fun createFragment(position: Int): Fragment = tabInfos[position].content
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
installSplashScreen()
super.onCreate(savedInstanceState)
@@ -247,44 +139,10 @@ class MainActivity : AppCompatActivity(), Logging {
(application as GeeksvilleApplication).askToRate(this)
}
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
initToolbar()
binding.pager.adapter = tabsAdapter
binding.pager.isUserInputEnabled =
false // Gestures for screen switching doesn't work so good with the map view
// pager.offscreenPageLimit = 0 // Don't keep any offscreen pages around, because we want to make sure our bluetooth scanning stops
TabLayoutMediator(binding.tabLayout, binding.pager, false, false) { tab, position ->
tab.icon = ContextCompat.getDrawable(this, tabInfos[position].icon)
tab.contentDescription = ContextCompat.getString(this, tabInfos[position].textResId)
}.attach()
binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) {
val mainTab = tab?.position ?: 0
model.setCurrentTab(mainTab)
}
override fun onTabUnselected(tab: TabLayout.Tab?) { }
override fun onTabReselected(tab: TabLayout.Tab?) { }
})
binding.composeView.setContent {
val connState by model.connectionState.collectAsStateWithLifecycle()
val channels by model.channels.collectAsStateWithLifecycle()
val requestChannelSet by model.requestChannelSet.collectAsStateWithLifecycle()
AppTheme {
if (connState.isConnected()) {
if (requestChannelSet != null) {
ScannedQrCodeDialog(
channels = channels,
incoming = requestChannelSet!!,
onDismiss = model::clearRequestChannelUrl,
onConfirm = model::setChannels,
)
}
setContent {
Box(Modifier.safeDrawingPadding()) {
AppTheme {
MainScreen(viewModel = model, onAction = ::onMainMenuAction)
}
}
}
@@ -293,28 +151,6 @@ class MainActivity : AppCompatActivity(), Logging {
handleIntent(intent)
}
private fun initToolbar() {
val toolbar = binding.toolbar as Toolbar
setSupportActionBar(toolbar)
supportActionBar?.setDisplayShowTitleEnabled(false)
}
private fun updateConnectionStatusImage(connected: MeshService.ConnectionState) {
if (model.actionBarMenu == null) return
val (image, tooltip) = when (connected) {
MeshService.ConnectionState.CONNECTED -> R.drawable.cloud_on to R.string.connected
MeshService.ConnectionState.DEVICE_SLEEP -> R.drawable.ic_twotone_cloud_upload_24 to R.string.device_sleeping
MeshService.ConnectionState.DISCONNECTED -> R.drawable.cloud_off to R.string.disconnected
}
val item = model.actionBarMenu?.findItem(R.id.connectStatusImage)
if (item != null) {
item.setIcon(image)
item.setTitle(tooltip)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
@@ -329,17 +165,11 @@ class MainActivity : AppCompatActivity(), Logging {
Intent.ACTION_VIEW -> {
debug("Asked to open a channel URL - ask user if they want to switch to that channel. If so send the config to the radio")
appLinkData?.let(model::requestChannelUrl)
// We now wait for the device to connect, once connected, we ask the user if they want to switch to the new channel
}
MeshServiceNotifications.OPEN_MESSAGE_ACTION -> {
val contactKey =
intent.getStringExtra(MeshServiceNotifications.OPEN_MESSAGE_EXTRA_CONTACT_KEY)
showMessages(contactKey)
}
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
debug("USB device attached")
showSettingsPage()
}
@@ -349,7 +179,7 @@ class MainActivity : AppCompatActivity(), Logging {
Intent.ACTION_SEND -> {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
if (text != null) {
shareMessages(text)
createShareIntent(text).send()
}
}
@@ -359,6 +189,34 @@ class MainActivity : AppCompatActivity(), Logging {
}
}
private fun createShareIntent(message: String): PendingIntent {
val deepLink = "$DEEP_LINK_BASE_URI/share?message=$message"
val startActivityIntent = Intent(
Intent.ACTION_VIEW, deepLink.toUri(),
this, MainActivity::class.java
)
val resultPendingIntent: PendingIntent? = TaskStackBuilder.create(this).run {
addNextIntentWithParentStack(startActivityIntent)
getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE)
}
return resultPendingIntent!!
}
private fun createSettingsIntent(): PendingIntent {
val deepLink = "$DEEP_LINK_BASE_URI/settings"
val startActivityIntent = Intent(
Intent.ACTION_VIEW, deepLink.toUri(),
this, MainActivity::class.java
)
val resultPendingIntent: PendingIntent? = TaskStackBuilder.create(this).run {
addNextIntentWithParentStack(startActivityIntent)
getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE)
}
return resultPendingIntent!!
}
private var requestedEnable = false
private val bleRequestEnable = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
@@ -369,7 +227,7 @@ class MainActivity : AppCompatActivity(), Logging {
private val createDocumentLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { file_uri -> model.saveMessagesCSV(file_uri) }
}
}
@@ -400,6 +258,7 @@ class MainActivity : AppCompatActivity(), Logging {
// Linkify.addLinks(view, Linkify.ALL) // not needed with this method
view.movementMethod = LinkMovementMethod.getInstance()
debug("showAlert: $titleText")
showSettingsPage() // Default to the settings page in this case
}
@@ -465,6 +324,7 @@ class MainActivity : AppCompatActivity(), Logging {
intent.putExtra(Settings.EXTRA_CHANNEL_ID, "my_alerts")
startActivity(intent)
}
val message = Html.fromHtml(
getString(R.string.alerts_dnd_request_text),
Html.FROM_HTML_MODE_COMPACT
@@ -491,27 +351,6 @@ class MainActivity : AppCompatActivity(), Logging {
}
}
private fun showSnackbar(msgId: Int) {
try {
Snackbar.make(binding.root, msgId, Snackbar.LENGTH_LONG).show()
} catch (ex: IllegalStateException) {
errormsg("Snackbar couldn't find view for msgId $msgId")
}
}
private fun showSnackbar(msg: String, duration: Int = Snackbar.LENGTH_INDEFINITE) {
try {
Snackbar.make(binding.root, msg, duration)
.apply { view.findViewById<TextView>(R.id.snackbar_text).isSingleLine = false }
.setAction(R.string.okay) {
// dismiss
}
.show()
} catch (ex: IllegalStateException) {
errormsg("Snackbar couldn't find view for msgString $msg")
}
}
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return try {
super.dispatchTouchEvent(ev)
@@ -526,10 +365,7 @@ class MainActivity : AppCompatActivity(), Logging {
private var connectionJob: Job? = null
private val mesh = object :
ServiceClient<IMeshService>({
IMeshService.Stub.asInterface(it)
}) {
private val mesh = object : ServiceClient<IMeshService>(IMeshService.Stub::asInterface) {
override fun onConnected(service: IMeshService) {
connectionJob = mainScope.handledLaunch {
serviceRepository.setMeshService(service)
@@ -576,7 +412,7 @@ class MainActivity : AppCompatActivity(), Logging {
mesh.connect(
this,
MeshService.createIntent(),
Context.BIND_AUTO_CREATE + Context.BIND_ABOVE_CLIENT
BIND_AUTO_CREATE + BIND_ABOVE_CLIENT
)
}
@@ -602,11 +438,6 @@ class MainActivity : AppCompatActivity(), Logging {
override fun onStart() {
super.onStart()
model.connectionState.asLiveData().observe(this) { state ->
onMeshConnectionChanged(state)
updateConnectionStatusImage(state)
}
bluetoothViewModel.enabled.observe(this) { enabled ->
if (!enabled && !requestedEnable && model.selectedBluetooth) {
requestedEnable = true
@@ -622,17 +453,6 @@ class MainActivity : AppCompatActivity(), Logging {
}
}
// Call showSnackbar() whenever [snackbarText] updates with a non-null value
model.snackbarText.observe(this) { text ->
if (text is Int) showSnackbar(text)
if (text is String) showSnackbar(text)
if (text != null) model.clearSnackbarText()
}
model.currentTab.observe(this) {
binding.tabLayout.getTabAt(it)?.select()
}
model.tracerouteResponse.observe(this) { response ->
MaterialAlertDialogBuilder(this)
.setCancelable(false)
@@ -648,126 +468,42 @@ class MainActivity : AppCompatActivity(), Logging {
bindMeshService()
} catch (ex: BindFailedException) {
// App is probably shutting down, ignore
errormsg("Bind of MeshService failed")
errormsg("Bind of MeshService failed${ex.message}")
}
val bonded = model.bondedAddress != null
if (!bonded) showSettingsPage()
}
private fun showSettingsPage() {
binding.pager.currentItem = 5
createSettingsIntent().send()
}
private fun showMessages(contactKey: String?) {
model.setCurrentTab(0)
if (contactKey != null) {
supportFragmentManager.navigateToMessages(contactKey)
}
}
private fun shareMessages(message: String?) {
model.setCurrentTab(0)
if (message != null) {
supportFragmentManager.navigateToShareMessage(message)
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
// Inflate the menu; this adds items to the action bar if it is present.
menuInflater.inflate(R.menu.menu_main, menu)
model.actionBarMenu = menu
updateConnectionStatusImage(model.connectionState.value)
return true
}
private val handler: Handler by lazy {
Handler(Looper.getMainLooper())
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
menu.findItem(R.id.stress_test).isVisible =
BuildConfig.DEBUG // only show stress test for debug builds (for now)
menu.findItem(R.id.radio_config).isEnabled = !model.isManaged
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
// Handle action bar item clicks here. The action bar will
// automatically handle clicks on the Home/Up button, so long
// as you specify a parent activity in AndroidManifest.xml.
return when (item.itemId) {
R.id.about -> {
private fun onMainMenuAction(action: MainMenuAction) {
when (action) {
MainMenuAction.ABOUT -> {
getVersionInfo()
return true
}
R.id.connectStatusImage -> {
Toast.makeText(applicationContext, item.title, Toast.LENGTH_SHORT).show()
return true
}
R.id.debug -> {
val fragmentManager: FragmentManager = supportFragmentManager
val fragmentTransaction: FragmentTransaction = fragmentManager.beginTransaction()
val nameFragment = DebugFragment()
fragmentTransaction.add(R.id.mainActivityLayout, nameFragment)
fragmentTransaction.addToBackStack(null)
fragmentTransaction.commit()
return true
}
R.id.stress_test -> {
fun postPing() {
// Send ping message and arrange delayed recursion.
debug("Sending ping")
val str = "Ping " + DateFormat.getTimeInstance(DateFormat.MEDIUM)
.format(Date(System.currentTimeMillis()))
model.sendMessage(str)
handler.postDelayed({ postPing() }, 30000)
}
item.isChecked = !item.isChecked // toggle ping test
if (item.isChecked) {
postPing()
} else {
handler.removeCallbacksAndMessages(null)
}
return true
}
R.id.radio_config -> {
supportFragmentManager.navigateToNavGraph()
return true
}
R.id.save_messages_csv -> {
MainMenuAction.EXPORT_MESSAGES -> {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "rangetest.csv")
}
createDocumentLauncher.launch(intent)
return true
}
R.id.theme -> {
MainMenuAction.THEME -> {
chooseThemeDialog()
return true
}
R.id.preferences_language -> {
MainMenuAction.LANGUAGE -> {
chooseLangDialog()
return true
}
R.id.show_intro -> {
MainMenuAction.SHOW_INTRO -> {
startActivity(Intent(this, AppIntroduction::class.java))
return true
}
R.id.preferences_quick_chat -> {
val fragmentManager: FragmentManager = supportFragmentManager
val fragmentTransaction: FragmentTransaction = fragmentManager.beginTransaction()
val nameFragment = QuickChatSettingsFragment()
fragmentTransaction.add(R.id.mainActivityLayout, nameFragment)
fragmentTransaction.addToBackStack(null)
fragmentTransaction.commit()
return true
}
else -> super.onOptionsItemSelected(item)
else -> {}
}
}

View File

@@ -135,11 +135,14 @@ enum class TimeFrame(
return when (this.ordinal) {
TWENTY_FOUR_HOURS.ordinal ->
TimeUnit.HOURS.toSeconds(6)
FORTY_EIGHT_HOURS.ordinal ->
TimeUnit.HOURS.toSeconds(12)
ONE_WEEK.ordinal,
TWO_WEEKS.ordinal ->
TimeUnit.DAYS.toSeconds(1)
else ->
TimeUnit.DAYS.toSeconds(7)
}
@@ -152,8 +155,10 @@ enum class TimeFrame(
return when (this.ordinal) {
TWENTY_FOUR_HOURS.ordinal ->
TimeUnit.HOURS.toSeconds(6)
FORTY_EIGHT_HOURS.ordinal ->
TimeUnit.HOURS.toSeconds(12)
else ->
TimeUnit.DAYS.toSeconds(1)
}
@@ -204,7 +209,9 @@ class MetricsViewModel @Inject constructor(
}
fun clearPosition() = viewModelScope.launch(dispatchers.io) {
meshLogRepository.deleteLogs(destNum, PortNum.POSITION_APP_VALUE)
destNum?.let {
meshLogRepository.deleteLogs(it, PortNum.POSITION_APP_VALUE)
}
}
private val _state = MutableStateFlow(MetricsState.Empty)
@@ -219,79 +226,84 @@ class MetricsViewModel @Inject constructor(
private var deviceHardwareList: List<DeviceHardware> = listOf()
init {
radioConfigRepository.nodeDBbyNum
.mapLatest { nodes -> nodes[destNum] }
.distinctUntilChanged()
.onEach { node ->
_state.update { state -> state.copy(node = node) }
node?.user?.hwModel?.let { hwModel ->
val deviceHardware = getDeviceHardwareFromHardwareModel(hwModel)
deviceHardware?.let {
_state.update { state ->
state.copy(deviceHardware = it)
destNum?.let {
radioConfigRepository.nodeDBbyNum
.mapLatest { nodes -> nodes[destNum] }
.distinctUntilChanged()
.onEach { node ->
_state.update { state -> state.copy(node = node) }
node?.user?.hwModel?.let { hwModel ->
val deviceHardware = getDeviceHardwareFromHardwareModel(hwModel)
deviceHardware?.let {
_state.update { state ->
state.copy(deviceHardware = it)
}
}
}
}
}
.launchIn(viewModelScope)
.launchIn(viewModelScope)
radioConfigRepository.deviceProfileFlow.onEach { profile ->
val moduleConfig = profile.moduleConfig
_state.update { state ->
state.copy(
isManaged = profile.config.security.isManaged,
isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
)
}
}.launchIn(viewModelScope)
radioConfigRepository.deviceProfileFlow.onEach { profile ->
val moduleConfig = profile.moduleConfig
_state.update { state ->
state.copy(
isManaged = profile.config.security.isManaged,
isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
)
}
}.launchIn(viewModelScope)
meshLogRepository.getTelemetryFrom(destNum).onEach { telemetry ->
_state.update { state ->
state.copy(
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
powerMetrics = telemetry.filter { it.hasPowerMetrics() }
)
}
_envState.update { state ->
state.copy(
environmentMetrics = telemetry.filter {
it.hasEnvironmentMetrics() &&
it.environmentMetrics.relativeHumidity >= 0f &&
!it.environmentMetrics.temperature.isNaN()
},
)
}
}.launchIn(viewModelScope)
meshLogRepository.getTelemetryFrom(destNum).onEach { telemetry ->
_state.update { state ->
state.copy(
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
powerMetrics = telemetry.filter { it.hasPowerMetrics() }
)
}
_envState.update { state ->
state.copy(
environmentMetrics = telemetry.filter {
it.hasEnvironmentMetrics() &&
it.environmentMetrics.relativeHumidity >= 0f &&
!it.environmentMetrics.temperature.isNaN()
},
)
}
}.launchIn(viewModelScope)
meshLogRepository.getMeshPacketsFrom(destNum).onEach { meshPackets ->
_state.update { state ->
state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() })
}
}.launchIn(viewModelScope)
meshLogRepository.getMeshPacketsFrom(destNum).onEach { meshPackets ->
_state.update { state ->
state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() })
}
}.launchIn(viewModelScope)
combine(
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
meshLogRepository.getMeshPacketsFrom(destNum, PortNum.TRACEROUTE_APP_VALUE),
) { request, response ->
_state.update { state ->
state.copy(
tracerouteRequests = request.filter { it.hasValidTraceroute() },
tracerouteResults = response,
)
}
}.launchIn(viewModelScope)
combine(
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
meshLogRepository.getMeshPacketsFrom(destNum, PortNum.TRACEROUTE_APP_VALUE),
) { request, response ->
_state.update { state ->
state.copy(
tracerouteRequests = request.filter { it.hasValidTraceroute() },
tracerouteResults = response,
)
}
}.launchIn(viewModelScope)
meshLogRepository.getMeshPacketsFrom(destNum, PortNum.POSITION_APP_VALUE).onEach { packets ->
val distinctPositions =
packets.mapNotNull { it.toPosition() }.asFlow().distinctUntilChanged { old, new ->
old.time == new.time || (old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI)
}.toList()
_state.update { state ->
state.copy(positionLogs = distinctPositions)
}
}.launchIn(viewModelScope)
meshLogRepository.getMeshPacketsFrom(destNum, PortNum.POSITION_APP_VALUE)
.onEach { packets ->
val distinctPositions =
packets.mapNotNull { it.toPosition() }.asFlow()
.distinctUntilChanged { old, new ->
old.time == new.time ||
(old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI)
}.toList()
_state.update { state ->
state.copy(positionLogs = distinctPositions)
}
}.launchIn(viewModelScope)
debug("MetricsViewModel created")
debug("MetricsViewModel created")
}
}
override fun onCleared() {

View File

@@ -22,7 +22,7 @@ import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import android.os.RemoteException
import android.view.Menu
import androidx.compose.material.SnackbarHostState
import androidx.core.content.edit
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
@@ -55,6 +55,7 @@ import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.location.LocationRepository
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.ServiceAction
@@ -66,6 +67,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
@@ -123,7 +125,9 @@ internal fun getChannelList(
old: List<ChannelSettings>,
): List<ChannelProtos.Channel> = buildList {
for (i in 0..maxOf(old.lastIndex, new.lastIndex)) {
if (old.getOrNull(i) != new.getOrNull(i)) add(channel {
if (old.getOrNull(i) != new.getOrNull(i)) {
add(
channel {
role = when (i) {
0 -> ChannelProtos.Channel.Role.PRIMARY
in 1..new.lastIndex -> ChannelProtos.Channel.Role.SECONDARY
@@ -131,10 +135,11 @@ internal fun getChannelList(
}
index = i
settings = new.getOrNull(i) ?: channelSettings { }
})
}
)
}
}
}
data class NodesUiState(
val sort: NodeSortOption = NodeSortOption.LAST_HEARD,
val filter: String = "",
@@ -170,10 +175,16 @@ class UIViewModel @Inject constructor(
private val meshLogRepository: MeshLogRepository,
private val packetRepository: PacketRepository,
private val quickChatActionRepository: QuickChatActionRepository,
private val locationRepository: LocationRepository,
private val preferences: SharedPreferences
) : ViewModel(), Logging {
var actionBarMenu: Menu? = null
private val _title = MutableStateFlow("")
val title: StateFlow<String> = _title.asStateFlow()
fun setTitle(title: String) {
_title.value = title
}
val receivingLocationUpdates: StateFlow<Boolean> get() = locationRepository.receivingLocationUpdates
val meshService: IMeshService? get() = radioConfigRepository.meshService
val bondedAddress get() = radioInterfaceService.getBondedDeviceAddress()
@@ -265,12 +276,15 @@ class UIViewModel @Inject constructor(
fun getNode(userId: String?) = nodeDB.getNode(userId ?: DataPacket.ID_BROADCAST)
fun getUser(userId: String?) = nodeDB.getUser(userId ?: DataPacket.ID_BROADCAST)
private val _snackbarText = MutableLiveData<Any?>(null)
val snackbarText: LiveData<Any?> get() = _snackbarText
val snackbarState = SnackbarHostState()
fun showSnackbar(text: Int) = showSnackbar(app.getString(text))
fun showSnackbar(text: String) = viewModelScope.launch {
snackbarState.showSnackbar(text)
}
init {
radioConfigRepository.errorMessage.filterNotNull().onEach {
_snackbarText.value = it
showSnackbar(it)
radioConfigRepository.clearErrorMessage()
}.launchIn(viewModelScope)
@@ -468,17 +482,6 @@ class UIViewModel @Inject constructor(
_requestChannelSet.value = null
}
fun showSnackbar(resString: Any) {
_snackbarText.value = resString
}
/**
* Called immediately after activity observes [snackbarText]
*/
fun clearSnackbarText() {
_snackbarText.value = null
}
var txEnabled: Boolean
get() = config.lora.txEnabled
set(value) {
@@ -710,13 +713,6 @@ class UIViewModel @Inject constructor(
radioConfigRepository.clearTracerouteResponse()
}
private val _currentTab = MutableLiveData(0)
val currentTab: LiveData<Int> get() = _currentTab
fun setCurrentTab(tab: Int) {
_currentTab.value = tab
}
fun setNodeFilterText(text: String) {
nodeFilterText.value = text
}

View File

@@ -15,109 +15,306 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.navigation
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
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.res.stringResource
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navDeepLink
import androidx.navigation.toRoute
import com.geeksville.mesh.R
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.ui.ScreenFragment
import com.geeksville.mesh.ui.components.BaseScaffold
import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel
import com.geeksville.mesh.ui.theme.AppTheme
import dagger.hilt.android.AndroidEntryPoint
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.ChannelScreen
import com.geeksville.mesh.ui.ContactsScreen
import com.geeksville.mesh.ui.DebugScreen
import com.geeksville.mesh.ui.NodeScreen
import com.geeksville.mesh.ui.QuickChatScreen
import com.geeksville.mesh.ui.SettingsScreen
import com.geeksville.mesh.ui.ShareScreen
import com.geeksville.mesh.ui.TopLevelDestination.Companion.isTopLevel
import com.geeksville.mesh.ui.map.MapView
import com.geeksville.mesh.ui.message.MessageScreen
import kotlinx.serialization.Serializable
internal fun FragmentManager.navigateToNavGraph(
destNum: Int? = null,
startDestination: String = "RadioConfig",
) {
val radioConfigFragment = NavGraphFragment().apply {
arguments = bundleOf("destNum" to destNum, "startDestination" to startDestination)
}
beginTransaction()
.replace(R.id.mainActivityLayout, radioConfigFragment)
.addToBackStack(null)
.commit()
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),
}
@AndroidEntryPoint
class NavGraphFragment : ScreenFragment("NavGraph"), Logging {
const val DEEP_LINK_BASE_URI = "meshtastic://meshtastic"
private val model: RadioConfigViewModel by viewModels()
@Serializable
sealed interface Graph : Route {
@Serializable
data class NodeDetailGraph(val destNum: Int) : Graph
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
@Suppress("DEPRECATION")
val destNum = arguments?.getSerializable("destNum") as? Int
val startDestination: Any = when (arguments?.getString("startDestination")) {
"NodeDetails" -> Route.NodeDetail(destNum!!)
else -> Route.RadioConfig(destNum)
@Serializable
data class RadioConfigGraph(val destNum: Int? = null) : Graph
}
@Serializable
sealed interface Route {
@Serializable
data object Contacts : Route
@Serializable
data object Nodes : Route
@Serializable
data object Map : Route
@Serializable
data object Channels : Route
@Serializable
data object Settings : Route
@Serializable
data object DebugPanel : Route
@Serializable
data class Messages(val contactKey: String, val message: String = "") : Route
@Serializable
data object QuickChat : Route
@Serializable
data class Share(val message: String) : Route
@Serializable
data class RadioConfig(val destNum: Int? = null) : Route
@Serializable
data object User : Route
@Serializable
data object ChannelConfig : Route
@Serializable
data object Device : Route
@Serializable
data object Position : Route
@Serializable
data object Power : Route
@Serializable
data object Network : Route
@Serializable
data object Display : Route
@Serializable
data object LoRa : Route
@Serializable
data object Bluetooth : Route
@Serializable
data object Security : Route
@Serializable
data object MQTT : Route
@Serializable
data object Serial : Route
@Serializable
data object ExtNotification : Route
@Serializable
data object StoreForward : Route
@Serializable
data object RangeTest : Route
@Serializable
data object Telemetry : Route
@Serializable
data object CannedMessage : Route
@Serializable
data object Audio : Route
@Serializable
data object RemoteHardware : Route
@Serializable
data object NeighborInfo : Route
@Serializable
data object AmbientLighting : Route
@Serializable
data object DetectionSensor : Route
@Serializable
data object Paxcounter : Route
@Serializable
data class NodeDetail(val destNum: Int? = null) : Route
@Serializable
data object DeviceMetrics : Route
@Serializable
data object NodeMap : Route
@Serializable
data object PositionLog : Route
@Serializable
data object EnvironmentMetrics : Route
@Serializable
data object SignalMetrics : Route
@Serializable
data object PowerMetrics : Route
@Serializable
data object TracerouteLog : Route
}
fun NavDestination.isConfigRoute(): Boolean {
return ConfigRoute.entries.any { hasRoute(it.route::class) } ||
ModuleRoute.entries.any { hasRoute(it.route::class) }
}
fun NavDestination.isNodeDetailRoute(): Boolean {
return NodeDetailRoute.entries.any { hasRoute(it.route::class) }
}
fun NavDestination.showLongNameTitle(): Boolean {
return !this.isTopLevel() && (
this.hasRoute<Route.Messages>() ||
this.hasRoute<Route.RadioConfig>() ||
this.hasRoute<Route.NodeDetail>() ||
this.isConfigRoute() ||
this.isNodeDetailRoute()
)
}
@Suppress("LongMethod")
@Composable
fun NavGraph(
modifier: Modifier = Modifier,
uIViewModel: UIViewModel = hiltViewModel(),
navController: NavHostController = rememberNavController(),
) {
NavHost(
navController = navController,
startDestination = if (uIViewModel.bondedAddress.isNullOrBlank()) {
Route.Settings
} else {
Route.Contacts
},
modifier = modifier,
) {
composable<Route.Contacts> {
ContactsScreen(
uIViewModel,
onNavigate = { navController.navigate(Route.Messages(it)) }
)
}
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val node by model.destNode.collectAsStateWithLifecycle()
AppTheme {
val navController: NavHostController = rememberNavController()
BaseScaffold(
title = node?.user?.longName
?: stringResource(R.string.unknown_username),
canNavigateBack = true,
navigateUp = {
if (navController.previousBackStackEntry != null) {
navController.navigateUp()
} else {
parentFragmentManager.popBackStack()
}
},
) {
NavGraph(
navController = navController,
startDestination = startDestination,
)
composable<Route.Nodes> {
NodeScreen(
model = uIViewModel,
navigateToMessages = { navController.navigate(Route.Messages(it)) },
navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) },
)
}
composable<Route.Map> {
MapView(uIViewModel)
}
composable<Route.Channels> {
ChannelScreen(uIViewModel)
}
composable<Route.Settings>(
deepLinks = listOf(
navDeepLink {
uriPattern = "$DEEP_LINK_BASE_URI/settings"
action = "android.intent.action.VIEW"
}
)
) { backStackEntry ->
SettingsScreen {
navController.navigate(Route.RadioConfig()) {
popUpTo(Route.Settings) {
inclusive = false
}
}
}
}
composable<Route.DebugPanel> {
DebugScreen()
}
composable<Route.Messages>(
deepLinks = listOf(
navDeepLink {
uriPattern = "$DEEP_LINK_BASE_URI/messages/{contactKey}?message={message}"
action = "android.intent.action.VIEW"
},
)
) { backStackEntry ->
val args = backStackEntry.toRoute<Route.Messages>()
MessageScreen(
contactKey = args.contactKey,
message = args.message,
viewModel = uIViewModel,
navigateToMessages = { navController.navigate(Route.Messages(it)) },
navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) },
onNavigateBack = navController::navigateUp
)
}
composable<Route.QuickChat> {
QuickChatScreen()
}
nodeDetailGraph(navController, uIViewModel)
radioConfigGraph(navController, uIViewModel)
composable<Route.Share>(
deepLinks = listOf(
navDeepLink {
uriPattern = "$DEEP_LINK_BASE_URI/share?message={message}"
action = "android.intent.action.VIEW"
}
)
) { backStackEntry ->
val message = backStackEntry.toRoute<Route.Share>().message
ShareScreen(uIViewModel) {
navController.navigate(Route.Messages(it, message)) {
popUpTo<Route.Share> { inclusive = true }
}
}
}
}
}
@Composable
fun NavGraph(
navController: NavHostController = rememberNavController(),
startDestination: Any,
modifier: Modifier = Modifier,
) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier,
) {
addNodDetailSection(navController)
addRadioConfigSection(navController)
shareScreen(
navigateUp = navController::navigateUp,
onConfirm = navController::navigateToSharedMessage,
)
}
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.navigation
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CellTower
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.PermScanWifi
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Router
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import com.geeksville.mesh.R
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.NodeDetailScreen
import com.geeksville.mesh.ui.components.DeviceMetricsScreen
import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen
import com.geeksville.mesh.ui.components.NodeMapScreen
import com.geeksville.mesh.ui.components.PositionLogScreen
import com.geeksville.mesh.ui.components.PowerMetricsScreen
import com.geeksville.mesh.ui.components.SignalMetricsScreen
import com.geeksville.mesh.ui.components.TracerouteLogScreen
fun NavGraphBuilder.nodeDetailGraph(
navController: NavHostController,
uiViewModel: UIViewModel
) {
navigation<Graph.NodeDetailGraph>(
startDestination = Route.NodeDetail(),
) {
composable<Route.NodeDetail> { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<Graph.NodeDetailGraph>()
}
NodeDetailScreen(uiViewModel = uiViewModel, viewModel = hiltViewModel(parentEntry)) {
navController.navigate(it) {
popUpTo(Route.NodeDetail()) {
inclusive = false
}
}
}
}
NodeDetailRoute.entries.forEach { nodeDetailRoute ->
composable(nodeDetailRoute.route::class) { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<Graph.NodeDetailGraph>()
}
when (nodeDetailRoute) {
NodeDetailRoute.DEVICE -> DeviceMetricsScreen(hiltViewModel(parentEntry))
NodeDetailRoute.NODE_MAP -> NodeMapScreen(hiltViewModel(parentEntry))
NodeDetailRoute.POSITION_LOG -> PositionLogScreen(hiltViewModel(parentEntry))
NodeDetailRoute.ENVIRONMENT -> EnvironmentMetricsScreen(hiltViewModel(parentEntry))
NodeDetailRoute.SIGNAL -> SignalMetricsScreen(hiltViewModel(parentEntry))
NodeDetailRoute.TRACEROUTE -> TracerouteLogScreen(hiltViewModel(parentEntry))
NodeDetailRoute.POWER -> PowerMetricsScreen(hiltViewModel(parentEntry))
}
}
}
}
}
enum class NodeDetailRoute(
@StringRes val title: Int,
val route: Route,
val icon: ImageVector?,
) {
DEVICE(R.string.device, Route.DeviceMetrics, Icons.Default.Router),
NODE_MAP(R.string.node_map, Route.NodeMap, Icons.Default.LocationOn),
POSITION_LOG(R.string.position_log, Route.PositionLog, Icons.Default.LocationOn),
ENVIRONMENT(R.string.environment, Route.EnvironmentMetrics, Icons.Default.LightMode),
SIGNAL(R.string.signal, Route.SignalMetrics, Icons.Default.CellTower),
TRACEROUTE(R.string.traceroute, Route.TracerouteLog, Icons.Default.PermScanWifi),
POWER(R.string.power, Route.PowerMetrics, Icons.Default.Power),
}

View File

@@ -1,83 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.navigation
import androidx.compose.runtime.remember
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.NodeDetailScreen
import com.geeksville.mesh.ui.components.DeviceMetricsScreen
import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen
import com.geeksville.mesh.ui.components.NodeMapScreen
import com.geeksville.mesh.ui.components.PositionLogScreen
import com.geeksville.mesh.ui.components.PowerMetricsScreen
import com.geeksville.mesh.ui.components.SignalMetricsScreen
import com.geeksville.mesh.ui.components.TracerouteLogScreen
fun NavGraphBuilder.addNodDetailSection(navController: NavController) {
composable<Route.NodeDetail> {
NodeDetailScreen(
onNavigate = navController::navigate,
)
}
composable<Route.DeviceMetrics> {
val parentEntry = remember { navController.getBackStackEntry<Route.NodeDetail>() }
DeviceMetricsScreen(
viewModel = hiltViewModel<MetricsViewModel>(parentEntry),
)
}
composable<Route.NodeMap> {
val parentEntry = remember { navController.getBackStackEntry<Route.NodeDetail>() }
NodeMapScreen(
viewModel = hiltViewModel<MetricsViewModel>(parentEntry),
)
}
composable<Route.PositionLog> {
val parentEntry = remember { navController.getBackStackEntry<Route.NodeDetail>() }
PositionLogScreen(
viewModel = hiltViewModel<MetricsViewModel>(parentEntry),
)
}
composable<Route.EnvironmentMetrics> {
val parentEntry = remember { navController.getBackStackEntry<Route.NodeDetail>() }
EnvironmentMetricsScreen(
viewModel = hiltViewModel<MetricsViewModel>(parentEntry),
)
}
composable<Route.SignalMetrics> {
val parentEntry = remember { navController.getBackStackEntry<Route.NodeDetail>() }
SignalMetricsScreen(
viewModel = hiltViewModel<MetricsViewModel>(parentEntry),
)
}
composable<Route.PowerMetrics> {
val parentEntry = remember { navController.getBackStackEntry<Route.NodeDetail>() }
PowerMetricsScreen(
viewModel = hiltViewModel<MetricsViewModel>(parentEntry),
)
}
composable<Route.TracerouteLog> {
val parentEntry = remember { navController.getBackStackEntry<Route.NodeDetail>() }
TracerouteLogScreen(
viewModel = hiltViewModel<MetricsViewModel>(parentEntry),
)
}
}

View File

@@ -0,0 +1,256 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.navigation
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.List
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.CellTower
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.DataUsage
import androidx.compose.material.icons.filled.DisplaySettings
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.LocationOn
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.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.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.material.icons.filled.Wifi
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import com.geeksville.mesh.MeshProtos.DeviceMetadata
import com.geeksville.mesh.R
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.radioconfig.RadioConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.AmbientLightingConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.AudioConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.BluetoothConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.CannedMessageConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.ChannelConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.DetectionSensorConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.DeviceConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.DisplayConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.ExternalNotificationConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.LoRaConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.MQTTConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.NeighborInfoConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.NetworkConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.PaxcounterConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.PositionConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.PowerConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.RangeTestConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.RemoteHardwareConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.SecurityConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.SerialConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.StoreForwardConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.TelemetryConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.UserConfigScreen
fun NavGraphBuilder.radioConfigGraph(navController: NavHostController, uiViewModel: UIViewModel) {
navigation<Graph.RadioConfigGraph>(
startDestination = Route.RadioConfig(),
) {
composable<Route.RadioConfig> { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<Graph.RadioConfigGraph>()
}
RadioConfigScreen(
uiViewModel = uiViewModel,
viewModel = hiltViewModel(parentEntry)
) {
navController.navigate(it) {
popUpTo(Route.RadioConfig()) {
inclusive = false
}
}
}
}
configRoutes(navController)
moduleRoutes(navController)
}
}
private fun NavGraphBuilder.configRoutes(
navController: NavHostController,
) {
ConfigRoute.entries.forEach { configRoute ->
composable(configRoute.route::class) { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<Graph.RadioConfigGraph>()
}
when (configRoute) {
ConfigRoute.USER -> UserConfigScreen(hiltViewModel(parentEntry))
ConfigRoute.CHANNELS -> ChannelConfigScreen(hiltViewModel(parentEntry))
ConfigRoute.DEVICE -> DeviceConfigScreen(hiltViewModel(parentEntry))
ConfigRoute.POSITION -> PositionConfigScreen(hiltViewModel(parentEntry))
ConfigRoute.POWER -> PowerConfigScreen(hiltViewModel(parentEntry))
ConfigRoute.NETWORK -> NetworkConfigScreen(hiltViewModel(parentEntry))
ConfigRoute.DISPLAY -> DisplayConfigScreen(hiltViewModel(parentEntry))
ConfigRoute.LORA -> LoRaConfigScreen(hiltViewModel(parentEntry))
ConfigRoute.BLUETOOTH -> BluetoothConfigScreen(hiltViewModel(parentEntry))
ConfigRoute.SECURITY -> SecurityConfigScreen(hiltViewModel(parentEntry))
}
}
}
}
@Suppress("CyclomaticComplexMethod")
private fun NavGraphBuilder.moduleRoutes(
navController: NavHostController,
) {
ModuleRoute.entries.forEach { moduleRoute ->
composable(moduleRoute.route::class) { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<Graph.RadioConfigGraph>()
}
when (moduleRoute) {
ModuleRoute.MQTT -> MQTTConfigScreen(hiltViewModel(parentEntry))
ModuleRoute.SERIAL -> SerialConfigScreen(hiltViewModel(parentEntry))
ModuleRoute.EXT_NOTIFICATION -> ExternalNotificationConfigScreen(
hiltViewModel(parentEntry)
)
ModuleRoute.STORE_FORWARD -> StoreForwardConfigScreen(hiltViewModel(parentEntry))
ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(hiltViewModel(parentEntry))
ModuleRoute.TELEMETRY -> TelemetryConfigScreen(hiltViewModel(parentEntry))
ModuleRoute.CANNED_MESSAGE -> CannedMessageConfigScreen(
hiltViewModel(parentEntry)
)
ModuleRoute.AUDIO -> AudioConfigScreen(hiltViewModel(parentEntry))
ModuleRoute.REMOTE_HARDWARE -> RemoteHardwareConfigScreen(
hiltViewModel(parentEntry)
)
ModuleRoute.NEIGHBOR_INFO -> NeighborInfoConfigScreen(hiltViewModel(parentEntry))
ModuleRoute.AMBIENT_LIGHTING -> AmbientLightingConfigScreen(
hiltViewModel(parentEntry)
)
ModuleRoute.DETECTION_SENSOR -> DetectionSensorConfigScreen(
hiltViewModel(parentEntry)
)
ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(hiltViewModel(parentEntry))
}
}
}
}
// Config (type = AdminProtos.AdminMessage.ConfigType)
@Suppress("MagicNumber")
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<ConfigRoute> = entries.filter {
when {
metadata == null -> true
it == BLUETOOTH -> metadata.hasBluetooth
it == NETWORK -> metadata.hasWifi || metadata.hasEthernet
else -> true // Include all other routes by default
}
}
}
}
// ModuleConfig (type = AdminProtos.AdminMessage.ModuleConfigType)
@Suppress("MagicNumber")
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<ModuleRoute> = entries.filter {
when (metadata) {
null -> true
else -> metadata.excludedModules and it.bitfield == 0
}
}
}
}

View File

@@ -1,196 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.navigation
import androidx.compose.runtime.remember
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.geeksville.mesh.ui.radioconfig.RadioConfigScreen
import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel
import com.geeksville.mesh.ui.radioconfig.components.AmbientLightingConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.AudioConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.BluetoothConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.CannedMessageConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.ChannelConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.DetectionSensorConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.DeviceConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.DisplayConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.ExternalNotificationConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.LoRaConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.MQTTConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.NeighborInfoConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.NetworkConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.PaxcounterConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.PositionConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.PowerConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.RangeTestConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.RemoteHardwareConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.SecurityConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.SerialConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.StoreForwardConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.TelemetryConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.UserConfigScreen
@Suppress("LongMethod")
fun NavGraphBuilder.addRadioConfigSection(navController: NavController) {
composable<Route.RadioConfig> {
RadioConfigScreen(
onNavigate = navController::navigate,
)
}
composable<Route.User> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
UserConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.ChannelConfig> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
ChannelConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.Device> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
DeviceConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.Position> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
PositionConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.Power> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
PowerConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.Network> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
NetworkConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.Display> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
DisplayConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.LoRa> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
LoRaConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.Bluetooth> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
BluetoothConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.Security> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
SecurityConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.MQTT> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
MQTTConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.Serial> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
SerialConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.ExtNotification> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
ExternalNotificationConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.StoreForward> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
StoreForwardConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.RangeTest> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
RangeTestConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.Telemetry> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
TelemetryConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.CannedMessage> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
CannedMessageConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.Audio> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
AudioConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.RemoteHardware> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
RemoteHardwareConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.NeighborInfo> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
NeighborInfoConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.AmbientLighting> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
AmbientLightingConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.DetectionSensor> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
DetectionSensorConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
composable<Route.Paxcounter> {
val parentEntry = remember { navController.getBackStackEntry<Route.RadioConfig>() }
PaxcounterConfigScreen(
viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry),
)
}
}

View File

@@ -1,71 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.navigation
import kotlinx.serialization.Serializable
sealed interface Route {
@Serializable data object Contacts : Route
@Serializable data object Nodes : Route
@Serializable data object Map : Route
@Serializable data object Channels : Route
@Serializable data object Settings : Route
@Serializable data object DebugPanel : Route
@Serializable
data class Messages(val contactKey: String, val message: String = "") : Route
@Serializable data object QuickChat : Route
@Serializable
data class Share(val message: String) : Route
@Serializable
data class RadioConfig(val destNum: Int? = null) : Route
@Serializable data object User : Route
@Serializable data object ChannelConfig : Route
@Serializable data object Device : Route
@Serializable data object Position : Route
@Serializable data object Power : Route
@Serializable data object Network : Route
@Serializable data object Display : Route
@Serializable data object LoRa : Route
@Serializable data object Bluetooth : Route
@Serializable data object Security : Route
@Serializable data object MQTT : Route
@Serializable data object Serial : Route
@Serializable data object ExtNotification : Route
@Serializable data object StoreForward : Route
@Serializable data object RangeTest : Route
@Serializable data object Telemetry : Route
@Serializable data object CannedMessage : Route
@Serializable data object Audio : Route
@Serializable data object RemoteHardware : Route
@Serializable data object NeighborInfo : Route
@Serializable data object AmbientLighting : Route
@Serializable data object DetectionSensor : Route
@Serializable data object Paxcounter : Route
@Serializable data class NodeDetail(val destNum: Int) : Route
@Serializable data object DeviceMetrics : Route
@Serializable data object NodeMap : Route
@Serializable data object PositionLog : Route
@Serializable data object EnvironmentMetrics : Route
@Serializable data object SignalMetrics : Route
@Serializable data object PowerMetrics : Route
@Serializable data object TracerouteLog : Route
}

View File

@@ -1,42 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.geeksville.mesh.ui.ShareScreen
fun NavController.navigateToSharedMessage(contactKey: String, message: String) {
navigate(Route.Messages(contactKey, message)) {
popUpTo<Route.Share> { inclusive = true }
}
}
fun NavGraphBuilder.shareScreen(
navigateUp: () -> Unit,
onConfirm: (String, String) -> Unit
) {
composable<Route.Share> { backStackEntry ->
val message = backStackEntry.toRoute<Route.Share>().message
ShareScreen(
navigateUp = navigateUp,
) { contactKey -> onConfirm(contactKey, message) }
}
}

View File

@@ -21,6 +21,7 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.TaskStackBuilder
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
@@ -37,6 +38,7 @@ import com.geeksville.mesh.R
import com.geeksville.mesh.TelemetryProtos.LocalStats
import com.geeksville.mesh.android.notificationManager
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.navigation.DEEP_LINK_BASE_URI
import com.geeksville.mesh.util.formatUptime
@Suppress("TooManyFunctions")
@@ -48,9 +50,6 @@ class MeshServiceNotifications(
companion object {
private const val FIFTEEN_MINUTES_IN_MILLIS = 15L * 60 * 1000
const val OPEN_MESSAGE_ACTION = "com.geeksville.mesh.OPEN_MESSAGE_ACTION"
const val OPEN_MESSAGE_EXTRA_CONTACT_KEY =
"com.geeksville.mesh.OPEN_MESSAGE_EXTRA_CONTACT_KEY"
const val MAX_BATTERY_LEVEL = 100
}
@@ -208,7 +207,8 @@ class MeshServiceNotifications(
private fun createLowBatteryRemoteNotificationChannel(): String {
val channelId = "low_battery_remote"
if (notificationManager.getNotificationChannel(channelId) == null) {
val channelName = context.getString(R.string.meshtastic_low_battery_temporary_remote_notifications)
val channelName =
context.getString(R.string.meshtastic_low_battery_temporary_remote_notifications)
val channel = NotificationChannel(
channelId,
channelName,
@@ -293,9 +293,9 @@ class MeshServiceNotifications(
"air_util_tx" -> "AirUtilTX: %.2f%%".format(v)
else ->
"${
k.name.replace('_', ' ').split(" ")
.joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } }
}: $v"
k.name.replace('_', ' ').split(" ")
.joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } }
}: $v"
}
}?.joinToString("\n") ?: "No Local Stats"
@@ -354,18 +354,20 @@ class MeshServiceNotifications(
)
}
private fun openMessageIntent(contactKey: String): PendingIntent {
val intent = Intent(context, MainActivity::class.java)
intent.action = OPEN_MESSAGE_ACTION
intent.putExtra(OPEN_MESSAGE_EXTRA_CONTACT_KEY, contactKey)
val pendingIntent = PendingIntent.getActivity(
private fun createOpenMessageIntent(contactKey: String): PendingIntent {
val deepLink = "$DEEP_LINK_BASE_URI/messages/$contactKey"
val startActivityIntent = Intent(
Intent.ACTION_VIEW,
deepLink.toUri(),
context,
0,
intent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
MainActivity::class.java
)
return pendingIntent
val resultPendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(startActivityIntent)
getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE)
}
return resultPendingIntent!!
}
private fun commonBuilder(channel: String): NotificationCompat.Builder {
@@ -437,7 +439,7 @@ class MeshServiceNotifications(
}
val person = Person.Builder().setName(name).build()
with(messageNotificationBuilder) {
setContentIntent(openMessageIntent(contactKey))
setContentIntent(createOpenMessageIntent(contactKey))
priority = NotificationCompat.PRIORITY_DEFAULT
setCategory(Notification.CATEGORY_MESSAGE)
setAutoCancel(true)
@@ -462,7 +464,7 @@ class MeshServiceNotifications(
}
val person = Person.Builder().setName(name).build()
with(alertNotificationBuilder) {
setContentIntent(openMessageIntent(contactKey))
setContentIntent(createOpenMessageIntent(contactKey))
priority = NotificationCompat.PRIORITY_HIGH
setCategory(Notification.CATEGORY_ALARM)
setAutoCancel(true)

View File

@@ -17,12 +17,9 @@
package com.geeksville.mesh.ui
import android.content.ClipData
import android.net.Uri
import android.os.Bundle
import android.os.RemoteException
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.compose.animation.core.animateDpAsState
@@ -49,12 +46,12 @@ import androidx.compose.material.icons.twotone.Check
import androidx.compose.material.icons.twotone.Close
import androidx.compose.material.icons.twotone.ContentCopy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -64,20 +61,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
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.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
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.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
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.AppOnlyProtos.ChannelSet
@@ -88,7 +82,6 @@ import com.geeksville.mesh.analytics.DataPair
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.BuildUtils.errormsg
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.android.getCameraPermissions
import com.geeksville.mesh.android.hasCameraPermission
import com.geeksville.mesh.channelSet
@@ -110,36 +103,11 @@ import com.geeksville.mesh.ui.components.rememberDragDropState
import com.geeksville.mesh.ui.radioconfig.components.ChannelCard
import com.geeksville.mesh.ui.radioconfig.components.ChannelSelection
import com.geeksville.mesh.ui.radioconfig.components.EditChannelDialog
import com.geeksville.mesh.ui.theme.AppTheme
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class ChannelFragment : ScreenFragment("Channel"), 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 {
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colors.onSurface
) {
ChannelScreen(model)
}
}
}
}
}
}
import kotlinx.coroutines.launch
import androidx.core.net.toUri
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
@@ -174,7 +142,7 @@ fun ChannelScreen(
val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
if (result.contents != null) {
viewModel.requestChannelUrl(Uri.parse(result.contents))
viewModel.requestChannelUrl(result.contents.toUri())
}
}
@@ -296,8 +264,11 @@ fun ChannelScreen(
modemPresetName = modemPresetName,
onAddClick = {
with(channelSet) {
if (settingsCount > index) channelSet = copy { settings[index] = it }
else channelSet = copy { settings.add(it) }
if (settingsCount > index) {
channelSet = copy { settings[index] = it }
} else {
channelSet = copy { settings.add(it) }
}
}
showEditChannelDialog = null
},
@@ -366,7 +337,8 @@ fun ChannelScreen(
}
item {
DropDownPreference(title = stringResource(id = R.string.channel_options),
DropDownPreference(
title = stringResource(id = R.string.channel_options),
enabled = enabled,
items = ChannelOption.entries
.map { it.modemPreset to stringResource(it.configRes) },
@@ -374,7 +346,8 @@ fun ChannelScreen(
onItemSelected = {
val lora = channelSet.loraConfig.copy { modemPreset = it }
channelSet = channelSet.copy { loraConfig = lora }
})
}
)
}
item {
@@ -389,7 +362,8 @@ fun ChannelScreen(
onSaveClicked = {
focusManager.clearFocus()
sendButton()
})
}
)
} else {
PreferenceFooter(
enabled = enabled,
@@ -402,7 +376,8 @@ fun ChannelScreen(
onPositiveClicked = {
focusManager.clearFocus()
if (context.hasCameraPermission()) zxingScan() else requestPermissionAndScan()
})
}
)
}
}
}
@@ -417,7 +392,8 @@ private fun EditChannelUrl(
onConfirm: (Uri) -> Unit
) {
val focusManager = LocalFocusManager.current
val clipboardManager = LocalClipboardManager.current
val clipboardManager = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
var valueState by remember(channelUrl) { mutableStateOf(channelUrl) }
var isError by remember { mutableStateOf(false) }
@@ -433,7 +409,7 @@ private fun EditChannelUrl(
value = valueState.toString(),
onValueChange = {
isError = runCatching {
valueState = Uri.parse(it)
valueState = it.toUri()
valueState.toChannelSet()
}.isFailure
},
@@ -442,6 +418,7 @@ private fun EditChannelUrl(
label = { Text(stringResource(R.string.url)) },
isError = isError,
trailingIcon = {
val label = stringResource(R.string.url)
val isUrlEqual = valueState == channelUrl
IconButton(onClick = {
when {
@@ -460,7 +437,16 @@ private fun EditChannelUrl(
GeeksvilleApplication.analytics.track(
"share", DataPair("content_type", "channel")
)
clipboardManager.setText(AnnotatedString(valueState.toString()))
coroutineScope.launch {
clipboardManager.setClipEntry(
ClipEntry(
ClipData.newPlainText(
label,
valueState.toString()
)
)
)
}
}
}
}) {

View File

@@ -36,13 +36,13 @@ import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.VolumeOff
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -130,7 +130,7 @@ fun ContactItem(
)
AnimatedVisibility(visible = isMuted) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_twotone_volume_off_24),
imageVector = Icons.AutoMirrored.TwoTone.VolumeOff,
contentDescription = null,
)
}

View File

@@ -0,0 +1,360 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.RadioButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.VolumeMute
import androidx.compose.material.icons.automirrored.twotone.VolumeUp
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.R
import com.geeksville.mesh.model.Contact
import com.geeksville.mesh.model.UIViewModel
import java.util.concurrent.TimeUnit
@Composable
fun ContactsScreen(
uiViewModel: UIViewModel = hiltViewModel(),
onNavigate: (String) -> Unit = {}
) {
var showMuteDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
// State for managing selected contacts
val selectedContactKeys = remember { mutableStateListOf<String>() }
val isSelectionModeActive by remember { derivedStateOf { selectedContactKeys.isNotEmpty() } }
// State for contacts list
val contacts by uiViewModel.contactList.collectAsStateWithLifecycle()
// Derived state for selected contacts and count
val selectedContacts = remember(contacts, selectedContactKeys) {
contacts.filter { it.contactKey in selectedContactKeys }
}
val selectedCount = remember(selectedContacts) { selectedContacts.sumOf { it.messageCount } }
val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } }
// Callback functions for item interaction
val onContactClick: (Contact) -> Unit = { contact ->
if (isSelectionModeActive) {
// If in selection mode, toggle selection
if (selectedContactKeys.contains(contact.contactKey)) {
selectedContactKeys.remove(contact.contactKey)
} else {
selectedContactKeys.add(contact.contactKey)
}
} else {
// If not in selection mode, navigate to messages
onNavigate(contact.contactKey)
}
}
val onContactLongClick: (Contact) -> Unit = { contact ->
// Enter selection mode and select the item on long press
if (!isSelectionModeActive) {
selectedContactKeys.add(contact.contactKey)
} else {
// If already in selection mode, toggle selection
if (selectedContactKeys.contains(contact.contactKey)) {
selectedContactKeys.remove(contact.contactKey)
} else {
selectedContactKeys.add(contact.contactKey)
}
}
}
Scaffold(
topBar = {
if (isSelectionModeActive) {
// Display selection toolbar when in selection mode
SelectionToolbar(
selectedCount = selectedContactKeys.size,
onCloseSelection = { selectedContactKeys.clear() },
onMuteSelected = {
showMuteDialog = true
},
onDeleteSelected = {
showDeleteDialog = true
},
onSelectAll = {
selectedContactKeys.clear()
selectedContactKeys.addAll(contacts.map { it.contactKey })
},
isAllMuted = isAllMuted // Pass the derived state
)
}
}
) { paddingValues ->
ContactListView(
contacts = contacts,
selectedList = selectedContactKeys,
onClick = onContactClick,
onLongClick = onContactLongClick,
contentPadding = paddingValues
)
}
DeleteConfirmationDialog(
showDialog = showDeleteDialog,
selectedCount = selectedCount,
onDismiss = { showDeleteDialog = false },
onConfirm = {
showDeleteDialog = false
uiViewModel.deleteContacts(selectedContactKeys.toList())
selectedContactKeys.clear()
}
)
MuteNotificationsDialog(
showDialog = showMuteDialog,
onDismiss = { showMuteDialog = false },
onConfirm = { muteUntil ->
showMuteDialog = false
uiViewModel.setMuteUntil(selectedContactKeys.toList(), muteUntil)
selectedContactKeys.clear()
}
)
}
@OptIn(ExperimentalMaterialApi::class) // Required for AlertDialog in some cases, though often not strictly necessary now
@Composable
fun MuteNotificationsDialog(
showDialog: Boolean,
onDismiss: () -> Unit,
onConfirm: (Long) -> Unit // Lambda to handle the confirmed mute duration
) {
if (showDialog) {
// Options for mute duration
val muteOptions = remember {
listOf(
R.string.unmute to 0L,
R.string.mute_8_hours to TimeUnit.HOURS.toMillis(8),
R.string.mute_1_week to TimeUnit.DAYS.toMillis(7),
R.string.mute_always to Long.MAX_VALUE
)
}
// State to hold the selected mute duration index
var selectedOptionIndex by remember { mutableStateOf(2) } // Default to "Always"
AlertDialog(
onDismissRequest = onDismiss, // Dismiss the dialog when clicked outside
title = {
Text(text = stringResource(R.string.mute_notifications))
},
text = {
Column {
muteOptions.forEachIndexed { index, (stringRes, _) ->
val isSelected = index == selectedOptionIndex
val text = stringResource(stringRes)
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { selectedOptionIndex = index }
)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = isSelected,
onClick = { selectedOptionIndex = index }
)
Text(
text = text,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
},
confirmButton = {
Button(
onClick = {
val selectedMuteDuration = muteOptions[selectedOptionIndex].second
onConfirm(selectedMuteDuration)
onDismiss() // Dismiss the dialog after confirming
}
) {
Text(stringResource(R.string.okay))
}
},
dismissButton = {
Button(
onClick = onDismiss // Dismiss the dialog on cancel
) {
Text(stringResource(R.string.cancel))
}
}
)
}
}
@OptIn(ExperimentalMaterialApi::class) // Not strictly needed for simple AlertDialog
@Composable
fun DeleteConfirmationDialog(
showDialog: Boolean,
selectedCount: Int, // Number of items to be deleted
onDismiss: () -> Unit,
onConfirm: () -> Unit // Lambda to handle the delete action
) {
if (showDialog) {
val deleteMessage = pluralStringResource(
id = R.plurals.delete_messages,
count = selectedCount,
formatArgs = arrayOf(selectedCount) // Pass the count as a format argument
)
AlertDialog(
onDismissRequest = onDismiss,
title = {
// Optional: You could add a title here if needed, e.g., "Confirm Deletion"
},
text = {
Text(text = deleteMessage)
},
confirmButton = {
Button(
onClick = {
onConfirm()
onDismiss() // Dismiss the dialog after confirming
}
) {
Text(stringResource(R.string.delete))
}
},
dismissButton = {
Button(
onClick = onDismiss
) {
Text(stringResource(R.string.cancel))
}
},
properties = DialogProperties(
dismissOnClickOutside = true, // Allow dismissing by clicking outside
dismissOnBackPress = true // Allow dismissing with the back button
)
)
}
}
@Composable
fun SelectionToolbar(
selectedCount: Int,
onCloseSelection: () -> Unit,
onMuteSelected: () -> Unit,
onDeleteSelected: () -> Unit,
onSelectAll: () -> Unit,
isAllMuted: Boolean
) {
TopAppBar(
title = { Text(text = "$selectedCount") },
navigationIcon = {
IconButton(onClick = onCloseSelection) {
Icon(Icons.Default.Close, contentDescription = "Close selection")
}
},
actions = {
IconButton(onClick = onMuteSelected) {
Icon(
imageVector = if (isAllMuted) {
Icons.AutoMirrored.TwoTone.VolumeUp
} else {
Icons.AutoMirrored.TwoTone.VolumeMute
},
contentDescription = if (isAllMuted) {
"Unmute selected"
} else {
"Mute selected"
}
)
}
IconButton(onClick = onDeleteSelected) {
Icon(Icons.Default.Delete, contentDescription = "Delete selected")
}
IconButton(onClick = onSelectAll) {
Icon(Icons.Default.SelectAll, contentDescription = "Select all")
}
}
)
}
@Composable
fun ContactListView(
contacts: List<Contact>,
selectedList: List<String>,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
contentPadding: PaddingValues
) {
val haptics = LocalHapticFeedback.current
LazyColumn(
modifier = Modifier
.fillMaxSize(),
contentPadding = contentPadding,
) {
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)
},
)
}
}
}

View File

@@ -1,246 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.R
import com.geeksville.mesh.model.Contact
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.message.navigateToMessages
import com.geeksville.mesh.ui.theme.AppTheme
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import java.util.concurrent.TimeUnit
@AndroidEntryPoint
class ContactsFragment : ScreenFragment("Messages"), Logging {
private val actionModeCallback: ActionModeCallback = ActionModeCallback()
private var actionMode: ActionMode? = null
private val model: UIViewModel by activityViewModels()
private val contacts get() = model.contactList.value
private val selectedList = emptyList<String>().toMutableStateList()
private val selectedContacts get() = contacts.filter { it.contactKey in selectedList }
private val isAllMuted get() = selectedContacts.all { it.isMuted }
private val selectedCount get() = selectedContacts.sumOf { it.messageCount }
private fun onClick(contact: Contact) {
if (actionMode != null) {
onLongClick(contact)
} else {
debug("calling MessagesFragment filter:${contact.contactKey}")
parentFragmentManager.navigateToMessages(contact.contactKey)
}
}
private fun onLongClick(contact: Contact) {
if (actionMode == null) {
actionMode = (activity as AppCompatActivity).startSupportActionMode(actionModeCallback)
}
selectedList.apply {
if (!remove(contact.contactKey)) add(contact.contactKey)
}
if (selectedList.isEmpty()) {
// finish action mode when no items selected
actionMode?.finish()
} else {
actionMode?.invalidate()
}
}
override fun onPause() {
actionMode?.finish()
super.onPause()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val contacts by model.contactList.collectAsStateWithLifecycle()
AppTheme {
ContactListView(
contacts = contacts,
selectedList = selectedList,
onClick = ::onClick,
onLongClick = ::onLongClick,
)
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
actionMode?.finish()
actionMode = null
}
private inner class ActionModeCallback : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.menu_messages, menu)
mode.title = "1"
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.title = selectedList.size.toString()
menu.findItem(R.id.muteButton).setIcon(
if (isAllMuted) {
R.drawable.ic_twotone_volume_up_24
} else {
R.drawable.ic_twotone_volume_off_24
}
)
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
when (item.itemId) {
R.id.muteButton -> if (isAllMuted) {
model.setMuteUntil(selectedList.toList(), 0L)
mode.finish()
} else {
var muteUntil: Long = Long.MAX_VALUE
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.mute_notifications)
.setSingleChoiceItems(
setOf(
R.string.mute_8_hours,
R.string.mute_1_week,
R.string.mute_always,
).map(::getString).toTypedArray(),
2
) { _, which ->
muteUntil = when (which) {
0 -> System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8)
1 -> System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7)
else -> Long.MAX_VALUE // always
}
}
.setPositiveButton(getString(R.string.okay)) { _, _ ->
debug("User clicked muteButton")
model.setMuteUntil(selectedList.toList(), muteUntil)
mode.finish()
}
.setNeutralButton(R.string.cancel) { _, _ ->
}
.show()
}
R.id.deleteButton -> {
val deleteMessagesString = resources.getQuantityString(
R.plurals.delete_messages,
selectedCount,
selectedCount
)
MaterialAlertDialogBuilder(requireContext())
.setMessage(deleteMessagesString)
.setPositiveButton(getString(R.string.delete)) { _, _ ->
debug("User clicked deleteButton")
model.deleteContacts(selectedList.toList())
mode.finish()
}
.setNeutralButton(R.string.cancel) { _, _ ->
}
.show()
}
R.id.selectAllButton -> {
// if all selected -> unselect all
if (selectedList.size == contacts.size) {
selectedList.clear()
mode.finish()
} else {
// else --> 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<Contact>,
selectedList: List<String>,
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)
},
)
}
}
}

View File

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

View File

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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Route.DebugPanel>() ->
Text(
stringResource(id = R.string.debug_panel),
)
currentDestination.hasRoute<Route.QuickChat>() ->
Text(
stringResource(id = R.string.quick_chat),
)
currentDestination.hasRoute<Route.Share>() ->
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<Route.DebugPanel>() ->
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
}
}
}
)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<RegionInfo>(
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<String, BTScanModel.DeviceListEntry>?) {
// 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()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.
*/

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,6 +50,7 @@ import kotlinx.coroutines.flow.debounce
@Suppress("LongMethod")
@Composable
internal fun MessageList(
modifier: Modifier = Modifier,
messages: List<Message>,
selectedIds: MutableState<Set<Long>>,
onUnreadChanged: (Long) -> Unit,
@@ -84,7 +85,7 @@ internal fun MessageList(
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = modifier.fillMaxSize(),
state = listState,
reverseLayout = true,
) {

View File

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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ConfigRoute> = entries.filter {
when {
metadata == null -> true
it == BLUETOOTH -> metadata.hasBluetooth
it == NETWORK -> metadata.hasWifi || metadata.hasEthernet
else -> true // Include all other routes by default
}
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ModuleRoute> = entries.filter {
when (metadata) {
null -> true
else -> metadata.excludedModules and it.bitfield == 0
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (c) 2025 Meshtastic LLC
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/selectedColor" android:state_selected="true"/>
<item android:color="@color/unselectedColor"/>
</selector>

View File

@@ -1,14 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M22,15c0,-1.66 -1.34,-3 -3,-3h-1.5v-0.5C17.5,8.46 15.04,6 12,6c-0.77,0 -1.49,0.17 -2.16,0.46L20.79,17.4c0.73,-0.55 1.21,-1.41 1.21,-2.4zM2,14c0,2.21 1.79,4 4,4h9.73l-8,-8H6c-2.21,0 -4,1.79 -4,4z"
android:fillAlpha=".3"/>
<path
android:fillColor="@android:color/white"
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4c-1.33,0 -2.57,0.36 -3.65,0.97l1.49,1.49C10.51,6.17 11.23,6 12,6c3.04,0 5.5,2.46 5.5,5.5v0.5H19c1.66,0 3,1.34 3,3 0,0.99 -0.48,1.85 -1.21,2.4l1.41,1.41c1.09,-0.92 1.8,-2.27 1.8,-3.81 0,-2.64 -2.05,-4.78 -4.65,-4.96zM3,5.27l2.77,2.77h-0.42C2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h11.73l2,2 1.41,-1.41L4.41,3.86 3,5.27zM7.73,10l8,8H6c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4h1.73z"/>
</vector>

View File

@@ -1,14 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19.21,12.04l-1.53,-0.11 -0.3,-1.5C16.88,7.86 14.62,6 12,6 9.94,6 8.08,7.14 7.12,8.96l-0.5,0.95 -1.07,0.11C3.53,10.24 2,11.95 2,14c0,2.21 1.79,4 4,4h13c1.65,0 3,-1.35 3,-3 0,-1.55 -1.22,-2.86 -2.79,-2.96zM10,17l-3.5,-3.5 1.41,-1.41L10,14.18l4.6,-4.6 1.41,1.41L10,17z"
android:fillAlpha=".3"/>
<path
android:fillColor="@android:color/white"
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM19,18L6,18c-2.21,0 -4,-1.79 -4,-4 0,-2.05 1.53,-3.76 3.56,-3.97l1.07,-0.11 0.5,-0.95C8.08,7.14 9.94,6 12,6c2.62,0 4.88,1.86 5.39,4.43l0.3,1.5 1.53,0.11c1.56,0.1 2.78,1.41 2.78,2.96 0,1.65 -1.35,3 -3,3zM10,14.18l-2.09,-2.09L6.5,13.5 10,17l6.01,-6.01 -1.41,-1.41z"/>
</vector>

View File

@@ -1,5 +0,0 @@
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4V15z"/>
</vector>

View File

@@ -1,5 +0,0 @@
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
</vector>

View File

@@ -1,14 +0,0 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
>
<path
android:fillColor="@android:color/white"
android:pathData="M11.62,1L17.28,6.67L15.16,8.79L13.04,6.67L11.62,8.09L13.95,10.41L12.79,11.58L13.24,12.04C14.17,11.61 15.31,11.77 16.07,12.54L12.54,16.07C11.77,15.31 11.61,14.17 12.04,13.24L11.58,12.79L10.41,13.95L8.09,11.62L6.67,13.04L8.79,15.16L6.67,17.28L1,11.62L3.14,9.5L5.26,11.62L6.67,10.21L3.84,7.38C3.06,6.6 3.06,5.33 3.84,4.55L4.55,3.84C5.33,3.06 6.6,3.06 7.38,3.84L10.21,6.67L11.62,5.26L9.5,3.14L11.62,1M18,14A4,4 0,0 1,14 18V16A2,2 0,0 0,16 14H18M22,14A8,8 0,0 1,14 22V20A6,6 0,0 0,20 14H22Z"
android:fillAlpha="0.5"
/>
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>

View File

@@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19.21,12.04l-1.53,-0.11 -0.3,-1.5C16.88,7.86 14.62,6 12,6 9.94,6 8.08,7.14 7.12,8.96l-0.5,0.95 -1.07,0.11C3.53,10.24 2,11.95 2,14c0,2.21 1.79,4 4,4h13c1.65,0 3,-1.35 3,-3 0,-1.55 -1.22,-2.86 -2.79,-2.96zM13.45,13v3h-2.91v-3L8,13l4,-4 4,4h-2.55z"
android:strokeAlpha="0.3"
android:fillAlpha="0.3"/>
<path
android:fillColor="@android:color/white"
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM19,18H6c-2.21,0 -4,-1.79 -4,-4 0,-2.05 1.53,-3.76 3.56,-3.97l1.07,-0.11 0.5,-0.95C8.08,7.14 9.94,6 12,6c2.62,0 4.88,1.86 5.39,4.43l0.3,1.5 1.53,0.11c1.56,0.1 2.78,1.41 2.78,2.96 0,1.65 -1.35,3 -3,3zM8,13h2.55v3h2.9v-3H16l-4,-4z"/>
</vector>

View File

@@ -1,25 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,4c-4.42,0 -8,3.58 -8,8s3.58,8 8,8s8,-3.58 8,-8S16.42,4 12,4zM8.46,14.45L7.1,13.83c0.28,-0.61 0.41,-1.24 0.4,-1.86c-0.01,-0.63 -0.14,-1.24 -0.4,-1.8l1.36,-0.63c0.35,0.75 0.53,1.56 0.54,2.4C9.01,12.8 8.83,13.64 8.46,14.45zM11.53,16.01l-1.3,-0.74c0.52,-0.92 0.78,-1.98 0.78,-3.15c0,-1.19 -0.27,-2.33 -0.8,-3.4l1.34,-0.67c0.64,1.28 0.96,2.65 0.96,4.07C12.51,13.55 12.18,14.86 11.53,16.01zM14.67,17.33l-1.35,-0.66c0.78,-1.6 1.18,-3.18 1.18,-4.69c0,-1.51 -0.4,-3.07 -1.18,-4.64l1.34,-0.67C15.56,8.45 16,10.23 16,11.98C16,13.72 15.56,15.52 14.67,17.33z"
android:strokeAlpha="0.3"
android:fillAlpha="0.3"/>
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12c0,5.52 4.48,10 10,10s10,-4.48 10,-10C22,6.48 17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8s8,3.58 8,8S16.42,20 12,20z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M7.1,10.18c0.26,0.56 0.39,1.16 0.4,1.8c0.01,0.63 -0.13,1.25 -0.4,1.86l1.37,0.62c0.37,-0.81 0.55,-1.65 0.54,-2.5c-0.01,-0.84 -0.19,-1.65 -0.54,-2.4L7.1,10.18z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M13.33,7.33c0.78,1.57 1.18,3.14 1.18,4.64c0,1.51 -0.4,3.09 -1.18,4.69l1.35,0.66c0.88,-1.81 1.33,-3.61 1.33,-5.35c0,-1.74 -0.45,-3.53 -1.33,-5.31L13.33,7.33z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M10.2,8.72c0.53,1.07 0.8,2.21 0.8,3.4c0,1.17 -0.26,2.23 -0.78,3.15l1.3,0.74c0.65,-1.15 0.98,-2.45 0.98,-3.89c0,-1.42 -0.32,-2.79 -0.96,-4.07L10.2,8.72z"/>
</vector>

View File

@@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M8,9h8v10H8z"
android:strokeAlpha="0.3"
android:fillAlpha="0.3"/>
<path
android:fillColor="@android:color/white"
android:pathData="M15.5,4l-1,-1h-5l-1,1H5v2h14V4zM6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM8,9h8v10H8V9z"/>
</vector>

View File

@@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M6,20h12L18,10L6,10v10zM12,13c1.1,0 2,0.9 2,2s-0.9,2 -2,2 -2,-0.9 -2,-2 0.9,-2 2,-2z"
android:strokeAlpha="0.3"
android:fillAlpha="0.3"/>
<path
android:fillColor="@android:color/white"
android:pathData="M18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM9,6c0,-1.66 1.34,-3 3,-3s3,1.34 3,3v2L9,8L9,6zM18,20L6,20L6,10h12v10zM12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2z"/>
</vector>

View File

@@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M5,18.31l3,-1.16L8,5.45L5,6.46zM16,18.55l3,-1.01L19,5.69l-3,1.17z"
android:strokeAlpha="0.3"
android:fillAlpha="0.3"/>
<path
android:fillColor="@android:color/white"
android:pathData="M20.5,3l-0.16,0.03L15,5.1 9,3 3.36,4.9c-0.21,0.07 -0.36,0.25 -0.36,0.48L3,20.5c0,0.28 0.22,0.5 0.5,0.5l0.16,-0.03L9,18.9l6,2.1 5.64,-1.9c0.21,-0.07 0.36,-0.25 0.36,-0.48L21,3.5c0,-0.28 -0.22,-0.5 -0.5,-0.5zM8,17.15l-3,1.16L5,6.46l3,-1.01v11.7zM14,18.53l-4,-1.4L10,5.47l4,1.4v11.66zM19,17.54l-3,1.01L16,6.86l3,-1.16v11.84z"/>
</vector>

View File

@@ -1,14 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M20,4L4,4v13.17L5.17,16L20,16L20,4zM18,14L6,14v-2h12v2zM18,11L6,11L6,9h12v2zM18,8L6,8L6,6h12v2z"
android:fillAlpha=".3"/>
<path
android:fillColor="@android:color/white"
android:pathData="M20,18c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14zM4,17.17L4,4h16v12L5.17,16L4,17.17zM6,12h12v2L6,14zM6,9h12v2L6,11zM6,6h12v2L6,8z"/>
</vector>

View File

@@ -1,18 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M9,8.5m-1.5,0a1.5,1.5 0,1 1,3 0a1.5,1.5 0,1 1,-3 0"
android:fillAlpha=".3"/>
<path
android:fillColor="@android:color/white"
android:pathData="M4.34,17h9.32c-0.84,-0.58 -2.87,-1.25 -4.66,-1.25s-3.82,0.67 -4.66,1.25z"
android:fillAlpha=".3"/>
<path
android:fillColor="@android:color/white"
android:pathData="M9,12c1.93,0 3.5,-1.57 3.5,-3.5S10.93,5 9,5 5.5,6.57 5.5,8.5 7.07,12 9,12zM9,7c0.83,0 1.5,0.67 1.5,1.5S9.83,10 9,10s-1.5,-0.67 -1.5,-1.5S8.17,7 9,7zM9,13.75c-2.34,0 -7,1.17 -7,3.5L2,19h14v-1.75c0,-2.33 -4.66,-3.5 -7,-3.5zM4.34,17c0.84,-0.58 2.87,-1.25 4.66,-1.25s3.82,0.67 4.66,1.25L4.34,17zM16.04,13.81c1.16,0.84 1.96,1.96 1.96,3.44L18,19h4v-1.75c0,-2.02 -3.5,-3.17 -5.96,-3.44zM15,12c1.93,0 3.5,-1.57 3.5,-3.5S16.93,5 15,5c-0.54,0 -1.04,0.13 -1.5,0.35 0.63,0.89 1,1.98 1,3.15s-0.37,2.26 -1,3.15c0.46,0.22 0.96,0.35 1.5,0.35z"/>
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3,5h2L5,3c-1.1,0 -2,0.9 -2,2zM3,13h2v-2L3,11v2zM7,21h2v-2L7,19v2zM3,9h2L5,7L3,7v2zM13,3h-2v2h2L13,3zM19,3v2h2c0,-1.1 -0.9,-2 -2,-2zM5,21v-2L3,19c0,1.1 0.9,2 2,2zM3,17h2v-2L3,15v2zM9,3L7,3v2h2L9,3zM11,21h2v-2h-2v2zM19,13h2v-2h-2v2zM19,21c1.1,0 2,-0.9 2,-2h-2v2zM19,9h2L21,7h-2v2zM19,17h2v-2h-2v2zM15,21h2v-2h-2v2zM15,5h2L17,3h-2v2zM7,17h10L17,7L7,7v10zM9,9h6v6L9,15L9,9z"/>
</vector>

View File

@@ -1,16 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M4,8.25l7.51,1 -7.5,-3.22zM4.01,17.97l7.5,-3.22 -7.51,1z"
android:strokeAlpha="0.3"
android:fillAlpha="0.3"/>
<path
android:fillColor="@android:color/white"
android:pathData="M2.01,3L2,10l15,2 -15,2 0.01,7L23,12 2.01,3zM4,8.25L4,6.03l7.51,3.22 -7.51,-1zM4.01,17.97v-2.22l7.51,-1 -7.51,3.22z"/>
</vector>

View File

@@ -1,14 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M5,19h14L19,5L5,5v14zM7.5,12c0,-0.2 0.02,-0.39 0.04,-0.58l-1.27,-0.99c-0.11,-0.09 -0.15,-0.26 -0.07,-0.39l1.2,-2.07c0.08,-0.13 0.23,-0.18 0.37,-0.13l1.49,0.6c0.31,-0.25 0.66,-0.44 1.02,-0.6l0.22,-1.59c0.03,-0.14 0.15,-0.25 0.3,-0.25h2.4c0.15,0 0.27,0.11 0.3,0.25l0.22,1.59c0.37,0.15 0.7,0.35 1.01,0.59l1.49,-0.6c0.14,-0.05 0.29,0 0.37,0.13l1.2,2.07c0.08,0.13 0.04,0.29 -0.07,0.39l-1.27,0.99c0.03,0.2 0.04,0.39 0.04,0.59 0,0.2 -0.02,0.39 -0.04,0.58l1.27,0.99c0.11,0.09 0.15,0.26 0.07,0.39l-1.2,2.07c-0.08,0.13 -0.23,0.18 -0.37,0.13l-1.49,-0.6c-0.31,0.24 -0.65,0.44 -1.01,0.59l-0.22,1.59c-0.03,0.15 -0.15,0.26 -0.3,0.26h-2.4c-0.15,0 -0.27,-0.11 -0.3,-0.25l-0.22,-1.59c-0.37,-0.15 -0.7,-0.35 -1.01,-0.59l-1.49,0.6c-0.14,0.05 -0.29,0 -0.37,-0.13l-1.2,-2.07c-0.08,-0.13 -0.04,-0.29 0.07,-0.39l1.27,-0.99c-0.03,-0.2 -0.05,-0.39 -0.05,-0.59z"
android:fillAlpha=".3"/>
<path
android:fillColor="@android:color/white"
android:pathData="M6.21,13.97l1.2,2.07c0.08,0.13 0.23,0.18 0.37,0.13l1.49,-0.6c0.31,0.24 0.64,0.44 1.01,0.59l0.22,1.59c0.03,0.14 0.15,0.25 0.3,0.25h2.4c0.15,0 0.27,-0.11 0.3,-0.26l0.22,-1.59c0.36,-0.15 0.7,-0.35 1.01,-0.59l1.49,0.6c0.14,0.05 0.29,0 0.37,-0.13l1.2,-2.07c0.08,-0.13 0.04,-0.29 -0.07,-0.39l-1.27,-0.99c0.03,-0.19 0.04,-0.39 0.04,-0.58 0,-0.2 -0.02,-0.39 -0.04,-0.59l1.27,-0.99c0.11,-0.09 0.15,-0.26 0.07,-0.39l-1.2,-2.07c-0.08,-0.13 -0.23,-0.18 -0.37,-0.13l-1.49,0.6c-0.31,-0.24 -0.64,-0.44 -1.01,-0.59l-0.22,-1.59c-0.03,-0.14 -0.15,-0.25 -0.3,-0.25h-2.4c-0.15,0 -0.27,0.11 -0.3,0.26l-0.22,1.59c-0.36,0.15 -0.71,0.34 -1.01,0.58l-1.49,-0.6c-0.14,-0.05 -0.29,0 -0.37,0.13l-1.2,2.07c-0.08,0.13 -0.04,0.29 0.07,0.39l1.27,0.99c-0.03,0.2 -0.05,0.39 -0.05,0.59 0,0.2 0.02,0.39 0.04,0.59l-1.27,0.99c-0.11,0.1 -0.14,0.26 -0.06,0.39zM12,10.29c0.94,0 1.71,0.77 1.71,1.71s-0.77,1.71 -1.71,1.71 -1.71,-0.77 -1.71,-1.71 0.77,-1.71 1.71,-1.71zM19,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.11,0 2,-0.9 2,-2L21,5c0,-1.1 -0.89,-2 -2,-2zM19,19L5,19L5,5h14v14z"/>
</vector>

View File

@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M3,18h6v-2L3,16v2zM3,6v2h18L21,6L3,6zM3,13h12v-2L3,11v2z" />
</vector>

View File

@@ -1,14 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillAlpha="0.3"
android:fillColor="@android:color/white"
android:pathData="M12,6c-3.79,0 -7.17,2.13 -8.82,5.5C4.83,14.87 8.21,17 12,17s7.17,-2.13 8.82,-5.5C19.17,8.13 15.79,6 12,6zM12,16c-2.48,0 -4.5,-2.02 -4.5,-4.5S9.52,7 12,7s4.5,2.02 4.5,4.5S14.48,16 12,16z"
android:strokeAlpha="0.3" />
<path
android:fillColor="@android:color/white"
android:pathData="M12,4C7,4 2.73,7.11 1,11.5 2.73,15.89 7,19 12,19s9.27,-3.11 11,-7.5C21.27,7.11 17,4 12,4zM12,17c-3.79,0 -7.17,-2.13 -8.82,-5.5C4.83,8.13 8.21,6 12,6s7.17,2.13 8.82,5.5C19.17,14.87 15.79,17 12,17zM12,7c-2.48,0 -4.5,2.02 -4.5,4.5S9.52,16 12,16s4.5,-2.02 4.5,-4.5S14.48,7 12,7zM12,14c-1.38,0 -2.5,-1.12 -2.5,-2.5S10.62,9 12,9s2.5,1.12 2.5,2.5S13.38,14 12,14z" />
</vector>

View File

@@ -1,14 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillAlpha="0.3"
android:fillColor="@android:color/white"
android:pathData="M12,14c0.04,0 0.08,-0.01 0.12,-0.01l-2.61,-2.61c0,0.04 -0.01,0.08 -0.01,0.12 0,1.38 1.12,2.5 2.5,2.5zM13.01,9.21l1.28,1.28c-0.26,-0.57 -0.71,-1.03 -1.28,-1.28zM20.82,11.5C19.17,8.13 15.79,6 12,6c-0.68,0 -1.34,0.09 -1.99,0.22l0.92,0.92c0.35,-0.09 0.7,-0.14 1.07,-0.14 2.48,0 4.5,2.02 4.5,4.5 0,0.37 -0.06,0.72 -0.14,1.07l2.05,2.05c0.98,-0.86 1.81,-1.91 2.41,-3.12zM12,17c0.95,0 1.87,-0.13 2.75,-0.39l-0.98,-0.98c-0.54,0.24 -1.14,0.37 -1.77,0.37 -2.48,0 -4.5,-2.02 -4.5,-4.5 0,-0.63 0.13,-1.23 0.36,-1.77L6.11,7.97c-1.22,0.91 -2.23,2.1 -2.93,3.52C4.83,14.86 8.21,17 12,17z"
android:strokeAlpha="0.3" />
<path
android:fillColor="@android:color/white"
android:pathData="M12,6c3.79,0 7.17,2.13 8.82,5.5 -0.59,1.22 -1.42,2.27 -2.41,3.12l1.41,1.41c1.39,-1.23 2.49,-2.77 3.18,-4.53C21.27,7.11 17,4 12,4c-1.27,0 -2.49,0.2 -3.64,0.57l1.65,1.65C10.66,6.09 11.32,6 12,6zM14.28,10.49l2.07,2.07c0.08,-0.34 0.14,-0.7 0.14,-1.07C16.5,9.01 14.48,7 12,7c-0.37,0 -0.72,0.06 -1.07,0.14L13,9.21c0.58,0.25 1.03,0.71 1.28,1.28zM2.01,3.87l2.68,2.68C3.06,7.83 1.77,9.53 1,11.5 2.73,15.89 7,19 12,19c1.52,0 2.98,-0.29 4.32,-0.82l3.42,3.42 1.41,-1.41L3.42,2.45 2.01,3.87zM9.51,11.37l2.61,2.61c-0.04,0.01 -0.08,0.02 -0.12,0.02 -1.38,0 -2.5,-1.12 -2.5,-2.5 0,-0.05 0.01,-0.08 0.01,-0.13zM6.11,7.97l1.75,1.75c-0.23,0.55 -0.36,1.15 -0.36,1.78 0,2.48 2.02,4.5 4.5,4.5 0.63,0 1.23,-0.13 1.77,-0.36l0.98,0.98c-0.88,0.24 -1.8,0.38 -2.75,0.38 -3.79,0 -7.17,-2.13 -8.82,-5.5 0.7,-1.43 1.72,-2.61 2.93,-3.53z" />
</vector>

View File

@@ -1,16 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillAlpha="0.3"
android:fillColor="@android:color/white"
android:pathData="M7.83,11H5v2h2.83L10,15.17v-3.76l-1.29,-1.29z"
android:strokeAlpha="0.3" />
<path
android:fillColor="@android:color/white"
android:pathData="M4.34,2.93L2.93,4.34 7.29,8.7 7,9L3,9v6h4l5,5v-6.59l4.18,4.18c-0.65,0.49 -1.38,0.88 -2.18,1.11v2.06c1.34,-0.3 2.57,-0.92 3.61,-1.75l2.05,2.05 1.41,-1.41L4.34,2.93zM10,15.17L7.83,13L5,13v-2h2.83l0.88,-0.88L10,11.41v3.76zM19,12c0,0.82 -0.15,1.61 -0.41,2.34l1.53,1.53c0.56,-1.17 0.88,-2.48 0.88,-3.87 0,-4.28 -2.99,-7.86 -7,-8.77v2.06c2.89,0.86 5,3.54 5,6.71zM12,4l-1.88,1.88L12,7.76zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v1.79l2.48,2.48c0.01,-0.08 0.02,-0.16 0.02,-0.24z" />
</vector>

View File

@@ -1,16 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillAlpha="0.3"
android:fillColor="@android:color/white"
android:pathData="M5,13h2.83L10,15.17V8.83L7.83,11H5z"
android:strokeAlpha="0.3" />
<path
android:fillColor="@android:color/white"
android:pathData="M3,9v6h4l5,5L12,4L7,9L3,9zM10,8.83v6.34L7.83,13L5,13v-2h2.83L10,8.83zM14,7.97v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02 0,-1.77 -1.02,-3.29 -2.5,-4.03zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77 0,-4.28 -2.99,-7.86 -7,-8.77z" />
</vector>

View File

@@ -1,97 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (c) 2025 Meshtastic LLC
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
android:id="@+id/mainActivityLayout">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
style="@style/MyToolbar"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/appIconImageVIew"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/application_icon"
android:scaleType="center"
android:scaleX="1.5"
android:scaleY="1.5"
app:srcCompat="@drawable/app_icon"
tools:layout_editor_absoluteX="16dp"
tools:layout_editor_absoluteY="18dp" />
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:gravity="center"
android:minHeight="?actionBarSize"
android:padding="16dp"
android:text="@string/app_name"
android:textAppearance="@style/TextAppearance.Widget.AppCompat.Toolbar.Title" />
</com.google.android.material.appbar.MaterialToolbar>
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabGravity="fill"
app:tabIconTint="@color/tab_color_selector"
app:tabIndicatorColor="@color/selectedColor" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/composeView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View File

@@ -1,211 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (c) 2025 Meshtastic LLC
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
tools:context=".MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="16dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/nodeSettings"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="16dp"
>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/usernameView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:hint="@string/your_name"
app:layout_constraintHorizontal_weight="2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/regionSpinner"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/usernameEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textview.MaterialTextView
android:id="@+id/regionLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/region"
app:layout_constraintEnd_toEndOf="@+id/regionSpinner"
app:layout_constraintTop_toTopOf="parent"
tools:text="Region" />
<Spinner
android:id="@+id/regionSpinner"
android:layout_width="120dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="16dp"
android:theme="@style/AppTheme.Spinner"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/regionLabel"
app:layout_constraintStart_toEndOf="@+id/usernameView"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.textview.MaterialTextView
android:id="@+id/scanStatusText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:text="@string/looking_for_meshtastic_devices"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/nodeSettings"
/>
<ProgressBar
android:id="@+id/scanProgressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/deviceRadioGroup" />
<RadioGroup
android:id="@+id/deviceRadioGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/scanStatusText">
<RadioButton
android:id="@+id/radioButton2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/test_devname1" />
<RadioButton
android:id="@+id/radioButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/test_devname2" />
<RadioButton
android:id="@+id/radioButtonManual"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/ip_address" />
<EditText
android:id="@+id/editManualAddress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ems="10"
android:hint="@string/ip_address"
android:inputType="number|text"
android:visibility="visible"
android:importantForAutofill="no"
/>
</RadioGroup>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/changeRadioButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="@string/change_radio"
app:srcCompat="@drawable/ic_twotone_add_24"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/reportBugButton" />
<CheckBox
android:id="@+id/provideLocationCheckbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="@string/provide_location_to_mesh"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/deviceRadioGroup" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/warningNotPaired"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:autoLink="web"
android:ems="10"
android:gravity="start|top"
android:text="@string/warning_not_paired"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/provideLocationCheckbox" />
<CheckBox
android:id="@+id/analyticsOkayCheckbox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:background="@android:color/transparent"
android:checked="true"
android:text="@string/analytics_okay"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/reportBugButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/warningNotPaired"
app:layout_constraintVertical_bias="1.0" />
<com.google.android.material.button.MaterialButton
android:id="@+id/reportBugButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:text="@string/report_bug"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.4" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View File

@@ -1,70 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (c) 2025 Meshtastic LLC
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
>
<item
android:id="@+id/connectStatusImage"
android:contentDescription="@string/connection_status"
android:icon="@drawable/cloud_off"
app:iconTint="@color/toolbarText"
android:title="@string/disconnected"
app:showAsAction="ifRoom"
/>
<item
android:id="@+id/debug"
android:title="@string/debug_panel"
app:showAsAction="withText" />
<item
android:id="@+id/stress_test"
android:checkable="true"
android:checked="false"
android:title="@string/protocol_stress_test" />
<item
android:id="@+id/radio_config"
app:showAsAction="withText"
android:title="@string/device_settings" />
<item
android:id="@+id/save_messages_csv"
app:showAsAction="withText"
android:title="@string/save_messages" />
<item
android:id="@+id/theme"
android:title="@string/theme"
app:showAsAction="withText" />
<item
android:id="@+id/preferences_language"
android:title="@string/preferences_language"
app:showAsAction="withText" />
<item
android:id="@+id/show_intro"
android:title="@string/intro_show"
app:showAsAction="withText" />
<item
android:id="@+id/preferences_quick_chat"
android:title="@string/quick_chat"
app:showAsAction="withText" />
<item
android:id="@+id/about"
android:title="@string/about"
app:showAsAction="withText" />
</menu>

View File

@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (c) 2025 Meshtastic LLC
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/muteButton"
android:icon="@drawable/ic_twotone_volume_off_24"
android:title="@string/mute"
app:showAsAction="ifRoom" />
<item
android:id="@+id/deleteButton"
android:icon="@drawable/ic_twotone_delete_24"
android:title="@string/delete"
app:showAsAction="ifRoom" />
<item
android:id="@+id/selectAllButton"
android:icon="@drawable/ic_twotone_select_all_24"
android:title="@string/select_all"
app:showAsAction="ifRoom" />
</menu>

View File

@@ -605,4 +605,6 @@
<string name="heading">Heading</string>
<string name="sats">Sats</string>
<string name="alt">Alt</string>
<string name="set_region">Set Region</string>
<string name="unmute">Unmute</string>
</resources>

View File

@@ -19,78 +19,7 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight">
<!-- Customize your theme here. -->
<item name="actionBarTheme">@style/MyActionBar</item>
<item name="materialButtonStyle">@style/Widget.App.Button</item>
<item name="materialAlertDialogTheme">@style/CustomMaterialDialog</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="windowNoTitle">true</item>
<item name="android:itemTextAppearance">@style/menu_item_color</item>
<item name="actionModeStyle">@style/MyActionMode</item>
<item name="windowActionModeOverlay">true</item>
</style>
<style name="AppTheme.Spinner">
<item name="android:spinnerItemStyle">@style/SpinnerItem</item>
<item name="android:spinnerDropDownItemStyle">@style/SpinnerDropDownItem</item>
</style>
<style name="SpinnerItem">
<item name="android:gravity">right|center_vertical</item>
<item name="android:paddingRight">16dp</item>
</style>
<style name="SpinnerDropDownItem">
<item name="android:gravity">right|center_vertical</item>
<item name="android:paddingRight">16dp</item>
</style>
<style name="menu_item_color">
<item name="android:textColor">@color/colorMenuItem</item>
</style>
<style name="Meshtastic.Button.Rounded" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">50%</item>
</style>
<style name="Widget.App.Button" parent="Widget.MaterialComponents.Button">
<item name="backgroundTint">@color/buttonColor</item>
</style>
<style name="CustomMaterialDialog" parent="@style/ThemeOverlay.MaterialComponents.MaterialAlertDialog">
<!-- Background Color
<item name="android:background">#006db3</item> -->
<!-- Text Color for title and message
<item name="colorOnSurface">@color/unselectedColor</item> -->
<!-- Text Color for buttons -->
<item name="colorPrimary">@color/unselectedColor</item>
</style>
<style name="MyThemeOverlay_Toolbar" parent="">
<item name="android:textColorPrimary">@color/toolbarText</item>
<item name="tint">@color/toolbarText</item>
</style>
<style name="MyActionBar" parent="@style/ThemeOverlay.MaterialComponents.ActionBar">
<item name="background">@color/colorPrimary</item>
<item name="android:textColorPrimary">@color/colorOnPrimary</item>
<item name="tint">@color/colorOnPrimary</item>
</style>
<style name="MyToolbar" parent="Widget.MaterialComponents.Toolbar">
<item name="materialThemeOverlay">@style/MyThemeOverlay_Toolbar</item>
</style>
<style name="MyActionMode" parent="Base.Widget.AppCompat.ActionMode">
<item name="background">@color/colorPrimary</item>
<item name="android:textSize">16sp</item>
<item name="android:textColorPrimary">@color/colorOnPrimary</item>
</style>
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar"/>
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
// Set the splash screen background, animated icon, and animation duration.

View File

@@ -72,8 +72,7 @@
<ID>ConstructorParameterNaming:Packet.kt$Packet$@ColumnInfo(name = "contact_key") val contact_key: String</ID>
<ID>ConstructorParameterNaming:Packet.kt$Packet$@ColumnInfo(name = "port_num") val port_num: Int</ID>
<ID>ConstructorParameterNaming:Packet.kt$Packet$@ColumnInfo(name = "received_time") val received_time: Long</ID>
<ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$override fun onOptionsItemSelected(item: MenuItem): Boolean</ID>
<ID>CyclomaticComplexMethod:MapFragment.kt$@Composable fun MapView( model: UIViewModel = viewModel(), )</ID>
<ID>CyclomaticComplexMethod:MapView.kt$@Composable fun MapView( model: UIViewModel = viewModel(), )</ID>
<ID>CyclomaticComplexMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)</ID>
<ID>CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)</ID>
<ID>CyclomaticComplexMethod:UIState.kt$UIViewModel$fun saveMessagesCSV(uri: Uri)</ID>
@@ -81,7 +80,6 @@
<ID>EmptyClassBlock:DebugLogFile.kt$BinaryLogFile${ }</ID>
<ID>EmptyDefaultConstructor:SqlTileWriterExt.kt$SqlTileWriterExt$()</ID>
<ID>EmptyDefaultConstructor:SqlTileWriterExt.kt$SqlTileWriterExt.SourceCount$()</ID>
<ID>EmptyFunctionBlock:MainActivity.kt$MainActivity.&lt;no name provided&gt;${ }</ID>
<ID>EmptyFunctionBlock:NopInterface.kt$NopInterface${ }</ID>
<ID>EmptyFunctionBlock:NsdManager.kt$&lt;no name provided&gt;${ }</ID>
<ID>EmptyFunctionBlock:TrustAllX509TrustManager.kt$TrustAllX509TrustManager${}</ID>
@@ -130,7 +128,7 @@
<ID>FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt</ID>
<ID>FinalNewline:UsbBroadcastReceiver.kt$com.geeksville.mesh.repository.usb.UsbBroadcastReceiver.kt</ID>
<ID>FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt</ID>
<ID>ForbiddenComment:MapFragment.kt$// TODO: Accept filename input param from user</ID>
<ID>ForbiddenComment:MapView.kt$// TODO: Accept filename input param from user</ID>
<ID>ForbiddenComment:SafeBluetooth.kt$SafeBluetooth$// TODO: display some kind of UI about restarting BLE</ID>
<ID>FunctionNaming:PacketDao.kt$PacketDao$@Query("DELETE FROM packet WHERE uuid=:uuid") suspend fun _delete(uuid: Long)</ID>
<ID>FunctionNaming:QuickChatActionDao.kt$QuickChatActionDao$@Query("Delete from quick_chat where uuid=:uuid") fun _delete(uuid: Long)</ID>
@@ -148,16 +146,15 @@
<ID>LongMethod:AmbientLightingConfigItemList.kt$@Composable fun AmbientLightingConfigItemList( ambientLightingConfig: ModuleConfigProtos.ModuleConfig.AmbientLightingConfig, enabled: Boolean, onSaveClicked: (ModuleConfigProtos.ModuleConfig.AmbientLightingConfig) -&gt; Unit, )</ID>
<ID>LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigItemList( audioConfig: AudioConfig, enabled: Boolean, onSaveClicked: (AudioConfig) -&gt; Unit, )</ID>
<ID>LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigItemList( messages: String, cannedMessageConfig: CannedMessageConfig, enabled: Boolean, onSaveClicked: (messages: String, config: CannedMessageConfig) -&gt; Unit, )</ID>
<ID>LongMethod:ChannelSettingsItemList.kt$@Composable fun ChannelSettingsItemList( settingsList: List&lt;ChannelSettings&gt;, modemPresetName: String = "Default", maxChannels: Int = 8, enabled: Boolean, onNegativeClicked: () -&gt; Unit = { }, onPositiveClicked: (List&lt;ChannelSettings&gt;) -&gt; Unit, )</ID>
<ID>LongMethod:ContactsFragment.kt$ContactsFragment.ActionModeCallback$override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean</ID>
<ID>LongMethod:Contacts.kt$@Composable fun ContactsScreen( uiViewModel: UIViewModel = hiltViewModel(), onNavigate: (String) -&gt; Unit = {} )</ID>
<ID>LongMethod:Contacts.kt$@OptIn(ExperimentalMaterialApi::class) // Required for AlertDialog in some cases, though often not strictly necessary now @Composable fun MuteNotificationsDialog( showDialog: Boolean, onDismiss: () -&gt; Unit, onConfirm: (Long) -&gt; Unit // Lambda to handle the confirmed mute duration )</ID>
<ID>LongMethod:DeviceConfigItemList.kt$@Composable fun DeviceConfigItemList( deviceConfig: DeviceConfig, enabled: Boolean, onSaveClicked: (DeviceConfig) -&gt; Unit, )</ID>
<ID>LongMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigItemList( displayConfig: DisplayConfig, enabled: Boolean, onSaveClicked: (DisplayConfig) -&gt; Unit, )</ID>
<ID>LongMethod:DropDownPreference.kt$@Composable fun &lt;T&gt; DropDownPreference( title: String, enabled: Boolean, items: List&lt;Pair&lt;T, String&gt;&gt;, selectedItem: T, onItemSelected: (T) -&gt; Unit, modifier: Modifier = Modifier, summary: String? = null, )</ID>
<ID>LongMethod:EditListPreference.kt$@Composable inline fun &lt;reified T&gt; EditListPreference( title: String, list: List&lt;T&gt;, maxCount: Int, enabled: Boolean, keyboardActions: KeyboardActions, crossinline onValuesChanged: (List&lt;T&gt;) -&gt; Unit, modifier: Modifier = Modifier, )</ID>
<ID>LongMethod:ExternalNotificationConfigItemList.kt$@Composable fun ExternalNotificationConfigItemList( ringtone: String, extNotificationConfig: ExternalNotificationConfig, enabled: Boolean, onSaveClicked: (ringtone: String, config: ExternalNotificationConfig) -&gt; Unit, )</ID>
<ID>LongMethod:MQTTConfigItemList.kt$@Composable fun MQTTConfigItemList( mqttConfig: MQTTConfig, enabled: Boolean, onSaveClicked: (MQTTConfig) -&gt; Unit, )</ID>
<ID>LongMethod:MainActivity.kt$MainActivity$override fun onOptionsItemSelected(item: MenuItem): Boolean</ID>
<ID>LongMethod:MapFragment.kt$@Composable fun MapView( model: UIViewModel = viewModel(), )</ID>
<ID>LongMethod:MapView.kt$@Composable fun MapView( model: UIViewModel = viewModel(), )</ID>
<ID>LongMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)</ID>
<ID>LongMethod:PowerConfigItemList.kt$@Composable fun PowerConfigItemList( powerConfig: PowerConfig, enabled: Boolean, onSaveClicked: (PowerConfig) -&gt; Unit, )</ID>
<ID>LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)</ID>
@@ -211,11 +208,11 @@
<ID>MagicNumber:ChannelOption.kt$ChannelOption.VERY_LONG_SLOW$.0625f</ID>
<ID>MagicNumber:ChannelSet.kt$40</ID>
<ID>MagicNumber:ChannelSet.kt$960</ID>
<ID>MagicNumber:ContactsFragment.kt$ContactsFragment.ActionModeCallback$7</ID>
<ID>MagicNumber:ContactsFragment.kt$ContactsFragment.ActionModeCallback$8</ID>
<ID>MagicNumber:Contacts.kt$7</ID>
<ID>MagicNumber:Contacts.kt$8</ID>
<ID>MagicNumber:ContextServices.kt$33</ID>
<ID>MagicNumber:DataPacket.kt$DataPacket.CREATOR$16</ID>
<ID>MagicNumber:DebugFragment.kt$3</ID>
<ID>MagicNumber:Debug.kt$3</ID>
<ID>MagicNumber:DeviceVersion.kt$DeviceVersion$100</ID>
<ID>MagicNumber:DeviceVersion.kt$DeviceVersion$10000</ID>
<ID>MagicNumber:DownloadButton.kt$1.25f</ID>
@@ -253,16 +250,14 @@
<ID>MagicNumber:LocationUtils.kt$6366000</ID>
<ID>MagicNumber:LocationUtils.kt$GPSFormat$3</ID>
<ID>MagicNumber:MQTTRepository.kt$MQTTRepository$512</ID>
<ID>MagicNumber:MainActivity.kt$MainActivity$30000</ID>
<ID>MagicNumber:MainActivity.kt$MainActivity$5</ID>
<ID>MagicNumber:MapFragment.kt$0.5f</ID>
<ID>MagicNumber:MapFragment.kt$1.3</ID>
<ID>MagicNumber:MapFragment.kt$1000</ID>
<ID>MagicNumber:MapFragment.kt$1024.0</ID>
<ID>MagicNumber:MapFragment.kt$128205</ID>
<ID>MagicNumber:MapFragment.kt$12F</ID>
<ID>MagicNumber:MapFragment.kt$1e-7</ID>
<ID>MagicNumber:MapFragment.kt$&lt;no name provided&gt;$1e7</ID>
<ID>MagicNumber:MapView.kt$0.5f</ID>
<ID>MagicNumber:MapView.kt$1.3</ID>
<ID>MagicNumber:MapView.kt$1000</ID>
<ID>MagicNumber:MapView.kt$1024.0</ID>
<ID>MagicNumber:MapView.kt$128205</ID>
<ID>MagicNumber:MapView.kt$12F</ID>
<ID>MagicNumber:MapView.kt$1e-7</ID>
<ID>MagicNumber:MapView.kt$&lt;no name provided&gt;$1e7</ID>
<ID>MagicNumber:MapViewExtensions.kt$1e-5</ID>
<ID>MagicNumber:MapViewExtensions.kt$1e-7</ID>
<ID>MagicNumber:MapViewExtensions.kt$3.0f</ID>
@@ -296,6 +291,21 @@
<ID>MagicNumber:NOAAWmsTileSource.kt$NOAAWmsTileSource$360.0</ID>
<ID>MagicNumber:NOAAWmsTileSource.kt$NOAAWmsTileSource$4</ID>
<ID>MagicNumber:NOAAWmsTileSource.kt$NOAAWmsTileSource$5</ID>
<ID>MagicNumber:NavGraph.kt$ConfigRoute.BLUETOOTH$6</ID>
<ID>MagicNumber:NavGraph.kt$ConfigRoute.DISPLAY$4</ID>
<ID>MagicNumber:NavGraph.kt$ConfigRoute.LORA$5</ID>
<ID>MagicNumber:NavGraph.kt$ConfigRoute.NETWORK$3</ID>
<ID>MagicNumber:NavGraph.kt$ConfigRoute.SECURITY$7</ID>
<ID>MagicNumber:NavGraph.kt$ModuleRoute.AMBIENT_LIGHTING$10</ID>
<ID>MagicNumber:NavGraph.kt$ModuleRoute.AUDIO$7</ID>
<ID>MagicNumber:NavGraph.kt$ModuleRoute.CANNED_MESSAGE$6</ID>
<ID>MagicNumber:NavGraph.kt$ModuleRoute.DETECTION_SENSOR$11</ID>
<ID>MagicNumber:NavGraph.kt$ModuleRoute.NEIGHBOR_INFO$9</ID>
<ID>MagicNumber:NavGraph.kt$ModuleRoute.PAXCOUNTER$12</ID>
<ID>MagicNumber:NavGraph.kt$ModuleRoute.RANGE_TEST$4</ID>
<ID>MagicNumber:NavGraph.kt$ModuleRoute.REMOTE_HARDWARE$8</ID>
<ID>MagicNumber:NavGraph.kt$ModuleRoute.STORE_FORWARD$3</ID>
<ID>MagicNumber:NavGraph.kt$ModuleRoute.TELEMETRY$5</ID>
<ID>MagicNumber:NodeInfo.kt$DeviceMetrics.Companion$1000</ID>
<ID>MagicNumber:NodeInfo.kt$EnvironmentMetrics.Companion$1000</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$0.114</ID>
@@ -340,7 +350,6 @@
<ID>MagicNumber:StreamInterface.kt$StreamInterface$8</ID>
<ID>MagicNumber:TCPInterface.kt$TCPInterface$1000</ID>
<ID>MagicNumber:TCPInterface.kt$TCPInterface$180</ID>
<ID>MagicNumber:TCPInterface.kt$TCPInterface$4403</ID>
<ID>MagicNumber:TCPInterface.kt$TCPInterface$500</ID>
<ID>MagicNumber:UIState.kt$4</ID>
<ID>MatchingDeclarationName:AnalyticsClient.kt$AnalyticsProvider</ID>
@@ -365,6 +374,7 @@
<ID>MaxLineLength:BluetoothInterface.kt$BluetoothInterface$null</ID>
<ID>MaxLineLength:BluetoothState.kt$BluetoothState$"BluetoothState(hasPermissions=$hasPermissions, enabled=$enabled, bondedDevices=${bondedDevices.map { it.anonymize }})"</ID>
<ID>MaxLineLength:Channel.kt$Channel$// We have a new style 'empty' channel name. Use the same logic from the device to convert that to a human readable name</ID>
<ID>MaxLineLength:Contacts.kt$@OptIn(ExperimentalMaterialApi::class)</ID>
<ID>MaxLineLength:ContextServices.kt$val Context.locationManager: LocationManager get() = requireNotNull(getSystemService(Context.LOCATION_SERVICE) as? LocationManager?)</ID>
<ID>MaxLineLength:ContextServices.kt$val Context.notificationManager: NotificationManager get() = requireNotNull(getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager?)</ID>
<ID>MaxLineLength:CustomTileSource.kt$CustomTileSource.Companion$arrayOf("https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/radar_meteo_imagery_nexrad_time/MapServer/WmsServer?")</ID>
@@ -372,12 +382,9 @@
<ID>MaxLineLength:LoRaConfigItemList.kt$value = if (isFocused || loraInput.overrideFrequency != 0f) loraInput.overrideFrequency else primaryChannel.radioFreq</ID>
<ID>MaxLineLength:LocationRepository.kt$LocationRepository$info("Starting location updates with $providerList intervalMs=${intervalMs}ms and minDistanceM=${minDistanceM}m")</ID>
<ID>MaxLineLength:MQTTRepository.kt$MQTTRepository.Companion$*</ID>
<ID>MaxLineLength:MainActivity.kt$/* UI design material setup instructions: https://material.io/develop/android/docs/getting-started/ dark theme (or use system eventually) https://material.io/develop/android/theming/dark/ NavDrawer is a standard draw which can be dragged in from the left or the menu icon inside the app title. Fragments: SettingsFragment shows "Settings" username shortname bluetooth pairing list (eventually misc device settings that are not channel related) Channel fragment "Channel" qr code, copy link button ch number misc other settings (eventually a way of choosing between past channels) ChatFragment "Messages" a text box to enter new texts a scrolling list of rows. each row is a text and a sender info layout NodeListFragment "Users" a node info row for every node ViewModels: BTScanModel starts/stops bt scan and provides list of devices (manages entire scan lifecycle) MeshModel contains: (manages entire service relationship) current received texts current radio macaddr current node infos (updated dynamically) eventually use bottom navigation bar to switch between, Members, Chat, Channel, Settings. https://material.io/develop/android/components/bottom-navigation-view/ use numbers of # chat messages and # of members in the badges. (per this recommendation to not use top tabs: https://ux.stackexchange.com/questions/102439/android-ux-when-to-use-bottom-navigation-and-when-to-use-tabs ) eventually: make a custom theme: https://github.com/material-components/material-components-android/tree/master/material-theme-builder */</ID>
<ID>MaxLineLength:MainActivity.kt$MainActivity$/* This problem can occur if we unbind, but there is already an onConnected job waiting to run. That job runs and then makes meshService != null again I think I've fixed this by cancelling connectionJob. We'll see! */</ID>
<ID>MaxLineLength:MainActivity.kt$MainActivity$// Old samsung phones have a race condition andthis might rarely fail. Which is probably find because the bind will be sufficient most of the time</ID>
<ID>MaxLineLength:MainActivity.kt$MainActivity$// We now wait for the device to connect, once connected, we ask the user if they want to switch to the new channel</ID>
<ID>MaxLineLength:MainActivity.kt$MainActivity$// pager.offscreenPageLimit = 0 // Don't keep any offscreen pages around, because we want to make sure our bluetooth scanning stops</ID>
<ID>MaxLineLength:MainActivity.kt$MainActivity$MeshService.ConnectionState.DEVICE_SLEEP -&gt; R.drawable.ic_twotone_cloud_upload_24 to R.string.device_sleeping</ID>
<ID>MaxLineLength:MainActivity.kt$MainActivity$debug("Asked to open a channel URL - ask user if they want to switch to that channel. If so send the config to the radio")</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$*</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$* Send a mesh packet to the radio, if the radio is not currently connected this function will throw NotConnectedException</ID>
@@ -454,8 +461,6 @@
<ID>MultiLineIfElse:BluetoothRepository.kt$BluetoothRepository$emptyList()</ID>
<ID>MultiLineIfElse:Channel.kt$Channel$"Custom"</ID>
<ID>MultiLineIfElse:Channel.kt$Channel$when (loraConfig.modemPreset) { ModemPreset.SHORT_TURBO -&gt; "ShortTurbo" ModemPreset.SHORT_FAST -&gt; "ShortFast" ModemPreset.SHORT_SLOW -&gt; "ShortSlow" ModemPreset.MEDIUM_FAST -&gt; "MediumFast" ModemPreset.MEDIUM_SLOW -&gt; "MediumSlow" ModemPreset.LONG_FAST -&gt; "LongFast" ModemPreset.LONG_SLOW -&gt; "LongSlow" ModemPreset.LONG_MODERATE -&gt; "LongMod" ModemPreset.VERY_LONG_SLOW -&gt; "VLongSlow" else -&gt; "Invalid" }</ID>
<ID>MultiLineIfElse:ChannelFragment.kt$channelSet = copy { settings.add(it) }</ID>
<ID>MultiLineIfElse:ChannelFragment.kt$channelSet = copy { settings[index] = it }</ID>
<ID>MultiLineIfElse:ChannelOption.kt$when (bandwidth) { 31 -&gt; .03125f 62 -&gt; .0625f 200 -&gt; .203125f 400 -&gt; .40625f 800 -&gt; .8125f 1600 -&gt; 1.6250f else -&gt; bandwidth / 1000f }</ID>
<ID>MultiLineIfElse:ContextServices.kt$MaterialAlertDialogBuilder(this) .setTitle(title) .setMessage(rationale) .setNeutralButton(R.string.cancel) { _, _ -&gt; } .setPositiveButton(R.string.accept) { _, _ -&gt; invokeFun() } .show()</ID>
<ID>MultiLineIfElse:ContextServices.kt$invokeFun()</ID>
@@ -471,18 +476,11 @@
<ID>MultiLineIfElse:EditWaypointDialog.kt$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( 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) { Button( modifier = modifier.weight(1f), onClick = { onDeleteClicked(waypointInput) }, enabled = waypointInput.name.isNotEmpty(), ) { Text(stringResource(R.string.delete)) } } Button( modifier = modifier.weight(1f), onClick = { onSendClicked(waypointInput) }, enabled = waypointInput.name.isNotEmpty(), ) { Text(stringResource(R.string.send)) } } }, )</ID>
<ID>MultiLineIfElse:Exceptions.kt$Exceptions.errormsg("ignoring exception", ex)</ID>
<ID>MultiLineIfElse:ExpireChecker.kt$ExpireChecker$doExpire()</ID>
<ID>MultiLineIfElse:ExternalNotificationConfigItemList.kt$item { SwitchPreference(title = "Output LED active high", checked = externalNotificationInput.active, enabled = enabled, onCheckedChange = { externalNotificationInput = externalNotificationInput.copy { active = it } }) }</ID>
<ID>MultiLineIfElse:ExternalNotificationConfigItemList.kt$item { SwitchPreference(title = "Use PWM buzzer", checked = externalNotificationInput.usePwm, enabled = enabled, onCheckedChange = { externalNotificationInput = externalNotificationInput.copy { usePwm = it } }) }</ID>
<ID>MultiLineIfElse:Logging.kt$Logging$printlog(Log.ERROR, tag(), "$msg (exception ${ex.message})")</ID>
<ID>MultiLineIfElse:Logging.kt$Logging$printlog(Log.ERROR, tag(), "$msg")</ID>
<ID>MultiLineIfElse:MapFragment.kt$&lt;no name provided&gt;$showEditWaypointDialog = waypoint { latitudeI = (p.latitude * 1e7).toInt() longitudeI = (p.longitude * 1e7).toInt() }</ID>
<ID>MultiLineIfElse:MapFragment.kt$CacheLayout( cacheEstimate = cacheEstimate, onExecuteJob = { startDownload() }, onCancelDownload = { downloadRegionBoundingBox = null map.overlays.removeAll { it is Polygon } map.invalidate() }, modifier = Modifier.align(Alignment.BottomCenter) )</ID>
<ID>MultiLineIfElse:MapFragment.kt$Column( modifier = Modifier .padding(top = 16.dp, end = 16.dp) .align(Alignment.TopEnd), verticalArrangement = Arrangement.spacedBy(8.dp), ) { MapButton( onClick = ::showMapStyleDialog, icon = Icons.Outlined.Layers, contentDescription = R.string.map_style_selection, ) MapButton( enabled = hasGps, icon = if (myLocationOverlay == null) { Icons.Outlined.MyLocation } else { Icons.Default.LocationDisabled }, contentDescription = null, ) { if (context.hasLocationPermission()) { map.toggleMyLocation() } else { requestPermissionAndToggleLauncher.launch(context.getLocationPermissions()) } } }</ID>
<ID>MultiLineIfElse:MapViewWithLifecycle.kt$try { acquire() } catch (e: SecurityException) { errormsg("WakeLock permission exception: ${e.message}") } catch (e: IllegalStateException) { errormsg("WakeLock acquire() exception: ${e.message}") }</ID>
<ID>MultiLineIfElse:MapViewWithLifecycle.kt$try { release() } catch (e: IllegalStateException) { errormsg("WakeLock release() exception: ${e.message}") }</ID>
<ID>MultiLineIfElse:MeshService.kt$MeshService$getDataPacketById(packetId)?.let { p -&gt; if (p.status == m) return@handledLaunch packetRepository.get().updateMessageStatus(p, m) serviceBroadcasts.broadcastMessageStatus(packetId, m) }</ID>
<ID>MultiLineIfElse:MeshService.kt$MeshService$p</ID>
<ID>MultiLineIfElse:MeshService.kt$MeshService$p.copy { warn("Public key mismatch from $longName ($shortName)") publicKey = it.errorByteString }</ID>
<ID>MultiLineIfElse:MeshService.kt$MeshService.&lt;no name provided&gt;$try { sendNow(p) } catch (ex: Exception) { errormsg("Error sending message, so enqueueing", ex) enqueueForSending(p) }</ID>
<ID>MultiLineIfElse:NOAAWmsTileSource.kt$NOAAWmsTileSource$sb.append("service=WMS")</ID>
<ID>MultiLineIfElse:NodeInfo.kt$MeshUser$hwModel.name.replace('_', '-').replace('p', '.').lowercase()</ID>
@@ -602,7 +600,6 @@
<ID>ParameterListWrapping:SafeBluetooth.kt$SafeBluetooth$( c: BluetoothGattDescriptor, cont: Continuation&lt;BluetoothGattDescriptor&gt;, timeout: Long = 0 )</ID>
<ID>RethrowCaughtException:SyncContinuation.kt$Continuation$throw ex</ID>
<ID>ReturnCount:ChannelOption.kt$internal fun LoRaConfig.radioFreq(channelNum: Int): Float</ID>
<ID>ReturnCount:MainActivity.kt$MainActivity$override fun onOptionsItemSelected(item: MenuItem): Boolean</ID>
<ID>ReturnCount:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)</ID>
<ID>SpacingAroundColon:PreviewParameterProviders.kt$NodeInfoPreviewParameterProvider$:</ID>
<ID>SpacingAroundCurly:AppPrefs.kt$FloatPref$}</ID>
@@ -617,7 +614,6 @@
<ID>SwallowedException:DeviceVersion.kt$DeviceVersion$e: Exception</ID>
<ID>SwallowedException:Exceptions.kt$ex: Throwable</ID>
<ID>SwallowedException:MainActivity.kt$MainActivity$ex: BindFailedException</ID>
<ID>SwallowedException:MainActivity.kt$MainActivity$ex: IllegalStateException</ID>
<ID>SwallowedException:MeshLog.kt$MeshLog$e: IOException</ID>
<ID>SwallowedException:MeshService.kt$MeshService$e: Exception</ID>
<ID>SwallowedException:MeshService.kt$MeshService$e: TimeoutException</ID>
@@ -631,7 +627,7 @@
<ID>SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException</ID>
<ID>TooGenericExceptionCaught:BTScanModel.kt$BTScanModel$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:BluetoothInterface.kt$BluetoothInterface$ex: Exception</ID>
<ID>TooGenericExceptionCaught:ChannelFragment.kt$ex: Exception</ID>
<ID>TooGenericExceptionCaught:Channel.kt$ex: Exception</ID>
<ID>TooGenericExceptionCaught:ChannelSet.kt$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:DeviceVersion.kt$DeviceVersion$e: Exception</ID>
<ID>TooGenericExceptionCaught:Exceptions.kt$ex: Throwable</ID>
@@ -640,7 +636,7 @@
<ID>TooGenericExceptionCaught:MQTTRepository.kt$MQTTRepository$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MainActivity.kt$MainActivity$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MainActivity.kt$MainActivity$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:MapFragment.kt$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MapView.kt$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService$e: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService.&lt;no name provided&gt;$ex: Exception</ID>

View File

@@ -17,6 +17,7 @@ devtools-ksp = "2.1.21-2.0.1"
emoji2 = "1.5.0"
espresso-core = "3.6.1"
firebase-bom = "33.13.0"
fragment-compose = "1.8.6"
fragment-ktx = "1.8.6"
google-services = "4.4.2"
hilt = "2.56.2"
@@ -47,6 +48,7 @@ zxing-core = "3.3.0" #do not update
[libraries]
agp = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" }
actvity-ktx = { group = "androidx.activity", name = "activity-ktx" }
activity-compose = { group = "androidx.activity", name = "activity-compose" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
appcompat-resources = { group = "androidx.appcompat", name = "appcompat-resources", version.ref = "appcompat" }
@@ -76,6 +78,7 @@ firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase-bom" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics"}
firebase-crashlytics-gradle = { group = "com.google.firebase", name ="firebase-crashlytics-gradle", version.ref = "crashlytics" }
fragment-compose = { module = "androidx.fragment:fragment-compose", version.ref = "fragment-compose" }
fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment-ktx" }
google-services = { group = "com.google.gms", name = "google-services", version.ref = "google-services" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
@@ -122,7 +125,7 @@ zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing-c
[bundles]
# Core AndroidX
androidx = ["core-ktx", "appcompat", "appcompat-resources", "cardview", "fragment-ktx", "activity-compose"]
androidx = ["core-ktx", "appcompat", "appcompat-resources", "cardview", "fragment-ktx", "actvity-ktx", "fragment-compose", "activity-compose"]
# UI
ui = ["material", "constraintlayout", "viewpager2", "compose-material", "compose-material-icons-extended", "compose-ui-tooling-preview", "compose-runtime-livedata"]