Skip to content

Instantly share code, notes, and snippets.

@timusus
Last active April 21, 2020 10:53
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save timusus/9032d7e3f2691c3fef397a3aeab5df42 to your computer and use it in GitHub Desktop.
Save timusus/9032d7e3f2691c3fef397a3aeab5df42 to your computer and use it in GitHub Desktop.
Shuttle - Home Contract
// 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