diff --git a/gnd/src/main/java/com/google/android/gnd/ui/home/mapcontainer/PolygonDrawingViewModel.java b/gnd/src/main/java/com/google/android/gnd/ui/home/mapcontainer/PolygonDrawingViewModel.java deleted file mode 100644 index 9a9e8f3a81..0000000000 --- a/gnd/src/main/java/com/google/android/gnd/ui/home/mapcontainer/PolygonDrawingViewModel.java +++ /dev/null @@ -1,362 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.gnd.ui.home.mapcontainer; - -import static java8.util.stream.StreamSupport.stream; - -import androidx.lifecycle.LiveData; -import androidx.lifecycle.LiveDataReactiveStreams; -import androidx.lifecycle.MutableLiveData; -import com.google.android.gnd.R; -import com.google.android.gnd.model.AuditInfo; -import com.google.android.gnd.model.Project; -import com.google.android.gnd.model.feature.Point; -import com.google.android.gnd.model.feature.PolygonFeature; -import com.google.android.gnd.model.layer.Layer; -import com.google.android.gnd.persistence.uuid.OfflineUuidGenerator; -import com.google.android.gnd.rx.BooleanOrError; -import com.google.android.gnd.rx.annotations.Hot; -import com.google.android.gnd.system.LocationManager; -import com.google.android.gnd.system.auth.AuthenticationManager; -import com.google.android.gnd.ui.common.AbstractViewModel; -import com.google.android.gnd.ui.common.SharedViewModel; -import com.google.android.gnd.ui.map.MapFeature; -import com.google.android.gnd.ui.map.MapPin; -import com.google.android.gnd.ui.map.MapPolygon; -import com.google.auto.value.AutoValue; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import io.reactivex.BackpressureStrategy; -import io.reactivex.Flowable; -import io.reactivex.Observable; -import io.reactivex.processors.BehaviorProcessor; -import io.reactivex.subjects.PublishSubject; -import io.reactivex.subjects.Subject; -import java.util.ArrayList; -import java.util.List; -import java8.util.Optional; -import javax.annotation.Nullable; -import javax.inject.Inject; -import timber.log.Timber; - -@SharedViewModel -public class PolygonDrawingViewModel extends AbstractViewModel { - - /** Min. distance in dp between two points for them be considered as overlapping. */ - public static final int DISTANCE_THRESHOLD_DP = 24; - - @Hot private final Subject polygonDrawingState = PublishSubject.create(); - - @Hot private final Subject> mapPolygonFlowable = PublishSubject.create(); - - /** Denotes whether the drawn polygon is complete or not. This is different from drawing state. */ - @Hot private final LiveData polygonCompleted; - - /** Features drawn by the user but not yet saved. */ - @Hot private final LiveData> unsavedMapFeatures; - - @Hot(replays = true) - private final MutableLiveData locationLockEnabled = new MutableLiveData<>(); - - private final LiveData iconTint; - @Hot private final Subject locationLockChangeRequests = PublishSubject.create(); - private final LocationManager locationManager; - private final LiveData locationLockState; - private final List vertices = new ArrayList<>(); - - /** The currently selected layer and project for the polygon drawing. */ - private final BehaviorProcessor selectedLayer = BehaviorProcessor.create(); - private final BehaviorProcessor selectedProject = BehaviorProcessor.create(); - - private final OfflineUuidGenerator uuidGenerator; - private final AuthenticationManager authManager; - @Nullable private Point cameraTarget; - - /** - * If true, then it means that the last vertex is added automatically and should be removed before - * adding any permanent vertex. Used for rendering a line between last added point and current - * camera target. - */ - boolean isLastVertexNotSelectedByUser; - - private Optional mapPolygon = Optional.empty(); - - @Inject - PolygonDrawingViewModel( - LocationManager locationManager, - AuthenticationManager authManager, - OfflineUuidGenerator uuidGenerator) { - this.locationManager = locationManager; - this.authManager = authManager; - this.uuidGenerator = uuidGenerator; - // TODO: Create custom ui component for location lock button and share across app. - Flowable locationLockStateFlowable = createLocationLockStateFlowable().share(); - this.locationLockState = - LiveDataReactiveStreams.fromPublisher( - locationLockStateFlowable.startWith(BooleanOrError.falseValue())); - this.iconTint = - LiveDataReactiveStreams.fromPublisher( - locationLockStateFlowable - .map(locked -> locked.isTrue() ? R.color.colorMapBlue : R.color.colorGrey800) - .startWith(R.color.colorGrey800)); - Flowable> polygonFlowable = - mapPolygonFlowable - .startWith(Optional.empty()) - .toFlowable(BackpressureStrategy.LATEST) - .share(); - this.polygonCompleted = - LiveDataReactiveStreams.fromPublisher( - polygonFlowable - .map(polygon -> polygon.map(MapPolygon::isPolygonComplete).orElse(false)) - .startWith(false)); - this.unsavedMapFeatures = - LiveDataReactiveStreams.fromPublisher( - polygonFlowable.map( - polygon -> - polygon - .map(PolygonDrawingViewModel::unsavedFeaturesFromPolygon) - .orElse(ImmutableSet.of()))); - } - - private Flowable createLocationLockStateFlowable() { - return locationLockChangeRequests - .switchMapSingle( - enabled -> - enabled - ? this.locationManager.enableLocationUpdates() - : this.locationManager.disableLocationUpdates()) - .toFlowable(BackpressureStrategy.LATEST); - } - - /** Returns a set of {@link MapFeature} to be drawn on map for the given {@link MapPolygon}. */ - private static ImmutableSet unsavedFeaturesFromPolygon(MapPolygon mapPolygon) { - ImmutableList vertices = mapPolygon.getVertices(); - - if (vertices.isEmpty()) { - // Return if polygon has 0 vertices. - return ImmutableSet.of(); - } - - // Include the given polygon and add 1 MapPin for each of its vertex. - return ImmutableSet.builder() - .add(mapPolygon) - .addAll( - stream(vertices) - .map( - point -> - MapPin.newBuilder() - .setId(mapPolygon.getId()) - .setPosition(point) - // TODO: Use different marker style for unsaved markers. - .setStyle(mapPolygon.getStyle()) - .build()) - .toList()) - .build(); - } - - @Hot - public Observable getDrawingState() { - return polygonDrawingState; - } - - public void onCameraMoved(Point newTarget) { - cameraTarget = newTarget; - if (locationLockState.getValue() != null && isLocationLockEnabled()) { - Timber.d("User dragged map. Disabling location lock"); - locationLockChangeRequests.onNext(false); - } - } - - /** - * Adds another vertex at the given point if {@param distanceInPixels} is more than the configured - * threshold. Otherwise, snaps to the first vertex. - * - * @param newTarget Position of the map camera. - * @param distanceInPixels Distance between the last vertex and {@param newTarget}. - */ - public void updateLastVertex(Point newTarget, double distanceInPixels) { - boolean isPolygonComplete = vertices.size() > 2 && distanceInPixels <= DISTANCE_THRESHOLD_DP; - addVertex(isPolygonComplete ? vertices.get(0) : newTarget, true); - } - - /** Attempts to remove the last vertex of drawn polygon, if any. */ - public void removeLastVertex() { - if (vertices.isEmpty()) { - polygonDrawingState.onNext(PolygonDrawingState.canceled()); - reset(); - } else { - vertices.remove(vertices.size() - 1); - updateVertices(ImmutableList.copyOf(vertices)); - } - } - - public void selectCurrentVertex() { - if (cameraTarget != null) { - addVertex(cameraTarget, false); - } - } - - public void setLocationLockEnabled(boolean enabled) { - locationLockEnabled.postValue(enabled); - } - - /** - * Adds a new vertex. - * - * @param vertex new position - * @param isNotSelectedByUser whether the vertex is not selected by the user - */ - private void addVertex(Point vertex, boolean isNotSelectedByUser) { - // Clear last vertex if it is unselected - if (isLastVertexNotSelectedByUser && !vertices.isEmpty()) { - vertices.remove(vertices.size() - 1); - } - - // Update selected state - isLastVertexNotSelectedByUser = isNotSelectedByUser; - - // Add the new vertex - vertices.add(vertex); - - // Render changes to UI - updateVertices(ImmutableList.copyOf(vertices)); - } - - private void updateVertices(ImmutableList newVertices) { - mapPolygon = mapPolygon.map(polygon -> polygon.toBuilder().setVertices(newVertices).build()); - mapPolygonFlowable.onNext(mapPolygon); - } - - public void onCompletePolygonButtonClick() { - if (selectedLayer.getValue() == null || selectedProject.getValue() == null) { - throw new IllegalStateException("Project or layer is null"); - } - MapPolygon polygon = mapPolygon.get(); - if (!polygon.isPolygonComplete()) { - throw new IllegalStateException("Polygon is not complete"); - } - AuditInfo auditInfo = AuditInfo.now(authManager.getCurrentUser()); - PolygonFeature polygonFeature = - PolygonFeature.builder() - .setId(polygon.getId()) - .setVertices(polygon.getVertices()) - .setProject(selectedProject.getValue()) - .setLayer(selectedLayer.getValue()) - .setCreated(auditInfo) - .setLastModified(auditInfo) - .build(); - polygonDrawingState.onNext(PolygonDrawingState.completed(polygonFeature)); - reset(); - } - - private void reset() { - isLastVertexNotSelectedByUser = false; - vertices.clear(); - mapPolygon = Optional.empty(); - mapPolygonFlowable.onNext(Optional.empty()); - } - - Optional getFirstVertex() { - return mapPolygon.map(MapPolygon::getFirstVertex); - } - - public void onLocationLockClick() { - locationLockChangeRequests.onNext(!isLocationLockEnabled()); - } - - public LiveData isPolygonCompleted() { - return polygonCompleted; - } - - private boolean isLocationLockEnabled() { - return locationLockState.getValue().isTrue(); - } - - public LiveData getLocationLockEnabled() { - // TODO : current location is not working value is always false. - return locationLockEnabled; - } - - public void startDrawingFlow(Project selectedProject, Layer selectedLayer) { - this.selectedLayer.onNext(selectedLayer); - this.selectedProject.onNext(selectedProject); - polygonDrawingState.onNext(PolygonDrawingState.inProgress()); - - mapPolygon = - Optional.of( - MapPolygon.newBuilder() - .setId(uuidGenerator.generateUuid()) - .setVertices(ImmutableList.of()) - .setStyle(selectedLayer.getDefaultStyle()) - .build()); - } - - public LiveData getIconTint() { - return iconTint; - } - - public LiveData> getUnsavedMapFeatures() { - return unsavedMapFeatures; - } - - @AutoValue - public abstract static class PolygonDrawingState { - - public static PolygonDrawingState canceled() { - return createDrawingState(State.CANCELED, null); - } - - public static PolygonDrawingState inProgress() { - return createDrawingState(State.IN_PROGRESS, null); - } - - public static PolygonDrawingState completed(PolygonFeature unsavedFeature) { - return createDrawingState(State.COMPLETED, unsavedFeature); - } - - private static PolygonDrawingState createDrawingState( - State state, @Nullable PolygonFeature unsavedFeature) { - return new AutoValue_PolygonDrawingViewModel_PolygonDrawingState(state, unsavedFeature); - } - - public boolean isCanceled() { - return getState() == State.CANCELED; - } - - public boolean isInProgress() { - return getState() == State.IN_PROGRESS; - } - - public boolean isCompleted() { - return getState() == State.COMPLETED; - } - - /** Represents state of PolygonDrawing action. */ - public enum State { - IN_PROGRESS, - COMPLETED, - CANCELED - } - - /** Current state of polygon drawing. */ - public abstract State getState(); - - /** Final polygon feature. */ - @Nullable - public abstract PolygonFeature getUnsavedPolygonFeature(); - } -} diff --git a/gnd/src/main/java/com/google/android/gnd/ui/home/mapcontainer/PolygonDrawingViewModel.kt b/gnd/src/main/java/com/google/android/gnd/ui/home/mapcontainer/PolygonDrawingViewModel.kt new file mode 100644 index 0000000000..26d0fc1caa --- /dev/null +++ b/gnd/src/main/java/com/google/android/gnd/ui/home/mapcontainer/PolygonDrawingViewModel.kt @@ -0,0 +1,314 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.gnd.ui.home.mapcontainer + +import androidx.lifecycle.LiveData +import com.google.android.gnd.rx.BooleanOrError.Companion.falseValue +import com.google.android.gnd.ui.common.SharedViewModel +import javax.inject.Inject +import com.google.android.gnd.system.auth.AuthenticationManager +import com.google.android.gnd.persistence.uuid.OfflineUuidGenerator +import com.google.android.gnd.ui.common.AbstractViewModel +import com.google.android.gnd.rx.annotations.Hot +import io.reactivex.subjects.PublishSubject +import com.google.android.gnd.ui.map.MapPolygon +import com.google.android.gnd.ui.map.MapFeature +import com.google.android.gnd.rx.BooleanOrError +import io.reactivex.processors.BehaviorProcessor +import com.google.android.gnd.model.Project +import io.reactivex.Flowable +import io.reactivex.BackpressureStrategy +import timber.log.Timber +import com.google.android.gnd.model.AuditInfo +import com.google.android.gnd.model.feature.PolygonFeature +import com.google.android.gnd.ui.map.MapPin +import androidx.lifecycle.LiveDataReactiveStreams +import androidx.lifecycle.MutableLiveData +import com.google.android.gnd.R +import com.google.android.gnd.model.feature.Point +import com.google.android.gnd.model.layer.Layer +import com.google.android.gnd.system.LocationManager +import com.google.auto.value.AutoValue +import com.google.common.collect.ImmutableList +import com.google.common.collect.ImmutableSet +import io.reactivex.Observable +import io.reactivex.subjects.Subject +import java8.util.Optional +import java.util.ArrayList + +@SharedViewModel +class PolygonDrawingViewModel @Inject internal constructor( + private val locationManager: LocationManager, + private val authManager: AuthenticationManager, + private val uuidGenerator: OfflineUuidGenerator +) : AbstractViewModel() { + private val polygonDrawingState: @Hot Subject = PublishSubject.create() + private val mapPolygonFlowable: @Hot Subject> = PublishSubject.create() + + /** Denotes whether the drawn polygon is complete or not. This is different from drawing state. */ + val isPolygonCompleted: @Hot LiveData + + /** Features drawn by the user but not yet saved. */ + val unsavedMapFeatures: @Hot LiveData> + + private val locationLockEnabled: @Hot(replays = true) MutableLiveData = + MutableLiveData() + + val iconTint: LiveData + private val locationLockChangeRequests: @Hot Subject = PublishSubject.create() + private val locationLockState: LiveData + private val vertices: MutableList = ArrayList() + + /** The currently selected layer and project for the polygon drawing. */ + private val selectedLayer = BehaviorProcessor.create() + private val selectedProject = BehaviorProcessor.create() + private var cameraTarget: Point? = null + + /** + * If true, then it means that the last vertex is added automatically and should be removed before + * adding any permanent vertex. Used for rendering a line between last added point and current + * camera target. + */ + private var isLastVertexNotSelectedByUser = false + + private var mapPolygon = Optional.empty() + + private fun createLocationLockStateFlowable(): Flowable = + locationLockChangeRequests + .switchMapSingle { enabled -> if (enabled) locationManager.enableLocationUpdates() else locationManager.disableLocationUpdates() } + .toFlowable(BackpressureStrategy.LATEST) + + val drawingState: @Hot Observable + get() = polygonDrawingState + + fun onCameraMoved(newTarget: Point) { + cameraTarget = newTarget + if (locationLockState.value != null && isLocationLockEnabled()) { + Timber.d("User dragged map. Disabling location lock") + locationLockChangeRequests.onNext(false) + } + } + + /** + * Adds another vertex at the given point if {@param distanceInPixels} is more than the configured + * threshold. Otherwise, snaps to the first vertex. + * + * @param newTarget Position of the map camera. + * @param distanceInPixels Distance between the last vertex and {@param newTarget}. + */ + fun updateLastVertex(newTarget: Point, distanceInPixels: Double) { + val isPolygonComplete = vertices.size > 2 && distanceInPixels <= DISTANCE_THRESHOLD_DP + addVertex((if (isPolygonComplete) vertices[0] else newTarget), true) + } + + /** Attempts to remove the last vertex of drawn polygon, if any. */ + fun removeLastVertex() { + if (vertices.isEmpty()) { + polygonDrawingState.onNext(PolygonDrawingState.canceled()) + reset() + } else { + vertices.removeAt(vertices.size - 1) + updateVertices(ImmutableList.copyOf(vertices)) + } + } + + fun selectCurrentVertex() = + cameraTarget?.let { + addVertex(it, false) + } + + fun setLocationLockEnabled(enabled: Boolean) { + locationLockEnabled.postValue(enabled) + } + + /** + * Adds a new vertex. + * + * @param vertex new position + * @param isNotSelectedByUser whether the vertex is not selected by the user + */ + private fun addVertex(vertex: Point, isNotSelectedByUser: Boolean) { + // Clear last vertex if it is unselected + if (isLastVertexNotSelectedByUser && vertices.isNotEmpty()) { + vertices.removeAt(vertices.size - 1) + } + + // Update selected state + isLastVertexNotSelectedByUser = isNotSelectedByUser + + // Add the new vertex + vertices.add(vertex) + + // Render changes to UI + updateVertices(ImmutableList.copyOf(vertices)) + } + + private fun updateVertices(newVertices: ImmutableList) { + mapPolygon = mapPolygon.map { polygon: MapPolygon -> + polygon.toBuilder().setVertices(newVertices).build() + } + mapPolygonFlowable.onNext(mapPolygon) + } + + fun onCompletePolygonButtonClick() { + check(!(selectedLayer.value == null || selectedProject.value == null)) { "Project or layer is null" } + val polygon = mapPolygon.get() + check(polygon.isPolygonComplete) { "Polygon is not complete" } + val auditInfo = AuditInfo.now(authManager.currentUser) + val polygonFeature = PolygonFeature.builder() + .setId(polygon.id) + .setVertices(polygon.vertices) + .setProject(selectedProject.value!!) + .setLayer(selectedLayer.value!!) + .setCreated(auditInfo) + .setLastModified(auditInfo) + .build() + polygonDrawingState.onNext(PolygonDrawingState.completed(polygonFeature)) + reset() + } + + private fun reset() { + isLastVertexNotSelectedByUser = false + vertices.clear() + mapPolygon = Optional.empty() + mapPolygonFlowable.onNext(Optional.empty()) + } + + val firstVertex: Optional + get() = mapPolygon.map { it.firstVertex } + + fun onLocationLockClick() = + locationLockChangeRequests.onNext(!isLocationLockEnabled()) + + private fun isLocationLockEnabled(): Boolean = locationLockState.value!!.isTrue + + // TODO : current location is not working value is always false. + fun getLocationLockEnabled(): LiveData = locationLockEnabled + + fun startDrawingFlow(selectedProject: Project, selectedLayer: Layer) { + this.selectedLayer.onNext(selectedLayer) + this.selectedProject.onNext(selectedProject) + polygonDrawingState.onNext(PolygonDrawingState.inProgress()) + + mapPolygon = Optional.of( + MapPolygon.newBuilder() + .setId(uuidGenerator.generateUuid()) + .setVertices(ImmutableList.of()) + .setStyle(selectedLayer.defaultStyle) + .build() + ) + } + + @AutoValue + abstract class PolygonDrawingState { + val isCanceled: Boolean + get() = state == State.CANCELED + val isInProgress: Boolean + get() = state == State.IN_PROGRESS + val isCompleted: Boolean + get() = state == State.COMPLETED + + /** Represents state of PolygonDrawing action. */ + enum class State { + IN_PROGRESS, COMPLETED, CANCELED + } + + /** Current state of polygon drawing. */ + abstract val state: State + + /** Final polygon feature. */ + abstract val unsavedPolygonFeature: PolygonFeature? + + companion object { + fun canceled(): PolygonDrawingState { + return createDrawingState(State.CANCELED, null) + } + + fun inProgress(): PolygonDrawingState { + return createDrawingState(State.IN_PROGRESS, null) + } + + fun completed(unsavedFeature: PolygonFeature?): PolygonDrawingState { + return createDrawingState(State.COMPLETED, unsavedFeature) + } + + private fun createDrawingState( + state: State, unsavedFeature: PolygonFeature? + ): PolygonDrawingState { + return AutoValue_PolygonDrawingViewModel_PolygonDrawingState(state, unsavedFeature) + } + } + } + + companion object { + /** Min. distance in dp between two points for them be considered as overlapping. */ + const val DISTANCE_THRESHOLD_DP = 24 + + /** Returns a set of [MapFeature] to be drawn on map for the given [MapPolygon]. */ + private fun unsavedFeaturesFromPolygon(mapPolygon: MapPolygon): ImmutableSet { + val vertices = mapPolygon.vertices + + if (vertices.isEmpty()) { + return ImmutableSet.of() + } + + // Include the given polygon and add 1 MapPin for each of its vertex. + return ImmutableSet.builder() + .add(mapPolygon) + .addAll( + vertices + .map { point -> + MapPin.newBuilder() + .setId(mapPolygon.id) + .setPosition(point) + // TODO: Use different marker style for unsaved markers. + .setStyle(mapPolygon.style) + .build() + } + .toList()) + .build() + } + } + + init { + // TODO: Create custom ui component for location lock button and share across app. + val locationLockStateFlowable = createLocationLockStateFlowable().share() + locationLockState = LiveDataReactiveStreams.fromPublisher( + locationLockStateFlowable.startWith(falseValue()) + ) + iconTint = LiveDataReactiveStreams.fromPublisher( + locationLockStateFlowable + .map { locked -> if (locked.isTrue) R.color.colorMapBlue else R.color.colorGrey800 } + .startWith(R.color.colorGrey800)) + val polygonFlowable = mapPolygonFlowable + .startWith(Optional.empty()) + .toFlowable(BackpressureStrategy.LATEST) + .share() + isPolygonCompleted = LiveDataReactiveStreams.fromPublisher( + polygonFlowable + .map { polygon -> + polygon.map { it.isPolygonComplete } + .orElse(false) + } + .startWith(false)) + unsavedMapFeatures = LiveDataReactiveStreams.fromPublisher( + polygonFlowable.map { polygon -> + polygon + .map { unsavedFeaturesFromPolygon(it) } + .orElse(ImmutableSet.of()) + }) + } +} \ No newline at end of file