diff --git a/app/build.gradle b/app/build.gradle index 215cada2..b8098a9f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -54,7 +54,6 @@ android { shrinkResources true proguardFiles 'proguard-rules.pro' } - debug {} } flavorDimensions += "color" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 420b2352..0d49febc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -143,6 +143,7 @@ + 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 = 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 = + 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()) { diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 0db85211..8d2b2a75 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -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.hasTab(id: String): Boolean = this.find { it.id == id } != null + fun createTabDataFromId(id: String, arguments: List = emptyList()): TabData { return when (id) { HOME -> TabData( @@ -75,6 +79,12 @@ fun createTabDataFromId(id: String, arguments: List = 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, diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 0f20a785..d476ea72 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -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)) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingDateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingDateViewHolder.kt new file mode 100644 index 00000000..88ec17d1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingDateViewHolder.kt @@ -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 . */ + +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) + ) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingTagViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingTagViewHolder.kt new file mode 100644 index 00000000..270ede6c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingTagViewHolder.kt @@ -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 . */ + +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, 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]) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt new file mode 100644 index 00000000..e9bd3b70 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt @@ -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 . */ + +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 + + @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) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt new file mode 100644 index 00000000..73a327b7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt @@ -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 . */ + +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(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() { + 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 + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt new file mode 100644 index 00000000..41e404a9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt @@ -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 . */ + +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) { + 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() + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt new file mode 100644 index 00000000..500313ef --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt @@ -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 . */ + +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, + val loadingState: LoadingState + ) + + val uiState: Flow 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() + .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" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index 2704ee70..bbbd5337 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -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 } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index 4a5e9738..3ad18fca 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -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 } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index aab1fa3d..60265845 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -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 } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt new file mode 100644 index 00000000..1c20a5e9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt @@ -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 . */ + +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, + 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) diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt new file mode 100644 index 00000000..1189dd3b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt @@ -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 . + */ + +package com.keylesspalace.tusky.interfaces + +interface FabFragment { + fun isFabVisible(): Boolean +} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 5633ad3d..6420216a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -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 + + @GET("api/v1/trends/tags") + suspend fun trendingTags(): Response> } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index bc40cdd6..51646512 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt new file mode 100644 index 00000000..1d3c49d9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt @@ -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 . */ + +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 = 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 = 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, 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) = 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) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt new file mode 100644 index 00000000..3c596512 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt @@ -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 . */ + +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 +} diff --git a/app/src/main/res/drawable/ic_trending_up_24px.xml b/app/src/main/res/drawable/ic_trending_up_24px.xml new file mode 100644 index 00000000..95e98c21 --- /dev/null +++ b/app/src/main/res/drawable/ic_trending_up_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout-land/item_trending_cell.xml b/app/src/main/res/layout-land/item_trending_cell.xml new file mode 100644 index 00000000..a6f22b06 --- /dev/null +++ b/app/src/main/res/layout-land/item_trending_cell.xml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_trending.xml b/app/src/main/res/layout/activity_trending.xml new file mode 100644 index 00000000..d51fdf57 --- /dev/null +++ b/app/src/main/res/layout/activity_trending.xml @@ -0,0 +1,21 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_trending.xml b/app/src/main/res/layout/fragment_trending.xml new file mode 100644 index 00000000..db59df72 --- /dev/null +++ b/app/src/main/res/layout/fragment_trending.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_trending_cell.xml b/app/src/main/res/layout/item_trending_cell.xml new file mode 100644 index 00000000..10e49771 --- /dev/null +++ b/app/src/main/res/layout/item_trending_cell.xml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_trending_date.xml b/app/src/main/res/layout/item_trending_date.xml new file mode 100644 index 00000000..09481c72 --- /dev/null +++ b/app/src/main/res/layout/item_trending_date.xml @@ -0,0 +1,17 @@ + + diff --git a/app/src/main/res/values-w640dp/integers.xml b/app/src/main/res/values-w640dp/integers.xml new file mode 100644 index 00000000..ad79f913 --- /dev/null +++ b/app/src/main/res/values-w640dp/integers.xml @@ -0,0 +1,4 @@ + + + 3 + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 66109928..a333a79a 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -8,6 +8,15 @@ + + + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 8ec84acf..b6f88d09 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -59,4 +59,6 @@ 16dp 4dp + + 1dp diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index f1b6839e..3126b942 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -13,6 +13,7 @@ :%s: %s * " • " + %1$s — %2$s public diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml index c4de3448..e85c561c 100644 --- a/app/src/main/res/values/integers.xml +++ b/app/src/main/res/values/integers.xml @@ -1,4 +1,6 @@ 3 + + 1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b6c70b75..b6be0ec5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,7 @@ Home Notifications Local + Trending hashtags Federated Direct messages Tabs @@ -727,7 +728,7 @@ Unfollow #%s? Mute notifications - + Reading order Oldest first @@ -739,4 +740,9 @@ %1$s created %2$s Loading thread + + + %1$d people are talking about hashtag %2$s + Total usage + Total accounts