mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: migrate to Compose navigation (#1835)
Co-authored-by: andrekir <andrekir@pm.me>
This commit is contained in:
parent
79c77ab1d5
commit
8cde47bdf9
74 changed files with 2576 additions and 3427 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue