From 04d94a6eb163e294343febaccafc82ab58036c00 Mon Sep 17 00:00:00 2001 From: andrekir Date: Thu, 2 Jan 2025 08:31:45 -0300 Subject: [PATCH] refactor: migrate to Compose navigation --- app/build.gradle | 3 +- .../java/com/geeksville/mesh/MainActivity.kt | 337 ++---------------- .../java/com/geeksville/mesh/model/UIState.kt | 31 +- .../ui/{ChannelFragment.kt => Channel.kt} | 38 +- .../geeksville/mesh/ui/ContactsFragment.kt | 3 +- .../mesh/ui/{DebugFragment.kt => Debug.kt} | 74 +--- .../main/java/com/geeksville/mesh/ui/Main.kt | 327 +++++++++++++++++ .../java/com/geeksville/mesh/ui/NavGraph.kt | 174 +++------ .../ui/{UsersFragment.kt => NodeScreen.kt} | 65 +--- ...ckChatSettingsFragment.kt => QuickChat.kt} | 49 --- .../com/geeksville/mesh/ui/ShareFragment.kt | 24 +- .../mesh/ui/components/ScannedQrCodeDialog.kt | 17 + .../com/geeksville/mesh/ui/map/MapFragment.kt | 32 -- .../com/geeksville/mesh/ui/message/Message.kt | 90 +---- .../com/geeksville/mesh/ui/theme/Shape.kt | 28 -- .../com/geeksville/mesh/ui/theme/Theme.kt | 4 +- .../java/com/geeksville/mesh/ui/theme/Type.kt | 58 --- app/src/main/res/layout/activity_main.xml | 97 ----- app/src/main/res/menu/menu_main.xml | 70 ---- config/detekt/detekt-baseline.xml | 24 +- 20 files changed, 479 insertions(+), 1066 deletions(-) rename app/src/main/java/com/geeksville/mesh/ui/{ChannelFragment.kt => Channel.kt} (94%) rename app/src/main/java/com/geeksville/mesh/ui/{DebugFragment.kt => Debug.kt} (76%) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/Main.kt rename app/src/main/java/com/geeksville/mesh/ui/{UsersFragment.kt => NodeScreen.kt} (66%) rename app/src/main/java/com/geeksville/mesh/ui/{QuickChatSettingsFragment.kt => QuickChat.kt} (87%) delete mode 100644 app/src/main/java/com/geeksville/mesh/ui/theme/Shape.kt delete mode 100644 app/src/main/java/com/geeksville/mesh/ui/theme/Type.kt delete mode 100644 app/src/main/res/layout/activity_main.xml delete mode 100644 app/src/main/res/menu/menu_main.xml diff --git a/app/build.gradle b/app/build.gradle index fb4ef751..7ab19aae 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -151,10 +151,10 @@ dependencies { implementation 'androidx.core:core-ktx:1.15.0' implementation 'androidx.core:core-location-altitude:1.0.0-alpha03' implementation 'androidx.fragment:fragment-ktx:1.8.5' + implementation 'androidx.fragment:fragment-compose:1.8.5' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.0' implementation 'com.google.android.material:material:1.12.0' - implementation 'androidx.viewpager2:viewpager2:1.1.0' implementation 'androidx.datastore:datastore:1.1.1' // Lifecycle @@ -197,7 +197,6 @@ dependencies { implementation 'androidx.compose.material:material-icons-extended' implementation 'androidx.activity:activity-compose' implementation 'androidx.compose.runtime:runtime-livedata' - implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.36.0" // Android Studio Preview support implementation 'androidx.compose.ui:ui-tooling-preview' diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 8ee57738..c9b9646b 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -26,29 +26,17 @@ import android.content.pm.PackageManager import android.hardware.usb.UsbManager import android.net.Uri import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.os.RemoteException 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.result.contract.ActivityResultContracts import androidx.activity.viewModels 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.core.splashscreen.SplashScreen.Companion.installSplashScreen -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 @@ -61,7 +49,6 @@ 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 @@ -69,88 +56,23 @@ 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.navigateToNavGraph -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()) @@ -166,7 +88,7 @@ class MainActivity : AppCompatActivity(), Logging { info("Bluetooth permissions granted") } else { warn("Bluetooth permissions denied") - showSnackbar(permissionMissing) + model.showSnackbar(permissionMissing) } requestedEnable = false bluetoothViewModel.permissionsUpdated() @@ -178,45 +100,10 @@ class MainActivity : AppCompatActivity(), Logging { info("Notification permissions granted") } else { warn("Notification permissions denied") - showSnackbar(getString(R.string.notification_denied), Snackbar.LENGTH_SHORT) + model.showSnackbar(getString(R.string.notification_denied)) } } - data class TabInfo(val text: String, val icon: Int, val content: Fragment) - - private val tabInfos = arrayOf( - TabInfo( - "Messages", - R.drawable.ic_twotone_message_24, - ContactsFragment() - ), - TabInfo( - "Users", - R.drawable.ic_twotone_people_24, - UsersFragment() - ), - TabInfo( - "Map", - R.drawable.ic_twotone_map_24, - MapFragment() - ), - TabInfo( - "Channel", - R.drawable.ic_twotone_contactless_24, - ChannelFragment() - ), - TabInfo( - "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?) { installSplashScreen() super.onCreate(savedInstanceState) @@ -239,45 +126,9 @@ 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.text = tabInfos[position].text // I think it looks better with icons only - tab.icon = ContextCompat.getDrawable(this, tabInfos[position].icon) - }.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() - + setContent { AppTheme { - if (connState.isConnected()) { - if (requestChannelSet != null) { - ScannedQrCodeDialog( - channels = channels, - incoming = requestChannelSet!!, - onDismiss = model::clearRequestChannelUrl, - onConfirm = model::setChannels, - ) - } - } + MainScreen(model, ::onMainMenuAction) } } @@ -285,28 +136,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) @@ -328,7 +157,9 @@ class MainActivity : AppCompatActivity(), Logging { MeshServiceNotifications.OPEN_MESSAGE_ACTION -> { val contactKey = intent.getStringExtra(MeshServiceNotifications.OPEN_MESSAGE_EXTRA_CONTACT_KEY) - showMessages(contactKey) + if (contactKey != null) { + // showMessages(contactKey) + } } UsbManager.ACTION_USB_DEVICE_ATTACHED -> { @@ -341,7 +172,7 @@ class MainActivity : AppCompatActivity(), Logging { Intent.ACTION_SEND -> { val text = intent.getStringExtra(Intent.EXTRA_TEXT) if (text != null) { - shareMessages(text) + // shareMessages(text) } } @@ -440,27 +271,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(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) @@ -475,10 +285,7 @@ class MainActivity : AppCompatActivity(), Logging { private var connectionJob: Job? = null - private val mesh = object : - ServiceClient({ - IMeshService.Stub.asInterface(it) - }) { + private val mesh = object : ServiceClient(IMeshService.Stub::asInterface) { override fun onConnected(service: IMeshService) { connectionJob = mainScope.handledLaunch { serviceRepository.setMeshService(service) @@ -551,11 +358,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 @@ -571,17 +373,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) @@ -605,118 +396,32 @@ class MainActivity : AppCompatActivity(), Logging { } private fun showSettingsPage() { - binding.pager.currentItem = 5 + // binding.pager.currentItem = 5 } - 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 -> {} } } diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index d1eb1570..e0a6d67e 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -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 @@ -171,8 +171,6 @@ class UIViewModel @Inject constructor( private val quickChatActionRepository: QuickChatActionRepository, private val preferences: SharedPreferences ) : ViewModel(), Logging { - - var actionBarMenu: Menu? = null val meshService: IMeshService? get() = radioConfigRepository.meshService val bondedAddress get() = radioInterfaceService.getBondedDeviceAddress() @@ -255,12 +253,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(null) - val snackbarText: LiveData 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) @@ -460,17 +461,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) { @@ -694,13 +684,6 @@ class UIViewModel @Inject constructor( radioConfigRepository.clearTracerouteResponse() } - private val _currentTab = MutableLiveData(0) - val currentTab: LiveData get() = _currentTab - - fun setCurrentTab(tab: Int) { - _currentTab.value = tab - } - fun setNodeFilterText(text: String) { nodeFilterText.value = text } diff --git a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/Channel.kt similarity index 94% rename from app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt rename to app/src/main/java/com/geeksville/mesh/ui/Channel.kt index 2a6ffeb1..beb57971 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Channel.kt @@ -18,11 +18,7 @@ package com.geeksville.mesh.ui 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 @@ -63,12 +59,10 @@ 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.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 @@ -76,7 +70,6 @@ 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 @@ -87,7 +80,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 @@ -109,32 +101,9 @@ import com.geeksville.mesh.ui.components.config.EditChannelDialog import com.geeksville.mesh.ui.components.dragContainer import com.geeksville.mesh.ui.components.dragDropItemsIndexed import com.geeksville.mesh.ui.components.rememberDragDropState -import com.google.accompanist.themeadapter.appcompat.AppCompatTheme 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 { - AppCompatTheme { - ChannelScreen(model) - } - } - } - } -} @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable @@ -291,8 +260,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 }, diff --git a/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt index 640e7a49..822d331e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt @@ -46,7 +46,6 @@ 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 @@ -71,7 +70,7 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { onLongClick(contact) } else { debug("calling MessagesFragment filter:${contact.contactKey}") - parentFragmentManager.navigateToMessages(contact.contactKey) + // parentFragmentManager.navigateToMessages(contact.contactKey) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/Debug.kt similarity index 76% rename from app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt rename to app/src/main/java/com/geeksville/mesh/ui/Debug.kt index 96f9acd8..0519da7c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Debug.kt @@ -17,13 +17,8 @@ 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -36,13 +31,9 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.Button import androidx.compose.material.Card import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.Scaffold import androidx.compose.material.Surface 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.outlined.CloudDownload import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -51,8 +42,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember 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.SpanStyle @@ -64,63 +53,15 @@ 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.core.content.ContextCompat -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.database.entity.MeshLog import com.geeksville.mesh.model.DebugViewModel import com.geeksville.mesh.ui.theme.AppTheme -import dagger.hilt.android.AndroidEntryPoint import java.text.DateFormat import java.util.Locale -@AndroidEntryPoint -class DebugFragment : Fragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground)) - setContent { - val viewModel: DebugViewModel = hiltViewModel() - - AppTheme { - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(id = R.string.debug_panel)) }, - navigationIcon = { - IconButton(onClick = { parentFragmentManager.popBackStack() }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - stringResource(id = R.string.navigate_back), - ) - } - }, - actions = { - Button(onClick = viewModel::deleteAllLogs) { - Text(text = stringResource(R.string.clear)) - } - } - ) - }, - ) { innerPadding -> - DebugScreen( - viewModel = viewModel, - contentPadding = innerPadding, - ) - } - } - } - } - } -} - private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE) /** @@ -185,7 +126,6 @@ private fun Int.asNodeId(): String { @Composable internal fun DebugScreen( viewModel: DebugViewModel = hiltViewModel(), - contentPadding: PaddingValues, ) { val listState = rememberLazyListState() val logs by viewModel.meshLog.collectAsStateWithLifecycle() @@ -203,7 +143,6 @@ internal fun DebugScreen( LazyColumn( modifier = Modifier.fillMaxSize(), state = listState, - contentPadding = contentPadding, ) { items(logs, key = { it.uuid }) { log -> DebugItem(annotateMeshLog(log)) } } @@ -300,3 +239,16 @@ private fun DebugScreenPreview() { ) } } + +@Composable +fun DebugMenuActions( + viewModel: DebugViewModel = hiltViewModel(), + modifier: Modifier = Modifier, +) { + Button( + onClick = viewModel::deleteAllLogs, + modifier = modifier, + ) { + Text(text = stringResource(R.string.clear)) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt new file mode 100644 index 00000000..fd631f66 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -0,0 +1,327 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui + +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.twotone.CloudDone +import androidx.compose.material.icons.twotone.CloudOff +import androidx.compose.material.icons.twotone.CloudUpload +import androidx.compose.material.icons.filled.MoreVert +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.Alignment +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.compose.ui.unit.dp +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.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.geeksville.mesh.R +import com.geeksville.mesh.model.UIViewModel +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 backStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = backStackEntry?.destination + + 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) + } + } + + Scaffold( + topBar = { + MainAppBar( + isManaged = localConfig.security.isManaged, + connectionState = connectionState, + currentDestination = currentDestination, + canNavigateBack = navController.previousBackStackEntry != null, + navigateUp = navController::navigateUp, + ) { 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, + currentDestination = currentDestination, + ) + }, + snackbarHost = { SnackbarHost(hostState = viewModel.snackbarState) } + ) { innerPadding -> + NavGraph( + model = viewModel, + navController = navController, + modifier = Modifier.padding(innerPadding), + ) + } +} + +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), +} + +@Composable +private fun MainAppBar( + isManaged: Boolean, + connectionState: MeshService.ConnectionState, + currentDestination: NavDestination?, + canNavigateBack: Boolean, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, + onAction: (MainMenuAction) -> Unit +) = AnimatedVisibility( + visible = currentDestination?.hasRoute() == false, + enter = slideInVertically(animationSpec = tween(durationMillis = 200)), + exit = slideOutVertically(animationSpec = tween(durationMillis = 200)), +) { + val isTopLevelRoute = currentDestination?.isTopLevel() == true + TopAppBar( + title = { + when { + currentDestination == null || isTopLevelRoute -> { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.app_icon), + contentDescription = stringResource(id = R.string.application_icon), + modifier = Modifier + .size(36.dp) + .padding(end = 8.dp) + ) + Text(text = stringResource(id = R.string.app_name)) + } + } + + currentDestination.hasRoute() -> + Text(stringResource(id = R.string.debug_panel)) + + currentDestination.hasRoute() -> + Text(stringResource(id = R.string.quick_chat)) + + else -> Text("Node name here") // TODO show destNode longName + } + }, + modifier = modifier, + navigationIcon = if (canNavigateBack && !isTopLevelRoute) { + { + IconButton(onClick = navigateUp) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.navigate_back), + ) + } + } + } else { + null + }, + actions = { + when { + currentDestination == null || isTopLevelRoute -> + MainMenuActions(isManaged, connectionState, onAction) + + currentDestination.hasRoute() -> + DebugMenuActions() + + else -> {} + } + }, + ) +} + +@Composable +private fun MainMenuActions( + isManaged: Boolean, + connectionState: MeshService.ConnectionState, + onAction: (MainMenuAction) -> Unit +) { + val context = LocalContext.current + val (image, tooltip) = when (connectionState) { + MeshService.ConnectionState.CONNECTED -> Icons.TwoTone.CloudDone to R.string.connected + MeshService.ConnectionState.DEVICE_SLEEP -> Icons.TwoTone.CloudUpload to R.string.device_sleeping + MeshService.ConnectionState.DISCONNECTED -> Icons.TwoTone.CloudOff to R.string.disconnected + } + + var showMenu by remember { mutableStateOf(false) } + IconButton( + onClick = { + Toast.makeText(context, tooltip, Toast.LENGTH_SHORT).show() + }, + ) { + Icon( + imageVector = image, + contentDescription = stringResource(id = tooltip), + ) + } + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Overflow menu", + ) + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + modifier = Modifier.background(MaterialTheme.colors.background.copy(alpha = 1f)), + ) { + MainMenuAction.entries.forEach { action -> + DropdownMenuItem( + onClick = { + onAction(action) + showMenu = false + }, + enabled = when (action) { + MainMenuAction.RADIO_CONFIG -> !isManaged + else -> true + }, + ) { Text(stringResource(id = action.stringRes)) } + } + } +} + +@Composable +private fun BottomNavigation( + navController: NavController, + currentDestination: NavDestination?, +) { + val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination) + + AnimatedVisibility( + visible = topLevelDestination != null, + enter = slideInVertically( + initialOffsetY = { it / 2 }, + animationSpec = tween(durationMillis = 200), + ), + exit = slideOutVertically( + targetOffsetY = { it / 2 }, + animationSpec = tween(durationMillis = 200), + ), + ) { + BottomNavigation { + TopLevelDestination.entries.forEach { + val isSelected = it == topLevelDestination + BottomNavigationItem( + icon = { + Icon( + imageVector = it.icon, + contentDescription = it.name, + ) + }, + // label = { Text(it.label) }, + selected = isSelected, + onClick = { + if (!isSelected) { + navController.navigate(it.route) { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + // on the back stack as users select items + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } + } + } + ) + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt index 807d7edb..9bea9d67 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt @@ -17,19 +17,9 @@ package com.geeksville.mesh.ui -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.annotation.StringRes -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Forward import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.Message @@ -54,35 +44,27 @@ 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.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.stringResource -import androidx.core.content.ContextCompat -import androidx.core.os.bundleOf -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.viewModels +import androidx.fragment.compose.AndroidFragment import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute import com.geeksville.mesh.MeshProtos.DeviceMetadata import com.geeksville.mesh.R -import com.geeksville.mesh.android.Logging import com.geeksville.mesh.model.MetricsViewModel import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.model.UIViewModel 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.SignalMetricsScreen import com.geeksville.mesh.ui.components.TracerouteLogScreen -import com.geeksville.mesh.util.UiText import com.geeksville.mesh.ui.components.config.AmbientLightingConfigScreen import com.geeksville.mesh.ui.components.config.AudioConfigScreen import com.geeksville.mesh.ui.components.config.BluetoothConfigScreen @@ -106,76 +88,11 @@ import com.geeksville.mesh.ui.components.config.SerialConfigScreen import com.geeksville.mesh.ui.components.config.StoreForwardConfigScreen import com.geeksville.mesh.ui.components.config.TelemetryConfigScreen import com.geeksville.mesh.ui.components.config.UserConfigScreen -import com.google.accompanist.themeadapter.appcompat.AppCompatTheme -import dagger.hilt.android.AndroidEntryPoint +import com.geeksville.mesh.ui.map.MapView +import com.geeksville.mesh.ui.message.MessageScreen +import com.geeksville.mesh.util.UiText 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() -} - -@AndroidEntryPoint -class NavGraphFragment : ScreenFragment("NavGraph"), Logging { - - private val model: RadioConfigViewModel by viewModels() - - 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) - } - - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground)) - setContent { - val node by model.destNode.collectAsStateWithLifecycle() - - AppCompatTheme { - val navController: NavHostController = rememberNavController() - Scaffold( - topBar = { - MeshAppBar( - currentScreen = node?.user?.longName - ?: stringResource(R.string.unknown_username), - canNavigateBack = true, - navigateUp = { - if (navController.previousBackStackEntry != null) { - navController.navigateUp() - } else { - parentFragmentManager.popBackStack() - } - }, - ) - } - ) { innerPadding -> - NavGraph( - navController = navController, - startDestination = startDestination, - modifier = Modifier.padding(innerPadding), - ) - } - } - } - } - } -} - enum class AdminRoute(@StringRes val title: Int) { REBOOT(R.string.reboot), SHUTDOWN(R.string.shutdown), @@ -184,13 +101,21 @@ enum class AdminRoute(@StringRes val title: Int) { } 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 RadioConfig(val destNum: Int? = null) : Route @Serializable data object User : Route - @Serializable data object Channels : Route + @Serializable data object ChannelConfig : Route @Serializable data object Device : Route @Serializable data object Position : Route @Serializable data object Power : Route @@ -227,7 +152,7 @@ sealed interface Route { // Config (type = AdminProtos.AdminMessage.ConfigType) enum class ConfigRoute(val title: String, val route: Route, val icon: ImageVector?, val type: Int = 0) { USER("User", Route.User, Icons.Default.Person, 0), - CHANNELS("Channels", Route.Channels, Icons.AutoMirrored.Default.List, 0), + CHANNELS("Channels", Route.ChannelConfig, Icons.AutoMirrored.Default.List, 0), DEVICE("Device", Route.Device, Icons.Default.Router, 0), POSITION("Position", Route.Position, Icons.Default.LocationOn, 1), POWER("Power", Route.Power, Icons.Default.Power, 2), @@ -291,41 +216,54 @@ sealed class ResponseState { fun isWaiting() = this !is Empty } -@Composable -private fun MeshAppBar( - currentScreen: String, - canNavigateBack: Boolean, - navigateUp: () -> Unit, - modifier: Modifier = Modifier, -) { - TopAppBar( - title = { Text(currentScreen) }, - modifier = modifier, - navigationIcon = { - if (canNavigateBack) { - IconButton(onClick = navigateUp) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(id = R.string.navigate_back), - ) - } - } - } - ) -} - @Suppress("LongMethod") @Composable fun NavGraph( + model: UIViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), - startDestination: Any, modifier: Modifier = Modifier, ) { NavHost( navController = navController, - startDestination = startDestination, + startDestination = Route.Contacts, modifier = modifier, ) { + composable { + AndroidFragment() + } + composable { + NodeScreen( + model = model, + navigateToMessages = { navController.navigate(Route.Messages(it)) }, + navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) }, + ) + } + composable { + MapView(model) + } + composable { + ChannelScreen(model) + } + composable { + AndroidFragment(Modifier.fillMaxSize()) + } + composable { + DebugScreen() + } + composable { backStackEntry -> + val args = backStackEntry.toRoute() + MessageScreen( + contactKey = args.contactKey, + message = args.message, + viewModel = model, + navigateToMessages = { navController.navigate(Route.Messages(it)) }, + navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) }, + onNavigateBack = navController::navigateUp + ) + } + composable { + QuickChatScreen() + } composable { NodeDetailScreen { navController.navigate(route = it) } } @@ -360,7 +298,7 @@ fun NavGraph( val parentEntry = remember { navController.getBackStackEntry() } UserConfigScreen(hiltViewModel(parentEntry)) } - composable { + composable { val parentEntry = remember { navController.getBackStackEntry() } ChannelConfigScreen(hiltViewModel(parentEntry)) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt similarity index 66% rename from app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt rename to app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt index 8650d955..335b2bf6 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt @@ -17,10 +17,6 @@ package com.geeksville.mesh.ui -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -31,67 +27,21 @@ import androidx.compose.foundation.lazy.rememberLazyListState 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.ui.components.NodeMenuAction 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() @@ -135,7 +85,12 @@ fun NodesScreen( when (menuItem) { is NodeMenuAction.Remove -> model.removeNode(node.num) is NodeMenuAction.Ignore -> model.ignoreNode(node) - is NodeMenuAction.DirectMessage -> navigateToMessages(node) + is NodeMenuAction.DirectMessage -> { + val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC + val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel + navigateToMessages("$channel${node.user.id}") + } + is NodeMenuAction.RequestUserInfo -> model.requestUserInfo(node.num) is NodeMenuAction.RequestPosition -> model.requestPosition(node.num) is NodeMenuAction.TraceRoute -> model.requestTraceroute(node.num) diff --git a/app/src/main/java/com/geeksville/mesh/ui/QuickChatSettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/QuickChat.kt similarity index 87% rename from app/src/main/java/com/geeksville/mesh/ui/QuickChatSettingsFragment.kt rename to app/src/main/java/com/geeksville/mesh/ui/QuickChat.kt index 2a512df5..c247ca14 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/QuickChatSettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/QuickChat.kt @@ -17,10 +17,6 @@ package com.geeksville.mesh.ui -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -49,13 +45,10 @@ import androidx.compose.material.IconButton import androidx.compose.material.ListItem import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Scaffold import androidx.compose.material.Switch import androidx.compose.material.Text import androidx.compose.material.TextButton -import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.FastForward import androidx.compose.runtime.Composable @@ -66,9 +59,7 @@ 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.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 @@ -76,55 +67,15 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat 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.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) - setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground)) - setContent { - AppTheme { - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(id = R.string.quick_chat)) }, - navigationIcon = { - IconButton(onClick = { parentFragmentManager.popBackStack() }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - stringResource(id = R.string.navigate_back), - ) - } - }, - ) - }, - ) { innerPadding -> - QuickChatScreen( - modifier = Modifier.padding(innerPadding) - ) - } - } - } - } - } -} @Composable internal fun QuickChatScreen( diff --git a/app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt index c4d566dd..0a32baf9 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt @@ -23,36 +23,22 @@ import android.view.View import android.view.ViewGroup 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.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.core.os.bundleOf -import androidx.fragment.app.FragmentManager 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.databinding.ShareFragmentBinding 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 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("Messages"), Logging { @@ -67,10 +53,10 @@ class ShareFragment : ScreenFragment("Messages"), Logging { private fun shareMessage(contact: Contact) { debug("calling MessagesFragment filter:${contact.contactKey}") - parentFragmentManager.navigateToMessages( - contact.contactKey, - arguments?.getString("message").toString() - ) +// parentFragmentManager.navigateToMessages( +// contact.contactKey, +// arguments?.getString("message").toString() +// ) } private fun onClick(contact: Contact) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/ScannedQrCodeDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/ScannedQrCodeDialog.kt index 2b16eb23..cdb10f53 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/ScannedQrCodeDialog.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/ScannedQrCodeDialog.kt @@ -49,13 +49,30 @@ import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.AppOnlyProtos.ChannelSet import com.geeksville.mesh.R import com.geeksville.mesh.channelSet import com.geeksville.mesh.copy import com.geeksville.mesh.model.Channel +import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.components.config.ChannelSelection +@Composable +fun ScannedQrCodeDialog( + viewModel: UIViewModel, + incoming: ChannelSet, +) { + val channels by viewModel.channels.collectAsStateWithLifecycle() + + ScannedQrCodeDialog( + channels = channels, + incoming = incoming, + onDismiss = viewModel::clearRequestChannelUrl, + onConfirm = viewModel::setChannels, + ) +} + /** * Enables the user to select which channels to accept after scanning a QR code. */ diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt index 3cbd08b9..35072960 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt @@ -18,10 +18,6 @@ package com.geeksville.mesh.ui.map import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.content.res.AppCompatResources @@ -45,21 +41,17 @@ 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.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 @@ -71,8 +63,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 @@ -81,7 +71,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 @@ -104,27 +93,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, diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt index 77dab99b..82748ac6 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt @@ -17,10 +17,6 @@ package com.geeksville.mesh.ui.message -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -66,10 +62,8 @@ 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.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 @@ -80,95 +74,26 @@ 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.content.ContextCompat -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.ui.components.NodeKeyStatusIcon import com.geeksville.mesh.ui.components.NodeMenuAction import com.geeksville.mesh.ui.message.components.MessageList -import com.geeksville.mesh.ui.navigateToNavGraph import com.geeksville.mesh.ui.theme.AppTheme -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -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) - setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground)) - 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 ) { @@ -281,7 +206,11 @@ internal fun MessageScreen( when (action) { is NodeMenuAction.Remove -> viewModel.removeNode(action.node.num) is NodeMenuAction.Ignore -> viewModel.ignoreNode(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) @@ -324,6 +253,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, diff --git a/app/src/main/java/com/geeksville/mesh/ui/theme/Shape.kt b/app/src/main/java/com/geeksville/mesh/ui/theme/Shape.kt deleted file mode 100644 index 23b90bba..00000000 --- a/app/src/main/java/com/geeksville/mesh/ui/theme/Shape.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.geeksville.mesh.ui.theme - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes -import androidx.compose.ui.unit.dp - -val Shapes = Shapes( - small = RoundedCornerShape(4.dp), - medium = RoundedCornerShape(4.dp), - large = RoundedCornerShape(0.dp) -) diff --git a/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt b/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt index df4a464e..8b80b583 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt @@ -61,8 +61,6 @@ fun AppTheme( MaterialTheme( colors = colors, - typography = Typography, - shapes = Shapes, content = content ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/theme/Type.kt b/app/src/main/java/com/geeksville/mesh/ui/theme/Type.kt deleted file mode 100644 index 513a1f14..00000000 --- a/app/src/main/java/com/geeksville/mesh/ui/theme/Type.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.geeksville.mesh.ui.theme - -import androidx.compose.material.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -// Set of Material typography styles to start with -val Typography = Typography( - h3 = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 24.sp - ), - h4 = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 20.sp - ), - body1 = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp - ), - body2 = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, - fontSize = 16.sp - ), - /* Other default text styles to override - button = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.W500, - fontSize = 14.sp - ), - caption = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 12.sp - ) - */ -) diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 5ab60f6b..00000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml deleted file mode 100644 index 4b9d7df4..00000000 --- a/app/src/main/res/menu/menu_main.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/config/detekt/detekt-baseline.xml b/config/detekt/detekt-baseline.xml index a1b8943b..81aceeca 100644 --- a/config/detekt/detekt-baseline.xml +++ b/config/detekt/detekt-baseline.xml @@ -72,7 +72,6 @@ ConstructorParameterNaming:Packet.kt$Packet$@ColumnInfo(name = "contact_key") val contact_key: String ConstructorParameterNaming:Packet.kt$Packet$@ColumnInfo(name = "port_num") val port_num: Int ConstructorParameterNaming:Packet.kt$Packet$@ColumnInfo(name = "received_time") val received_time: Long - CyclomaticComplexMethod:MainActivity.kt$MainActivity$override fun onOptionsItemSelected(item: MenuItem): Boolean CyclomaticComplexMethod:MapFragment.kt$@Composable fun MapView( model: UIViewModel = viewModel(), ) CyclomaticComplexMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket) CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket) @@ -81,7 +80,6 @@ EmptyClassBlock:DebugLogFile.kt$BinaryLogFile${ } EmptyDefaultConstructor:SqlTileWriterExt.kt$SqlTileWriterExt$() EmptyDefaultConstructor:SqlTileWriterExt.kt$SqlTileWriterExt.SourceCount$() - EmptyFunctionBlock:MainActivity.kt$MainActivity.<no name provided>${ } EmptyFunctionBlock:NopInterface.kt$NopInterface${ } EmptyFunctionBlock:NsdManager.kt$<no name provided>${ } EmptyFunctionBlock:TrustAllX509TrustManager.kt$TrustAllX509TrustManager${} @@ -129,7 +127,6 @@ FinalNewline:SoftwareUpdateService.kt$com.geeksville.mesh.service.SoftwareUpdateService.kt FinalNewline:SqlTileWriterExt.kt$com.geeksville.mesh.util.SqlTileWriterExt.kt FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt - FinalNewline:Theme.kt$com.geeksville.mesh.ui.theme.Theme.kt FinalNewline:UsbBroadcastReceiver.kt$com.geeksville.mesh.repository.usb.UsbBroadcastReceiver.kt FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt ForbiddenComment:MapFragment.kt$// TODO: Accept filename input param from user @@ -158,10 +155,8 @@ LongMethod:EditListPreference.kt$@Composable inline fun <reified T> EditListPreference( title: String, list: List<T>, maxCount: Int, enabled: Boolean, keyboardActions: KeyboardActions, crossinline onValuesChanged: (List<T>) -> Unit, modifier: Modifier = Modifier, ) LongMethod:ExternalNotificationConfigItemList.kt$@Composable fun ExternalNotificationConfigItemList( ringtone: String, extNotificationConfig: ExternalNotificationConfig, enabled: Boolean, onSaveClicked: (ringtone: String, config: ExternalNotificationConfig) -> Unit, ) LongMethod:MQTTConfigItemList.kt$@Composable fun MQTTConfigItemList( mqttConfig: MQTTConfig, enabled: Boolean, onSaveClicked: (MQTTConfig) -> Unit, ) - LongMethod:MainActivity.kt$MainActivity$override fun onOptionsItemSelected(item: MenuItem): Boolean LongMethod:MapFragment.kt$@Composable fun MapView( model: UIViewModel = viewModel(), ) LongMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket) - LongMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigItemList( networkConfig: NetworkConfig, enabled: Boolean, onSaveClicked: (NetworkConfig) -> Unit, ) LongMethod:PowerConfigItemList.kt$@Composable fun PowerConfigItemList( powerConfig: PowerConfig, enabled: Boolean, onSaveClicked: (PowerConfig) -> Unit, ) LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket) LongMethod:SerialConfigItemList.kt$@Composable fun SerialConfigItemList( serialConfig: SerialConfig, enabled: Boolean, onSaveClicked: (SerialConfig) -> Unit, ) @@ -219,7 +214,7 @@ MagicNumber:ContactsFragment.kt$ContactsFragment.ActionModeCallback$8 MagicNumber:ContextServices.kt$33 MagicNumber:DataPacket.kt$DataPacket.CREATOR$16 - MagicNumber:DebugFragment.kt$3 + MagicNumber:Debug.kt$3 MagicNumber:DeviceVersion.kt$DeviceVersion$100 MagicNumber:DeviceVersion.kt$DeviceVersion$10000 MagicNumber:DownloadButton.kt$1.25f @@ -257,8 +252,6 @@ MagicNumber:LocationUtils.kt$6366000 MagicNumber:LocationUtils.kt$GPSFormat$3 MagicNumber:MQTTRepository.kt$MQTTRepository$512 - MagicNumber:MainActivity.kt$MainActivity$30000 - MagicNumber:MainActivity.kt$MainActivity$5 MagicNumber:MapFragment.kt$0.5f MagicNumber:MapFragment.kt$1.3 MagicNumber:MapFragment.kt$1000 @@ -396,12 +389,9 @@ MaxLineLength:LoRaConfigItemList.kt$value = if (isFocused || loraInput.overrideFrequency != 0f) loraInput.overrideFrequency else primaryChannel.radioFreq MaxLineLength:LocationRepository.kt$LocationRepository$info("Starting location updates with $providerList intervalMs=${intervalMs}ms and minDistanceM=${minDistanceM}m") MaxLineLength:MQTTRepository.kt$MQTTRepository.Companion$* - MaxLineLength:MainActivity.kt$/* UI design material setup instructions: https://material.io/develop/android/docs/getting-started/ dark theme (or use system eventually) https://material.io/develop/android/theming/dark/ NavDrawer is a standard draw which can be dragged in from the left or the menu icon inside the app title. Fragments: SettingsFragment shows "Settings" username shortname bluetooth pairing list (eventually misc device settings that are not channel related) Channel fragment "Channel" qr code, copy link button ch number misc other settings (eventually a way of choosing between past channels) ChatFragment "Messages" a text box to enter new texts a scrolling list of rows. each row is a text and a sender info layout NodeListFragment "Users" a node info row for every node ViewModels: BTScanModel starts/stops bt scan and provides list of devices (manages entire scan lifecycle) MeshModel contains: (manages entire service relationship) current received texts current radio macaddr current node infos (updated dynamically) eventually use bottom navigation bar to switch between, Members, Chat, Channel, Settings. https://material.io/develop/android/components/bottom-navigation-view/ use numbers of # chat messages and # of members in the badges. (per this recommendation to not use top tabs: https://ux.stackexchange.com/questions/102439/android-ux-when-to-use-bottom-navigation-and-when-to-use-tabs ) eventually: make a custom theme: https://github.com/material-components/material-components-android/tree/master/material-theme-builder */ MaxLineLength:MainActivity.kt$MainActivity$/* This problem can occur if we unbind, but there is already an onConnected job waiting to run. That job runs and then makes meshService != null again I think I've fixed this by cancelling connectionJob. We'll see! */ MaxLineLength:MainActivity.kt$MainActivity$// Old samsung phones have a race condition andthis might rarely fail. Which is probably find because the bind will be sufficient most of the time MaxLineLength:MainActivity.kt$MainActivity$// We now wait for the device to connect, once connected, we ask the user if they want to switch to the new channel - MaxLineLength:MainActivity.kt$MainActivity$// pager.offscreenPageLimit = 0 // Don't keep any offscreen pages around, because we want to make sure our bluetooth scanning stops - MaxLineLength:MainActivity.kt$MainActivity$MeshService.ConnectionState.DEVICE_SLEEP -> R.drawable.ic_twotone_cloud_upload_24 to R.string.device_sleeping MaxLineLength:MainActivity.kt$MainActivity$debug("Asked to open a channel URL - ask user if they want to switch to that channel. If so send the config to the radio") MaxLineLength:MeshService.kt$MeshService$* MaxLineLength:MeshService.kt$MeshService$* Send a mesh packet to the radio, if the radio is not currently connected this function will throw NotConnectedException @@ -480,8 +470,6 @@ MultiLineIfElse:BuildUtils.kt$BuildUtils$return false MultiLineIfElse:Channel.kt$Channel$"Custom" MultiLineIfElse:Channel.kt$Channel$when (loraConfig.modemPreset) { ModemPreset.SHORT_TURBO -> "ShortTurbo" ModemPreset.SHORT_FAST -> "ShortFast" ModemPreset.SHORT_SLOW -> "ShortSlow" ModemPreset.MEDIUM_FAST -> "MediumFast" ModemPreset.MEDIUM_SLOW -> "MediumSlow" ModemPreset.LONG_FAST -> "LongFast" ModemPreset.LONG_SLOW -> "LongSlow" ModemPreset.LONG_MODERATE -> "LongMod" ModemPreset.VERY_LONG_SLOW -> "VLongSlow" else -> "Invalid" } - MultiLineIfElse:ChannelFragment.kt$channelSet = copy { settings.add(it) } - MultiLineIfElse:ChannelFragment.kt$channelSet = copy { settings[index] = it } MultiLineIfElse:ChannelOption.kt$when (bandwidth) { 31 -> .03125f 62 -> .0625f 200 -> .203125f 400 -> .40625f 800 -> .8125f 1600 -> 1.6250f else -> bandwidth / 1000f } MultiLineIfElse:ContextServices.kt$MaterialAlertDialogBuilder(this) .setTitle(title) .setMessage(rationale) .setNeutralButton(R.string.cancel) { _, _ -> } .setPositiveButton(R.string.accept) { _, _ -> invokeFun() } .show() MultiLineIfElse:ContextServices.kt$invokeFun() @@ -514,7 +502,6 @@ MultiLineIfElse:NodeInfo.kt$MeshUser$hwModel.name.replace('_', '-').replace('p', '.').lowercase() MultiLineIfElse:NodeInfo.kt$MeshUser$null MultiLineIfElse:RadioConfigScreen.kt$AlertDialog( onDismissRequest = {}, shape = RoundedCornerShape(16.dp), backgroundColor = MaterialTheme.colors.background, title = { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, ) { Icon( imageVector = Icons.TwoTone.Warning, contentDescription = "warning", modifier = Modifier.padding(end = 8.dp) ) Text( text = "${stringResource(title)}?\n") Icon( imageVector = Icons.TwoTone.Warning, contentDescription = "warning", modifier = Modifier.padding(start = 8.dp) ) } }, buttons = { Row( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { TextButton( modifier = Modifier.weight(1f), onClick = { showDialog = false }, ) { Text(stringResource(R.string.cancel)) } Button( modifier = Modifier.weight(1f), onClick = { showDialog = false onClick() }, ) { Text(stringResource(R.string.send)) } } } ) - MultiLineIfElse:RadioConfigViewModel.kt$RadioConfigViewModel$try { setChannels(channelUrl) } catch (ex: Exception) { errormsg("DeviceProfile channel import error", ex) setResponseStateError(ex.customMessage) } MultiLineIfElse:RadioConfigViewModel.kt$RadioConfigViewModel$viewModelScope.launch { radioConfigRepository.replaceAllSettings(new) } MultiLineIfElse:RadioInterfaceService.kt$RadioInterfaceService$startInterface() MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$cb @@ -582,7 +569,6 @@ NewLineAtEndOfFile:SoftwareUpdateService.kt$com.geeksville.mesh.service.SoftwareUpdateService.kt NewLineAtEndOfFile:SqlTileWriterExt.kt$com.geeksville.mesh.util.SqlTileWriterExt.kt NewLineAtEndOfFile:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt - NewLineAtEndOfFile:Theme.kt$com.geeksville.mesh.ui.theme.Theme.kt NewLineAtEndOfFile:UsbBroadcastReceiver.kt$com.geeksville.mesh.repository.usb.UsbBroadcastReceiver.kt NewLineAtEndOfFile:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt NoBlankLineBeforeRbrace:BluetoothInterface.kt$BluetoothInterface$ @@ -617,8 +603,6 @@ NoWildcardImports:BluetoothInterface.kt$import com.geeksville.mesh.service.* NoWildcardImports:DeviceVersionTest.kt$import org.junit.Assert.* NoWildcardImports:ExampleUnitTest.kt$import org.junit.Assert.* - NoWildcardImports:MeshService.kt$import com.geeksville.mesh.* - NoWildcardImports:MeshService.kt$import com.geeksville.mesh.util.* NoWildcardImports:MockInterface.kt$import com.geeksville.mesh.* NoWildcardImports:PreferenceFooter.kt$import androidx.compose.foundation.layout.* NoWildcardImports:PreferenceFooter.kt$import androidx.compose.material.* @@ -635,7 +619,6 @@ ParameterListWrapping:SafeBluetooth.kt$SafeBluetooth$( c: BluetoothGattDescriptor, cont: Continuation<BluetoothGattDescriptor>, timeout: Long = 0 ) RethrowCaughtException:SyncContinuation.kt$Continuation$throw ex ReturnCount:ChannelOption.kt$internal fun LoRaConfig.radioFreq(channelNum: Int): Float - ReturnCount:MainActivity.kt$MainActivity$override fun onOptionsItemSelected(item: MenuItem): Boolean ReturnCount:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket) SpacingAroundColon:PreviewParameterProviders.kt$NodeInfoPreviewParameterProvider$: SpacingAroundCurly:AppPrefs.kt$FloatPref$} @@ -650,7 +633,6 @@ SwallowedException:DeviceVersion.kt$DeviceVersion$e: Exception SwallowedException:Exceptions.kt$ex: Throwable SwallowedException:MainActivity.kt$MainActivity$ex: BindFailedException - SwallowedException:MainActivity.kt$MainActivity$ex: IllegalStateException SwallowedException:MeshLog.kt$MeshLog$e: IOException SwallowedException:MeshService.kt$MeshService$e: Exception SwallowedException:MeshService.kt$MeshService$e: TimeoutException @@ -664,7 +646,7 @@ SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException TooGenericExceptionCaught:BTScanModel.kt$BTScanModel$ex: Throwable TooGenericExceptionCaught:BluetoothInterface.kt$BluetoothInterface$ex: Exception - TooGenericExceptionCaught:ChannelFragment.kt$ex: Exception + TooGenericExceptionCaught:Channel.kt$ex: Exception TooGenericExceptionCaught:ChannelSet.kt$ex: Throwable TooGenericExceptionCaught:DeviceVersion.kt$DeviceVersion$e: Exception TooGenericExceptionCaught:Exceptions.kt$ex: Throwable @@ -732,8 +714,6 @@ WildcardImport:BluetoothInterface.kt$import com.geeksville.mesh.service.* WildcardImport:DeviceVersionTest.kt$import org.junit.Assert.* WildcardImport:ExampleUnitTest.kt$import org.junit.Assert.* - WildcardImport:MeshService.kt$import com.geeksville.mesh.* - WildcardImport:MeshService.kt$import com.geeksville.mesh.util.* WildcardImport:MockInterface.kt$import com.geeksville.mesh.* WildcardImport:PreferenceFooter.kt$import androidx.compose.foundation.layout.* WildcardImport:PreferenceFooter.kt$import androidx.compose.material.*