add hashtag tabs (#1145)

* add hashtag tabs

* address review feedback
This commit is contained in:
Konrad Pozniak 2019-03-24 08:59:55 +01:00 committed by GitHub
parent c343fb9d28
commit 0c48dcf06c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 209 additions and 58 deletions

View file

@ -29,20 +29,22 @@ const val NOTIFICATIONS = "Notifications"
const val LOCAL = "Local" const val LOCAL = "Local"
const val FEDERATED = "Federated" const val FEDERATED = "Federated"
const val DIRECT = "Direct" const val DIRECT = "Direct"
const val HASHTAG = "Hashtag"
data class TabData(val id: String, data class TabData(val id: String,
@StringRes val text: Int, @StringRes val text: Int,
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
val fragment: () -> Fragment) val fragment: (List<String>) -> Fragment,
val arguments: List<String> = emptyList())
fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabData {
fun createTabDataFromId(id: String): TabData {
return when (id) { return when (id) {
HOME -> TabData(HOME, R.string.title_home, R.drawable.ic_home_24dp) { TimelineFragment.newInstance(TimelineFragment.Kind.HOME) } HOME -> TabData(HOME, R.string.title_home, R.drawable.ic_home_24dp, { TimelineFragment.newInstance(TimelineFragment.Kind.HOME) })
NOTIFICATIONS -> TabData(NOTIFICATIONS, R.string.title_notifications, R.drawable.ic_notifications_24dp) { NotificationsFragment.newInstance() } NOTIFICATIONS -> TabData(NOTIFICATIONS, R.string.title_notifications, R.drawable.ic_notifications_24dp, { NotificationsFragment.newInstance() })
LOCAL -> TabData(LOCAL, R.string.title_public_local, R.drawable.ic_local_24dp) { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL) } LOCAL -> TabData(LOCAL, R.string.title_public_local, R.drawable.ic_local_24dp, { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL) })
FEDERATED -> TabData(FEDERATED, R.string.title_public_federated, R.drawable.ic_public_24dp) { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED) } FEDERATED -> TabData(FEDERATED, R.string.title_public_federated, R.drawable.ic_public_24dp, { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED) })
DIRECT -> TabData(DIRECT, R.string.title_direct_messages, R.drawable.reblog_direct_dark) { ConversationsFragment.newInstance() } DIRECT -> TabData(DIRECT, R.string.title_direct_messages, R.drawable.reblog_direct_dark, { ConversationsFragment.newInstance() })
HASHTAG -> TabData(HASHTAG, R.string.hashtag, R.drawable.ic_hashtag, { args -> TimelineFragment.newInstance(TimelineFragment.Kind.TAG, args.getOrNull(0).orEmpty()) }, arguments)
else -> throw IllegalArgumentException("unknown tab type") else -> throw IllegalArgumentException("unknown tab type")
} }
} }

View file

@ -17,6 +17,8 @@ package com.keylesspalace.tusky
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatEditText
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
@ -27,16 +29,17 @@ import com.keylesspalace.tusky.adapter.TabAdapter
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
import com.uber.autodispose.autoDisposable
import io.reactivex.Single import io.reactivex.Single
import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.Schedulers
import kotlinx.android.synthetic.main.activity_tab_preference.* import kotlinx.android.synthetic.main.activity_tab_preference.*
import kotlinx.android.synthetic.main.toolbar_basic.* import kotlinx.android.synthetic.main.toolbar_basic.*
import java.util.regex.Pattern
import javax.inject.Inject import javax.inject.Inject
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
import com.uber.autodispose.autoDisposable
class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListener { class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListener {
@Inject @Inject
@ -51,6 +54,8 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
private val selectedItemElevation by lazy { resources.getDimension(R.dimen.selected_drag_item_elevation) } private val selectedItemElevation by lazy { resources.getDimension(R.dimen.selected_drag_item_elevation) }
private val hashtagRegex by lazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -74,7 +79,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
addTabRecyclerView.adapter = addTabAdapter addTabRecyclerView.adapter = addTabAdapter
addTabRecyclerView.layoutManager = LinearLayoutManager(this) addTabRecyclerView.layoutManager = LinearLayoutManager(this)
touchHelper = ItemTouchHelper(object: ItemTouchHelper.Callback(){ touchHelper = ItemTouchHelper(object : ItemTouchHelper.Callback() {
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.END) return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.END)
} }
@ -105,7 +110,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
} }
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
if(actionState == ItemTouchHelper.ACTION_STATE_DRAG) { if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
viewHolder?.itemView?.elevation = selectedItemElevation viewHolder?.itemView?.elevation = selectedItemElevation
} }
} }
@ -134,37 +139,93 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
} }
override fun onTabAdded(tab: TabData) { override fun onTabAdded(tab: TabData) {
if (currentTabs.size >= MAX_TAB_COUNT) {
return
}
actionButton.isExpanded = false
if (tab.id == HASHTAG) {
showEditHashtagDialog()
return
}
currentTabs.add(tab) currentTabs.add(tab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
actionButton.isExpanded = false
updateAvailableTabs() updateAvailableTabs()
saveTabs() saveTabs()
} }
override fun onActionChipClicked(tab: TabData) {
showEditHashtagDialog(tab)
}
private fun showEditHashtagDialog(tab: TabData? = null) {
val editText = AppCompatEditText(this)
editText.setHint(R.string.edit_hashtag_hint)
editText.setText("")
editText.append(tab?.arguments?.first().orEmpty())
val dialog = AlertDialog.Builder(this)
.setTitle(R.string.edit_hashtag_title)
.setView(editText)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.action_save) { _, _ ->
val input = editText.text.toString().trim()
if (tab == null) {
val newTab = createTabDataFromId(HASHTAG, listOf(input))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
} else {
val newTab = tab.copy(arguments = listOf(input))
val position = currentTabs.indexOf(tab)
currentTabs[position] = newTab
currentTabsAdapter.notifyItemChanged(position)
}
updateAvailableTabs()
saveTabs()
}
.create()
editText.onTextChanged { s, _, _, _ ->
val input = s.trim()
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = input.isNotEmpty() && hashtagRegex.matcher(input).matches()
}
dialog.show()
editText.requestFocus()
}
private fun updateAvailableTabs() { private fun updateAvailableTabs() {
val addableTabs: MutableList<TabData> = mutableListOf() val addableTabs: MutableList<TabData> = mutableListOf()
val homeTab = createTabDataFromId(HOME) val homeTab = createTabDataFromId(HOME)
if(!currentTabs.contains(homeTab)) { if (!currentTabs.contains(homeTab)) {
addableTabs.add(homeTab) addableTabs.add(homeTab)
} }
val notificationTab = createTabDataFromId(NOTIFICATIONS) val notificationTab = createTabDataFromId(NOTIFICATIONS)
if(!currentTabs.contains(notificationTab)) { if (!currentTabs.contains(notificationTab)) {
addableTabs.add(notificationTab) addableTabs.add(notificationTab)
} }
val localTab = createTabDataFromId(LOCAL) val localTab = createTabDataFromId(LOCAL)
if(!currentTabs.contains(localTab)) { if (!currentTabs.contains(localTab)) {
addableTabs.add(localTab) addableTabs.add(localTab)
} }
val federatedTab = createTabDataFromId(FEDERATED) val federatedTab = createTabDataFromId(FEDERATED)
if(!currentTabs.contains(federatedTab)) { if (!currentTabs.contains(federatedTab)) {
addableTabs.add(federatedTab) addableTabs.add(federatedTab)
} }
val directMessagesTab = createTabDataFromId(DIRECT) val directMessagesTab = createTabDataFromId(DIRECT)
if(!currentTabs.contains(directMessagesTab)) { if (!currentTabs.contains(directMessagesTab)) {
addableTabs.add(directMessagesTab) addableTabs.add(directMessagesTab)
} }
addableTabs.add(createTabDataFromId(HASHTAG))
addTabAdapter.updateData(addableTabs) addTabAdapter.updateData(addableTabs)
maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT) maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT)
@ -211,7 +272,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
if(tabsChanged) { if (tabsChanged) {
eventHub.dispatch(MainTabsChangedEvent(currentTabs)) eventHub.dispatch(MainTabsChangedEvent(currentTabs))
} }
} }

View file

@ -19,22 +19,26 @@ import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.HASHTAG
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import kotlinx.android.synthetic.main.item_tab_preference.view.* import kotlinx.android.synthetic.main.item_tab_preference.view.*
interface ItemInteractionListener { interface ItemInteractionListener {
fun onTabAdded(tab: TabData) fun onTabAdded(tab: TabData)
fun onStartDelete(viewHolder: RecyclerView.ViewHolder) fun onStartDelete(viewHolder: RecyclerView.ViewHolder)
fun onStartDrag(viewHolder: RecyclerView.ViewHolder) fun onStartDrag(viewHolder: RecyclerView.ViewHolder)
fun onActionChipClicked(tab: TabData)
} }
class TabAdapter(var data: List<TabData>, class TabAdapter(private var data: List<TabData>,
val small: Boolean = false, private val small: Boolean = false,
val listener: ItemInteractionListener? = null) : RecyclerView.Adapter<TabAdapter.ViewHolder>() { private val listener: ItemInteractionListener? = null) : RecyclerView.Adapter<TabAdapter.ViewHolder>() {
fun updateData(newData: List<TabData>) { fun updateData(newData: List<TabData>) {
this.data = newData this.data = newData
@ -42,7 +46,7 @@ class TabAdapter(var data: List<TabData>,
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val layoutId = if(small) { val layoutId = if (small) {
R.layout.item_tab_preference_small R.layout.item_tab_preference_small
} else { } else {
R.layout.item_tab_preference R.layout.item_tab_preference
@ -52,26 +56,43 @@ class TabAdapter(var data: List<TabData>,
} }
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val context = holder.itemView.context
holder.itemView.textView.setText(data[position].text) holder.itemView.textView.setText(data[position].text)
val iconDrawable = ThemeUtils.getTintedDrawable(holder.itemView.context, data[position].icon, android.R.attr.textColorSecondary) val iconDrawable = ThemeUtils.getTintedDrawable(context, data[position].icon, android.R.attr.textColorSecondary)
holder.itemView.textView.setCompoundDrawablesRelativeWithIntrinsicBounds(iconDrawable, null, null, null) holder.itemView.textView.setCompoundDrawablesRelativeWithIntrinsicBounds(iconDrawable, null, null, null)
if(small) { if (small) {
holder.itemView.textView.setOnClickListener { holder.itemView.textView.setOnClickListener {
listener?.onTabAdded(data[position]) listener?.onTabAdded(data[position])
} }
} }
holder.itemView.imageView?.setOnTouchListener { _, event -> holder.itemView.imageView?.setOnTouchListener { _, event ->
if(event.action == MotionEvent.ACTION_DOWN) { if (event.action == MotionEvent.ACTION_DOWN) {
listener?.onStartDrag(holder) listener?.onStartDrag(holder)
true true
} else { } else {
false false
} }
} }
if (!small) {
if (data[position].id == HASHTAG) {
holder.itemView.chipGroup.show()
holder.itemView.actionChip.text = data[position].arguments[0]
holder.itemView.actionChip.setChipIconResource(R.drawable.ic_edit_chip)
holder.itemView.actionChip.chipIcon = context.getDrawable(R.drawable.ic_edit_chip)
holder.itemView.actionChip.setOnClickListener {
listener?.onActionChipClicked(data[position])
}
} else {
holder.itemView.chipGroup.hide()
}
}
} }
override fun getItemCount(): Int { override fun getItemCount(): Int {
return data.size return data.size
} }

View file

@ -58,12 +58,15 @@ class Converters {
@TypeConverter @TypeConverter
fun stringToTabData(str: String?): List<TabData>? { fun stringToTabData(str: String?): List<TabData>? {
return str?.split(";") return str?.split(";")
?.map { createTabDataFromId(it) } ?.map {
val data = it.split(":")
createTabDataFromId(data[0], data.drop(1))
}
} }
@TypeConverter @TypeConverter
fun tabDataToString(tabData: List<TabData>?): String? { fun tabDataToString(tabData: List<TabData>?): String? {
return tabData?.joinToString(";") { it.id } return tabData?.joinToString(";") { it.id + ":" + it.arguments.joinToString(":") }
} }
@TypeConverter @TypeConverter

View file

@ -24,7 +24,8 @@ import com.keylesspalace.tusky.TabData
class MainPagerAdapter(val tabs: List<TabData>, manager: FragmentManager) : FragmentPagerAdapter(manager) { class MainPagerAdapter(val tabs: List<TabData>, manager: FragmentManager) : FragmentPagerAdapter(manager) {
override fun getItem(position: Int): Fragment { override fun getItem(position: Int): Fragment {
return tabs[position].fragment() val tab = tabs[position]
return tab.fragment(tab.arguments)
} }
override fun getCount(): Int { override fun getCount(): Int {
@ -36,7 +37,7 @@ class MainPagerAdapter(val tabs: List<TabData>, manager: FragmentManager) : Frag
} }
override fun getItemId(position: Int): Long { override fun getItemId(position: Int): Long {
return tabs[position].id.hashCode().toLong() return tabs[position].hashCode() + position.toLong()
} }
override fun getItemPosition(item: Any): Int { override fun getItemPosition(item: Any): Int {

View file

@ -34,20 +34,20 @@ fun View.visible(visible: Boolean, or: Int = View.GONE) {
} }
open class DefaultTextWatcher : TextWatcher { open class DefaultTextWatcher : TextWatcher {
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable) {
} }
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
} }
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
} }
} }
inline fun EditText.onTextChanged( inline fun EditText.onTextChanged(
crossinline callback: (s: CharSequence?, start: Int, before: Int, count: Int) -> Unit) { crossinline callback: (s: CharSequence, start: Int, before: Int, count: Int) -> Unit) {
addTextChangedListener(object : DefaultTextWatcher() { addTextChangedListener(object : DefaultTextWatcher() {
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
callback(s, start, before, count) callback(s, start, before, count)
} }
}) })

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="24dp"
android:layout_height="24dp">
<background android:drawable="@color/tusky_blue" />
<foreground>
<inset
android:drawable="@drawable/ic_create_24dp"
android:inset="30%" />
</foreground>
</adaptive-icon>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?android:attr/textColorPrimary"
android:pathData="m4.5088,16.3703v3.1209H7.6297L16.8342,10.2866 13.7134,7.1658ZM19.2477,7.8732c0.3246,-0.3246 0.3246,-0.8489 0,-1.1735l-1.9474,-1.9474c-0.3246,-0.3246 -0.8489,-0.3246 -1.1735,0l-1.523,1.523 3.1209,3.1209z" />
</vector>

View file

@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#000" android:pathData="M5.41,21L6.12,17H2.12L2.47,15H6.47L7.53,9H3.53L3.88,7H7.88L8.59,3H10.59L9.88,7H15.88L16.59,3H18.59L17.88,7H21.88L21.53,9H17.53L16.47,15H20.47L20.12,17H16.12L15.41,21H13.41L14.12,17H8.12L7.41,21H5.41M9.53,9L8.47,15H14.47L15.53,9H9.53Z" />
</vector>

View file

@ -1,28 +1,59 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_height="wrap_content" xmlns:tools="http://schemas.android.com/tools"
android:background="?android:colorBackground" android:layout_width="match_parent"
android:orientation="horizontal" android:layout_height="wrap_content"
android:padding="16dp"> android:background="?android:colorBackground"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingTop="16dp"
android:paddingEnd="16dp">
<ImageView <ImageView
android:id="@+id/imageView" android:id="@+id/imageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end" android:layout_gravity="end"
android:src="@drawable/ic_drag_indicator_24dp" android:src="@drawable/ic_drag_indicator_24dp"
android:layout_width="wrap_content" app:layout_constraintStart_toStartOf="parent"
android:layout_height="wrap_content" /> app:layout_constraintTop_toTopOf="parent" />
<TextView <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:textSize="?attr/status_text_large" android:layout_width="0dp"
android:textColor="?android:attr/textColorSecondary" android:layout_height="wrap_content"
android:drawableStart="@drawable/ic_home_24dp" android:layout_marginStart="8dp"
android:layout_width="0dp" android:layout_weight="1"
android:layout_marginStart="8dp" android:drawablePadding="12dp"
android:drawablePadding="12dp" android:textColor="?android:attr/textColorSecondary"
android:layout_weight="1" android:textSize="?attr/status_text_large"
android:layout_height="wrap_content"/> app:layout_constraintBottom_toTopOf="@id/chipGroup"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/imageView"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginBottom="16dp"
tools:drawableStart="@drawable/ic_home_24dp"
tools:text="Home" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginBottom="8dp"
android:paddingTop="8dp"
app:layout_constraintBottom_toBottomOf="parent">
</LinearLayout> <com.google.android.material.chip.Chip
android:id="@+id/actionChip"
style="@style/Widget.MaterialComponents.Chip.Action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checkable="false"
tools:text="add hashtag" />
</com.google.android.material.chip.ChipGroup>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -457,4 +457,9 @@
<string name="hint_list_name">List name</string> <string name="hint_list_name">List name</string>
<string name="edit_hashtag_title">Edit hashtag</string>
<string name="edit_hashtag_hint">Hashtag without #</string>
<string name="hashtag">Hashtag</string>
</resources> </resources>