Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Abstracting the map

Abstracting the map

Slides from the Droidcon Berlin 2018 talk about abstracting the map.

Marcel

June 26, 2018
Tweet

More Decks by Marcel

Other Decks in Programming

Transcript

  1. How can we avoid it? 1. Single responsibility 2. Immutability

    3. Unidirectional data flow © 2017 HERE | HERE Public Abstracting the map | June 24, 2018 3
  2. class MapsActivity : AppCompatActivity(), OnMapReadyCallback, SomeMapView { private lateinit var

    mMap: GoogleMap private val presenter = SomeMapPresenter() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_maps) presenter.view = this val mapFragment = supportFragmentManager .findFragmentById(R.id.map) as SupportMapFragment mapFragment.getMapAsync(this) } override fun onMapReady(googleMap: GoogleMap) { mMap = googleMap presenter.onMapReady() } override fun showMarker(marker: MarkerOptions) { mMap.addMarker(marker) } override fun moveCamera(update: CameraUpdate) { mMap.moveCamera(update) } } Simple example © 2017 HERE | HERE Public Abstracting the map | June 24, 2018 4 class MapsActivity : AppCompatActivity(), OnMapReadyCallback { private lateinit var mMap: GoogleMap override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_maps) val mapFragment = supportFragmentManager .findFragmentById(R.id.map) as SupportMapFragment mapFragment.getMapAsync(this) } override fun onMapReady(googleMap: GoogleMap) { mMap = googleMap // Add a marker in Sydney and move the camera val sydney = LatLng(-34.0, 151.0) mMap.addMarker(Marker Options().position(sydney).title("Marker in Sydney")) mMap.moveCamera(CameraUpdateFactory.newLatLng(sydney)) } } interface SomeMapView { fun showMarker(marker: MarkerOptions) fun moveCamera(update: CameraUpdate) } class SomeMapPresenter { var view: SomeMapView? = null fun onMapReady() { val sydney = LatLng(-34.0, 151.0) val marker = MarkerOptions().position(sydney).title("Marker in Sydney") view?.showMarker(marker) view?.moveCamera(CameraUpdateFactory.newLatLng(sydney)) } }
  3. class MapsActivity : AppCompatActivity(), OnMapReadyCallback, SomeMapView { private lateinit var

    mMap: GoogleMap private val presenter = SomeMapPresenter() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_maps) presenter.view = this val mapFragment = supportFragmentManager .findFragmentById(R.id.map) as SupportMapFragment mapFragment.getMapAsync(this) } override fun onMapReady(googleMap: GoogleMap) { mMap = googleMap presenter.onMapReady() } override fun showMarker(marker: MarkerOptions) { mMap.addMarker(marker) } override fun moveCamera(update: CameraUpdate) { mMap.moveCamera(update) } } Simple example © 2017 HERE | HERE Public Abstracting the map | June 24, 2018 5 interface SomeMapView { fun showMarker(marker: MarkerOptions) fun moveCamera(update: CameraUpdate) } class SomeMapPresenter { var view: SomeMapView? = null fun onMapReady() { val sydney = LatLng(-34.0, 151.0) val marker = MarkerOptions().position(sydney).title("Marker in Sydney") view?.showMarker(marker) view?.moveCamera(CameraUpdateFactory.newLatLng(sydney)) } }
  4. AnotherActivity AnotherView … and the chaos starts © 2017 HERE

    | HERE Public Abstracting the map | June 24, 2018 6 LatLng M arkerOptions M apActivity Som eM apPresenter Som eM apView GoogleM ap Location … SearchRepository PlaceRepository Som eRepository
  5. Single responsibility 1. Map belongs the view layer. 2. Do

    NOT spread the Map classes all around, 3. Do NOT contact the Map from different points. 4. Abstract the Map into a class that’s only accessible by a ViewModel. © 2017 HERE | HERE Public Abstracting the map | June 24, 2018 7
  6. Single responsibility © 2017 HERE | HERE Public Abstracting the

    map | June 24, 2018 8 class MapViewModel: ViewModel() { private val _mapItems = MediatorLiveData<List<MapItem>>() private val searchRepository = SearchRepository() private val placeRepository = PlaceRepository() init { // Add sources to the _mapItems mediator _mapItems.addSource(searchRepository.searchItems) { updateItems() } _mapItems.addSource(placeRepository.placeItems) { updateItems() } } fun getMapItems(): LiveData<List<MapItem>> = _mapItems private fun updateItems() { val searchItems = searchRepository.searchItems.value?.map { MapItem.Point.Marker.Search(it.id, it.position) }.orEmpty() val placeItems = placeRepository.placeItems.value?.map { MapItem.Point.Marker.Place(it.id, it.position) }.orEmpty() // Apply some logic to decide which ones to show or filter... _mapItems.postValue(searchItems.plus(placeItems)) } }
  7. Single responsibility © 2017 HERE | HERE Public Abstracting the

    map | June 24, 2018 9 class MapViewModel: ViewModel() { private val _mapItems = MediatorLiveData<List<MapItem>>() private val searchRepository = SearchRepository() private val placeRepository = PlaceRepository() init { // Add sources to the _mapItems mediator _mapItems.addSource(searchRepository.searchItems) { updateItems() } _mapItems.addSource(placeRepository.placeItems) { updateItems() } } fun getMapItems(): LiveData<List<MapItem>> = _mapItems private fun updateItems() { val searchItems = searchRepository.searchItems.value?.map { MapItem.Point.Marker.Search(it.id, it.position) }.orEmpty() val placeItems = placeRepository.placeItems.value?.map { MapItem.Point.Marker.Place(it.id, it.position) }.orEmpty() // Apply some logic to decide which ones to show or filter... _mapItems.postValue(searchItems.plus(placeItems)) } }
  8. Single responsibility © 2017 HERE | HERE Public Abstracting the

    map | June 24, 2018 10 class MapViewModel: ViewModel() { private val _mapItems = MediatorLiveData<List<MapItem>>() private val searchRepository = SearchRepository() private val placeRepository = PlaceRepository() init { // Add sources to the _mapItems mediator _mapItems.addSource(searchRepository.searchItems) { updateItems() } _mapItems.addSource(placeRepository.placeItems) { updateItems() } } fun getMapItems(): LiveData<List<MapItem>> = _mapItems private fun updateItems() { val searchItems = searchRepository.searchItems.value?.map { MapItem.Point.Marker.Search(it.id, it.position) }.orEmpty() val placeItems = placeRepository.placeItems.value?.map { MapItem.Point.Marker.Place(it.id, it.position) }.orEmpty() // Apply some logic to decide which ones to show or filter... _mapItems.postValue(searchItems.plus(placeItems)) } }
  9. Single responsibility © 2017 HERE | HERE Public Abstracting the

    map | June 24, 2018 11 class MapViewModel: ViewModel() { private val _mapItems = MediatorLiveData<List<MapItem>>() private val searchRepository = SearchRepository() private val placeRepository = PlaceRepository() init { // Add sources to the _mapItems mediator _mapItems.addSource(searchRepository.searchItems) { updateItems() } _mapItems.addSource(placeRepository.placeItems) { updateItems() } } fun getMapItems(): LiveData<List<MapItem>> = _mapItems private fun updateItems() { val searchItems = searchRepository.searchItems.value?.map { MapItem.Point.Marker.Search(it.id, it.position) }.orEmpty() val placeItems = placeRepository.placeItems.value?.map { MapItem.Point.Marker.Place(it.id, it.position) }.orEmpty() // Apply some logic to decide which ones to show or filter... _mapItems.postValue(searchItems.plus(placeItems)) } }
  10. override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel

    = ViewModelProviders.of(this).get(MapViewModel::class.java) mapView.getMapAsync { googleMap = it viewModel.getMapItems().observe(this, Observer { googleMap.clear() it?.forEach { val marker = when (it) { is MapItem.Path.Route -> TODO() is MapItem.Point.Marker.Search -> { val icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE) MarkerOptions().position(LatLng(it.latitude, it.longitude)).icon(icon) } is MapItem.Point.Marker.Place -> { val icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE) MarkerOptions().position(LatLng(it.latitude, it.longitude)).icon(icon) } } googleMap.addMarker(marker) } }) } } Single responsibility © 2017 HERE | HERE Public Abstracting the map | June 24, 2018 12
  11. override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel

    = ViewModelProviders.of(this).get(MapViewModel::class.java) mapView.getMapAsync { googleMap = it viewModel.getMapItems().observe(this, Observer { googleMap.clear() it?.forEach { val marker = when (it) { is MapItem.Path.Route -> TODO() is MapItem.Point.Marker.Search -> { val icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE) MarkerOptions().position(LatLng(it.latitude, it.longitude)).icon(icon) } is MapItem.Point.Marker.Place -> { val icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE) MarkerOptions().position(LatLng(it.latitude, it.longitude)).icon(icon) } } googleMap.addMarker(marker) } }) } } Single responsibility © 2017 HERE | HERE Public Abstracting the map | June 24, 2018 13
  12. override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel

    = ViewModelProviders.of(this).get(MapViewModel::class.java) mapView.getMapAsync { googleMap = it viewModel.getMapItems().observe(this, Observer { googleMap.clear() it?.forEach { val marker = when (it) { is MapItem.Path.Route -> TODO() is MapItem.Point.Marker.Search -> { val icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE) MarkerOptions().position(LatLng(it.latitude, it.longitude)).icon(icon) } is MapItem.Point.Marker.Place -> { val icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE) MarkerOptions().position(LatLng(it.latitude, it.longitude)).icon(icon) } } googleMap.addMarker(marker) } }) } } Single responsibility © 2017 HERE | HERE Public Abstracting the map | June 24, 2018 14
  13. Immutability • Map provider objects might be mutable, letting your

    app with an unexpected/invalid state. • Immutable objects avoid possible concurrency issues and ensure consistency between your model an views. • Easy to test. © 2017 HERE | HERE Public Abstracting the map | June 24, 2018 15
  14. Immutability © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 16 data class GeoCoordinate(val latitude: Double, val longitude: Double) sealed class MapItem { abstract val id: String sealed class Path : MapItem() { data class Route(override val id: String, val path: List<Point>) : Path() } sealed class Point : MapItem() { abstract val coordinate: GeoCoordinate sealed class Marker(override val id: String, override val coordinate: GeoCoordinate) : Point() { data class Search(override val id: String, override val coordinate: GeoCoordinate) : Marker(id, coordinate) data class Place(override val id: String, override val coordinate: GeoCoordinate) : Marker(id, coordinate) } } }
  15. Immutability © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 17 data class GeoCoordinate(val latitude: Double, val longitude: Double) sealed class MapItem { abstract val id: String sealed class Path : MapItem() { data class Route(override val id: String, val path: List<Point>) : Path() } sealed class Point : MapItem() { abstract val coordinate: GeoCoordinate sealed class Marker(override val id: String, override val coordinate: GeoCoordinate) : Point() { data class Search(override val id: String, override val coordinate: GeoCoordinate) : Marker(id, coordinate) data class Place(override val id: String, override val coordinate: GeoCoordinate) : Marker(id, coordinate) } } }
  16. Immutability © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 18 fun getMapItems(): LiveData<List<MapItem>> = _mapItems private fun updateItems() { val searchItems = searchRepository.searchItems.value?.map { MapItem.Point.Marker.Search(it.id, it.position) }.orEmpty() val placeItems = placeRepository.placeItems.value?.map { MapItem.Point.Marker.Place(it.id, it.position) }.orEmpty() // Apply some logic to decide which ones to show or filter... _mapItems.postValue(searchItems.plus(placeItems)) }
  17. Immutability © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 19 fun getMapItems(): LiveData<List<MapItem>> = _mapItems private fun updateItems() { val searchItems = searchRepository.searchItems.value?.map { MapItem.Point.Marker.Search(it.id, it.position) }.orEmpty() val placeItems = placeRepository.placeItems.value?.map { MapItem.Point.Marker.Place(it.id, it.position) }.orEmpty() // Apply some logic to decide which ones to show or filter... _mapItems.postValue(searchItems.plus(placeItems)) } Immutable LiveData
  18. Immutability © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 20 fun getMapItems(): LiveData<List<MapItem>> = _mapItems private fun updateItems() { val searchItems = searchRepository.searchItems.value?.map { MapItem.Point.Marker.Search(it.id, it.position) }.orEmpty() val placeItems = placeRepository.placeItems.value?.map { MapItem.Point.Marker.Place(it.id, it.position) }.orEmpty() // Apply some logic to decide which ones to show or filter... _mapItems.postValue(searchItems.plus(placeItems)) } Immutable LiveData New Immutable Object
  19. Immutability © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 21 fun getMapItems(): LiveData<List<MapItem>> = _mapItems private fun updateItems() { val searchItems = searchRepository.searchItems.value?.map { MapItem.Point.Marker.Search(it.id, it.position) }.orEmpty() val placeItems = placeRepository.placeItems.value?.map { MapItem.Point.Marker.Place(it.id, it.position) }.orEmpty() // Apply some logic to decide which ones to show or filter... _mapItems.postValue(searchItems.plus(placeItems)) } New Immutable list Immutable LiveData New Immutable Object
  20. Unidirectional data flow © 2017 HERE | HERE Public Abstracting

    the map | June 24, 2018 22 Som eFragm ent Som eViewM odel M apFragm ent M apViewM odel SearchRepository PlaceRepository M ainInteractor
  21. Result © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 23 LatLng M arkerOptions GoogleM ap Cam eraUpdate … Som eFragm ent Som eViewM odel M apFragm ent M apViewM odel SearchRepository PlaceRepository M ainInteractor
  22. Improvements © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 24 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel = ViewModelProviders.of(this).get(MapViewModel::class.java) mapView.getMapAsync { googleMap = it viewModel.getMapItems().observe(this, Observer { googleMap.clear() it?.forEach { val marker = when (it) { is MapItem.Path.Route -> TODO() is MapItem.Point.Marker.Search -> { val icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE) MarkerOptions().position(LatLng(it.latitude, it.longitude)).icon(icon) } is MapItem.Point.Marker.Place -> { val icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE) MarkerOptions().position(LatLng(it.latitude, it.longitude)).icon(icon) } } googleMap.addMarker(marker) } }) } }
  23. Improvements © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 25 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel = ViewModelProviders.of(this).get(MapViewModel::class.java) mapView.getMapAsync { googleMap = it viewModel.getMapItems().observe(this, Observer { googleMap.clear() it?.forEach { val marker = when (it) { is MapItem.Path.Route -> TODO() is MapItem.Point.Marker.Search -> { val icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE) MarkerOptions().position(LatLng(it.latitude, it.longitude)).icon(icon) } is MapItem.Point.Marker.Place -> { val icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE) MarkerOptions().position(LatLng(it.latitude, it.longitude)).icon(icon) } } googleMap.addMarker(marker) } }) } }
  24. Improvements © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 26 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel = ViewModelProviders.of(this).get(MapViewModel::class.java) adapter = MapsAdapter(requireContext()) mapView.getMapAsync { googleMap = it adapter.attach(view, googleMap) viewModel.getMapItems().observe(this, adapter) } } Adapter Pattern https://github.com/TradeMe/MapMe
  25. Improvements © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 27 class MapsAdapter(context: Context) : GoogleMapMeAdapter(context), Observer<List<MapItem>> { private var markers: List<MapItem.Point> = emptyList() override fun onChanged(items: List<MapItem>?) { // Since we don't support Path yet just cast it val newMarkers = items.orEmpty().map { it as MapItem.Point } val diff = DiffUtil.calculateDiff(MarkersDiffCallback(markers, newMarkers)) markers = newMarkers diff.dispatchUpdatesTo(this) } override fun getItemCount(): Int = markers.size override fun onCreateAnnotation(factory: AnnotationFactory<GoogleMap>, position: Int, annotationType: Int): MapAnnotation { val item = this.markers[position] return factory.createMarker(item.toLatLng(), getIcon(item), item.id) } override fun onBindAnnotation(annotation: MapAnnotation, position: Int, payload: Any?) { if (annotation is MarkerAnnotation) { val item = this.markers[position] annotation.latLng = item.toLatLng() annotation.icon = getIcon(item) } } } Adapter Pattern https://github.com/TradeMe/MapMe
  26. Improvements © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 28 class MapsAdapter(context: Context) : GoogleMapMeAdapter(context), Observer<List<MapItem>> { private var markers: List<MapItem.Point> = emptyList() override fun onChanged(items: List<MapItem>?) { // Since we don't support Path yet just cast it val newMarkers = items.orEmpty().map { it as MapItem.Point } val diff = DiffUtil.calculateDiff(MarkersDiffCallback(markers, newMarkers)) markers = newMarkers diff.dispatchUpdatesTo(this) } override fun getItemCount(): Int = markers.size override fun onCreateAnnotation(factory: AnnotationFactory<GoogleMap>, position: Int, annotationType: Int): MapAnnotation { val item = this.markers[position] return factory.createMarker(item.toLatLng(), getIcon(item), item.id) } override fun onBindAnnotation(annotation: MapAnnotation, position: Int, payload: Any?) { if (annotation is MarkerAnnotation) { val item = this.markers[position] annotation.latLng = item.toLatLng() annotation.icon = getIcon(item) } } } Adapter Pattern https://github.com/TradeMe/MapMe
  27. Improvements © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 29 class MapsAdapter(context: Context) : GoogleMapMeAdapter(context), Observer<List<MapItem>> { private var markers: List<MapItem.Point> = emptyList() override fun onChanged(items: List<MapItem>?) { // Since we don't support Path yet just cast it val newMarkers = items.orEmpty().map { it as MapItem.Point } val diff = DiffUtil.calculateDiff(MarkersDiffCallback(markers, newMarkers)) markers = newMarkers diff.dispatchUpdatesTo(this) } override fun getItemCount(): Int = markers.size override fun onCreateAnnotation(factory: AnnotationFactory<GoogleMap>, position: Int, annotationType: Int): MapAnnotation { val item = this.markers[position] return factory.createMarker(item.toLatLng(), getIcon(item), item.id) } override fun onBindAnnotation(annotation: MapAnnotation, position: Int, payload: Any?) { if (annotation is MarkerAnnotation) { val item = this.markers[position] annotation.latLng = item.toLatLng() annotation.icon = getIcon(item) } } } Adapter Pattern https://github.com/TradeMe/MapMe
  28. Improvements © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 30 class MapsAdapter(context: Context) : GoogleMapMeAdapter(context), Observer<List<MapItem>> { private var markers: List<MapItem.Point> = emptyList() override fun onChanged(items: List<MapItem>?) { // Since we don't support Path yet just cast it val newMarkers = items.orEmpty().map { it as MapItem.Point } val diff = DiffUtil.calculateDiff(MarkersDiffCallback(markers, newMarkers)) markers = newMarkers diff.dispatchUpdatesTo(this) } override fun getItemCount(): Int = markers.size override fun onCreateAnnotation(factory: AnnotationFactory<GoogleMap>, position: Int, annotationType: Int): MapAnnotation { val item = this.markers[position] return factory.createMarker(item.toLatLng(), getIcon(item), item.id) } override fun onBindAnnotation(annotation: MapAnnotation, position: Int, payload: Any?) { if (annotation is MarkerAnnotation) { val item = this.markers[position] annotation.latLng = item.toLatLng() annotation.icon = getIcon(item) } } } Adapter Pattern https://github.com/TradeMe/MapMe
  29. Improvements © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 31 class MapsAdapter(context: Context) : GoogleMapMeAdapter(context), Observer<List<MapItem>> { private var markers: List<MapItem.Point> = emptyList() override fun onChanged(items: List<MapItem>?) { // Since we don't support Path yet just cast it val newMarkers = items.orEmpty().map { it as MapItem.Point } val diff = DiffUtil.calculateDiff(MarkersDiffCallback(markers, newMarkers)) markers = newMarkers diff.dispatchUpdatesTo(this) } override fun getItemCount(): Int = markers.size override fun onCreateAnnotation(factory: AnnotationFactory<GoogleMap>, position: Int, annotationType: Int): MapAnnotation { val item = this.markers[position] return factory.createMarker(item.toLatLng(), getIcon(item), item.id) } override fun onBindAnnotation(annotation: MapAnnotation, position: Int, payload: Any?) { if (annotation is MarkerAnnotation) { val item = this.markers[position] annotation.latLng = item.toLatLng() annotation.icon = getIcon(item) } } } Adapter Pattern https://github.com/TradeMe/MapMe
  30. Improvements © 2017 HERE | HERE Public Abstracting the map

    | June 24, 2018 32 class MapsAdapter(context: Context) : GoogleMapMeAdapter(context), Observer<List<MapItem>> { private var markers: List<MapItem.Point> = emptyList() override fun onChanged(items: List<MapItem>?) { // Since we don't support Path yet just cast it val newMarkers = items.orEmpty().map { it as MapItem.Point } val diff = DiffUtil.calculateDiff(MarkersDiffCallback(markers, newMarkers)) markers = newMarkers diff.dispatchUpdatesTo(this) } override fun getItemCount(): Int = markers.size override fun onCreateAnnotation(factory: AnnotationFactory<GoogleMap>, position: Int, annotationType: Int): MapAnnotation { val item = this.markers[position] return factory.createMarker(item.toLatLng(), getIcon(item), item.id) } override fun onBindAnnotation(annotation: MapAnnotation, position: Int, payload: Any?) { if (annotation is MarkerAnnotation) { val item = this.markers[position] annotation.latLng = item.toLatLng() annotation.icon = getIcon(item) } } } Adapter Pattern https://github.com/TradeMe/MapMe
  31. Next steps • Handle Camera State • Map gestures •

    Path, routes and other object types • Complex interactions © 2017 HERE | HERE Public Abstracting the map | June 24, 2018 34
  32. Thank you Contact M arcel Pintó Biescas @ m arxallski

    https://m edium .com /@ m arxallski https://github.com /skim arxall