Created
July 26, 2021 09:00
-
-
Save Akhu/51c74bf2761c48e1196f3a766e80b906 to your computer and use it in GitHub Desktop.
Sealed Class for UI State sample Kotlin + Android
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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