A spiffy lil' playlist maker for Spotify - swipe playlists to like and dislike tracks, create playlists with ease, check out tracks stats, and gain full control over your recommendations by customizing stats and genres:
One day I got frustrated with Discover Weekly and thought "why can't I just pluck out the one or two tracks I actually like??" and hence "tinder for spotify" was born. And then I added more screens so I wouldn't have to call it "tinder for spotify" 👍
This project was really an excuse for me to bite off more than I can chew and EXPLORE ALL THE THINGS!1!!, so it is an unholy amalgamation of:
- A Model-View-Intent "reactive" architecture, inspired by Redux's uni-directional data flow and viewstates as finite state machines
- Android Arch Components and Room
- Repository pattern
- RxJava/RxKotlin voodoo
- Lots of UI tidbits like shared element transitions and lottie animations
- Kotlin, in varying states of quality
What do we mean by MVI? There's a great outline in the unofficial boiler of the single flow:
Each self-contained view - whether an activity, a fragment, or a widget - comprises its own MVI component, which is really just a function that transforms data through these actors:
Intents
- events, whether user-driven like "opened playlist" or programmatic like a background fetchActions
- the action to take for a intent, with all the necessary data (they are a separate layer from intents because multiple intents can go to a singleAction
)Results
- the result of the action's processing, includes success/failure status and the payload with all the necessary data for re-rendering- The
View
- a dumb view layer that just renders a givenViewState
, and emits a stream ofObservable<Intent>
(user actions like "click" or "opened screen") for theViewModel
to bind to:
interface MviView<out I : MviIntent, in S : MviViewState> {
fun intents(): Observable<out I>
fun render(state: S)
}
- The
ViewModel
- holds the data backing theView
and emits it as aObservable<ViewState>
, which it gets by binding to the view'sintents
stream, hooking that up to the business logic, and listening toResults
to construct the nextViewState
interface MviViewModel<in I : MviIntent, S : MviViewState, in R: MviResult> {
fun processIntents(intents: Observable<out I>)
fun states(): Observable<S>
}
The core of MVI is the one-way data flow. There is only one place for events to go in, and one place for view states to come out:
View
pipes inIntents
(user click, app-initiated events etc) to theViewModel
- Ex: ProfileFragment
ViewModel
transformsIntents
toActions
-> pass toActionProcessor
ActionProcessor
transformsActions
toResults
-> pass theResults
toViewModel
'sreducer
reducer
is a function that combines theResult
+Previous ViewState
=>New ViewState
New ViewState
pipes through the states stream -> triggersView
'srender(ViewState)
// Intents => Actions => Results => reducer (previous state + result) => new view state
// You can see most of the flow in a single observable in the ViewModel, like `ProfileViewModel` here:
val observable = intentsSubject
// given events from the view's `intents()` stream
.subscribeOn(schedulerProvider.io())
// filter for relevant intents
.compose(intentFilter)
.map{ it -> actionFromIntent(it)}
// filter for relevant actions
.compose(actionFilter<ProfileActionMarker>())
// do the business logic (hit repo etc) using other services, managers etc
.compose(actionProcessor.combinedProcessor)
// filter for relevant results
.compose(resultFilter<ProfileResultMarker>())
// map previous state + result => new state
.scan(currentViewState, reducer)
.observeOn(schedulerProvider.ui())
.distinctUntilChanged().subscribe({
// publish the new state!
viewState -> viewStates.accept(viewState)
})
// ProfileFragment is subscribed to the ProfileViewModel's states stream, so it re-renders the new state:
viewModel.states().subscribe({ state ->
this.render(state)
})
Create a project over at Spotify developer to get a client key.
Create a secrets.properties
file in the project root. Add two keys:
SPOTIFY_CLIENT_ID = 12345
SPOTIFY_REDIRECT_URI = soundbits://callback
If you have trouble with gradle, try:
- checking
ic_launcher_foreground.xml
isn't malformed, sometimes import cuts off the vector - deleting the project
.gradle
andbuildSrc/build
folders, then invalidate and restart
- Arch components + Room
- RxJava/RxKotlin/RxRelay
- Dagger2
- Retrofit + Gson + Glide
- kaaes's spotify api wrapper
- PlaceholderView for the tinder ui
- other UI libs - see Dependencies
- Spotify SDK integration to play tracks without preview urls
- "Add to existing playlist" functionality
- Pagination with DiffUtil + Paging library
- Bugfixes and unit tests
- https://github.com/oldergod/android-architecture/tree/todo-mvi-rxjava
- https://android.jlelse.eu/reactive-architecture-7baa4ec651c4
- https://android.jlelse.eu/reactive-architecture-deep-dive-90cbc1f2dfcb
- Ray Ryan on Reactive Workflows at Square
- https://engineering.udacity.com/modeling-viewmodel-states-using-kotlins-sealed-classes-a5d415ed87a7
- More on Finite State Machines:
- Redux on reducer composition: https://redux.js.org/docs/recipes/ReducingBoilerplate.html