kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
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 nowpull/852/head
rodzic
a88ffbc0fb
commit
2bfda9784f
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
Ładowanie…
Reference in New Issue