diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditItem.kt b/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditItem.kt index 17c32babf67..85c5c6e42e9 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditItem.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditItem.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.data.edithistory import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.edits.ElementEdit import de.westnordost.streetcomplete.data.osm.edits.delete.DeletePoiNodeAction +import de.westnordost.streetcomplete.data.osm.edits.move.MoveNodeAction import de.westnordost.streetcomplete.data.osm.edits.split_way.SplitWayAction import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestHidden import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEdit @@ -28,6 +29,7 @@ val Edit.overlayIcon: Int get() = when (this) { when (action) { is DeletePoiNodeAction -> R.drawable.ic_undo_delete is SplitWayAction -> R.drawable.ic_undo_split + is MoveNodeAction -> R.drawable.ic_undo_move_node else -> 0 } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt index 78151cf4af4..066958f9f09 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt @@ -19,6 +19,8 @@ import de.westnordost.streetcomplete.data.osm.edits.create.CreateNodeAction import de.westnordost.streetcomplete.data.osm.edits.create.RevertCreateNodeAction import de.westnordost.streetcomplete.data.osm.edits.delete.DeletePoiNodeAction import de.westnordost.streetcomplete.data.osm.edits.delete.RevertDeletePoiNodeAction +import de.westnordost.streetcomplete.data.osm.edits.move.MoveNodeAction +import de.westnordost.streetcomplete.data.osm.edits.move.RevertMoveNodeAction import de.westnordost.streetcomplete.data.osm.edits.split_way.SplitWayAction import de.westnordost.streetcomplete.data.osm.edits.update_tags.RevertUpdateElementTagsAction import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTagsAction @@ -48,6 +50,8 @@ class ElementEditsDao( subclass(RevertDeletePoiNodeAction::class) subclass(CreateNodeAction::class) subclass(RevertCreateNodeAction::class) + subclass(MoveNodeAction::class) + subclass(RevertMoveNodeAction::class) } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSource.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSource.kt index 8e6f803918d..7cc88edbd0e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSource.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSource.kt @@ -1,5 +1,7 @@ package de.westnordost.streetcomplete.data.osm.edits +import de.westnordost.streetcomplete.data.osm.edits.move.MoveNodeAction +import de.westnordost.streetcomplete.data.osm.edits.move.RevertMoveNodeAction import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometryCreator import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometryEntry @@ -81,7 +83,7 @@ class MapDataWithEditsSource internal constructor( rebuildLocalChanges() - /* nothingChanged can be false at this point when e.g. there are two edits on the + /* nothingChanged can be false at this point when e.g. there are two edits on the same element, and onUpdated is called after the first edit is uploaded. */ val nothingChanged = deletedIsUnchanged && elementsThatMightHaveChangedByKey.all { val updatedElement = get(it.first.type, it.first.id) @@ -184,6 +186,15 @@ class MapDataWithEditsSource internal constructor( // element that got edited by the deleted edit not found? Hmm, okay then (not sure if this can happen at all) elementsToDelete.add(ElementKey(edit.elementType, edit.elementId)) } + + if (edit.action is MoveNodeAction || edit.action is RevertMoveNodeAction) { + val waysContainingNode = getWaysForNode(edit.elementId) + val affectedRelations = getRelationsForNode(edit.elementId) + waysContainingNode.flatMap { getRelationsForWay(it.id) } + for (elem in waysContainingNode + affectedRelations) { + mapData.put(elem, getGeometry(elem.type, elem.id)) + } + } + } } @@ -427,7 +438,21 @@ class MapDataWithEditsSource internal constructor( updatedElements[key] = element updatedGeometries[key] = createGeometry(element) } - return MapDataUpdates(updated = updates, deleted = deletedKeys) + + // get affected ways and relations and update geometries if a node was moved + val elementsWithChangedGeometry = mutableListOf() + if (edit.action is MoveNodeAction || edit.action is RevertMoveNodeAction) { + val waysContainingNode = getWaysForNode(edit.elementId) + val affectedRelations = getRelationsForNode(edit.elementId) + waysContainingNode.flatMap { getRelationsForWay(it.id) } + for (element in waysContainingNode + affectedRelations) { + val key = ElementKey(element.type, element.id) + deletedElements.remove(key) + updatedElements[key] = element + updatedGeometries[key] = createGeometry(element) + elementsWithChangedGeometry.add(element) // questController needs to know that element was affected (though it was not updated in OSM sense) + } + } + return MapDataUpdates(updated = updates + elementsWithChangedGeometry, deleted = deletedKeys) } private fun createGeometry(element: Element): ElementGeometry? { diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/move/MoveNodeAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/move/MoveNodeAction.kt new file mode 100644 index 00000000000..bd1b52a7e89 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/move/MoveNodeAction.kt @@ -0,0 +1,40 @@ +package de.westnordost.streetcomplete.data.osm.edits.move + +import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction +import de.westnordost.streetcomplete.data.osm.edits.ElementIdProvider +import de.westnordost.streetcomplete.data.osm.edits.IsActionRevertable +import de.westnordost.streetcomplete.data.osm.edits.NewElementsCount +import de.westnordost.streetcomplete.data.osm.edits.update_tags.isGeometrySubstantiallyDifferent +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository +import de.westnordost.streetcomplete.data.osm.mapdata.Node +import de.westnordost.streetcomplete.data.upload.ConflictException +import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds +import kotlinx.serialization.Serializable + +/** Action that moves a node. */ +@Serializable +data class MoveNodeAction(val position: LatLon) : ElementEditAction, IsActionRevertable { + + override val newElementsCount get() = NewElementsCount(0, 0, 0) + + override fun createUpdates( + originalElement: Element, + element: Element?, + mapDataRepository: MapDataRepository, + idProvider: ElementIdProvider + ): MapDataChanges { + val node = element as? Node ?: throw ConflictException("Element deleted") + if (isGeometrySubstantiallyDifferent(originalElement, element)) { + throw ConflictException("Element geometry changed substantially") + } + return MapDataChanges(modifications = listOf(node.copy( + position = position, + timestampEdited = nowAsEpochMilliseconds() + ))) + } + + override fun createReverted(): ElementEditAction = RevertMoveNodeAction +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/move/RevertMoveNodeAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/move/RevertMoveNodeAction.kt new file mode 100644 index 00000000000..c96e5b082f0 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/move/RevertMoveNodeAction.kt @@ -0,0 +1,31 @@ +package de.westnordost.streetcomplete.data.osm.edits.move + +import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction +import de.westnordost.streetcomplete.data.osm.edits.ElementIdProvider +import de.westnordost.streetcomplete.data.osm.edits.IsRevertAction +import de.westnordost.streetcomplete.data.osm.edits.update_tags.isGeometrySubstantiallyDifferent +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository +import de.westnordost.streetcomplete.data.osm.mapdata.Node +import de.westnordost.streetcomplete.data.upload.ConflictException +import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds +import kotlinx.serialization.Serializable + +/** Action reverts moving a node. */ +@Serializable +object RevertMoveNodeAction : ElementEditAction, IsRevertAction { + + override fun createUpdates( + originalElement: Element, + element: Element?, + mapDataRepository: MapDataRepository, + idProvider: ElementIdProvider + ): MapDataChanges { + val node = element as? Node ?: throw ConflictException("Element deleted") + return MapDataChanges(modifications = listOf(node.copy( + position = (originalElement as Node).position, + timestampEdited = nowAsEpochMilliseconds() + ))) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/overlays/AbstractOverlayForm.kt b/app/src/main/java/de/westnordost/streetcomplete/overlays/AbstractOverlayForm.kt index ea1ce61e3ee..6d76f26063d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/overlays/AbstractOverlayForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/overlays/AbstractOverlayForm.kt @@ -28,6 +28,7 @@ import de.westnordost.streetcomplete.data.osm.edits.AddElementEditsController import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction import de.westnordost.streetcomplete.data.osm.edits.ElementEditType import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController +import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry import de.westnordost.streetcomplete.data.osm.geometry.ElementPolylinesGeometry @@ -73,6 +74,7 @@ abstract class AbstractOverlayForm : private val countryInfos: CountryInfos by inject() private val countryBoundaries: FutureTask by inject(named("CountryBoundariesFuture")) private val overlayRegistry: OverlayRegistry by inject() + private val mapDataWithEditsSource: MapDataWithEditsSource by inject() private val featureDictionaryFuture: FutureTask by inject(named("FeatureDictionaryFuture")) protected val featureDictionary: FeatureDictionary get() = featureDictionaryFuture.get() private var _countryInfo: CountryInfo? = null // lazy but resettable because based on lateinit var @@ -141,6 +143,9 @@ abstract class AbstractOverlayForm : /** Called when the user chose to split the way */ fun onSplitWay(editType: ElementEditType, way: Way, geometry: ElementPolylinesGeometry) + /** Called when the user chose to move the node */ + fun onMoveNode(editType: ElementEditType, node: Node) + fun getMapPositionAt(screenPos: Point): LatLon? } private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener @@ -347,6 +352,13 @@ abstract class AbstractOverlayForm : if (element.isSplittable()) { answers.add(AnswerItem(R.string.split_way) { splitWay(element) }) } + + if (element is Node // add moveNodeAnswer only if it's a free floating node + && mapDataWithEditsSource.getWaysForNode(element.id).isEmpty() + && mapDataWithEditsSource.getRelationsForNode(element.id).isEmpty()) { + answers.add(AnswerItem(R.string.move_node) { moveNode() }) + } + } answers.addAll(otherAnswers) @@ -357,6 +369,10 @@ abstract class AbstractOverlayForm : listener?.onSplitWay(overlay, element as Way, geometry as ElementPolylinesGeometry) } + private fun moveNode() { + listener?.onMoveNode(overlay, element as Node) + } + protected fun composeNote(element: Element) { val overlayTitle = englishResources.getString(overlay.title) val leaveNoteContext = "In context of \"$overlayTitle\" overlay" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt index b2d47e661ac..5777b38b0b9 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt @@ -17,6 +17,7 @@ import de.westnordost.streetcomplete.data.osm.edits.AddElementEditsController import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction import de.westnordost.streetcomplete.data.osm.edits.ElementEditType import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController +import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource import de.westnordost.streetcomplete.data.osm.edits.delete.DeletePoiNodeAction import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChanges import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChangesBuilder @@ -25,6 +26,7 @@ import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.geometry.ElementPolylinesGeometry import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.ElementType +import de.westnordost.streetcomplete.data.osm.mapdata.Node import de.westnordost.streetcomplete.data.osm.mapdata.Way import de.westnordost.streetcomplete.data.osm.osmquests.HideOsmQuestController import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType @@ -59,6 +61,7 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta private val noteEditsController: NoteEditsController by inject() private val osmQuestController: OsmQuestController by inject() private val featureDictionaryFuture: FutureTask by inject(named("FeatureDictionaryFuture")) + private val mapDataWithEditsSource: MapDataWithEditsSource by inject() protected val featureDictionary: FeatureDictionary get() = featureDictionaryFuture.get() @@ -95,6 +98,9 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta /** Called when the user chose to split the way */ fun onSplitWay(editType: ElementEditType, way: Way, geometry: ElementPolylinesGeometry) + /** Called when the user chose to move the node */ + fun onMoveNode(editType: ElementEditType, node: Node) + /** Called when the user chose to hide the quest instead */ fun onQuestHidden(osmQuestKey: OsmQuestKey) } @@ -139,6 +145,12 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta } createDeleteOrReplaceElementAnswer()?.let { answers.add(it) } + if (element is Node // add moveNodeAnswer only if it's a free floating node + && mapDataWithEditsSource.getWaysForNode(element.id).isEmpty() + && mapDataWithEditsSource.getRelationsForNode(element.id).isEmpty()) { + answers.add(AnswerItem(R.string.move_node) { onClickMoveNodeAnswer() }) + } + answers.addAll(otherAnswers) return answers } @@ -195,6 +207,17 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta } } + private fun onClickMoveNodeAnswer() { + context?.let { AlertDialog.Builder(it) + .setMessage(R.string.quest_move_node_message) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + listener?.onMoveNode(osmElementQuestType, element as Node) + } + .show() + } + } + protected fun applyAnswer(answer: T) { viewLifecycleScope.launch { solve(UpdateElementTagsAction(createQuestChanges(answer))) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt index 5ad9304e598..f5fcab641b6 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt @@ -50,6 +50,7 @@ import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Node import de.westnordost.streetcomplete.data.osm.mapdata.Way import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuest import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestHidden @@ -68,6 +69,7 @@ import de.westnordost.streetcomplete.osm.level.createLevelsOrNull import de.westnordost.streetcomplete.osm.level.levelsIntersect import de.westnordost.streetcomplete.overlays.AbstractOverlayForm import de.westnordost.streetcomplete.overlays.IsShowingElement +import de.westnordost.streetcomplete.overlays.Overlay import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm import de.westnordost.streetcomplete.quests.AbstractQuestForm import de.westnordost.streetcomplete.quests.IsShowingQuestDetails @@ -77,6 +79,8 @@ import de.westnordost.streetcomplete.screens.HandlesOnBackPressed import de.westnordost.streetcomplete.screens.main.bottom_sheet.CreateNoteFragment import de.westnordost.streetcomplete.screens.main.bottom_sheet.IsCloseableBottomSheet import de.westnordost.streetcomplete.screens.main.bottom_sheet.IsMapOrientationAware +import de.westnordost.streetcomplete.screens.main.bottom_sheet.IsMapPositionAware +import de.westnordost.streetcomplete.screens.main.bottom_sheet.MoveNodeFragment import de.westnordost.streetcomplete.screens.main.bottom_sheet.SplitWayFragment import de.westnordost.streetcomplete.screens.main.controls.LocationStateButton import de.westnordost.streetcomplete.screens.main.controls.MainMenuButtonFragment @@ -152,6 +156,7 @@ class MainFragment : NoteDiscussionForm.Listener, LeaveNoteInsteadFragment.Listener, CreateNoteFragment.Listener, + MoveNodeFragment.Listener, EditHistoryFragment.Listener, MainMenuButtonFragment.Listener, UndoButtonFragment.Listener, @@ -328,6 +333,7 @@ class MainFragment : val f = bottomSheetFragment if (f is IsMapOrientationAware) f.onMapOrientation(rotation, tilt) + if (f is IsMapPositionAware) f.onMapMoved(position) } override fun onPanBegin() { @@ -472,6 +478,26 @@ class MainFragment : closeBottomSheet() } + /* ------------------------------- MoveNodeFragment.Listener -------------------------------- */ + + override fun onMoveNode(editType: ElementEditType, node: Node) { + val mapFragment = mapFragment ?: return + showInBottomSheet(MoveNodeFragment.create(editType, node), clearPreviousHighlighting = false) + mapFragment.clearSelectedPins() + mapFragment.hideNonHighlightedPins() + if (editType !is Overlay) + mapFragment.hideOverlay() + + mapFragment.show3DBuildings = false + val offsetPos = mapFragment.getPositionThatCentersPosition(node.position, RectF()) + mapFragment.updateCameraPosition { position = offsetPos } + } + + override fun onMovedNode(editType: ElementEditType, position: LatLon) { + showQuestSolvedAnimation(editType.icon, position) + closeBottomSheet() + } + /* ------------------------------- ShowsPointMarkers -------------------------------- */ override fun putMarkerForCurrentHighlighting( diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/IsMapPositionAware.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/IsMapPositionAware.kt new file mode 100644 index 00000000000..42008f35b7e --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/IsMapPositionAware.kt @@ -0,0 +1,7 @@ +package de.westnordost.streetcomplete.screens.main.bottom_sheet + +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon + +interface IsMapPositionAware { + fun onMapMoved(position: LatLon) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/MoveNodeFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/MoveNodeFragment.kt new file mode 100644 index 00000000000..c50e447efee --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/MoveNodeFragment.kt @@ -0,0 +1,187 @@ +package de.westnordost.streetcomplete.screens.main.bottom_sheet + +import android.graphics.Point +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.UiThread +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import de.westnordost.countryboundaries.CountryBoundaries +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.meta.CountryInfos +import de.westnordost.streetcomplete.data.meta.LengthUnit +import de.westnordost.streetcomplete.data.meta.getByLocation +import de.westnordost.streetcomplete.data.osm.edits.ElementEditType +import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController +import de.westnordost.streetcomplete.data.osm.edits.move.MoveNodeAction +import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osm.mapdata.Node +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.data.overlays.OverlayRegistry +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry +import de.westnordost.streetcomplete.databinding.FragmentMoveNodeBinding +import de.westnordost.streetcomplete.overlays.IsShowingElement +import de.westnordost.streetcomplete.screens.measure.MeasureDisplayUnit +import de.westnordost.streetcomplete.screens.measure.MeasureDisplayUnitFeetInch +import de.westnordost.streetcomplete.screens.measure.MeasureDisplayUnitMeter +import de.westnordost.streetcomplete.util.ktx.getLocationInWindow +import de.westnordost.streetcomplete.util.ktx.popIn +import de.westnordost.streetcomplete.util.ktx.popOut +import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope +import de.westnordost.streetcomplete.util.math.distanceTo +import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.koin.android.ext.android.inject +import org.koin.core.qualifier.named +import java.util.concurrent.FutureTask + +/** Fragment that lets the user move an OSM node */ +class MoveNodeFragment : + Fragment(R.layout.fragment_create_note), IsCloseableBottomSheet, IsShowingElement, IsMapPositionAware { + + private var _binding: FragmentMoveNodeBinding? = null + private val binding: FragmentMoveNodeBinding get() = _binding!! + private val okButton get() = binding.okButton + private val okButtonContainer get() = binding.okButtonContainer + + private val elementEditsController: ElementEditsController by inject() + private val questTypeRegistry: QuestTypeRegistry by inject() + private val overlayRegistry: OverlayRegistry by inject() + private val countryBoundaries: FutureTask by inject(named("CountryBoundariesFuture")) + private val countryInfos: CountryInfos by inject() + + override val elementKey: ElementKey by lazy { ElementKey(node.type, node.id) } + + private lateinit var node: Node + private lateinit var editType: ElementEditType + private lateinit var displayUnit: MeasureDisplayUnit + + private val hasChanges get() = getPosition() != node.position + + interface Listener { + fun getMapPositionAt(screenPos: Point): LatLon? + + fun onMovedNode(editType: ElementEditType, position: LatLon) + } + private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val args = requireArguments() + node = Json.decodeFromString(args.getString(ARG_NODE)!!) + editType = questTypeRegistry.getByName(args.getString(ARG_QUEST_TYPE)!!) as? OsmElementQuestType<*> + ?: overlayRegistry.getByName(args.getString(ARG_QUEST_TYPE)!!)!! + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentMoveNodeBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + okButton.setOnClickListener { onClickOk() } + binding.cancelButton.setOnClickListener { activity?.onBackPressed() } + val countryInfo = countryInfos.getByLocation(countryBoundaries.get(), node.position.longitude, node.position.latitude) + displayUnit = if (countryInfo.lengthUnits.firstOrNull() == LengthUnit.FOOT_AND_INCH) + MeasureDisplayUnitFeetInch(1) + else + MeasureDisplayUnitMeter(2) + binding.createNoteIconView.setImageResource(editType.icon) + } + + private fun getPosition(): LatLon? { + val createNoteMarker = binding.createNoteMarker + val screenPos = createNoteMarker.getLocationInWindow() + screenPos.offset(createNoteMarker.width / 2, createNoteMarker.height / 2) + return listener?.getMapPositionAt(screenPos) + } + + private fun onClickOk() { + val pos = getPosition() ?: return + if (!checkIsDistanceOkAndUpdateText(pos)) return + viewLifecycleScope.launch { + val action = MoveNodeAction(pos) + elementEditsController.add(editType, node, ElementPointGeometry(node.position), "survey", action) + listener?.onMovedNode(editType, pos) + } + } + + override fun onClickMapAt(position: LatLon, clickAreaSizeInMeters: Double) = false + + @UiThread override fun onMapMoved(position: LatLon) { + if (checkIsDistanceOkAndUpdateText(position)) + okButtonContainer.popIn() + else + okButtonContainer.popOut() + } + + private fun checkIsDistanceOkAndUpdateText(position: LatLon): Boolean { + val moveDistance = position.distanceTo(node.position) + return when { + moveDistance < MIN_MOVE_DISTANCE -> { + binding.titleLabel.setText(R.string.node_moved_not_far_enough) + false + } + moveDistance > MAX_MOVE_DISTANCE -> { + binding.titleLabel.setText(R.string.node_moved_too_far) + false + } + else -> { + binding.titleLabel.text = resources.getString(R.string.node_moved, displayUnit.format(moveDistance.toFloat())) + true + } + } + } + + @UiThread override fun onClickClose(onConfirmed: () -> Unit) { + if (!hasChanges) { + onConfirmed() + } else { + activity?.let { + AlertDialog.Builder(it) + .setMessage(R.string.confirmation_discard_title) + .setPositiveButton(R.string.confirmation_discard_positive) { _, _ -> + onConfirmed() + } + .setNegativeButton(R.string.short_no_answer_on_button, null) + .show() + } + } + } + + companion object { + private const val ARG_NODE = "node" + private const val ARG_QUEST_TYPE = "quest_type" + + fun create(elementEditType: ElementEditType, node: Node): MoveNodeFragment { + val f = MoveNodeFragment() + f.arguments = bundleOf( + ARG_NODE to Json.encodeToString(node), + ARG_QUEST_TYPE to elementEditType.name + ) + return f + } + } +} + +// Require a minimum distance because: +// 1. The map is not perfectly precise, especially displayed road widths may be off by a few meters, +// so it may be hard to tell whether something really is misplaced (e.g. bench along a path) +// without good aerial imagery. +// 2. The value added by moving nodes by such small distance, even if correct, is rather low. +// 3. The position imprecision is already about 1.5 m because it is not really possible for the user +// to ascertain exactly what is the center of the icon, which is ca 3x3 m at maximum zoom. +private const val MIN_MOVE_DISTANCE = 2.0 +// Move node functionality is meant for fixing slightly misplaced elements. If something moved far +// away, it is reasonable to assume there are more substantial changes required, also to nearby +// elements. Additionally, the default radius for highlighted elements is 30 m, so moving outside +// should not be allowed. +private const val MAX_MOVE_DISTANCE = 30.0 diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/UndoDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/UndoDialog.kt index a332b36840f..6428ee798cc 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/UndoDialog.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/UndoDialog.kt @@ -20,6 +20,7 @@ import de.westnordost.streetcomplete.data.osm.edits.ElementEdit import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource import de.westnordost.streetcomplete.data.osm.edits.create.CreateNodeAction import de.westnordost.streetcomplete.data.osm.edits.delete.DeletePoiNodeAction +import de.westnordost.streetcomplete.data.osm.edits.move.MoveNodeAction import de.westnordost.streetcomplete.data.osm.edits.split_way.SplitWayAction import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryAdd import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryChange @@ -118,6 +119,7 @@ class UndoDialog( is DeletePoiNodeAction -> createTextView(ResText(R.string.deleted_poi_action_description)) is SplitWayAction -> createTextView(ResText(R.string.split_way_action_description)) is CreateNodeAction -> createCreateNodeDescriptionView(action.position, action.tags) + is MoveNodeAction -> createTextView(ResText(R.string.move_node_action_description)) else -> throw IllegalArgumentException() } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt index 90f63c1c245..2ebd949cb41 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt @@ -247,6 +247,10 @@ class MainMapFragment : LocationAwareMapFragment(), ShowsGeometryMarkers { geometryMarkersMapComponent?.clear() } + fun clearSelectedPins() { + selectedPinsMapComponent?.clear() + } + /* ---------------------------- Markers for current highlighting --------------------------- */ override fun putMarkerForCurrentHighlighting( diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/debug/ShowQuestFormsActivity.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/debug/ShowQuestFormsActivity.kt index 7622c9236ec..0ac37310532 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/debug/ShowQuestFormsActivity.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/debug/ShowQuestFormsActivity.kt @@ -23,6 +23,7 @@ import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.geometry.ElementPolylinesGeometry import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osm.mapdata.Node import de.westnordost.streetcomplete.data.osm.mapdata.Way import de.westnordost.streetcomplete.data.osm.osmquests.HideOsmQuestController import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType @@ -198,6 +199,11 @@ class ShowQuestFormsActivity : BaseActivity(), AbstractOsmQuestForm.Listener { popQuestForm() } + override fun onMoveNode(editType: ElementEditType, node: Node) { + message("Moving node") + popQuestForm() + } + override fun onQuestHidden(osmQuestKey: OsmQuestKey) { popQuestForm() } diff --git a/app/src/main/res/drawable/ic_undo_move_node.xml b/app/src/main/res/drawable/ic_undo_move_node.xml new file mode 100644 index 00000000000..5df1da99747 --- /dev/null +++ b/app/src/main/res/drawable/ic_undo_move_node.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/app/src/main/res/layout/fragment_move_node.xml b/app/src/main/res/layout/fragment_move_node.xml new file mode 100644 index 00000000000..b27cd87aa5d --- /dev/null +++ b/app/src/main/res/layout/fragment_move_node.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +