From 3080f0d4997f8db27891d8316826fac7b89f9d23 Mon Sep 17 00:00:00 2001 From: Sebastiano Barezzi Date: Mon, 18 Nov 2024 23:05:05 +0100 Subject: [PATCH] Twelve: Activity UI Change-Id: I983c648669a1fe1a134c8cbe44518352b8767deb --- .../twelve/fragments/ActivityFragment.kt | 113 ++++++++++++ .../twelve/ui/views/ActivityTabItem.kt | 171 ++++++++++++++++++ .../twelve/ui/views/ActivityTabView.kt | 93 ++++++++++ .../twelve/viewmodels/ActivityViewModel.kt | 24 +++ app/src/main/res/layout/fragment_activity.xml | 40 +++- app/src/main/res/layout/item_activity_tab.xml | 79 ++++++++ app/src/main/res/layout/view_activity_tab.xml | 46 +++++ app/src/main/res/navigation/fragment_main.xml | 8 + app/src/main/res/values/strings.xml | 2 + 9 files changed, 574 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/org/lineageos/twelve/ui/views/ActivityTabItem.kt create mode 100644 app/src/main/java/org/lineageos/twelve/ui/views/ActivityTabView.kt create mode 100644 app/src/main/java/org/lineageos/twelve/viewmodels/ActivityViewModel.kt create mode 100644 app/src/main/res/layout/item_activity_tab.xml create mode 100644 app/src/main/res/layout/view_activity_tab.xml diff --git a/app/src/main/java/org/lineageos/twelve/fragments/ActivityFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/ActivityFragment.kt index 61442cf6..697df3f2 100644 --- a/app/src/main/java/org/lineageos/twelve/fragments/ActivityFragment.kt +++ b/app/src/main/java/org/lineageos/twelve/fragments/ActivityFragment.kt @@ -6,20 +6,92 @@ package org.lineageos.twelve.fragments import android.os.Bundle +import android.util.Log import android.view.View +import android.widget.LinearLayout +import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.progressindicator.LinearProgressIndicator +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.lineageos.twelve.R +import org.lineageos.twelve.ext.getViewProperty +import org.lineageos.twelve.ext.setProgressCompat +import org.lineageos.twelve.models.ActivityTab +import org.lineageos.twelve.models.Album +import org.lineageos.twelve.models.Artist +import org.lineageos.twelve.models.Audio +import org.lineageos.twelve.models.Genre +import org.lineageos.twelve.models.Playlist +import org.lineageos.twelve.models.RequestStatus +import org.lineageos.twelve.ui.recyclerview.SimpleListAdapter +import org.lineageos.twelve.ui.recyclerview.UniqueItemDiffCallback +import org.lineageos.twelve.ui.views.ActivityTabView import org.lineageos.twelve.utils.PermissionsChecker import org.lineageos.twelve.utils.PermissionsUtils +import org.lineageos.twelve.viewmodels.ActivityViewModel /** * User activity, notifications and recommendations. */ class ActivityFragment : Fragment(R.layout.fragment_activity) { + // View models + private val viewModel by viewModels() + + // Views + private val linearProgressIndicator by getViewProperty(R.id.linearProgressIndicator) + private val noElementsLinearLayout by getViewProperty(R.id.noElementsLinearLayout) + private val recyclerView by getViewProperty(R.id.recyclerView) + + // RecyclerView + private val adapter by lazy { + object : SimpleListAdapter( + UniqueItemDiffCallback(), + ::ActivityTabView, + ) { + override fun ViewHolder.onPrepareView() { + view.setOnItemClickListener { items, position -> + when (val item = items[position]) { + is Album -> findNavController().navigate( + R.id.action_mainFragment_to_fragment_album, + AlbumFragment.createBundle(item.uri) + ) + + is Artist -> findNavController().navigate( + R.id.action_mainFragment_to_fragment_artist, + ArtistFragment.createBundle(item.uri) + ) + + is Audio -> findNavController().navigate( + R.id.action_mainFragment_to_fragment_audio_bottom_sheet_dialog, + AudioBottomSheetDialogFragment.createBundle(item.uri) + ) + + is Genre -> findNavController().navigate( + R.id.action_mainFragment_to_fragment_genre, + GenreFragment.createBundle(item.uri) + ) + + is Playlist -> findNavController().navigate( + R.id.action_mainFragment_to_fragment_playlist, + PlaylistFragment.createBundle(item.uri) + ) + } + } + } + + override fun ViewHolder.onBindView(item: ActivityTab) { + view.setActivityTab(item) + } + } + } + // Permissions private val permissionsChecker = PermissionsChecker( this, PermissionsUtils.mainPermissions @@ -28,12 +100,53 @@ class ActivityFragment : Fragment(R.layout.fragment_activity) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + recyclerView.adapter = adapter + viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { permissionsChecker.withPermissionsGranted { + loadData() + } + } + } + } + + override fun onDestroyView() { + recyclerView.adapter = null + + super.onDestroyView() + } + + private suspend fun loadData() { + viewModel.activity.collectLatest { + linearProgressIndicator.setProgressCompat(it, true) + + when (it) { + is RequestStatus.Loading -> { // Do nothing } + + is RequestStatus.Success -> { + val data = it.data + + adapter.submitList(data) + + val isEmpty = it.data.isEmpty() + recyclerView.isVisible = !isEmpty + noElementsLinearLayout.isVisible = isEmpty + } + + is RequestStatus.Error -> { + Log.e(LOG_TAG, "Failed to load activity, error: ${it.error}") + + recyclerView.isVisible = false + noElementsLinearLayout.isVisible = true + } } } } + + companion object { + private val LOG_TAG = ActivityFragment::class.simpleName!! + } } diff --git a/app/src/main/java/org/lineageos/twelve/ui/views/ActivityTabItem.kt b/app/src/main/java/org/lineageos/twelve/ui/views/ActivityTabItem.kt new file mode 100644 index 00000000..53549fdb --- /dev/null +++ b/app/src/main/java/org/lineageos/twelve/ui/views/ActivityTabItem.kt @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: 2024 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.twelve.ui.views + +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.AttrRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import coil3.load +import coil3.request.ImageRequest +import com.google.android.material.card.MaterialCardView +import org.lineageos.twelve.R +import org.lineageos.twelve.models.Album +import org.lineageos.twelve.models.Artist +import org.lineageos.twelve.models.Audio +import org.lineageos.twelve.models.Genre +import org.lineageos.twelve.models.MediaItem +import org.lineageos.twelve.models.Playlist + +class ActivityTabItem @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = com.google.android.material.R.attr.materialCardViewStyle, +) : MaterialCardView(context, attrs, defStyleAttr) { + private val headlineTextView by lazy { findViewById(R.id.headlineTextView) } + private val placeholderImageView by lazy { findViewById(R.id.placeholderImageView) } + private val subheadTextView by lazy { findViewById(R.id.subheadTextView) } + private val supportingTextView by lazy { findViewById(R.id.supportingTextView) } + private val thumbnailImageView by lazy { findViewById(R.id.thumbnailImageView) } + + private var headlineText: CharSequence? + get() = headlineTextView.text + set(value) { + headlineTextView.setTextAndUpdateVisibility(value) + } + + private var subheadText: CharSequence? + get() = subheadTextView.text + set(value) { + subheadTextView.setTextAndUpdateVisibility(value) + } + + private var supportingText: CharSequence? + get() = supportingTextView.text + set(value) { + supportingTextView.setTextAndUpdateVisibility(value) + } + + init { + setCardBackgroundColor(Color.TRANSPARENT) + cardElevation = 0f + strokeWidth = 0 + + inflate(context, R.layout.item_activity_tab, this) + } + + fun setItem(item: MediaItem<*>) { + when (item) { + is Album -> { + headlineText = item.title + subheadText = item.artistName + supportingText = item.year?.toString() + + item.thumbnail?.uri?.let { + loadThumbnailImage(it, R.drawable.ic_album) + } ?: item.thumbnail?.bitmap?.let { + loadThumbnailImage(it, R.drawable.ic_album) + } ?: setPlaceholderImage(R.drawable.ic_album) + } + + is Artist -> { + headlineText = item.name + subheadText = null + supportingText = null + + item.thumbnail?.uri?.let { + loadThumbnailImage(it) + } ?: item.thumbnail?.bitmap?.let { + loadThumbnailImage(it) + } ?: setPlaceholderImage(R.drawable.ic_person) + } + + is Audio -> { + headlineText = item.title + subheadText = item.artistName + supportingText = item.albumTitle + + setPlaceholderImage(R.drawable.ic_music_note) + } + + is Genre -> { + item.name?.let { + headlineText = it + } ?: setHeadlineText(R.string.genre_unknown) + subheadText = null + supportingText = null + + setPlaceholderImage(R.drawable.ic_genres) + } + + is Playlist -> { + headlineText = item.name + subheadText = null + supportingText = null + + setPlaceholderImage(R.drawable.ic_playlist_play) + } + } + } + + private fun setPlaceholderImage(@DrawableRes placeholder: Int) { + placeholderImageView.setImageResource(placeholder) + placeholderImageView.isVisible = true + thumbnailImageView.isVisible = false + } + + private fun loadThumbnailImage( + data: Any?, + @DrawableRes placeholder: Int? = null, + builder: ImageRequest.Builder.() -> Unit = { + listener( + onCancel = { + placeholder?.let { + placeholderImageView.setImageResource(it) + placeholderImageView.isVisible = true + } + thumbnailImageView.isVisible = false + }, + onError = { _, _ -> + placeholder?.let { + placeholderImageView.setImageResource(it) + placeholderImageView.isVisible = true + } + thumbnailImageView.isVisible = false + }, + onSuccess = { _, _ -> + placeholderImageView.isVisible = false + thumbnailImageView.isVisible = true + }, + ) + } + ) = thumbnailImageView.load(data, builder = builder) + + private fun setHeadlineText(@StringRes resId: Int) = + headlineTextView.setTextAndUpdateVisibility(resId) + + private fun setSubheadText(@StringRes resId: Int) = + subheadTextView.setTextAndUpdateVisibility(resId) + + private fun setSupportingText(@StringRes resId: Int) = + supportingTextView.setTextAndUpdateVisibility(resId) + + // TextView utils + + private fun TextView.setTextAndUpdateVisibility(text: CharSequence?) { + this.text = text.also { + isVisible = it != null + } + } + + private fun TextView.setTextAndUpdateVisibility(@StringRes resId: Int) = + setTextAndUpdateVisibility(resources.getText(resId)) +} diff --git a/app/src/main/java/org/lineageos/twelve/ui/views/ActivityTabView.kt b/app/src/main/java/org/lineageos/twelve/ui/views/ActivityTabView.kt new file mode 100644 index 00000000..f1790538 --- /dev/null +++ b/app/src/main/java/org/lineageos/twelve/ui/views/ActivityTabView.kt @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2024 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.twelve.ui.views + +import android.content.Context +import android.widget.FrameLayout +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import org.lineageos.twelve.R +import org.lineageos.twelve.models.ActivityTab +import org.lineageos.twelve.models.Album +import org.lineageos.twelve.models.Artist +import org.lineageos.twelve.models.Audio +import org.lineageos.twelve.models.Genre +import org.lineageos.twelve.models.MediaItem +import org.lineageos.twelve.models.Playlist +import org.lineageos.twelve.models.areContentsTheSame +import org.lineageos.twelve.models.areItemsTheSame +import org.lineageos.twelve.ui.recyclerview.SimpleListAdapter + +class ActivityTabView(context: Context) : FrameLayout(context) { + // Views + private val recyclerView by lazy { findViewById(R.id.recyclerView) } + private val titleTextView by lazy { findViewById(R.id.titleTextView) } + + // RecyclerView + private val adapter = object : SimpleListAdapter, ActivityTabItem>( + mediaItemDiffCallback, + ::ActivityTabItem, + ) { + override fun ViewHolder.onPrepareView() { + view.setOnClickListener { + onItemClickListener(currentList, bindingAdapterPosition) + } + } + + override fun ViewHolder.onBindView(item: MediaItem<*>) { + view.setItem(item) + } + } + + // Callbacks + private var onItemClickListener: (items: List>, position: Int) -> Unit = + { _, _ -> } + + init { + inflate(context, R.layout.view_activity_tab, this) + + recyclerView.adapter = adapter + } + + fun setOnItemClickListener(listener: ((items: List>, position: Int) -> Unit)?) { + onItemClickListener = listener ?: { _, _ -> } + } + + fun setActivityTab(activityTab: ActivityTab) { + titleTextView.text = activityTab.title.getString(context) + + adapter.submitList(activityTab.items) + recyclerView.isVisible = activityTab.items.isNotEmpty() + } + + companion object { + private val mediaItemDiffCallback = object : DiffUtil.ItemCallback>() { + override fun areItemsTheSame( + oldItem: MediaItem<*>, + newItem: MediaItem<*>, + ) = when (oldItem) { + is Album -> oldItem.areItemsTheSame(newItem) + is Artist -> oldItem.areItemsTheSame(newItem) + is Audio -> oldItem.areItemsTheSame