refactor: Store tab preferences as polymorphic JSON

This commit is contained in:
Nik Clayton 2024-03-12 10:43:33 +01:00
parent e93e4ffb53
commit a973f67ac8
10 changed files with 152 additions and 116 deletions

View File

@ -77,7 +77,7 @@ import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.data.repository.Lists import app.pachli.core.data.repository.Lists
import app.pachli.core.data.repository.ListsRepository import app.pachli.core.data.repository.ListsRepository
import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.AccountEntity
import app.pachli.core.database.model.TabKind import app.pachli.core.database.model.TabData
import app.pachli.core.designsystem.EmbeddedFontFamily import app.pachli.core.designsystem.EmbeddedFontFamily
import app.pachli.core.designsystem.R as DR import app.pachli.core.designsystem.R as DR
import app.pachli.core.navigation.AboutActivityIntent import app.pachli.core.navigation.AboutActivityIntent
@ -866,8 +866,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
tabLayoutMediator = TabLayoutMediator(activeTabLayout, binding.viewPager, true) { tabLayoutMediator = TabLayoutMediator(activeTabLayout, binding.viewPager, true) {
tab: TabLayout.Tab, position: Int -> tab: TabLayout.Tab, position: Int ->
tab.icon = AppCompatResources.getDrawable(this@MainActivity, tabs[position].icon) tab.icon = AppCompatResources.getDrawable(this@MainActivity, tabs[position].icon)
tab.contentDescription = when (tabs[position].kind) { tab.contentDescription = when (tabs[position].tabData) {
TabKind.LIST -> tabs[position].arguments[1] is TabData.UserList -> tabs[position].title(this@MainActivity)
else -> getString(tabs[position].text) else -> getString(tabs[position].text)
} }
}.also { it.attach() } }.also { it.attach() }
@ -877,7 +877,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
// - The previously selected tab (if it hasn't been removed) // - The previously selected tab (if it hasn't been removed)
// - Left-most tab // - Left-most tab
val position = if (selectNotificationTab) { val position = if (selectNotificationTab) {
tabs.indexOfFirst { it.kind == TabKind.NOTIFICATIONS } tabs.indexOfFirst { it.tabData is TabData.Notifications }
} else { } else {
previousTab?.let { tabs.indexOfFirst { it == previousTab } } previousTab?.let { tabs.indexOfFirst { it == previousTab } }
}.takeIf { it != -1 } ?: 0 }.takeIf { it != -1 } ?: 0

View File

@ -49,7 +49,6 @@ import app.pachli.core.common.extensions.visible
import app.pachli.core.data.repository.Lists import app.pachli.core.data.repository.Lists
import app.pachli.core.data.repository.ListsRepository import app.pachli.core.data.repository.ListsRepository
import app.pachli.core.database.model.TabData import app.pachli.core.database.model.TabData
import app.pachli.core.database.model.TabKind
import app.pachli.core.designsystem.R as DR import app.pachli.core.designsystem.R as DR
import app.pachli.core.navigation.ListActivityIntent import app.pachli.core.navigation.ListActivityIntent
import app.pachli.core.network.model.MastoList import app.pachli.core.network.model.MastoList
@ -126,7 +125,7 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
MaterialDividerItemDecoration(this, MaterialDividerItemDecoration.VERTICAL), MaterialDividerItemDecoration(this, MaterialDividerItemDecoration.VERTICAL),
) )
addTabAdapter = TabAdapter(listOf(TabViewData.from(TabData(TabKind.DIRECT))), true, this) addTabAdapter = TabAdapter(listOf(TabViewData.from(TabData.Direct)), true, this)
binding.addTabRecyclerView.adapter = addTabAdapter binding.addTabRecyclerView.adapter = addTabAdapter
binding.addTabRecyclerView.layoutManager = LinearLayoutManager(this) binding.addTabRecyclerView.layoutManager = LinearLayoutManager(this)
@ -189,12 +188,12 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
override fun onTabAdded(tab: TabViewData) { override fun onTabAdded(tab: TabViewData) {
toggleFab(false) toggleFab(false)
if (tab.kind == TabKind.HASHTAG) { if (tab.tabData is TabData.Hashtag) {
showAddHashtagDialog() showAddHashtagDialog()
return return
} }
if (tab.kind == TabKind.LIST) { if (tab.tabData is TabData.UserList) {
showSelectListDialog() showSelectListDialog()
return return
} }
@ -212,14 +211,16 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
saveTabs() saveTabs()
} }
override fun onActionChipClicked(tab: TabViewData, tabPosition: Int) { override fun onActionChipClicked(tabData: TabData.Hashtag, tabPosition: Int) {
showAddHashtagDialog(tab, tabPosition) showAddHashtagDialog(tabData, tabPosition)
} }
override fun onChipClicked(tab: TabViewData, tabPosition: Int, chipPosition: Int) { override fun onChipClicked(tabData: TabData.Hashtag, tabPosition: Int, chipPosition: Int) {
val newArguments = tab.arguments.filterIndexed { i, _ -> i != chipPosition } currentTabs[tabPosition] = currentTabs[tabPosition].copy(
val newTab = tab.copy(tabData = tab.tabData.copy(arguments = newArguments)) tabData = tabData.copy(
currentTabs[tabPosition] = newTab tags = tabData.tags.filterIndexed { i, _ -> i != chipPosition },
),
)
saveTabs() saveTabs()
currentTabsAdapter.notifyItemChanged(tabPosition) currentTabsAdapter.notifyItemChanged(tabPosition)
@ -243,7 +244,7 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
onFabDismissedCallback.isEnabled = expand onFabDismissedCallback.isEnabled = expand
} }
private fun showAddHashtagDialog(tab: TabViewData? = null, tabPosition: Int = 0) { private fun showAddHashtagDialog(tabData: TabData.Hashtag? = null, tabPosition: Int = 0) {
val frameLayout = FrameLayout(this) val frameLayout = FrameLayout(this)
val padding = Utils.dpToPx(this, 8) val padding = Utils.dpToPx(this, 8)
frameLayout.updatePadding(left = padding, right = padding) frameLayout.updatePadding(left = padding, right = padding)
@ -259,13 +260,14 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.action_save) { _, _ -> .setPositiveButton(R.string.action_save) { _, _ ->
val input = editText.text.toString().trim() val input = editText.text.toString().trim()
if (tab == null) { if (tabData == null) {
val newTab = TabViewData.from(TabData(TabKind.HASHTAG, listOf(input))) val newTab = TabViewData.from(TabData.Hashtag(listOf(input)))
currentTabs.add(newTab) currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
} else { } else {
val newTab = tab.copy(tabData = tab.tabData.copy(arguments = tab.arguments + input)) currentTabs[tabPosition] = currentTabs[tabPosition].copy(
currentTabs[tabPosition] = newTab tabData = tabData.copy(tags = tabData.tags + input),
)
currentTabsAdapter.notifyItemChanged(tabPosition) currentTabsAdapter.notifyItemChanged(tabPosition)
} }
@ -315,7 +317,7 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
.setView(statusLayout) .setView(statusLayout)
.setAdapter(adapter) { _, position -> .setAdapter(adapter) { _, position ->
adapter.getItem(position)?.let { item -> adapter.getItem(position)?.let { item ->
val newTab = TabViewData.from(TabData(TabKind.LIST, listOf(item.id, item.title))) val newTab = TabViewData.from(TabData.UserList(item.id, item.title))
currentTabs.add(newTab) currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
updateAvailableTabs() updateAvailableTabs()
@ -377,45 +379,45 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
private fun updateAvailableTabs() { private fun updateAvailableTabs() {
val addableTabs: MutableList<TabViewData> = mutableListOf() val addableTabs: MutableList<TabViewData> = mutableListOf()
val homeTab = TabViewData.from(TabData(TabKind.HOME)) val homeTab = TabViewData.from(TabData.Home)
if (!currentTabs.contains(homeTab)) { if (!currentTabs.contains(homeTab)) {
addableTabs.add(homeTab) addableTabs.add(homeTab)
} }
val notificationTab = TabViewData.from(TabData(TabKind.NOTIFICATIONS)) val notificationTab = TabViewData.from(TabData.Notifications)
if (!currentTabs.contains(notificationTab)) { if (!currentTabs.contains(notificationTab)) {
addableTabs.add(notificationTab) addableTabs.add(notificationTab)
} }
val localTab = TabViewData.from(TabData(TabKind.LOCAL)) val localTab = TabViewData.from(TabData.Local)
if (!currentTabs.contains(localTab)) { if (!currentTabs.contains(localTab)) {
addableTabs.add(localTab) addableTabs.add(localTab)
} }
val federatedTab = TabViewData.from(TabData(TabKind.FEDERATED)) val federatedTab = TabViewData.from(TabData.Federated)
if (!currentTabs.contains(federatedTab)) { if (!currentTabs.contains(federatedTab)) {
addableTabs.add(federatedTab) addableTabs.add(federatedTab)
} }
val directMessagesTab = TabViewData.from(TabData(TabKind.DIRECT)) val directMessagesTab = TabViewData.from(TabData.Direct)
if (!currentTabs.contains(directMessagesTab)) { if (!currentTabs.contains(directMessagesTab)) {
addableTabs.add(directMessagesTab) addableTabs.add(directMessagesTab)
} }
val trendingTagsTab = TabViewData.from(TabData(TabKind.TRENDING_TAGS)) val trendingTagsTab = TabViewData.from(TabData.TrendingTags)
if (!currentTabs.contains(trendingTagsTab)) { if (!currentTabs.contains(trendingTagsTab)) {
addableTabs.add(trendingTagsTab) addableTabs.add(trendingTagsTab)
} }
val trendingLinksTab = TabViewData.from(TabData(TabKind.TRENDING_LINKS)) val trendingLinksTab = TabViewData.from(TabData.TrendingLinks)
if (!currentTabs.contains(trendingLinksTab)) { if (!currentTabs.contains(trendingLinksTab)) {
addableTabs.add(trendingLinksTab) addableTabs.add(trendingLinksTab)
} }
val trendingStatusesTab = TabViewData.from(TabData(TabKind.TRENDING_STATUSES)) val trendingStatusesTab = TabViewData.from(TabData.TrendingStatuses)
if (!currentTabs.contains(trendingStatusesTab)) { if (!currentTabs.contains(trendingStatusesTab)) {
addableTabs.add(trendingStatusesTab) addableTabs.add(trendingStatusesTab)
} }
val bookmarksTab = TabViewData.from(TabData(TabKind.BOOKMARKS)) val bookmarksTab = TabViewData.from(TabData.Bookmarks)
if (!currentTabs.contains(trendingTagsTab)) { if (!currentTabs.contains(trendingTagsTab)) {
addableTabs.add(bookmarksTab) addableTabs.add(bookmarksTab)
} }
addableTabs.add(TabViewData.from(TabData(TabKind.HASHTAG))) addableTabs.add(TabViewData.from(TabData.Hashtag(emptyList())))
addableTabs.add(TabViewData.from(TabData(TabKind.LIST))) addableTabs.add(TabViewData.from(TabData.UserList("", "")))
addTabAdapter.updateData(addableTabs) addTabAdapter.updateData(addableTabs)

View File

@ -27,7 +27,6 @@ import app.pachli.components.timeline.TimelineFragment
import app.pachli.components.trending.TrendingLinksFragment import app.pachli.components.trending.TrendingLinksFragment
import app.pachli.components.trending.TrendingTagsFragment import app.pachli.components.trending.TrendingTagsFragment
import app.pachli.core.database.model.TabData import app.pachli.core.database.model.TabData
import app.pachli.core.database.model.TabKind
import app.pachli.core.network.model.TimelineKind import app.pachli.core.network.model.TimelineKind
/** /**
@ -43,13 +42,9 @@ data class TabViewData(
val tabData: TabData, val tabData: TabData,
@StringRes val text: Int, @StringRes val text: Int,
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
val fragment: (List<String>) -> Fragment, val fragment: () -> Fragment,
val title: (Context) -> String = { context -> context.getString(text) }, val title: (Context) -> String = { context -> context.getString(text) },
) { ) {
val kind get() = this.tabData.kind
val arguments get() = this.tabData.arguments
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
@ -64,64 +59,62 @@ data class TabViewData(
override fun hashCode() = tabData.hashCode() override fun hashCode() = tabData.hashCode()
companion object { companion object {
fun from(tabKind: TabKind) = from(TabData.from(tabKind)) fun from(tabData: TabData) = when (tabData) {
TabData.Home -> TabViewData(
fun from(tabData: TabData) = when (tabData.kind) {
TabKind.HOME -> TabViewData(
tabData = tabData, tabData = tabData,
text = R.string.title_home, text = R.string.title_home,
icon = R.drawable.ic_home_24dp, icon = R.drawable.ic_home_24dp,
fragment = { TimelineFragment.newInstance(TimelineKind.Home) }, fragment = { TimelineFragment.newInstance(TimelineKind.Home) },
) )
TabKind.NOTIFICATIONS -> TabViewData( TabData.Notifications -> TabViewData(
tabData = tabData, tabData = tabData,
text = R.string.title_notifications, text = R.string.title_notifications,
icon = R.drawable.ic_notifications_24dp, icon = R.drawable.ic_notifications_24dp,
fragment = { NotificationsFragment.newInstance() }, fragment = { NotificationsFragment.newInstance() },
) )
TabKind.LOCAL -> TabViewData( TabData.Local -> TabViewData(
tabData = tabData, tabData = tabData,
text = R.string.title_public_local, text = R.string.title_public_local,
icon = R.drawable.ic_local_24dp, icon = R.drawable.ic_local_24dp,
fragment = { TimelineFragment.newInstance(TimelineKind.PublicLocal) }, fragment = { TimelineFragment.newInstance(TimelineKind.PublicLocal) },
) )
TabKind.FEDERATED -> TabViewData( TabData.Federated -> TabViewData(
tabData = tabData, tabData = tabData,
text = R.string.title_public_federated, text = R.string.title_public_federated,
icon = R.drawable.ic_public_24dp, icon = R.drawable.ic_public_24dp,
fragment = { TimelineFragment.newInstance(TimelineKind.PublicFederated) }, fragment = { TimelineFragment.newInstance(TimelineKind.PublicFederated) },
) )
TabKind.DIRECT -> TabViewData( TabData.Direct -> TabViewData(
tabData = tabData, tabData = tabData,
text = R.string.title_direct_messages, text = R.string.title_direct_messages,
icon = R.drawable.ic_reblog_direct_24dp, icon = R.drawable.ic_reblog_direct_24dp,
fragment = { ConversationsFragment.newInstance() }, fragment = { ConversationsFragment.newInstance() },
) )
TabKind.TRENDING_TAGS -> TabViewData( TabData.TrendingTags -> TabViewData(
tabData = tabData, tabData = tabData,
text = R.string.title_public_trending_hashtags, text = R.string.title_public_trending_hashtags,
icon = R.drawable.ic_trending_up_24px, icon = R.drawable.ic_trending_up_24px,
fragment = { TrendingTagsFragment.newInstance() }, fragment = { TrendingTagsFragment.newInstance() },
) )
TabKind.TRENDING_LINKS -> TabViewData( TabData.TrendingLinks -> TabViewData(
tabData = tabData, tabData = tabData,
text = R.string.title_public_trending_links, text = R.string.title_public_trending_links,
icon = R.drawable.ic_trending_up_24px, icon = R.drawable.ic_trending_up_24px,
fragment = { TrendingLinksFragment.newInstance() }, fragment = { TrendingLinksFragment.newInstance() },
) )
TabKind.TRENDING_STATUSES -> TabViewData( TabData.TrendingStatuses -> TabViewData(
tabData = tabData, tabData = tabData,
text = R.string.title_public_trending_statuses, text = R.string.title_public_trending_statuses,
icon = R.drawable.ic_trending_up_24px, icon = R.drawable.ic_trending_up_24px,
fragment = { TimelineFragment.newInstance(TimelineKind.TrendingStatuses) }, fragment = { TimelineFragment.newInstance(TimelineKind.TrendingStatuses) },
) )
TabKind.HASHTAG -> TabViewData( is TabData.Hashtag -> TabViewData(
tabData = tabData, tabData = tabData,
text = R.string.hashtags, text = R.string.hashtags,
icon = R.drawable.ic_hashtag, icon = R.drawable.ic_hashtag,
fragment = { args -> TimelineFragment.newInstance(TimelineKind.Tag(args)) }, fragment = { TimelineFragment.newInstance(TimelineKind.Tag(tabData.tags)) },
title = { context -> title = { context ->
tabData.arguments.joinToString(separator = " ") { tabData.tags.joinToString(separator = " ") {
context.getString( context.getString(
R.string.title_tag, R.string.title_tag,
it, it,
@ -129,18 +122,18 @@ data class TabViewData(
} }
}, },
) )
TabKind.LIST -> TabViewData( is TabData.UserList -> TabViewData(
tabData = tabData, tabData = tabData,
text = R.string.list, text = R.string.list,
icon = R.drawable.ic_list, icon = R.drawable.ic_list,
fragment = { args -> fragment = {
TimelineFragment.newInstance( TimelineFragment.newInstance(
TimelineKind.UserList(args.first(), args.last()), TimelineKind.UserList(tabData.listId, tabData.title),
) )
}, },
title = { tabData.arguments.getOrNull(1).orEmpty() }, title = { tabData.title },
) )
TabKind.BOOKMARKS -> TabViewData( TabData.Bookmarks -> TabViewData(
tabData = tabData, tabData = tabData,
text = R.string.title_bookmarks, text = R.string.title_bookmarks,
icon = R.drawable.ic_bookmark_active_24dp, icon = R.drawable.ic_bookmark_active_24dp,

View File

@ -26,7 +26,7 @@ import app.pachli.R
import app.pachli.TabViewData import app.pachli.TabViewData
import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
import app.pachli.core.database.model.TabKind import app.pachli.core.database.model.TabData
import app.pachli.core.designsystem.R as DR import app.pachli.core.designsystem.R as DR
import app.pachli.databinding.ItemTabPreferenceBinding import app.pachli.databinding.ItemTabPreferenceBinding
import app.pachli.databinding.ItemTabPreferenceSmallBinding import app.pachli.databinding.ItemTabPreferenceSmallBinding
@ -39,8 +39,8 @@ interface ItemInteractionListener {
fun onTabRemoved(position: Int) fun onTabRemoved(position: Int)
fun onStartDelete(viewHolder: RecyclerView.ViewHolder) fun onStartDelete(viewHolder: RecyclerView.ViewHolder)
fun onStartDrag(viewHolder: RecyclerView.ViewHolder) fun onStartDrag(viewHolder: RecyclerView.ViewHolder)
fun onActionChipClicked(tab: TabViewData, tabPosition: Int) fun onActionChipClicked(tabData: TabData.Hashtag, tabPosition: Int)
fun onChipClicked(tab: TabViewData, tabPosition: Int, chipPosition: Int) fun onChipClicked(tabData: TabData.Hashtag, tabPosition: Int, chipPosition: Int)
} }
class TabAdapter( class TabAdapter(
@ -81,8 +81,8 @@ class TabAdapter(
} else { } else {
val binding = holder.binding as ItemTabPreferenceBinding val binding = holder.binding as ItemTabPreferenceBinding
if (tab.kind == TabKind.LIST) { if (tab.tabData is TabData.UserList) {
binding.textView.text = tab.arguments.getOrNull(1).orEmpty() binding.textView.text = tab.tabData.title
} else { } else {
binding.textView.setText(tab.text) binding.textView.setText(tab.text)
} }
@ -107,7 +107,7 @@ class TabAdapter(
(if (removeButtonEnabled) android.R.attr.textColorTertiary else DR.attr.textColorDisabled), (if (removeButtonEnabled) android.R.attr.textColorTertiary else DR.attr.textColorDisabled),
) )
if (tab.kind == TabKind.HASHTAG) { if (tab.tabData is TabData.Hashtag) {
binding.chipGroup.show() binding.chipGroup.show()
/* /*
@ -115,7 +115,7 @@ class TabAdapter(
* The other dynamic chips are inserted in front of the actionChip. * The other dynamic chips are inserted in front of the actionChip.
* This code tries to reuse already added chips to reduce the number of Views created. * This code tries to reuse already added chips to reduce the number of Views created.
*/ */
tab.arguments.forEachIndexed { i, arg -> tab.tabData.tags.forEachIndexed { i, arg ->
val chip = binding.chipGroup.getChildAt(i).takeUnless { it.id == R.id.actionChip } as Chip? val chip = binding.chipGroup.getChildAt(i).takeUnless { it.id == R.id.actionChip } as Chip?
?: Chip(context).apply { ?: Chip(context).apply {
@ -126,23 +126,23 @@ class TabAdapter(
chip.text = arg chip.text = arg
if (tab.arguments.size <= 1) { if (tab.tabData.tags.size <= 1) {
chip.isCloseIconVisible = false chip.isCloseIconVisible = false
chip.setOnClickListener(null) chip.setOnClickListener(null)
} else { } else {
chip.isCloseIconVisible = true chip.isCloseIconVisible = true
chip.setOnClickListener { chip.setOnClickListener {
listener.onChipClicked(tab, holder.bindingAdapterPosition, i) listener.onChipClicked(tab.tabData, holder.bindingAdapterPosition, i)
} }
} }
} }
while (binding.chipGroup.size - 1 > tab.arguments.size) { while (binding.chipGroup.size - 1 > tab.tabData.tags.size) {
binding.chipGroup.removeViewAt(tab.arguments.size) binding.chipGroup.removeViewAt(tab.tabData.tags.size)
} }
binding.actionChip.setOnClickListener { binding.actionChip.setOnClickListener {
listener.onActionChipClicked(tab, holder.bindingAdapterPosition) listener.onActionChipClicked(tab.tabData, holder.bindingAdapterPosition)
} }
} else { } else {
binding.chipGroup.hide() binding.chipGroup.hide()

View File

@ -25,7 +25,7 @@ class MainPagerAdapter(var tabs: List<TabViewData>, activity: FragmentActivity)
override fun createFragment(position: Int): Fragment { override fun createFragment(position: Int): Fragment {
val tab = tabs[position] val tab = tabs[position]
return tab.fragment(tab.arguments) return tab.fragment()
} }
override fun getItemCount() = tabs.size override fun getItemCount() = tabs.size

View File

@ -31,7 +31,7 @@ import app.pachli.components.notifications.createNotificationChannelsForAccount
import app.pachli.components.notifications.makeNotification import app.pachli.components.notifications.makeNotification
import app.pachli.core.accounts.AccountManager import app.pachli.core.accounts.AccountManager
import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.AccountEntity
import app.pachli.core.database.model.TabKind import app.pachli.core.database.model.TabData
import app.pachli.core.database.model.defaultTabs import app.pachli.core.database.model.defaultTabs
import app.pachli.core.navigation.AccountListActivityIntent import app.pachli.core.navigation.AccountListActivityIntent
import app.pachli.core.network.model.Account import app.pachli.core.network.model.Account
@ -154,7 +154,7 @@ class MainActivityTest {
rule.launch(intent) rule.launch(intent)
rule.getScenario().onActivity { rule.getScenario().onActivity {
val currentTab = it.findViewById<ViewPager2>(R.id.viewPager).currentItem val currentTab = it.findViewById<ViewPager2>(R.id.viewPager).currentItem
val notificationTab = defaultTabs().indexOfFirst { it.kind == TabKind.NOTIFICATIONS } val notificationTab = defaultTabs().indexOfFirst { it is TabData.Notifications }
assertEquals(currentTab, notificationTab) assertEquals(currentTab, notificationTab)
} }
} }

View File

@ -39,4 +39,7 @@ dependencies {
implementation(libs.moshi) implementation(libs.moshi)
implementation(libs.moshi.adapters) implementation(libs.moshi.adapters)
ksp(libs.moshi.codegen) ksp(libs.moshi.codegen)
implementation(libs.moshix.sealed.runtime)
ksp(libs.moshix.sealed.codegen)
} }

View File

@ -33,7 +33,6 @@ import app.pachli.core.network.model.TranslatedPoll
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter import com.squareup.moshi.adapter
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder
import java.time.Instant import java.time.Instant
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
@ -68,17 +67,47 @@ class Converters @Inject constructor(
@TypeConverter @TypeConverter
fun stringToTabData(str: String?): List<TabData>? { fun stringToTabData(str: String?): List<TabData>? {
return str?.split(";") str ?: return null
?.map {
val data = it.split(":") // Two possible storage formats. Newer (from Pachli 2.4.0) is polymorphic
TabData.from(data[0], data.drop(1).map { s -> URLDecoder.decode(s, "UTF-8") }) // JSON, and the first character will be a '['
if (str.startsWith('[')) {
return moshi.adapter<List<TabData>>().fromJson(str)
}
// Older is string of ';' delimited tuples, one per tab.
// The per-tab data is ':' delimited tuples where the first item is the tab's kind,
// any subsequent entries are tab-specific data.
//
// The "Trending_..." / "Trending..." is to work around
// https://github.com/pachli/pachli-android/issues/329
return str.split(";").map {
val data = it.split(":")
val kind = data[0]
val arguments = data.drop(1).map { s -> URLDecoder.decode(s, "UTF-8") }
when (kind) {
"Home" -> TabData.Home
"Notifications" -> TabData.Notifications
"Local" -> TabData.Local
"Federated" -> TabData.Federated
"Direct" -> TabData.Direct
// Work around for https://github.com/pachli/pachli-android/issues/329
// when the Trending... kinds may have been serialised without the '_'
"TrendingTags", "Trending_Tags" -> TabData.TrendingTags
"TrendingLinks", "Trending_Links" -> TabData.TrendingLinks
"TrendingStatuses", "Trending_Statuses" -> TabData.TrendingStatuses
"Hashtag" -> TabData.Hashtag(arguments)
"List" -> TabData.UserList(arguments[0], arguments[1])
"Bookmarks" -> TabData.Bookmarks
else -> throw IllegalStateException("Unrecognised tab kind: $kind")
} }
}
} }
@TypeConverter @TypeConverter
fun tabDataToString(tabData: List<TabData>?): String? { fun tabDataToString(tabData: List<TabData>?): String? {
// List name may include ":" return moshi.adapter<List<TabData>>().toJson(tabData)
return tabData?.joinToString(";") { it.kind.repr + ":" + it.arguments.joinToString(":") { s -> URLEncoder.encode(s, "UTF-8") } }
} }
@TypeConverter @TypeConverter

View File

@ -17,48 +17,54 @@
package app.pachli.core.database.model package app.pachli.core.database.model
/** import com.squareup.moshi.JsonClass
* A tab's kind. import dev.zacsweers.moshix.sealed.annotations.TypeLabel
*
* @param repr String representation of the tab in the database
*/
enum class TabKind(val repr: String) {
HOME("Home"),
NOTIFICATIONS("Notifications"),
LOCAL("Local"),
FEDERATED("Federated"),
DIRECT("Direct"),
TRENDING_TAGS("Trending_Tags"),
TRENDING_LINKS("Trending_Links"),
TRENDING_STATUSES("Trending_Statuses"),
HASHTAG("Hashtag"),
LIST("List"),
BOOKMARKS("Bookmarks"),
}
/** this would be a good case for a sealed class, but that does not work nice with Room */ @JsonClass(generateAdapter = true, generator = "sealed:kind")
sealed interface TabData {
@TypeLabel("home")
data object Home : TabData
data class TabData(val kind: TabKind, val arguments: List<String> = emptyList()) { @TypeLabel("notifications")
companion object { data object Notifications : TabData
fun from(kind: TabKind, arguments: List<String> = emptyList()) =
TabData(kind, arguments)
fun from(kind: String, arguments: List<String> = emptyList()): TabData { @TypeLabel("local")
// Work around for https://github.com/pachli/pachli-android/issues/329, data object Local : TabData
// as the Trending... kinds may have been serialised without the `_`
return when (kind) { @TypeLabel("federated")
"TrendingTags" -> TabData(TabKind.TRENDING_TAGS, arguments) data object Federated : TabData
"TrendingLinks" -> TabData(TabKind.TRENDING_LINKS, arguments)
"TrendingStatuses" -> TabData(TabKind.TRENDING_STATUSES, arguments) @TypeLabel("direct")
else -> TabData(TabKind.valueOf(kind.uppercase()), arguments) data object Direct : TabData
}
} @TypeLabel("trending_tags")
} data object TrendingTags : TabData
@TypeLabel("trending_links")
data object TrendingLinks : TabData
@TypeLabel("trending_statuses")
data object TrendingStatuses : TabData
/**
* @property tags List of one or more hashtags (without the leading '#')
* to show in the tab.
*/
@TypeLabel("hashtag")
@JsonClass(generateAdapter = true)
data class Hashtag(val tags: List<String>) : TabData
@TypeLabel("list")
@JsonClass(generateAdapter = true)
data class UserList(val listId: String, val title: String) : TabData
@TypeLabel("bookmarks")
data object Bookmarks : TabData
} }
fun defaultTabs() = listOf( fun defaultTabs() = listOf(
TabData.from(TabKind.HOME), TabData.Home,
TabData.from(TabKind.NOTIFICATIONS), TabData.Notifications,
TabData.from(TabKind.LOCAL), TabData.Local,
TabData.from(TabKind.DIRECT), TabData.Direct,
) )

View File

@ -54,6 +54,7 @@ material-typeface = "4.0.0.2-kotlin"
mockito-inline = "5.2.0" mockito-inline = "5.2.0"
mockito-kotlin = "5.2.1" mockito-kotlin = "5.2.1"
moshi = "1.15.1" moshi = "1.15.1"
moshix = "0.25.1"
networkresult-calladapter = "1.0.0" networkresult-calladapter = "1.0.0"
okhttp = "4.12.0" okhttp = "4.12.0"
quadrant = "1.9.1" quadrant = "1.9.1"
@ -177,6 +178,8 @@ mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "moshi" } moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "moshi" }
moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
moshix-sealed-runtime = { module = "dev.zacsweers.moshix:moshi-sealed-runtime", version.ref = "moshix" }
moshix-sealed-codegen = { module = "dev.zacsweers.moshix:moshi-sealed-codegen", version.ref = "moshix" }
networkresult-calladapter = { module = "at.connyduck:networkresult-calladapter", version.ref = "networkresult-calladapter" } networkresult-calladapter = { module = "at.connyduck:networkresult-calladapter", version.ref = "networkresult-calladapter" }
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }