Add "Refresh" accessibility menu (#3121)

* Add "Refresh" accessibility menu to TimelineFragment

Per https://developer.android.com/reference/androidx/swiperefreshlayout/widget/SwipeRefreshLayout
the layout does not provide accessibility events, and a menu item should be
provided as an alternative method for refreshing the content.

In `TimelineFragment`:
- Implement the `MenuProvider` interface so it can populate the action bar
  menu in activities that host the fragment
- Create a "Refresh" menu item, and refresh the state when it is selected

`MainActivity` has to change how the menu is created, so that fragments
can add items to it.

In `MainActivity`:
- Call `setSupportActionBar` so `mainToolbar` participates in menus
- Implement the `MenuProvider` interface, and move menu creation there
- Set the title via supportActionBar

* Never show the refresh item as a menubar action

Per guidelines in https://developer.android.com/develop/ui/views/touch-and-input/swipe/add-swipe-interface#AddRefreshAction

* Add "Refresh" menu item for AccountMediaFragment

Also, fix the colour of the refresh progress indicator

* Implement "Refresh" for AnnouncementsActivity

* Add "Refresh" menu for ConversationsFragment

* Keep the tabs adapter over the life of the viewpager

Make `tabs` `var` instead of `val` in `MainPagerAdapter` so it can be updated
when tabs change.

Then detach the `tabLayoutMediator`, update the tabs, and call
`notifyItemRangeChanged` in `setupTabs()`.

This fixes a bug (not sure if it's this code, or in ViewPager2) where
assigning a new adapter to the view pager seemed to result in a leak of one
or more fragments. This wasn't user-visible, but it's a leak, and it becomes
user-visible when fragments want to display menus.

This also fixes two other bugs:

1. Be on the left-most tab. Scroll down a bit. Then modify the tabs at
   "Account preferences > tabs", but keep the left-most tab as-is.

   Then go back to MainActivity. Your reading position in the left-most
   tab has been jumped to the top.

2. Be on any non-left-most tab. Then modify the tab list by reordering tabs
   (adding/removing tabs is also OK).

   Then go back to MainActivity. Your tab selection has been overridden,
   and the left-most tab has been selected.

Because the fragments are not destroyed unnecessarily your reading position
is retained. And it remembers the tab you had selected, and as long as that
tab is still present you will be returned to it, even if it's changed
position in the list.

Fixes https://github.com/tuskyapp/Tusky/issues/3251

* Add "Refresh" menu for ScheduledStatusActivity

* Lint

* Add "Refresh" menu for SearchFragment / SearchActivity

* Explicitly set the searchview width

Using "collapseActionView" requires the user to press "Back" twice to exit
the activity, which is not acceptable.

* Move toolbar handling in to ViewThreadActivity

Previous code had the toolbar in the fragment's layout. Refactor to make
consistent with other activities, and move the toolbar in to the activity
layout.

Implement MenuProvider in ViewThreadFragment to adjust the menu in the
activity.

* Add "Refresh" menu to ViewThreadFragment

* Implement "Refresh" for ViewEditsFragment

* Lint

* Add "Refresh" menu to ReportStatusesFragment

* Add "Refresh" menu to NotificationsFragment

* Rename menu resource files

Be consistent with the layout resource files, which have an activity/fragment
prefix, then the lower_snake_case name of the activity or fragment it's for.

* Only enable refresh menu if swiptorefresh is enabled

Some timelines don't have swipetorefresh enabled (e.g., those shown on
AccountActivity). In those cases don't add the refresh menu, rely on the
hosting activity to provide it.

Update AccountActivity to provide the refresh menu item.
This commit is contained in:
Nik Clayton 2023-03-01 19:58:18 +01:00 committed by GitHub
parent f9588b48e2
commit 1b6108ca94
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 671 additions and 144 deletions

View file

@ -29,6 +29,8 @@ import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
@ -40,6 +42,7 @@ import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.view.GravityCompat
import androidx.core.view.MenuProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
@ -135,7 +138,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.launch
import javax.inject.Inject
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector {
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, MenuProvider {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@ -244,6 +247,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own
setContentView(binding.root)
setSupportActionBar(binding.mainToolbar)
glide = Glide.with(this)
@ -257,17 +261,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
loadDrawerAvatar(activeAccount.profilePictureUrl, true)
binding.mainToolbar.menu.add(R.string.action_search).apply {
setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary)
}
setOnMenuItemClickListener {
startActivity(SearchActivity.getIntent(this@MainActivity))
true
}
}
addMenuProvider(this)
binding.viewPager.reduceSwipeSensitivity()
@ -352,6 +346,26 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
draftsAlert.observeInContext(this, true)
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.activity_main, menu)
menu.findItem(R.id.action_search)?.apply {
icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary)
}
}
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_search -> {
startActivity(SearchActivity.getIntent(this@MainActivity))
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onResume() {
super.onResume()
val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
@ -745,7 +759,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0
binding.mainToolbar.title = tabs[activeTabPosition].title(this@MainActivity)
supportActionBar?.title = tabs[activeTabPosition].title(this@MainActivity)
binding.mainToolbar.setOnClickListener {
(tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
}

View file

@ -26,6 +26,7 @@ import android.graphics.drawable.LayerDrawable
import android.os.Bundle
import android.text.Editable
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
@ -35,6 +36,7 @@ import androidx.annotation.Px
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@ -88,6 +90,10 @@ import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.view.showMuteAccountDialog
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import java.text.NumberFormat
@ -97,7 +103,7 @@ import java.util.Locale
import javax.inject.Inject
import kotlin.math.abs
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, LinkListener {
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@ -153,6 +159,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
loadResources()
makeNotificationBarTransparent()
setContentView(binding.root)
addMenuProvider(this)
// Obtain information to fill out the profile.
viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!)
@ -414,14 +421,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
draftsAlert.observeInContext(this, true)
}
private fun onRefresh() {
viewModel.refresh()
adapter.refreshContent()
}
/**
* Setup swipe to refresh layout
*/
private fun setupRefreshLayout() {
binding.swipeToRefreshLayout.setOnRefreshListener {
viewModel.refresh()
adapter.refreshContent()
}
binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() }
viewModel.isRefreshing.observe(
this
) { isRefreshing ->
@ -731,7 +740,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.account_toolbar, menu)
val openAsItem = menu.findItem(R.id.action_open_as)
@ -796,7 +805,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
menu.removeItem(R.id.action_add_or_remove_from_list)
}
return super.onCreateOptionsMenu(menu)
menu.findItem(R.id.action_search)?.apply {
icon = IconicsDrawable(this@AccountActivity, GoogleMaterial.Icon.gmd_search).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.collapsingToolbar, android.R.attr.textColorPrimary)
}
}
}
private fun showFollowRequestPendingDialog() {
@ -884,7 +898,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
viewUrl(url)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
override fun onMenuItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_open_in_web -> {
// If the account isn't loaded yet, eat the input.
@ -949,6 +963,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
viewModel.changeShowReblogsState()
return true
}
R.id.action_refresh -> {
binding.swipeToRefreshLayout.isRefreshing = true
onRefresh()
return true
}
R.id.action_report -> {
loadedAccount?.let { loadedAccount ->
startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username))
@ -956,7 +975,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
return true
}
}
return super.onOptionsItemSelected(item)
return false
}
override fun getActionButton(): FloatingActionButton? {

View file

@ -16,15 +16,21 @@
package com.keylesspalace.tusky.components.account.media
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
@ -39,20 +45,22 @@ import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
/**
* Created by charlag on 26/10/2017.
*
* Fragment with multiple columns of media previews for the specified account.
*/
class AccountMediaFragment :
Fragment(R.layout.fragment_timeline),
RefreshableFragment,
MenuProvider,
Injectable {
@Inject
@ -73,6 +81,7 @@ class AccountMediaFragment :
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
@ -95,6 +104,8 @@ class AccountMediaFragment :
binding.recyclerView.adapter = adapter
binding.swipeRefreshLayout.isEnabled = false
binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
binding.statusView.visibility = View.GONE
@ -108,6 +119,10 @@ class AccountMediaFragment :
binding.statusView.hide()
binding.progressBar.hide()
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
}
if (adapter.itemCount == 0) {
when (loadState.refresh) {
is LoadState.NotLoading -> {
@ -133,6 +148,27 @@ class AccountMediaFragment :
}
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_account_media, menu)
menu.findItem(R.id.action_refresh)?.apply {
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_refresh -> {
binding.swipeRefreshLayout.isRefreshing = true
refreshContent()
true
}
else -> false
}
}
private fun onAttachmentClick(selected: AttachmentViewData, view: View) {
if (!selected.isRevealed) {
viewModel.revealAttachment(selected)

View file

@ -19,12 +19,17 @@ import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.PopupWindow
import androidx.activity.viewModels
import androidx.core.view.MenuProvider
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
@ -42,9 +47,18 @@ import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EmojiPicker
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import javax.inject.Inject
class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, OnEmojiSelectedListener, Injectable {
class AnnouncementsActivity :
BottomSheetActivity(),
AnnouncementActionListener,
OnEmojiSelectedListener,
MenuProvider,
Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -71,6 +85,7 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
addMenuProvider(this)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.apply {
@ -130,6 +145,27 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
binding.progressBar.show()
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.activity_announcements, menu)
menu.findItem(R.id.action_search)?.apply {
icon = IconicsDrawable(this@AnnouncementsActivity, GoogleMaterial.Icon.gmd_search).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.includedToolbar.toolbar, android.R.attr.textColorPrimary)
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_refresh -> {
binding.swipeRefreshLayout.isRefreshing = true
refreshAnnouncements()
true
}
else -> false
}
}
private fun refreshAnnouncements() {
viewModel.load()
binding.swipeRefreshLayout.isRefreshing = true

View file

@ -17,10 +17,14 @@ package com.keylesspalace.tusky.components.conversation
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.MenuProvider
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
@ -32,6 +36,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.autoDispose
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
@ -52,6 +57,10 @@ import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@ -61,7 +70,12 @@ import javax.inject.Inject
import kotlin.time.DurationUnit
import kotlin.time.toDuration
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
class ConversationsFragment :
SFragment(),
StatusActionListener,
Injectable,
ReselectableFragment,
MenuProvider {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -82,6 +96,8 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val statusDisplayOptions = StatusDisplayOptions(
@ -189,6 +205,27 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_conversations, menu)
menu.findItem(R.id.action_refresh)?.apply {
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_refresh -> {
binding.swipeRefreshLayout.isRefreshing = true
refreshContent()
true
}
else -> false
}
}
private fun setupRecyclerView() {
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(context)
@ -200,10 +237,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
}
private fun refreshContent() {
adapter.refresh()
}
private fun initSwipeToRefresh() {
binding.swipeRefreshLayout.setOnRefreshListener {
adapter.refresh()
}
binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}

View file

@ -16,17 +16,24 @@
package com.keylesspalace.tusky.components.report.fragments
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
@ -48,11 +55,20 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Injectable, AdapterHandler {
class ReportStatusesFragment :
Fragment(R.layout.fragment_report_statuses),
Injectable,
OnRefreshListener,
MenuProvider,
AdapterHandler {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -90,18 +106,42 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
handleClicks()
initStatusesView()
setupSwipeRefreshLayout()
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_report_statuses, menu)
menu.findItem(R.id.action_refresh)?.apply {
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_refresh -> {
binding.swipeRefreshLayout.isRefreshing = true
onRefresh()
true
}
else -> false
}
}
override fun onRefresh() {
snackbarErrorRetry?.dismiss()
adapter.refresh()
}
private fun setupSwipeRefreshLayout() {
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
binding.swipeRefreshLayout.setOnRefreshListener {
snackbarErrorRetry?.dismiss()
adapter.refresh()
}
binding.swipeRefreshLayout.setOnRefreshListener(this)
}
private fun initStatusesView() {

View file

@ -18,13 +18,18 @@ package com.keylesspalace.tusky.components.scheduled
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.MenuProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import autodispose2.androidx.lifecycle.autoDispose
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
@ -36,12 +41,21 @@ import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, Injectable {
class ScheduledStatusActivity :
BaseActivity(),
ScheduledStatusActionListener,
MenuProvider,
Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -51,13 +65,15 @@ class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, I
private val viewModel: ScheduledStatusViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(ActivityScheduledStatusBinding::inflate)
private val adapter = ScheduledStatusAdapter(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityScheduledStatusBinding.inflate(layoutInflater)
setContentView(binding.root)
addMenuProvider(this)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.run {
@ -113,6 +129,27 @@ class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, I
}
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.activity_announcements, menu)
menu.findItem(R.id.action_search)?.apply {
icon = IconicsDrawable(this@ScheduledStatusActivity, GoogleMaterial.Icon.gmd_search).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.includedToolbar.toolbar, android.R.attr.textColorPrimary)
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_refresh -> {
binding.swipeRefreshLayout.isRefreshing = true
refreshStatuses()
true
}
else -> false
}
}
private fun refreshStatuses() {
adapter.refresh()
}

View file

@ -20,8 +20,11 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuProvider
import androidx.preference.PreferenceManager
import com.google.android.material.tabs.TabLayoutMediator
import com.keylesspalace.tusky.BottomSheetActivity
@ -37,7 +40,7 @@ import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class SearchActivity : BottomSheetActivity(), HasAndroidInjector {
class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@ -59,6 +62,7 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector {
setDisplayShowHomeEnabled(true)
setDisplayShowTitleEnabled(false)
}
addMenuProvider(this)
setupPages()
handleIntent(intent)
}
@ -81,17 +85,18 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector {
handleIntent(intent)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.search_toolbar, menu)
val searchView = menu.findItem(R.id.action_search)
.actionView as SearchView
val searchViewMenuItem = menu.findItem(R.id.action_search)
searchViewMenuItem.expandActionView()
val searchView = searchViewMenuItem.actionView as SearchView
setupSearchView(searchView)
searchView.setQuery(viewModel.currentQuery, false)
}
return true
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return false
}
override fun finish() {
@ -116,17 +121,42 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector {
private fun setupSearchView(searchView: SearchView) {
searchView.setIconifiedByDefault(false)
searchView.setSearchableInfo((getSystemService(Context.SEARCH_SERVICE) as? SearchManager)?.getSearchableInfo(componentName))
searchView.requestFocus()
// SearchView has a bug. If it's displayed 'app:showAsAction="always"' it's too wide,
// pushing other icons (including the options menu '...' icon) off the edge of the
// screen.
//
// E.g., see:
//
// - https://stackoverflow.com/questions/41662373/android-toolbar-searchview-too-wide-to-move-other-items
// - https://stackoverflow.com/questions/51525088/how-to-control-size-of-a-searchview-in-toolbar
// - https://stackoverflow.com/questions/36976163/push-icons-away-when-expandig-searchview-in-android-toolbar
// - https://issuetracker.google.com/issues/36976484
//
// The fix is to use 'app:showAsAction="ifRoom|collapseActionView"' and then immediately
// expand it after inflating. That sets the width correctly.
//
// But if you do that code in AppCompatDelegateImpl activates, and when the user presses
// the "Back" button the SearchView is first set to its collapsed state. The user has to
// press "Back" again to exit the activity. This is clearly unacceptable.
//
// It appears to be impossible to override this behaviour on API level < 33.
//
// SearchView does allow you to specify the maximum width. So take the screen width,
// subtract 48dp * 2 (for the menu icon and back icon on either side), convert to pixels,
// and use that.
val pxScreenWidth = resources.displayMetrics.widthPixels
val pxBuffer = ((48 * 2) * resources.displayMetrics.density).toInt()
searchView.maxWidth = pxScreenWidth - pxBuffer
searchView.maxWidth = Integer.MAX_VALUE
searchView.requestFocus()
}
override fun androidInjector() = androidInjector
companion object {
const val TAG = "SearchActivity"
fun getIntent(context: Context) = Intent(context, SearchActivity::class.java)
}
}

View file

@ -1,9 +1,14 @@
package com.keylesspalace.tusky.components.search.fragments
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.paging.PagingData
@ -12,6 +17,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
@ -25,6 +31,10 @@ import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@ -34,7 +44,8 @@ abstract class SearchFragment<T : Any> :
Fragment(R.layout.fragment_search),
LinkListener,
Injectable,
SwipeRefreshLayout.OnRefreshListener {
SwipeRefreshLayout.OnRefreshListener,
MenuProvider {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -58,6 +69,7 @@ abstract class SearchFragment<T : Any> :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
initAdapter()
setupSwipeRefreshLayout()
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
subscribeObservables()
}
@ -95,6 +107,27 @@ abstract class SearchFragment<T : Any> :
}
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_timeline, menu)
menu.findItem(R.id.action_refresh)?.apply {
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_refresh -> {
binding.swipeRefreshLayout.isRefreshing = true
onRefresh()
true
}
else -> false
}
}
private fun initAdapter() {
binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL))
binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context)

View file

@ -18,10 +18,14 @@ package com.keylesspalace.tusky.components.timeline
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityManager
import androidx.core.content.ContextCompat
import androidx.core.view.MenuProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
@ -34,6 +38,7 @@ import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.autoDispose
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
@ -65,6 +70,10 @@ import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.flow.collectLatest
@ -79,7 +88,8 @@ class TimelineFragment :
StatusActionListener,
Injectable,
ReselectableFragment,
RefreshableFragment {
RefreshableFragment,
MenuProvider {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -198,6 +208,8 @@ class TimelineFragment :
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
setupSwipeRefreshLayout()
setupRecyclerView()
@ -293,6 +305,35 @@ class TimelineFragment :
}
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
if (isSwipeToRefreshEnabled) {
menuInflater.inflate(R.menu.fragment_timeline, menu)
menu.findItem(R.id.action_refresh)?.apply {
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
sizeDp = 20
colorInt =
MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
}
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_refresh -> {
if (isSwipeToRefreshEnabled) {
binding.swipeRefreshLayout.isRefreshing = true
refreshContent()
true
} else {
false
}
}
else -> false
}
}
/**
* Set the correct reading position in the timeline after the user clicked "Load more",
* assuming the reading position should be below the freshly-loaded statuses.

View file

@ -21,18 +21,28 @@ import android.os.Bundle
import androidx.fragment.app.commit
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityViewThreadBinding
import com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class ViewThreadActivity : BottomSheetActivity(), HasAndroidInjector {
private val binding by viewBinding(ActivityViewThreadBinding::inflate)
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_view_thread)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
setDisplayShowTitleEnabled(true)
}
val id = intent.getStringExtra(ID_EXTRA)!!
val url = intent.getStringExtra(URL_EXTRA)!!
val fragment =

View file

@ -18,10 +18,14 @@ package com.keylesspalace.tusky.components.viewthread
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.annotation.CheckResult
import androidx.core.view.MenuProvider
import androidx.fragment.app.commit
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
@ -59,7 +63,12 @@ import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, Injectable {
class ViewThreadFragment :
SFragment(),
OnRefreshListener,
StatusActionListener,
MenuProvider,
Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -74,6 +83,16 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
private var alwaysShowSensitiveMedia = false
private var alwaysOpenSpoiler = false
/**
* State of the "reveal" menu item that shows/hides content that is behind a content
* warning. Setting this invalidates the menu to redraw the menu item.
*/
private var revealButtonState = RevealButtonState.NO_BUTTON
set(value) {
field = value
requireActivity().invalidateMenu()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
thisThreadsStatusId = requireArguments().getString(ID_EXTRA)!!
@ -107,24 +126,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.toolbar.setNavigationOnClickListener {
activity?.onBackPressedDispatcher?.onBackPressed()
}
binding.toolbar.inflateMenu(R.menu.view_thread_toolbar)
binding.toolbar.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_reveal -> {
viewModel.toggleRevealButton()
true
}
R.id.action_open_in_web -> {
context?.openLink(requireArguments().getString(URL_EXTRA)!!)
true
}
else -> false
}
}
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
binding.swipeRefreshLayout.setOnRefreshListener(this)
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
@ -154,7 +156,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
viewModel.uiState.collect { uiState ->
when (uiState) {
is ThreadUiState.Loading -> {
updateRevealButton(RevealButtonState.NO_BUTTON)
revealButtonState = RevealButtonState.NO_BUTTON
binding.recyclerView.hide()
binding.statusView.hide()
@ -175,7 +177,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
adapter.submitList(listOf(uiState.statusViewDatum))
updateRevealButton(uiState.revealButton)
revealButtonState = uiState.revealButton
binding.swipeRefreshLayout.isRefreshing = false
binding.recyclerView.show()
@ -186,18 +188,24 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
initialProgressBar.cancel()
threadProgressBar.cancel()
updateRevealButton(RevealButtonState.NO_BUTTON)
revealButtonState = RevealButtonState.NO_BUTTON
binding.swipeRefreshLayout.isRefreshing = false
binding.recyclerView.hide()
binding.statusView.show()
if (uiState.throwable is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
binding.statusView.setup(
R.drawable.elephant_offline,
R.string.error_network
) {
viewModel.retry(thisThreadsStatusId)
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.statusView.setup(
R.drawable.elephant_error,
R.string.error_generic
) {
viewModel.retry(thisThreadsStatusId)
}
}
@ -216,11 +224,14 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
viewModel.isInitialLoad = false
// Ensure the top of the status is visible
(binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(uiState.detailedStatusPosition, 0)
(binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(
uiState.detailedStatusPosition,
0
)
}
}
updateRevealButton(uiState.revealButton)
revealButtonState = uiState.revealButton
binding.swipeRefreshLayout.isRefreshing = false
binding.recyclerView.show()
@ -247,6 +258,41 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
viewModel.loadThread(thisThreadsStatusId)
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_view_thread, menu)
val actionReveal = menu.findItem(R.id.action_reveal)
actionReveal.isVisible = revealButtonState != RevealButtonState.NO_BUTTON
actionReveal.setIcon(
when (revealButtonState) {
RevealButtonState.REVEAL -> R.drawable.ic_eye_24dp
else -> R.drawable.ic_hide_media_24dp
}
)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_reveal -> {
viewModel.toggleRevealButton()
true
}
R.id.action_open_in_web -> {
context?.openLink(requireArguments().getString(URL_EXTRA)!!)
true
}
R.id.action_refresh -> {
onRefresh()
true
}
else -> false
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.title_view_thread)
}
/**
* Create a job to implement a delayed-visible progress bar.
*
@ -269,13 +315,6 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
}
}
private fun updateRevealButton(state: RevealButtonState) {
val menuItem = binding.toolbar.menu.findItem(R.id.action_reveal)
menuItem.isVisible = state != RevealButtonState.NO_BUTTON
menuItem.setIcon(if (state == RevealButtonState.REVEAL) R.drawable.ic_eye_24dp else R.drawable.ic_hide_media_24dp)
}
override fun onRefresh() {
viewModel.refresh(thisThreadsStatusId)
}

View file

@ -17,15 +17,22 @@ package com.keylesspalace.tusky.components.viewthread.edits
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.LinearLayout
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
@ -38,11 +45,20 @@ import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener, Injectable {
class ViewEditsFragment :
Fragment(R.layout.fragment_view_thread),
LinkListener,
OnRefreshListener,
MenuProvider,
Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -54,12 +70,10 @@ class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener,
private lateinit var statusId: String
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
binding.toolbar.setNavigationOnClickListener {
activity?.onBackPressedDispatcher?.onBackPressed()
}
binding.toolbar.title = getString(R.string.title_edits)
binding.swipeRefreshLayout.isEnabled = false
binding.swipeRefreshLayout.setOnRefreshListener(this)
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(context)
@ -84,9 +98,11 @@ class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener,
binding.statusView.hide()
binding.initialProgressBar.show()
}
EditsUiState.Refreshing -> {}
is EditsUiState.Error -> {
Log.w(TAG, "failed to load edits", uiState.throwable)
binding.swipeRefreshLayout.isRefreshing = false
binding.recyclerView.hide()
binding.statusView.show()
binding.initialProgressBar.hide()
@ -102,6 +118,7 @@ class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener,
}
}
is EditsUiState.Success -> {
binding.swipeRefreshLayout.isRefreshing = false
binding.recyclerView.show()
binding.statusView.hide()
binding.initialProgressBar.hide()
@ -121,6 +138,36 @@ class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener,
viewModel.loadEdits(statusId)
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_view_edits, menu)
menu.findItem(R.id.action_refresh)?.apply {
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_refresh -> {
binding.swipeRefreshLayout.isRefreshing = true
onRefresh()
true
}
else -> false
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.title_edits)
}
override fun onRefresh() {
viewModel.loadEdits(statusId, force = true, refreshing = true)
}
override fun onViewAccount(id: String) {
bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id))
}

View file

@ -20,8 +20,9 @@ import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.entity.StatusEdit
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -30,25 +31,27 @@ class ViewEditsViewModel @Inject constructor(
) : ViewModel() {
private val _uiState: MutableStateFlow<EditsUiState> = MutableStateFlow(EditsUiState.Initial)
val uiState: Flow<EditsUiState>
get() = _uiState
val uiState: StateFlow<EditsUiState> = _uiState.asStateFlow()
fun loadEdits(statusId: String, force: Boolean = false, refreshing: Boolean = false) {
if (force || _uiState.value is EditsUiState.Initial) {
if (!refreshing) {
_uiState.value = EditsUiState.Loading
}
viewModelScope.launch {
api.statusEdits(statusId).fold(
{ edits ->
val sortedEdits = edits.sortedBy { edit -> edit.createdAt }.reversed()
_uiState.value = EditsUiState.Success(sortedEdits)
},
{ throwable ->
_uiState.value = EditsUiState.Error(throwable)
}
)
}
if (!force && _uiState.value !is EditsUiState.Initial) return
if (refreshing) {
_uiState.value = EditsUiState.Refreshing
} else {
_uiState.value = EditsUiState.Loading
}
viewModelScope.launch {
api.statusEdits(statusId).fold(
{ edits ->
val sortedEdits = edits.sortedBy { edit -> edit.createdAt }.reversed()
_uiState.value = EditsUiState.Success(sortedEdits)
},
{ throwable ->
_uiState.value = EditsUiState.Error(throwable)
}
)
}
}
}
@ -56,6 +59,10 @@ class ViewEditsViewModel @Inject constructor(
sealed interface EditsUiState {
object Initial : EditsUiState
object Loading : EditsUiState
// "Refreshing" state is necessary, otherwise a refresh state transition is Success -> Success,
// and state flows don't emit repeated states, so the UI never updates.
object Refreshing : EditsUiState
class Error(val throwable: Throwable) : EditsUiState
data class Success(
val edits: List<StatusEdit>

View file

@ -27,6 +27,9 @@ import android.os.Bundle;
import android.util.Log;
import android.util.SparseBooleanArray;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
@ -39,6 +42,7 @@ import androidx.appcompat.app.AlertDialog;
import androidx.arch.core.util.Function;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.util.Pair;
import androidx.core.view.MenuProvider;
import androidx.lifecycle.Lifecycle;
import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.AsyncDifferConfig;
@ -120,7 +124,9 @@ public class NotificationsFragment extends SFragment implements
StatusActionListener,
NotificationsAdapter.NotificationActionListener,
AccountActionListener,
Injectable, ReselectableFragment {
Injectable,
MenuProvider,
ReselectableFragment {
private static final String TAG = "NotificationF"; // logging tag
private static final int LOAD_AT_ONCE = 30;
@ -205,6 +211,8 @@ public class NotificationsFragment extends SFragment implements
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
requireActivity().addMenuProvider(this, getViewLifecycleOwner(), Lifecycle.State.RESUMED);
binding = FragmentTimelineNotificationsBinding.inflate(inflater, container, false);
@NonNull Context context = inflater.getContext(); // from inflater to silence warning
@ -287,6 +295,22 @@ public class NotificationsFragment extends SFragment implements
binding = null;
}
@Override
public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) {
menuInflater.inflate(R.menu.fragment_notifications, menu);
}
@Override
public boolean onMenuItemSelected(@NonNull MenuItem menuItem) {
if (menuItem.getItemId() == R.id.action_refresh) {
binding.swipeRefreshLayout.setRefreshing(true);
onRefresh();
return true;
}
return false;
}
private void updateFilterVisibility() {
CoordinatorLayout.LayoutParams params =
(CoordinatorLayout.LayoutParams) binding.swipeRefreshLayout.getLayoutParams();

View file

@ -4,22 +4,6 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="@dimen/actionbar_elevation">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationContentDescription="@string/abc_action_bar_up_description"
app:navigationIcon="?attr/homeAsUpIndicator"
app:title="@string/title_view_thread" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="640dp"

View file

@ -6,6 +6,23 @@
android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.components.viewthread.ViewThreadActivity">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="@dimen/actionbar_elevation"
app:elevationOverlayEnabled="false">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
style="@style/Widget.AppCompat.Toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:contentInsetStartWithNavigation="0dp"
app:layout_scrollFlags="scroll|enterAlways"
app:navigationContentDescription="@string/action_open_drawer" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container"
android:layout_width="match_parent"

View file

@ -4,22 +4,6 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="@dimen/actionbar_elevation">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationContentDescription="@string/abc_action_bar_up_description"
app:navigationIcon="?attr/homeAsUpIndicator"
app:title="@string/title_view_thread" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"

View file

@ -43,6 +43,11 @@
android:title="@string/action_hide_reblogs"
app:showAsAction="never" />
<item
android:id="@+id/action_refresh"
android:title="@string/action_refresh"
app:showAsAction="never" />
<item
android:id="@+id/action_report"
android:title="@string/action_report" />

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_refresh"
android:title="@string/action_refresh"
app:showAsAction="never" />
</menu>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_search"
android:title="@string/action_search"
app:showAsAction="ifRoom" />
</menu>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_refresh"
android:title="@string/action_refresh"
app:showAsAction="never" />
</menu>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_refresh"
android:title="@string/action_refresh"
app:showAsAction="never" />
</menu>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_refresh"
android:title="@string/action_refresh"
app:showAsAction="never" />
</menu>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_refresh"
android:title="@string/action_refresh"
app:showAsAction="never" />
</menu>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_refresh"
android:title="@string/action_refresh"
app:showAsAction="never" />
</menu>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_refresh"
android:title="@string/action_refresh"
app:showAsAction="never" />
</menu>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_refresh"
android:title="@string/action_refresh"
app:showAsAction="never" />
</menu>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_refresh"
android:title="@string/action_refresh"
app:showAsAction="never" />
</menu>

View file

@ -13,5 +13,8 @@
app:showAsAction="ifRoom"
android:icon="@drawable/ic_eye_24dp" />
<item
android:id="@+id/action_refresh"
android:title="@string/action_refresh"
app:showAsAction="never" />
</menu>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_search"
android:title="@string/action_search"

View file

@ -730,6 +730,7 @@
<string name="report_category_other">Other</string>
<string name="action_unfollow_hashtag_format">Unfollow #%s?</string>
<string name="action_refresh">Refresh</string>
<string name="mute_notifications_switch">Mute notifications</string>
<!-- Reading order preference -->