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
pull/852/head
Davis 2024-02-13 14:32:52 -07:00 zatwierdzone przez GitHub
rodzic a88ffbc0fb
commit 2bfda9784f
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
7 zmienionych plików z 167 dodań i 37 usunięć

Wyświetl plik

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

Wyświetl plik

@ -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<List<QuickChatAction>>(emptyList())
val quickChatActions: StateFlow<List<QuickChatAction>> = _quickChatActions
private val _focusedNode = MutableStateFlow<NodeInfo?>(null)
val focusedNode: StateFlow<NodeInfo?> = _focusedNode
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow<MyNodeInfo?> get() = nodeDB.myNodeInfo
val ourNodeInfo: StateFlow<NodeInfo?> 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<MainTab> get() = _currentTab
fun setCurrentTab(tab: MainTab) {
_currentTab.value = tab
}
fun focusUserNode(node: NodeInfo?) {
_currentTab.value = MainTab.USERS
_focusedNode.value = node
}
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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<ViewHolder>() {
private var nodes = arrayOf<NodeInfo>()
var nodes = arrayOf<NodeInfo>()
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
}
}
}
}

Wyświetl plik

@ -7,9 +7,11 @@
<com.google.android.material.card.MaterialCardView
style="@style/Widget.App.CardView"
android:id="@+id/nodeCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp">
android:layout_margin="8dp"
>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"

Wyświetl plik

@ -11,6 +11,13 @@
<string name="sample_coords" translatable="false">55.332244 34.442211</string>
<string name="sample_message" translatable="false">hey I found the cache, it is over here next to the big tiger. I\'m kinda scared.</string>
<!-- Main Tabs -->
<string name="main_tab_lbl_messages" translatable="false">Messages</string>
<string name="main_tab_lbl_users" translatable="false">Users</string>
<string name="main_tab_lbl_map" translatable="false">Map</string>
<string name="main_tab_lbl_channel" translatable="false">Channel</string>
<string name="main_tab_lbl_settings" translatable="false">Settings</string>
<string name="channel_name">Channel Name</string>
<string name="channel_options">Channel options</string>
<string name="qr_code">QR code</string>