List editing (#1104)
* List editing groundwork * Add ability to add/remove accounts from list, delete lists * Rename list, improve lists UI * Add error handling, extract strings * Revert gradle.properties * Apply feedback suggestions * Apply feedback * Update license header
This commit is contained in:
parent
08c1bbd253
commit
520e0d6e7a
23 changed files with 1047 additions and 222 deletions
|
@ -0,0 +1,289 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.State
|
||||
import com.squareup.picasso.Picasso
|
||||
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import com.uber.autodispose.autoDisposable
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.android.extensions.LayoutContainer
|
||||
import kotlinx.android.synthetic.main.fragment_accounts_in_list.*
|
||||
import kotlinx.android.synthetic.main.item_follow_request.*
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
private typealias AccountInfo = Pair<Account, Boolean>
|
||||
|
||||
class AccountsInListFragment : DialogFragment(), Injectable {
|
||||
|
||||
companion object {
|
||||
private const val LIST_ID_ARG = "listId"
|
||||
private const val LIST_NAME_ARG = "listName"
|
||||
|
||||
@JvmStatic
|
||||
fun newInstance(listId: String, listName: String): AccountsInListFragment {
|
||||
val args = Bundle().apply {
|
||||
putString(LIST_ID_ARG, listId)
|
||||
putString(LIST_NAME_ARG, listName)
|
||||
}
|
||||
return AccountsInListFragment().apply { arguments = args }
|
||||
}
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
lateinit var viewModel: AccountsInListViewModel
|
||||
|
||||
private lateinit var listId: String
|
||||
private lateinit var listName: String
|
||||
private val adapter = Adapter()
|
||||
private val searchAdapter = SearchAdapter()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(DialogFragment.STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
|
||||
viewModel = viewModelFactory.create(AccountsInListViewModel::class.java)
|
||||
val args = arguments!!
|
||||
listId = args.getString(LIST_ID_ARG)!!
|
||||
listName = args.getString(LIST_NAME_ARG)!!
|
||||
|
||||
viewModel.load(listId)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
dialog?.apply {
|
||||
// Stretch dialog to the window
|
||||
window?.setLayout(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_accounts_in_list, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
accountsRecycler.layoutManager = LinearLayoutManager(view.context)
|
||||
accountsRecycler.adapter = adapter
|
||||
|
||||
accountsSearchRecycler.layoutManager = LinearLayoutManager(view.context)
|
||||
accountsSearchRecycler.adapter = searchAdapter
|
||||
|
||||
viewModel.state
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDisposable(from(this))
|
||||
.subscribe { state ->
|
||||
adapter.submitList(state.accounts.asRightOrNull() ?: listOf())
|
||||
|
||||
when (state.accounts) {
|
||||
is Either.Right -> messageView.hide()
|
||||
is Either.Left -> handleError(state.accounts.value)
|
||||
}
|
||||
|
||||
setupSearchView(state)
|
||||
}
|
||||
|
||||
searchView.isSubmitButtonEnabled = true
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
viewModel.search(query ?: "")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
// Close event is not sent so we use this instead
|
||||
if (newText.isNullOrBlank()) {
|
||||
viewModel.search("")
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun setupSearchView(state: State) {
|
||||
if (state.searchResult == null) {
|
||||
searchAdapter.submitList(listOf())
|
||||
accountsSearchRecycler.hide()
|
||||
} else {
|
||||
val listAccounts = state.accounts.asRightOrNull() ?: listOf()
|
||||
val newList = state.searchResult.map { acc ->
|
||||
acc to listAccounts.contains(acc)
|
||||
}
|
||||
searchAdapter.submitList(newList)
|
||||
accountsSearchRecycler.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleError(error: Throwable) {
|
||||
messageView.show()
|
||||
val retryAction = { _: View ->
|
||||
messageView.hide()
|
||||
viewModel.load(listId)
|
||||
}
|
||||
if (error is IOException) {
|
||||
messageView.setup(R.drawable.elephant_offline,
|
||||
R.string.error_network, retryAction)
|
||||
} else {
|
||||
messageView.setup(R.drawable.elephant_error,
|
||||
R.string.error_generic, retryAction)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRemoveFromList(accountId: String) {
|
||||
viewModel.deleteAccountFromList(listId, accountId)
|
||||
}
|
||||
|
||||
private fun onAddToList(account: Account) {
|
||||
viewModel.addAccountToList(listId, account)
|
||||
}
|
||||
|
||||
private object AccountDiffer : DiffUtil.ItemCallback<Account>() {
|
||||
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean {
|
||||
return oldItem.deepEquals(newItem)
|
||||
}
|
||||
}
|
||||
|
||||
inner class Adapter : ListAdapter<Account, Adapter.ViewHolder>(AccountDiffer) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_follow_request, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView),
|
||||
View.OnClickListener, LayoutContainer {
|
||||
|
||||
override val containerView = itemView
|
||||
|
||||
init {
|
||||
acceptButton.hide()
|
||||
rejectButton.setOnClickListener(this)
|
||||
rejectButton.contentDescription =
|
||||
itemView.context.getString(R.string.action_remove_from_list)
|
||||
}
|
||||
|
||||
fun bind(account: Account) {
|
||||
usernameTextView.text = account.username
|
||||
displayNameTextView.text = account.displayName
|
||||
Picasso.with(avatar.context)
|
||||
.load(account.avatar)
|
||||
.fit()
|
||||
.placeholder(R.drawable.avatar_default)
|
||||
.into(avatar)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
onRemoveFromList(getItem(adapterPosition).id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object SearchDiffer : DiffUtil.ItemCallback<AccountInfo>() {
|
||||
override fun areItemsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean {
|
||||
return oldItem.second == newItem.second
|
||||
&& oldItem.first.deepEquals(newItem.first)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
inner class SearchAdapter : ListAdapter<AccountInfo, SearchAdapter.ViewHolder>(SearchDiffer) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_follow_request, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val (account, inAList) = getItem(position)
|
||||
holder.bind(account, inAList)
|
||||
|
||||
}
|
||||
|
||||
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView),
|
||||
View.OnClickListener, LayoutContainer {
|
||||
|
||||
override val containerView = itemView
|
||||
|
||||
fun bind(account: Account, inAList: Boolean) {
|
||||
usernameTextView.text = account.username
|
||||
displayNameTextView.text = account.displayName
|
||||
Picasso.with(avatar.context)
|
||||
.load(account.avatar)
|
||||
.fit()
|
||||
.placeholder(R.drawable.avatar_default)
|
||||
.into(avatar)
|
||||
rejectButton.apply {
|
||||
if (inAList) {
|
||||
setImageResource(R.drawable.ic_reject_24dp)
|
||||
contentDescription = getString(R.string.action_remove_from_list)
|
||||
} else {
|
||||
setImageResource(R.drawable.ic_plus_24dp)
|
||||
contentDescription = getString(R.string.action_add_to_list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
acceptButton.hide()
|
||||
rejectButton.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
val (account, inAList) = getItem(adapterPosition)
|
||||
if (inAList) {
|
||||
onRemoveFromList(account.id)
|
||||
} else {
|
||||
onAddToList(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -786,8 +786,8 @@ public final class ComposeActivity
|
|||
|
||||
private void showEmojis() {
|
||||
|
||||
if(emojiView.getAdapter() != null) {
|
||||
if(emojiView.getAdapter().getItemCount() == 0) {
|
||||
if (emojiView.getAdapter() != null) {
|
||||
if (emojiView.getAdapter().getItemCount() == 0) {
|
||||
String errorMessage = getString(R.string.error_no_custom_emojis, accountManager.getActiveAccount().getDomain());
|
||||
Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
|
@ -1551,15 +1551,15 @@ public final class ComposeActivity
|
|||
try {
|
||||
switch (token.charAt(0)) {
|
||||
case '@':
|
||||
ArrayList<Account> resultList = new ArrayList<>();
|
||||
try {
|
||||
List<Account> accountList = mastodonApi
|
||||
.searchAccounts(token.substring(1), false, 20)
|
||||
.execute()
|
||||
.body();
|
||||
if (accountList != null) {
|
||||
resultList.addAll(accountList);
|
||||
.searchAccounts(token.substring(1), false, 20, null)
|
||||
.blockingGet();
|
||||
return CollectionsKt.map(accountList,
|
||||
ComposeAutoCompleteAdapter.AccountResult::new);
|
||||
} catch (Throwable e) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return CollectionsKt.map(resultList, ComposeAutoCompleteAdapter.AccountResult::new);
|
||||
case '#':
|
||||
Response<SearchResults> response = mastodonApi.search(token, false).execute();
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
|
|
|
@ -1,5 +1,22 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
|
@ -7,96 +24,38 @@ import android.view.LayoutInflater
|
|||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.LoadingState.*
|
||||
import android.widget.*
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.*
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.fragment.TimelineFragment
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event.*
|
||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.*
|
||||
import com.mikepenz.google_material_typeface_library.GoogleMaterial
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import com.uber.autodispose.autoDisposable
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.support.HasSupportFragmentInjector
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.android.synthetic.main.activity_lists.*
|
||||
import retrofit2.Call
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlinx.android.synthetic.main.toolbar_basic.*
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Created by charlag on 1/4/18.
|
||||
*/
|
||||
|
||||
interface ListsView {
|
||||
fun update(state: State)
|
||||
fun openTimeline(listId: String)
|
||||
}
|
||||
|
||||
|
||||
enum class LoadingState {
|
||||
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER
|
||||
}
|
||||
|
||||
data class State(val lists: List<MastoList>, val loadingState: LoadingState)
|
||||
|
||||
class ListsViewModel(private val api: MastodonApi) {
|
||||
|
||||
private var _view: WeakReference<ListsView>? = null
|
||||
private val view: ListsView? get() = _view?.get()
|
||||
private var state = State(listOf(), INITIAL)
|
||||
|
||||
fun attach(view: ListsView) {
|
||||
this._view = WeakReference(view)
|
||||
updateView()
|
||||
loadIfNeeded()
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
this._view = null
|
||||
}
|
||||
|
||||
fun didSelectItem(id: String) {
|
||||
view?.openTimeline(id)
|
||||
}
|
||||
|
||||
fun retryLoading() {
|
||||
loadIfNeeded()
|
||||
}
|
||||
|
||||
private fun loadIfNeeded() {
|
||||
if (state.loadingState == LOADING || !state.lists.isEmpty()) return
|
||||
updateState(state.copy(loadingState = LOADING))
|
||||
|
||||
api.getLists().enqueue(object : retrofit2.Callback<List<MastoList>> {
|
||||
override fun onResponse(call: Call<List<MastoList>>, response: Response<List<MastoList>>) {
|
||||
updateState(state.copy(lists = response.body() ?: listOf(), loadingState = LOADED))
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<List<MastoList>>, err: Throwable?) {
|
||||
updateState(state.copy(
|
||||
loadingState = if (err is IOException) ERROR_NETWORK else ERROR_OTHER
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun updateState(state: State) {
|
||||
this.state = state
|
||||
view?.update(state)
|
||||
}
|
||||
|
||||
private fun updateView() {
|
||||
view?.update(state)
|
||||
}
|
||||
}
|
||||
|
||||
class ListsActivity : BaseActivity(), ListsView, Injectable {
|
||||
class ListsActivity : BaseActivity(), Injectable, HasSupportFragmentInjector {
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
|
@ -106,7 +65,10 @@ class ListsActivity : BaseActivity(), ListsView, Injectable {
|
|||
}
|
||||
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
@Inject
|
||||
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Fragment>
|
||||
|
||||
private lateinit var viewModel: ListsViewModel
|
||||
private val adapter = ListsAdapter()
|
||||
|
@ -115,14 +77,12 @@ class ListsActivity : BaseActivity(), ListsView, Injectable {
|
|||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_lists)
|
||||
|
||||
val toolbar = findViewById<Toolbar>(R.id.toolbar)
|
||||
|
||||
setSupportActionBar(toolbar)
|
||||
val bar = supportActionBar
|
||||
if (bar != null) {
|
||||
bar.title = getString(R.string.title_lists)
|
||||
bar.setDisplayHomeAsUpEnabled(true)
|
||||
bar.setDisplayShowHomeEnabled(true)
|
||||
supportActionBar?.apply {
|
||||
title = getString(R.string.title_lists)
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowHomeEnabled(true)
|
||||
}
|
||||
|
||||
listsRecycler.adapter = adapter
|
||||
|
@ -130,23 +90,62 @@ class ListsActivity : BaseActivity(), ListsView, Injectable {
|
|||
listsRecycler.addItemDecoration(
|
||||
DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
|
||||
|
||||
viewModel = lastNonConfigurationInstance as? ListsViewModel ?: ListsViewModel(mastodonApi)
|
||||
viewModel.attach(this)
|
||||
viewModel = viewModelFactory.create(ListsViewModel::class.java)
|
||||
viewModel.state
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDisposable(from(this))
|
||||
.subscribe(this::update)
|
||||
viewModel.retryLoading()
|
||||
|
||||
addListButton.setOnClickListener {
|
||||
showlistNameDialog(null)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
viewModel.detach()
|
||||
super.onDestroy()
|
||||
viewModel.events.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDisposable(from(this))
|
||||
.subscribe { event ->
|
||||
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
|
||||
when (event) {
|
||||
CREATE_ERROR -> showMessage(R.string.error_create_list)
|
||||
RENAME_ERROR -> showMessage(R.string.error_rename_list)
|
||||
DELETE_ERROR -> showMessage(R.string.error_delete_list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRetainCustomNonConfigurationInstance(): Any {
|
||||
return viewModel
|
||||
private fun showlistNameDialog(list: MastoList?) {
|
||||
val layout = FrameLayout(this)
|
||||
val editText = EditText(this)
|
||||
editText.setHint(R.string.hint_list_name)
|
||||
layout.addView(editText)
|
||||
val margin = Utils.dpToPx(this, 8)
|
||||
(editText.layoutParams as ViewGroup.MarginLayoutParams)
|
||||
.setMargins(margin, margin, margin, 0)
|
||||
|
||||
val dialog = AlertDialog.Builder(this)
|
||||
.setView(layout)
|
||||
.setPositiveButton(
|
||||
if (list == null) R.string.action_create_list
|
||||
else R.string.action_rename_list) { _, _ ->
|
||||
onPickedDialogName(editText.text, list?.id)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ ->
|
||||
d.dismiss()
|
||||
}
|
||||
.show()
|
||||
|
||||
val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
|
||||
editText.onTextChanged { s, _, _, _ ->
|
||||
positiveButton.isEnabled = !s.isNullOrBlank()
|
||||
}
|
||||
editText.setText(list?.title)
|
||||
editText.text?.let { editText.setSelection(it.length) }
|
||||
}
|
||||
|
||||
|
||||
override fun update(state: State) {
|
||||
adapter.update(state.lists)
|
||||
progressBar.visibility = if (state.loadingState == LOADING) View.VISIBLE else View.GONE
|
||||
private fun update(state: ListsViewModel.State) {
|
||||
adapter.submitList(state.lists)
|
||||
progressBar.visible(state.loadingState == LOADING)
|
||||
when (state.loadingState) {
|
||||
INITIAL, LOADING -> messageView.hide()
|
||||
ERROR_NETWORK -> {
|
||||
|
@ -172,11 +171,44 @@ class ListsActivity : BaseActivity(), ListsView, Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
override fun openTimeline(listId: String) {
|
||||
private fun showMessage(@StringRes messageId: Int) {
|
||||
Snackbar.make(
|
||||
listsRecycler, messageId, Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
}
|
||||
|
||||
private fun onListSelected(listId: String) {
|
||||
startActivityWithSlideInAnimation(
|
||||
ModalTimelineActivity.newIntent(this, TimelineFragment.Kind.LIST, listId))
|
||||
}
|
||||
|
||||
private fun openListSettings(list: MastoList) {
|
||||
AccountsInListFragment.newInstance(list.id, list.title).show(supportFragmentManager, null)
|
||||
}
|
||||
|
||||
private fun renameListDialog(list: MastoList) {
|
||||
showlistNameDialog(list)
|
||||
}
|
||||
|
||||
private fun onMore(list: MastoList, view: View) {
|
||||
PopupMenu(view.context, view).apply {
|
||||
inflate(R.menu.list_actions)
|
||||
setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.list_edit -> openListSettings(list)
|
||||
R.id.list_rename -> renameListDialog(list)
|
||||
R.id.list_delete -> viewModel.deleteList(list.id)
|
||||
else -> return@setOnMenuItemClickListener false
|
||||
}
|
||||
true
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun supportFragmentInjector() = dispatchingAndroidInjector
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == android.R.id.home) {
|
||||
onBackPressed()
|
||||
|
@ -185,17 +217,18 @@ class ListsActivity : BaseActivity(), ListsView, Injectable {
|
|||
return false
|
||||
}
|
||||
|
||||
private inner class ListsAdapter : RecyclerView.Adapter<ListsAdapter.ListViewHolder>() {
|
||||
|
||||
private val items = mutableListOf<MastoList>()
|
||||
|
||||
fun update(list: List<MastoList>) {
|
||||
this.items.clear()
|
||||
this.items.addAll(list)
|
||||
notifyDataSetChanged()
|
||||
private object ListsDiffer : DiffUtil.ItemCallback<MastoList>() {
|
||||
override fun areItemsTheSame(oldItem: MastoList, newItem: MastoList): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
override fun areContentsTheSame(oldItem: MastoList, newItem: MastoList): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ListsAdapter
|
||||
: ListAdapter<MastoList, ListsAdapter.ListViewHolder>(ListsDiffer) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder {
|
||||
return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false)
|
||||
|
@ -210,20 +243,34 @@ class ListsActivity : BaseActivity(), ListsView, Injectable {
|
|||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ListViewHolder, position: Int) {
|
||||
holder.nameTextView.text = items[position].title
|
||||
holder.nameTextView.text = getItem(position).title
|
||||
}
|
||||
|
||||
private inner class ListViewHolder(view: View) : RecyclerView.ViewHolder(view),
|
||||
View.OnClickListener {
|
||||
val nameTextView: TextView = view.findViewById(R.id.list_name_textview)
|
||||
val moreButton: ImageButton = view.findViewById(R.id.editListButton)
|
||||
|
||||
init {
|
||||
view.setOnClickListener(this)
|
||||
moreButton.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
viewModel.didSelectItem(items[adapterPosition].id)
|
||||
override fun onClick(v: View) {
|
||||
if (v == itemView) {
|
||||
onListSelected(getItem(adapterPosition).id)
|
||||
} else {
|
||||
onMore(getItem(adapterPosition), v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPickedDialogName(name: CharSequence, listId: String?) {
|
||||
if (listId == null) {
|
||||
viewModel.createNewList(name.toString())
|
||||
} else {
|
||||
viewModel.renameList(listId, name.toString())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -73,11 +73,11 @@ public class FollowRequestsAdapter extends AccountAdapter {
|
|||
|
||||
FollowRequestViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
avatar = itemView.findViewById(R.id.follow_request_avatar);
|
||||
username = itemView.findViewById(R.id.follow_request_username);
|
||||
displayName = itemView.findViewById(R.id.follow_request_display_name);
|
||||
accept = itemView.findViewById(R.id.follow_request_accept);
|
||||
reject = itemView.findViewById(R.id.follow_request_reject);
|
||||
avatar = itemView.findViewById(R.id.avatar);
|
||||
username = itemView.findViewById(R.id.usernameTextView);
|
||||
displayName = itemView.findViewById(R.id.displayNameTextView);
|
||||
accept = itemView.findViewById(R.id.acceptButton);
|
||||
reject = itemView.findViewById(R.id.rejectButton);
|
||||
}
|
||||
|
||||
void setupWithAccount(Account account) {
|
||||
|
|
|
@ -16,9 +16,11 @@
|
|||
|
||||
package com.keylesspalace.tusky.di
|
||||
|
||||
import com.keylesspalace.tusky.AccountsInListFragment
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
||||
import com.keylesspalace.tusky.fragment.*
|
||||
import com.keylesspalace.tusky.fragment.preference.*
|
||||
import com.keylesspalace.tusky.fragment.preference.AccountPreferencesFragment
|
||||
import com.keylesspalace.tusky.fragment.preference.NotificationPreferencesFragment
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
|
@ -55,4 +57,7 @@ abstract class FragmentBuildersModule {
|
|||
@ContributesAndroidInjector
|
||||
abstract fun directMessagesPreferencesFragment(): ConversationsFragment
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun accountInListsFragment(): AccountsInListFragment
|
||||
|
||||
}
|
|
@ -6,7 +6,9 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.keylesspalace.tusky.viewmodel.AccountViewModel
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel
|
||||
import dagger.Binds
|
||||
import dagger.MapKey
|
||||
import dagger.Module
|
||||
|
@ -48,5 +50,16 @@ abstract class ViewModelModule {
|
|||
@ViewModelKey(ConversationsViewModel::class)
|
||||
internal abstract fun conversationsViewModel(viewModel: ConversationsViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(ListsViewModel::class)
|
||||
internal abstract fun listsViewModel(viewModel: ListsViewModel): ViewModel
|
||||
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(AccountsInListViewModel::class)
|
||||
internal abstract fun accountsInListViewModel(viewModel: AccountsInListViewModel): ViewModel
|
||||
|
||||
//Add more ViewModels here
|
||||
}
|
|
@ -65,6 +65,25 @@ data class Account(
|
|||
return account?.id == this.id
|
||||
}
|
||||
|
||||
fun deepEquals(other: Account): Boolean {
|
||||
return id == other.id
|
||||
&& localUsername == other.localUsername
|
||||
&& displayName == other.displayName
|
||||
&& note == other.note
|
||||
&& url == other.url
|
||||
&& avatar == other.avatar
|
||||
&& header == other.header
|
||||
&& locked == other.locked
|
||||
&& followersCount == other.followersCount
|
||||
&& followingCount == other.followingCount
|
||||
&& statusesCount == other.statusesCount
|
||||
&& source == other.source
|
||||
&& bot == other.bot
|
||||
&& emojis == other.emojis
|
||||
&& fields == other.fields
|
||||
&& moved == other.moved
|
||||
}
|
||||
|
||||
fun isRemote(): Boolean = this.username != this.localUsername
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,19 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.entity
|
||||
|
||||
/**
|
||||
|
|
|
@ -33,6 +33,7 @@ import com.keylesspalace.tusky.entity.StatusContext;
|
|||
import java.util.List;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import io.reactivex.Completable;
|
||||
import io.reactivex.Single;
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.RequestBody;
|
||||
|
@ -196,10 +197,11 @@ public interface MastodonApi {
|
|||
@Nullable @Part(value="fields_attributes[3][value]") RequestBody fieldValue3);
|
||||
|
||||
@GET("api/v1/accounts/search")
|
||||
Call<List<Account>> searchAccounts(
|
||||
Single<List<Account>> searchAccounts(
|
||||
@Query("q") String q,
|
||||
@Query("resolve") Boolean resolve,
|
||||
@Query("limit") Integer limit);
|
||||
@Query("limit") Integer limit,
|
||||
@Query("following") Boolean following);
|
||||
|
||||
@GET("api/v1/accounts/{id}")
|
||||
Call<Account> account(@Path("id") String accountId);
|
||||
|
@ -312,7 +314,29 @@ public interface MastodonApi {
|
|||
);
|
||||
|
||||
@GET("/api/v1/lists")
|
||||
Call<List<MastoList>> getLists();
|
||||
Single<List<MastoList>> getLists();
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/lists")
|
||||
Single<MastoList> createList(@Field("title") String title);
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("api/v1/lists/{listId}")
|
||||
Single<MastoList> updateList(@Path("listId") String listId, @Field("title") String title);
|
||||
|
||||
@DELETE("api/v1/lists/{listId}")
|
||||
Completable deleteList(@Path("listId") String listId);
|
||||
|
||||
@GET("api/v1/lists/{listId}/accounts")
|
||||
Single<List<Account>> getAccountsInList(@Path("listId") String listId, @Query("limit") int limit);
|
||||
|
||||
@DELETE("api/v1/lists/{listId}/accounts")
|
||||
Completable deleteAccountFromList(@Path("listId") String listId,
|
||||
@Query("account_ids[]") List<String> accountIds);
|
||||
|
||||
@POST("api/v1/lists/{listId}/accounts")
|
||||
Completable addCountToList(@Path("listId") String listId,
|
||||
@Query("account_ids[]") List<String> accountIds);
|
||||
|
||||
@GET("/api/v1/custom_emojis")
|
||||
Call<List<Emoji>> getCustomEmojis();
|
||||
|
|
|
@ -27,6 +27,8 @@ sealed class Either<out L, out R> {
|
|||
|
||||
fun isRight() = this is Right
|
||||
|
||||
fun isLeft() = this is Left
|
||||
|
||||
fun asLeftOrNull() = (this as? Left<L, R>)?.value
|
||||
|
||||
fun asRightOrNull() = (this as? Right<L, R>)?.value
|
||||
|
@ -34,4 +36,12 @@ sealed class Either<out L, out R> {
|
|||
fun asLeft(): L = (this as Left<L, R>).value
|
||||
|
||||
fun asRight(): R = (this as Right<L, R>).value
|
||||
|
||||
inline fun <N> map(crossinline mapper: (R) -> N): Either<L, N> {
|
||||
return if (this.isLeft()) {
|
||||
Left(this.asLeft())
|
||||
} else {
|
||||
Right(mapper(this.asRight()))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.util;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
|
||||
public class ListUtils {
|
||||
/**
|
||||
* @return true if list is null or else return list.isEmpty()
|
||||
*/
|
||||
public static boolean isEmpty(@Nullable List list) {
|
||||
return list == null || list.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a new ArrayList containing the elements without duplicates in the same order
|
||||
*/
|
||||
public static <T> ArrayList<T> removeDuplicates(List<T> list) {
|
||||
LinkedHashSet<T> set = new LinkedHashSet<>(list);
|
||||
return new ArrayList<>(set);
|
||||
}
|
||||
}
|
55
app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt
Normal file
55
app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt
Normal file
|
@ -0,0 +1,55 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
@file:JvmName("ListUtils")
|
||||
|
||||
package com.keylesspalace.tusky.util
|
||||
|
||||
import java.util.LinkedHashSet
|
||||
import java.util.ArrayList
|
||||
|
||||
|
||||
/**
|
||||
* @return true if list is null or else return list.isEmpty()
|
||||
*/
|
||||
fun isEmpty(list: List<*>?): Boolean {
|
||||
return list == null || list.isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a new ArrayList containing the elements without duplicates in the same order
|
||||
*/
|
||||
fun <T> removeDuplicates(list: List<T>): ArrayList<T> {
|
||||
val set = LinkedHashSet(list)
|
||||
return ArrayList(set)
|
||||
}
|
||||
|
||||
inline fun <T> List<T>.withoutFirstWhich(predicate: (T) -> Boolean): List<T> {
|
||||
val newList = toMutableList()
|
||||
val index = newList.indexOfFirst(predicate)
|
||||
if (index != -1) {
|
||||
newList.removeAt(index)
|
||||
}
|
||||
return newList
|
||||
}
|
||||
|
||||
inline fun <T> List<T>.replacedFirstWhich(replacement: T, predicate: (T) -> Boolean): List<T> {
|
||||
val newList = toMutableList()
|
||||
val index = newList.indexOfFirst(predicate)
|
||||
if (index != -1) {
|
||||
newList[index] = replacement
|
||||
}
|
||||
return newList
|
||||
}
|
|
@ -1,6 +1,25 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.util
|
||||
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
|
||||
fun View.show() {
|
||||
this.visibility = View.VISIBLE
|
||||
|
@ -10,6 +29,26 @@ fun View.hide() {
|
|||
this.visibility = View.GONE
|
||||
}
|
||||
|
||||
fun View.visible(visible: Boolean) {
|
||||
this.visibility = if (visible) View.VISIBLE else View.GONE
|
||||
fun View.visible(visible: Boolean, or: Int = View.GONE) {
|
||||
this.visibility = if (visible) View.VISIBLE else or
|
||||
}
|
||||
|
||||
open class DefaultTextWatcher : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
}
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
||||
}
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
inline fun EditText.onTextChanged(
|
||||
crossinline callback: (s: CharSequence?, start: Int, before: Int, count: Int) -> Unit) {
|
||||
addTextChangedListener(object : DefaultTextWatcher() {
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
callback(s, start, before, count)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.Either.Left
|
||||
import com.keylesspalace.tusky.util.Either.Right
|
||||
import com.keylesspalace.tusky.util.withoutFirstWhich
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import io.reactivex.rxkotlin.addTo
|
||||
import io.reactivex.subjects.BehaviorSubject
|
||||
import javax.inject.Inject
|
||||
|
||||
data class State(val accounts: Either<Throwable, List<Account>>, val searchResult: List<Account>?)
|
||||
|
||||
class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() {
|
||||
|
||||
val state: Observable<State> get() = _state
|
||||
private val _state = BehaviorSubject.createDefault(State(Right(listOf()), null))
|
||||
private val disposable = CompositeDisposable()
|
||||
|
||||
fun load(listId: String) {
|
||||
val state = _state.value!!
|
||||
if (state.accounts.isLeft() || state.accounts.asRight().isEmpty()) {
|
||||
api.getAccountsInList(listId, 0).subscribe({ accounts ->
|
||||
updateState { copy(accounts = Right(accounts)) }
|
||||
}, { e ->
|
||||
updateState { copy(accounts = Left(e)) }
|
||||
}).addTo(disposable)
|
||||
}
|
||||
}
|
||||
|
||||
fun addAccountToList(listId: String, account: Account) {
|
||||
api.addCountToList(listId, listOf(account.id))
|
||||
.subscribe({
|
||||
updateState {
|
||||
copy(accounts = accounts.map { it + account })
|
||||
}
|
||||
}, {
|
||||
Log.i(javaClass.simpleName,
|
||||
"Failed to add account to the list: ${account.username}")
|
||||
})
|
||||
.addTo(disposable)
|
||||
}
|
||||
|
||||
fun deleteAccountFromList(listId: String, accountId: String) {
|
||||
api.deleteAccountFromList(listId, listOf(accountId))
|
||||
.subscribe({
|
||||
updateState {
|
||||
copy(accounts = accounts.map { accounts ->
|
||||
accounts.withoutFirstWhich { it.id == accountId }
|
||||
})
|
||||
}
|
||||
}, {
|
||||
Log.i(javaClass.simpleName, "Failed to remove account from thelist: $accountId")
|
||||
})
|
||||
.addTo(disposable)
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
when {
|
||||
query.isEmpty() -> updateState { copy(searchResult = null) }
|
||||
query.isBlank() -> updateState { copy(searchResult = listOf()) }
|
||||
else -> api.searchAccounts(query, null, 10, true)
|
||||
.subscribe({ result ->
|
||||
updateState { copy(searchResult = result) }
|
||||
}, {
|
||||
updateState { copy(searchResult = listOf()) }
|
||||
}).addTo(disposable)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun updateState(crossinline fn: State.() -> State) {
|
||||
_state.onNext(fn(_state.value!!))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.withoutFirstWhich
|
||||
import com.keylesspalace.tusky.util.replacedFirstWhich
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import io.reactivex.rxkotlin.addTo
|
||||
import io.reactivex.subjects.BehaviorSubject
|
||||
import io.reactivex.subjects.PublishSubject
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() {
|
||||
enum class LoadingState {
|
||||
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER
|
||||
}
|
||||
|
||||
enum class Event {
|
||||
CREATE_ERROR, DELETE_ERROR, RENAME_ERROR
|
||||
}
|
||||
|
||||
data class State(val lists: List<MastoList>, val loadingState: LoadingState)
|
||||
|
||||
val state: Observable<State> get() = _state
|
||||
val events: Observable<Event> get() = _events
|
||||
private val _state = BehaviorSubject.createDefault(State(listOf(), LoadingState.INITIAL))
|
||||
private val _events = PublishSubject.create<Event>()
|
||||
private val disposable = CompositeDisposable()
|
||||
|
||||
fun retryLoading() {
|
||||
loadIfNeeded()
|
||||
}
|
||||
|
||||
private fun loadIfNeeded() {
|
||||
val state = _state.value!!
|
||||
if (state.loadingState == LoadingState.LOADING || !state.lists.isEmpty()) return
|
||||
updateState {
|
||||
copy(loadingState = LoadingState.LOADING)
|
||||
}
|
||||
|
||||
api.getLists().subscribe({ lists ->
|
||||
updateState {
|
||||
copy(
|
||||
lists = lists,
|
||||
loadingState = LoadingState.LOADED
|
||||
)
|
||||
}
|
||||
}, { err ->
|
||||
updateState {
|
||||
copy(loadingState = if (err is IOException || err is ConnectException)
|
||||
LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER)
|
||||
}
|
||||
}).addTo(disposable)
|
||||
}
|
||||
|
||||
fun createNewList(listName: String) {
|
||||
api.createList(listName).subscribe({ list ->
|
||||
updateState {
|
||||
copy(lists = lists + list)
|
||||
}
|
||||
}, {
|
||||
sendEvent(Event.CREATE_ERROR)
|
||||
}).addTo(disposable)
|
||||
}
|
||||
|
||||
fun renameList(listId: String, listName: String) {
|
||||
api.updateList(listId, listName).subscribe({ list ->
|
||||
updateState {
|
||||
copy(lists = lists.replacedFirstWhich(list) { it.id == listId })
|
||||
}
|
||||
}, {
|
||||
sendEvent(Event.RENAME_ERROR)
|
||||
}).addTo(disposable)
|
||||
}
|
||||
|
||||
fun deleteList(listId: String) {
|
||||
api.deleteList(listId).subscribe({
|
||||
updateState {
|
||||
copy(lists = lists.withoutFirstWhich { it.id == listId })
|
||||
}
|
||||
}, {
|
||||
sendEvent(Event.DELETE_ERROR)
|
||||
}).addTo(disposable)
|
||||
}
|
||||
|
||||
private inline fun updateState(crossinline fn: State.() -> State) {
|
||||
_state.onNext(fn(_state.value!!))
|
||||
}
|
||||
|
||||
private fun sendEvent(event: Event) {
|
||||
_events.onNext(event)
|
||||
}
|
||||
}
|
5
app/src/main/res/drawable/dialog_bg.xml
Normal file
5
app/src/main/res/drawable/dialog_bg.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:radius="4dp" />
|
||||
<solid android:color="?attr/window_background" />
|
||||
</shape>
|
|
@ -1,10 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<include layout="@layout/toolbar_basic" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
|
@ -37,4 +41,16 @@
|
|||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/addListButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:contentDescription="@string/action_create_list"
|
||||
app:layout_anchor="@id/listsRecycler"
|
||||
app:layout_anchorGravity="bottom|end"
|
||||
app:srcCompat="@drawable/ic_plus_24dp" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
58
app/src/main/res/layout/fragment_accounts_in_list.xml
Normal file
58
app/src/main/res/layout/fragment_accounts_in_list.xml
Normal file
|
@ -0,0 +1,58 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:animateLayoutChanges="true">
|
||||
|
||||
<androidx.appcompat.widget.SearchView
|
||||
android:id="@+id/searchView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionSearch"
|
||||
android:lines="1"
|
||||
app:closeIcon="@drawable/ic_close_24dp"
|
||||
app:defaultQueryHint="@string/hint_search_people_list"
|
||||
app:iconifiedByDefault="false"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/accountsRecycler"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="8dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/searchView" />
|
||||
|
||||
|
||||
<com.keylesspalace.tusky.view.BackgroundMessageView
|
||||
android:id="@+id/messageView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:src="@android:color/transparent"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@drawable/elephant_error"
|
||||
tools:visibility="visible" />
|
||||
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/accountsSearchRecycler"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:background="?attr/window_background"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/searchView" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -9,7 +9,7 @@
|
|||
android:paddingRight="16dp">
|
||||
|
||||
<com.keylesspalace.tusky.view.RoundedImageView
|
||||
android:id="@+id/follow_request_avatar"
|
||||
android:id="@+id/avatar"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_alignParentStart="true"
|
||||
|
@ -20,13 +20,13 @@
|
|||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_toEndOf="@id/follow_request_avatar"
|
||||
android:layout_toStartOf="@id/follow_request_accept"
|
||||
android:layout_toEndOf="@id/avatar"
|
||||
android:layout_toStartOf="@id/acceptButton"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
android:id="@+id/follow_request_display_name"
|
||||
android:id="@+id/displayNameTextView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
|
@ -37,7 +37,7 @@
|
|||
tools:text="Display name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/follow_request_username"
|
||||
android:id="@+id/usernameTextView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
|
@ -49,20 +49,20 @@
|
|||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/follow_request_accept"
|
||||
android:id="@+id/acceptButton"
|
||||
style="?attr/image_button_style"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_toStartOf="@id/follow_request_reject"
|
||||
android:layout_toStartOf="@id/rejectButton"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/action_accept"
|
||||
android:padding="4dp"
|
||||
app:srcCompat="@drawable/ic_check_24dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/follow_request_reject"
|
||||
android:id="@+id/rejectButton"
|
||||
style="?attr/image_button_style"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
|
|
|
@ -1,13 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/list_name_textview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/list_name_textview"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="60dp"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:layout_weight="1"
|
||||
android:background="?selectableItemBackground"
|
||||
android:drawablePadding="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
tools:text="Example list" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/editListButton"
|
||||
style="?attr/image_button_style"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/action_more"
|
||||
android:paddingLeft="8dp"
|
||||
android:paddingRight="8dp"
|
||||
android:src="@drawable/ic_more_horiz_24dp" />
|
||||
</LinearLayout>
|
||||
|
|
13
app/src/main/res/menu/list_actions.xml
Normal file
13
app/src/main/res/menu/list_actions.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/list_edit"
|
||||
android:title="@string/action_edit_list" />
|
||||
<item
|
||||
android:id="@+id/list_rename"
|
||||
android:title="@string/action_rename_list" />
|
||||
<item
|
||||
android:id="@+id/list_delete"
|
||||
android:title="@string/action_delete_list" />
|
||||
|
||||
</menu>
|
|
@ -325,6 +325,16 @@
|
|||
<string name="action_lists">Lists</string>
|
||||
<string name="title_lists">Lists</string>
|
||||
<string name="title_list_timeline">List timeline</string>
|
||||
<string name="error_create_list">Could not create list</string>
|
||||
<string name="error_rename_list">Could not rename list</string>
|
||||
<string name="error_delete_list">Could not delete list</string>
|
||||
<string name="action_create_list">Create a list</string>
|
||||
<string name="action_rename_list">Rename the list</string>
|
||||
<string name="action_delete_list">Delete the list</string>
|
||||
<string name="action_edit_list">Edit the list</string>
|
||||
<string name="hint_search_people_list">Search for people you follow</string>
|
||||
<string name="action_add_to_list">Add account to the list</string>
|
||||
<string name="action_remove_from_list">Remove account from the list</string>
|
||||
|
||||
<string name="compose_active_account_description">Posting with account %1$s</string>
|
||||
|
||||
|
@ -435,4 +445,6 @@
|
|||
%s; %s; %s, %s, %s; %s, %s, %s, %s; %s, %s, %s
|
||||
</string>
|
||||
|
||||
<string name="hint_list_name">List name</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -47,6 +47,12 @@
|
|||
|
||||
<style name="TuskyDialogActivityTheme" parent="@style/TuskyTheme" />
|
||||
|
||||
<style name="TuskyDialogFragmentStyle" parent="@style/ThemeOverlay.MaterialComponents.Dialog">
|
||||
<item name="dialogCornerRadius">8dp</item>
|
||||
<item name="android:backgroundTint">?attr/window_background</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="TuskyBaseTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
|
||||
<item name="colorPrimary">@color/tusky_blue</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
|
@ -82,21 +88,29 @@
|
|||
<item name="status_favourite_active_drawable">@drawable/favourite_active_light</item>
|
||||
<item name="status_favourite_inactive_drawable">@drawable/favourite_inactive_light</item>
|
||||
<item name="content_warning_button">@drawable/toggle_small_light</item>
|
||||
<item name="sensitive_media_warning_background_color">@color/sensitive_media_warning_background_light</item>
|
||||
<item name="sensitive_media_warning_background_color">
|
||||
@color/sensitive_media_warning_background_light
|
||||
</item>
|
||||
<item name="media_preview_unloaded_drawable">@drawable/media_preview_unloaded_light</item>
|
||||
<item name="android:listDivider">@drawable/status_divider_light</item>
|
||||
<item name="conversation_thread_line_drawable">@drawable/conversation_thread_line_light</item>
|
||||
<item name="conversation_thread_line_drawable">@drawable/conversation_thread_line_light
|
||||
</item>
|
||||
<item name="tab_icon_selected_tint">@color/tusky_blue</item>
|
||||
<item name="tab_page_margin_drawable">@drawable/tab_page_margin_light</item>
|
||||
<item name="account_header_background_color">@color/color_primary_dark_light</item>
|
||||
<item name="account_toolbar_icon_tint_uncollapsed">@color/toolbar_icon_dark
|
||||
</item> <!--Default to dark on purpose, because header backgrounds with gradients are always dark.-->
|
||||
<item name="account_toolbar_icon_tint_collapsed">@color/account_toolbar_icon_collapsed_light</item>
|
||||
<item name="account_toolbar_icon_tint_collapsed">
|
||||
@color/account_toolbar_icon_collapsed_light
|
||||
</item>
|
||||
|
||||
<item name="compose_close_button_tint">@color/toolbar_icon_light</item>
|
||||
<item name="compose_media_button_disabled_tint">@color/compose_media_button_disabled_light</item>
|
||||
<item name="compose_media_button_disabled_tint">@color/compose_media_button_disabled_light
|
||||
</item>
|
||||
<item name="compose_content_warning_bar_background">@drawable/border_background_light</item>
|
||||
<item name="compose_reply_content_background">@color/compose_reply_content_background_light</item>
|
||||
<item name="compose_reply_content_background">
|
||||
@color/compose_reply_content_background_light
|
||||
</item>
|
||||
|
||||
<item name="report_status_background_color">@color/report_status_background_light</item>
|
||||
|
||||
|
@ -189,6 +203,7 @@
|
|||
<item name="material_drawer_header_selection_text">@color/text_color_primary_black</item>
|
||||
<item name="tab_page_margin_drawable">@drawable/tab_page_margin_black</item>
|
||||
</style>
|
||||
<style name="TuskyBlackTheme" parent="TuskyBlackThemeBase"/>
|
||||
|
||||
<style name="TuskyBlackTheme" parent="TuskyBlackThemeBase" />
|
||||
|
||||
</resources>
|
||||
|
|
Loading…
Reference in a new issue