From 2bfda9784ff07e5f82fd042f523463ec4ad3090f Mon Sep 17 00:00:00 2001 From: Davis Date: Tue, 13 Feb 2024 14:32:52 -0700 Subject: [PATCH] Feature: Jump to node info from message (#844) * Highlight the node in the node list tab when the user taps on the node chip in messages * Represent main tabs as enum for more reliable referencing * Extract tab labels to string resources for easier translation Annotate resource IDs with their corresponding Android types * Index off nodes actually in the adapter since they are sorted * Update viewmodel when tab changes to prevent jumping to other tabs in onResume * Mark strings as non-translatable for now --- .../java/com/geeksville/mesh/MainActivity.kt | 53 +++++--------- .../java/com/geeksville/mesh/model/UIState.kt | 16 +++++ .../java/com/geeksville/mesh/ui/MainTab.kt | 41 +++++++++++ .../geeksville/mesh/ui/MessagesFragment.kt | 13 ++++ .../com/geeksville/mesh/ui/UsersFragment.kt | 70 ++++++++++++++++++- .../main/res/layout/adapter_node_layout.xml | 4 +- app/src/main/res/values/strings.xml | 7 ++ 7 files changed, 167 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/MainTab.kt diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 95cba78b0..acbbb18fd 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -39,12 +39,12 @@ import com.geeksville.mesh.model.toChannelSet import com.geeksville.mesh.repository.radio.BluetoothInterface import com.geeksville.mesh.service.* import com.geeksville.mesh.ui.* -import com.geeksville.mesh.ui.map.MapFragment 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 @@ -140,40 +140,9 @@ class MainActivity : AppCompatActivity(), Logging { } } - 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 getItemCount(): Int = MainTab.entries.size + override fun createFragment(position: Int): Fragment = MainTab.entries[position].content } override fun onCreate(savedInstanceState: Bundle?) { @@ -209,9 +178,18 @@ class MainActivity : AppCompatActivity(), Logging { // 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) + tab.icon = ContextCompat.getDrawable(this, MainTab.entries[position].icon) }.attach() + binding.tabLayout.addOnTabSelectedListener(object: TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab?) { + val mainTab = MainTab.entries[tab?.position ?: 0] + model.setCurrentTab(mainTab) + } + override fun onTabUnselected(tab: TabLayout.Tab?) { } + override fun onTabReselected(tab: TabLayout.Tab?) { } + }) + // Handle any intent handleIntent(intent) } @@ -514,6 +492,7 @@ class MainActivity : AppCompatActivity(), Logging { bluetoothViewModel.enabled.removeObservers(this) model.requestChannelUrl.removeObservers(this) model.snackbarText.removeObservers(this) + model.currentTab.removeObservers(this) super.onStop() } @@ -557,6 +536,10 @@ class MainActivity : AppCompatActivity(), Logging { if (text != null) model.clearSnackbarText() } + model.currentTab.observe(this) { + binding.tabLayout.getTabAt(it.ordinal)?.select() + } + try { bindMeshService() } catch (ex: BindFailedException) { 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 a2e230f2a..09087ba86 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -27,6 +27,7 @@ import com.geeksville.mesh.database.PacketRepository import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.service.MeshService +import com.geeksville.mesh.ui.MainTab import com.geeksville.mesh.util.positionToMeter import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -138,6 +139,9 @@ class UIViewModel @Inject constructor( private val _quickChatActions = MutableStateFlow>(emptyList()) val quickChatActions: StateFlow> = _quickChatActions + private val _focusedNode = MutableStateFlow(null) + val focusedNode: StateFlow = _focusedNode + // hardware info about our local device (can be null) val myNodeInfo: StateFlow get() = nodeDB.myNodeInfo val ourNodeInfo: StateFlow get() = nodeDB.ourNodeInfo @@ -584,4 +588,16 @@ class UIViewModel @Inject constructor( requestIds.update { it.apply { put(data.requestId, true) } } } } + + private val _currentTab = MutableLiveData(MainTab.MESSAGES) + val currentTab: LiveData get() = _currentTab + + fun setCurrentTab(tab: MainTab) { + _currentTab.value = tab + } + + fun focusUserNode(node: NodeInfo?) { + _currentTab.value = MainTab.USERS + _focusedNode.value = node + } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/MainTab.kt b/app/src/main/java/com/geeksville/mesh/ui/MainTab.kt new file mode 100644 index 000000000..971c901fe --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/MainTab.kt @@ -0,0 +1,41 @@ +package com.geeksville.mesh.ui + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import com.geeksville.mesh.R +import com.geeksville.mesh.ui.map.MapFragment + +enum class MainTab( + @StringRes + val text: Int, + @DrawableRes + val icon: Int, + val content: Fragment +) { + MESSAGES( + R.string.main_tab_lbl_messages, + R.drawable.ic_twotone_message_24, + ContactsFragment() + ), + USERS( + R.string.main_tab_lbl_users, + R.drawable.ic_twotone_people_24, + UsersFragment() + ), + MAP( + R.string.main_tab_lbl_map, + R.drawable.ic_twotone_map_24, + MapFragment() + ), + CHANNEL( + R.string.main_tab_lbl_channel, + R.drawable.ic_twotone_contactless_24, + ChannelFragment() + ), + SETTINGS( + R.string.main_tab_lbl_settings, + R.drawable.ic_twotone_settings_applications_24, + SettingsFragment() + ); +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt index d5c1cbf4d..5d10862d0 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt @@ -20,6 +20,7 @@ import androidx.recyclerview.widget.RecyclerView import com.geeksville.mesh.android.Logging import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MessageStatus +import com.geeksville.mesh.NodeInfo import com.geeksville.mesh.R import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.database.entity.QuickChatAction @@ -126,6 +127,7 @@ class MessagesFragment : Fragment(), Logging { ) } } + // Hide the username chip for my messages if (isLocal) { holder.username.visibility = View.GONE @@ -134,7 +136,12 @@ class MessagesFragment : Fragment(), Logging { // If we can't find the sender, just use the ID val user = node?.user holder.username.text = user?.shortName ?: msg.from + + holder.username.setOnClickListener { + node?.let { openNodeInfo(it) } + } } + if (msg.errorMessage != null) { context?.let { holder.card.setCardBackgroundColor(Color.RED) } holder.messageText.text = msg.errorMessage @@ -414,4 +421,10 @@ class MessagesFragment : Fragment(), Logging { actionMode = null } } + + private fun openNodeInfo(node: NodeInfo) { + parentFragmentManager.popBackStack() + model.focusUserNode(node) + } + } diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt index 4fbbd9b04..52ea1ee7b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -1,6 +1,8 @@ package com.geeksville.mesh.ui +import android.animation.ValueAnimator import android.content.res.ColorStateList +import android.graphics.Color import android.os.Bundle import android.text.SpannableString import android.text.method.LinkMovementMethod @@ -9,14 +11,18 @@ import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.view.animation.LinearInterpolator import androidx.appcompat.widget.PopupMenu +import androidx.core.animation.doOnEnd import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.text.HtmlCompat import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResult import androidx.lifecycle.asLiveData +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView import com.geeksville.mesh.NodeInfo import com.geeksville.mesh.R @@ -27,6 +33,9 @@ import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.util.formatAgo import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.net.URLEncoder @AndroidEntryPoint @@ -56,11 +65,34 @@ class UsersFragment : ScreenFragment("Users"), Logging { val powerIcon = itemView.batteryIcon val signalView = itemView.signalView val envMetrics = itemView.envMetrics + val background = itemView.nodeCard + + fun blink() { + val bg = background.backgroundTintList + ValueAnimator.ofArgb( + Color.parseColor("#00FFFFFF"), + Color.parseColor("#33FFFFFF") + ).apply { + interpolator = LinearInterpolator() + startDelay = 500 + duration = 250 + repeatCount = 3 + repeatMode = ValueAnimator.REVERSE + addUpdateListener { + background.backgroundTintList = ColorStateList.valueOf(it.animatedValue as Int) + } + start() + doOnEnd { + background.backgroundTintList = bg + } + } + } } private val nodesAdapter = object : RecyclerView.Adapter() { - private var nodes = arrayOf() + var nodes = arrayOf() + private set private fun CharSequence.strike() = SpannableString(this).apply { setSpan(StrikethroughSpan(), 0, this.length, SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE) @@ -347,10 +379,46 @@ class UsersFragment : ScreenFragment("Users"), Logging { model.clearTracerouteResponse() } + + model.focusedNode.asLiveData().observe(viewLifecycleOwner) { node -> + val idx = nodesAdapter.nodes.indexOfFirst { + it.user?.id == node?.user?.id + } + + if (idx < 1) return@observe + + lifecycleScope.launch { + binding.nodeListView.layoutManager?.smoothScrollToTop(idx) + val vh = binding.nodeListView.findViewHolderForLayoutPosition(idx) + (vh as? ViewHolder)?.blink() + model.focusUserNode(null) + } + } } override fun onDestroyView() { super.onDestroyView() _binding = null } + + /** + * Scrolls the recycler view until the item at [position] is at the top of the view, then waits + * until the scrolling is finished. + */ + private suspend fun RecyclerView.LayoutManager.smoothScrollToTop(position: Int) { + this.startSmoothScroll( + object : LinearSmoothScroller(requireContext()) { + override fun getVerticalSnapPreference(): Int { + return SNAP_TO_START + } + }.apply { + targetPosition = position + } + ) + withContext(Dispatchers.Default) { + while (this@smoothScrollToTop.isSmoothScrolling) { + // noop + } + } + } } diff --git a/app/src/main/res/layout/adapter_node_layout.xml b/app/src/main/res/layout/adapter_node_layout.xml index e56700fcc..7ddbdf324 100644 --- a/app/src/main/res/layout/adapter_node_layout.xml +++ b/app/src/main/res/layout/adapter_node_layout.xml @@ -7,9 +7,11 @@ + android:layout_margin="8dp" + > 55.332244 34.442211 hey I found the cache, it is over here next to the big tiger. I\'m kinda scared. + + Messages + Users + Map + Channel + Settings + Channel Name Channel options QR code