Last active
April 21, 2020 10:53
-
-
Save timusus/9032d7e3f2691c3fef397a3aeab5df42 to your computer and use it in GitHub Desktop.
Shuttle - Home Contract
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// This Gist demonstrates the use of MVP in an Android project. | |
// File 1 - The contract. Defines the View and Presenter interface. | |
// File 2 - The Presenter implementation | |
// File 3 - The View implementation (ViewController / Fragment) | |
// I like this architecture because it helps with separation of concerns. The business logic is carried out | |
// in the Presenter, which has no (or limited) knowledge of Android framework components (which also makes it easier to test) | |
// The View Controller (Fragment) is then kept very lightweight, and only deals with updating views and dispatching | |
// their actions to the Presenter. | |
// This file demonstrates the use of Dependency Injection (using Google's Dagger2 framework) | |
// Also featured is some RXJava code - which I've recently started to replace with Kotlin Coroutines (similar to Swift Combine) | |
// File 1 - Contract | |
interface HomeContract { | |
data class HomeData( | |
val mostPlayedAlbums: List<Album>, | |
val recentlyPlayedAlbums: List<Album>, | |
val albumsFromThisYear: List<Album>, | |
val unplayedAlbumArtists: List<AlbumArtist> | |
) | |
// The 'V' in MVP | |
interface View { | |
fun showLoadError(error: Error) | |
fun setData(data: HomeData) | |
fun onAddedToQueue(albumArtist: AlbumArtist) | |
fun onAddedToQueue(album: Album) | |
fun showDeleteError(error: Error) | |
} | |
// The 'P' in MVP | |
interface Presenter { | |
fun shuffleAll() | |
fun loadData() | |
fun addToQueue(albumArtist: AlbumArtist) | |
fun playNext(albumArtist: AlbumArtist) | |
fun blacklist(albumArtist: AlbumArtist) | |
fun addToQueue(album: Album) | |
fun playNext(album: Album) | |
fun blacklist(album: Album) | |
fun play(albumArtist: AlbumArtist) | |
fun play(album: Album) | |
} | |
} | |
// File 2 - Presenter (Implements 'P') | |
class HomePresenter @Inject constructor( | |
private val songRepository: SongRepository, | |
private val albumRepository: AlbumRepository, | |
private val albumArtistRepository: AlbumArtistRepository, | |
private val playbackManager: PlaybackManager | |
) : HomeContract.Presenter, BasePresenter<HomeContract.View>() { | |
override fun shuffleAll() { | |
addDisposable(songRepository.getSongs() | |
.first(emptyList()) | |
.subscribeOn(Schedulers.io()) | |
.observeOn(AndroidSchedulers.mainThread()) | |
.subscribeBy( | |
onSuccess = { songs -> | |
if (songs.isEmpty()) { | |
view?.showLoadError(UserFriendlyError("Your library is empty")) | |
return@subscribeBy | |
} | |
playbackManager.shuffle(songs) { result -> | |
result.onSuccess { | |
playbackManager.play() | |
} | |
result.onFailure { error -> view?.showLoadError(Error(error)) } | |
} | |
}, | |
onError = { throwable -> Timber.e(throwable, "Error retrieving songs") } | |
)) | |
} | |
override fun loadData() { | |
addDisposable(Observables.combineLatest( | |
albumRepository.getAlbums(AlbumQuery.PlayCount(1, AlbumSortOrder.PlayCount)).map { albums -> albums.take(20) }, | |
songRepository.getSongs(SmartPlaylist.RecentlyPlayed.songQuery) | |
.map { songs -> SmartPlaylist.RecentlyPlayed.songQuery?.sortOrder?.let { songSortOrder -> songs.sortedWith(songSortOrder.comparator) } ?: songs } | |
.map { songs -> songs.distinctBy { it.albumId }.map { it.albumId } } | |
.concatMap { albumIds -> albumRepository.getAlbums(AlbumQuery.AlbumIds(albumIds)).take(20) }, | |
albumRepository.getAlbums(AlbumQuery.Year(Calendar.getInstance().get(Calendar.YEAR))).map { albums -> albums.take(20) }, | |
albumArtistRepository.getAlbumArtists(AlbumArtistQuery.PlayCount(0, AlbumArtistSortOrder.PlayCount)) | |
.map { albumArtists -> albumArtists.shuffled().take(20) } | |
) { mostPlayedAlbums, recentlyPlayedAlbums, albumsFromThisYear, unplayedAlbumArtists -> | |
HomeContract.HomeData( | |
mostPlayedAlbums, | |
recentlyPlayedAlbums, | |
albumsFromThisYear, | |
unplayedAlbumArtists | |
) | |
} | |
.subscribeBy( | |
onNext = { homeData -> view?.setData(homeData) }, | |
onError = { throwable -> Timber.e(throwable, "Failed to load home data") } | |
)) | |
} | |
override fun addToQueue(albumArtist: AlbumArtist) { | |
addDisposable( | |
songRepository.getSongs(SongQuery.AlbumArtistIds(listOf(albumArtist.id))) | |
.first(emptyList()) | |
.subscribeOn(Schedulers.io()) | |
.observeOn(AndroidSchedulers.mainThread()) | |
.subscribeBy( | |
onSuccess = { songs -> | |
playbackManager.addToQueue(songs) | |
view?.onAddedToQueue(albumArtist) | |
}, | |
onError = { throwable -> Timber.e(throwable, "Failed to retrieve songs for album artist: ${albumArtist.name}") }) | |
) | |
} | |
override fun playNext(albumArtist: AlbumArtist) { | |
addDisposable( | |
songRepository.getSongs(SongQuery.AlbumArtistIds(listOf(albumArtist.id))) | |
.first(emptyList()) | |
.subscribeOn(Schedulers.io()) | |
.observeOn(AndroidSchedulers.mainThread()) | |
.subscribeBy( | |
onSuccess = { songs -> | |
playbackManager.playNext(songs) | |
view?.onAddedToQueue(albumArtist) | |
}, | |
onError = { throwable -> Timber.e(throwable, "Failed to retrieve songs for album artist: ${albumArtist.name}") }) | |
) | |
} | |
override fun addToQueue(album: Album) { | |
addDisposable( | |
songRepository.getSongs(SongQuery.AlbumIds(listOf(album.id))) | |
.first(emptyList()) | |
.subscribeOn(Schedulers.io()) | |
.observeOn(AndroidSchedulers.mainThread()) | |
.subscribeBy( | |
onSuccess = { songs -> | |
playbackManager.addToQueue(songs) | |
view?.onAddedToQueue(album) | |
}, | |
onError = { throwable -> Timber.e(throwable, "Failed to retrieve songs for album: ${album.name}") }) | |
) | |
} | |
override fun playNext(album: Album) { | |
addDisposable( | |
songRepository.getSongs(SongQuery.AlbumIds(listOf(album.id))) | |
.first(emptyList()) | |
.subscribeOn(Schedulers.io()) | |
.observeOn(AndroidSchedulers.mainThread()) | |
.subscribeBy( | |
onSuccess = { songs -> | |
playbackManager.playNext(songs) | |
view?.onAddedToQueue(album) | |
}, | |
onError = { throwable -> Timber.e(throwable, "Failed to retrieve songs for album: ${album.name}") }) | |
) | |
} | |
override fun blacklist(albumArtist: AlbumArtist) { | |
addDisposable( | |
songRepository.getSongs(SongQuery.AlbumArtistIds(listOf(albumArtist.id))) | |
.first(emptyList()) | |
.flatMapCompletable { songs -> | |
songRepository.setBlacklisted(songs, true) | |
} | |
.subscribeOn(Schedulers.io()) | |
.observeOn(AndroidSchedulers.mainThread()) | |
.subscribeBy( | |
onError = { throwable -> Timber.e(throwable, "Failed to retrieve songs for album artist: ${albumArtist.name}") }) | |
) | |
} | |
override fun blacklist(album: Album) { | |
addDisposable( | |
songRepository.getSongs(SongQuery.AlbumIds(listOf(album.id))) | |
.first(emptyList()) | |
.flatMapCompletable { songs -> | |
songRepository.setBlacklisted(songs, true) | |
} | |
.subscribeOn(Schedulers.io()) | |
.observeOn(AndroidSchedulers.mainThread()) | |
.subscribeBy( | |
onError = { throwable -> Timber.e(throwable, "Failed to blacklist album ${album.name}") }) | |
) | |
} | |
override fun play(albumArtist: AlbumArtist) { | |
addDisposable( | |
songRepository.getSongs(SongQuery.AlbumArtistIds(listOf(albumArtist.id))) | |
.first(emptyList()) | |
.subscribeOn(Schedulers.io()) | |
.observeOn(AndroidSchedulers.mainThread()) | |
.subscribeBy(onSuccess = { songs -> | |
playbackManager.load(songs, 0) { result -> | |
result.onSuccess { playbackManager.play() } | |
result.onFailure { error -> view?.showLoadError(error as Error) } | |
} | |
}, onError = { error -> | |
Timber.e(error, "Failed to retrieve songs for album artist") | |
}) | |
) | |
} | |
override fun play(album: Album) { | |
addDisposable( | |
songRepository.getSongs(SongQuery.AlbumIds(listOf(album.id))) | |
.first(emptyList()) | |
.subscribeOn(Schedulers.io()) | |
.observeOn(AndroidSchedulers.mainThread()) | |
.subscribeBy(onSuccess = { songs -> | |
playbackManager.load(songs, 0) { result -> | |
result.onSuccess { playbackManager.play() } | |
result.onFailure { error -> view?.showLoadError(error as Error) } | |
} | |
}, onError = { error -> | |
Timber.e(error, "Failed to retrieve songs for album") | |
}) | |
) | |
} | |
} | |
// File 3 - Controller (Implements 'V') | |
class HomeFragment : | |
Fragment(), | |
Injectable, | |
HomeContract.View, | |
CreatePlaylistDialogFragment.Listener { | |
@Inject lateinit var presenter: HomePresenter | |
@Inject lateinit var playlistMenuPresenter: PlaylistMenuPresenter | |
@Inject lateinit var playlistRepository: PlaylistRepository | |
private var recyclerView: RecyclerView by autoCleared() | |
private lateinit var adapter: RecyclerAdapter | |
private var imageLoader: ArtworkImageLoader by autoCleared() | |
private val disposable: CompositeDisposable = CompositeDisposable() | |
private lateinit var playlistMenuView: PlaylistMenuView | |
private var recyclerViewState: Parcelable? = null | |
// Lifecycle | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
adapter = RecyclerAdapter() | |
} | |
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { | |
return inflater.inflate(R.layout.fragment_home, container, false) | |
} | |
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | |
super.onViewCreated(view, savedInstanceState) | |
val historyButton: HomeButton = view.findViewById(R.id.historyButton) | |
val latestButton: HomeButton = view.findViewById(R.id.latestButton) | |
val favoritesButton: HomeButton = view.findViewById(R.id.favoritesButton) | |
val shuffleButton: HomeButton = view.findViewById(R.id.shuffleButton) | |
playlistMenuView = PlaylistMenuView(requireContext(), playlistMenuPresenter, childFragmentManager) | |
historyButton.setOnClickListener { | |
val navController = findNavController() | |
if (navController.currentDestination?.id != R.id.smartPlaylistDetailFragment) { | |
navController.navigate( | |
R.id.action_homeFragment_to_smartPlaylistDetailFragment, | |
SmartPlaylistDetailFragmentArgs(SmartPlaylist.RecentlyPlayed).toBundle() | |
) | |
} | |
} | |
latestButton.setOnClickListener { | |
val navController = findNavController() | |
if (navController.currentDestination?.id != R.id.smartPlaylistDetailFragment) { | |
navController.navigate( | |
R.id.action_homeFragment_to_smartPlaylistDetailFragment, | |
SmartPlaylistDetailFragmentArgs(SmartPlaylist.RecentlyAdded).toBundle() | |
) | |
} | |
} | |
favoritesButton.setOnClickListener { navigateToPlaylist(PlaylistQuery.PlaylistName("Favorites")) } | |
shuffleButton.setOnClickListener { presenter.shuffleAll() } | |
recyclerView = view.findViewById(R.id.recyclerView) | |
recyclerView.adapter = adapter | |
recyclerView.setRecyclerListener(RecyclerListener()) | |
recyclerView.clearAdapterOnDetach() | |
val decoration = DividerItemDecoration(context, LinearLayout.VERTICAL) | |
decoration.setDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.divider)!!) | |
recyclerView.addItemDecoration(decoration) | |
imageLoader = GlideImageLoader(this) | |
savedInstanceState?.getParcelable<Parcelable>(ARG_RECYCLER_STATE)?.let { recyclerViewState = it } | |
presenter.bindView(this) | |
playlistMenuPresenter.bindView(playlistMenuView) | |
} | |
override fun onResume() { | |
super.onResume() | |
recyclerViewState?.let { | |
recyclerView.layoutManager?.onRestoreInstanceState(recyclerViewState) | |
} | |
presenter.loadData() | |
} | |
override fun onPause() { | |
super.onPause() | |
recyclerViewState = recyclerView.layoutManager?.onSaveInstanceState() | |
} | |
override fun onSaveInstanceState(outState: Bundle) { | |
outState.putParcelable(ARG_RECYCLER_STATE, recyclerViewState) | |
super.onSaveInstanceState(outState) | |
} | |
override fun onDestroyView() { | |
presenter.unbindView() | |
playlistMenuPresenter.unbindView() | |
adapter.dispose() | |
disposable.clear() | |
super.onDestroyView() | |
} | |
// HomeContract.View Implementation | |
override fun showLoadError(error: Error) { | |
Toast.makeText(context, error.userDescription(), Toast.LENGTH_LONG).show() | |
} | |
override fun setData(data: HomeContract.HomeData) { | |
val viewBinders = mutableListOf<ViewBinder>() | |
if (data.recentlyPlayedAlbums.isNotEmpty()) { | |
viewBinders.add( | |
HorizontalAlbumListBinder("Recent", "Recently played albums", data.recentlyPlayedAlbums, imageLoader, listener = albumBinderListener) | |
) | |
} | |
if (data.mostPlayedAlbums.isNotEmpty()) { | |
viewBinders.add( | |
HorizontalAlbumListBinder("Most Played", "Albums in heavy rotation", data.mostPlayedAlbums, imageLoader, showPlayCountBadge = true, listener = albumBinderListener) | |
) | |
} | |
if (data.albumsFromThisYear.isNotEmpty()) { | |
viewBinders.add( | |
HorizontalAlbumListBinder("This Year", "Albums released in ${Calendar.getInstance().get(Calendar.YEAR)}", data.albumsFromThisYear, imageLoader, listener = albumBinderListener) | |
) | |
} | |
if (data.unplayedAlbumArtists.isNotEmpty()) { | |
viewBinders.add( | |
HorizontalAlbumArtistListBinder("Something Different", "Artists you haven't listened to in a while", data.unplayedAlbumArtists, imageLoader, listener = albumArtistBinderListener) | |
) | |
} | |
adapter.setData(viewBinders.toList(), completion = { | |
recyclerViewState?.let { | |
recyclerView.layoutManager?.onRestoreInstanceState(recyclerViewState) | |
} | |
}) | |
} | |
override fun showDeleteError(error: Error) { | |
Toast.makeText(requireContext(), error.userDescription(), Toast.LENGTH_LONG).show() | |
} | |
override fun onAddedToQueue(albumArtist: AlbumArtist) { | |
Toast.makeText(context, "${albumArtist.name} added to queue", Toast.LENGTH_SHORT).show() | |
} | |
override fun onAddedToQueue(album: Album) { | |
Toast.makeText(context, "${album.name} added to queue", Toast.LENGTH_SHORT).show() | |
} | |
// Private | |
private val albumArtistBinderListener = object : GridAlbumArtistBinder.Listener { | |
override fun onAlbumArtistClicked(albumArtist: AlbumArtist, viewHolder: GridAlbumArtistBinder.ViewHolder) { | |
findNavController().navigate( | |
R.id.action_homeFragment_to_albumArtistDetailFragment, | |
AlbumArtistDetailFragmentArgs(albumArtist, true).toBundle(), | |
null, | |
FragmentNavigatorExtras(viewHolder.imageView to viewHolder.imageView.transitionName) | |
) | |
} | |
override fun onAlbumArtistLongPressed(view: View, albumArtist: AlbumArtist) { | |
val popupMenu = PopupMenu(requireContext(), view) | |
popupMenu.inflate(R.menu.menu_popup_add) | |
playlistMenuView.createPlaylistMenu(popupMenu.menu) | |
popupMenu.setOnMenuItemClickListener { menuItem -> | |
if (playlistMenuView.handleMenuItem(menuItem, PlaylistData.AlbumArtists(albumArtist))) { | |
return@setOnMenuItemClickListener true | |
} else { | |
when (menuItem.itemId) { | |
R.id.play -> { | |
presenter.play(albumArtist) | |
return@setOnMenuItemClickListener true | |
} | |
R.id.queue -> { | |
presenter.addToQueue(albumArtist) | |
return@setOnMenuItemClickListener true | |
} | |
R.id.playNext -> { | |
presenter.playNext(albumArtist) | |
return@setOnMenuItemClickListener true | |
} | |
R.id.blacklist -> { | |
presenter.blacklist(albumArtist) | |
return@setOnMenuItemClickListener true | |
} | |
} | |
} | |
false | |
} | |
popupMenu.show() | |
} | |
} | |
private val albumBinderListener = object : GridAlbumBinder.Listener { | |
override fun onAlbumClicked(album: Album, viewHolder: GridAlbumBinder.ViewHolder) { | |
findNavController().navigate( | |
R.id.action_homeFragment_to_albumDetailFragment, | |
AlbumDetailFragmentArgs(album, true).toBundle(), | |
null, | |
FragmentNavigatorExtras(viewHolder.imageView to viewHolder.imageView.transitionName) | |
) | |
} | |
override fun onAlbumLongPressed(view: View, album: Album) { | |
val popupMenu = PopupMenu(requireContext(), view) | |
popupMenu.inflate(R.menu.menu_popup_add) | |
playlistMenuView.createPlaylistMenu(popupMenu.menu) | |
popupMenu.setOnMenuItemClickListener { menuItem -> | |
if (playlistMenuView.handleMenuItem(menuItem, PlaylistData.Albums(album))) { | |
return@setOnMenuItemClickListener true | |
} else { | |
when (menuItem.itemId) { | |
R.id.play -> { | |
presenter.play(album) | |
return@setOnMenuItemClickListener true | |
} | |
R.id.queue -> { | |
presenter.addToQueue(album) | |
return@setOnMenuItemClickListener true | |
} | |
R.id.playNext -> { | |
presenter.playNext(album) | |
return@setOnMenuItemClickListener true | |
} | |
R.id.blacklist -> { | |
presenter.blacklist(album) | |
return@setOnMenuItemClickListener true | |
} | |
} | |
} | |
false | |
} | |
popupMenu.show() | |
} | |
} | |
private fun navigateToPlaylist(query: PlaylistQuery) { | |
disposable.add(playlistRepository | |
.getPlaylists(query) | |
.first(emptyList()) | |
.map { playlists -> playlists.first() } | |
.subscribeOn(Schedulers.io()) | |
.observeOn(AndroidSchedulers.mainThread()) | |
.subscribeBy( | |
onSuccess = { playlist -> | |
if (playlist.songCount == 0) { | |
Toast.makeText(context, "Playlist empty", Toast.LENGTH_SHORT).show() | |
} else { | |
if (findNavController().currentDestination?.id != R.id.playlistDetailFragment) { | |
findNavController().navigate(R.id.action_homeFragment_to_playlistDetailFragment, PlaylistDetailFragmentArgs(playlist).toBundle()) | |
} | |
} | |
}, | |
onError = { throwable -> | |
if (throwable is NoSuchElementException) { | |
Toast.makeText(context, "Playlist empty", Toast.LENGTH_SHORT).show() | |
} else { | |
Timber.e(throwable, "Failed to retrieve favorites playlist") | |
} | |
} | |
)) | |
} | |
// CreatePlaylistDialogFragment.Listener Implementation | |
override fun onSave(text: String, playlistData: PlaylistData) { | |
playlistMenuPresenter.createPlaylist(text, playlistData) | |
} | |
companion object { | |
const val ARG_RECYCLER_STATE = "recycler_state" | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment