port HomeFragment to ViewModel and Kotlin flow; update gradle wrapper
This commit is contained in:
		| @ -1,34 +1,53 @@ | ||||
| /** | ||||
|  * Teapod | ||||
|  * | ||||
|  * Copyright 2020-2022  <seil0@mosad.xyz> | ||||
|  * | ||||
|  * 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. | ||||
|  * | ||||
|  * This program 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 this program; if not, write to the Free Software | ||||
|  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, | ||||
|  * MA 02110-1301, USA. | ||||
|  * | ||||
|  */ | ||||
|  | ||||
| package org.mosad.teapod.ui.activity.main.fragments | ||||
|  | ||||
| import android.os.Bundle | ||||
| import android.util.Log | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.viewModels | ||||
| import androidx.lifecycle.Lifecycle | ||||
| import androidx.lifecycle.lifecycleScope | ||||
| import androidx.lifecycle.repeatOnLifecycle | ||||
| import com.bumptech.glide.Glide | ||||
| import kotlinx.coroutines.Job | ||||
| import kotlinx.coroutines.joinAll | ||||
| import kotlinx.coroutines.launch | ||||
| import org.mosad.teapod.R | ||||
| import org.mosad.teapod.databinding.FragmentHomeBinding | ||||
| import org.mosad.teapod.parser.crunchyroll.Crunchyroll | ||||
| import org.mosad.teapod.parser.crunchyroll.Item | ||||
| import org.mosad.teapod.parser.crunchyroll.SortBy | ||||
| import org.mosad.teapod.util.adapter.MediaItemAdapter | ||||
| import org.mosad.teapod.ui.activity.main.viewmodel.HomeViewModel | ||||
| import org.mosad.teapod.util.adapter.MediaItemListAdapter | ||||
| import org.mosad.teapod.util.decoration.MediaItemDecoration | ||||
| import org.mosad.teapod.util.setDrawableTop | ||||
| import org.mosad.teapod.util.showFragment | ||||
| import org.mosad.teapod.util.toItemMediaList | ||||
| import kotlin.random.Random | ||||
|  | ||||
| class HomeFragment : Fragment() { | ||||
|  | ||||
|     private val classTag = javaClass.name | ||||
|     private val model: HomeViewModel by viewModels() | ||||
|     private lateinit var binding: FragmentHomeBinding | ||||
|     private lateinit var adapterUpNext: MediaItemAdapter | ||||
|     private lateinit var adapterWatchlist: MediaItemAdapter | ||||
|     private lateinit var adapterNewTitles: MediaItemAdapter | ||||
|     private lateinit var adapterTopTen: MediaItemAdapter | ||||
|  | ||||
|     private lateinit var highlightMedia: Item | ||||
|  | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||
|         binding = FragmentHomeBinding.inflate(inflater, container, false) | ||||
| @ -38,84 +57,43 @@ class HomeFragment : Fragment() { | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|  | ||||
|         lifecycleScope.launch { | ||||
|             context?.let { | ||||
|                 initHighlight() | ||||
|                 initRecyclerViews() | ||||
|                 initActions() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun initHighlight() { | ||||
|         lifecycleScope.launch { | ||||
|             val newTitles = Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 10) | ||||
|             // FIXME crashes on newTitles.items.size == 0 | ||||
|             highlightMedia =  newTitles.items[Random.nextInt(newTitles.items.size)] | ||||
|  | ||||
|             // add media item to gui | ||||
|             binding.textHighlightTitle.text = highlightMedia.title | ||||
|             Glide.with(requireContext()).load(highlightMedia.images.poster_wide[0][3].source) | ||||
|                 .into(binding.imageHighlight) | ||||
|  | ||||
|             // TODO watchlist indicator | ||||
| //            if (StorageController.myList.contains(0)) { | ||||
| //                binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24) | ||||
| //            } else { | ||||
| //                binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24) | ||||
| //            } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Suspend, since adapters need to be initialized before we can initialize the actions. | ||||
|      */ | ||||
|     private suspend fun initRecyclerViews() { | ||||
|         binding.recyclerWatchlist.addItemDecoration(MediaItemDecoration(9)) | ||||
|         binding.recyclerNewEpisodes.addItemDecoration(MediaItemDecoration(9)) | ||||
|         binding.recyclerWatchlist.addItemDecoration(MediaItemDecoration(9)) | ||||
|         binding.recyclerNewTitles.addItemDecoration(MediaItemDecoration(9)) | ||||
|         binding.recyclerTopTen.addItemDecoration(MediaItemDecoration(9)) | ||||
|  | ||||
|         val asyncJobList = arrayListOf<Job>() | ||||
|         binding.recyclerNewEpisodes.adapter = MediaItemListAdapter( | ||||
|             MediaItemListAdapter.OnClickListener { | ||||
|                 activity?.showFragment(MediaFragment(it.id)) | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         // continue watching | ||||
|         val upNextJob = lifecycleScope.launch { | ||||
|             // TODO create EpisodeItemAdapter, which will start the playback of the selected episode immediately | ||||
|             adapterUpNext = MediaItemAdapter(Crunchyroll.upNextAccount().items | ||||
|                 .filter { !it.fullyWatched }.toItemMediaList()) | ||||
|             binding.recyclerNewEpisodes.adapter = adapterUpNext | ||||
|         binding.recyclerWatchlist.adapter = MediaItemListAdapter( | ||||
|             MediaItemListAdapter.OnClickListener { | ||||
|                 activity?.showFragment(MediaFragment(it.id)) | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         binding.recyclerNewTitles.adapter = MediaItemListAdapter( | ||||
|             MediaItemListAdapter.OnClickListener { | ||||
|                 activity?.showFragment(MediaFragment(it.id)) | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         binding.recyclerTopTen.adapter = MediaItemListAdapter( | ||||
|             MediaItemListAdapter.OnClickListener { | ||||
|                 activity?.showFragment(MediaFragment(it.id)) | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         binding.textHighlightMyList.setOnClickListener { | ||||
|             model.toggleHighlightWatchlist() | ||||
|  | ||||
|             // disable the watchlist button until the result has been loaded | ||||
|             binding.textHighlightMyList.isClickable = false | ||||
|             // TODO since this might take a few seconds show a loading animation for the watchlist button | ||||
|         } | ||||
|         asyncJobList.add(upNextJob) | ||||
|  | ||||
|         // watchlist | ||||
|         val watchlistJob = lifecycleScope.launch { | ||||
|             adapterWatchlist = MediaItemAdapter(Crunchyroll.watchlist(50).toItemMediaList()) | ||||
|             binding.recyclerWatchlist.adapter = adapterWatchlist | ||||
|         } | ||||
|         asyncJobList.add(watchlistJob) | ||||
|  | ||||
|         // new simulcasts | ||||
|         val simulcastsJob = lifecycleScope.launch { | ||||
|             // val latestSeasonTag = Crunchyroll.seasonList().items.first().id | ||||
|             // val newSimulcasts = Crunchyroll.browse(seasonTag = latestSeasonTag, n = 50) | ||||
|             val newSimulcasts = Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 50) | ||||
|  | ||||
|             adapterNewTitles = MediaItemAdapter(newSimulcasts.toItemMediaList()) | ||||
|             binding.recyclerNewTitles.adapter = adapterNewTitles | ||||
|         } | ||||
|         asyncJobList.add(simulcastsJob) | ||||
|  | ||||
|         // newly added / top ten | ||||
|         val newlyAddedJob = lifecycleScope.launch { | ||||
|             adapterTopTen = MediaItemAdapter(Crunchyroll.browse(sortBy = SortBy.POPULARITY, n = 10).toItemMediaList()) | ||||
|             binding.recyclerTopTen.adapter = adapterTopTen | ||||
|         } | ||||
|         asyncJobList.add(newlyAddedJob) | ||||
|  | ||||
|         asyncJobList.joinAll() | ||||
|     } | ||||
|  | ||||
|     private fun initActions() { | ||||
|         binding.buttonPlayHighlight.setOnClickListener { | ||||
|             // TODO implement | ||||
|             lifecycleScope.launch { | ||||
| @ -126,37 +104,57 @@ class HomeFragment : Fragment() { | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         binding.textHighlightMyList.setOnClickListener { | ||||
|             // TODO implement | ||||
| //            if (StorageController.myList.contains(0)) { | ||||
| //                StorageController.myList.remove(0) | ||||
| //                binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24) | ||||
| //            } else { | ||||
| //                StorageController.myList.add(0) | ||||
| //                binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24) | ||||
| //            } | ||||
| //            StorageController.saveMyList(requireContext()) | ||||
|         } | ||||
|  | ||||
|         binding.textHighlightInfo.setOnClickListener { | ||||
|             activity?.showFragment(MediaFragment(highlightMedia.id)) | ||||
|         } | ||||
|  | ||||
|         adapterUpNext.onItemClick = { id, _ -> | ||||
|             activity?.showFragment(MediaFragment(id)) | ||||
|         } | ||||
|  | ||||
|         adapterWatchlist.onItemClick = { id, _ -> | ||||
|             activity?.showFragment(MediaFragment(id)) | ||||
|         } | ||||
|  | ||||
|         adapterNewTitles.onItemClick = { id, _ -> | ||||
|             activity?.showFragment(MediaFragment(id)) | ||||
|         } | ||||
|  | ||||
|         adapterTopTen.onItemClick = { id, _ -> | ||||
|             activity?.showFragment(MediaFragment(id)) //(mediaId)) | ||||
|         viewLifecycleOwner.lifecycleScope.launch { | ||||
|             viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { | ||||
|                 model.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> | ||||
|                     when (uiState) { | ||||
|                         is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState) | ||||
|                         is HomeViewModel.UiState.Loading -> bindUiStateLoading() | ||||
|                         is HomeViewModel.UiState.Error -> bindUiStateError(uiState) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun bindUiStateNormal(uiState: HomeViewModel.UiState.Normal) { | ||||
|         val adapterUpNext = binding.recyclerNewEpisodes.adapter as MediaItemListAdapter | ||||
|         adapterUpNext.submitList(uiState.upNextItems.filter { !it.fullyWatched }.toItemMediaList()) | ||||
|  | ||||
|         val adapterWatchlist = binding.recyclerWatchlist.adapter as MediaItemListAdapter | ||||
|         adapterWatchlist.submitList(uiState.watchlistItems.toItemMediaList()) | ||||
|  | ||||
|         val adapterNewTitles = binding.recyclerNewTitles.adapter as MediaItemListAdapter | ||||
|         adapterNewTitles.submitList(uiState.recentlyAddedItems.toItemMediaList()) | ||||
|  | ||||
|         val adapterTopTen = binding.recyclerTopTen.adapter as MediaItemListAdapter | ||||
|         adapterTopTen.submitList(uiState.topTenItems.toItemMediaList()) | ||||
|  | ||||
|         // highlight item | ||||
|         binding.textHighlightTitle.text = uiState.highlightItem.title | ||||
|         Glide.with(requireContext()).load(uiState.highlightItem.images.poster_wide[0][3].source) | ||||
|             .into(binding.imageHighlight) | ||||
|  | ||||
|         val iconHighlightWatchlist = if (uiState.highlightIsWatchlist) { | ||||
|             R.drawable.ic_baseline_check_24 | ||||
|         } else { | ||||
|             R.drawable.ic_baseline_add_24 | ||||
|         } | ||||
|         binding.textHighlightMyList.setDrawableTop(iconHighlightWatchlist) | ||||
|         binding.textHighlightMyList.isClickable = true | ||||
|  | ||||
|         binding.textHighlightInfo.setOnClickListener { | ||||
|             activity?.showFragment(MediaFragment(uiState.highlightItem.id)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun bindUiStateLoading() { | ||||
|         // currently not used | ||||
|     } | ||||
|  | ||||
|     private fun bindUiStateError(uiState: HomeViewModel.UiState.Error) { | ||||
|         // currently not used | ||||
|         Log.e(classTag, "A error occurred while loading a UiState: ${uiState.message}") | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| @ -0,0 +1,119 @@ | ||||
| /** | ||||
|  * Teapod | ||||
|  * | ||||
|  * Copyright 2020-2022  <seil0@mosad.xyz> | ||||
|  * | ||||
|  * 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. | ||||
|  * | ||||
|  * This program 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 this program; if not, write to the Free Software | ||||
|  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, | ||||
|  * MA 02110-1301, USA. | ||||
|  * | ||||
|  */ | ||||
|  | ||||
| package org.mosad.teapod.ui.activity.main.viewmodel | ||||
|  | ||||
| import androidx.lifecycle.LifecycleCoroutineScope | ||||
| import androidx.lifecycle.ViewModel | ||||
| import androidx.lifecycle.viewModelScope | ||||
| import kotlinx.coroutines.async | ||||
| import kotlinx.coroutines.flow.* | ||||
| import kotlinx.coroutines.launch | ||||
| import org.mosad.teapod.parser.crunchyroll.* | ||||
| import kotlin.random.Random | ||||
|  | ||||
| class HomeViewModel : ViewModel()  { | ||||
|  | ||||
|     private val uiState = MutableStateFlow<UiState>(UiState.Loading) | ||||
|  | ||||
|     sealed class UiState { | ||||
|         object Loading : UiState() | ||||
|         data class Normal( | ||||
|             val upNextItems: List<ContinueWatchingItem>, | ||||
|             val watchlistItems: List<Item>, | ||||
|             val recentlyAddedItems: List<Item>, | ||||
|             val topTenItems: List<Item>, | ||||
|             val highlightItem: Item, | ||||
|             val highlightIsWatchlist:Boolean | ||||
|         ) : UiState() | ||||
|         data class Error(val message: String?) : UiState() | ||||
|     } | ||||
|  | ||||
|     init { | ||||
|         load() | ||||
|     } | ||||
|  | ||||
|     fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { | ||||
|         scope.launch { uiState.collect { collector(it) } } | ||||
|     } | ||||
|  | ||||
|     fun load() { | ||||
|         viewModelScope.launch { | ||||
|             uiState.emit(UiState.Loading) | ||||
|             try { | ||||
|                 // run the loading in parallel to speed up the process | ||||
|  | ||||
|                 val upNextJob = viewModelScope.async { Crunchyroll.upNextAccount().items } | ||||
|                 val watchlistJob = viewModelScope.async { Crunchyroll.watchlist(50).items } | ||||
|                 val recentlyAddedJob = viewModelScope.async { | ||||
|                     Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 50).items | ||||
|                 } | ||||
|                 val topTenJob = viewModelScope.async { | ||||
|                     Crunchyroll.browse(sortBy = SortBy.POPULARITY, n = 10).items | ||||
|                 } | ||||
|  | ||||
|                 val recentlyAddedItems = recentlyAddedJob.await() | ||||
|                 // FIXME crashes on newTitles.items.size == 0 | ||||
|                 val highlightItem = recentlyAddedItems[Random.nextInt(recentlyAddedItems.size)] | ||||
|                 val highlightItemIsWatchlist = Crunchyroll.isWatchlist(highlightItem.id) | ||||
|  | ||||
|                 uiState.emit(UiState.Normal( | ||||
|                     upNextJob.await(), watchlistJob.await(), recentlyAddedJob.await(), | ||||
|                     topTenJob.await(), highlightItem, highlightItemIsWatchlist | ||||
|                 )) | ||||
|             } catch (e: Exception) { | ||||
|                 uiState.emit(UiState.Error(e.message)) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Toggle the watchlist state of the highlight media. | ||||
|      */ | ||||
|     fun toggleHighlightWatchlist() { | ||||
|         viewModelScope.launch { | ||||
|             uiState.update { currentUiState -> | ||||
|                 if (currentUiState is UiState.Normal) { | ||||
|                     if (currentUiState.highlightIsWatchlist) { | ||||
|                         Crunchyroll.deleteWatchlist(currentUiState.highlightItem.id) | ||||
|                     } else { | ||||
|                         Crunchyroll.postWatchlist(currentUiState.highlightItem.id) | ||||
|                     } | ||||
|  | ||||
|                     // update the watchlist after a item has been added/removed | ||||
|                     val watchlistItems = Crunchyroll.watchlist(50).items | ||||
|  | ||||
|                     currentUiState.copy( | ||||
|                         watchlistItems = watchlistItems, | ||||
|                         highlightIsWatchlist = !currentUiState.highlightIsWatchlist) | ||||
|                 } else { | ||||
|                     currentUiState | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|  | ||||
|  | ||||
|     } | ||||
|  | ||||
| } | ||||
| @ -21,6 +21,13 @@ fun Collection<Item>.toItemMediaList(): List<ItemMedia> { | ||||
|     } | ||||
| } | ||||
|  | ||||
| @JvmName("toItemMediaListItem") | ||||
| fun List<Item>.toItemMediaList(): List<ItemMedia> { | ||||
|     return this.map { | ||||
|         ItemMedia(it.id, it.title, it.images.poster_wide[0][0].source) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @JvmName("toItemMediaListContinueWatchingItem") | ||||
| fun Collection<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> { | ||||
|     return items.map { | ||||
|  | ||||
| @ -0,0 +1,55 @@ | ||||
| package org.mosad.teapod.util.adapter | ||||
|  | ||||
| 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.bumptech.glide.Glide | ||||
| import org.mosad.teapod.databinding.ItemMediaBinding | ||||
| import org.mosad.teapod.util.ItemMedia | ||||
|  | ||||
| class MediaItemListAdapter(private val onClickListener: OnClickListener) : ListAdapter<ItemMedia, MediaItemListAdapter.MediaViewHolder>(DiffCallback) { | ||||
|  | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { | ||||
|         return MediaViewHolder( | ||||
|             ItemMediaBinding.inflate( | ||||
|                 LayoutInflater.from(parent.context), | ||||
|                 parent, | ||||
|                 false | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     override fun onBindViewHolder(holder: MediaViewHolder, position: Int) { | ||||
|         val item = getItem(position) | ||||
|         holder.binding.root.setOnClickListener { | ||||
|             onClickListener.onClick(item) | ||||
|         } | ||||
|         holder.bind(item) | ||||
|     } | ||||
|  | ||||
|     inner class MediaViewHolder(val binding: ItemMediaBinding) : | ||||
|         RecyclerView.ViewHolder(binding.root) { | ||||
|  | ||||
|         fun bind(item: ItemMedia) { | ||||
|             binding.textTitle.text = item.title | ||||
|             // can we use the view instead of context here? | ||||
|             Glide.with(binding.root.context).load(item.posterUrl).into(binding.imagePoster) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object DiffCallback : DiffUtil.ItemCallback<ItemMedia>() { | ||||
|         override fun areItemsTheSame(oldItem: ItemMedia, newItem: ItemMedia): Boolean { | ||||
|             return oldItem.id == newItem.id | ||||
|         } | ||||
|  | ||||
|         override fun areContentsTheSame(oldItem: ItemMedia, newItem: ItemMedia): Boolean { | ||||
|             return oldItem == newItem | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     class OnClickListener(val clickListener: (item: ItemMedia) -> Unit) { | ||||
|         fun onClick(item: ItemMedia) = clickListener(item) | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user