mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-13 01:05:55 -04:00
refactor: migrate to Compose navigation (#1835)
Co-authored-by: andrekir <andrekir@pm.me>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}) {
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
360
app/src/main/java/com/geeksville/mesh/ui/Contacts.kt
Normal file
360
app/src/main/java/com/geeksville/mesh/ui/Contacts.kt
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
339
app/src/main/java/com/geeksville/mesh/ui/Main.kt
Normal file
339
app/src/main/java/com/geeksville/mesh/ui/Main.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
617
app/src/main/java/com/geeksville/mesh/ui/Settings.kt
Normal file
617
app/src/main/java/com/geeksville/mesh/ui/Settings.kt
Normal 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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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}")
|
||||
@@ -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")) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.<no name provided>${ }</ID>
|
||||
<ID>EmptyFunctionBlock:NopInterface.kt$NopInterface${ }</ID>
|
||||
<ID>EmptyFunctionBlock:NsdManager.kt$<no name provided>${ }</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) -> Unit, )</ID>
|
||||
<ID>LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigItemList( audioConfig: AudioConfig, enabled: Boolean, onSaveClicked: (AudioConfig) -> Unit, )</ID>
|
||||
<ID>LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigItemList( messages: String, cannedMessageConfig: CannedMessageConfig, enabled: Boolean, onSaveClicked: (messages: String, config: CannedMessageConfig) -> Unit, )</ID>
|
||||
<ID>LongMethod:ChannelSettingsItemList.kt$@Composable fun ChannelSettingsItemList( settingsList: List<ChannelSettings>, modemPresetName: String = "Default", maxChannels: Int = 8, enabled: Boolean, onNegativeClicked: () -> Unit = { }, onPositiveClicked: (List<ChannelSettings>) -> 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) -> 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: () -> Unit, onConfirm: (Long) -> Unit // Lambda to handle the confirmed mute duration )</ID>
|
||||
<ID>LongMethod:DeviceConfigItemList.kt$@Composable fun DeviceConfigItemList( deviceConfig: DeviceConfig, enabled: Boolean, onSaveClicked: (DeviceConfig) -> Unit, )</ID>
|
||||
<ID>LongMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigItemList( displayConfig: DisplayConfig, enabled: Boolean, onSaveClicked: (DisplayConfig) -> Unit, )</ID>
|
||||
<ID>LongMethod:DropDownPreference.kt$@Composable fun <T> DropDownPreference( title: String, enabled: Boolean, items: List<Pair<T, String>>, selectedItem: T, onItemSelected: (T) -> Unit, modifier: Modifier = Modifier, summary: String? = null, )</ID>
|
||||
<ID>LongMethod:EditListPreference.kt$@Composable inline fun <reified T> EditListPreference( title: String, list: List<T>, maxCount: Int, enabled: Boolean, keyboardActions: KeyboardActions, crossinline onValuesChanged: (List<T>) -> Unit, modifier: Modifier = Modifier, )</ID>
|
||||
<ID>LongMethod:ExternalNotificationConfigItemList.kt$@Composable fun ExternalNotificationConfigItemList( ringtone: String, extNotificationConfig: ExternalNotificationConfig, enabled: Boolean, onSaveClicked: (ringtone: String, config: ExternalNotificationConfig) -> Unit, )</ID>
|
||||
<ID>LongMethod:MQTTConfigItemList.kt$@Composable fun MQTTConfigItemList( mqttConfig: MQTTConfig, enabled: Boolean, onSaveClicked: (MQTTConfig) -> 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) -> 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$<no name provided>$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$<no name provided>$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 -> 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 -> "ShortTurbo" ModemPreset.SHORT_FAST -> "ShortFast" ModemPreset.SHORT_SLOW -> "ShortSlow" ModemPreset.MEDIUM_FAST -> "MediumFast" ModemPreset.MEDIUM_SLOW -> "MediumSlow" ModemPreset.LONG_FAST -> "LongFast" ModemPreset.LONG_SLOW -> "LongSlow" ModemPreset.LONG_MODERATE -> "LongMod" ModemPreset.VERY_LONG_SLOW -> "VLongSlow" else -> "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 -> .03125f 62 -> .0625f 200 -> .203125f 400 -> .40625f 800 -> .8125f 1600 -> 1.6250f else -> bandwidth / 1000f }</ID>
|
||||
<ID>MultiLineIfElse:ContextServices.kt$MaterialAlertDialogBuilder(this) .setTitle(title) .setMessage(rationale) .setNeutralButton(R.string.cancel) { _, _ -> } .setPositiveButton(R.string.accept) { _, _ -> 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$<no name provided>$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 -> 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.<no name provided>$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<BluetoothGattDescriptor>, 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.<no name provided>$ex: Exception</ID>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user