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:
Ivan Kupalov 2019-03-16 13:36:16 +01:00 committed by Konrad Pozniak
parent 08c1bbd253
commit 520e0d6e7a
23 changed files with 1047 additions and 222 deletions

View file

@ -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)
}
}
}
}
}

View file

@ -1551,15 +1551,15 @@ public final class ComposeActivity
try { try {
switch (token.charAt(0)) { switch (token.charAt(0)) {
case '@': case '@':
ArrayList<Account> resultList = new ArrayList<>(); try {
List<Account> accountList = mastodonApi List<Account> accountList = mastodonApi
.searchAccounts(token.substring(1), false, 20) .searchAccounts(token.substring(1), false, 20, null)
.execute() .blockingGet();
.body(); return CollectionsKt.map(accountList,
if (accountList != null) { ComposeAutoCompleteAdapter.AccountResult::new);
resultList.addAll(accountList); } catch (Throwable e) {
return Collections.emptyList();
} }
return CollectionsKt.map(resultList, ComposeAutoCompleteAdapter.AccountResult::new);
case '#': case '#':
Response<SearchResults> response = mastodonApi.search(token, false).execute(); Response<SearchResults> response = mastodonApi.search(token, false).execute();
if (response.isSuccessful() && response.body() != null) { if (response.isSuccessful() && response.body() != null) {

View file

@ -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 package com.keylesspalace.tusky
import android.app.Dialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
@ -7,96 +24,38 @@ import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView import android.widget.*
import androidx.appcompat.widget.Toolbar import androidx.annotation.StringRes
import androidx.recyclerview.widget.DividerItemDecoration import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.LinearLayoutManager import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.*
import com.keylesspalace.tusky.LoadingState.* 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.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.fragment.TimelineFragment import com.keylesspalace.tusky.fragment.TimelineFragment
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.viewmodel.ListsViewModel
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event.*
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.*
import com.mikepenz.google_material_typeface_library.GoogleMaterial import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.mikepenz.iconics.IconicsDrawable 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 kotlinx.android.synthetic.main.activity_lists.*
import retrofit2.Call import kotlinx.android.synthetic.main.toolbar_basic.*
import retrofit2.Response
import java.io.IOException
import java.lang.ref.WeakReference
import javax.inject.Inject import javax.inject.Inject
/** /**
* Created by charlag on 1/4/18. * Created by charlag on 1/4/18.
*/ */
interface ListsView { class ListsActivity : BaseActivity(), Injectable, HasSupportFragmentInjector {
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 {
companion object { companion object {
@JvmStatic @JvmStatic
@ -106,7 +65,10 @@ class ListsActivity : BaseActivity(), ListsView, Injectable {
} }
@Inject @Inject
lateinit var mastodonApi: MastodonApi lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Fragment>
private lateinit var viewModel: ListsViewModel private lateinit var viewModel: ListsViewModel
private val adapter = ListsAdapter() private val adapter = ListsAdapter()
@ -115,14 +77,12 @@ class ListsActivity : BaseActivity(), ListsView, Injectable {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_lists) setContentView(R.layout.activity_lists)
val toolbar = findViewById<Toolbar>(R.id.toolbar)
setSupportActionBar(toolbar) setSupportActionBar(toolbar)
val bar = supportActionBar supportActionBar?.apply {
if (bar != null) { title = getString(R.string.title_lists)
bar.title = getString(R.string.title_lists) setDisplayHomeAsUpEnabled(true)
bar.setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true)
bar.setDisplayShowHomeEnabled(true)
} }
listsRecycler.adapter = adapter listsRecycler.adapter = adapter
@ -130,23 +90,62 @@ class ListsActivity : BaseActivity(), ListsView, Injectable {
listsRecycler.addItemDecoration( listsRecycler.addItemDecoration(
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
viewModel = lastNonConfigurationInstance as? ListsViewModel ?: ListsViewModel(mastodonApi) viewModel = viewModelFactory.create(ListsViewModel::class.java)
viewModel.attach(this) viewModel.state
.observeOn(AndroidSchedulers.mainThread())
.autoDisposable(from(this))
.subscribe(this::update)
viewModel.retryLoading()
addListButton.setOnClickListener {
showlistNameDialog(null)
} }
override fun onDestroy() { viewModel.events.observeOn(AndroidSchedulers.mainThread())
viewModel.detach() .autoDisposable(from(this))
super.onDestroy() .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 { private fun showlistNameDialog(list: MastoList?) {
return viewModel 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) { private fun update(state: ListsViewModel.State) {
adapter.update(state.lists) adapter.submitList(state.lists)
progressBar.visibility = if (state.loadingState == LOADING) View.VISIBLE else View.GONE progressBar.visible(state.loadingState == LOADING)
when (state.loadingState) { when (state.loadingState) {
INITIAL, LOADING -> messageView.hide() INITIAL, LOADING -> messageView.hide()
ERROR_NETWORK -> { 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( startActivityWithSlideInAnimation(
ModalTimelineActivity.newIntent(this, TimelineFragment.Kind.LIST, listId)) 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 { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) { if (item.itemId == android.R.id.home) {
onBackPressed() onBackPressed()
@ -185,17 +217,18 @@ class ListsActivity : BaseActivity(), ListsView, Injectable {
return false return false
} }
private inner class ListsAdapter : RecyclerView.Adapter<ListsAdapter.ListViewHolder>() { private object ListsDiffer : DiffUtil.ItemCallback<MastoList>() {
override fun areItemsTheSame(oldItem: MastoList, newItem: MastoList): Boolean {
private val items = mutableListOf<MastoList>() return oldItem.id == newItem.id
fun update(list: List<MastoList>) {
this.items.clear()
this.items.addAll(list)
notifyDataSetChanged()
} }
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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder {
return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false) 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) { 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), private inner class ListViewHolder(view: View) : RecyclerView.ViewHolder(view),
View.OnClickListener { View.OnClickListener {
val nameTextView: TextView = view.findViewById(R.id.list_name_textview) val nameTextView: TextView = view.findViewById(R.id.list_name_textview)
val moreButton: ImageButton = view.findViewById(R.id.editListButton)
init { init {
view.setOnClickListener(this) view.setOnClickListener(this)
moreButton.setOnClickListener(this)
} }
override fun onClick(v: View?) { override fun onClick(v: View) {
viewModel.didSelectItem(items[adapterPosition].id) 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())
}
}
}

View file

@ -73,11 +73,11 @@ public class FollowRequestsAdapter extends AccountAdapter {
FollowRequestViewHolder(View itemView) { FollowRequestViewHolder(View itemView) {
super(itemView); super(itemView);
avatar = itemView.findViewById(R.id.follow_request_avatar); avatar = itemView.findViewById(R.id.avatar);
username = itemView.findViewById(R.id.follow_request_username); username = itemView.findViewById(R.id.usernameTextView);
displayName = itemView.findViewById(R.id.follow_request_display_name); displayName = itemView.findViewById(R.id.displayNameTextView);
accept = itemView.findViewById(R.id.follow_request_accept); accept = itemView.findViewById(R.id.acceptButton);
reject = itemView.findViewById(R.id.follow_request_reject); reject = itemView.findViewById(R.id.rejectButton);
} }
void setupWithAccount(Account account) { void setupWithAccount(Account account) {

View file

@ -16,9 +16,11 @@
package com.keylesspalace.tusky.di package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.AccountsInListFragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.fragment.* 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.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
@ -55,4 +57,7 @@ abstract class FragmentBuildersModule {
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun directMessagesPreferencesFragment(): ConversationsFragment abstract fun directMessagesPreferencesFragment(): ConversationsFragment
@ContributesAndroidInjector
abstract fun accountInListsFragment(): AccountsInListFragment
} }

View file

@ -6,7 +6,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.keylesspalace.tusky.viewmodel.AccountViewModel import com.keylesspalace.tusky.viewmodel.AccountViewModel
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
import com.keylesspalace.tusky.viewmodel.ListsViewModel
import dagger.Binds import dagger.Binds
import dagger.MapKey import dagger.MapKey
import dagger.Module import dagger.Module
@ -48,5 +50,16 @@ abstract class ViewModelModule {
@ViewModelKey(ConversationsViewModel::class) @ViewModelKey(ConversationsViewModel::class)
internal abstract fun conversationsViewModel(viewModel: ConversationsViewModel): ViewModel 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 //Add more ViewModels here
} }

View file

@ -65,6 +65,25 @@ data class Account(
return account?.id == this.id 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 fun isRemote(): Boolean = this.username != this.localUsername
} }

View file

@ -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 package com.keylesspalace.tusky.entity
/** /**

View file

@ -33,6 +33,7 @@ import com.keylesspalace.tusky.entity.StatusContext;
import java.util.List; import java.util.List;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import io.reactivex.Completable;
import io.reactivex.Single; import io.reactivex.Single;
import okhttp3.MultipartBody; import okhttp3.MultipartBody;
import okhttp3.RequestBody; import okhttp3.RequestBody;
@ -196,10 +197,11 @@ public interface MastodonApi {
@Nullable @Part(value="fields_attributes[3][value]") RequestBody fieldValue3); @Nullable @Part(value="fields_attributes[3][value]") RequestBody fieldValue3);
@GET("api/v1/accounts/search") @GET("api/v1/accounts/search")
Call<List<Account>> searchAccounts( Single<List<Account>> searchAccounts(
@Query("q") String q, @Query("q") String q,
@Query("resolve") Boolean resolve, @Query("resolve") Boolean resolve,
@Query("limit") Integer limit); @Query("limit") Integer limit,
@Query("following") Boolean following);
@GET("api/v1/accounts/{id}") @GET("api/v1/accounts/{id}")
Call<Account> account(@Path("id") String accountId); Call<Account> account(@Path("id") String accountId);
@ -312,7 +314,29 @@ public interface MastodonApi {
); );
@GET("/api/v1/lists") @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") @GET("/api/v1/custom_emojis")
Call<List<Emoji>> getCustomEmojis(); Call<List<Emoji>> getCustomEmojis();

View file

@ -27,6 +27,8 @@ sealed class Either<out L, out R> {
fun isRight() = this is Right fun isRight() = this is Right
fun isLeft() = this is Left
fun asLeftOrNull() = (this as? Left<L, R>)?.value fun asLeftOrNull() = (this as? Left<L, R>)?.value
fun asRightOrNull() = (this as? Right<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 asLeft(): L = (this as Left<L, R>).value
fun asRight(): R = (this as Right<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()))
}
}
} }

View file

@ -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);
}
}

View 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
}

View file

@ -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 package com.keylesspalace.tusky.util
import android.text.Editable
import android.text.TextWatcher
import android.view.View import android.view.View
import android.widget.EditText
fun View.show() { fun View.show() {
this.visibility = View.VISIBLE this.visibility = View.VISIBLE
@ -10,6 +29,26 @@ fun View.hide() {
this.visibility = View.GONE this.visibility = View.GONE
} }
fun View.visible(visible: Boolean) { fun View.visible(visible: Boolean, or: Int = View.GONE) {
this.visibility = if (visible) View.VISIBLE else 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)
}
})
} }

View file

@ -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!!))
}
}

View file

@ -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)
}
}

View 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>

View file

@ -1,10 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?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:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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" /> <include layout="@layout/toolbar_basic" />
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
@ -38,3 +42,15 @@
app:layout_constraintTop_toTopOf="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>

View 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>

View file

@ -9,7 +9,7 @@
android:paddingRight="16dp"> android:paddingRight="16dp">
<com.keylesspalace.tusky.view.RoundedImageView <com.keylesspalace.tusky.view.RoundedImageView
android:id="@+id/follow_request_avatar" android:id="@+id/avatar"
android:layout_width="48dp" android:layout_width="48dp"
android:layout_height="48dp" android:layout_height="48dp"
android:layout_alignParentStart="true" android:layout_alignParentStart="true"
@ -20,13 +20,13 @@
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_toEndOf="@id/follow_request_avatar" android:layout_toEndOf="@id/avatar"
android:layout_toStartOf="@id/follow_request_accept" android:layout_toStartOf="@id/acceptButton"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="vertical"> android:orientation="vertical">
<androidx.emoji.widget.EmojiTextView <androidx.emoji.widget.EmojiTextView
android:id="@+id/follow_request_display_name" android:id="@+id/displayNameTextView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end" android:ellipsize="end"
@ -37,7 +37,7 @@
tools:text="Display name" /> tools:text="Display name" />
<TextView <TextView
android:id="@+id/follow_request_username" android:id="@+id/usernameTextView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end" android:ellipsize="end"
@ -49,20 +49,20 @@
</LinearLayout> </LinearLayout>
<ImageButton <ImageButton
android:id="@+id/follow_request_accept" android:id="@+id/acceptButton"
style="?attr/image_button_style" style="?attr/image_button_style"
android:layout_width="32dp" android:layout_width="32dp"
android:layout_height="32dp" android:layout_height="32dp"
android:layout_centerVertical="true" android:layout_centerVertical="true"
android:layout_marginStart="12dp" android:layout_marginStart="12dp"
android:layout_toStartOf="@id/follow_request_reject" android:layout_toStartOf="@id/rejectButton"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_accept" android:contentDescription="@string/action_accept"
android:padding="4dp" android:padding="4dp"
app:srcCompat="@drawable/ic_check_24dp" /> app:srcCompat="@drawable/ic_check_24dp" />
<ImageButton <ImageButton
android:id="@+id/follow_request_reject" android:id="@+id/rejectButton"
style="?attr/image_button_style" style="?attr/image_button_style"
android:layout_width="32dp" android:layout_width="32dp"
android:layout_height="32dp" android:layout_height="32dp"

View file

@ -1,13 +1,33 @@
<?xml version="1.0" encoding="utf-8"?> <?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" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/list_name_textview"
android:layout_width="match_parent" 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:layout_height="60dp"
android:paddingLeft="16dp" android:layout_weight="1"
android:paddingRight="16dp"
android:background="?selectableItemBackground" android:background="?selectableItemBackground"
android:drawablePadding="8dp" android:drawablePadding="8dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:textSize="?attr/status_text_medium" android:textSize="?attr/status_text_medium"
tools:text="Example list" /> 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>

View 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>

View file

@ -325,6 +325,16 @@
<string name="action_lists">Lists</string> <string name="action_lists">Lists</string>
<string name="title_lists">Lists</string> <string name="title_lists">Lists</string>
<string name="title_list_timeline">List timeline</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> <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 %s; %s; %s, %s, %s; %s, %s, %s, %s; %s, %s, %s
</string> </string>
<string name="hint_list_name">List name</string>
</resources> </resources>

View file

@ -47,6 +47,12 @@
<style name="TuskyDialogActivityTheme" parent="@style/TuskyTheme" /> <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"> <style name="TuskyBaseTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
<item name="colorPrimary">@color/tusky_blue</item> <item name="colorPrimary">@color/tusky_blue</item>
<item name="colorOnPrimary">@color/white</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_active_drawable">@drawable/favourite_active_light</item>
<item name="status_favourite_inactive_drawable">@drawable/favourite_inactive_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="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="media_preview_unloaded_drawable">@drawable/media_preview_unloaded_light</item>
<item name="android:listDivider">@drawable/status_divider_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_icon_selected_tint">@color/tusky_blue</item>
<item name="tab_page_margin_drawable">@drawable/tab_page_margin_light</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_header_background_color">@color/color_primary_dark_light</item>
<item name="account_toolbar_icon_tint_uncollapsed">@color/toolbar_icon_dark <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> <!--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_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_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> <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="material_drawer_header_selection_text">@color/text_color_primary_black</item>
<item name="tab_page_margin_drawable">@drawable/tab_page_margin_black</item> <item name="tab_page_margin_drawable">@drawable/tab_page_margin_black</item>
</style> </style>
<style name="TuskyBlackTheme" parent="TuskyBlackThemeBase" /> <style name="TuskyBlackTheme" parent="TuskyBlackThemeBase" />
</resources> </resources>