From 520e0d6e7a9d478eff09f3afc32d7bffdacef752 Mon Sep 17 00:00:00 2001 From: Ivan Kupalov Date: Sat, 16 Mar 2019 13:36:16 +0100 Subject: [PATCH] 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 --- .../tusky/AccountsInListFragment.kt | 289 ++++++++++++++++++ .../keylesspalace/tusky/ComposeActivity.java | 20 +- .../com/keylesspalace/tusky/ListsActivity.kt | 265 +++++++++------- .../tusky/adapter/FollowRequestsAdapter.java | 10 +- .../tusky/di/FragmentBuildersModule.kt | 7 +- .../tusky/di/ViewModelFactory.kt | 13 + .../com/keylesspalace/tusky/entity/Account.kt | 19 ++ .../keylesspalace/tusky/entity/MastoList.kt | 16 + .../tusky/network/MastodonApi.java | 30 +- .../com/keylesspalace/tusky/util/Either.kt | 10 + .../keylesspalace/tusky/util/ListUtils.java | 40 --- .../com/keylesspalace/tusky/util/ListUtils.kt | 55 ++++ .../tusky/util/ViewExtensions.kt | 43 ++- .../viewmodel/AccountsInListViewModel.kt | 95 ++++++ .../tusky/viewmodel/ListsViewModel.kt | 114 +++++++ app/src/main/res/drawable/dialog_bg.xml | 5 + app/src/main/res/layout/activity_lists.xml | 74 +++-- .../res/layout/fragment_accounts_in_list.xml | 58 ++++ .../main/res/layout/item_follow_request.xml | 16 +- app/src/main/res/layout/item_list.xml | 38 ++- app/src/main/res/menu/list_actions.xml | 13 + app/src/main/res/values/strings.xml | 12 + app/src/main/res/values/styles.xml | 27 +- 23 files changed, 1047 insertions(+), 222 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/util/ListUtils.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt create mode 100644 app/src/main/res/drawable/dialog_bg.xml create mode 100644 app/src/main/res/layout/fragment_accounts_in_list.xml create mode 100644 app/src/main/res/menu/list_actions.xml diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt new file mode 100644 index 00000000..68d9671f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt @@ -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 . + */ + +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 + +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() { + 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(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() { + 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(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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java index 6f7cc4f7..7a194115 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java @@ -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 resultList = new ArrayList<>(); - List accountList = mastodonApi - .searchAccounts(token.substring(1), false, 20) - .execute() - .body(); - if (accountList != null) { - resultList.addAll(accountList); + try { + List accountList = mastodonApi + .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 response = mastodonApi.search(token, false).execute(); if (response.isSuccessful() && response.body() != null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index e95b7df8..86d14335 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -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 . + */ + 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, val loadingState: LoadingState) - -class ListsViewModel(private val api: MastodonApi) { - - private var _view: WeakReference? = 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> { - override fun onResponse(call: Call>, response: Response>) { - updateState(state.copy(lists = response.body() ?: listOf(), loadingState = LOADED)) - } - - override fun onFailure(call: Call>, 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 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(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) + } + + 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 onDestroy() { - viewModel.detach() - super.onDestroy() - } + 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) - override fun onRetainCustomNonConfigurationInstance(): Any { - return viewModel + 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() { - - private val items = mutableListOf() - - fun update(list: List) { - this.items.clear() - this.items.addAll(list) - notifyDataSetChanged() + private object ListsDiffer : DiffUtil.ItemCallback() { + 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(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()) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java index 9b42dfc7..b115dd72 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java @@ -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) { diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index df1ee4de..128bb1f5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -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 + } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index 0849bac7..175c5b36 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt index d96485c0..d1121f5b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -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 } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt b/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt index 60b2bbc3..224168e8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt @@ -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 . + */ + package com.keylesspalace.tusky.entity /** diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java index 7a7712e7..645330b9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -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> searchAccounts( + Single> 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(@Path("id") String accountId); @@ -312,7 +314,29 @@ public interface MastodonApi { ); @GET("/api/v1/lists") - Call> getLists(); + Single> getLists(); + + @FormUrlEncoded + @POST("api/v1/lists") + Single createList(@Field("title") String title); + + @FormUrlEncoded + @PUT("api/v1/lists/{listId}") + Single 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> 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 accountIds); + + @POST("api/v1/lists/{listId}/accounts") + Completable addCountToList(@Path("listId") String listId, + @Query("account_ids[]") List accountIds); @GET("/api/v1/custom_emojis") Call> getCustomEmojis(); diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Either.kt b/app/src/main/java/com/keylesspalace/tusky/util/Either.kt index d4247d83..f0955cfa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/Either.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/Either.kt @@ -27,6 +27,8 @@ sealed class Either { fun isRight() = this is Right + fun isLeft() = this is Left + fun asLeftOrNull() = (this as? Left)?.value fun asRightOrNull() = (this as? Right)?.value @@ -34,4 +36,12 @@ sealed class Either { fun asLeft(): L = (this as Left).value fun asRight(): R = (this as Right).value + + inline fun map(crossinline mapper: (R) -> N): Either { + return if (this.isLeft()) { + Left(this.asLeft()) + } else { + Right(mapper(this.asRight())) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.java deleted file mode 100644 index efd55110..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.java +++ /dev/null @@ -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 . */ - -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 ArrayList removeDuplicates(List list) { - LinkedHashSet set = new LinkedHashSet<>(list); - return new ArrayList<>(set); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt new file mode 100644 index 00000000..8a5223ce --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt @@ -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 . */ + +@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 removeDuplicates(list: List): ArrayList { + val set = LinkedHashSet(list) + return ArrayList(set) +} + +inline fun List.withoutFirstWhich(predicate: (T) -> Boolean): List { + val newList = toMutableList() + val index = newList.indexOfFirst(predicate) + if (index != -1) { + newList.removeAt(index) + } + return newList +} + +inline fun List.replacedFirstWhich(replacement: T, predicate: (T) -> Boolean): List { + val newList = toMutableList() + val index = newList.indexOfFirst(predicate) + if (index != -1) { + newList[index] = replacement + } + return newList +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt index 24b64650..2e421015 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt @@ -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 . + */ + 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) + } + }) } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt new file mode 100644 index 00000000..58c844d9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt @@ -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 . + */ + +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>, val searchResult: List?) + +class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { + + val state: Observable 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!!)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt new file mode 100644 index 00000000..8424b103 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt @@ -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 . + */ + +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, val loadingState: LoadingState) + + val state: Observable get() = _state + val events: Observable get() = _events + private val _state = BehaviorSubject.createDefault(State(listOf(), LoadingState.INITIAL)) + private val _events = PublishSubject.create() + 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) + } +} diff --git a/app/src/main/res/drawable/dialog_bg.xml b/app/src/main/res/drawable/dialog_bg.xml new file mode 100644 index 00000000..6749156d --- /dev/null +++ b/app/src/main/res/drawable/dialog_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_lists.xml b/app/src/main/res/layout/activity_lists.xml index 44ccf5c8..45b01066 100644 --- a/app/src/main/res/layout/activity_lists.xml +++ b/app/src/main/res/layout/activity_lists.xml @@ -1,40 +1,56 @@ - - + - + - + + + + + + + + + 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" /> - - - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_accounts_in_list.xml b/app/src/main/res/layout/fragment_accounts_in_list.xml new file mode 100644 index 00000000..be2a51e3 --- /dev/null +++ b/app/src/main/res/layout/fragment_accounts_in_list.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_follow_request.xml b/app/src/main/res/layout/item_follow_request.xml index 07f877ab..0a56d866 100644 --- a/app/src/main/res/layout/item_follow_request.xml +++ b/app/src/main/res/layout/item_follow_request.xml @@ -9,7 +9,7 @@ android:paddingRight="16dp"> - + android:orientation="horizontal"> + + + + + diff --git a/app/src/main/res/menu/list_actions.xml b/app/src/main/res/menu/list_actions.xml new file mode 100644 index 00000000..307fbf74 --- /dev/null +++ b/app/src/main/res/menu/list_actions.xml @@ -0,0 +1,13 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7711b743..a6080242 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -325,6 +325,16 @@ Lists Lists List timeline + Could not create list + Could not rename list + Could not delete list + Create a list + Rename the list + Delete the list + Edit the list + Search for people you follow + Add account to the list + Remove account from the list Posting with account %1$s @@ -435,4 +445,6 @@ %s; %s; %s, %s, %s; %s, %s, %s, %s; %s, %s, %s + List name + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 67dc25ef..97af0a5b 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -47,6 +47,12 @@ + + -