Add trending tags (#3149)
* Add initial feature for viewing trending graphs. Currently only views hash tag trends. Contains API additions, tab additions and a set of trending components. * Add clickable system through a LinkListener. Duplicates a little code from SFragment. * Add accessibility description. * The background for the graph should match the background for black theme too. * Add error handling through a state flow system using existing code as an example. * Graphing: Use a primary and a secondary line. Remove under line fill. Apply line thickness. Dotted end of line. * Trending changes: New layout for trending: Cell. Use ViewBinding. Add padding to RecyclerView to stop the FAB from hiding content. Multiple bugs in GraphView resolved. Wide (landscape, for example) will show 4 columns, portrait will show 2. Remove unused base holder class. ViewModel invalidate scoping changed. Some renaming to variables made. For uses and accounts, use longs. These could be big numbers eventually. TagViewHolder renamed to TrendingTagViewHolder. * Trending changes: Remove old layout. Update cell textsizes and use proper string. Remove bad comment. * Trending changes: Refresh the main drawer when the tabs are edited. This will allow the trending item to toggle. * Trending changes: Add a trending activity to be able to view the trending data from the main drawer. * Trending changes: The title text should be changed to Trending Hashtags. * Trending changes: Add meta color to draw axis etc. Draw the date boundaries on the graph. Remove dates from each cell and place them in the list as a header. Graphs should be proportional to the highest historical value. Add a new interface to control whether the FAB should be visible (important when switching tabs, the state is lost). Add header to the adapter and viewdata structures. Add QOL extensions for getting the dates from history. * Trending changes: Refresh FAB through the main activity and FabFragment interface. Trending has no FAB. * Trending changes: Make graph proportional to the highest usage value. Fixes to the graph ratio calculations. * Trending changes: KtLintFix * Trending changes: Remove accidental build gradle change. Remove trending cases. Remove unused progress. Set drawer button addition explicitly to false, leaving the code there for future issue #3010. Remove unnecessary arguments for intent. Remove media preview preferences, there is nothing to preview. No padding between hashtag symbol and text. Do not ellipsize hashtags. * Trending changes: Use bottomsheet slide in animation helper for opening the hashtag intent. Remove explicit layout height from the XML and apply it to the view holder itself. The height was not being respected in XML. * Use some platform standards for styling - Align on an 8dp grid - Use android:attr for paddingStart and paddingEnd - Use textAppearanceListItem variants - Adjust constraints to handle different size containers * Correct lineWidth calculations Previous code didn't convert the value to pixels, so it was always displaying as a hairline stroke, irrespective of the value in the layout file. While I'm here, rename from lineThickness to lineWidth, to be consistent with parameters like strokeWidth. * Does not need to inherit from FabFragment * Rename to TrendingAdapter "Paging" in the adapter name is a misnomer here * Clean up comments, use full class name as tag * Simplify TrendingViewModel - Remove unncessary properties - Fetch tags and map in invalidate() - emptyList() instead of listOf() for clarity * Remove line dividers, use X-axis to separate content Experiment with UI -- instead of dividers between each item, draw an explicit x-axis for each chart, and add a little more vertical padding, to see if that provides a cleaner separation between the content * Adjust date format - Show day and year - Use platform attributes for size * Locale-aware format of numbers Format numbers < 100,000 by inserting locale-aware separators. Numbers larger are scaled and have K, M, G, ... etc suffix appended. * Prevent a crash if viewData is empty Don't access viewData without first checking if it's empty. This can be the case if the server returned an empty list for some reason, or the data has been filtered. * Filter out tags the user has filtered from their home timeline Invalidate the list if the user's preferences change, as that may indicate they've changed their filters. * Experiment with alternative layout * Set chart height to 160dp to align to an 8dp grid * Draw ticks that are 5% the height of the x-axis * Legend adjustments - Use tuskyblue for the ticks - Wrap legend components in a layout so they can have a dedicated background - Use a 60% transparent background for the legend to retain legibility if lines go under it * Bezier curves, shorter cell height * More tweaks - List tags in order of popularity, most popular first - Make it clear that uses/accounts in the legend are totals, not current - Show current values at end of the chart * Hide FAB * Fix crash, it's not always hosted in an ActionButtonActivity * Arrange totals vertically in landscape layout * Always add the Trending drawer menu if it's not a tab * Revert unrelated whitespace changes * One more whitespace revert --------- Co-authored-by: Nik Clayton <nik@ngo.org.uk>
This commit is contained in:
parent
8d2ef28028
commit
98e5363692
32 changed files with 1716 additions and 17 deletions
|
@ -54,7 +54,6 @@ android {
|
|||
shrinkResources true
|
||||
proguardFiles 'proguard-rules.pro'
|
||||
}
|
||||
debug {}
|
||||
}
|
||||
|
||||
flavorDimensions += "color"
|
||||
|
|
|
@ -143,6 +143,7 @@
|
|||
<activity android:name=".ListsActivity" />
|
||||
<activity android:name=".LicenseActivity" />
|
||||
<activity android:name=".FiltersActivity" />
|
||||
<activity android:name=".components.trending.TrendingActivity" />
|
||||
<activity android:name=".components.followedtags.FollowedTagsActivity" />
|
||||
<activity
|
||||
android:name=".components.report.ReportActivity"
|
||||
|
|
|
@ -75,6 +75,7 @@ import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNec
|
|||
import com.keylesspalace.tusky.components.preference.PreferencesActivity
|
||||
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
|
||||
import com.keylesspalace.tusky.components.search.SearchActivity
|
||||
import com.keylesspalace.tusky.components.trending.TrendingActivity
|
||||
import com.keylesspalace.tusky.databinding.ActivityMainBinding
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.DraftsAlert
|
||||
|
@ -82,6 +83,7 @@ import com.keylesspalace.tusky.entity.Account
|
|||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.FabFragment
|
||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.pager.MainPagerAdapter
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
|
@ -261,7 +263,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
binding.viewPager.reduceSwipeSensitivity()
|
||||
|
||||
setupDrawer(savedInstanceState, addSearchButton = hideTopToolbar)
|
||||
setupDrawer(
|
||||
savedInstanceState,
|
||||
addSearchButton = hideTopToolbar,
|
||||
addTrendingButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING)
|
||||
)
|
||||
|
||||
/* Fetch user info while we're doing other things. This has to be done after setting up the
|
||||
* drawer, though, because its callback touches the header in the drawer. */
|
||||
|
@ -277,7 +283,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
.subscribe { event: Event? ->
|
||||
when (event) {
|
||||
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
|
||||
is MainTabsChangedEvent -> setupTabs(false)
|
||||
is MainTabsChangedEvent -> {
|
||||
refreshMainDrawerItems(
|
||||
addSearchButton = hideTopToolbar,
|
||||
addTrendingButton = !event.newTabs.hasTab(TRENDING),
|
||||
)
|
||||
|
||||
setupTabs(false)
|
||||
}
|
||||
|
||||
is AnnouncementReadEvent -> {
|
||||
unreadAnnouncementsCount--
|
||||
updateAnnouncementsBadge()
|
||||
|
@ -397,7 +411,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
finish()
|
||||
}
|
||||
|
||||
private fun setupDrawer(savedInstanceState: Bundle?, addSearchButton: Boolean) {
|
||||
private fun setupDrawer(
|
||||
savedInstanceState: Bundle?,
|
||||
addSearchButton: Boolean,
|
||||
addTrendingButton: Boolean
|
||||
) {
|
||||
|
||||
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
|
||||
|
||||
|
@ -455,6 +473,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
})
|
||||
|
||||
binding.mainDrawer.apply {
|
||||
refreshMainDrawerItems(addSearchButton, addTrendingButton)
|
||||
setSavedInstance(savedInstanceState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) {
|
||||
binding.mainDrawer.apply {
|
||||
itemAdapter.clear()
|
||||
tintStatusBar = true
|
||||
addItems(
|
||||
primaryDrawerItem {
|
||||
|
@ -521,7 +547,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
badgeStyle = BadgeStyle().apply {
|
||||
textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorOnPrimary))
|
||||
color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, androidx.appcompat.R.attr.colorPrimary))
|
||||
color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorPrimary))
|
||||
}
|
||||
},
|
||||
DividerDrawerItem(),
|
||||
|
@ -569,7 +595,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
)
|
||||
}
|
||||
|
||||
setSavedInstance(savedInstanceState)
|
||||
if (addTrendingButton) {
|
||||
binding.mainDrawer.addItemsAtPosition(
|
||||
5,
|
||||
primaryDrawerItem {
|
||||
nameRes = R.string.title_public_trending_hashtags
|
||||
iconicsIcon = GoogleMaterial.Icon.gmd_trending_up
|
||||
onClick = {
|
||||
startActivityWithSlideInAnimation(TrendingActivity.getIntent(context))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
|
@ -672,6 +709,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
binding.mainToolbar.title = tabs[tab.position].title(this@MainActivity)
|
||||
|
||||
refreshComposeButtonState(adapter, tab.position)
|
||||
}
|
||||
|
||||
override fun onTabUnselected(tab: TabLayout.Tab) {}
|
||||
|
@ -681,6 +720,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
if (fragment is ReselectableFragment) {
|
||||
(fragment as ReselectableFragment).onReselect()
|
||||
}
|
||||
|
||||
refreshComposeButtonState(adapter, tab.position)
|
||||
}
|
||||
}.also {
|
||||
activeTabLayout.addOnTabSelectedListener(it)
|
||||
|
@ -695,6 +736,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
updateProfiles()
|
||||
}
|
||||
|
||||
private fun refreshComposeButtonState(adapter: MainPagerAdapter, tabPosition: Int) {
|
||||
adapter.getFragment(tabPosition)?.also { fragment ->
|
||||
if (fragment is FabFragment) {
|
||||
if (fragment.isFabVisible()) {
|
||||
binding.composeButton.show()
|
||||
} else {
|
||||
binding.composeButton.hide()
|
||||
}
|
||||
} else {
|
||||
binding.composeButton.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean {
|
||||
val activeAccount = accountManager.activeAccount
|
||||
|
||||
|
@ -930,16 +985,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
private fun updateProfiles() {
|
||||
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
val profiles: MutableList<IProfile> = accountManager.getAllAccountsOrderedByActive().map { acc ->
|
||||
ProfileDrawerItem().apply {
|
||||
isSelected = acc.isActive
|
||||
nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis)
|
||||
iconUrl = acc.profilePictureUrl
|
||||
isNameShown = true
|
||||
identifier = acc.id
|
||||
descriptionText = acc.fullName
|
||||
}
|
||||
}.toMutableList()
|
||||
val profiles: MutableList<IProfile> =
|
||||
accountManager.getAllAccountsOrderedByActive().map { acc ->
|
||||
ProfileDrawerItem().apply {
|
||||
isSelected = acc.isActive
|
||||
nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis)
|
||||
iconUrl = acc.profilePictureUrl
|
||||
isNameShown = true
|
||||
identifier = acc.id
|
||||
descriptionText = acc.fullName
|
||||
}
|
||||
}.toMutableList()
|
||||
|
||||
// reuse the already existing "add account" item
|
||||
for (profile in header.profiles.orEmpty()) {
|
||||
|
|
|
@ -22,6 +22,7 @@ import androidx.fragment.app.Fragment
|
|||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||
import com.keylesspalace.tusky.components.trending.TrendingFragment
|
||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||
|
||||
/** this would be a good case for a sealed class, but that does not work nice with Room */
|
||||
|
@ -31,6 +32,7 @@ const val NOTIFICATIONS = "Notifications"
|
|||
const val LOCAL = "Local"
|
||||
const val FEDERATED = "Federated"
|
||||
const val DIRECT = "Direct"
|
||||
const val TRENDING = "Trending"
|
||||
const val HASHTAG = "Hashtag"
|
||||
const val LIST = "List"
|
||||
|
||||
|
@ -43,6 +45,8 @@ data class TabData(
|
|||
val title: (Context) -> String = { context -> context.getString(text) }
|
||||
)
|
||||
|
||||
fun List<TabData>.hasTab(id: String): Boolean = this.find { it.id == id } != null
|
||||
|
||||
fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabData {
|
||||
return when (id) {
|
||||
HOME -> TabData(
|
||||
|
@ -75,6 +79,12 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
|
|||
R.drawable.ic_reblog_direct_24dp,
|
||||
{ ConversationsFragment.newInstance() }
|
||||
)
|
||||
TRENDING -> TabData(
|
||||
TRENDING,
|
||||
R.string.title_public_trending_hashtags,
|
||||
R.drawable.ic_trending_up_24px,
|
||||
{ TrendingFragment.newInstance() }
|
||||
)
|
||||
HASHTAG -> TabData(
|
||||
HASHTAG,
|
||||
R.string.hashtags,
|
||||
|
|
|
@ -317,6 +317,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
if (!currentTabs.contains(directMessagesTab)) {
|
||||
addableTabs.add(directMessagesTab)
|
||||
}
|
||||
val trendingTab = createTabDataFromId(TRENDING)
|
||||
if (!currentTabs.contains(trendingTab)) {
|
||||
addableTabs.add(trendingTab)
|
||||
}
|
||||
|
||||
addableTabs.add(createTabDataFromId(HASHTAG))
|
||||
addableTabs.add(createTabDataFromId(LIST))
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
/* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
class TrendingDateViewHolder(
|
||||
private val binding: ItemTrendingDateBinding,
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
private val dateFormat = SimpleDateFormat("EEE dd MMM yyyy", Locale.getDefault()).apply {
|
||||
this.timeZone = TimeZone.getDefault()
|
||||
}
|
||||
|
||||
fun setup(start: Date, end: Date) {
|
||||
binding.dates.text = itemView.context.getString(
|
||||
R.string.date_range,
|
||||
dateFormat.format(start),
|
||||
dateFormat.format(end)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
/* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
|
||||
import com.keylesspalace.tusky.entity.TrendingTagHistory
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
||||
import java.text.NumberFormat
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.pow
|
||||
|
||||
class TrendingTagViewHolder(
|
||||
private val binding: ItemTrendingCellBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun setup(
|
||||
tagViewData: TrendingViewData.Tag,
|
||||
maxTrendingValue: Long,
|
||||
trendingListener: LinkListener,
|
||||
) {
|
||||
val reversedHistory = tagViewData.tag.history.reversed()
|
||||
setGraph(reversedHistory, maxTrendingValue)
|
||||
setTag(tagViewData.tag.name)
|
||||
|
||||
val totalUsage = tagViewData.tag.history.sumOf { it.uses.toLongOrNull() ?: 0 }
|
||||
binding.totalUsage.text = formatNumber(totalUsage)
|
||||
|
||||
val totalAccounts = tagViewData.tag.history.sumOf { it.accounts.toLongOrNull() ?: 0 }
|
||||
binding.totalAccounts.text = formatNumber(totalAccounts)
|
||||
|
||||
binding.currentUsage.text = reversedHistory.last().uses
|
||||
binding.currentAccounts.text = reversedHistory.last().accounts
|
||||
|
||||
itemView.setOnClickListener {
|
||||
trendingListener.onViewTag(tagViewData.tag.name)
|
||||
}
|
||||
|
||||
setAccessibility(totalAccounts, tagViewData.tag.name)
|
||||
}
|
||||
|
||||
private fun setGraph(history: List<TrendingTagHistory>, maxTrendingValue: Long) {
|
||||
binding.graph.maxTrendingValue = maxTrendingValue
|
||||
binding.graph.primaryLineData = history
|
||||
.mapNotNull { it.uses.toLongOrNull() }
|
||||
binding.graph.secondaryLineData = history
|
||||
.mapNotNull { it.accounts.toLongOrNull() }
|
||||
}
|
||||
|
||||
private fun setTag(tag: String) {
|
||||
binding.tag.text = binding.root.context.getString(R.string.title_tag, tag)
|
||||
}
|
||||
|
||||
private fun setAccessibility(totalAccounts: Long, tag: String) {
|
||||
itemView.contentDescription =
|
||||
itemView.context.getString(R.string.accessibility_talking_about_tag, totalAccounts, tag)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val numberFormatter: NumberFormat = NumberFormat.getInstance()
|
||||
private val ln_1k = ln(1000.0)
|
||||
|
||||
/**
|
||||
* Format numbers according to the current locale. Numbers < min have
|
||||
* separators (',', '.', etc) inserted according to the locale.
|
||||
*
|
||||
* Numbers > min are scaled down to that by multiples of 1,000, and
|
||||
* a suffix appropriate to the scaling is appended.
|
||||
*/
|
||||
private fun formatNumber(num: Long, min: Int = 100000): String {
|
||||
if (num < min) return numberFormatter.format(num)
|
||||
|
||||
val exp = (ln(num.toDouble()) / ln_1k).toInt()
|
||||
|
||||
// TODO: is the choice of suffixes here locale-agnostic?
|
||||
return String.format("%.1f %c", num / 1000.0.pow(exp.toDouble()), "KMGTPE"[exp - 1])
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
/* Copyright 2019 Tusky Contributors
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.trending
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.commit
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.databinding.ActivityTrendingBinding
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import javax.inject.Inject
|
||||
|
||||
class TrendingActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||
|
||||
@Inject
|
||||
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
private val binding: ActivityTrendingBinding by viewBinding(ActivityTrendingBinding::inflate)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
|
||||
setSupportActionBar(binding.includedToolbar.toolbar)
|
||||
|
||||
val title = getString(R.string.title_public_trending_hashtags)
|
||||
|
||||
supportActionBar?.run {
|
||||
setTitle(title)
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowHomeEnabled(true)
|
||||
}
|
||||
|
||||
if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) {
|
||||
supportFragmentManager.commit {
|
||||
val fragment = TrendingFragment.newInstance()
|
||||
replace(R.id.fragmentContainer, fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun androidInjector() = dispatchingAndroidInjector
|
||||
|
||||
companion object {
|
||||
const val TAG = "TrendingActivity"
|
||||
|
||||
@JvmStatic
|
||||
fun getIntent(context: Context) =
|
||||
Intent(context, TrendingActivity::class.java)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.trending
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.adapter.TrendingDateViewHolder
|
||||
import com.keylesspalace.tusky.adapter.TrendingTagViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
||||
|
||||
class TrendingAdapter(
|
||||
private val trendingListener: LinkListener,
|
||||
) : ListAdapter<TrendingViewData, RecyclerView.ViewHolder>(TrendingDifferCallback) {
|
||||
|
||||
init {
|
||||
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return when (viewType) {
|
||||
VIEW_TYPE_TAG -> {
|
||||
val binding =
|
||||
ItemTrendingCellBinding.inflate(LayoutInflater.from(viewGroup.context))
|
||||
TrendingTagViewHolder(binding)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val binding =
|
||||
ItemTrendingDateBinding.inflate(LayoutInflater.from(viewGroup.context))
|
||||
TrendingDateViewHolder(binding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
|
||||
bindViewHolder(viewHolder, position, null)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: List<*>
|
||||
) {
|
||||
bindViewHolder(viewHolder, position, payloads)
|
||||
}
|
||||
|
||||
private fun bindViewHolder(
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: List<*>?
|
||||
) {
|
||||
when (val header = getItem(position)) {
|
||||
is TrendingViewData.Tag -> {
|
||||
val maxTrendingValue = currentList
|
||||
.flatMap { trendingViewData ->
|
||||
trendingViewData.asTagOrNull()?.tag?.history ?: emptyList()
|
||||
}
|
||||
.mapNotNull { it.uses.toLongOrNull() }
|
||||
.maxOrNull() ?: 1
|
||||
|
||||
val holder = viewHolder as TrendingTagViewHolder
|
||||
holder.setup(header, maxTrendingValue, trendingListener)
|
||||
}
|
||||
|
||||
is TrendingViewData.Header -> {
|
||||
val holder = viewHolder as TrendingDateViewHolder
|
||||
holder.setup(header.start, header.end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if (getItem(position) is TrendingViewData.Tag) {
|
||||
VIEW_TYPE_TAG
|
||||
} else {
|
||||
VIEW_TYPE_HEADER
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val VIEW_TYPE_HEADER = 0
|
||||
const val VIEW_TYPE_TAG = 1
|
||||
|
||||
val TrendingDifferCallback = object : DiffUtil.ItemCallback<TrendingViewData>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: TrendingViewData,
|
||||
newItem: TrendingViewData
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: TrendingViewData,
|
||||
newItem: TrendingViewData
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getChangePayload(
|
||||
oldItem: TrendingViewData,
|
||||
newItem: TrendingViewData
|
||||
): Any? {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,303 @@
|
|||
/* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.trending
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.PostLookupFallbackBehavior
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel
|
||||
import com.keylesspalace.tusky.databinding.FragmentTrendingBinding
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.interfaces.RefreshableFragment
|
||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class TrendingFragment :
|
||||
Fragment(),
|
||||
OnRefreshListener,
|
||||
LinkListener,
|
||||
Injectable,
|
||||
ReselectableFragment,
|
||||
RefreshableFragment {
|
||||
|
||||
private lateinit var bottomSheetActivity: BottomSheetActivity
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
private val viewModel: TrendingViewModel by lazy {
|
||||
ViewModelProvider(this, viewModelFactory)[TrendingViewModel::class.java]
|
||||
}
|
||||
|
||||
private val binding by viewBinding(FragmentTrendingBinding::bind)
|
||||
|
||||
private lateinit var adapter: TrendingAdapter
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
bottomSheetActivity = if (context is BottomSheetActivity) {
|
||||
context
|
||||
} else {
|
||||
throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = TrendingAdapter(
|
||||
this,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
val columnCount =
|
||||
requireContext().resources.getInteger(R.integer.trending_column_count)
|
||||
setupLayoutManager(columnCount)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_trending, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
setupSwipeRefreshLayout()
|
||||
setupRecyclerView()
|
||||
|
||||
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
if (positionStart == 0 && adapter.itemCount != itemCount) {
|
||||
binding.recyclerView.post {
|
||||
if (getView() != null) {
|
||||
binding.recyclerView.scrollBy(
|
||||
0,
|
||||
Utils.dpToPx(requireContext(), -30)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.uiState.collectLatest { trendingState ->
|
||||
processViewState(trendingState)
|
||||
}
|
||||
}
|
||||
|
||||
if (activity is ActionButtonActivity) {
|
||||
(activity as ActionButtonActivity).actionButton?.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupSwipeRefreshLayout() {
|
||||
binding.swipeRefreshLayout.setOnRefreshListener(this)
|
||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
||||
}
|
||||
|
||||
private fun setupLayoutManager(columnCount: Int) {
|
||||
binding.recyclerView.layoutManager = GridLayoutManager(context, columnCount).apply {
|
||||
spanSizeLookup = object : SpanSizeLookup() {
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
return when (adapter.getItemViewType(position)) {
|
||||
TrendingAdapter.VIEW_TYPE_HEADER -> columnCount
|
||||
TrendingAdapter.VIEW_TYPE_TAG -> 1
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
val columnCount =
|
||||
requireContext().resources.getInteger(R.integer.trending_column_count)
|
||||
setupLayoutManager(columnCount)
|
||||
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
binding.recyclerView.adapter = adapter
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
viewModel.invalidate()
|
||||
}
|
||||
|
||||
override fun onViewUrl(url: String) {
|
||||
bottomSheetActivity.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER)
|
||||
}
|
||||
|
||||
override fun onViewTag(tag: String) {
|
||||
bottomSheetActivity.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
|
||||
}
|
||||
|
||||
override fun onViewAccount(id: String) {
|
||||
bottomSheetActivity.viewAccount(id)
|
||||
}
|
||||
|
||||
private fun processViewState(uiState: TrendingViewModel.TrendingUiState) {
|
||||
when (uiState.loadingState) {
|
||||
TrendingViewModel.LoadingState.INITIAL -> clearLoadingState()
|
||||
TrendingViewModel.LoadingState.LOADING -> applyLoadingState()
|
||||
TrendingViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData)
|
||||
TrendingViewModel.LoadingState.ERROR_NETWORK -> networkError()
|
||||
TrendingViewModel.LoadingState.ERROR_OTHER -> otherError()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyLoadedState(viewData: List<TrendingViewData>) {
|
||||
clearLoadingState()
|
||||
|
||||
if (viewData.isEmpty()) {
|
||||
adapter.submitList(emptyList())
|
||||
binding.recyclerView.hide()
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(
|
||||
R.drawable.elephant_friend_empty, R.string.message_empty,
|
||||
null
|
||||
)
|
||||
} else {
|
||||
val viewDataWithDates = listOf(viewData.first().asHeaderOrNull()) + viewData
|
||||
|
||||
adapter.submitList(viewDataWithDates)
|
||||
|
||||
binding.recyclerView.show()
|
||||
binding.messageView.hide()
|
||||
}
|
||||
binding.progressBar.hide()
|
||||
}
|
||||
|
||||
private fun applyLoadingState() {
|
||||
binding.recyclerView.hide()
|
||||
binding.messageView.hide()
|
||||
binding.progressBar.show()
|
||||
}
|
||||
|
||||
private fun clearLoadingState() {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
binding.progressBar.hide()
|
||||
binding.messageView.hide()
|
||||
}
|
||||
|
||||
private fun networkError() {
|
||||
binding.recyclerView.hide()
|
||||
binding.messageView.show()
|
||||
binding.progressBar.hide()
|
||||
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
binding.messageView.setup(
|
||||
R.drawable.elephant_offline,
|
||||
R.string.error_network,
|
||||
) { refreshContent() }
|
||||
}
|
||||
|
||||
private fun otherError() {
|
||||
binding.recyclerView.hide()
|
||||
binding.messageView.show()
|
||||
binding.progressBar.hide()
|
||||
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
binding.messageView.setup(
|
||||
R.drawable.elephant_error,
|
||||
R.string.error_generic,
|
||||
) { refreshContent() }
|
||||
}
|
||||
|
||||
private fun actionButtonPresent(): Boolean {
|
||||
return activity is ActionButtonActivity
|
||||
}
|
||||
|
||||
private var talkBackWasEnabled = false
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val a11yManager =
|
||||
ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java)
|
||||
|
||||
val wasEnabled = talkBackWasEnabled
|
||||
talkBackWasEnabled = a11yManager?.isEnabled == true
|
||||
Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled")
|
||||
if (talkBackWasEnabled && !wasEnabled) {
|
||||
adapter.notifyItemRangeChanged(0, adapter.itemCount)
|
||||
}
|
||||
|
||||
if (actionButtonPresent()) {
|
||||
val composeButton = (activity as ActionButtonActivity).actionButton
|
||||
composeButton?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReselect() {
|
||||
if (isAdded) {
|
||||
binding.recyclerView.layoutManager?.scrollToPosition(0)
|
||||
binding.recyclerView.stopScroll()
|
||||
}
|
||||
}
|
||||
|
||||
override fun refreshContent() {
|
||||
onRefresh()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TrendingFragment"
|
||||
|
||||
fun newInstance(): TrendingFragment {
|
||||
return TrendingFragment()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
/* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.trending.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.toViewData
|
||||
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.asFlow
|
||||
import okio.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class TrendingViewModel @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val eventHub: EventHub
|
||||
) : ViewModel() {
|
||||
enum class LoadingState {
|
||||
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER
|
||||
}
|
||||
|
||||
data class TrendingUiState(
|
||||
val trendingViewData: List<TrendingViewData>,
|
||||
val loadingState: LoadingState
|
||||
)
|
||||
|
||||
val uiState: Flow<TrendingUiState> get() = _uiState
|
||||
private val _uiState = MutableStateFlow(TrendingUiState(listOf(), LoadingState.INITIAL))
|
||||
|
||||
init {
|
||||
invalidate()
|
||||
|
||||
// Collect PreferenceChangedEvent, FiltersActivity creates them when a filter is created
|
||||
// or deleted. Unfortunately, there's nothing in the event to determine if it's a filter
|
||||
// that was modified, so refresh on every preference change.
|
||||
viewModelScope.launch {
|
||||
eventHub.events.asFlow()
|
||||
.filterIsInstance<PreferenceChangedEvent>()
|
||||
.collect {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the current list of trending tags and fetch a new list.
|
||||
*
|
||||
* A tag is excluded if it is filtered by the user on their home timeline.
|
||||
*/
|
||||
fun invalidate() = viewModelScope.launch {
|
||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING)
|
||||
|
||||
try {
|
||||
val deferredFilters = async { mastodonApi.getFilters() }
|
||||
val response = mastodonApi.trendingTags()
|
||||
if (!response.isSuccessful) {
|
||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val homeFilters = deferredFilters.await().getOrNull()?.filter {
|
||||
it.context.contains(Filter.HOME)
|
||||
}
|
||||
|
||||
val tags = response.body()!!
|
||||
.filter { homeFilters?.none { filter -> filter.phrase.equals(it.name, ignoreCase = true) } ?: false }
|
||||
.sortedBy { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
|
||||
.map { it.toViewData() }
|
||||
.asReversed()
|
||||
|
||||
_uiState.value = TrendingUiState(tags, LoadingState.LOADED)
|
||||
} catch (e: IOException) {
|
||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TrendingViewModel"
|
||||
}
|
||||
}
|
|
@ -39,6 +39,7 @@ import com.keylesspalace.tusky.components.preference.PreferencesActivity
|
|||
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
|
||||
import com.keylesspalace.tusky.components.search.SearchActivity
|
||||
import com.keylesspalace.tusky.components.trending.TrendingActivity
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
@ -124,4 +125,7 @@ abstract class ActivitiesModule {
|
|||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributesSplashActivity(): SplashActivity
|
||||
|
||||
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
|
||||
abstract fun contributesTrendingActivity(): TrendingActivity
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragmen
|
|||
import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment
|
||||
import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||
import com.keylesspalace.tusky.components.trending.TrendingFragment
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
|
||||
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
|
||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||
|
@ -99,4 +100,7 @@ abstract class FragmentBuildersModule {
|
|||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun listsForAccountFragment(): ListsForAccountFragment
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun trendingFragment(): TrendingFragment
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel
|
|||
import com.keylesspalace.tusky.components.search.SearchViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
|
||||
import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadViewModel
|
||||
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||
|
@ -144,5 +145,10 @@ abstract class ViewModelModule {
|
|||
@ViewModelKey(ListsForAccountViewModel::class)
|
||||
internal abstract fun listsForAccountViewModel(viewModel: ListsForAccountViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(TrendingViewModel::class)
|
||||
internal abstract fun trendingViewModel(viewModel: TrendingViewModel): ViewModel
|
||||
|
||||
// Add more ViewModels here
|
||||
}
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
/* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.entity
|
||||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Mastodon API Documentation: https://docs.joinmastodon.org/methods/trends/#tags
|
||||
*
|
||||
* @param name The name of the hashtag (after the #). The "caturday" in "#caturday".
|
||||
* @param url The URL to your mastodon instance list for this hashtag.
|
||||
* @param history A list of [TrendingTagHistory]. Each element contains metrics per day for this hashtag.
|
||||
* @param following This is not listed in the APIs at the time of writing, but an instance is delivering it.
|
||||
*/
|
||||
data class TrendingTag(
|
||||
val name: String,
|
||||
val url: String,
|
||||
val history: List<TrendingTagHistory>,
|
||||
val following: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Mastodon API Documentation: https://docs.joinmastodon.org/methods/trends/#tags
|
||||
*
|
||||
* @param day The day that this was posted in Unix Epoch Seconds.
|
||||
* @param accounts The number of accounts that have posted with this hashtag.
|
||||
* @param uses The number of posts with this hashtag.
|
||||
*/
|
||||
data class TrendingTagHistory(
|
||||
val day: String,
|
||||
val accounts: String,
|
||||
val uses: String,
|
||||
)
|
||||
|
||||
fun TrendingTag.start() = Date(history.last().day.toLong() * 1000L)
|
||||
fun TrendingTag.end() = Date(history.first().day.toLong() * 1000L)
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.interfaces
|
||||
|
||||
interface FabFragment {
|
||||
fun isFabVisible(): Boolean
|
||||
}
|
|
@ -42,6 +42,7 @@ import com.keylesspalace.tusky.entity.StatusContext
|
|||
import com.keylesspalace.tusky.entity.StatusEdit
|
||||
import com.keylesspalace.tusky.entity.StatusSource
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.entity.TrendingTag
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
|
@ -709,4 +710,7 @@ interface MastodonApi {
|
|||
|
||||
@POST("api/v1/tags/{name}/unfollow")
|
||||
suspend fun unfollowTag(@Path("name") name: String): NetworkResult<HashTag>
|
||||
|
||||
@GET("api/v1/trends/tags")
|
||||
suspend fun trendingTags(): Response<List<TrendingTag>>
|
||||
}
|
||||
|
|
|
@ -18,8 +18,10 @@ package com.keylesspalace.tusky.util
|
|||
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.entity.TrendingTag
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
||||
|
||||
@JvmName("statusToViewData")
|
||||
fun Status.toViewData(
|
||||
|
@ -51,3 +53,10 @@ fun Notification.toViewData(
|
|||
this.report,
|
||||
)
|
||||
}
|
||||
|
||||
@JvmName("tagToViewData")
|
||||
fun TrendingTag.toViewData(): TrendingViewData.Tag {
|
||||
return TrendingViewData.Tag(
|
||||
tag = this,
|
||||
)
|
||||
}
|
||||
|
|
307
app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt
Normal file
307
app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt
Normal file
|
@ -0,0 +1,307 @@
|
|||
/* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.PathMeasure
|
||||
import android.graphics.Rect
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.Dimension
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.keylesspalace.tusky.R
|
||||
import kotlin.math.max
|
||||
|
||||
class GraphView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
defStyleRes: Int = 0,
|
||||
) : AppCompatImageView(context, attrs, defStyleAttr) {
|
||||
@get:ColorInt
|
||||
@ColorInt
|
||||
var primaryLineColor = 0
|
||||
|
||||
@get:ColorInt
|
||||
@ColorInt
|
||||
var secondaryLineColor = 0
|
||||
|
||||
@get:Dimension
|
||||
var lineWidth = 0f
|
||||
|
||||
@get:ColorInt
|
||||
@ColorInt
|
||||
var graphColor = 0
|
||||
|
||||
@get:ColorInt
|
||||
@ColorInt
|
||||
var metaColor = 0
|
||||
|
||||
var proportionalTrending = false
|
||||
|
||||
private lateinit var primaryLinePaint: Paint
|
||||
private lateinit var secondaryLinePaint: Paint
|
||||
private lateinit var primaryCirclePaint: Paint
|
||||
private lateinit var secondaryCirclePaint: Paint
|
||||
private lateinit var graphPaint: Paint
|
||||
private lateinit var metaPaint: Paint
|
||||
|
||||
private lateinit var sizeRect: Rect
|
||||
private var primaryLinePath: Path = Path()
|
||||
private var secondaryLinePath: Path = Path()
|
||||
|
||||
var maxTrendingValue: Long = 300
|
||||
var primaryLineData: List<Long> = if (isInEditMode) listOf(
|
||||
30, 60, 70, 80, 130, 190, 80,
|
||||
) else listOf(
|
||||
1, 1, 1, 1, 1, 1, 1,
|
||||
)
|
||||
set(value) {
|
||||
field = value.map { max(1, it) }
|
||||
primaryLinePath.reset()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var secondaryLineData: List<Long> = if (isInEditMode) listOf(
|
||||
10, 20, 40, 60, 100, 132, 20,
|
||||
) else listOf(
|
||||
1, 1, 1, 1, 1, 1, 1,
|
||||
)
|
||||
set(value) {
|
||||
field = value.map { max(1, it) }
|
||||
secondaryLinePath.reset()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
init {
|
||||
initFromXML(attrs)
|
||||
}
|
||||
|
||||
private fun initFromXML(attr: AttributeSet?) {
|
||||
val a = context.obtainStyledAttributes(attr, R.styleable.GraphView)
|
||||
|
||||
primaryLineColor = ContextCompat.getColor(
|
||||
context,
|
||||
a.getResourceId(
|
||||
R.styleable.GraphView_primaryLineColor,
|
||||
R.color.tusky_blue,
|
||||
)
|
||||
)
|
||||
|
||||
secondaryLineColor = ContextCompat.getColor(
|
||||
context,
|
||||
a.getResourceId(
|
||||
R.styleable.GraphView_secondaryLineColor,
|
||||
R.color.tusky_red,
|
||||
)
|
||||
)
|
||||
|
||||
lineWidth = a.getDimensionPixelSize(
|
||||
R.styleable.GraphView_lineWidth,
|
||||
R.dimen.graph_line_thickness
|
||||
).toFloat()
|
||||
|
||||
graphColor = ContextCompat.getColor(
|
||||
context,
|
||||
a.getResourceId(
|
||||
R.styleable.GraphView_graphColor,
|
||||
R.color.colorBackground,
|
||||
)
|
||||
)
|
||||
|
||||
metaColor = ContextCompat.getColor(
|
||||
context,
|
||||
a.getResourceId(
|
||||
R.styleable.GraphView_metaColor,
|
||||
R.color.dividerColor,
|
||||
)
|
||||
)
|
||||
|
||||
proportionalTrending = a.getBoolean(
|
||||
R.styleable.GraphView_proportionalTrending,
|
||||
proportionalTrending,
|
||||
)
|
||||
|
||||
primaryLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = primaryLineColor
|
||||
strokeWidth = lineWidth
|
||||
style = Paint.Style.STROKE
|
||||
}
|
||||
|
||||
primaryCirclePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = primaryLineColor
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
secondaryLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = secondaryLineColor
|
||||
strokeWidth = lineWidth
|
||||
style = Paint.Style.STROKE
|
||||
}
|
||||
|
||||
secondaryCirclePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = secondaryLineColor
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
graphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = graphColor
|
||||
}
|
||||
|
||||
metaPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = metaColor
|
||||
strokeWidth = 0f
|
||||
style = Paint.Style.STROKE
|
||||
}
|
||||
|
||||
a.recycle()
|
||||
}
|
||||
|
||||
private fun initializeVertices() {
|
||||
sizeRect = Rect(0, 0, width, height)
|
||||
|
||||
initLine(primaryLineData, primaryLinePath)
|
||||
initLine(secondaryLineData, secondaryLinePath)
|
||||
}
|
||||
|
||||
private fun initLine(lineData: List<Long>, path: Path) {
|
||||
val max = if (proportionalTrending) {
|
||||
maxTrendingValue
|
||||
} else {
|
||||
max(primaryLineData.max(), 1)
|
||||
}
|
||||
val mainRatio = height.toFloat() / max.toFloat()
|
||||
|
||||
val ratioedData = lineData.map { it.toFloat() * mainRatio }
|
||||
|
||||
val pointDistance = dataSpacing(ratioedData)
|
||||
|
||||
/** X coord of the start of this path segment */
|
||||
var startX = 0F
|
||||
|
||||
/** Y coord of the start of this path segment */
|
||||
var startY = 0F
|
||||
|
||||
/** X coord of the end of this path segment */
|
||||
var endX: Float
|
||||
|
||||
/** Y coord of the end of this path segment */
|
||||
var endY: Float
|
||||
|
||||
/** X coord of bezier control point #1 */
|
||||
var controlX1: Float
|
||||
|
||||
/** X coord of bezier control point #2 */
|
||||
var controlX2: Float
|
||||
|
||||
// Draw cubic bezier curves between each pair of points.
|
||||
ratioedData.forEachIndexed { index, magnitude ->
|
||||
val x = pointDistance * index.toFloat()
|
||||
val y = height.toFloat() - magnitude
|
||||
|
||||
if (index == 0) {
|
||||
path.reset()
|
||||
path.moveTo(x, y)
|
||||
startX = x
|
||||
startY = y
|
||||
} else {
|
||||
endX = x
|
||||
endY = y
|
||||
|
||||
// X-coord for a control point is placed one third of the distance between the
|
||||
// two points.
|
||||
val offsetX = (endX - startX) / 3
|
||||
controlX1 = startX + offsetX
|
||||
controlX2 = endX - offsetX
|
||||
path.cubicTo(controlX1, startY, controlX2, endY, x, y)
|
||||
|
||||
startX = x
|
||||
startY = y
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dataSpacing(data: List<Any>) = width.toFloat() / max(data.size - 1, 1).toFloat()
|
||||
|
||||
override fun onDraw(canvas: Canvas?) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
if (primaryLinePath.isEmpty && width > 0) {
|
||||
initializeVertices()
|
||||
}
|
||||
|
||||
canvas?.apply {
|
||||
drawRect(sizeRect, graphPaint)
|
||||
|
||||
val pointDistance = dataSpacing(primaryLineData)
|
||||
|
||||
// Vertical tick marks
|
||||
for (i in 0 until primaryLineData.size + 1) {
|
||||
drawLine(
|
||||
i * pointDistance,
|
||||
height.toFloat(),
|
||||
i * pointDistance,
|
||||
height - (height.toFloat() / 20),
|
||||
metaPaint
|
||||
)
|
||||
}
|
||||
|
||||
// X-axis
|
||||
drawLine(0f, height.toFloat(), width.toFloat(), height.toFloat(), metaPaint)
|
||||
|
||||
// Data lines
|
||||
drawLine(
|
||||
canvas = canvas,
|
||||
linePath = secondaryLinePath,
|
||||
linePaint = secondaryLinePaint,
|
||||
circlePaint = secondaryCirclePaint,
|
||||
lineThickness = lineWidth,
|
||||
)
|
||||
drawLine(
|
||||
canvas = canvas,
|
||||
linePath = primaryLinePath,
|
||||
linePaint = primaryLinePaint,
|
||||
circlePaint = primaryCirclePaint,
|
||||
lineThickness = lineWidth,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawLine(
|
||||
canvas: Canvas,
|
||||
linePath: Path,
|
||||
linePaint: Paint,
|
||||
circlePaint: Paint,
|
||||
lineThickness: Float,
|
||||
) {
|
||||
canvas.apply {
|
||||
drawPath(
|
||||
linePath,
|
||||
linePaint,
|
||||
)
|
||||
|
||||
val pm = PathMeasure(linePath, false)
|
||||
val coord = floatArrayOf(0f, 0f)
|
||||
pm.getPosTan(pm.length * 1f, coord, null)
|
||||
|
||||
drawCircle(coord[0], coord[1], lineThickness * 2f, circlePaint)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
/* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.viewdata
|
||||
|
||||
import com.keylesspalace.tusky.entity.TrendingTag
|
||||
import com.keylesspalace.tusky.entity.end
|
||||
import com.keylesspalace.tusky.entity.start
|
||||
import java.util.Date
|
||||
|
||||
sealed class TrendingViewData {
|
||||
abstract val id: String
|
||||
|
||||
data class Header(
|
||||
val start: Date,
|
||||
val end: Date,
|
||||
) : TrendingViewData() {
|
||||
override val id: String
|
||||
get() = start.toString() + end.toString()
|
||||
}
|
||||
|
||||
fun asHeaderOrNull(): Header? {
|
||||
val tag = (this as? Tag)?.tag
|
||||
?: return null
|
||||
return Header(tag.start(), tag.end())
|
||||
}
|
||||
|
||||
data class Tag(
|
||||
val tag: TrendingTag
|
||||
) : TrendingViewData() {
|
||||
override val id: String
|
||||
get() = tag.name
|
||||
}
|
||||
|
||||
fun asTagOrNull() = this as? Tag
|
||||
}
|
11
app/src/main/res/drawable/ic_trending_up_24px.xml
Normal file
11
app/src/main/res/drawable/ic_trending_up_24px.xml
Normal file
|
@ -0,0 +1,11 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:autoMirrored="true">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3.4,18 L2,16.6 9.4,9.15 13.4,13.15 18.6,8H16V6H22V12H20V9.4L13.4,16L9.4,12Z"/>
|
||||
</vector>
|
163
app/src/main/res/layout-land/item_trending_cell.xml
Normal file
163
app/src/main/res/layout-land/item_trending_cell.xml
Normal file
|
@ -0,0 +1,163 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/trending_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:focusable="true"
|
||||
android:importantForAccessibility="yes"
|
||||
android:padding="8dp"
|
||||
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
|
||||
tools:layout_height="128dp">
|
||||
|
||||
<com.keylesspalace.tusky.view.GraphView
|
||||
android:id="@+id/graph"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="120dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:graphColor="?android:colorBackground"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/current_usage"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:lineWidth="2sp"
|
||||
app:metaColor="@color/tusky_blue_light"
|
||||
app:primaryLineColor="@color/tusky_blue"
|
||||
app:proportionalTrending="true"
|
||||
app:secondaryLineColor="@color/tusky_red" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/current_usage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:importantForAccessibility="no"
|
||||
android:paddingStart="6dp"
|
||||
android:textAlignment="textEnd"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
||||
android:textColor="@color/tusky_blue"
|
||||
android:textSize="8sp"
|
||||
android:textStyle="normal"
|
||||
app:layout_constrainedWidth="true"
|
||||
app:layout_constraintBottom_toTopOf="@id/current_accounts"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/graph"
|
||||
tools:text="12 345" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/current_accounts"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:importantForAccessibility="no"
|
||||
android:paddingStart="6dp"
|
||||
android:textAlignment="textEnd"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
||||
android:textColor="@color/tusky_red"
|
||||
android:textSize="8sp"
|
||||
android:textStyle="normal"
|
||||
app:layout_constrainedWidth="true"
|
||||
app:layout_constraintBottom_toBottomOf="@id/graph"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/graph"
|
||||
tools:text="12 345" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/legend_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:colorBackground"
|
||||
android:backgroundTint="@color/color_background_transparent_60"
|
||||
android:backgroundTintMode="src_in"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tag"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="none"
|
||||
android:importantForAccessibility="no"
|
||||
android:singleLine="true"
|
||||
android:textAlignment="textStart"
|
||||
android:textAppearance="?android:attr/textAppearanceListItem"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:textStyle="normal"
|
||||
app:layout_constrainedWidth="true"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="#itishashtagtuesdayitishashtagtuesday" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/total_usage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:textAlignment="textEnd"
|
||||
android:textAppearance="?android:attr/textAppearanceListItemSmall"
|
||||
android:textColor="@color/tusky_blue"
|
||||
android:textStyle="normal|bold"
|
||||
app:layout_constrainedWidth="false"
|
||||
app:layout_constraintEnd_toStartOf="@id/barrier2"
|
||||
app:layout_constraintHorizontal_chainStyle="spread_inside"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/tag"
|
||||
tools:text="12 345" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/usageLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:text="@string/total_usage"
|
||||
android:textAlignment="textEnd"
|
||||
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
app:layout_constrainedWidth="false"
|
||||
app:layout_constraintBaseline_toBaselineOf="@+id/total_usage"
|
||||
app:layout_constraintStart_toEndOf="@id/barrier2" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/total_accounts"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:importantForAccessibility="no"
|
||||
android:textAlignment="textEnd"
|
||||
android:textAppearance="?android:attr/textAppearanceListItemSmall"
|
||||
android:textColor="@color/tusky_red"
|
||||
android:textStyle="normal|bold"
|
||||
app:layout_constrainedWidth="false"
|
||||
app:layout_constraintEnd_toStartOf="@id/barrier2"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/total_usage"
|
||||
tools:text="498" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/accountsLabel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:text="@string/total_accounts"
|
||||
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
app:layout_constrainedWidth="true"
|
||||
app:layout_constraintBaseline_toBaselineOf="@+id/total_accounts"
|
||||
app:layout_constraintStart_toEndOf="@id/barrier2" />
|
||||
|
||||
<androidx.constraintlayout.widget.Barrier
|
||||
android:id="@+id/barrier2"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:barrierDirection="end"
|
||||
app:constraint_referenced_ids="total_usage,total_accounts"
|
||||
tools:layout_editor_absoluteY="8dp" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
21
app/src/main/res/layout/activity_trending.xml
Normal file
21
app/src/main/res/layout/activity_trending.xml
Normal file
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context="com.keylesspalace.tusky.components.trending.TrendingActivity">
|
||||
|
||||
<include
|
||||
android:id="@+id/includedToolbar"
|
||||
layout="@layout/toolbar_basic" />
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/fragmentContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
<include layout="@layout/item_status_bottom_sheet" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
42
app/src/main/res/layout/fragment_trending.xml
Normal file
42
app/src/main/res/layout/fragment_trending.xml
Normal file
|
@ -0,0 +1,42 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?android:attr/colorBackground">
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipeRefreshLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<com.keylesspalace.tusky.view.BackgroundMessageView
|
||||
android:id="@+id/messageView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
app:layout_constrainedHeight="true"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
159
app/src/main/res/layout/item_trending_cell.xml
Normal file
159
app/src/main/res/layout/item_trending_cell.xml
Normal file
|
@ -0,0 +1,159 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/trending_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:focusable="true"
|
||||
android:importantForAccessibility="yes"
|
||||
android:padding="8dp"
|
||||
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
|
||||
tools:layout_height="128dp">
|
||||
|
||||
<com.keylesspalace.tusky.view.GraphView
|
||||
android:id="@+id/graph"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="120dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:graphColor="?android:colorBackground"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/current_usage"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:lineWidth="2sp"
|
||||
app:metaColor="@color/tusky_blue_light"
|
||||
app:primaryLineColor="@color/tusky_blue"
|
||||
app:proportionalTrending="true"
|
||||
app:secondaryLineColor="@color/tusky_red" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/current_usage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:importantForAccessibility="no"
|
||||
android:paddingStart="6dp"
|
||||
android:textAlignment="textEnd"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
||||
android:textColor="@color/tusky_blue"
|
||||
android:textSize="8sp"
|
||||
android:textStyle="normal"
|
||||
app:layout_constrainedWidth="true"
|
||||
app:layout_constraintBottom_toTopOf="@id/current_accounts"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/graph"
|
||||
tools:text="12 345" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/current_accounts"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:importantForAccessibility="no"
|
||||
android:paddingStart="6dp"
|
||||
android:textAlignment="textEnd"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
||||
android:textColor="@color/tusky_red"
|
||||
android:textSize="8sp"
|
||||
android:textStyle="normal"
|
||||
app:layout_constrainedWidth="true"
|
||||
app:layout_constraintBottom_toBottomOf="@id/graph"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/graph"
|
||||
tools:text="12 345" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/legend_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:colorBackground"
|
||||
android:backgroundTint="@color/color_background_transparent_60"
|
||||
android:backgroundTintMode="src_in"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tag"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="none"
|
||||
android:importantForAccessibility="no"
|
||||
android:singleLine="true"
|
||||
android:textAlignment="textStart"
|
||||
android:textAppearance="?android:attr/textAppearanceListItem"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:textStyle="normal"
|
||||
app:layout_constrainedWidth="true"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="#itishashtagtuesdayitishashtagtuesday" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/total_usage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:textAlignment="textEnd"
|
||||
android:textAppearance="?android:attr/textAppearanceListItemSmall"
|
||||
android:textColor="@color/tusky_blue"
|
||||
android:textStyle="normal|bold"
|
||||
app:layout_constrainedWidth="false"
|
||||
app:layout_constraintEnd_toStartOf="@id/usageLabel"
|
||||
app:layout_constraintHorizontal_chainStyle="spread_inside"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/tag"
|
||||
tools:text="12 345" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/usageLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:text="@string/total_usage"
|
||||
android:textAlignment="textEnd"
|
||||
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
app:layout_constrainedWidth="false"
|
||||
app:layout_constraintBaseline_toBaselineOf="@+id/total_usage"
|
||||
app:layout_constraintEnd_toStartOf="@id/total_accounts"
|
||||
app:layout_constraintStart_toEndOf="@+id/total_usage" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/total_accounts"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:textAlignment="textEnd"
|
||||
android:textAppearance="?android:attr/textAppearanceListItemSmall"
|
||||
android:textColor="@color/tusky_red"
|
||||
android:textStyle="normal|bold"
|
||||
app:layout_constrainedWidth="false"
|
||||
app:layout_constraintEnd_toStartOf="@+id/accountsLabel"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toEndOf="@id/usageLabel"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tag"
|
||||
tools:text="498" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/accountsLabel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:text="@string/total_accounts"
|
||||
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
app:layout_constrainedWidth="true"
|
||||
app:layout_constraintBaseline_toBaselineOf="@+id/total_accounts"
|
||||
app:layout_constraintStart_toEndOf="@id/total_accounts" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
17
app/src/main/res/layout/item_trending_date.xml
Normal file
17
app/src/main/res/layout/item_trending_date.xml
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/dates"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="?android:attr/listPreferredItemHeightSmall"
|
||||
android:focusableInTouchMode="true"
|
||||
android:importantForAccessibility="yes"
|
||||
android:maxLines="1"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
|
||||
android:textAppearance="?android:attr/textAppearanceListItem"
|
||||
android:textAlignment="textEnd"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
tools:text="@string/date_range" />
|
4
app/src/main/res/values-w640dp/integers.xml
Normal file
4
app/src/main/res/values-w640dp/integers.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<integer name="trending_column_count">3</integer>
|
||||
</resources>
|
|
@ -8,6 +8,15 @@
|
|||
<attr name="description" format="string|reference" />
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="GraphView">
|
||||
<attr name="primaryLineColor" format="reference|color" />
|
||||
<attr name="secondaryLineColor" format="reference|color" />
|
||||
<attr name="lineWidth" format="dimension" />
|
||||
<attr name="graphColor" format="reference|color" />
|
||||
<attr name="metaColor" format="reference|color" />
|
||||
<attr name="proportionalTrending" format="boolean" />
|
||||
</declare-styleable>
|
||||
|
||||
<!--Themed Attributes-->
|
||||
<attr name="colorBackgroundAccent" format="reference|color" />
|
||||
<attr name="colorBackgroundHighlight" format="reference|color" />
|
||||
|
|
|
@ -59,4 +59,6 @@
|
|||
<dimen name="profile_media_audio_icon_padding">16dp</dimen>
|
||||
|
||||
<dimen name="preview_image_spacing">4dp</dimen>
|
||||
|
||||
<dimen name="graph_line_thickness">1dp</dimen>
|
||||
</resources>
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
<string name="emoji_shortcode_format" translatable="false">:%s:</string>
|
||||
<string name="post_timestamp_with_edited_indicator" translatable="false">%s *</string>
|
||||
<string name="metadata_joiner" translatable="false">" • "</string>
|
||||
<string name="date_range" translatable="false">%1$s — %2$s</string>
|
||||
|
||||
<string-array name="post_privacy_values">
|
||||
<item>public</item>
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<integer name="profile_media_column_count">3</integer>
|
||||
|
||||
<integer name="trending_column_count">1</integer>
|
||||
</resources>
|
||||
|
|
|
@ -33,6 +33,7 @@
|
|||
<string name="title_home">Home</string>
|
||||
<string name="title_notifications">Notifications</string>
|
||||
<string name="title_public_local">Local</string>
|
||||
<string name="title_public_trending_hashtags">Trending hashtags</string>
|
||||
<string name="title_public_federated">Federated</string>
|
||||
<string name="title_direct_messages">Direct messages</string>
|
||||
<string name="title_tab_preferences">Tabs</string>
|
||||
|
@ -727,7 +728,7 @@
|
|||
|
||||
<string name="action_unfollow_hashtag_format">Unfollow #%s?</string>
|
||||
<string name="mute_notifications_switch">Mute notifications</string>
|
||||
|
||||
|
||||
<!-- Reading order preference -->
|
||||
<string name="pref_title_reading_order">Reading order</string>
|
||||
<string name="pref_reading_order_oldest_first">Oldest first</string>
|
||||
|
@ -739,4 +740,9 @@
|
|||
<string name="status_created_info">%1$s created %2$s</string>
|
||||
|
||||
<string name="a11y_label_loading_thread">Loading thread</string>
|
||||
|
||||
<!--@knossos@fosstodon.org created 2023-01-07 -->
|
||||
<string name="accessibility_talking_about_tag">%1$d people are talking about hashtag %2$s</string>
|
||||
<string name="total_usage">Total usage</string>
|
||||
<string name="total_accounts">Total accounts</string>
|
||||
</resources>
|
||||
|
|
Loading…
Reference in a new issue