diff --git a/src/main/java/de/blau/android/DisambiguationMenu.java b/src/main/java/de/blau/android/DisambiguationMenu.java index f7c5701580..4339f3577b 100644 --- a/src/main/java/de/blau/android/DisambiguationMenu.java +++ b/src/main/java/de/blau/android/DisambiguationMenu.java @@ -187,6 +187,19 @@ public void add(int id, @Nullable Type type, @NonNull String text, boolean selec add(new DisambiguationMenuItem(type, s), listener); } + /** + * Add a menu item + * + * @param id an id (unused) + * @param the Type of object + * @param text a descriptive text + * @param listener callback when the menu item is selected + */ + public void add(int id, @Nullable Type type, @NonNull String text, @NonNull final OnMenuItemClickListener listener) { // NOSONAR + SpannableString s = new SpannableString(text); + add(new DisambiguationMenuItem(type, s), listener); + } + /** * Add a menu item * @@ -212,7 +225,7 @@ public void add(int id, @Nullable Type type, @NonNull int textRes, boolean selec public void add(int id, @Nullable Type type, @NonNull SpannableString text, boolean selected, @NonNull final OnMenuItemClickListener listener) { // NOSONAR add(new DisambiguationMenuItem(type, text), listener); } - + /** * Add a menu item * diff --git a/src/main/java/de/blau/android/easyedit/PathCreationActionModeCallback.java b/src/main/java/de/blau/android/easyedit/PathCreationActionModeCallback.java index 2370428549..ba0bf43d50 100644 --- a/src/main/java/de/blau/android/easyedit/PathCreationActionModeCallback.java +++ b/src/main/java/de/blau/android/easyedit/PathCreationActionModeCallback.java @@ -1,6 +1,8 @@ package de.blau.android.easyedit; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; import java.util.List; import android.content.Context; @@ -17,8 +19,10 @@ import androidx.appcompat.view.ActionMode; import androidx.appcompat.widget.AppCompatCheckBox; import de.blau.android.App; +import de.blau.android.DisambiguationMenu; import de.blau.android.Map; import de.blau.android.R; +import de.blau.android.DisambiguationMenu.Type; import de.blau.android.dialogs.AddressInterpolationDialog; import de.blau.android.dialogs.Tip; import de.blau.android.exception.OsmIllegalOperationException; @@ -29,8 +33,9 @@ import de.blau.android.osm.UndoStorage.UndoElement; import de.blau.android.osm.UndoStorage.UndoWay; import de.blau.android.osm.Way; -import de.blau.android.util.SerializableState; +import de.blau.android.util.MathUtil; import de.blau.android.util.ScreenMessage; +import de.blau.android.util.SerializableState; import de.blau.android.util.Sound; import de.blau.android.util.ThemeUtils; import de.blau.android.util.Util; @@ -44,14 +49,18 @@ public class PathCreationActionModeCallback extends BuilderActionModeCallback { protected static final int MENUITEM_UNDO = 1; private static final int MENUITEM_SNAP = 2; private static final int MENUITEM_NEWWAY_PRESET = 3; - private static final int MENUITEM_ADDRESS = 4; - - private static final String NODE_IDS_KEY = "node ids"; - private static final String EXISTING_NODE_IDS_KEY = "existing node ids"; - private static final String WAY_ID_KEY = "way id"; - private static final String TITLE_KEY = "title"; - private static final String SUBTITLE_KEY = "subtitle"; - private static final String CHECKPOINT_NAME_KEY = "checkpoint name"; + private static final int MENUITEM_FOLLOW_WAY = 4; + private static final int MENUITEM_ADDRESS = 5; + + private static final String NODE_IDS_KEY = "node ids"; + private static final String EXISTING_NODE_IDS_KEY = "existing node ids"; + private static final String WAY_ID_KEY = "way id"; + private static final String TITLE_KEY = "title"; + private static final String SUBTITLE_KEY = "subtitle"; + private static final String CHECKPOINT_NAME_KEY = "checkpoint name"; + private static final String CANDIDATES_FOR_FOLLOWING_IDS_KEY = "candidates for following ids"; + private static final String INITIAL_FOLLOW_NODE_ID_KEY = "initial follow node id"; + private static final String WAY_TO_FOLLOW_ID_KEY = "way to follow id"; /** x coordinate of first node */ private float x; @@ -67,11 +76,15 @@ public class PathCreationActionModeCallback extends BuilderActionModeCallback { private boolean snap = true; /** contains a pointer to the created way if one was created. used to fix selection after undo. */ - private Way createdWay = null; + private Way createdWay = null; /** contains a list of added nodes. used to fix selection after undo. */ - protected List addedNodes = new ArrayList<>(); + protected List addedNodes = new ArrayList<>(); /** nodes we added that already existed */ - private List existingNodes = new ArrayList<>(); + private List existingNodes = new ArrayList<>(); + /** ways that we could potentially follow */ + private List candidatesForFollowing = null; + private Node initialFollowNode = null; + private Way wayToFollow = null; private String savedTitle = null; private String savedSubtitle = null; @@ -87,25 +100,9 @@ public class PathCreationActionModeCallback extends BuilderActionModeCallback { */ public PathCreationActionModeCallback(@NonNull EasyEditManager manager, @NonNull SerializableState state) { super(manager); - List ids = state.getList(NODE_IDS_KEY); StorageDelegator delegator = App.getDelegator(); - for (Long id : ids) { - Node node = (Node) delegator.getOsmElement(Node.NAME, id); - if (node != null) { - addedNodes.add(node); - } else { - throw new IllegalStateException("Failed to find node " + id); - } - } - List existingIds = state.getList(EXISTING_NODE_IDS_KEY); - for (Long id : existingIds) { - Node node = (Node) delegator.getOsmElement(Node.NAME, id); - if (node != null) { - existingNodes.add(node); - } else { - throw new IllegalStateException("Failed to find node " + id); - } - } + getElementsFromIds(state, delegator, NODE_IDS_KEY, addedNodes, Node.NAME); + getElementsFromIds(state, delegator, EXISTING_NODE_IDS_KEY, existingNodes, Node.NAME); if (!addedNodes.isEmpty()) { appendTargetNode = addedNodes.get(addedNodes.size() - 1); } @@ -117,6 +114,40 @@ public PathCreationActionModeCallback(@NonNull EasyEditManager manager, @NonNull savedTitle = state.getString(TITLE_KEY); savedSubtitle = state.getString(SUBTITLE_KEY); checkpointName = state.getInteger(CHECKPOINT_NAME_KEY); + getElementsFromIds(state, delegator, CANDIDATES_FOR_FOLLOWING_IDS_KEY, candidatesForFollowing, Way.NAME); + Long initialFollowNodeId = state.getLong(INITIAL_FOLLOW_NODE_ID_KEY); + if (initialFollowNodeId != null) { + initialFollowNode = (Node) delegator.getOsmElement(Node.NAME, initialFollowNodeId); + } + Long wayToFollowId = state.getLong(WAY_TO_FOLLOW_ID_KEY); + if (wayToFollowId != null) { + wayToFollow = (Way) delegator.getOsmElement(Way.NAME, wayToFollowId); + } + } + + /** + * File List list fith OsmElements from ids in state + * + * @param the OsmElement type + * @param state the saved state + * @param delegator the StorageDelegator instance + * @param key the key for the list of ids + * @param list the target List + * @param elementName the element Name + */ + private void getElementsFromIds(SerializableState state, StorageDelegator delegator, String key, List list, String elementName) { + List ids = state.getList(key); + if (ids != null) { + for (Long id : ids) { + @SuppressWarnings("unchecked") + T element = (T) delegator.getOsmElement(elementName, id); + if (element != null) { + list.add(element); + } else { + throw new IllegalStateException("Failed to find element key " + key + " " + id); + } + } + } } /** @@ -197,6 +228,9 @@ public boolean onPrepareActionMode(ActionMode mode, Menu menu) { }); // menu.add(Menu.NONE, MENUITEM_NEWWAY_PRESET, Menu.NONE, R.string.tag_menu_preset).setIcon(ThemeUtils.getResIdFromAttribute(main, R.attr.menu_preset)); + if (candidatesForFollowing != null && !candidatesForFollowing.isEmpty()) { + menu.add(Menu.NONE, MENUITEM_FOLLOW_WAY, Menu.NONE, R.string.menu_follow_way).setIcon(ThemeUtils.getResIdFromAttribute(main, R.attr.menu_follow)); + } menu.add(Menu.NONE, MENUITEM_ADDRESS, Menu.NONE, R.string.tag_menu_address).setIcon(ThemeUtils.getResIdFromAttribute(main, R.attr.menu_address)); menu.add(GROUP_BASE, MENUITEM_HELP, Menu.CATEGORY_SYSTEM | 10, R.string.menu_help).setIcon(ThemeUtils.getResIdFromAttribute(main, R.attr.menu_help)); arrangeMenu(menu); @@ -244,6 +278,9 @@ static void addSnapCheckBox(@NonNull Context ctx, @NonNull Menu menu, boolean sn @Override public boolean handleClick(float x, float y) { super.handleClick(x, y); + if (logic.getClickableElements() != null) { // way follow + return false; + } try { pathCreateNode(x, y); } catch (OsmIllegalOperationException e) { @@ -252,6 +289,76 @@ public boolean handleClick(float x, float y) { return true; } + @Override + public boolean handleElementClick(OsmElement element) { + // protect against race conditions and other issues + if (!(element instanceof Node) || wayToFollow == null || initialFollowNode == null) { + Log.e(DEBUG_TAG, "handleElementClick " + element + " " + wayToFollow + " " + initialFollowNode); + return false; + } + List followNodes = wayToFollow.getNodes(); + List nodesToAdd = nodesFromFollow(followNodes, initialFollowNode, addedNodes.get(addedNodes.size() - 1), (Node) element, wayToFollow.isClosed()); + existingNodes.addAll(nodesToAdd); + addedNodes.addAll(nodesToAdd); + createdWay.getNodes().addAll(nodesToAdd); // nodes already all exist in storage + createdWay.invalidateBoundingBox(); + logic.setClickableElements(null); + if (createdWay.isClosed()) { + finishPath(createdWay, null); + return true; + } + logic.setSelectedWay(createdWay); + logic.setSelectedNode((Node) element); + mode.setTitle(savedTitle); + mode.setSubtitle(R.string.add_way_node_instruction); + mode.invalidate(); + main.invalidateMap(); + return true; + } + + /** + * Extract the list of Nodes to add to the new way in the correct order + * + * @param followNodes full List of nodes from the original way + * @param initialNode the first of the two nodes that enable following + * @param startNode start Node + * @param endNode end Node + * @param closed true if this was a closed way + * @return a List of Nodes + */ + @NonNull + private List nodesFromFollow(@NonNull List followNodes, @NonNull Node initialNode, @NonNull Node startNode, @NonNull Node endNode, + boolean closed) { + int posStart = followNodes.indexOf(startNode); // positions in the way we are copying + int posEnd = followNodes.indexOf(endNode); + if (!closed) { + if (posEnd > posStart) { + return followNodes.subList(posStart + 1, posEnd + 1); + } + List toReverse = new ArrayList<>(followNodes.subList(posEnd, posStart)); // copy required + Collections.reverse(toReverse); + return toReverse; + } + // closed way slightly complicated + int posInitial = followNodes.indexOf(initialNode); + List result = new ArrayList<>(); + int count = followNodes.size(); + + final int lastNodePos = count - 2; // -2 to skip the closing node + // determine in which direction we are traversing the nodes of the way we are following + int inc = (posStart > posInitial && posInitial != 0 && posStart != lastNodePos) || (posInitial == lastNodePos && posStart == 0) + || (posInitial == 0 && posStart == 1) ? 1 : -1; + + for (int i = MathUtil.floorMod(posStart + inc, count); i != MathUtil.floorMod(posEnd + inc, count); i = MathUtil.floorMod(i + inc, count)) { + final Node next = followNodes.get(i); + // skip the closing node if there is one + if (!next.equals(startNode) && (result.isEmpty() || !result.get(result.size() - 1).equals(next))) { + result.add(next); + } + } + return result; + } + /** * Creates/adds a node into a path during path creation * @@ -278,22 +385,44 @@ private synchronized void pathCreateNode(float x, float y) { if (logic.getSelectedNode() == null) { // user clicked last node again -> finish adding finishPath(lastSelectedWay, lastSelectedNode); - } else { // update cache for undo - createdWay = logic.getSelectedWay(); - if (createdWay == null) { - addedNodes = new ArrayList<>(); - } else { - createdWay.dontValidate(); - } - addedNodes.add(logic.getSelectedNode()); - if (firstNode) { - mode.invalidate(); // activate undo - } - if (clicked != null) { - // node already existed - existingNodes.add(clicked); + return; + } + // update cache for undo + createdWay = logic.getSelectedWay(); + if (createdWay == null) { + addedNodes = new ArrayList<>(); + } else { + createdWay.dontValidate(); + } + addedNodes.add(logic.getSelectedNode()); + if (firstNode) { + mode.invalidate(); // activate undo + } + // node already existed id clicked != null + if (clicked != null) { + existingNodes.add(clicked); + // check if we are potentially following a way + if (lastSelectedNode != null) { + boolean alreadyAvailable = candidatesForFollowing != null && !candidatesForFollowing.isEmpty(); + candidatesForFollowing = logic.getWaysForNode(lastSelectedNode); + candidatesForFollowing.retainAll(logic.getWaysForNode(clicked)); + candidatesForFollowing.remove(createdWay); + // remove any ways that we have "used up" + for (Way candidate : new ArrayList<>(candidatesForFollowing)) { + if (candidate.isEndNode(clicked) && !candidate.isClosed()) { + candidatesForFollowing.remove(candidate); + } + } + initialFollowNode = lastSelectedNode; + if (!alreadyAvailable || (alreadyAvailable && candidatesForFollowing.isEmpty())) { + mode.invalidate(); + } } + } else { + candidatesForFollowing = null; + mode.invalidate(); } + mode.setSubtitle(R.string.add_way_node_instruction); main.invalidateMap(); } @@ -312,7 +441,10 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { switch (itemId) { case MENUITEM_UNDO: handleUndo(); - break; + return true; + case MENUITEM_FOLLOW_WAY: + handleFollow(); + return true; case MENUITEM_NEWWAY_PRESET: case MENUITEM_ADDRESS: Way lastSelectedWay = logic.getSelectedWay(); @@ -334,6 +466,59 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { return false; } + /** + * Setup follow way selection + */ + private void handleFollow() { + if (candidatesForFollowing.size() == 1) { + followWay(mode, candidatesForFollowing.get(0)); + return; + } + DisambiguationMenu menu = new DisambiguationMenu(main.getMap()); + menu.setHeaderTitle(R.string.select_follow_way); + int id = 0; + for (Way w : candidatesForFollowing) { + menu.add(id, Type.WAY, w.getDescription(main), (int position) -> followWay(mode, w)); + id++; + } + menu.show(); + } + + /** + * Follow a way from the last node added to a end node that is selected in the next step + * + * @param mode the current ActionMode + */ + private void followWay(@NonNull ActionMode mode, @NonNull Way follow) { + wayToFollow = follow; + List endNodesCandidates = new ArrayList<>(follow.getNodes()); // copy required!! + // remove nodes that are not "in front of the current node" + final Node current = addedNodes.get(addedNodes.size() - 1); + int posCurrent = endNodesCandidates.indexOf(current); + final Node previous = addedNodes.get(addedNodes.size() - 2); + int posPrevious = endNodesCandidates.indexOf(previous); + if (follow.isClosed()) { + endNodesCandidates.removeAll(addedNodes); + final Node firstAdded = addedNodes.get(0); + if (follow.hasNode(firstAdded)) { + endNodesCandidates.add(firstAdded); + } + } else { + endNodesCandidates = posPrevious < posCurrent ? endNodesCandidates.subList(posCurrent + 1, endNodesCandidates.size()) + : endNodesCandidates.subList(0, posCurrent); + } + logic.setSelectedWay(null); + logic.setSelectedNode(null); + logic.setClickableElements(new HashSet<>(endNodesCandidates)); + logic.setReturnRelations(false); + if (savedTitle == null) { + savedTitle = mode.getTitle().toString(); + } + mode.setTitle(R.string.actionmode_createpath_follow_way); + mode.setSubtitle(R.string.actionmode_createpath_follow_way_select_end_node); + main.invalidateMap(); + } + /** * Handle presses on the undo button, this does not invoke the normal undo mechanism but simply removes the * non-saved nodes one by one @@ -429,6 +614,11 @@ protected void delayedResetHasProblem(@Nullable final Way way) { public boolean processShortcut(Character c) { if (c == Util.getShortCut(main, R.string.shortcut_copy)) { handleUndo(); + return true; + } + if (c == Util.getShortCut(main, R.string.shortcut_follow)) { + handleFollow(); + return true; } return super.processShortcut(c); } @@ -473,6 +663,19 @@ public void saveState(SerializableState state) { state.putString(TITLE_KEY, mode.getTitle().toString()); state.putString(SUBTITLE_KEY, mode.getSubtitle().toString()); state.putInteger(CHECKPOINT_NAME_KEY, checkpointName); + if (candidatesForFollowing != null) { + List candidatesForFollowingIds = new ArrayList<>(); + for (Way w : candidatesForFollowing) { + candidatesForFollowingIds.add(w.getOsmId()); + } + state.putList(CANDIDATES_FOR_FOLLOWING_IDS_KEY, candidatesForFollowingIds); + } + if (initialFollowNode != null) { + state.putLong(INITIAL_FOLLOW_NODE_ID_KEY, initialFollowNode.getOsmId()); + } + if (wayToFollow != null) { + state.putLong(WAY_TO_FOLLOW_ID_KEY, wayToFollow.getOsmId()); + } } @Override diff --git a/src/main/java/de/blau/android/util/MathUtil.java b/src/main/java/de/blau/android/util/MathUtil.java new file mode 100644 index 0000000000..e064b0b394 --- /dev/null +++ b/src/main/java/de/blau/android/util/MathUtil.java @@ -0,0 +1,31 @@ +package de.blau.android.util; + +/** + * Android doesn't have floor mode before API 24 + */ +public final class MathUtil { + + /** + * Private constructor + */ + private MathUtil() { + // empty + } + + /** + * Floor modulus + * + * See Math.floorMod + * + * @param x dividend + * @param y dividor + * @return the floor modulus of x and y + */ + public static int floorMod(int x, int y) { + int r = x / y; + if ((x ^ y) < 0 && (r * y != x)) { + r--; + } + return x - r * y; + } +} diff --git a/src/main/res/drawable-xhdpi/follow_dark.png b/src/main/res/drawable-xhdpi/follow_dark.png new file mode 100644 index 0000000000..7fc1099247 Binary files /dev/null and b/src/main/res/drawable-xhdpi/follow_dark.png differ diff --git a/src/main/res/drawable-xhdpi/follow_light.png b/src/main/res/drawable-xhdpi/follow_light.png new file mode 100644 index 0000000000..ffc71c3221 Binary files /dev/null and b/src/main/res/drawable-xhdpi/follow_light.png differ diff --git a/src/main/res/values/attrs.xml b/src/main/res/values/attrs.xml index bf03d6c690..68ea602928 100644 --- a/src/main/res/values/attrs.xml +++ b/src/main/res/values/attrs.xml @@ -50,6 +50,7 @@ + diff --git a/src/main/res/values/shortcuts.xml b/src/main/res/values/shortcuts.xml index 298dda76e2..5b85b0a570 100644 --- a/src/main/res/values/shortcuts.xml +++ b/src/main/res/values/shortcuts.xml @@ -17,4 +17,5 @@ m s r + f \ No newline at end of file diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 76d1635367..37782d953c 100755 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1081,6 +1081,7 @@ Node selected Relation selected Creating path + Follow way Multiselect New Note selected Add segment @@ -1104,11 +1105,15 @@ Split \"via\" segment Select segment Set tags + Select end node 1 object %1$d objects Rotate way + Snap + Follow way + Select way to follow Add node Tap the screen position @@ -1125,7 +1130,6 @@ Tap the screen position Paste multiple times Tap the screen position - Snap Map background The tile based map background. diff --git a/src/main/res/values/styles.xml b/src/main/res/values/styles.xml index 3c0cec38a3..898be7bb9c 100644 --- a/src/main/res/values/styles.xml +++ b/src/main/res/values/styles.xml @@ -185,6 +185,7 @@ @drawable/bridge_dark @drawable/culvert_dark @drawable/steps_dark + @drawable/follow_dark @drawable/undolist_undo_dark @drawable/undolist_redo_dark @drawable/ic_action_warning_holo_dark @@ -263,6 +264,7 @@ @drawable/bridge_light @drawable/culvert_light @drawable/steps_light + @drawable/follow_light @drawable/undolist_undo_light @drawable/undolist_redo_light @drawable/ic_action_warning_holo_light diff --git a/svg/follow.svg b/svg/follow.svg new file mode 100644 index 0000000000..b79c0b7dd7 --- /dev/null +++ b/svg/follow.svg @@ -0,0 +1,300 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/svg/follow_dark.svg b/svg/follow_dark.svg new file mode 100644 index 0000000000..bcd3115dad --- /dev/null +++ b/svg/follow_dark.svg @@ -0,0 +1,296 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/svg/follow_light.svg b/svg/follow_light.svg new file mode 100644 index 0000000000..dcc0044899 --- /dev/null +++ b/svg/follow_light.svg @@ -0,0 +1,300 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +