3488 improve profile list (#3507)

Fixes #3488 

Working with lists from a profile page and in the normal "lists view"
from the drawer now use the same fragment view code.

(also) RFC regarding joining different list lists


![grafik](https://user-images.githubusercontent.com/1618905/229463168-397bd943-82d8-4e05-a8bf-9fcf22f6c1f9.png)
This commit is contained in:
UlrichKu 2024-01-03 21:17:03 +01:00 committed by GitHub
commit 0698333665
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 250 additions and 380 deletions

View file

@ -66,7 +66,7 @@ import com.keylesspalace.tusky.EditProfileActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment
import com.keylesspalace.tusky.components.account.list.ListSelectionFragment
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.report.ReportActivity
@ -991,7 +991,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
return true
}
R.id.action_add_or_remove_from_list -> {
ListsForAccountFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null)
ListSelectionFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null)
return true
}
R.id.action_mute_domain -> {

View file

@ -1,4 +1,4 @@
/* Copyright 2022 kyori19
/* Copyright Tusky Contributors
*
* This file is a part of Tusky.
*
@ -16,79 +16,99 @@
package com.keylesspalace.tusky.components.account.list
import android.app.Dialog
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.ListsActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.FragmentListsForAccountBinding
import com.keylesspalace.tusky.databinding.ItemAddOrRemoveFromListBinding
import com.keylesspalace.tusky.databinding.FragmentListsListBinding
import com.keylesspalace.tusky.databinding.ItemListBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
class ListsForAccountFragment : DialogFragment(), Injectable {
class ListSelectionFragment : DialogFragment(), Injectable {
interface ListSelectionListener {
fun onListSelected(list: MastoList)
}
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: ListsForAccountViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentListsForAccountBinding::bind)
private var _binding: FragmentListsListBinding? = null
// This property is only valid between onCreateDialog and onDestroyView
private val binding get() = _binding!!
private val adapter = Adapter()
private var selectListener: ListSelectionListener? = null
private var accountId: String? = null
override fun onAttach(context: Context) {
super.onAttach(context)
selectListener = context as? ListSelectionListener
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
viewModel.setup(requireArguments().getString(ARG_ACCOUNT_ID)!!)
accountId = requireArguments().getString(ARG_ACCOUNT_ID)
}
override fun onStart() {
super.onStart()
dialog?.apply {
window?.setLayout(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
)
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = requireContext()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_lists_for_account, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.listsView.layoutManager = LinearLayoutManager(view.context)
_binding = FragmentListsListBinding.inflate(layoutInflater)
binding.listsView.adapter = adapter
viewLifecycleOwner.lifecycleScope.launch {
val dialogBuilder = AlertDialog.Builder(context)
.setView(binding.root)
.setTitle(R.string.select_list_title)
.setNeutralButton(R.string.select_list_manage) { _, _ ->
val listIntent = Intent(context, ListsActivity::class.java)
startActivity(listIntent)
}
.setNegativeButton(if (accountId != null) R.string.button_done else android.R.string.cancel, null)
val dialog = dialogBuilder.create()
val showProgressBarJob = getProgressBarJob(binding.progressBar, 500)
showProgressBarJob.start()
// TODO change this to a (single) LoadState like elsewhere?
lifecycleScope.launch {
viewModel.states.collectLatest { states ->
binding.progressBar.hide()
showProgressBarJob.cancel()
if (states.isEmpty()) {
binding.messageView.show()
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.no_lists) {
load()
}
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.no_lists)
} else {
binding.listsView.show()
adapter.submitList(states)
@ -96,9 +116,11 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
}
}
viewLifecycleOwner.lifecycleScope.launch {
lifecycleScope.launch {
viewModel.loadError.collectLatest { error ->
Log.e(TAG, "failed to load lists", error)
binding.progressBar.hide()
showProgressBarJob.cancel()
binding.listsView.hide()
binding.messageView.apply {
show()
@ -107,20 +129,20 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
}
}
viewLifecycleOwner.lifecycleScope.launch {
lifecycleScope.launch {
viewModel.actionError.collectLatest { error ->
when (error.type) {
ActionError.Type.ADD -> {
Snackbar.make(binding.root, R.string.failed_to_add_to_list, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) {
viewModel.addAccountToList(error.listId)
viewModel.addAccountToList(accountId!!, error.listId)
}
.show()
}
ActionError.Type.REMOVE -> {
Snackbar.make(binding.root, R.string.failed_to_remove_from_list, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) {
viewModel.removeAccountFromList(error.listId)
viewModel.removeAccountFromList(accountId!!, error.listId)
}
.show()
}
@ -128,18 +150,35 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
}
}
binding.doneButton.setOnClickListener {
dismiss()
lifecycleScope.launch {
load()
}
load()
return dialog
}
private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch(
start = CoroutineStart.LAZY
) {
try {
delay(delayMs)
progressView.show()
awaitCancellation()
} finally {
progressView.hide()
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private fun load() {
binding.progressBar.show()
binding.listsView.hide()
binding.messageView.hide()
viewModel.load()
viewModel.load(accountId)
}
private object Differ : DiffUtil.ItemCallback<AccountListState>() {
@ -159,42 +198,55 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
}
inner class Adapter :
ListAdapter<AccountListState, BindingHolder<ItemAddOrRemoveFromListBinding>>(Differ) {
ListAdapter<AccountListState, BindingHolder<ItemListBinding>>(Differ) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemAddOrRemoveFromListBinding> {
val binding =
ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
): BindingHolder<ItemListBinding> {
return BindingHolder(ItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
override fun onBindViewHolder(holder: BindingHolder<ItemAddOrRemoveFromListBinding>, position: Int) {
override fun onBindViewHolder(holder: BindingHolder<ItemListBinding>, position: Int) {
val item = getItem(position)
holder.binding.listNameView.text = item.list.title
holder.binding.addButton.apply {
visible(!item.includesAccount)
setOnClickListener {
viewModel.addAccountToList(item.list.id)
holder.binding.listName.text = item.list.title
accountId?.let { accountId ->
holder.binding.addButton.apply {
visible(!item.includesAccount)
setOnClickListener {
viewModel.addAccountToList(accountId, item.list.id)
}
}
holder.binding.removeButton.apply {
visible(item.includesAccount)
setOnClickListener {
viewModel.removeAccountFromList(accountId, item.list.id)
}
}
}
holder.binding.removeButton.apply {
visible(item.includesAccount)
setOnClickListener {
viewModel.removeAccountFromList(item.list.id)
holder.itemView.setOnClickListener {
selectListener?.onListSelected(item.list)
accountId?.let { accountId ->
if (item.includesAccount) {
viewModel.removeAccountFromList(accountId, item.list.id)
} else {
viewModel.addAccountToList(accountId, item.list.id)
}
}
}
}
}
companion object {
private const val TAG = "ListsListFragment"
private const val ARG_ACCOUNT_ID = "accountId"
fun newInstance(accountId: String): ListsForAccountFragment {
fun newInstance(accountId: String?): ListSelectionFragment {
val args = Bundle().apply {
putString(ARG_ACCOUNT_ID, accountId)
}
return ListsForAccountFragment().apply { arguments = args }
return ListSelectionFragment().apply { arguments = args }
}
}
}

View file

@ -25,8 +25,6 @@ import at.connyduck.calladapter.networkresult.runCatching
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.first
@ -54,8 +52,6 @@ class ListsForAccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi
) : ViewModel() {
private lateinit var accountId: String
private val _states = MutableSharedFlow<List<AccountListState>>(1)
val states: SharedFlow<List<AccountListState>> = _states
@ -65,24 +61,21 @@ class ListsForAccountViewModel @Inject constructor(
private val _actionError = MutableSharedFlow<ActionError>(1)
val actionError: SharedFlow<ActionError> = _actionError
fun setup(accountId: String) {
this.accountId = accountId
}
fun load() {
fun load(accountId: String?) {
_loadError.resetReplayCache()
viewModelScope.launch {
runCatching {
val (all, includes) = listOf(
async { mastodonApi.getLists() },
async { mastodonApi.getListsIncludesAccount(accountId) }
).awaitAll()
val all = mastodonApi.getLists().getOrThrow()
var includes: List<MastoList> = emptyList()
if (accountId != null) {
includes = mastodonApi.getListsIncludesAccount(accountId).getOrThrow()
}
_states.emit(
all.getOrThrow().map { list ->
all.map { listState ->
AccountListState(
list = list,
includesAccount = includes.getOrThrow().any { it.id == list.id }
list = listState,
includesAccount = includes.any { it.id == listState.id }
)
}
)
@ -93,7 +86,9 @@ class ListsForAccountViewModel @Inject constructor(
}
}
fun addAccountToList(listId: String) {
// TODO there is no "progress" visible for these
fun addAccountToList(accountId: String, listId: String) {
_actionError.resetReplayCache()
viewModelScope.launch {
mastodonApi.addAccountToList(listId, listOf(accountId))
@ -114,7 +109,7 @@ class ListsForAccountViewModel @Inject constructor(
}
}
fun removeAccountFromList(listId: String) {
fun removeAccountFromList(accountId: String, listId: String) {
_actionError.resetReplayCache()
viewModelScope.launch {
mastodonApi.deleteAccountFromList(listId, listOf(accountId))