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.repository.radio.BluetoothInterface
|
||||||
import com.geeksville.mesh.service.*
|
import com.geeksville.mesh.service.*
|
||||||
import com.geeksville.mesh.ui.*
|
import com.geeksville.mesh.ui.*
|
||||||
import com.geeksville.mesh.ui.map.MapFragment
|
|
||||||
import com.geeksville.mesh.util.Exceptions
|
import com.geeksville.mesh.util.Exceptions
|
||||||
import com.geeksville.mesh.util.LanguageUtils
|
import com.geeksville.mesh.util.LanguageUtils
|
||||||
import com.geeksville.mesh.util.getPackageInfoCompat
|
import com.geeksville.mesh.util.getPackageInfoCompat
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import com.google.android.material.tabs.TabLayout
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.CoroutineScope
|
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) {
|
private val tabsAdapter = object : FragmentStateAdapter(supportFragmentManager, lifecycle) {
|
||||||
|
override fun getItemCount(): Int = MainTab.entries.size
|
||||||
override fun getItemCount(): Int = tabInfos.size
|
override fun createFragment(position: Int): Fragment = MainTab.entries[position].content
|
||||||
override fun createFragment(position: Int): Fragment = tabInfos[position].content
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
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
|
// 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 ->
|
TabLayoutMediator(binding.tabLayout, binding.pager, false, false) { tab, position ->
|
||||||
// tab.text = tabInfos[position].text // I think it looks better with icons only
|
// 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()
|
}.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
|
// Handle any intent
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
}
|
}
|
||||||
|
@ -514,6 +492,7 @@ class MainActivity : AppCompatActivity(), Logging {
|
||||||
bluetoothViewModel.enabled.removeObservers(this)
|
bluetoothViewModel.enabled.removeObservers(this)
|
||||||
model.requestChannelUrl.removeObservers(this)
|
model.requestChannelUrl.removeObservers(this)
|
||||||
model.snackbarText.removeObservers(this)
|
model.snackbarText.removeObservers(this)
|
||||||
|
model.currentTab.removeObservers(this)
|
||||||
|
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
@ -557,6 +536,10 @@ class MainActivity : AppCompatActivity(), Logging {
|
||||||
if (text != null) model.clearSnackbarText()
|
if (text != null) model.clearSnackbarText()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model.currentTab.observe(this) {
|
||||||
|
binding.tabLayout.getTabAt(it.ordinal)?.select()
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bindMeshService()
|
bindMeshService()
|
||||||
} catch (ex: BindFailedException) {
|
} 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.datastore.RadioConfigRepository
|
||||||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||||
import com.geeksville.mesh.service.MeshService
|
import com.geeksville.mesh.service.MeshService
|
||||||
|
import com.geeksville.mesh.ui.MainTab
|
||||||
import com.geeksville.mesh.util.positionToMeter
|
import com.geeksville.mesh.util.positionToMeter
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -138,6 +139,9 @@ class UIViewModel @Inject constructor(
|
||||||
private val _quickChatActions = MutableStateFlow<List<QuickChatAction>>(emptyList())
|
private val _quickChatActions = MutableStateFlow<List<QuickChatAction>>(emptyList())
|
||||||
val quickChatActions: StateFlow<List<QuickChatAction>> = _quickChatActions
|
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)
|
// hardware info about our local device (can be null)
|
||||||
val myNodeInfo: StateFlow<MyNodeInfo?> get() = nodeDB.myNodeInfo
|
val myNodeInfo: StateFlow<MyNodeInfo?> get() = nodeDB.myNodeInfo
|
||||||
val ourNodeInfo: StateFlow<NodeInfo?> get() = nodeDB.ourNodeInfo
|
val ourNodeInfo: StateFlow<NodeInfo?> get() = nodeDB.ourNodeInfo
|
||||||
|
@ -584,4 +588,16 @@ class UIViewModel @Inject constructor(
|
||||||
requestIds.update { it.apply { put(data.requestId, true) } }
|
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.android.Logging
|
||||||
import com.geeksville.mesh.DataPacket
|
import com.geeksville.mesh.DataPacket
|
||||||
import com.geeksville.mesh.MessageStatus
|
import com.geeksville.mesh.MessageStatus
|
||||||
|
import com.geeksville.mesh.NodeInfo
|
||||||
import com.geeksville.mesh.R
|
import com.geeksville.mesh.R
|
||||||
import com.geeksville.mesh.database.entity.Packet
|
import com.geeksville.mesh.database.entity.Packet
|
||||||
import com.geeksville.mesh.database.entity.QuickChatAction
|
import com.geeksville.mesh.database.entity.QuickChatAction
|
||||||
|
@ -126,6 +127,7 @@ class MessagesFragment : Fragment(), Logging {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the username chip for my messages
|
// Hide the username chip for my messages
|
||||||
if (isLocal) {
|
if (isLocal) {
|
||||||
holder.username.visibility = View.GONE
|
holder.username.visibility = View.GONE
|
||||||
|
@ -134,7 +136,12 @@ class MessagesFragment : Fragment(), Logging {
|
||||||
// If we can't find the sender, just use the ID
|
// If we can't find the sender, just use the ID
|
||||||
val user = node?.user
|
val user = node?.user
|
||||||
holder.username.text = user?.shortName ?: msg.from
|
holder.username.text = user?.shortName ?: msg.from
|
||||||
|
|
||||||
|
holder.username.setOnClickListener {
|
||||||
|
node?.let { openNodeInfo(it) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.errorMessage != null) {
|
if (msg.errorMessage != null) {
|
||||||
context?.let { holder.card.setCardBackgroundColor(Color.RED) }
|
context?.let { holder.card.setCardBackgroundColor(Color.RED) }
|
||||||
holder.messageText.text = msg.errorMessage
|
holder.messageText.text = msg.errorMessage
|
||||||
|
@ -414,4 +421,10 @@ class MessagesFragment : Fragment(), Logging {
|
||||||
actionMode = null
|
actionMode = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun openNodeInfo(node: NodeInfo) {
|
||||||
|
parentFragmentManager.popBackStack()
|
||||||
|
model.focusUserNode(node)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package com.geeksville.mesh.ui
|
package com.geeksville.mesh.ui
|
||||||
|
|
||||||
|
import android.animation.ValueAnimator
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.SpannableString
|
import android.text.SpannableString
|
||||||
import android.text.method.LinkMovementMethod
|
import android.text.method.LinkMovementMethod
|
||||||
|
@ -9,14 +11,18 @@ import android.view.LayoutInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.animation.LinearInterpolator
|
||||||
import androidx.appcompat.widget.PopupMenu
|
import androidx.appcompat.widget.PopupMenu
|
||||||
|
import androidx.core.animation.doOnEnd
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.os.bundleOf
|
import androidx.core.os.bundleOf
|
||||||
import androidx.core.text.HtmlCompat
|
import androidx.core.text.HtmlCompat
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.fragment.app.setFragmentResult
|
import androidx.fragment.app.setFragmentResult
|
||||||
import androidx.lifecycle.asLiveData
|
import androidx.lifecycle.asLiveData
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.geeksville.mesh.NodeInfo
|
import com.geeksville.mesh.NodeInfo
|
||||||
import com.geeksville.mesh.R
|
import com.geeksville.mesh.R
|
||||||
|
@ -27,6 +33,9 @@ import com.geeksville.mesh.model.UIViewModel
|
||||||
import com.geeksville.mesh.util.formatAgo
|
import com.geeksville.mesh.util.formatAgo
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -56,11 +65,34 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
||||||
val powerIcon = itemView.batteryIcon
|
val powerIcon = itemView.batteryIcon
|
||||||
val signalView = itemView.signalView
|
val signalView = itemView.signalView
|
||||||
val envMetrics = itemView.envMetrics
|
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 val nodesAdapter = object : RecyclerView.Adapter<ViewHolder>() {
|
||||||
|
|
||||||
private var nodes = arrayOf<NodeInfo>()
|
var nodes = arrayOf<NodeInfo>()
|
||||||
|
private set
|
||||||
|
|
||||||
private fun CharSequence.strike() = SpannableString(this).apply {
|
private fun CharSequence.strike() = SpannableString(this).apply {
|
||||||
setSpan(StrikethroughSpan(), 0, this.length, SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE)
|
setSpan(StrikethroughSpan(), 0, this.length, SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
@ -347,10 +379,46 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
||||||
|
|
||||||
model.clearTracerouteResponse()
|
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() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
_binding = null
|
_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
|
<com.google.android.material.card.MaterialCardView
|
||||||
style="@style/Widget.App.CardView"
|
style="@style/Widget.App.CardView"
|
||||||
|
android:id="@+id/nodeCard"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_margin="8dp">
|
android:layout_margin="8dp"
|
||||||
|
>
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|
|
@ -11,6 +11,13 @@
|
||||||
<string name="sample_coords" translatable="false">55.332244 34.442211</string>
|
<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>
|
<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_name">Channel Name</string>
|
||||||
<string name="channel_options">Channel options</string>
|
<string name="channel_options">Channel options</string>
|
||||||
<string name="qr_code">QR code</string>
|
<string name="qr_code">QR code</string>
|
||||||
|
|
Ładowanie…
Reference in New Issue