Skip to content

Instantly share code, notes, and snippets.

@Akhu
Created July 26, 2021 09:00
Show Gist options
  • Save Akhu/51c74bf2761c48e1196f3a766e80b906 to your computer and use it in GitHub Desktop.
Save Akhu/51c74bf2761c48e1196f3a766e80b906 to your computer and use it in GitHub Desktop.
Sealed Class for UI State sample Kotlin + Android
package com.pickle.theendormap.map
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Resources
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.lifecycle.Observer
import com.google.android.gms.common.api.ResolvableApiException
import com.google.android.gms.maps.*
import com.google.android.gms.maps.model.*
import com.pickle.theendormap.R
import com.pickle.theendormap.geofence.GEOFENCE_ID_MORDOR
import com.pickle.theendormap.geofence.GeofenceManager
import com.pickle.theendormap.location.LocationData
import com.pickle.theendormap.location.LocationLiveData
import com.pickle.theendormap.poi.MOUNT_DOOM
import com.pickle.theendormap.poi.Poi
import com.pickle.theendormap.poi.colorIntToBitmapConstant
import com.pickle.theendormap.poi.generatePois
import kotlinx.android.synthetic.main.activity_main.*
import timber.log.Timber
import java.lang.Exception
private const val REQUEST_PERMISSION_LOCATION_START_UPDATE = 101
private const val REQUEST_CHECK_FOR_SETTINGS = 200
private const val MAP_DEFAULT_ZOOM = 8f
/**
* For more informations go to https://developer.android.com/training/location
* What you need to do to get the location
* - Adding Google services for location (check the doc for dependency)
* - Add permissions to the AndroidManifest.xml
* - Check and ask (onRequestPermissionsResult) location permissions on runtime
* (Note that Android version will ask different types of permissions)
* - Check if location is enabled on the phone, handle if it's not the case
* - Create your location request (With time interval, precisions etc.)
* - Start location update from fused client
* - Handle the result.
*/
class MapActivity : AppCompatActivity(), OnMapReadyCallback {
private lateinit var map: GoogleMap
private lateinit var locationLiveData: LocationLiveData
//We ask Android to create a view model of this type for us
private val viewModel: MapViewModel by viewModels()
private var userMarker : Marker? = null
private var firstLocation = true
private lateinit var geofenceManager: GeofenceManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
/**
* We set map options according to the documentation here
* https://developers.google.com/maps/documentation/android-sdk/map#programmatically
* We put any settings we want for our map
*/
val mapOptions = GoogleMapOptions()
.mapType(GoogleMap.MAP_TYPE_NORMAL)
.zoomControlsEnabled(true)
.zoomGesturesEnabled(true)
//See https://developers.google.com/maps/documentation/android-sdk/map
//For more detail about how to use a map Fragment
val mapFragment = SupportMapFragment.newInstance(mapOptions)
mapFragment.getMapAsync(this)
//Now we replace the framelayout set in our XML by the actual map Fragment
supportFragmentManager
.beginTransaction().replace(R.id.mapFrame, mapFragment)
.commit()
geofenceManager = GeofenceManager(this)
locationLiveData = LocationLiveData(this)
locationLiveData.observe(this, Observer { handleLocationData(it!!) })
viewModel.getUiState().observe(this, Observer {
updateUiState(it!!)
Timber.i("Map Ui State $it")
})
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.activity_map_menu, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when(item.itemId){
R.id.generatePois -> {
refreshPoisFromCurrentLocation()
true
}
else -> false
}
}
private fun refreshPoisFromCurrentLocation() {
userMarker?.let {
geofenceManager.removeAllGeofences()
map.clear()
viewModel.loadPois(it.position.latitude, it.position.longitude)
}
}
/**
* Callback set to this activity by `getMapAsync` of the SupportMapFragment furnished by Google
* On map Ready is called wheen Google map is ready to display the map inside the fragment
*/
override fun onMapReady(googleMap: GoogleMap?) {
googleMap?.let {
map = it
map.setInfoWindowAdapter(EndorInfoWindowAdapter(this))
map.setMapStyle(MapStyleOptions.loadRawResourceStyle(this, R.raw.map_theme))
map.setOnInfoWindowClickListener {
showPoiDetail(it.tag as Poi)
}
}
}
private fun showPoiDetail(poi: Poi) {
if(poi.detailUrl.isEmpty()){
return
}
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(poi.detailUrl))
startActivity(intent)
}
/**
* Override of Activity function, will be useful when you start an activity to expect a result from it.
* You must attribute a request code when call the external activity. This activity will respond with the provided request code,
* so you can act following the request code received.
*/
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
REQUEST_CHECK_FOR_SETTINGS -> locationLiveData.startRequestLocation()
}
}
/**
* This override the request permission result.
* So we gather the user response of what the system has prompted about our permission request
*/
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
//Not so cool, should be refactored be able to handle any size of permissions array
if (grantResults.isEmpty() || grantResults[0] != PackageManager.PERMISSION_GRANTED) {
return
}
//We can manage multiple different permissions. We need to set up what to do when a permission is granted.
when (requestCode) {
REQUEST_PERMISSION_LOCATION_START_UPDATE -> locationLiveData.startRequestLocation()
}
}
private fun addPoiToMapMarker(poi: Poi, map: GoogleMap) : Marker {
val options = MarkerOptions()
.position(LatLng(poi.latitude, poi.longitude))
.title(poi.title)
.snippet(poi.description)
if (poi.iconId != 0) {
options.icon(BitmapDescriptorFactory.fromResource(poi.iconId))
} else {
options.icon(BitmapDescriptorFactory.defaultMarker(colorIntToBitmapConstant(poi.iconColor)))
}
val marker = map.addMarker(options)
marker.tag = poi
return marker
}
/**
* Update Map UI according to our state
*/
private fun updateUiState(state: MapUiState) {
Timber.i("State $state")
return when(state) {
MapUiState.Loading -> {
loadingProgressBar.show()
}
is MapUiState.Error -> {
loadingProgressBar.hide()
Toast.makeText(this, "Error : ${state.errorMessage}", Toast.LENGTH_SHORT).show()
}
is MapUiState.PoiReady -> {
loadingProgressBar.hide()
state.userPoi?.let {
userMarker = addPoiToMapMarker(it, map)
}
state.poiList?.let {
it.forEach { poi ->
if(poi.title == MOUNT_DOOM){
geofenceManager.createGeofence(poi, radiusMeter = 10000.0f, requestId = GEOFENCE_ID_MORDOR)
}
addPoiToMapMarker(poi, map)
}
}
return
}
}
}
private fun handleLocationData(locationData: LocationData) {
if (handleLocationException(locationData.exception)) {
return
}
//If we get the location
locationData.location?.let {
val latLng = LatLng(it.latitude, it.longitude)
userMarker?.let {
it.position = latLng
} ?: run {
if(firstLocation && ::map.isInitialized) {
map.moveCamera(CameraUpdateFactory
.newLatLngZoom(latLng, MAP_DEFAULT_ZOOM))
firstLocation = false
viewModel.loadPois(it.latitude, it.longitude)
}
}
}
//Timber.i("Last location from LIVE DATA${locationData.location}")
}
/**
* Will handle all exceptions coming from our LocationLiveData
*/
private fun handleLocationException(exception: Exception?): Boolean {
exception ?: return false
Timber.e(exception, "HandleLocationException()")
when (exception) {
is SecurityException -> checkLocationPermission(
REQUEST_PERMISSION_LOCATION_START_UPDATE
)
is ResolvableApiException -> exception.startResolutionForResult(
this,
REQUEST_CHECK_FOR_SETTINGS
)
}
return true
}
/**
* Ask for permissions when needed location
* As noted that in Android 10, you need to also ask for ACCESS_BACKGROUND_LOCATION
* See https://www.youtube.com/watch?v=L7zwfTwrDEs
*/
private fun checkLocationPermission(requestCode: Int): Boolean {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
requestCode
)
return false
}
return true
}
}
package com.pickle.theendormap.map
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.pickle.theendormap.poi.Poi
import com.pickle.theendormap.poi.generatePois
import com.pickle.theendormap.poi.generateUserPoi
import timber.log.Timber
sealed class MapUiState {
object Loading: MapUiState()
data class Error(val errorMessage: String) : MapUiState()
data class PoiReady(
val userPoi: Poi? = null,
val poiList: List<Poi>? = null
) : MapUiState()
}
class MapViewModel : ViewModel() {
private val uiState = MutableLiveData<MapUiState>()
fun getUiState(): LiveData<MapUiState> = uiState
fun loadPois(latitude: Double, longitude: Double){
Timber.i("LoadPoiList()")
if(!(latitude in -90.0..90.0 && longitude in -180.0..180.0)){
uiState.value =
MapUiState.Error("Invalid GPS coordinate received: lat=$latitude, long=$longitude")
return
}
//State if we load via HTTP and it can be long
uiState.value = MapUiState.Loading
//When ready, assign the value
uiState.value = MapUiState.PoiReady(
userPoi = generateUserPoi(
latitude,
longitude
),
poiList = generatePois(
latitude,
longitude
)
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment