Skip to content

Instantly share code, notes, and snippets.

@Jeevuz
Last active April 5, 2017 19:36
Show Gist options
  • Save Jeevuz/ae0d071b4f44566793d4c5eaeb530a9c to your computer and use it in GitHub Desktop.
Save Jeevuz/ae0d071b4f44566793d4c5eaeb530a9c to your computer and use it in GitHub Desktop.
Real project's part converted from MVP (with Moxy) into RxPM (with Outlast). See revisions diff.
This is real project screen with complex UI that was switched
from using the MVP with Moxy library
to the use of RxPM pattern (Reactive Presentation Model) with Outlast library (persistent PM layer).
I was doing it to see pros and cons of the RxPM pattern.
Pros:
- easy integration with RxBindings for complex UI.
- nice saved states in PM (for PM and MVVM lovers).
- easy combining of reactive streams coming from network, db, etc. in PM.
- declarative logic.
Cons:
- need of wrappers to pass many params in stream at once.
- need of boilerplate getters for use states and consumers outside of PM.
- it is possible to forget to bind some state in the view. No help from compilator or IDE as with the View interface.
- harder to quick switch to project because of more rx. Especially for middle or junior.
- need to remember the contract of bindPresentationModel and bindTo (see javadocs, they subscribe/unsubscribe in resume/pause).
As result I can say that the RxPM+Outlast is good combination
easy competing with MVP+Moxy and differences is largely a matter of taste.
RxPM will do good in full-of-rx projects, while MVP is better for no-rx projects or teams with different level of knowledge.
package com.example.ui.common.pm
import android.os.Bundle
import android.support.annotation.LayoutRes
import android.support.v4.app.Fragment
import android.view.LayoutInflater
import android.view.ViewGroup
import com.example.util.inflate
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.functions.Consumer
import me.jeevuz.outlast.Outlasting
import me.jeevuz.outlast.predefined.FragmentOutlast
import timber.log.Timber
/**
* Helps to handle subscriptions and binding.
* @author Vasili Chyrvon (vasili.chyrvon@gmail.com)
*/
abstract class BaseFragment<PM : PresentationModel> : Fragment() {
// To let PM outlast the configuration changes
private lateinit var outlast: FragmentOutlast<PM>
// To unsubscribe when needed
private val composite = CompositeDisposable()
/**
* Provide layout to use in [onCreateView]
*/
@get:LayoutRes
protected abstract val fragmentLayout: Int
/**
* Returns stored Presentation Model for this fragment.
* Call it only after onCreate().
* @return Presentation Model
*/
protected val presentationModel: PM = outlast.outlasting
/**
* Provide presentation model to use with this fragment.
*/
protected abstract fun providePresentationModel(): PM
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Create the outlast delegate
outlast = FragmentOutlast<PM>(
this,
Outlasting.Creator { providePresentationModel() },
savedInstanceState)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) =
container?.inflate(fragmentLayout)
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
initViews()
}
override fun onStart() {
super.onStart()
outlast.onStart() // Delegated callback
}
override fun onResume() {
super.onResume()
outlast.onResume() // Delegated callback
bindPresentationModel()
}
override fun onSaveInstanceState(outState: Bundle?) {
super.onSaveInstanceState(outState)
outlast.onSaveInstanceState(outState) // Delegated callback
}
override fun onDestroy() {
super.onDestroy()
outlast.onDestroy() // Delegated callback
}
/**
* Bind to the Presentation Model in that method.
* Called from [onResume].
* Use convenient extension [bindTo] (all subscriptions done using it will be cleared in [onPause]).
*/
protected abstract fun bindPresentationModel()
/**
* Method for base views initialization.
* Called from [onActivityCreated].
*/
protected open fun initViews() {
// No-op
}
override fun onPause() {
super.onPause()
composite.clear()
}
/**
* Local extension to
* subscribe to the source observable with observeOn set to main thread
* and adds it to the subscriptions list that will be CLEARED [ON PAUSE][onPause], so use it ONLY in [bindPresentationModel].
*/
protected fun <T> Observable<T>.bindTo(consumer: Consumer<in T>, onError: Consumer<in Throwable> = Consumer { Timber.e(it) }) {
composite.add(
this
.observeOn(AndroidSchedulers.mainThread())
.subscribe(consumer, onError)
)
}
/**
* Local extension to
* subscribe to the source observable with observeOn set to main thread
* and adds it to the subscriptions list that will be CLEARED [ON PAUSE][onPause], so use it ONLY in [bindPresentationModel].
*/
protected fun <T> Observable<T>.bindTo(consumer: (T) -> Unit, onError: (Throwable) -> Unit) {
composite.add(
this
.observeOn(AndroidSchedulers.mainThread())
.subscribe(consumer, onError)
)
}
/**
* Local extension to
* subscribe to the source observable with observeOn set to main thread
* and adds it to the subscriptions list that will be CLEARED [ON PAUSE][onPause], so use it ONLY in [bindPresentationModel].
*/
protected fun <T> Observable<T>.bindTo(consumer: (T) -> Unit) {
bindTo(consumer, Timber::e)
}
/**
* Local extension to
* pass the value to the [Consumer].
*/
protected fun passTo(consumer: Consumer<Unit>) {
consumer.accept(Unit)
}
/**
* Local extension to
* pass the value to the [Consumer].
*/
protected fun <T> T.passTo(consumer: Consumer<T>) {
consumer.accept(this)
}
}
package com.example.ui.common.pm
import com.jakewharton.rxrelay2.PublishRelay
import io.reactivex.functions.Consumer
/**
* Base presentation model with common properties like backPresses
*/
abstract class BasePresentationModel : PresentationModel() {
// Common actions
protected val backPresses = PublishRelay.create<Unit>()
val backPressesConsumer: Consumer<Unit> = backPresses
override fun onCreate() {
initialValues()
setUp()
}
/**
* Set initial values of states and actions streams here
*/
protected open fun initialValues() {
// No-op
}
/**
* All initialization of streams and other set up goes here
*/
abstract fun setUp()
}
package com.example.ui.common.pm
import me.jeevuz.outlast.Outlasting
/**
* Presentation model that can outlast the configuration changes.
* @author Vasili Chyrvon (vasili.chyrvon@gmail.com)
*/
abstract class PresentationModel : Outlasting {
/**
* All initialization goes here.
*/
override abstract fun onCreate()
/**
* Use for the unsubscribing and clearings.
*/
override fun onDestroy() {
// No-op
}
}
package com.example.ui.settings
import android.view.View
import android.widget.CompoundButton
import android.widget.TextView
import com.bayer.aerius.ui.settings.SettingsPresentationModel
import com.bayer.aerius.ui.settings.Sound
import com.example.R
import com.example.entity.Day
import com.example.entity.RemindersRepeating
import com.example.ui.common.BackPressHandler
import com.example.ui.common.SingleChoiceDialogFragment
import com.example.ui.common.pm.BaseFragment
import com.codetroopers.betterpickers.radialtimepicker.RadialTimePickerDialogFragment
import com.jakewharton.rxbinding2.view.clicks
import com.jakewharton.rxbinding2.widget.checked
import com.jakewharton.rxbinding2.widget.checkedChanges
import com.jakewharton.rxbinding2.widget.text
import io.reactivex.functions.BiFunction
import kotlinx.android.synthetic.main.fragment_settings.*
/**
* @author Vasili Chyrvon (vasili.chyrvon@gmail.com)
*/
class SettingsFragment : BaseFragment<SettingsPresentationModel>(), SingleChoiceDialogFragment.ResponseListener, BackPressHandler, RadialTimePickerDialogFragment.OnTimeSetListener {
companion object {
private val DIALOG_REPEATING = "dialog_repeating"
private val DIALOG_REMINDERS_START_TIME = "dialog_reminders_start_time"
private val DIALOG_REMINDERS_STOP_TIME = "dialog_reminders_stop_time"
private val DIALOG_SILENCE_START_TIME = "dialog_silence_start_time"
private val DIALOG_SILENCE_STOP_TIME = "dialog_silence_stop_time"
private val DIALOG_SOUNDS = "dialog_sounds"
}
override val fragmentLayout = R.layout.fragment_settings
private lateinit var sounds: List<Sound>
override fun providePresentationModel() = SettingsPresentationModel()
override fun initViews() {
setDaysOnCheckedChangeListener()
}
override fun bindPresentationModel() {
// Local function to be more compact
fun <T, D> returnData() = BiFunction<T, D, D>
{ _, data -> data }
presentationModel.remindersEnabledState
.doOnNext(this::setRemindersBlockEnabled)
.bindTo(remindersSwitch.checked())
presentationModel.onlyUseStartTimeState
.bindTo { onlyStartTime ->
if (onlyStartTime) {
stopTimeLabel.visibility = View.INVISIBLE
stopTime.visibility = View.INVISIBLE
startTimeLabel.setText(R.string.settings_reminder_at)
} else {
stopTimeLabel.visibility = View.VISIBLE
stopTime.visibility = View.VISIBLE
startTimeLabel.setText(R.string.settings_reminder_from)
}
}
presentationModel.repeatingsState
.map { it.items[it.selectedItem].text }
.bindTo(repeatText.text())
repeatText.clicks()
.withLatestFrom(
presentationModel.repeatingsState,
returnData()
)
.bindTo {
dropDown ->
showChoiceDialog(dropDown.items.map { it.text }.toTypedArray(), dropDown.selectedItem, DIALOG_REPEATING)
}
startTime.clicks()
.withLatestFrom(
presentationModel.remindersStartTimeState,
returnData()
)
.bindTo { showTimePicker(it, DIALOG_REMINDERS_START_TIME) }
presentationModel.remindersStartTimeState
.bindTo(startTime::setTime)
stopTime.clicks()
.withLatestFrom(
presentationModel.remindersStopTimeState,
returnData()
)
.bindTo { showTimePicker(it, DIALOG_REMINDERS_STOP_TIME) }
presentationModel.remindersStopTimeState
.bindTo(stopTime::setTime)
presentationModel.selectedDaysState
.bindTo { selectedDays ->
monday.isChecked = selectedDays.contains(Day.MON)
tuesday.isChecked = selectedDays.contains(Day.TUE)
wednesday.isChecked = selectedDays.contains(Day.WED)
thursday.isChecked = selectedDays.contains(Day.THU)
friday.isChecked = selectedDays.contains(Day.FRI)
saturday.isChecked = selectedDays.contains(Day.SAT)
sunday.isChecked = selectedDays.contains(Day.SUN)
}
presentationModel.silenceEnabledState
.doOnNext(this::setSilenceBlockEnabled)
.bindTo(silenceSwitch.checked())
silenceStartTime.clicks()
.withLatestFrom(
presentationModel.silenceStartTimeState,
returnData()
)
.bindTo { showTimePicker(it, DIALOG_SILENCE_START_TIME) }
presentationModel.silenceStartTimeState
.bindTo(silenceStartTime::setTime)
silenceStopTime.clicks()
.withLatestFrom(
presentationModel.silenceStopTimeState,
returnData()
)
.bindTo { showTimePicker(it, DIALOG_SILENCE_STOP_TIME) }
presentationModel.silenceStopTimeState
.bindTo(silenceStopTime::setTime)
presentationModel.soundsState
.doOnNext {
this.sounds = it.items
}
.map { it.items[it.selectedItem].name }
.bindTo(soundText.text())
soundText.clicks()
.withLatestFrom(
presentationModel.soundsState,
returnData()
)
.bindTo {
dropDown ->
showChoiceDialog(dropDown.items.map { it.name }.toTypedArray(), dropDown.selectedItem, DIALOG_SOUNDS, true)
}
backButton.clicks().bindTo(presentationModel.backPressesConsumer)
remindersSwitch.checkedChanges().bindTo(presentationModel.reminderSwitchChangesConsumer)
silenceSwitch.checkedChanges().bindTo(presentationModel.silenceSwitchChangesConsumer)
}
override fun onPause() {
super.onPause()
passTo(presentationModel.inactivitySignalsConsumer)
}
private fun setRemindersBlockEnabled(enabled: Boolean) {
repeatText.isEnabled = enabled
startTimeLabel.isEnabled = enabled
startTime.isEnabled = enabled
stopTimeLabel.isEnabled = enabled
stopTime.isEnabled = enabled
monday.isEnabled = enabled
tuesday.isEnabled = enabled
wednesday.isEnabled = enabled
thursday.isEnabled = enabled
friday.isEnabled = enabled
saturday.isEnabled = enabled
sunday.isEnabled = enabled
}
private fun showChoiceDialog(items: Array<String>, checkedPosition: Int, dialogTag: String, withButtons: Boolean = false) {
if (childFragmentManager.findFragmentByTag(tag) == null) {
SingleChoiceDialogFragment.newInstance(items, checkedPosition, dialogTag, withButtons).show(childFragmentManager, tag)
}
}
override fun onSingleChoiceDialogResponse(tag: String, position: Int, done: Boolean) {
when (tag) {
DIALOG_REPEATING -> {
RemindersRepeating.values()[position].passTo(presentationModel.repeatingChangesConsumer)
}
DIALOG_SOUNDS -> {
if (done) {
sounds[position].uri.passTo(presentationModel.soundChangesConsumer)
} else {
sounds[position].uri.passTo(presentationModel.soundTestsConsumer)
}
}
}
}
private fun showTimePicker(hourMinute: Pair<Int, Int>, dialogTag: String) {
val timePicker = RadialTimePickerDialogFragment()
.setThemeCustom(R.style.TimePickersDialog)
.setForced24hFormat()
.setDoneText(getString(R.string.settings_time_dialog_ok))
.setCancelText(getString(R.string.settings_time_dialog_cancel))
.setOnTimeSetListener(this)
.setStartTime(hourMinute.first, hourMinute.second)
timePicker.show(childFragmentManager, dialogTag)
}
override fun onTimeSet(dialog: RadialTimePickerDialogFragment, hour: Int, minute: Int) {
when (dialog.tag) {
DIALOG_REMINDERS_START_TIME -> Pair(hour, minute).passTo(presentationModel.remindersStartTimeChangesConsumer)
DIALOG_REMINDERS_STOP_TIME -> Pair(hour, minute).passTo(presentationModel.remindersStopTimeChangesConsumer)
DIALOG_SILENCE_START_TIME -> Pair(hour, minute).passTo(presentationModel.silenceStartTimeChangesConsumer)
DIALOG_SILENCE_STOP_TIME -> Pair(hour, minute).passTo(presentationModel.silenceStopTimeChangesConsumer)
}
}
private fun setDaysOnCheckedChangeListener() {
val daysCheckedChangeListener = CompoundButton.OnCheckedChangeListener { compoundButton, isChecked ->
val daySelection = SettingsPresentationModel.DaySelection(
when (compoundButton.id) {
monday.id -> Day.MON
tuesday.id -> Day.TUE
wednesday.id -> Day.WED
thursday.id -> Day.THU
friday.id -> Day.FRI
saturday.id -> Day.SAT
sunday.id -> Day.SUN
else -> throw IllegalArgumentException("This is not a day of week")
},
isChecked)
daySelection.passTo(presentationModel.daysSelectionChangesConsumer)
}
monday.setOnCheckedChangeListener(daysCheckedChangeListener)
tuesday.setOnCheckedChangeListener(daysCheckedChangeListener)
wednesday.setOnCheckedChangeListener(daysCheckedChangeListener)
thursday.setOnCheckedChangeListener(daysCheckedChangeListener)
friday.setOnCheckedChangeListener(daysCheckedChangeListener)
saturday.setOnCheckedChangeListener(daysCheckedChangeListener)
sunday.setOnCheckedChangeListener(daysCheckedChangeListener)
}
private fun setSilenceBlockEnabled(enabled: Boolean) {
silenceStartTimeLabel.isEnabled = enabled
silenceStartTime.isEnabled = enabled
silenceStopTimeLabel.isEnabled = enabled
silenceStopTime.isEnabled = enabled
}
override fun onBackPressed(): Boolean {
passTo(presentationModel.backPressesConsumer)
return true
}
}
/**
* Local extension to set time Pair<hour, minute> to TextView
*/
private fun TextView.setTime(timePair: Pair<Int, Int>) {
text = context.getString(R.string.settings_time, timePair.first, timePair.second)
}
package com.example.ui.settings
import android.net.Uri
import com.arellomobile.mvp.InjectViewState
import com.bayer.aerius.ui.settings.Repeating
import com.bayer.aerius.ui.settings.Sound
import com.example.R
import com.example.TheApplication
import com.example.entity.Day
import com.example.entity.RemindersRepeating
import com.example.notifications.Notifier
import com.example.repository.SettingsRepository
import com.example.system.ResourceHelper
import com.example.system.SoundsHelper
import com.example.ui.common.pm.BasePresentationModel
import com.jakewharton.rxrelay2.BehaviorRelay
import com.jakewharton.rxrelay2.PublishRelay
import io.reactivex.Observable
import io.reactivex.functions.BiFunction
import io.reactivex.functions.Consumer
import ru.terrakok.cicerone.Router
import javax.inject.Inject
/**
* @author Vasili Chyrvon (vasili.chyrvon@gmail.com)
*/
@InjectViewState
class SettingsPresentationModel : BasePresentationModel() {
@Inject lateinit var router: Router
@Inject lateinit var settingsRepo: SettingsRepository
@Inject lateinit var notifier: Notifier
@Inject lateinit var soundsHelper: SoundsHelper
@Inject lateinit var resourceHelper: ResourceHelper
init {
TheApplication.appComponent.inject(this)
}
private val soundsList by lazy { soundsHelper.getNotificationSounds().toList() }
private val repeatingTextsList by lazy { resourceHelper.getStringsList(R.array.settings_reminder_repeat) }
// Wrappers for use in streams
data class DropDown<out T>(val items: List<T>, val selectedItem: Int)
data class DaySelection(val day: Day, val selected: Boolean)
// States
private val remindersEnabled = BehaviorRelay.create<Boolean>()
private val repeatings = BehaviorRelay.create<DropDown<Repeating>>()
private val onlyUseStartTime = BehaviorRelay.create<Boolean>()
private val remindersStartTime = BehaviorRelay.create<Pair<Int, Int>>()
private val remindersStopTime = BehaviorRelay.create<Pair<Int, Int>>()
private val selectedDays = BehaviorRelay.create<Set<Day>>()
private val silenceEnabled = BehaviorRelay.create<Boolean>()
private val silenceStartTime = BehaviorRelay.create<Pair<Int, Int>>()
private val silenceStopTime = BehaviorRelay.create<Pair<Int, Int>>()
private val sounds = BehaviorRelay.create<DropDown<Sound>>()
// Actions
private val reminderSwitchChanges = PublishRelay.create<Boolean>()
private val repeatingChanges = PublishRelay.create<RemindersRepeating>()
private val remindersStartTimeChanges = PublishRelay.create<Pair<Int, Int>>()
private val remindersStopTimeChanges = PublishRelay.create<Pair<Int, Int>>()
private val silenceSwitchChanges = PublishRelay.create<Boolean>()
private val silenceStartTimeChanges = PublishRelay.create<Pair<Int, Int>>()
private val silenceStopTimeChanges = PublishRelay.create<Pair<Int, Int>>()
private val daysSelectionChanges = PublishRelay.create<DaySelection>()
private val soundChanges = PublishRelay.create<Uri>()
private val soundTests = PublishRelay.create<Uri>()
private val inactivitySignals = PublishRelay.create<Unit>()
//region Getters for view states and actions consumers
val remindersEnabledState: Observable<Boolean> = remindersEnabled.hide()
val repeatingsState: Observable<DropDown<Repeating>> = repeatings.hide()
val onlyUseStartTimeState: Observable<Boolean> = onlyUseStartTime.hide()
val remindersStartTimeState: Observable<Pair<Int, Int>> = remindersStartTime.hide()
val remindersStopTimeState: Observable<Pair<Int, Int>> = remindersStopTime.hide()
val selectedDaysState: Observable<Set<Day>> = selectedDays.hide()
val silenceEnabledState: Observable<Boolean> = silenceEnabled.hide()
val silenceStartTimeState: Observable<Pair<Int, Int>> = silenceStartTime.hide()
val silenceStopTimeState: Observable<Pair<Int, Int>> = silenceStopTime.hide()
val soundsState: Observable<DropDown<Sound>> = sounds.hide()
val reminderSwitchChangesConsumer: Consumer<Boolean> = reminderSwitchChanges
val repeatingChangesConsumer: Consumer<RemindersRepeating> = repeatingChanges
val remindersStartTimeChangesConsumer: Consumer<Pair<Int, Int>> = remindersStartTimeChanges
val remindersStopTimeChangesConsumer: Consumer<Pair<Int, Int>> = remindersStopTimeChanges
val silenceSwitchChangesConsumer: Consumer<Boolean> = silenceSwitchChanges
val silenceStartTimeChangesConsumer: Consumer<Pair<Int, Int>> = silenceStartTimeChanges
val silenceStopTimeChangesConsumer: Consumer<Pair<Int, Int>> = silenceStopTimeChanges
val daysSelectionChangesConsumer: Consumer<DaySelection> = daysSelectionChanges
val soundChangesConsumer: Consumer<Uri> = soundChanges
val soundTestsConsumer: Consumer<Uri> = soundTests
val inactivitySignalsConsumer: Consumer<Unit> = inactivitySignals
//endregion
override fun initialValues() {
remindersEnabled.accept(settingsRepo.isRemindersEnabled())
repeatings.accept(dropDownFromRepeating(settingsRepo.getRepeating()))
remindersStartTime.accept(settingsRepo.getRemindersStartTime())
remindersStopTime.accept(settingsRepo.getRemindersStopTime())
selectedDays.accept(settingsRepo.getSelectedDays())
silenceEnabled.accept(settingsRepo.isSilenceEnabled())
silenceStartTime.accept(settingsRepo.getSilenceStartTime())
silenceStopTime.accept(settingsRepo.getSilenceStopTime())
sounds.accept(dropDownFromSound(settingsRepo.getSound()))
}
override fun setUp() {
backPresses
.subscribe {
notifier.scheduleReminders()
router.exit()
}
reminderSwitchChanges
.doOnNext(settingsRepo::putRemindersEnabled)
.subscribe(remindersEnabled)
repeatingChanges
.doOnNext(settingsRepo::putRepeating)
.map { dropDownFromRepeating(it) }
.subscribe(repeatings)
repeatingChanges
.map { it == RemindersRepeating.ONCE_A_DAY }
.subscribe(onlyUseStartTime)
remindersStartTimeChanges
.doOnNext(settingsRepo::putRemindersStartTime)
.subscribe(remindersStartTime)
remindersStopTimeChanges
.doOnNext(settingsRepo::putRemindersStopTime)
.subscribe(remindersStopTime)
silenceSwitchChanges
.doOnNext(settingsRepo::putSilenceEnabled)
.subscribe(silenceEnabled)
silenceStartTimeChanges
.doOnNext(settingsRepo::putSilenceStartTime)
.subscribe(silenceStartTime)
silenceStopTimeChanges
.doOnNext(settingsRepo::putSilenceStopTime)
.subscribe(silenceStopTime)
daysSelectionChanges
.withLatestFrom(
selectedDays,
BiFunction<DaySelection, Set<Day>, Set<Day>>
{ (day, selected), selectedDays ->
val currentlySelectedDays = selectedDays.toMutableSet()
if (selected) {
currentlySelectedDays.add(day)
} else {
// Remove if valid (at least one day must be selected)
if (selectedDays.size > 1) {
currentlySelectedDays.remove(day)
}
}
currentlySelectedDays
}
)
.doOnNext(settingsRepo::putSelectedDays)
.subscribe(selectedDays)
soundChanges
.doOnNext { settingsRepo.putSound(it) }
.map { dropDownFromSound(it) }
.subscribe(sounds)
soundTests
.subscribe(soundsHelper::playSound)
inactivitySignals
.subscribe { notifier::scheduleReminders }
}
private fun dropDownFromRepeating(repeating: RemindersRepeating): DropDown<Repeating> {
val repeatings = RemindersRepeating.values().toList()
.map { Repeating(repeatingTextsList[it.ordinal], it) }
val repeatingPosition = repeating.ordinal
return DropDown(repeatings, repeatingPosition)
}
private fun dropDownFromSound(sound: Uri): DropDown<Sound> {
val listOfSounds = soundsList.map { Sound(it.first, it.second) }
val soundPosition = soundsList.map { it.second }.indexOf(sound)
return DropDown(listOfSounds, soundPosition)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment