diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 751e7440f0d..ebaf683d12f 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,5 +1,6 @@ # These are supported funding model platforms -github: westnordost -liberapay: westnordost -patreon: westnordost +# maybe show both? +#github: westnordost +liberapay: Helium314 +#patreon: westnordost diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index f4868bf95fb..72e90cbb54e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -6,9 +6,14 @@ labels: bug --- **How to Reproduce** @@ -17,5 +22,8 @@ Maybe it is not a bug? Check the FAQ: https://wiki.openstreetmap.org/wiki/Street **Expected Behavior** +**Does it happen in normal StreetComplete?** + + **Versions affected** diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 597e5528e99..2cda0da4545 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: true contact_links: - name: Discussions - url: https://github.com/streetcomplete/StreetComplete/discussions - about: For discussions about the usage of StreetComplete, asking questions or talking about ideas which are not yet actionable. + url: https://github.com/Helium314/SCEE/discussions + about: For discussions about the usage of SCEE, asking questions or talking about ideas which are not yet actionable. - name: Translations - url: https://github.com/streetcomplete/StreetComplete/blob/master/CONTRIBUTING.md#translating-the-app - about: For contributing and discussing translations of StreetComplete + url: https://github.com/Helium314/SCEE#translations + about: For contributing and discussing translations of SCEE diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index a8ea2366599..10c85578de6 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -3,6 +3,14 @@ name: Feature request about: Suggest an idea for this project --- + + **Use case** diff --git a/.github/ISSUE_TEMPLATE/quest-suggestion.md b/.github/ISSUE_TEMPLATE/quest-suggestion.md index 1ea5595065b..7a3eaa3cd11 100644 --- a/.github/ISSUE_TEMPLATE/quest-suggestion.md +++ b/.github/ISSUE_TEMPLATE/quest-suggestion.md @@ -6,9 +6,13 @@ about: Suggest a new question to ask the user ### General @@ -17,10 +21,10 @@ Question asked: **Is there an example tag on item XY?** ### Checklist -Checklist for quest suggestions (see [guidelines](https://github.com/streetcomplete/StreetComplete/blob/master/QUEST_GUIDELINES.md)): +Checklist for quest suggestions (see [guidelines](https://github.com/Helium314/SCEE/blob/modified/QUEST_GUIDELINES.md)): - [ ] 🚧 To be added tag is established and has a useful purpose -- [ ] 🤔 Any answer the user can give must have an equivalent tagging (Quest should not reappear to other users when solved by one) -- [ ] 🐿️ Easily answerable by any pedestrian from the outside but a survey is necessary +- [ ] 🤔 Any answer the user can give should have an equivalent tagging (SCEE quests may not be answerable in all cases, but please keep this rare) +- [ ] 🐿️ A survey is necessary (may require knowledge of the subject, though easy answering for everyone is preferred) - [ ] 💤 Not an overwhelming percentage of quests have the same answer (No spam) - [ ] 🕓 Applies to a reasonable number of map data (Worth the effort) diff --git a/.github/workflows/build-debug-apk.yml b/.github/workflows/build-debug-apk.yml index 366038f53ae..c7fa0da94c1 100644 --- a/.github/workflows/build-debug-apk.yml +++ b/.github/workflows/build-debug-apk.yml @@ -31,7 +31,7 @@ jobs: run: ./gradlew assembleDebug - name: Rename APK - run: mv app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/debug/StreetComplete-debug-$COMMIT_SHA.apk + run: mv app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/debug/SCEE-debug-$COMMIT_SHA.apk - name: Archive APK uses: actions/upload-artifact@v4 diff --git a/.github/workflows/build-signed-apk.yml b/.github/workflows/build-signed-apk.yml new file mode 100644 index 00000000000..77bda72560b --- /dev/null +++ b/.github/workflows/build-signed-apk.yml @@ -0,0 +1,36 @@ +name: Build signed apk + +on: + [workflow_dispatch] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Create keystore file + run: echo "${{ secrets.keystore }}" | base64 -d > $GITHUB_WORKSPACE/signing-key.jks + - name: Build with Gradle + run: ./gradlew assembleRelease + -Pandroid.injected.signing.store.file=$GITHUB_WORKSPACE/signing-key.jks + -Pandroid.injected.signing.store.password=${{ secrets.keystore_password }} + -Pandroid.injected.signing.key.alias=${{ secrets.key_alias }} + -Pandroid.injected.signing.key.password=${{ secrets.key_password }} + - name: Rename APK + run: mv app/build/outputs/apk/release/app-release.apk app/build/outputs/apk/release/SCEE-release-$(git log -n 1 --format='%h').apk + - name: Archive APK + uses: actions/upload-artifact@v4 + with: + name: release-apk + path: app/build/outputs/apk/release/*.apk + retention-days: 14 diff --git a/.github/workflows/generate-quest-list.yml b/.github/workflows/generate-quest-list.yml index 4e6cc4f41af..885c28c0c8f 100644 --- a/.github/workflows/generate-quest-list.yml +++ b/.github/workflows/generate-quest-list.yml @@ -1,9 +1,6 @@ name: Generate quest list on: - push: - branches: - - master workflow_dispatch: jobs: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fd225a326ef..301e2c58e21 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,9 +1,6 @@ name: "Lint" on: - push: - branches: - - "master" workflow_dispatch: jobs: diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 79096b13f86..00000000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 0f7bc519db6..00000000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/.idea/icon.svg b/.idea/icon.svg index cd7c8311622..2b858bc3e7a 100644 --- a/.idea/icon.svg +++ b/.idea/icon.svg @@ -1,2 +1,2 @@ - \ No newline at end of file + diff --git a/.idea/ktlint.xml b/.idea/ktlint.xml deleted file mode 100644 index 92c444169bb..00000000000 --- a/.idea/ktlint.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - false - - \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58060bad821..ca70a4f9547 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,21 +16,15 @@ Content: ## Translating the app -You can translate StreetComplete at POEditor. You can add missing translations and improve existing ones. Discuss translations at POEditor. +You can translate StreetComplete at POEditor, and SCEE in Weblate. You can add missing translations and improve existing ones. The only required skills here are ability to read English text and write in your own language. -Follow [**this link** to improve the translations](https://poeditor.com/join/project/IE4GC127Ki): +Follow [**this link**](https://translate.codeberg.org/projects/scee/) to improve the translations only available in SCEE, or click the image below to improve translations for StreetComplete (which are also used in SCEE): [![POEditor](https://poeditor.com/public/images/logo_small.png)](https://poeditor.com/join/project/IE4GC127Ki) -After joining, the [main site of the POEditor](https://poeditor.com/projects/) should list StreetComplete for logged in users. - -Before each [release](res/documentation/creating%20new%20release.md), translations are pulled in from POEditor. Please, use POEditor for translating. Manual changes submitted as Pull Requests will not be merged as they do not help the project. - -Once 100% or close to 100% of text is translated, the given language becomes enabled. Translations which are not maintained are removed. Typically languages which are less than 60% translated will be considered as not maintained and such translation will be disabled. - -The source translation is in US English (in [app/src/main/res/values/strings.xml](app/src/main/res/values/strings.xml)). POEditor has a list of modifications in dialects such as UK English and Australian English, listed as separate languages. +The source translation is in US English (in [app/src/main/res/values/strings_ee.xml](app/src/main/res/values/strings_ee.xml)). ### iD presets @@ -42,7 +36,7 @@ For iD preset translation see [their documentation](https://github.com/openstree As you probably noticed, you can choose *"Cannot answer"* in StreetComplete and thus leave a note on OpenStreetMap. -You can help with [processing OSM notes opened by StreetComplete users](https://ent8r.github.io/NotesReview/?query=StreetComplete&limit=100&start=true). While processing and solving notes, it may become apparent that there is a systematic problem in that users misunderstand the UI or the wording when solving StreetComplete quests. +You can help with [processing OSM notes opened by StreetComplete and SCEE users](https://ent8r.github.io/NotesReview/?query=StreetComplete&limit=100&start=true). While processing and solving notes, it may become apparent that there is a systematic problem in that users misunderstand the UI or the wording when solving StreetComplete quests. If you find such user experience problems, please report them back in the [issue tracker of StreetComplete](https://github.com/streetcomplete/StreetComplete/issues). Do not forget to add links to examples, e.g. the notes StreetComplete mappers submitted. diff --git a/CONTRIBUTING_A_NEW_QUEST.md b/CONTRIBUTING_A_NEW_QUEST.md index 9b4d92e9bef..504e7dadb45 100644 --- a/CONTRIBUTING_A_NEW_QUEST.md +++ b/CONTRIBUTING_A_NEW_QUEST.md @@ -471,6 +471,8 @@ Please make sure that the images do not take too much disk space. Most useful wa [GIMP](https://gimp.org/) allows such previews while saving JPG files, and there are also online tools like [squoosh](https://squoosh.app/) which allow for quick visual comparison if you prefer that. +Please try to keep the images small (consider they already make up more than half of the APK size), and consider using vector graphics instead of photos where it's reasonable. + After adding a photo, remember to update [the credits file](app/src/main/res/authors.txt) (different to the one for icons). ## Resurvey diff --git a/QUEST_GUIDELINES.md b/QUEST_GUIDELINES.md index 003413e9ddb..790bab48beb 100644 --- a/QUEST_GUIDELINES.md +++ b/QUEST_GUIDELINES.md @@ -1,21 +1,22 @@ Do you have an idea for a new quest? Read this! -## 1. Decide whether the idea works out with StreetComplete Quests +## 1. Decide whether the idea works out with SCEE Quests Consider the following: -### Limitations of StreetComplete Quests -- 🌟 Only existing elements can be extended, no elements can be added or removed. +### Limitations of SCEE Quests +- 🌟 Only existing elements can be extended, but nodes can be moved, removed and added (free-floating or to a single way). - ✂️ The geometry of elements cannot be changed (except splitting up ways and moving nodes) -- 🏷️ So, basically: Only tags can be edited ### General guidelines +- See them as suggestions, not necessarily as requirements. - ⚛️ **Atomic quests**: Per quest, only **one** thing should need to be answered by the user. - 🚧 **Established tags only**: No new or unestablished tags should be introduced through StreetComplete. Establishing tags must remain a community process and not be dictated by software implementation. - 🤷 **Useful purpose**: Especially for tags that are not that well established yet - they should have some application. As by the design of OpenStreetMap, there are countless things that *could* be collected, such as the color of the cycleway, the brightness of street lamps, etc. and sometimes things like these are even documented on the wiki (because it is a wiki, obviously). That does not mean that it makes sense to collect this information (in this app). - 🕓 **Effort vs impact**: Consider if it is worth the effort when compared to the impact the quest would have. For how many elements would this quest type apply? This point is especially valid if you don't plan to implement a quest suggestion yourself through a PR. A quest to determine the type of building applies to 200 million elements while i.e. a quest to determine what a vending machine is selling applies to less than 1000 elements. ### Users +- See the guidelines below as suggestions. Not fulfilling any of the guidelines perfectly acceptable for SCEE, with the exception of the *no spam* guideline in case it leads to undesired tag spam. - 🤔 **No unanswerable quests**: All generated quests need to be actually answerable (no false-positives). This means that any answer given by the user must result in something being tagged. For example, a quest that asks for the website of a place must be able to tag the element somehow if the user answers that the place has no website - otherwise, the next user will be asked the same question. Sometimes, due to the nature of how things are tagged in OSM (such as the one given in the example) it is unfortunately simply not possible to fulfill this. - 👨‍💻 **Users are no experts**: No knowledge about OpenStreetMap or any other background knowledge must be necessary - 🐿️ **Easy answer**: Users are out and about and impatient. A quick, straightforward and clear answer must be possible @@ -32,6 +33,8 @@ Also, for very detailed information that can be assumed to always have the same Depending on the quest, this requires some research but is necessary preparational work that can be done without any programming knowledge (but with knowledge of OSM). +SCEE is also able to generate quests from external sources, so search for specific tags is not required. See e.g. the Osmose quest, which converts Osmose issues into quests. + ## 3. Design the form As mentioned, the user interface must leave no space for misunderstandings, it must be concise and quick and easy to use. Also sounds obvious, but you will quickly find out that a balance must be found between covering all the edge cases and designing the form to be as straightforward and clutterless as possible. diff --git a/README.md b/README.md index df6060442c3..8bc841a7a92 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,197 @@ -![StreetComplete](.github/images/feature_graphic.png) - -StreetComplete is an easy to use editor of OpenStreetMap data available for Android. It can be used without any OpenStreetMap-specific knowledge. It asks simple questions, with answers directly used to edit and improve OpenStreetMap data. The app is aimed at users who do not know anything about OSM tagging schemes but still want to contribute to OpenStreetMap. - -StreetComplete automatically looks for nearby places where a survey is needed and shows them as quest markers on its map. Each of these quests can then be solved on site by answering a simple question. For example, tapping on a marker may show the question "What is the name of this road?", with a text field to answer it. -More examples are shown in the screenshots below. - -The user's answer is automatically processed and uploaded directly into the OSM database. Edits are done in meaningful changesets using the user's OSM account. -Since the app is meant to be used on a survey, it can be used offline and is -economic with data usage. - -To make the app easy to use, quests are limited to those answerable by asking simple questions. - -* See the [latest release notes](https://github.com/streetcomplete/StreetComplete/releases). +SCEE is a modified version of StreetComplete, aimed at experienced OSM users unhappy about the lack of advanced editing capabilities in normal StreetComplete. +By default, most of the additional capabilities are disabled. Go through the settings (either in the app or [below](#differences-to-streetcomplete)) for details. + +Please be aware that SCEE is not suitable for people used to discarding warning messages without reading! +Users new to OpenStreetMap are best advised to use [StreetComplete](https://github.com/streetcomplete/StreetComplete). + +Functionality added in SCEE is considerably less tested than what you might be used to in StreetComplete, so bugs or unexpected behavior may happen. If you encounter any, please report the issue. + +1. [Download](#download-scee) +2. [Translate](#translations) +3. [Additional permissions](#permissions) +4. [Differences to StreetComplete](#differences-to-streetcomplete) +5. [Contributing quests](#contributing-quests) +6. [Differences in changesets](#changeset-differences-compared-to-streetcomplete) + +[StreetComplete readme](README_StreetComplete.md) + +[SCEE FAQ](https://wiki.openstreetmap.org/wiki/SCEE/FAQ) + +## Download SCEE + +[Get it on F-Droid](https://f-droid.org/packages/de.westnordost.streetcomplete.expert/) +[Download APK from GitHub](https://github.com/Helium314/SCEE/releases/latest) + +F-Droid releases of SCEE make use of reproducible builds, so releases on F-Droid and GitHub are signed with the same keys. This means you can switch between GitHub and F-Droid releases anytime without needing to uninstall first. + +__F-Droid anti-feature__ _non-free network_: SCEE uses map tiles provided by [jawg](https://www.jawg.io), and optionally [aerial / satellite imagery](https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer) by [Esri](https://www.esri.com) . + +## Translations +Translations for strings added in SCEE can be done [using Weblate at Codeberg](https://translate.codeberg.org/projects/scee/). +You will need an account to update translations and add languages. Add the language you want to translate to in _Languages_ -> _Manage translated languages_ in the top menu bar. + +## Permissions +SCEE asks for two more permissions than StreetComplete: `ACCESS_BACKGROUND_LOCATION` and `POST_NOTIFICATIONS`. Both are requested and used only in feature to notify about nearby quests while the app is in the background. + +## Differences to StreetComplete +* Non-optional differences to StreetComplete + * No star count on main screen + * When using auto-upload, an indicator now shows when there are changes waiting to be uploaded + * Dark theme uses dark buttons + * Prevent short scroll to user location at app start when map was at a different position + * Downloading data will interrupt upload queue (will resume afterwards) + * Manual downloads can be queued instead of always cancelling the previous one + * Additional answers for some quests + * Additional building types + * Additional path surfaces + * Specify that a crossing is raised + * Answer non-marked lanes with a count + * Answer "no seating, but not takeaway only" + * Add wheelchair description when answering wheelchair quest + * Move the "no cycleway" answer to more accessible position + * Highlight obstacles along the way for smoothness quests + * Open settings when pressing menu key in main menu dialog + * Allow switching to aerial view while adding or moving a node +* New quests that are not eligible for StreetComplete, usually because some answers cannot be tagged, or because not everyone has the required knowledge to answer the quest. These quests can only be enabled when expert mode is on. + * Material of benches and picnic tables + * Phone number and website + * Cuisine + * Healthcare speciality + * Outdoor seating type + * Service building type + * Service building operator + * Street cabinet type + * Artwork type + * Railway platform number + * Subway entrance reference number + * Trail visibility of hiking trails + * Genus / species of trees + * Allows providing a file containing translated tree names instead of the default English ones + * Color of building roofs + * Whether a barrier is locked + * Height of barriers + * Whether pharmacy is dispensing prescription drugs + * Destination of some road types after intersections + * Which beers are sold in restaurants + * Elevation, ref, sports and name of guideposts + * Width of footways + * Size and type of maps + * Via ferrata scale + * Difficulty and ref for pistes, and whether they are lit + * Quests based on external sources + * Osmose quest showing Osmose issues as quests, with filter options + * Custom quest from CSV file, allows creating nodes (see in-app description) + * Show POI quests with the sole purpose of indicating existence of elements of chosen type (may show labels) + * Option to show only quests added in SCEE in quest selection menu + * Some "other answers" result in a modified changeset comment (because in SCEE they may contain more unexpected changes) +* Customizable overlays: Choose which elements are highlighted, and which tag is used to determine the color +* Turn restriction overlay +* Settings + * Additional darker dark theme + * Background map can be changed to aerial / satellite imagery + * Adjust location update intervals + * Expert mode that enables capabilities, some of which can be dangerous when used by inexperienced OSM contributors + * Directly edit tags, with suggestions from iD and last used values + * Add nodes everywhere, either free-floating or as part of a way + * inserting nodes into a way may actually re-use existing nodes at that position + * Delete free-floating nodes + * Additional "other answers" + * add `access=private` to benches, bicycle parkings, picnic tables, pitches, (leisure) tracks and recycling containers + * add/adjust highway access + * tag highways as under construction (with finish date) + * tag buildings as demolished + * add conditional maxspeed (maxspeed quest only) + * Allow moving nodes that are part of a way (including a clear warning about changing geometry) + * Allow disabling and moving the note quest + * Allow closing notes + * Some of the settings below can only be enabled in expert mode + * Quest settings for most quests, mostly for customized element selection, but also for other things like allowing generic paved surface answer without note + * Such customization should be handled with care. There are some safeguards, but modifying element selection could still lead to inappropriate tagging, quests being asked over and over again, and maybe app crashes. + * Quests without settings need to be handled individually. Please open an issue if you want specific settings. + * UI settings + * Quick settings button for switching preset, background and reverse quest order. Also contains a level filter for displayed quests / overlay elements + * Quick selector for overlays (on main screen) + * long-press custom overlays to edit + * Show next quest for this element immediately + * Show nearby quests / other quests for same element when quest form is open + * Hide button for temporarily hiding quests (long press for permanent hide) + * Show keyboard automatically on showing feature search dialog when adding a node + * Auto-select first edit when opening edit history + * Search features in local language and all languages enabled in the system + * Select how many lines the form needs to have to move recent selection to front + * Show all main menu items as grid + * Add a _switch preset_ button to main menu (only if not a grid) + * Capitalize words when entering names + * Zoom using volume buttons + * Display settings + * Disable 3D buildings (currently not available, as 3D buildings are disabled in general with since the MapLibre switch) + * Show arrows indicating direction of highlighted way + * Highlight geometries for nearby quests + * Disable quest solved animation + * Provide GPX track and have it always shown on the map + * Provide GeoJson file and have geometries shown on the map (and text from _name_ property) + * Quest settings + * Hide or increase priority of quests depending on time of day + * Force resurvey for specific tags + * Different quest settings for each preset + * Dynamic quest creation for immediately applying changed quest settings and resurvey intervals + * Notifications about nearby quests when app is in background + * Hide overlay-specific quests when overlay is enabled + * Note settings + * Create personal notes in a GPX file (adds a new button when creating a note) + * Swap OSM and GPX note buttons, for switching default notes + * Disable hiding the keyboard before creating a note + * Create custom quests like notes + * Save full-size photos made for notes + * Hide notes created by specific users + * Data management settings + * Disable auto-download + * Disable always downloading map data on manual download, even if data is fresh + * Choose tile URL for aerial imagery + * Set data retention time + * Disable local statistics updates (hides achievement messages) + * Import / export + * Custom overlays + * Quest presets, including per-preset quest settings + * Hidden quests + * All other settings, including quest settings and recently selected answers. Does not export login data. +* When uploading from a debug build without being logged in, all uploads return fake success without contacting OSM API. This is used for testing parts of the uploader. + +Database and preferences files are compatible with StreetComplete, so if you have root privileges you can transfer them in either direction. + +## Contributing quests +The original [contributing guidelines](README_StreetComplete.md#contributing) are still valid, but note that the [guidelines for contributing a quest](QUEST_GUIDELINES.md) have been significantly relaxed: +* Creating, moving and deleting nodes is possible + * Inserting nodes into a way is possible +* Guidelines are useful suggestions, but not enforced +* Quests may be based on external sources like Osmose, not just on element selection + +## Changeset differences compared to StreetComplete +This section is aimed for people trying to decide whether a bad edit done in SCEE is fault of the user or of the app (SCEE modifications). +In general, SCEE changesets will contain changes very similar to StreetComplete changesets, with following differences: +* `created_by` is set to `StreetComplete_ee ` +* _AddBuildingType_ has additional answers `barn`, `sty`, `stable`, `cowshed`, `digester`, `presbytery`, `riding_hall`, `sports_hall`, `tent`, `elevator`, and `transformer_tower` +* _AddCrossingType_ may change `crossing_ref`, `crossing:markings`, and `traffic_calming` +* _AddPathSurface_ and _AddRoadSurface_ have additional surfaces `metal_grid` and `stepping_stones` +* _AddMaxSpeed_ may tag `maxspeed:conditional` +* [Discardable tags](https://wiki.openstreetmap.org/wiki/Discardable_tags) are removed automatically +* Any node may be moved, even if it is part of a way or relation +* Any node may be deleted, or have all tags removed if it's not free-floating +* `check_date:*` may be added without resurvey +* Wheelchair quests may add `wheelchair:description` and `wheelchair:description:` +* An element at the same position as a note may be edited (this is blocked in normal SC) +* Most quests may apply to an extended range of elements (user-defined) +* Starting with SCEE 52.0, some answers create separate changesets with comment `Other edits in context of: `. +This happens for changes that can occur in StreetComplete, such as moving or deleting a node, changing shop types, removing surface, changing highway to steps and removing sidewalks. +Furthermore SCEE adds new answers leading to such a changeset comment: + * All quest types related to roads / paths may adjust access tags + * Quests types asking about about benches, picnic tables, recycling containers, bicycle parkings and sports tracks/pitches may tag `access=private` + * All quest types related to buildings may change `building` to `demolished:building` +* SCEE contains some additional [quests (scroll to bottom)](app/src/main/java/de/westnordost/streetcomplete/quests/QuestsModule.kt) and [overlays](app/src/main/java/de/westnordost/streetcomplete/overlays/OverlaysModule.kt), recognizable in the files by `EE_QUEST_OFFSET` + * They usually do not fulfill the requirements for StreetComplete, and need to be enabled by the user first +* There are further "quest types" (though neither quests nor overlays, they are identified in `StreetComplete:quest_type` changeset tag) + * _TagEdit_: may modify any tag + * _AddNode_: adds nodes, free floating or part of ways, (may change tags of existing way node instead of inserting a new one under some circumstances) ## Screenshots - - -## Download - -[Get it on Google Play](https://play.google.com/store/apps/details?id=de.westnordost.streetcomplete)[Get it on F-Droid](https://f-droid.org/packages/de.westnordost.streetcomplete/)[Download APK from GitHub](https://github.com/streetcomplete/StreetComplete/releases/latest) - -## Quests - -There are quite a few different quest types now and more will be added over time. -You can see a community-managed [list of all quests in the OSM wiki](https://wiki.openstreetmap.org/wiki/StreetComplete/Quests). - -## FAQ - -You can find a list of [frequently asked questions in the wiki](https://wiki.openstreetmap.org/wiki/StreetComplete/FAQ). - -## Contributing - -This is an active open-source project, so you can get involved in it easily! -You can do so **without any programming or OpenStreetMap knowledge**! Just choose a task that you like. - -Here are a few things you can do: -* 🐛 [Test and report issues](CONTRIBUTING.md#testing-and-reporting-issues) -* 📃 [Translate the app into your language](CONTRIBUTING.md#translating-the-app) -* 🕵️ [Solve notes left by StreetComplete users](CONTRIBUTING.md#solving-notes) -* 💡 [Suggest new quests](CONTRIBUTING.md#suggesting-new-quests), or, even better, [implement them](CONTRIBUTING.md#developing-new-quests). -* ➕ [and more…](CONTRIBUTING.md) - -Also, if you like StreetComplete, **spread the word**! ❤️ - -## License - -This software is released under the terms of the [GNU General Public License](http://www.gnu.org/licenses/gpl-3.0.html). - -## Sponsors - -GitHub Sponsors Liberapay Patreon
-Many users are currently supporting this app through GitHub sponsors, Liberapay and Patreon. If you like the app, you can join them ☺️ to support the continued development and maintenance of the app.
-
- -JawgMaps
-Since mid 2020, **JawgMaps** provides their vector map tiles service to StreetComplete for free, i.e. the background map displayed in the app.
-
- -## Past Sponsors - -German Federal Ministry of Education and ResearchPrototype Fund
-Within the frame of **Prototype Fund** round 15 (March 2024 to August 2024), the German Federal Ministry of Education and Research sponsored Tobias Zwick to work on StreetComplete for iOS (see [progress report](https://github.com/streetcomplete/StreetComplete/issues/5421#issuecomment-2332402123))

-Development on this app was also sponsored in round 8 (September 2020 to February 2021) of the Prototype fund, with focus on collecting more data points and on general improvements of this app.
-
- -nlnet
-The **NLnet foundation** sponsored development on this app in three individual grants with funds from the European Commission:
-Two grants given to Mateusz Konieczny in 2019 and 2021 enabled him to work on StreetComplete for about one year in total, with a focus on clearer UI and improvements on data collection. -Furthermore, yet another grant enabled Tobias Zwick to work about five months in 2021/2022 on - most notably - the overlays functionality and measuring with AR.
-
- -OpenStreetMap foundation
-In August 2020, the **OpenStreetMap foundation** funded the development of Map Maintenance with StreetComplete within the frame of the microgrants program.
+ diff --git a/README_StreetComplete.md b/README_StreetComplete.md new file mode 100644 index 00000000000..3f0e52b8d07 --- /dev/null +++ b/README_StreetComplete.md @@ -0,0 +1,73 @@ +Readme of [StreetComplete](https://github.com/streetcomplete/StreetComplete) + +![StreetComplete](http://www.westnordost.de/streetcomplete/featureGraphic.png) + +StreetComplete is an easy to use editor of OpenStreetMap data available for Android. It can be used without any OpenStreetMap-specific knowledge. It asks simple questions, with answers directly used to edit and improve OpenStreetMap data. The app is aimed at users who do not know anything about OSM tagging schemes but still want to contribute to OpenStreetMap. + +StreetComplete automatically looks for nearby places where a survey is needed and shows them as quest markers on its map. Each of these quests can then be solved on site by answering a simple question. For example, tapping on a marker may show the question "What is the name of this road?", with a text field to answer it. +More examples are shown in the screenshots below. + +The user's answer is automatically processed and uploaded directly into the OSM database. Edits are done in meaningful changesets using the user's OSM account. +Since the app is meant to be used on a survey, it can be used offline and is +economic with data usage. + +To make the app easy to use, quests are limited to those answerable by asking simple questions. + +* See the [latest release notes](https://github.com/streetcomplete/StreetComplete/releases). + +## Download + +[Get it on Google Play](https://play.google.com/store/apps/details?id=de.westnordost.streetcomplete)[Get it on F-Droid](https://f-droid.org/packages/de.westnordost.streetcomplete/)[Download APK from GitHub](https://github.com/streetcomplete/StreetComplete/releases/latest) + +## Quests + +There are quite a few different quest types now and more will be added over time. +You can see a community-managed [list of all quests in the OSM wiki](https://wiki.openstreetmap.org/wiki/StreetComplete/Quests). + +## FAQ + +You can find a list of [frequently asked questions in the wiki](https://wiki.openstreetmap.org/wiki/StreetComplete/FAQ). + +## Contributing + +This is an active open-source project, so you can get involved in it easily! +You can do so **without any programming or OpenStreetMap knowledge**! Just choose a task that you like. + +Here are a few things you can do: +* 🐛 [Test and report issues](CONTRIBUTING.md#testing-and-reporting-issues) +* 📃 [Translate the app into your language](CONTRIBUTING.md#translating-the-app) +* 🕵️ [Solve notes left by StreetComplete users](CONTRIBUTING.md#solving-notes) +* 💡 [Suggest new quests](CONTRIBUTING.md#suggesting-new-quests), or, even better, [implement them](CONTRIBUTING.md#developing-new-quests). +* ➕ [and more…](CONTRIBUTING.md) + +Also, if you like StreetComplete, **spread the word**! ❤️ + +## License + +This software is released under the terms of the [GNU General Public License](http://www.gnu.org/licenses/gpl-3.0.html). + +## Sponsors + +GitHub Sponsors Liberapay Patreon
+Many users are currently supporting this app through GitHub sponsors, Liberapay and Patreon. If you like the app, you can join them ☺️ to support the continued development and maintenance of the app.
+
+ +JawgMaps
+Since mid 2020, **JawgMaps** provides their vector map tiles service to StreetComplete for free, i.e. the background map displayed in the app.
+
+ +## Past Sponsors + +German Federal Ministry of Education and ResearchPrototype Fund
+Within the frame of **Prototype Fund** round 15 (March 2024 to August 2024), the German Federal Ministry of Education and Research sponsored Tobias Zwick to work on StreetComplete for iOS (see [progress report](https://github.com/streetcomplete/StreetComplete/issues/5421#issuecomment-2332402123))

+Development on this app was also sponsored in round 8 (September 2020 to February 2021) of the Prototype fund, with focus on collecting more data points and on general improvements of this app.
+
+ +nlnet
+The **NLnet foundation** sponsored development on this app in three individual grants with funds from the European Commission:
+Two grants given to Mateusz Konieczny in 2019 and 2021 enabled him to work on StreetComplete for about one year in total, with a focus on clearer UI and improvements on data collection. +Furthermore, yet another grant enabled Tobias Zwick to work about five months in 2021/2022 on - most notably - the overlays functionality and measuring with AR.
+
+ +OpenStreetMap foundation
+In August 2020, the **OpenStreetMap foundation** funded the development of Map Maintenance with StreetComplete within the frame of the microgrants program.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f089a6db61d..20e4b774373 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -32,7 +32,7 @@ android { } defaultConfig { - applicationId = "de.westnordost.streetcomplete" + applicationId = "de.westnordost.streetcomplete.expert" minSdk = 25 targetSdk = 35 versionCode = 6100 @@ -42,13 +42,13 @@ android { buildTypes { all { - isMinifyEnabled = true isShrinkResources = false // don't use proguard-android-optimize.txt, it is too aggressive, it is more trouble than it is worth proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") testProguardFile("test-proguard-rules.pro") } getByName("release") { + isMinifyEnabled = true signingConfig = signingConfigs.getByName("release") buildConfigField("boolean", "IS_GOOGLE_PLAY", "false") } @@ -83,6 +83,13 @@ android { ) abortOnError = false } + + dependenciesInfo { + // Disables dependency metadata when building APKs. + includeInApk = false + // Disables dependency metadata when building Android App Bundles. + includeInBundle = false + } namespace = "de.westnordost.streetcomplete" } @@ -212,6 +219,23 @@ dependencies { // image view that allows zoom and pan implementation("com.github.chrisbanes:PhotoView:2.3.0") + + // faster sqlite library (additional capabilities like R*-tree or json1 not used) + // writing 25% faster, reading 5% faster than Android 9 built-in sqlite (tested with 3.36.0) + implementation("com.github.requery:sqlite-android:3.45.0") + implementation("androidx.sqlite:sqlite:2.4.0") + + // fast json (de)serialization used for database read and write + implementation("com.squareup.moshi:moshi:1.15.1") + + // sunset-sunrise parser for lit quests + implementation("com.luckycatlabs:SunriseSunsetCalculator:1.2") + + // diff utils for comparing filters modified by quest settings with original + implementation("io.github.java-diff-utils:java-diff-utils:4.12") + + // parser for user-supplied GPX tracks + implementation("com.github.ticofab:android-gpx-parser:2.3.1") } /** Localizations that should be pulled from POEditor */ @@ -319,3 +343,10 @@ tasks.register("copyDefaultStringsToEnStrings") { .copyTo(File("$projectDir/src/main/res/values-en/strings.xml"), true) } } + +// this task is EE only, suggestions are used in the tag editor +tasks.register("generateTagSuggestions") { + group = "streetcomplete" + version = presetsVersion + targetDir = "$projectDir/src/main/assets/tag_editor" +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4d603486113..32d6d1a1337 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -17,6 +17,15 @@ public static final ** CREATOR; } +# crashes when selecting some quests from tag editor +-keep class androidx.core.app.CoreComponentFactory { *; } + +# crashes on start after upgrading to gradle 8 (release version only for some reason, though same rules are used) +-keepclassmembers public class io.requery.android.database.sqlite.SQLiteConnection { *; } + +# after upgrading to gradle 8, stack traces contain "unknown source", which is horribly bad making them rather useless +-keepattributes SourceFile,LineNumberTable + # kotlinx-serialization start ---------------------------------------------------------------------- -keepattributes *Annotation*, InnerClasses diff --git a/app/src/androidTest/java/de/westnordost/streetcomplete/data/ApplicationDbTestCase.kt b/app/src/androidTest/java/de/westnordost/streetcomplete/data/ApplicationDbTestCase.kt index 545fecd0ce8..dd8fff039a5 100644 --- a/app/src/androidTest/java/de/westnordost/streetcomplete/data/ApplicationDbTestCase.kt +++ b/app/src/androidTest/java/de/westnordost/streetcomplete/data/ApplicationDbTestCase.kt @@ -1,6 +1,6 @@ package de.westnordost.streetcomplete.data -import android.database.sqlite.SQLiteOpenHelper +import io.requery.android.database.sqlite.SQLiteOpenHelper import androidx.test.platform.app.InstrumentationRegistry import kotlin.test.AfterTest import kotlin.test.BeforeTest diff --git a/app/src/androidTest/java/de/westnordost/streetcomplete/quests/building_colour/BuildingColourItemTest.kt b/app/src/androidTest/java/de/westnordost/streetcomplete/quests/building_colour/BuildingColourItemTest.kt new file mode 100644 index 00000000000..fb8828c0e53 --- /dev/null +++ b/app/src/androidTest/java/de/westnordost/streetcomplete/quests/building_colour/BuildingColourItemTest.kt @@ -0,0 +1,17 @@ +package de.westnordost.streetcomplete.quests.building_colour + +import androidx.test.platform.app.InstrumentationRegistry +import kotlin.test.Test +import kotlin.test.assertNotNull + +class BuildingColourItemTest { + @Test + fun parsableAsColorIcon() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + BuildingColour.values().map { + assertNotNull( + it.asItem(context) + ) + } + } +} diff --git a/app/src/androidTest/java/de/westnordost/streetcomplete/quests/roof_colour/RoofColourItemTest.kt b/app/src/androidTest/java/de/westnordost/streetcomplete/quests/roof_colour/RoofColourItemTest.kt new file mode 100644 index 00000000000..6778f7b7560 --- /dev/null +++ b/app/src/androidTest/java/de/westnordost/streetcomplete/quests/roof_colour/RoofColourItemTest.kt @@ -0,0 +1,30 @@ +package de.westnordost.streetcomplete.quests.roof_colour + +import androidx.test.platform.app.InstrumentationRegistry +import de.westnordost.streetcomplete.quests.roof_shape.RoofShape +import kotlin.test.Test +import kotlin.test.assertNotNull + +class RoofColourItemTest { + @Test + fun parsableAsColorIcon() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + RoofShape.values().map { shape -> + RoofColour.values().map { + assertNotNull( + it.asItem(context, shape) + ) + } + } + } + + @Test + fun parsableAsColorIconNoShape() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + RoofColour.values().map { + assertNotNull( + it.asItem(context, null) + ) + } + } +} diff --git a/app/src/debug/ic_launcher-playstore.png b/app/src/debug/ic_launcher-playstore.png index 26f78301e18..98859e15abe 100644 Binary files a/app/src/debug/ic_launcher-playstore.png and b/app/src/debug/ic_launcher-playstore.png differ diff --git a/app/src/debug/res/drawable-v24/ic_launcher_background.xml b/app/src/debug/res/drawable-v24/ic_launcher_background.xml new file mode 100644 index 00000000000..e0bf512d334 --- /dev/null +++ b/app/src/debug/res/drawable-v24/ic_launcher_background.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml index 67048fd38ac..db4457e1045 100644 --- a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - + - + diff --git a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml index 7353dbd1fd8..bbd3e021239 100644 --- a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher.png b/app/src/debug/res/mipmap-hdpi/ic_launcher.png index 484770a109b..839835651ff 100644 Binary files a/app/src/debug/res/mipmap-hdpi/ic_launcher.png and b/app/src/debug/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png index 5dbe632a90d..1b710a60767 100644 Binary files a/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/debug/res/mipmap-ldpi/ic_launcher.png b/app/src/debug/res/mipmap-ldpi/ic_launcher.png deleted file mode 100644 index d09c982db77..00000000000 Binary files a/app/src/debug/res/mipmap-ldpi/ic_launcher.png and /dev/null differ diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher.png b/app/src/debug/res/mipmap-mdpi/ic_launcher.png index 4d0ef1cf6cd..bf4acc4f082 100644 Binary files a/app/src/debug/res/mipmap-mdpi/ic_launcher.png and b/app/src/debug/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png index 1f4a37a18bc..3ac2cec7443 100644 Binary files a/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png index 84d7aeba7d2..5effc5e126f 100644 Binary files a/app/src/debug/res/mipmap-xhdpi/ic_launcher.png and b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png index 53ba889b807..035f3378fee 100644 Binary files a/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png index dc5f53415ad..2bbd70dd3b1 100644 Binary files a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png index 6c8b32832f1..8687756a387 100644 Binary files a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png index 0c73fc9cfda..d6d7834155a 100644 Binary files a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png index 72d448d0c88..0c0ec414a5d 100644 Binary files a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/debug/res/values/conf.xml b/app/src/debug/res/values/conf.xml index e0acbd8a56a..b072d7f3a34 100644 --- a/app/src/debug/res/values/conf.xml +++ b/app/src/debug/res/values/conf.xml @@ -1,4 +1,4 @@ - de.westnordost.streetcomplete.debug.fileprovider + de.westnordost.streetcomplete.expert.debug.fileprovider diff --git a/app/src/debug/res/values/ic_launcher_background.xml b/app/src/debug/res/values/ic_launcher_background.xml deleted file mode 100644 index 43d595e1c52..00000000000 --- a/app/src/debug/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #FFD42A - \ No newline at end of file diff --git a/app/src/debug/res/values/untranslatableStrings.xml b/app/src/debug/res/values/untranslatableStrings.xml index 22a212ab2dd..9f6511dc943 100644 --- a/app/src/debug/res/values/untranslatableStrings.xml +++ b/app/src/debug/res/values/untranslatableStrings.xml @@ -1,4 +1,4 @@ - Street­Complete Dev + SCEE Dev diff --git a/app/src/debug/res/xml/file_paths.xml b/app/src/debug/res/xml/file_paths.xml index b9f955c5421..d3478812886 100644 --- a/app/src/debug/res/xml/file_paths.xml +++ b/app/src/debug/res/xml/file_paths.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b347cb32719..b3a5f249a0b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,11 @@ Foreground service types exists since Android 10. --> + + + + + @@ -36,6 +41,7 @@ + + + + + + { androidContext().assets } factory { androidContext().resources } - single { CrashReportExceptionHandler(androidContext(), get(), "crashreport.txt") } + single { CrashReportExceptionHandler(androidContext(), get(), get(), "crashreport.txt") } single { DatabaseLogger(get()) } single { SoundFx(androidContext()) } single { HttpClient { diff --git a/app/src/main/java/de/westnordost/streetcomplete/Prefs.kt b/app/src/main/java/de/westnordost/streetcomplete/Prefs.kt new file mode 100644 index 00000000000..010de63814e --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/Prefs.kt @@ -0,0 +1,80 @@ +package de.westnordost.streetcomplete + +/** Constant class to have all the identifiers for SCEE shared preferences in one place */ +object Prefs { + const val VOLUME_ZOOM = "volume_button_zoom" + const val SHOW_3D_BUILDINGS = "3d_buildings" + const val QUEST_GEOMETRIES = "quest_geometries" + const val AUTO_DOWNLOAD = "auto_download" + const val GPS_INTERVAL = "gps_interval" + const val NETWORK_INTERVAL = "network_interval" + const val HIDE_NOTES_BY_USERS = "hide_notes_by_users2" + const val MANUAL_DOWNLOAD_OVERRIDE_CACHE = "manual_download_override_cache" + const val QUICK_SETTINGS = "quick_settings" + const val ALLOWED_LEVEL = "allowed_level" + const val ALLOWED_LEVEL_TAGS = "allowed_level_tags" + const val RESURVEY_KEYS = "resurvey_keys" + const val RESURVEY_DATE = "resurvey_date" + const val GPX_BUTTON = "gpx_button" + const val SWAP_GPX_NOTE_BUTTONS = "swap_gpx_note_buttons" + const val HIDE_KEYBOARD_FOR_NOTE = "hide_keyboard_for_note" + const val OFFSET_FIX = "offset_fix" + const val DAY_NIGHT_BEHAVIOR = "day_night_behavior" + const val QUEST_SETTINGS_PER_PRESET = "quest_settings_per_preset" + const val SHOW_HIDE_BUTTON = "show_hide_button" + const val SELECT_FIRST_EDIT = "select_first_edit" + const val BAN_CHECK_ERROR_COUNT = "ban_check_error_count" + const val DATA_RETAIN_TIME = "data_retain_time" + const val FAVS_FIRST_MIN_LINES = "favs_first_min_lines" + const val SHOW_NEARBY_QUESTS = "show_nearby_quests" + const val SHOW_NEARBY_QUESTS_DISTANCE = "show_nearby_quests_distance" + const val CUSTOM_OVERLAY_IDX_FILTER = "custom_overlay_idx_filter" + const val CUSTOM_OVERLAY_IDX_COLOR_KEY = "custom_overlay_idx_color_key" + const val CUSTOM_OVERLAY_IDX_NAME = "custom_overlay_idx_name" + const val CUSTOM_OVERLAY_IDX_ICON = "custom_overlay_idx_icon" + const val CUSTOM_OVERLAY_IDX_DASH_FILTER = "custom_overlay_idx_dash_filter" + const val CUSTOM_OVERLAY_IDX_HIGHLIGHT_MISSING_DATA = "custom_overlay_idx_highlight_missing_data" + const val CUSTOM_OVERLAY_INDICES = "custom_overlay_indices" + const val CUSTOM_OVERLAY_SELECTED_INDEX = "custom_overlay_selected_index" + const val SHOW_SOLVED_ANIMATION = "show_solved_animation" + const val SHOW_NEXT_QUEST_IMMEDIATELY = "show_next_quest_immediately" + const val MAIN_MENU_FULL_GRID = "main_menu_full_grid" + const val CREATE_POI_RECENT_FEATURE_IDS = "create_poi_recent_feature_ids" + const val DYNAMIC_QUEST_CREATION = "dynamic_quest_creation" + const val QUEST_MONITOR = "quest_monitor" + const val QUEST_MONITOR_GPS = "quest_monitor_gps" + const val QUEST_MONITOR_NET = "quest_monitor_net" + const val QUEST_MONITOR_RADIUS = "quest_monitor_radius" + const val QUEST_MONITOR_DOWNLOAD = "quest_monitor_download" + const val SHOW_GPX_TRACK = "show_gpx_track" + const val RASTER_TILE_URL = "raster_tile_url" + const val RASTER_TILE_MAXZOOM = "raster_tile_maxzoom" + const val CREATE_EXTERNAL_QUESTS = "create_external_quests" + const val SAVE_PHOTOS = "save_photos" + const val EXPERT_MODE = "expert_mode" + const val SHOW_WAY_DIRECTION = "show_way_direction" + const val SEARCH_MORE_LANGUAGES = "search_more_languages" + const val NO_SATELLITE_LABEL = "no_satellite_label" + const val CAPS_WORD_NAME_INPUT = "caps_word_name_input" + const val INSERT_NODE_RECENT_FEATURE_IDS = "insert_node_recent_feature_ids" + const val OVERLAY_QUICK_SELECTOR = "overlay_quick_selector" + const val CREATE_NODE_LAST_TAGS_FOR_FEATURE = "create_node_last_tags_for_" + const val CREATE_NODE_SHOW_KEYBOARD = "create_node_show_keyboard" + const val UPDATE_LOCAL_STATISTICS = "update_local_statistics" + const val HIDE_OVERLAY_QUESTS = "hide_overlay_quests" + const val MAIN_MENU_SWITCH_PRESETS = "main_menu_switch_presets" + const val DISABLE_NAVIGATION_MODE = "disable_navigation_mode" + const val TEMP_LOGGER = "temp_logger" + const val SHOW_CUSTOM_GEOMETRY = "show_custom_geometry" + const val THEME_BACKGROUND = "theme.background_type" + const val REALLY_ALL_NOTES = "really_all_notes" + const val ROTATE_WHILE_ZOOMING = "rotate_while_zooming" + const val ROTATE_ANGLE_THRESHOLD = "rotate_angle_threshold" + const val OVERRIDE_COUNTRY_RESTRICTIONS = "override_country_restrictions" + + enum class DayNightBehavior(val titleResId: Int) { + IGNORE(R.string.day_night_ignore), + PRIORITY(R.string.day_night_priority), + VISIBILITY(R.string.day_night_visibility) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/StreetCompleteApplication.kt b/app/src/main/java/de/westnordost/streetcomplete/StreetCompleteApplication.kt index 2ab494a72a2..5deced9553d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/StreetCompleteApplication.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/StreetCompleteApplication.kt @@ -1,7 +1,11 @@ package de.westnordost.streetcomplete +import android.app.ActivityManager +import android.app.ActivityManager.MemoryInfo import android.app.Application import android.content.ComponentCallbacks2 +import android.content.Context +import android.content.SharedPreferences import android.net.ConnectivityManager import android.os.LocaleList import androidx.appcompat.app.AppCompatDelegate @@ -12,6 +16,7 @@ import androidx.work.WorkManager import com.russhwolf.settings.SettingsListener import de.westnordost.streetcomplete.data.CacheTrimmer import de.westnordost.streetcomplete.data.CleanerWorker +import de.westnordost.streetcomplete.data.DatabaseInitializer import de.westnordost.streetcomplete.data.Preloader import de.westnordost.streetcomplete.data.allEditTypesModule import de.westnordost.streetcomplete.data.changelog.changelogModule @@ -33,6 +38,7 @@ import de.westnordost.streetcomplete.data.osmApiModule import de.westnordost.streetcomplete.data.osmnotes.edits.noteEditsModule import de.westnordost.streetcomplete.data.osmnotes.notequests.osmNoteQuestModule import de.westnordost.streetcomplete.data.osmnotes.notesModule +import de.westnordost.streetcomplete.data.externalsource.externalSourceModule import de.westnordost.streetcomplete.data.overlays.overlayModule import de.westnordost.streetcomplete.data.platform.platformModule import de.westnordost.streetcomplete.data.preferences.Preferences @@ -54,9 +60,13 @@ import de.westnordost.streetcomplete.quests.questsModule import de.westnordost.streetcomplete.screens.about.aboutScreenModule import de.westnordost.streetcomplete.screens.main.mainModule import de.westnordost.streetcomplete.screens.measure.arModule +import de.westnordost.streetcomplete.screens.settings.LAST_KNOWN_DB_VERSION +import de.westnordost.streetcomplete.screens.settings.renamedQuests +import de.westnordost.streetcomplete.screens.settings.renameUpdatedQuests import de.westnordost.streetcomplete.screens.settings.settingsModule import de.westnordost.streetcomplete.screens.user.userScreenModule import de.westnordost.streetcomplete.util.CrashReportExceptionHandler +import de.westnordost.streetcomplete.util.TempLogger import de.westnordost.streetcomplete.util.getSelectedLocales import de.westnordost.streetcomplete.util.ktx.deleteRecursively import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds @@ -98,6 +108,10 @@ class StreetCompleteApplication : Application() { override fun onCreate() { super.onCreate() + // got a crash report where prefs were not initialized, not sure how this can happen for a + // single person and not for everyone, but this should help (means that we keep using android-specific prefs interface) + preferences = getSharedPreferences(packageName + "_preferences", Context.MODE_PRIVATE) + deleteDatabase(ApplicationConstants.OLD_DATABASE_NAME) startKoin { @@ -141,12 +155,18 @@ class StreetCompleteApplication : Application() { overlayModule, urlConfigModule, urlConfigModule, - platformModule + platformModule, + externalSourceModule, ) } setLoggerInstances() + applicationScope.launch { + editHistoryController.deleteSyncedOlderThan(nowAsEpochMilliseconds() - ApplicationConstants.MAX_UNDO_HISTORY_AGE) + preloader.preload() + } + // Force logout users who are logged in with OAuth 1.0a, they need to re-authenticate with OAuth 2 if (prefs.hasOAuth1AccessToken) { userLoginController.logOut() @@ -156,11 +176,6 @@ class StreetCompleteApplication : Application() { crashReportExceptionHandler.install() - applicationScope.launch { - preloader.preload() - editHistoryController.deleteSyncedOlderThan(nowAsEpochMilliseconds() - ApplicationConstants.MAX_UNDO_HISTORY_AGE) - } - if (isConnected) userUpdater.update() enqueuePeriodicCleanupWork() @@ -169,19 +184,39 @@ class StreetCompleteApplication : Application() { resurveyIntervalsUpdater.update() + require(DatabaseInitializer.DB_VERSION == LAST_KNOWN_DB_VERSION.toInt()) { "update database import/export" } val lastVersion = prefs.lastDataVersion + if (BuildConfig.VERSION_NAME != lastVersion) { prefs.lastDataVersion = BuildConfig.VERSION_NAME if (lastVersion != null) { onNewVersion() } + // update prefs referring to renamed quests + val prefsToRename = preferences.all.filter { pref -> + val v = pref.value + renamedQuests.keys.any { pref.key.contains(it) || (v is String && v.contains(it)) } + } + val e = preferences.edit() + prefsToRename.forEach { + e.remove(it.key) + when (it.value) { + is String -> e.putString(it.key.renameUpdatedQuests(), (it.value as String).renameUpdatedQuests()) + is Boolean -> e.putBoolean(it.key.renameUpdatedQuests(), it.value as Boolean) + is Int -> e.putInt(it.key.renameUpdatedQuests(), it.value as Int) + is Long -> e.putLong(it.key.renameUpdatedQuests(), it.value as Long) + is Float -> e.putFloat(it.key.renameUpdatedQuests(), it.value as Float) + is Set<*> -> e.putStringSet(it.key.renameUpdatedQuests(), it.value as? Set?) + } + } + e.apply() } clearTangramCache() - settingsListeners += prefs.onLanguageChanged { updateDefaultLocales() } settingsListeners += prefs.onThemeChanged { updateTheme(it) } } + private fun onNewVersion() { // on each new version, invalidate quest cache downloadedTilesController.invalidateAll() @@ -198,9 +233,11 @@ class StreetCompleteApplication : Application() { ComponentCallbacks2.TRIM_MEMORY_COMPLETE, ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { // very low on memory -> drop caches cacheTrimmer.clearCaches() + Log.i("StreetCompleteApplication", "onTrimMemory, level $level: ${getMemString()}") } ComponentCallbacks2.TRIM_MEMORY_MODERATE, ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> { // memory needed, but not critical -> trim only + Log.i("StreetCompleteApplication", "onTrimMemory, level $level: ${getMemString()}") cacheTrimmer.trimCaches() } } @@ -210,13 +247,25 @@ class StreetCompleteApplication : Application() { LocaleList.setDefault(getSelectedLocales(prefs)) } + private fun getMemString(): String { + val memInfo = MemoryInfo() + getSystemService()?.getMemoryInfo(memInfo) + return "${memInfo.availMem / 0x100000L} MB of ${memInfo.totalMem / 0x100000L} available, mem low: ${memInfo.lowMemory}, mem low threshold: ${memInfo.threshold / 0x100000L} MB" + } + private fun updateTheme(theme: Theme) { + if (theme == Theme.DARK_CONTRAST || theme == Theme.DARK) + // night mode off to trigger reload (maybe there is a way to do it without this, but at least ir works...) + AppCompatDelegate.setDefaultNightMode(Theme.LIGHT.appCompatNightMode) AppCompatDelegate.setDefaultNightMode(theme.appCompatNightMode) } private fun setLoggerInstances() { Log.instances.add(AndroidLogger()) - Log.instances.add(databaseLogger) + if (prefs.getBoolean(Prefs.TEMP_LOGGER, false)) + Log.instances.add(TempLogger) + else + Log.instances.add(databaseLogger) } private fun enqueuePeriodicCleanupWork() { @@ -231,6 +280,10 @@ class StreetCompleteApplication : Application() { ) } + companion object { + lateinit var preferences: SharedPreferences + } + private val isConnected: Boolean get() = getSystemService()?.activeNetworkInfo?.isConnected == true @@ -248,6 +301,6 @@ class StreetCompleteApplication : Application() { private val Theme.appCompatNightMode: Int get() = when (this) { Theme.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO - Theme.DARK -> AppCompatDelegate.MODE_NIGHT_YES + Theme.DARK, Theme.DARK_CONTRAST -> AppCompatDelegate.MODE_NIGHT_YES Theme.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/AllEditTypes.kt b/app/src/main/java/de/westnordost/streetcomplete/data/AllEditTypes.kt index c6fb89f06a4..e7c2ca78584 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/AllEditTypes.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/AllEditTypes.kt @@ -3,10 +3,12 @@ package de.westnordost.streetcomplete.data import de.westnordost.streetcomplete.data.osm.edits.EditType class AllEditTypes( - registries: List> + registries: List>, + extras: List = emptyList() ) : AbstractCollection() { private val byName = registries.flatten().associateByTo(LinkedHashMap()) { it.name } + .apply { extras.forEach { put(it.name, it) } } override val size: Int get() = byName.size diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/AndroidDatabase.kt b/app/src/main/java/de/westnordost/streetcomplete/data/AndroidDatabase.kt index 91c2764e11a..58e4d2dd47f 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/AndroidDatabase.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/AndroidDatabase.kt @@ -3,21 +3,20 @@ package de.westnordost.streetcomplete.data import android.annotation.SuppressLint import android.content.ContentValues import android.database.Cursor -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT -import android.database.sqlite.SQLiteDatabase.CONFLICT_FAIL -import android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE -import android.database.sqlite.SQLiteDatabase.CONFLICT_NONE -import android.database.sqlite.SQLiteDatabase.CONFLICT_REPLACE -import android.database.sqlite.SQLiteDatabase.CONFLICT_ROLLBACK -import android.database.sqlite.SQLiteStatement +import io.requery.android.database.sqlite.SQLiteDatabase +import io.requery.android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT +import io.requery.android.database.sqlite.SQLiteDatabase.CONFLICT_FAIL +import io.requery.android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE +import io.requery.android.database.sqlite.SQLiteDatabase.CONFLICT_NONE +import io.requery.android.database.sqlite.SQLiteDatabase.CONFLICT_REPLACE +import io.requery.android.database.sqlite.SQLiteDatabase.CONFLICT_ROLLBACK +import io.requery.android.database.sqlite.SQLiteStatement import androidx.core.database.getBlobOrNull import androidx.core.database.getDoubleOrNull import androidx.core.database.getFloatOrNull import androidx.core.database.getIntOrNull import androidx.core.database.getLongOrNull import androidx.core.database.getStringOrNull -import androidx.core.database.sqlite.transaction import de.westnordost.streetcomplete.data.ConflictAlgorithm.ABORT import de.westnordost.streetcomplete.data.ConflictAlgorithm.FAIL import de.westnordost.streetcomplete.data.ConflictAlgorithm.IGNORE @@ -131,7 +130,16 @@ class AndroidDatabase(private val db: SQLiteDatabase) : Database { return db.delete(table, where, strArgs) } - override fun transaction(block: () -> T): T = db.transaction { block() } + override fun transaction(block: () -> T): T { + db.beginTransaction() + try { + val result = block() + db.setTransactionSuccessful() + return result + } finally { + db.endTransaction() + } + } } private fun Array.primitivesArrayToStringArray() = Array(size) { i -> diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/Cleaner.kt b/app/src/main/java/de/westnordost/streetcomplete/data/Cleaner.kt index 81b5981d335..24498c1341f 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/Cleaner.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/Cleaner.kt @@ -1,6 +1,8 @@ package de.westnordost.streetcomplete.data +import com.russhwolf.settings.ObservableSettings import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesController import de.westnordost.streetcomplete.data.logs.LogsController import de.westnordost.streetcomplete.data.maptiles.MapTilesDownloader @@ -24,13 +26,14 @@ class Cleaner( private val downloadedTilesController: DownloadedTilesController, private val logsController: LogsController, private val mapTilesDownloader: MapTilesDownloader, + private val prefs: ObservableSettings, ) { private val scope = CoroutineScope(SupervisorJob() + CoroutineName("Cleaner") + Dispatchers.IO) fun cleanOld() = scope.launch { val time = nowAsEpochMilliseconds() - val oldDataTimestamp = nowAsEpochMilliseconds() - ApplicationConstants.DELETE_OLD_DATA_AFTER + val oldDataTimestamp = nowAsEpochMilliseconds() - prefs.getInt(Prefs.DATA_RETAIN_TIME, ApplicationConstants.DELETE_OLD_DATA_AFTER_DAYS) * 24L * 60 * 60 * 1000 noteController.deleteOlderThan(oldDataTimestamp, MAX_DELETE_ELEMENTS) mapDataController.deleteOlderThan(oldDataTimestamp, MAX_DELETE_ELEMENTS) downloadedTilesController.deleteOlderThan(oldDataTimestamp) diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/DatabaseInitializer.kt b/app/src/main/java/de/westnordost/streetcomplete/data/DatabaseInitializer.kt index 184718c1514..2736f51de56 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/DatabaseInitializer.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/DatabaseInitializer.kt @@ -190,6 +190,7 @@ object DatabaseInitializer { } if (oldVersion <= 8 && newVersion > 8) { db.renameQuest("AddPicnicTableCover", "AddAmenityCover") + db.renameValue(ElementEditsTable.NAME, ElementEditsTable.Columns.QUEST_TYPE,"ExternalQuest", "CustomQuest") } if (oldVersion <= 9 && newVersion > 9) { db.exec("DROP TABLE ${DownloadedTilesTable.NAME};") diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/GpxImport.kt b/app/src/main/java/de/westnordost/streetcomplete/data/GpxImport.kt new file mode 100644 index 00000000000..ed912406e60 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/GpxImport.kt @@ -0,0 +1,261 @@ +package de.westnordost.streetcomplete.data + +import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.data.download.tiles.TilePos +import de.westnordost.streetcomplete.data.download.tiles.enclosingTilesRect +import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osm.mapdata.toPolygon +import de.westnordost.streetcomplete.util.logs.Log +import de.westnordost.streetcomplete.util.math.area +import de.westnordost.streetcomplete.util.math.distanceTo +import de.westnordost.streetcomplete.util.math.enclosingBoundingBox +import de.westnordost.streetcomplete.util.math.initialBearingTo +import de.westnordost.streetcomplete.util.math.translate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.math.sqrt + +private const val TAG = "GpxImport" + +// file slightly modified from https://github.com/streetcomplete/StreetComplete/pull/5369 +data class GpxImportData( + val displayTrack: Boolean, + val downloadAlongTrack: Boolean, + val trackpoints: List, + val downloadBBoxes: List, + val areaToDownloadInSqkm: Double, +) + +private class DecoratedBoundingBox(val polygon: Iterable) { + val boundingBox = polygon.enclosingBoundingBox() + val area = boundingBox.area() + val tiles = boundingBox.enclosingTilesRect(ApplicationConstants.DOWNLOAD_TILE_ZOOM) + .asTilePosSequence() + val numberOfTiles = tiles.count() +} + +// TODO sgr: refactor function signature when adapting UI +/** + * @param originalTrackPoints points from GPX + * @param displayTrack display the track on the map after import + * @param minDownloadDistance in meters; points within minDownloadDistance along the track should be downloaded + */ +suspend fun importGpx( + originalTrackPoints: List, + displayTrack: Boolean, + minDownloadDistance: Double, +): Result = withContext(Dispatchers.Default) { + require(minDownloadDistance in 10.0..500.0) { + "minDownloadDistance needs to be of reasonable size" + } + + /* Algorithm overview: + * + * Given that two resampled points A and B are at most 2 * minDownloadDistance away from each + * other and any track point between them is at most minDownloadDistance away from either A or B, + * an area that fully contains the track between A and B is given by a square S_track centered + * on the middle point between A and B, with side length 2 * minDownloadDistance and rotated + * such that two of its sides align with the vector from A to B. As we need to cover the area + * within minDownloadDistance of any track point (which might lie almost on the edge of S_track), + * a square S_min centered and rotated the same as S_track, but with + * side length = 4 * minDownloadDistance is a handy upper bound. + * + * If we download two non-rotated squares centered on A and B, they are guaranteed to contain + * S_min if their side length is at least 4 * minDownloadDistance / sqrt(2) - the worst case + * being were S_min is rotated 45 degrees with respect to the non-rotated squares. + */ + val maxSampleDistance = 2 * minDownloadDistance + val coveringSquareHalfLength = 2 * minDownloadDistance / sqrt(2.0) + + var progress = 0 + val mergedBBoxes = originalTrackPoints + .asSequence() + // TODO sgr: just a test how one could decorate sequences with a callback + // -> this approach would need orchestration at this level from UI code + .map { + progress++ + if (progress % 500 == 0) { + Log.d(TAG, "updating progress: ${progress / originalTrackPoints.size}") + } + it + } + .addInterpolatedPoints(maxSampleDistance) + .discardRedundantPoints(maxSampleDistance) + .mapToCenteredSquares(coveringSquareHalfLength) + .determineBBoxesToDownload() + .mergeBBoxesToDownload() + + return@withContext Result.success( + GpxImportData( + displayTrack, + true, + originalTrackPoints, + mergedBBoxes.map { it.boundingBox }.toList(), + mergedBBoxes + .flatMap { it.tiles } + .distinct() + .sumOf { it.asBoundingBox(ApplicationConstants.DOWNLOAD_TILE_ZOOM).area() } + / 1000000 + ) + ) +} + +/** + * TODO sgr: convert implementation to real sequence.. might be tricky + * Iteratively merge bounding boxes to save download calls in trade for a few more unique tiles + * downloaded + */ +private fun Sequence.mergeBBoxesToDownload(): Sequence { + var bBoxes = this.toList() + val mergedBBoxes = ArrayList() + while (mergedBBoxes.size < bBoxes.size) { + Log.d(TAG, "start a new round of bounding box merging") + var currentBBox: DecoratedBoundingBox? = null + for (bBox in bBoxes) { + if (currentBBox == null) { + currentBBox = bBox + continue + } + val mergedBBox = DecoratedBoundingBox(bBox.polygon + currentBBox.polygon) + // merge two adjacent boxes if at most one additional tile needs to be downloaded to save one call + currentBBox = + if (mergedBBox.numberOfTiles <= (currentBBox.tiles + bBox.tiles).toHashSet().size + 1) { + Log.d(TAG, "merge currentBBox with previous one") + mergedBBox + } else { + Log.d(TAG, "keep currentBBox separate from previous one") + mergedBBoxes.add(currentBBox) + bBox + } + } + currentBBox?.let { mergedBBoxes.add(it) } + if (mergedBBoxes.size < bBoxes.size) { + Log.d(TAG, "reduced bounding boxes from ${bBoxes.size} to ${mergedBBoxes.size}") + bBoxes = mergedBBoxes.toList() + mergedBBoxes.clear() + } else { + Log.d(TAG, "final number of bounding boxes: ${mergedBBoxes.size}") + } + } + return mergedBBoxes.asSequence() +} + +private fun Sequence.determineBBoxesToDownload(): Sequence { + var currentBBox: DecoratedBoundingBox? = null + val uniqueTilesToDownload = HashSet() + val inputIterator = this.map { DecoratedBoundingBox(it.toPolygon()) }.withIndex().iterator() + return sequence { + for ((index, newBBox) in inputIterator) { + if (currentBBox == null) { + currentBBox = newBBox + yield(newBBox) + continue + } + + if (!newBBox.tiles.any { tilePos -> tilePos !in uniqueTilesToDownload }) { + Log.d(TAG, "omit bounding box #$index, all tiles already scheduled for download") + continue + } + + val extendedBBox = DecoratedBoundingBox(currentBBox!!.polygon + newBBox.polygon) + currentBBox = if ( + // no additional tile needed to extend the polygon and download newBBox together with currentBBox + extendedBBox.numberOfTiles <= (currentBBox!!.tiles + newBBox.tiles).toHashSet().size + || + // downloaded area is not increased by extending the current polygon instead of downloading separately + extendedBBox.area < currentBBox!!.area + newBBox.area + ) { + Log.d(TAG, "extend currentBBox with bounding box #$index") + extendedBBox + } else { + Log.d(TAG, "schedule currentBBox, start new with bounding box #$index") + yield(currentBBox!!) + uniqueTilesToDownload.addAll(currentBBox!!.tiles) + newBBox + } + } + currentBBox?.let { yield(it) } + } +} + +/** + * Transform a sequence of points to a sequence of bounding boxes centered on the points. + */ +private fun Sequence.mapToCenteredSquares(halfSideLength: Double): Sequence = + map { + arrayListOf( + it.translate(halfSideLength, 0.0), + it.translate(halfSideLength, 90.0), + it.translate(halfSideLength, 180.0), + it.translate(halfSideLength, 270.0) + ).enclosingBoundingBox() + } + +/** + * Ensure points are at most samplingDistance away from each other. + * + * Given two consecutive points A, B which are more than samplingDistance away from each other, + * add intermediate points on the line from A to B, samplingDistance away from each other until the + * last one is <= samplingDistance away from B. + */ +private fun Sequence.addInterpolatedPoints(samplingDistance: Double): Sequence { + var candidatePoint: LatLon? = null + val seq = this.flatMap { currentPoint -> + if (candidatePoint == null) { + candidatePoint = currentPoint + return@flatMap emptySequence() + } + val interpolatedPoints = interpolate(candidatePoint!!, currentPoint, samplingDistance) + candidatePoint = currentPoint + return@flatMap interpolatedPoints + } + return seq + sequenceOf(candidatePoint).mapNotNull { it } +} + +/** + * Interpolate points between start (included) and end (not included) + * + * Returned points are samplingDistance away from each other and on the line between start and end. + * The last returned point is <= samplingDistance away from end. + */ +private fun interpolate(start: LatLon, end: LatLon, samplingDistance: Double): Sequence = + sequence { + val bearing = start.initialBearingTo(end) + var intermediatePoint = start + while (true) { + yield(intermediatePoint) + intermediatePoint = intermediatePoint.translate(samplingDistance, bearing) + if (intermediatePoint.distanceTo(end) <= samplingDistance) { + break + } + } + } + +/** + * Discard redundant points, such that no three remaining points A, B, C exist where B is less than + * samplingDistance away from both A and C + */ +private fun Sequence.discardRedundantPoints(samplingDistance: Double): Sequence { + var lastRetainedPoint: LatLon? = null + var candidatePoint: LatLon? = null + return this.flatMap { currentPoint -> + sequence { + if (candidatePoint == null) { + candidatePoint = currentPoint + } else if (lastRetainedPoint == null) { + lastRetainedPoint = candidatePoint + candidatePoint = currentPoint + } else if (lastRetainedPoint!!.distanceTo(candidatePoint!!) < samplingDistance + && candidatePoint!!.distanceTo(currentPoint) < samplingDistance + ) { + // discard candidatePoint + candidatePoint = currentPoint + } else { + lastRetainedPoint = candidatePoint + yield(lastRetainedPoint!!) + candidatePoint = currentPoint + } + } + } + sequenceOf(candidatePoint).mapNotNull { it } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/ObjectTypeRegistry.kt b/app/src/main/java/de/westnordost/streetcomplete/data/ObjectTypeRegistry.kt index 4901dcbd042..e10d06e586e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/ObjectTypeRegistry.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/ObjectTypeRegistry.kt @@ -5,32 +5,30 @@ package de.westnordost.streetcomplete.data * 2. or recalled by ordinal * 3. or iterated in the order as specified in the constructor */ -open class ObjectTypeRegistry(ordinalsAndEntries: List>) : AbstractList() { +open class ObjectTypeRegistry(private val ordinalsAndEntries: List>) : AbstractList() { - private val byName: Map - private val byOrdinal: Map - private val ordinalByObject: Map - private val objects = ordinalsAndEntries.map { it.second } + protected val byName = hashMapOf() + protected val byOrdinal = hashMapOf() + protected val ordinalByObject = hashMapOf() + protected val objects = mutableListOf() - init { - val byNameMap = mutableMapOf() - val highestOrdinal = ordinalsAndEntries.maxBy { it.first }.first - val byOrdinalMap = HashMap(highestOrdinal + 1) + init { reloadInit() } + + protected fun reloadInit() { for ((ordinal, objectType) in ordinalsAndEntries) { - val typeName = objectType::class.simpleName!! - require(!byNameMap.containsKey(typeName)) { + val typeName = objectType::class.simpleName!!.intern() + require(!byName.containsKey(typeName)) { "A object type's name must be unique! \"$typeName\" is defined twice!" } - require(!byOrdinalMap.containsKey(ordinal)) { - val otherTypeName = byOrdinalMap[ordinal]!!::class.simpleName!! + require(!byOrdinal.containsKey(ordinal)) { + val otherTypeName = byOrdinal[ordinal]!!::class.simpleName!! "Duplicate ordinal for \"$typeName\" and \"$otherTypeName\"" } - byNameMap[typeName] = objectType - byOrdinalMap[ordinal] = objectType + byName[typeName] = objectType + byOrdinal[ordinal] = objectType } - ordinalByObject = ordinalsAndEntries.associate { it.second to it.first } - byName = byNameMap - byOrdinal = byOrdinalMap + ordinalsAndEntries.associateTo(ordinalByObject) { it.second to it.first } + ordinalsAndEntries.mapTo(objects) { it.second } } fun getByName(typeName: String): T? = byName[typeName] diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt index 4cb80097bd6..f5e92b86e3a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt @@ -24,7 +24,7 @@ val OSM_API_URL = val osmApiModule = module { - factory { Cleaner(get(), get(), get(), get(), get(), get()) } + factory { Cleaner(get(), get(), get(), get(), get(), get(), get()) } factory { CacheTrimmer(get(), get()) } factory { MapDataApiClient(get(), OSM_API_URL, get(), get(), get()) } factory { NotesApiClient(get(), OSM_API_URL, get(), get()) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt b/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt index e95fc04d48f..c276f0619a2 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt @@ -1,14 +1,38 @@ package de.westnordost.streetcomplete.data import android.content.Context -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteOpenHelper +import io.requery.android.database.sqlite.SQLiteDatabase +import io.requery.android.database.sqlite.SQLiteOpenHelper +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestTable +import de.westnordost.streetcomplete.quests.osmose.OsmoseTable +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables class StreetCompleteSQLiteOpenHelper(context: Context, dbName: String) : SQLiteOpenHelper(context, dbName, null, DatabaseInitializer.DB_VERSION) { override fun onCreate(db: SQLiteDatabase) { DatabaseInitializer.onCreate(AndroidDatabase(db)) + + // external source and osmose tables (get created in init as well, but apparently may still cause issues) + db.execSQL(ExternalSourceQuestTables.CREATE_HIDDEN) + db.execSQL(ExternalSourceQuestTables.CREATE_EDITS) + db.execSQL(OsmoseTable.CREATE_IF_NOT_EXISTS) + db.execSQL(OsmoseTable.CREATE_SPATIAL_INDEX_IF_NOT_EXISTS) + } + + init { + // create some EE tables if not existing + // this is to avoid actual db upgrade to keep compatibility with upstream + + // create other source tables + writableDatabase.execSQL(ExternalSourceQuestTables.CREATE_HIDDEN) + writableDatabase.execSQL(ExternalSourceQuestTables.CREATE_EDITS) + + // create osmose table + writableDatabase.execSQL(OsmoseTable.CREATE_IF_NOT_EXISTS) + writableDatabase.execSQL(OsmoseTable.CREATE_SPATIAL_INDEX_IF_NOT_EXISTS) + // create osm quests element id index if not existing + writableDatabase.execSQL(OsmQuestTable.CREATE_ELEMENT_ID_INDEX_IF_NOT_EXISTS) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadController.kt index 37100e91cf6..343c48db1c7 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadController.kt @@ -15,7 +15,11 @@ class DownloadController(private val context: Context) { * @param isUserInitiated whether this shall be a priority download (cancels previous downloads * and puts itself in the front) */ - fun download(bbox: BoundingBox, isUserInitiated: Boolean = false) { + fun download(bbox: BoundingBox, isUserInitiated: Boolean = false, enqueue: Boolean = false) { + if (enqueue && DownloadWorker.downloading) { + DownloadWorker.enqueuedDownloads.add(bbox) + return + } WorkManager.getInstance(context).enqueueUniqueWork( Downloader.TAG, if (isUserInitiated) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP, diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadModule.kt index 3b260a92a98..2fa77bbdcbc 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadModule.kt @@ -15,7 +15,7 @@ val downloadModule = module { factory { MobileDataAutoDownloadStrategy(get(), get()) } factory { WifiAutoDownloadStrategy(get(), get()) } - single { Downloader(get(), get(), get(), get(), get(), get(named("SerializeSync"))) } + single { Downloader(get(), get(), get(), get(), get(), get(named("SerializeSync")), get()) } single { get() } single { DownloadController(get()) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadWorker.kt b/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadWorker.kt index 9a817005f01..20fccad4255 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadWorker.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadWorker.kt @@ -12,9 +12,9 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.sync.createSyncNotification -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json /** Downloads all quests and tiles in a given area asynchronously. @@ -43,20 +43,34 @@ class DownloadWorker( override suspend fun doWork(): Result { val bbox: BoundingBox = inputData.getString(ARG_BBOX)?.let { Json.decodeFromString(it) } ?: return Result.failure() + downloading = true return try { val isPriorityDownload = inputData.getBoolean(ARG_IS_USER_INITIATED, false) - downloader.download(bbox, isPriorityDownload) + downloader.download(bbox, + isPriorityDownload, + context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE) + .getBoolean(Prefs.MANUAL_DOWNLOAD_OVERRIDE_CACHE, true) + ) Result.success() } catch (e: Exception) { Result.failure() + } finally { + downloading = false + if (enqueuedDownloads.isNotEmpty()) { + val next = enqueuedDownloads.first() + enqueuedDownloads.removeFirstOrNull() + DownloadController(context).download(next, true) + } } } companion object { private const val ARG_BBOX = "bbox" private const val ARG_IS_USER_INITIATED = "isUserInitiated" + var downloading = false + val enqueuedDownloads = mutableListOf() fun createWorkRequest(bbox: BoundingBox, isUserInitiated: Boolean): OneTimeWorkRequest = OneTimeWorkRequestBuilder() .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST) diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/download/Downloader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/download/Downloader.kt index 98a33ed096a..c3fd4f50622 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/download/Downloader.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/download/Downloader.kt @@ -9,6 +9,7 @@ import de.westnordost.streetcomplete.data.maptiles.MapTilesDownloader import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osm.mapdata.MapDataDownloader import de.westnordost.streetcomplete.data.osmnotes.NotesDownloader +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController import de.westnordost.streetcomplete.data.user.UserLoginController import de.westnordost.streetcomplete.util.Listeners import de.westnordost.streetcomplete.util.ktx.format @@ -19,6 +20,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.yield import kotlin.coroutines.cancellation.CancellationException import kotlin.math.max @@ -29,7 +31,8 @@ class Downloader( private val mapTilesDownloader: MapTilesDownloader, private val downloadedTilesController: DownloadedTilesController, private val userLoginController: UserLoginController, - private val mutex: Mutex + private val mutex: Mutex, + private val externalSourceQuestController: ExternalSourceQuestController, ) : DownloadProgressSource { private val listeners = Listeners() @@ -40,7 +43,7 @@ class Downloader( override var isDownloadInProgress: Boolean = false private set - suspend fun download(bbox: BoundingBox, isUserInitiated: Boolean) { + suspend fun download(bbox: BoundingBox, isUserInitiated: Boolean, ignoreCache: Boolean) { var hasError = false try { isDownloadInProgress = true @@ -57,7 +60,7 @@ class Downloader( ).joinToString(",") val sqkm = (tilesBbox.area() / 1000 / 1000).format(1) - if (!isUserInitiated && hasDownloadedAlready(tiles)) { + if (!ignoreCache && hasDownloadedAlready(tiles)) { Log.i(TAG, "Not downloading ($sqkm km², bbox: $bboxString), data still fresh") return } @@ -69,7 +72,12 @@ class Downloader( coroutineScope { // all downloaders run concurrently launch { notesDownloader.download(tilesBbox) } - launch { mapDataDownloader.download(tilesBbox) } + launch { + mapDataDownloader.download(tilesBbox) + yield() + // download externalSource stuff after map data, because quest creation may depend on map data + externalSourceQuestController.download(bbox) + } launch { mapTilesDownloader.download(tilesBbox) } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/download/tiles/TilesRect.kt b/app/src/main/java/de/westnordost/streetcomplete/data/download/tiles/TilesRect.kt index dc6a1557715..cb2aed069aa 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/download/tiles/TilesRect.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/download/tiles/TilesRect.kt @@ -42,6 +42,48 @@ fun Collection.minTileRect(): TilesRect? { return TilesRect(left, top, right, bottom) } +/** Returns up to two TileRects that together enclose all the tiles. + * These TilesRects together should contain fewer TilePos than [minTileRect]. + * This method is specifically aimed at the SpatialCache to avoid loading the whole bbox after + * scrolling diagonally, and may not produce good results in other situations. + * If this function can't divide the Collection into two smaller TileRects, [minTileRect] is + * returned. + */ +fun Collection.upToTwoMinTileRects(): List? { + val minTileRect = minTileRect() ?: return null + if (minTileRect.size == size) + return listOf(minTileRect) + + val grouped = groupBy { it.y } // sort tiles in lines + val lines = grouped.keys.sorted() + if (lines != (lines.min()..lines.max()).toList()) + return listOf(minTileRect) // can't deal with missing lines + + val minXTop = grouped[lines.first()]!!.minOf { it.x } + val maxXTop = grouped[lines.first()]!!.maxOf { it.x } + val minXBottom = grouped[lines.last()]!!.minOf { it.x } + val maxXBottom = grouped[lines.last()]!!.maxOf { it.x } + + // Find the line where minX or maxX change. Typically there should be such a line, because + // otherwise minTileRect.size == size would return true. Exception is when there are holes, + // e.g. from zooming out with the center cached and thus loading a "ring". + val changeLine = lines.firstOrNull { line -> + grouped[line]!!.minOf { it.x } != minXTop || grouped[line]!!.maxOf { it.x } != maxXTop + } ?: return listOf(minTileRect) + + // Fall back to minTileRect if there is a second change in line width. We could deal with this, + // but this is not a situation expected in spatialCache. + if ((changeLine..lines.last()).any { line -> + grouped[line]!!.minOf { it.x } != minXBottom || grouped[line]!!.maxOf { it.x } != maxXBottom + }) + return listOf(minTileRect) + + return listOf( + TilesRect(minXTop, lines.first(), maxXTop, changeLine-1), + TilesRect(minXBottom, changeLine, maxXBottom, lines.last()) + ) +} + /** Returns the tile that encloses the position at the given zoom level */ fun LatLon.enclosingTilePos(zoom: Int) = TilePos( lon2tile(((longitude + 180) % 360) - 180, zoom), @@ -120,6 +162,6 @@ private fun lon2tile(lon: Double, zoom: Int): Int = (numTiles(zoom) * (lon + 180.0) / 360.0).toInt() private fun lat2tile(lat: Double, zoom: Int): Int = - (numTiles(zoom) * (1.0 - asinh(tan(PI * lat / 180.0)) / PI) / 2.0).nextUp().toInt() + (numTiles(zoom) * (1.0 - asinh(tan(lat * (PI / 180.0))) / PI) / 2.0).nextUp().toInt() private fun numTiles(zoom: Int): Int = 1 shl zoom diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryController.kt index 872b8768260..9fb00505a97 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryController.kt @@ -1,6 +1,7 @@ package de.westnordost.streetcomplete.data.edithistory import de.westnordost.streetcomplete.ApplicationConstants.MAX_UNDO_HISTORY_AGE +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController import de.westnordost.streetcomplete.data.osm.edits.ElementEdit import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController import de.westnordost.streetcomplete.data.osm.edits.ElementEditsSource @@ -21,6 +22,9 @@ import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenController import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenSource import de.westnordost.streetcomplete.util.Listeners import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestHidden +import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey +import de.westnordost.streetcomplete.util.logs.Log /** All edits done by the user in one place: Edits made on notes, on map data, hidings of quests */ class EditHistoryController( @@ -30,6 +34,7 @@ class EditHistoryController( private val notesSource: NotesWithEditsSource, private val mapDataSource: MapDataWithEditsSource, private val questTypeRegistry: QuestTypeRegistry, + private val externalSourceQuestController: ExternalSourceQuestController, ) : EditHistorySource { private val listeners = Listeners() @@ -37,6 +42,9 @@ class EditHistoryController( override fun onAddedEdit(edit: ElementEdit) { if (edit.action !is IsRevertAction) onAdded(edit) } + override fun onSyncedEdit(edit: ElementEdit, updatedEditIds: Collection) { + if (edit.action !is IsRevertAction) onSynced(edit) + } override fun onSyncedEdit(edit: ElementEdit) { if (edit.action !is IsRevertAction) onSynced(edit) } @@ -54,6 +62,7 @@ class EditHistoryController( private val questHiddenListener = object : QuestsHiddenSource.Listener { override fun onHid(key: QuestKey, timestamp: Long) { val edit = createQuestHiddenEdit(key, timestamp) + if (hiddenQuestsController.get(key) == null) return // must be tempHide -> don't create an edit if (edit != null) onAdded(edit) } override fun onUnhid(key: QuestKey, timestamp: Long) { @@ -74,6 +83,11 @@ class EditHistoryController( val questType = questTypeRegistry.getByName(key.questTypeName) as? OsmElementQuestType<*> ?: return null OsmQuestHidden(key.elementType, key.elementId, questType, geometry, timestamp) } + is ExternalSourceQuestKey -> { + val type = externalSourceQuestController.getQuestType(key) ?: return null + val quest = externalSourceQuestController.get(key) ?: return null + ExternalSourceQuestHidden(key.id, type, quest.position, timestamp) + } } } @@ -91,6 +105,7 @@ class EditHistoryController( is NoteEdit -> noteEditsController.undo(edit) is OsmNoteQuestHidden -> hiddenQuestsController.unhide(edit.questKey) is OsmQuestHidden -> hiddenQuestsController.unhide(edit.questKey) + is ExternalSourceQuestHidden -> hiddenQuestsController.unhide(edit.questKey) else -> throw IllegalArgumentException() } } @@ -134,6 +149,8 @@ class EditHistoryController( } private fun onAdded(edit: Edit) { + if (edit is ElementEdit) Log.i(TAG, "history: add edit ${edit.type.name} for ${edit.action.elementKeys}") + else Log.i(TAG, "history: add edit ${edit.key}") listeners.forEach { it.onAdded(edit) } } private fun onSynced(edit: Edit) { @@ -146,3 +163,5 @@ class EditHistoryController( listeners.forEach { it.onInvalidated() } } } + +private const val TAG = "EditHistoryController" diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryModule.kt index dc19f681548..d83254fd250 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryModule.kt @@ -4,5 +4,5 @@ import org.koin.dsl.module val editHistoryModule = module { single { get() } - single { EditHistoryController(get(), get(), get(), get(), get(), get()) } + single { EditHistoryController(get(), get(), get(), get(), get(), get(), get()) } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/ElementFiltersParser.kt b/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/ElementFiltersParser.kt index 60e4c3f9610..89d7de081bd 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/ElementFiltersParser.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/ElementFiltersParser.kt @@ -231,7 +231,7 @@ private fun StringWithCursor.parseElementFilter(): ElementFilter { return ElementNewerThan(parseDateFilter()) } - val key = parseTag() + val key = parseTag().intern() val operator = parseOperatorWithSurroundingSpaces() ?: return HasKey(key) if (operator == OLDER) { @@ -244,8 +244,8 @@ private fun StringWithCursor.parseElementFilter(): ElementFilter { if (operator in KEY_VALUE_OPERATORS) { val value = parseTag() when (operator) { - EQUALS -> return HasTag(key, value) - NOT_EQUALS -> return NotHasTag(key, value) + EQUALS -> return HasTag(key, value.intern()) + NOT_EQUALS -> return NotHasTag(key, value.intern()) LIKE -> return HasTagValueLike(key, value) NOT_LIKE -> return NotHasTagValueLike(key, value) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/ElementFilter.kt b/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/ElementFilter.kt index e4ab2a6b1b6..270ad08afec 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/ElementFilter.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/ElementFilter.kt @@ -131,7 +131,11 @@ abstract class CompareDateTagValue(val key: String, val dateFilter: DateFilter) class TagOlderThan(key: String, dateFilter: DateFilter) : CompareTagAge(key, dateFilter) { override fun toString() = "$key older $dateFilter" - override fun compareTo(tagValue: LocalDate) = tagValue < dateFilter.date + override fun compareTo(tagValue: LocalDate) = + if (resurveyDate != null && resurveyKeys.contains(key)) + tagValue < resurveyDate!! + else + tagValue < dateFilter.date } class TagNewerThan(key: String, dateFilter: DateFilter) : CompareTagAge(key, dateFilter) { override fun toString() = "$key newer $dateFilter" @@ -142,9 +146,21 @@ abstract class CompareTagAge(val key: String, val dateFilter: DateFilter) : Elem abstract fun compareTo(tagValue: LocalDate): Boolean override fun matches(obj: Element): Boolean { if (compareTo(Instant.fromEpochMilliseconds(obj.timestampEdited).toLocalDate())) return true - return getLastCheckDateKeys(key) - .mapNotNull { obj.tags[it]?.toCheckDate() } - .any { compareTo(it) } + return if (resurveyKeys.contains(key)) { + // if we have a tag in the resurvey list, interpret missing check date as match + val l = getLastCheckDateKeys(key) + .mapNotNull { obj.tags[it]?.toCheckDate() }.toList() + if (l.isEmpty()) true + else l.any { compareTo(it) } + } else + getLastCheckDateKeys(key) + .mapNotNull { obj.tags[it]?.toCheckDate() } + .any { compareTo(it) } + } + + companion object { + var resurveyKeys = listOf() + var resurveyDate: LocalDate? = null } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/RegexOrSet.kt b/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/RegexOrSet.kt index f88be865c8d..8ca4e2e5971 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/RegexOrSet.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/RegexOrSet.kt @@ -10,7 +10,8 @@ sealed class RegexOrSet { fun from(string: String): RegexOrSet = if (!string.contains(anyRegexStuffExceptPipe)) { - SetRegex(string.split('|').toSet()) + val split = string.split('|') + SetRegex(HashSet(split.size, 0.9f).apply { split.forEach { add(it.intern()) } }) } else { RealRegex(string.toRegex()) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceDao.kt new file mode 100644 index 00000000000..484ec818806 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceDao.kt @@ -0,0 +1,96 @@ +package de.westnordost.streetcomplete.data.externalsource + +import de.westnordost.streetcomplete.data.CursorPosition +import de.westnordost.streetcomplete.data.Database +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables.Columns.EDIT_ID +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables.Columns.ID +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables.Columns.SOURCE +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables.Columns.TIMESTAMP +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables.NAME_EDITS +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables.NAME_HIDDEN +import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey +import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds + +class ExternalSourceDao(private val db: Database) { + + fun addElementEdit(key: ExternalSourceQuestKey, elementEditId: Long) { + db.insert(NAME_EDITS, listOf( + EDIT_ID to elementEditId, + ID to key.id, + SOURCE to key.source + )) + } + + fun getKeyForElementEdit(elementEditId: Long): ExternalSourceQuestKey? = + db.queryOne(NAME_EDITS, where = "$EDIT_ID = $elementEditId") { it.toKey() } + + fun deleteElementEdit(elementEditId: Long) = + db.delete(NAME_EDITS, where = "$EDIT_ID = $elementEditId") > 0 + + fun deleteAllExceptForElementEdits(elementEditIds: Collection) = + db.delete(NAME_EDITS, where = "$EDIT_ID not in (${elementEditIds.joinToString(",")})") > 0 +} + +class ExternalSourceHiddenDao(private val db: Database) { + + fun add(key: ExternalSourceQuestKey): Long { + val timestamp = nowAsEpochMilliseconds() + val inserted = db.insert(NAME_HIDDEN, listOf( + ID to key.id, + SOURCE to key.source, + TIMESTAMP to timestamp + )) > 0 + return if (inserted) timestamp else 0L + } + + fun getTimestamp(key: ExternalSourceQuestKey): Long? = + db.queryOne(NAME_HIDDEN, + where = "$ID = '${key.id}' AND $SOURCE = '${key.source}'", + columns = arrayOf(TIMESTAMP) + ) { it.getLong(TIMESTAMP) } + + fun delete(key: ExternalSourceQuestKey) = + db.delete(NAME_HIDDEN, where = "$ID = '${key.id}' AND $SOURCE = '${key.source}'") > 0 + + fun getAllHiddenNewerThan(timestamp: Long): List> = + db.query(NAME_HIDDEN, where = "$TIMESTAMP > $timestamp") { it.toKey() to it.getLong(TIMESTAMP) } + + fun getAll(): List> = + db.query(NAME_HIDDEN, columns = arrayOf(ID, SOURCE, TIMESTAMP)) { it.toKey() to it.getLong(TIMESTAMP) } + + fun deleteAll() = db.delete(NAME_HIDDEN) +} + +private fun CursorPosition.toKey() = ExternalSourceQuestKey(getString(ID), getString(SOURCE)) + +object ExternalSourceQuestTables { + const val NAME_HIDDEN = "other_source_hidden" + const val NAME_EDITS = "other_source_edits" + + object Columns { + const val ID = "id" + const val SOURCE = "source" + const val TIMESTAMP = "timestamp" // hidden only + const val EDIT_ID = "edit_id" // edits only + } + + const val CREATE_HIDDEN = """ + CREATE TABLE IF NOT EXISTS $NAME_HIDDEN ( + $ID TEXT, + $SOURCE TEXT, + $TIMESTAMP int NOT NULL, + PRIMARY KEY ( + $ID, + $SOURCE + ) + ); + """ + + const val CREATE_EDITS = """ + CREATE TABLE IF NOT EXISTS $NAME_EDITS ( + $EDIT_ID INTEGER PRIMARY KEY, + $ID TEXT, + $SOURCE TEXT + ); + """ +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceModule.kt new file mode 100644 index 00000000000..e32e135bffb --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceModule.kt @@ -0,0 +1,25 @@ +package de.westnordost.streetcomplete.data.externalsource + +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val externalSourceModule = module { + single { ExternalSourceQuestController(get(named("CountryBoundariesLazy")), get(), get(), get()) } + single { ExternalSourceDao(get()) } + single { ExternalSourceHiddenDao(get()) } +} + +// todo: if a quest doesn't lead to an elementEdit, currently there is no way to undo +// -> implement for things like undoing false positive in osmose quest +/* +class ExternalSourceEditKey(val source: String, val id: Long) : EditKey() // have a key for each source that needs it? +data class ExternalSourceEdit( + override val position: LatLon, + override val isSynced: Boolean?, + val action: Unit, // depending on the source and what was done, need some type... +) : Edit { + override val key: ExternalSourceEditKey + override val createdTimestamp: Long + override val isUndoable: Boolean +} +*/ diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuest.kt b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuest.kt new file mode 100644 index 00000000000..e4cbcd85d18 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuest.kt @@ -0,0 +1,36 @@ +package de.westnordost.streetcomplete.data.externalsource + +import de.westnordost.streetcomplete.data.edithistory.Edit +import de.westnordost.streetcomplete.data.edithistory.QuestHiddenKey +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey +import de.westnordost.streetcomplete.data.quest.Quest + +data class ExternalSourceQuest( + /** Each quest must be uniquely identified by the [id] and [source] */ + val id: String, + override val geometry: ElementGeometry, + override val type: ExternalSourceQuestType, + override val position: LatLon = geometry.center // allow setting position to arbitrary LatLon, because e.g. Osmose issues are often located at outline of building instead of in center +) : Quest() { + override val key = ExternalSourceQuestKey(id, source) + override val markerLocations: Collection get() = listOf(position) + val source get() = type.source + + /** an element can be linked to the quest, but this is not necessary */ + var elementKey: ElementKey? = null +} + +data class ExternalSourceQuestHidden( + val id: String, + val questType: ExternalSourceQuestType, + override val position: LatLon, + override val createdTimestamp: Long +) : Edit { + val questKey get() = ExternalSourceQuestKey(id, questType.source) + override val key: QuestHiddenKey get() = QuestHiddenKey(questKey) + override val isUndoable: Boolean get() = true + override val isSynced: Boolean? get() = null +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuestController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuestController.kt new file mode 100644 index 00000000000..4f6669a3f54 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuestController.kt @@ -0,0 +1,144 @@ +package de.westnordost.streetcomplete.data.externalsource + +import de.westnordost.countryboundaries.CountryBoundaries +import de.westnordost.streetcomplete.data.osm.edits.ElementEdit +import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController +import de.westnordost.streetcomplete.data.osm.edits.ElementEditsSource +import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey +import de.westnordost.streetcomplete.data.quest.QuestKey +import de.westnordost.streetcomplete.data.quest.QuestType +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry +import de.westnordost.streetcomplete.util.ktx.intersects +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import java.util.concurrent.CopyOnWriteArrayList + +class ExternalSourceQuestController( + private val countryBoundaries: Lazy, + private val questTypeRegistry: QuestTypeRegistry, + private val externalSourceDao: ExternalSourceDao, + elementEditsController: ElementEditsController, +) : ElementEditsSource.Listener { + + interface QuestListener { + fun onUpdated(addedQuests: Collection = emptyList(), deletedQuestKeys: Collection = emptyList()) + fun onInvalidate() + } + private val questListeners: MutableList = CopyOnWriteArrayList() + fun addQuestListener(questListener: QuestListener) { + questListeners.add(questListener) + } + + private val questTypes get() = questTypeRegistry.filterIsInstance() + private val questTypeNamesBySource by lazy { + val types = questTypes + val namesBySource = types.associate { it.source to it.name } + if (types.size != namesBySource.size) + throw IllegalStateException("source values must be unique") + namesBySource + } + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + init { elementEditsController.addListener(this) } + + fun delete(key: ExternalSourceQuestKey) { + getQuestType(key)?.deleteQuest(key.id) + questListeners.forEach { it.onUpdated(deletedQuestKeys = listOf(key)) } + } + + fun getAllInBBox(bbox: BoundingBox, visibleQuestTypes: List? = null): List { + return (visibleQuestTypes?.filterIsInstance() ?: questTypes) + .flatMap { it.getQuests(bbox) } + } + + fun get(key: ExternalSourceQuestKey): ExternalSourceQuest? = getQuestType(key)?.get(key.id) + + /** calls [download] for each [ExternalSourceQuestType] enabled in this country, thus may take long */ + suspend fun download(bbox: BoundingBox) { + withContext(Dispatchers.IO) { + val countryBoundaries = countryBoundaries.value + val updates = questTypes.mapNotNull { type -> + if (!type.downloadEnabled) return@mapNotNull null + if (!countryBoundaries.intersects(bbox, type.enabledInCountries)) return@mapNotNull null + scope.async { + val previousQuests = type.getQuests(bbox).map { it.key } + val quests = type.download(bbox) + val questKeys = HashSet(quests.size).apply { quests.forEach { add(it.key) } } + quests to previousQuests.filterNot { it in questKeys } + } + }.awaitAll().unzip() + val newQuests = updates.first.flatten() + val obsoleteQuestKeys = updates.second.flatten() + questListeners.forEach { it.onUpdated(newQuests, obsoleteQuestKeys) } + } + } + + /** calls [upload] for each [ExternalSourceQuestType], thus may take long */ + suspend fun upload() = questTypes.forEach { it.upload() } + + fun invalidate() = questListeners.forEach { it.onInvalidate() } + + /** to be called if quests have been added outside a download, so they can be shown immediately */ + fun addQuests(quests: Collection) = + questListeners.forEach { it.onUpdated(addedQuests = quests) } + + // ElementEditsListener + + // ignore, and actually this should never be called for ExternalSourceQuestType + override fun onAddedEdit(edit: ElementEdit) {} + + override fun onAddedEdit(edit: ElementEdit, key: QuestKey?) { + if (key is ExternalSourceQuestKey) { + getQuestType(key)?.onAddedEdit(edit, key.id) + externalSourceDao.addElementEdit(key, edit.id) + questListeners.forEach { it.onUpdated(deletedQuestKeys = listOf(key)) } + } + } + + override fun onSyncedEdit(edit: ElementEdit) { + val type = edit.type as? ExternalSourceQuestType ?: return + val key = externalSourceDao.getKeyForElementEdit(edit.id) + // don't delete synced edits, this will be done by ElementEditsController (using onDeletedEdits) + type.onSyncedEdit(edit, key?.id) + } + + fun onSyncEditFailed(edit: ElementEdit) { + val type = edit.type as? ExternalSourceQuestType ?: return + val key = externalSourceDao.getKeyForElementEdit(edit.id) + type.onSyncEditFailed(edit, key?.id) + } + + suspend fun onUpload(edit: ElementEdit): Boolean { + val type = edit.type as? ExternalSourceQuestType ?: return true + val key = externalSourceDao.getKeyForElementEdit(edit.id) + return type.onUpload(edit, key?.id) + } + + // for undoing stuff + override fun onDeletedEdits(edits: List) { + val restoredQuests = edits.mapNotNull { edit -> + if (edit.isSynced) return@mapNotNull null // synced edits are deleted after 12 hours, and we don't want this to restore anything + val key = externalSourceDao.getKeyForElementEdit(edit.id) + externalSourceDao.deleteElementEdit(edit.id) + val type = questTypeNamesBySource[key?.source]?.let { questTypeRegistry.getByName(it) } as? ExternalSourceQuestType + type?.onDeletedEdit(edit, key?.id) + if (key == null) null + else get(key) + } + questListeners.forEach { it.onUpdated(addedQuests = restoredQuests) } + } + + // called when old elementEdits are removed + fun cleanElementEdits(elementEditsIds: Collection) = + externalSourceDao.deleteAllExceptForElementEdits(elementEditsIds) + + fun getQuestType(key: ExternalSourceQuestKey): ExternalSourceQuestType? { + val questTypeName = questTypeNamesBySource[key.source] ?: return null + return questTypeRegistry.getByName(questTypeName) as? ExternalSourceQuestType + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuestType.kt b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuestType.kt new file mode 100644 index 00000000000..162614e522c --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuestType.kt @@ -0,0 +1,115 @@ +package de.westnordost.streetcomplete.data.externalsource + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import de.westnordost.streetcomplete.data.osm.edits.ElementEdit +import de.westnordost.streetcomplete.data.osm.edits.ElementEditType +import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.quest.AllCountries +import de.westnordost.streetcomplete.data.quest.Countries +import de.westnordost.streetcomplete.data.quest.QuestType +import de.westnordost.streetcomplete.quests.questPrefix + +/** + * Very similar to OsmElementQuestType. + * + * Each quest type is responsible for downloading and persisting data, and uploading any changes if applicable. + * ElementEdits are uploaded automatically, and onSyncedEdit is called after each uploaded ElementEdit. + * Changes uploaded through [upload] may be things like reporting false positives for Osmose. + * + * [deleteMetadataOlderThan] is called every day and should be used to remove old data. + */ +// do it very similar to OsmElementQuestType +// for cleanup, each quest type should override deleteMetadataOlderThan, or old data will remain +interface ExternalSourceQuestType : QuestType, ElementEditType { + // like for OsmQuestType + override val title: Int get() = getTitle(emptyMap()) + fun getTitle(tags: Map): Int + val highlightedElementsRadius: Double get() = 30.0 + fun getHighlightedElements(getMapData: () -> MapDataWithGeometry): Sequence = emptySequence() + val enabledInCountries: Countries get() = AllCountries + + /** Unique string for each source (app will crash on start if sources are not unique). */ + val source: String + + /** + * Download and persist data, create quests inside the given [bbox] and return the new quests. + * Download date should be stored for each entry to allow cleanup of old data. + * It's probably a good idea to remove old data inside the [bbox] before inserting updates. + * + * Download will only happen if [downloadEnabled] is true. + */ + suspend fun download(bbox: BoundingBox): Collection + + /** + * Upload changes to the server. Uploaded quests should not be created again on [download]. + * Note that on each individual upload of an ElementEdit, [onUpload] will be called before + * uploading this edit, and [onSyncedEdit] will be called after, if there is a connected ElementEdit. + * [upload] is called only after all elementEdits. + */ + suspend fun upload() + + /** Return all quests inside the given [bbox]. This should be fast and not require internet access. */ + fun getQuests(bbox: BoundingBox): Collection + + /** Return quest with the given [id], or null. */ + fun get(id: String): ExternalSourceQuest? + + /** + * Called if an ElementEdit was done as part of solving the quest with the given [id]. + * Actions should be taken so the quest for [id] does not appear again. + */ + fun onAddedEdit(edit: ElementEdit, id: String) + + /** + * Called if the ElementEdit done as part of quest with the given [id] was deleted. + * This can be because + * an edit was undone (before or after upload) + * it was already uploaded and removed because it is older than MAX_UNDO_HISTORY_AGE + * uploading the edit failed with a conflict exception (in this case onSyncEditFailed is called first) + * [id] can be null in case edit was not properly associated with id. + */ + fun onDeletedEdit(edit: ElementEdit, id: String?) + + /** + * Called if the ElementEdit done as part of quest with the given [id] was synced (uploaded). + * [id] can be null in case edit was not properly associated with id. + * Note that [upload] will also be called (before the first edit upload). + */ + fun onSyncedEdit(edit: ElementEdit, id: String?) + + /** Uploading the [edit] has failed due to a conflict exception */ + fun onSyncEditFailed(edit: ElementEdit, id: String?) + + /** + * Called before uploading [edit]. Uploading will wait until this function returns. + * @return false to cancel the upload for this edit (will throw a conflict exception) + */ + suspend fun onUpload(edit: ElementEdit, id: String?): Boolean + + /** + * Removes the quest with the given [id]. What happens internally doesn't matter, as long as + * the quest doesn't show up again when using [get] or [getQuests]. + */ + fun deleteQuest(id: String): Boolean + + /** + * Necessary to clean old data. + * Will be called with (nearly) current time when clearing all stored data is desired. + */ + override fun deleteMetadataOlderThan(timestamp: Long) + + /** quest settings should always exist, at least to control [downloadEnabled] */ + override val hasQuestSettings get() = true + + override fun getQuestSettingsDialog(context: Context): AlertDialog? + + /** disabled by default, so either this must be enabled manually or overridden */ + var downloadEnabled: Boolean + get() = prefs.getBoolean(downloadPref, false) + set(value) = prefs.edit().putBoolean(downloadPref, value).apply() + + private val downloadPref get() = questPrefix(prefs) + "qs_${name}_enable_download" +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/AddElementEditsController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/AddElementEditsController.kt index f146fcb4a21..96d5de300c8 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/AddElementEditsController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/AddElementEditsController.kt @@ -1,6 +1,7 @@ package de.westnordost.streetcomplete.data.osm.edits import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.quest.QuestKey interface AddElementEditsController { fun add( @@ -8,6 +9,7 @@ interface AddElementEditsController { geometry: ElementGeometry, source: String, action: ElementEditAction, - isNearUserLocation: Boolean + isNearUserLocation: Boolean, + key: QuestKey? = null ) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/EditType.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/EditType.kt index 4c30e28b8db..f658da7db0d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/EditType.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/EditType.kt @@ -20,7 +20,7 @@ interface EditType { val wikiLink: String? /** towards which achievements solving an edit of this type should count */ - val achievements: List + val achievements: List get() = emptyList() /** the string resource id that explains why this edit type is disabled by default or zero if it * is not disabled by default. diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsController.kt index 1580ce4047e..7230f4b2528 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsController.kt @@ -1,24 +1,47 @@ package de.westnordost.streetcomplete.data.osm.edits +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestType import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChangesBuilder +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryAdd +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryDelete +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryModify +import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTagsAction import de.westnordost.streetcomplete.data.osm.mapdata.MapDataUpdates import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.quest.QuestKey import de.westnordost.streetcomplete.util.Listeners import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds import de.westnordost.streetcomplete.util.logs.Log +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject class ElementEditsController( private val editsDB: ElementEditsDao, private val editElementsDB: EditElementsDao, private val elementIdProviderDB: ElementIdProviderDao, - private val prefs: Preferences -) : ElementEditsSource, AddElementEditsController { + private val prefs: Preferences, +// private val externalSourceQuestController: ExternalSourceQuestController, +) : ElementEditsSource, AddElementEditsController, KoinComponent { /* Must be a singleton because there is a listener that should respond to a change in the * database table */ - + private val externalSourceQuestController: ExternalSourceQuestController by inject() private val listeners = Listeners() + private val editCache by lazy { + val c = hashMapOf() + editsDB.getAll().associateByTo(c) { it.id } + } + + // full elementIdProvider cache didn't work as expected, so only store empty idPoviders (resp. their ids) + // this is still very useful, because + // most are actually empty (edit tags action) + // on rebuildLocalChanges idProviders of all edits are queried, so the cache saves many db queries + // each query is fast, but for many unsynced edits this is a clear improvement + private val emptyIdProviderCache = HashSet() + /* ----------------------- Unsynced edits and syncing them -------------------------------- */ /** Add new unsynced edit to the to-be-uploaded queue */ @@ -27,26 +50,41 @@ class ElementEditsController( geometry: ElementGeometry, source: String, action: ElementEditAction, - isNearUserLocation: Boolean + isNearUserLocation: Boolean, + key: QuestKey? ) { Log.d(TAG, "Add ${type.name} for ${action.elementKeys.joinToString()}") - add(ElementEdit(0, type, geometry, source, nowAsEpochMilliseconds(), false, action, isNearUserLocation)) + // removes discardable tags if they were part of original element, but not if user added them + val newAction = if (action is UpdateElementTagsAction && action.originalElement.tags.keys.any { it in DISCARDABLE_TAGS }) { + val builder = StringMapChangesBuilder(action.originalElement.tags) + action.changes.changes.forEach { when (it) { + is StringMapEntryDelete -> builder.remove(it.key) + is StringMapEntryAdd -> builder[it.key] = it.value + is StringMapEntryModify -> builder[it.key] = it.value + } } + DISCARDABLE_TAGS.forEach { builder.remove(it) } + UpdateElementTagsAction(action.originalElement, builder.create()) + } else + action + add(ElementEdit(0, type, geometry, source, nowAsEpochMilliseconds(), false, newAction, isNearUserLocation), key) } - override fun get(id: Long): ElementEdit? = - editsDB.get(id) + override fun get(id: Long): ElementEdit? = synchronized(this) { editCache[id] } - override fun getAll(): List = - editsDB.getAll() + override fun getAll(): List = synchronized(this) { editCache.values.toList() } override fun getAllUnsynced(): List = - editsDB.getAllUnsynced() + getAll().filterNot { it.isSynced } fun getOldestUnsynced(): ElementEdit? = - editsDB.getOldestUnsynced() + getAllUnsynced().minByOrNull { it.createdTimestamp } - fun getIdProvider(id: Long): ElementIdProvider = - elementIdProviderDB.get(id) + fun getIdProvider(id: Long): ElementIdProvider = synchronized(emptyIdProviderCache) { + if (emptyIdProviderCache.contains(id)) return ElementIdProvider(emptyList()) + val p = elementIdProviderDB.get(id) + if (p.isEmpty()) emptyIdProviderCache.add(id) + return p + } /** Delete old synced (aka uploaded) edits older than the given timestamp. Used to clear * the undo history */ @@ -54,11 +92,15 @@ class ElementEditsController( val deletedCount: Int val deleteEdits: List synchronized(this) { - deleteEdits = editsDB.getSyncedOlderThan(timestamp) + val allEdits = editsDB.getAll() + deleteEdits = allEdits.filter { it.createdTimestamp < timestamp && it.isSynced } if (deleteEdits.isEmpty()) return 0 val ids = deleteEdits.map { it.id } + editCache.keys.removeAll(ids) deletedCount = editsDB.deleteAll(ids) editElementsDB.deleteAll(ids) + val keep = allEdits.filter { it.type is ExternalSourceQuestType && (it.isSynced || it.createdTimestamp >= timestamp) } + externalSourceQuestController.cleanElementEdits(keep.map { it.id }) } onDeletedEdits(deleteEdits) /* must be deleted after the callback because the callback might want to get the id provider @@ -68,10 +110,10 @@ class ElementEditsController( } override fun getUnsyncedCount(): Int = - editsDB.getUnsyncedCount() + getAllUnsynced().size override fun getPositiveUnsyncedCount(): Int { - val unsynced = editsDB.getAllUnsynced().map { it.action } + val unsynced = getAllUnsynced().map { it.action } return unsynced.filter { it !is IsRevertAction }.size - unsynced.filter { it is IsRevertAction }.size } @@ -80,22 +122,32 @@ class ElementEditsController( ElementKey(it.elementType, it.oldElementId) to it.newElementId } val syncSuccess: Boolean + val editIdsToUpdate = HashSet() + val syncedEdit by lazy { edit.copy(isSynced = true) } synchronized(this) { - val editIdsToUpdate = elementUpdates.idUpdates.flatMapTo(HashSet()) { + elementUpdates.idUpdates.flatMapTo(editIdsToUpdate) { editElementsDB.getAllByElement(it.elementType, it.oldElementId) } for (id in editIdsToUpdate) { val oldEdit = editsDB.get(id) ?: continue val updatedEdit = oldEdit.copy(action = oldEdit.action.idsUpdatesApplied(idUpdatesMap)) editsDB.put(updatedEdit) + editCache[updatedEdit.id] = updatedEdit // must clear first because the element ids associated with this id are different now editElementsDB.delete(id) editElementsDB.put(id, updatedEdit.action.elementKeys) } + if (editIdsToUpdate.isNotEmpty()) + synchronized(emptyIdProviderCache) { emptyIdProviderCache.removeAll(editIdsToUpdate) } syncSuccess = editsDB.markSynced(edit.id) + + if (syncSuccess) + editCache[edit.id] = syncedEdit } - if (syncSuccess) onSyncedEdit(edit.copy(isSynced = true)) + + if (syncSuccess) onSyncedEdit(syncedEdit, editIdsToUpdate) // forward which ids were updated, because history controller needs to reload those edits elementIdProviderDB.updateIds(elementUpdates.idUpdates) + synchronized(emptyIdProviderCache) { emptyIdProviderCache.remove(edit.id) } } fun markSyncFailed(edit: ElementEdit) { @@ -128,7 +180,7 @@ class ElementEditsController( /* ------------------------------------ add/sync/delete ------------------------------------- */ - private fun add(edit: ElementEdit) { + private fun add(edit: ElementEdit, key: QuestKey? = null) { synchronized(this) { editsDB.put(edit) editElementsDB.put(edit.id, edit.action.elementKeys) @@ -139,8 +191,9 @@ class ElementEditsController( createdElementsCount.ways, createdElementsCount.relations ) + editCache[edit.id] = edit } - onAddedEdit(edit) + onAddedEdit(edit, key) } private fun delete(edit: ElementEdit) { @@ -153,12 +206,14 @@ class ElementEditsController( editsDB.deleteAll(ids) editElementsDB.deleteAll(ids) + editCache.keys.removeAll(ids) } onDeletedEdits(edits) /* must be deleted after the callback because the callback might want to get the id provider for that edit */ + synchronized(emptyIdProviderCache) { ids.forEach { emptyIdProviderCache.remove(it) } } elementIdProviderDB.deleteAll(ids) } @@ -168,7 +223,7 @@ class ElementEditsController( val createdElementKeys = elementIdProviderDB.get(edit.id).getAll() val editsBasedOnThese = createdElementKeys .flatMapTo(HashSet()) { editElementsDB.getAllByElement(it.type, it.id) } - .mapNotNull { editsDB.get(it) } + .mapNotNull { editCache[it] } .filter { it.id != edit.id } // deep first @@ -189,13 +244,13 @@ class ElementEditsController( listeners.remove(listener) } - private fun onAddedEdit(edit: ElementEdit) { + private fun onAddedEdit(edit: ElementEdit, key: QuestKey?) { prefs.lastEditTime = nowAsEpochMilliseconds() - listeners.forEach { it.onAddedEdit(edit) } + listeners.forEach { it.onAddedEdit(edit, key) } } - private fun onSyncedEdit(edit: ElementEdit) { - listeners.forEach { it.onSyncedEdit(edit) } + private fun onSyncedEdit(edit: ElementEdit, updatedEditIds: Collection) { + listeners.forEach { it.onSyncedEdit(edit, updatedEditIds) } } private fun onDeletedEdits(edits: List) { @@ -206,3 +261,57 @@ class ElementEditsController( private const val TAG = "ElementEditsController" } } + +// list from josm +private val DISCARDABLE_TAGS = hashSetOf( + "created_by", + "converted_by", + "current_id", + "geobase:datasetName", + "geobase:uuid", + "KSJ2:ADS", + "KSJ2:ARE", + "KSJ2:AdminArea", + "KSJ2:COP_label", + "KSJ2:DFD", + "KSJ2:INT", + "KSJ2:INT_label", + "KSJ2:LOC", + "KSJ2:LPN", + "KSJ2:OPC", + "KSJ2:PubFacAdmin", + "KSJ2:RAC", + "KSJ2:RAC_label", + "KSJ2:RIC", + "KSJ2:RIN", + "KSJ2:WSC", + "KSJ2:coordinate", + "KSJ2:curve_id", + "KSJ2:curve_type", + "KSJ2:filename", + "KSJ2:lake_id", + "KSJ2:lat", + "KSJ2:long", + "KSJ2:river_id", + "odbl", + "odbl:note", + "osmarender:nameDirection", + "osmarender:renderName", + "osmarender:renderRef", + "osmarender:rendernames", + "SK53_bulk:load", + "sub_sea:type", + "tiger:source", + "tiger:separated", + "tiger:tlid", + "tiger:upload_uuid", + "import_uuid", + "gnis:import_uuid", + "yh:LINE_NAME", + "yh:LINE_NUM", + "yh:STRUCTURE", + "yh:TOTYUMONO", + "yh:TYPE", + "yh:WIDTH", + "yh:WIDTH_RANK" +) 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 a6bcdae6dca..a23aa71b463 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 @@ -24,8 +24,11 @@ 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 -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString +import de.westnordost.streetcomplete.data.osm.edits.create.CreateRelationAction +import de.westnordost.streetcomplete.data.osm.edits.delete.DeleteRelationAction +import de.westnordost.streetcomplete.data.osm.edits.delete.RevertDeleteRelationAction +import de.westnordost.streetcomplete.quests.tagEdit +import de.westnordost.streetcomplete.screens.main.bottom_sheet.addNodeEdit import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic @@ -48,6 +51,9 @@ class ElementEditsDao( subclass(MoveNodeAction::class) subclass(RevertMoveNodeAction::class) subclass(CreateNodeFromVertexAction::class) + subclass(CreateRelationAction::class) + subclass(DeleteRelationAction::class) + subclass(RevertDeleteRelationAction::class) } } } @@ -108,7 +114,9 @@ class ElementEditsDao( private fun CursorPosition.toElementEdit() = ElementEdit( getLong(ID), - allEditTypes.getByName(getString(QUEST_TYPE)) as ElementEditType, + allEditTypes.getByName(getString(QUEST_TYPE)) as? ElementEditType + ?: addNodeEdit.takeIf { getString(QUEST_TYPE) == addNodeEdit.name } + ?: tagEdit, // always assume it's a tagEdit if nothing matches, to avoid crashes if SCEE quests are removed json.decodeFromString(getString(GEOMETRY)), getString(SOURCE), getLong(CREATED_TIMESTAMP), diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt index c75536e62b4..85f5c39bf89 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt @@ -20,7 +20,7 @@ val elementEditsModule = module { single { OpenChangesetsManager(get(), get(), get(), get()) } - single { ElementEditsUploader(get(), get(), get(), get(), get(), get()) } + single { ElementEditsUploader(get(), get(), get(), get(), get(), get(), get(), get(), get()) } single { get() } single { ElementEditsController(get(), get(), get(), get()) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsSource.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsSource.kt index 75886fe1238..3ecc4b650ac 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsSource.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsSource.kt @@ -1,10 +1,14 @@ package de.westnordost.streetcomplete.data.osm.edits +import de.westnordost.streetcomplete.data.quest.QuestKey + interface ElementEditsSource { /** Interface to be notified of new or updated OSM elements */ interface Listener { fun onAddedEdit(edit: ElementEdit) + fun onAddedEdit(edit: ElementEdit, key: QuestKey?) = onAddedEdit(edit) // need to do it this weird way, otherwise must change everything that implement listener fun onSyncedEdit(edit: ElementEdit) + fun onSyncedEdit(edit: ElementEdit, updatedEditIds: Collection) = onSyncedEdit(edit) // see above // may be several because deleting one element edit leads to the deletion of all edits that // are based on that edit. E.g. splitting a way, then editing the newly created way segments fun onDeletedEdits(edits: List) 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 764da5a62cc..dba93a1a15e 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 @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.data.osm.edits import de.westnordost.streetcomplete.data.ConflictException 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.update_tags.UpdateElementTagsAction import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometryCreator import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometryEntry @@ -26,8 +27,8 @@ import de.westnordost.streetcomplete.data.osm.mapdata.MutableMapDataWithGeometry import de.westnordost.streetcomplete.data.osm.mapdata.Node import de.westnordost.streetcomplete.data.osm.mapdata.Relation import de.westnordost.streetcomplete.data.osm.mapdata.Way -import de.westnordost.streetcomplete.data.osm.mapdata.key import de.westnordost.streetcomplete.util.Listeners +import de.westnordost.streetcomplete.util.logs.Log import de.westnordost.streetcomplete.util.math.contains import de.westnordost.streetcomplete.util.math.intersect @@ -283,7 +284,7 @@ class MapDataWithEditsSource internal constructor( } private fun getWayNodes(way: Way): Collection? = synchronized(this) { - val ids = way.nodeIds.toSet() + val ids = way.nodeIds.toHashSet() val nodes = getNodes(ids) // If the way is (now) not complete, this is not acceptable @@ -489,7 +490,12 @@ class MapDataWithEditsSource internal constructor( val key = element.key deletedElements.remove(key) updatedElements[key] = element - updatedGeometries[key] = createGeometry(element) + updatedGeometries[key] = if (element !is Node && edit.action is UpdateElementTagsAction && updates.size == 1) + // fetch geometry if only tags were updated (size == 1 should always be true, but better be safe) + // but don't fetch if it's a node, because fetching usually isn't faster than creating, unless we have a node geometry cache + getGeometry(element.type, element.id) + else + createGeometry(element) } return MapDataUpdates(updated = updates, deleted = deletedKeys) @@ -537,6 +543,8 @@ class MapDataWithEditsSource internal constructor( private fun callOnUpdated(updated: MapDataWithGeometry, deleted: Collection) { if (updated.size == 0 && deleted.isEmpty()) return + if (updated.size > 10 || deleted.size > 10) Log.i(TAG, "updated ${updated.size}, deleted ${deleted.size}") + else Log.i(TAG, "updated: ${updated.map { it.key }}, deleted: $deleted") listeners.forEach { it.onUpdated(updated, deleted) } synchronized(isReplacingForBBoxLock) { @@ -560,3 +568,5 @@ private fun Element.isEqualExceptVersionAndTimestamp(element: Element?): Boolean is Way -> nodeIds == (element as Way).nodeIds is Relation -> members == (element as Relation).members } + +private const val TAG = "MapDataWithEditsSource" diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeActionUtils.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeActionUtils.kt index 73a83077a81..feb4f6266f8 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeActionUtils.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeActionUtils.kt @@ -3,8 +3,10 @@ package de.westnordost.streetcomplete.data.osm.edits.create import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChangesBuilder +import de.westnordost.streetcomplete.util.math.PositionOnCrossingWaySegments import de.westnordost.streetcomplete.util.math.PositionOnWay import de.westnordost.streetcomplete.util.math.PositionOnWaySegment +import de.westnordost.streetcomplete.util.math.PositionOnWaysSegment import de.westnordost.streetcomplete.util.math.VertexOfWay fun createNodeAction( @@ -30,5 +32,15 @@ fun createNodeAction( val containingWayIds = mapDataWithEditsSource.getWaysForNode(positionOnWay.nodeId).map { it.id } return CreateNodeFromVertexAction(node, tagChanges.create(), containingWayIds) } + is PositionOnWaysSegment -> { + val tagChanges = StringMapChangesBuilder(mapOf()) + createChanges(tagChanges) + return CreateNodeAction(positionOnWay.position, tagChanges, positionOnWay.insertIntoWaysAt) + } + is PositionOnCrossingWaySegments -> { + val tagChanges = StringMapChangesBuilder(mapOf()) + createChanges(tagChanges) + return CreateNodeAction(positionOnWay.position, tagChanges, positionOnWay.insertIntoWaysAt) + } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeFromVertexAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeFromVertexAction.kt index 040d3aea5fe..b0e214939c5 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeFromVertexAction.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeFromVertexAction.kt @@ -13,7 +13,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.ElementType 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.osm.mapdata.key import de.westnordost.streetcomplete.util.ktx.containsExactlyInAnyOrder import kotlinx.serialization.Serializable diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateRelationAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateRelationAction.kt new file mode 100644 index 00000000000..7015a2ba28a --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateRelationAction.kt @@ -0,0 +1,44 @@ +package de.westnordost.streetcomplete.data.osm.edits.create + +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.mapdata.ElementKey +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository +import de.westnordost.streetcomplete.data.osm.mapdata.Relation +import de.westnordost.streetcomplete.data.osm.mapdata.RelationMember +import de.westnordost.streetcomplete.data.osm.mapdata.key +import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds +import kotlinx.serialization.Serializable + +@Serializable +data class CreateRelationAction ( + val tags: Map, + val members: List +) : ElementEditAction, IsActionRevertable { + + override val newElementsCount get() = NewElementsCount(0, 0, 1) + + override val elementKeys: List get() = members.map { it.key } + + override fun idsUpdatesApplied(updatedIds: Map) = copy( + members = members.map { it.copy(ref = updatedIds[it.key] ?: it.ref) } + ) + + override fun createUpdates( + mapDataRepository: MapDataRepository, + idProvider: ElementIdProvider + ): MapDataChanges { + val newRelation = createRelation(idProvider) + + return MapDataChanges(creations = listOf(newRelation)) + } + + override fun createReverted(idProvider: ElementIdProvider) = + RevertCreateRelationAction(createRelation(idProvider)) + + private fun createRelation(idProvider: ElementIdProvider) = + Relation(idProvider.nextRelationId(), members, tags, 1, nowAsEpochMilliseconds()) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/RevertCreateNodeAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/RevertCreateNodeAction.kt index 339a380b591..25ed273db1a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/RevertCreateNodeAction.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/RevertCreateNodeAction.kt @@ -10,7 +10,6 @@ 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.osm.mapdata.Way -import de.westnordost.streetcomplete.data.osm.mapdata.key import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds import kotlinx.serialization.Serializable diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/RevertCreateRelationAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/RevertCreateRelationAction.kt new file mode 100644 index 00000000000..f624bf2290e --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/RevertCreateRelationAction.kt @@ -0,0 +1,44 @@ +package de.westnordost.streetcomplete.data.osm.edits.create + +import de.westnordost.streetcomplete.data.ConflictException +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.mapdata.ElementKey +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository +import de.westnordost.streetcomplete.data.osm.mapdata.Relation +import kotlinx.serialization.Serializable + +@Serializable +data class RevertCreateRelationAction( + val originalRelation: Relation, +) : ElementEditAction, IsRevertAction { + + override val elementKeys get() = listOf(originalRelation.key) + + override fun idsUpdatesApplied(updatedIds: Map) = copy( + originalRelation = originalRelation.copy(id = updatedIds[originalRelation.key] ?: originalRelation.id)) + + override fun createUpdates( + mapDataRepository: MapDataRepository, + idProvider: ElementIdProvider + ): MapDataChanges { + val currentRelation = mapDataRepository.getRelation(originalRelation.id) + ?: throw ConflictException("Element deleted") + + if (originalRelation.members != currentRelation.members) { + throw ConflictException("Relation members changed") + } + + if (originalRelation.tags != currentRelation.tags) { + throw ConflictException("Some tags have already been changed") + } + + if (mapDataRepository.getRelationsForNode(currentRelation.id).isNotEmpty()) { + throw ConflictException("Relation is now member of a relation") + } + + return MapDataChanges(deletions = listOf(currentRelation)) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/DeletePoiNodeAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/DeletePoiNodeAction.kt index 4b22b2e7b99..0ac869a57cf 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/DeletePoiNodeAction.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/DeletePoiNodeAction.kt @@ -9,7 +9,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey 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.osm.mapdata.key import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds import kotlinx.serialization.Serializable diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/DeleteRelationAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/DeleteRelationAction.kt new file mode 100644 index 00000000000..934a031c9bb --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/DeleteRelationAction.kt @@ -0,0 +1,55 @@ +package de.westnordost.streetcomplete.data.osm.edits.delete + +import de.westnordost.streetcomplete.data.ConflictException +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.update_tags.isGeometrySubstantiallyDifferent +import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository +import de.westnordost.streetcomplete.data.osm.mapdata.Relation +import kotlinx.serialization.Serializable + +/** Action that deletes a POI node. + * + * This is different from a generic element deletion seen in other editors in as ... + * + * 1. it only works on nodes. This is mainly to reduce complexity, because when deleting ways, it + * is expected to implicitly also delete all nodes of that way that are not part of any other + * way (or relation). + * + * 2. if that node is a vertex in a way or has a role in a relation, the node is not deleted but + * just "degraded" to be a vertex, i.e. the tags are cleared. + * */ +@Serializable +data class DeleteRelationAction( + val originalRelation: Relation +) : ElementEditAction, IsActionRevertable { + + override val elementKeys get() = listOf(originalRelation.key) + + override fun idsUpdatesApplied(updatedIds: Map) = copy( + originalRelation = originalRelation.copy(id = updatedIds[originalRelation.key] ?: originalRelation.id) + ) + + override fun createUpdates( + mapDataRepository: MapDataRepository, + idProvider: ElementIdProvider + ): MapDataChanges { + val currentRelation = mapDataRepository.getRelation(originalRelation.id) + ?: throw ConflictException("Element deleted") + + if (isGeometrySubstantiallyDifferent(originalRelation, currentRelation)) { + throw ConflictException("Element geometry changed substantially") + } + + // delete relation + val relations = mapDataRepository.getRelationsForRelation(currentRelation.id) + if (relations.isNotEmpty()) throw ConflictException("Deleting relation that is member of other relation currently not supported") + return MapDataChanges(deletions = listOf(currentRelation)) + } + + override fun createReverted(idProvider: ElementIdProvider) = + RevertDeleteRelationAction(originalRelation) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/RevertDeletePoiNodeAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/RevertDeletePoiNodeAction.kt index 1b66b39eb42..66a9a5caa84 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/RevertDeletePoiNodeAction.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/RevertDeletePoiNodeAction.kt @@ -9,7 +9,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey 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.osm.mapdata.key import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds import kotlinx.serialization.Serializable diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/RevertDeleteRelationAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/RevertDeleteRelationAction.kt new file mode 100644 index 00000000000..5db78cdb565 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/RevertDeleteRelationAction.kt @@ -0,0 +1,49 @@ +package de.westnordost.streetcomplete.data.osm.edits.delete + +import de.westnordost.streetcomplete.data.ConflictException +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.NewElementsCount +import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository +import de.westnordost.streetcomplete.data.osm.mapdata.Relation +import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds +import kotlinx.serialization.Serializable + +/** Action that restores a POI node to the previous state before deletion/clearing of tags + */ +@Serializable +data class RevertDeleteRelationAction( + val originalRelation: Relation +) : ElementEditAction, IsRevertAction { + + /** No "new" elements are created, instead, an old one is being revived */ + override val newElementsCount get() = NewElementsCount(0, 0, 0) + + override val elementKeys get() = listOf(originalRelation.key) + + override fun idsUpdatesApplied(updatedIds: Map) = copy( + originalRelation = originalRelation.copy(id = updatedIds[originalRelation.key] ?: originalRelation.id) + ) + + override fun createUpdates( + mapDataRepository: MapDataRepository, + idProvider: ElementIdProvider + ): MapDataChanges { + val newVersion = originalRelation.version + 1 + val currentRelation = mapDataRepository.getRelation(originalRelation.id) + + // already has been restored apparently + if (currentRelation != null && currentRelation.version > newVersion) { + throw ConflictException("Element has been restored already") + } + + val restoredRelation = originalRelation.copy( + version = newVersion, + timestampEdited = nowAsEpochMilliseconds() + ) + return MapDataChanges(modifications = listOf(restoredRelation)) + } +} 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 index 2fe0363ea00..1db41811eb7 100644 --- 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 @@ -10,7 +10,6 @@ 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.osm.mapdata.key import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds import kotlinx.serialization.Serializable 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 index d627b99ba04..e43bf5c75ae 100644 --- 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 @@ -8,7 +8,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey 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.osm.mapdata.key import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds import kotlinx.serialization.Serializable diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/split_way/SplitWayAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/split_way/SplitWayAction.kt index 5e071eb0f86..d0eccebffaf 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/split_way/SplitWayAction.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/split_way/SplitWayAction.kt @@ -13,7 +13,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.Node import de.westnordost.streetcomplete.data.osm.mapdata.Relation import de.westnordost.streetcomplete.data.osm.mapdata.RelationMember import de.westnordost.streetcomplete.data.osm.mapdata.Way -import de.westnordost.streetcomplete.data.osm.mapdata.key import de.westnordost.streetcomplete.util.ktx.containsAny import de.westnordost.streetcomplete.util.ktx.findNext import de.westnordost.streetcomplete.util.ktx.findPrevious diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/RevertUpdateElementTagsAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/RevertUpdateElementTagsAction.kt index d8097b9d33c..2936b881dca 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/RevertUpdateElementTagsAction.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/RevertUpdateElementTagsAction.kt @@ -8,7 +8,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository -import de.westnordost.streetcomplete.data.osm.mapdata.key import de.westnordost.streetcomplete.util.ktx.copy import kotlinx.serialization.Serializable diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/StringMapChangesXt.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/StringMapChangesXt.kt index d1916b3e6f5..ab54099ec2e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/StringMapChangesXt.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/StringMapChangesXt.kt @@ -29,3 +29,16 @@ fun Element.changesApplied(changes: StringMapChanges): Element { timestampEdited = nowAsEpochMilliseconds() ) } + +fun Map.createChanges(originalTags: Map): StringMapChangesBuilder { + val builder = StringMapChangesBuilder(originalTags) + for (key in originalTags.keys) { + if (!containsKey(key)) + builder.remove(key) + } + for ((key, value) in this) { + if (originalTags[key] == value) continue + builder[key] = value + } + return builder +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/UpdateElementTagsAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/UpdateElementTagsAction.kt index 2a1c7f4aa54..5b41a574a60 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/UpdateElementTagsAction.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/UpdateElementTagsAction.kt @@ -8,7 +8,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository -import de.westnordost.streetcomplete.data.osm.mapdata.key import de.westnordost.streetcomplete.util.ktx.copy import kotlinx.serialization.Serializable diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt index 5d0644a5386..a6d6c6ab664 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt @@ -1,17 +1,22 @@ package de.westnordost.streetcomplete.data.osm.edits.upload +import de.westnordost.streetcomplete.BuildConfig import de.westnordost.streetcomplete.ApplicationConstants.EDIT_ACTIONS_NOT_ALLOWED_TO_USE_LOCAL_CHANGES import de.westnordost.streetcomplete.ApplicationConstants.IGNORED_RELATION_TYPES import de.westnordost.streetcomplete.data.ConflictException import de.westnordost.streetcomplete.data.osm.edits.ElementEdit import de.westnordost.streetcomplete.data.osm.edits.ElementIdProvider import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.OpenChangesetsManager +import de.westnordost.streetcomplete.data.osm.mapdata.ElementIdUpdate import de.westnordost.streetcomplete.data.osm.mapdata.ChangesetTooLargeException import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiClient import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges import de.westnordost.streetcomplete.data.osm.mapdata.MapDataController import de.westnordost.streetcomplete.data.osm.mapdata.MapDataUpdates +import de.westnordost.streetcomplete.data.osm.mapdata.Way import de.westnordost.streetcomplete.data.osm.mapdata.RemoteMapDataRepository +import de.westnordost.streetcomplete.data.user.UserLoginController +import de.westnordost.streetcomplete.util.ktx.copy class ElementEditUploader( private val changesetManager: OpenChangesetsManager, @@ -27,6 +32,29 @@ class ElementEditUploader( // certain edit types don't allow building changes on top of cached map data val mustUseRemoteData = edit.action::class in EDIT_ACTIONS_NOT_ALLOWED_TO_USE_LOCAL_CHANGES + // fake upload in debug mode: create pseudo-random new (positive!) ids that are unlikely to clash with real ids + if (BuildConfig.DEBUG && !UserLoginController.loggedIn) { + val localChanges = edit.action.createUpdates(mapDataController, getIdProvider()) + val creationsByNewId = localChanges.creations.associateBy { Long.MAX_VALUE - Int.MAX_VALUE + it.hashCode() } + val updates = MapDataUpdates( + updated = (localChanges.modifications + creationsByNewId.map { it.value.copy(id = it.key) }) + .map { element -> + // need to update node ids of ways, don't care about relations + if (element is Way && element.nodeIds.any { it < 0 }) { + val newNodeIds = element.nodeIds.map { id -> + if (id > 0) id + else + creationsByNewId.entries.first { it.value.id == id }.key + } + element.copy(nodeIds = newNodeIds) + } else element + }, + deleted = localChanges.deletions.map { it.key }, + idUpdates = creationsByNewId.map { ElementIdUpdate(it.value.type, it.value.id, it.key) } + ) + return updates + } + return if (mustUseRemoteData) { uploadUsingRemoteRepo(edit, getIdProvider) } else { diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt index 98335d76ee8..767ea8eb820 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt @@ -1,10 +1,13 @@ package de.westnordost.streetcomplete.data.osm.edits.upload +import com.russhwolf.settings.ObservableSettings import de.westnordost.streetcomplete.data.ConflictException +import de.westnordost.streetcomplete.BuildConfig +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.data.download.Downloader import de.westnordost.streetcomplete.data.osm.edits.ElementEdit import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController import de.westnordost.streetcomplete.data.osm.edits.ElementIdProvider -import de.westnordost.streetcomplete.data.osm.edits.IsRevertAction import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey import de.westnordost.streetcomplete.data.osm.mapdata.ElementType @@ -14,13 +17,21 @@ import de.westnordost.streetcomplete.data.osm.mapdata.MapDataController import de.westnordost.streetcomplete.data.osm.mapdata.MapDataUpdates import de.westnordost.streetcomplete.data.osm.mapdata.MutableMapData import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsController +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestType +import de.westnordost.streetcomplete.data.osm.edits.IsRevertAction import de.westnordost.streetcomplete.data.upload.OnUploadedChangeListener +import de.westnordost.streetcomplete.data.upload.Uploader +import de.westnordost.streetcomplete.data.user.UserLoginController import de.westnordost.streetcomplete.data.user.statistics.StatisticsController import de.westnordost.streetcomplete.util.logs.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -31,21 +42,40 @@ class ElementEditsUploader( private val mapDataController: MapDataController, private val singleUploader: ElementEditUploader, private val mapDataApi: MapDataApiClient, - private val statisticsController: StatisticsController + private val statisticsController: StatisticsController, + private val downloader: Downloader, + private val externalSourceQuestController: ExternalSourceQuestController, + private val prefs: ObservableSettings, ) { var uploadedChangeListener: OnUploadedChangeListener? = null private val mutex = Mutex() private val scope = CoroutineScope(SupervisorJob() + CoroutineName("ElementEditsUploader")) - suspend fun upload() = mutex.withLock { withContext(Dispatchers.IO) { + suspend fun upload(uploader: Uploader) = mutex.withLock { withContext(Dispatchers.IO) { while (true) { val edit = elementEditsController.getOldestUnsynced() ?: break val getIdProvider: () -> ElementIdProvider = { elementEditsController.getIdProvider(edit.id) } + if (downloader.isDownloadInProgress) { + // cancel upload, and re-start uploading a second later + // then download will already be running + scope.launch { + delay(1000) + try { + uploader.upload() + } catch (e: Exception) { + Log.i(TAG, "exception when continuing interrupted upload: $e") + throw CancellationException() // not caught by UploadWorker.doWork, but we don't want + } + } + throw CancellationException() // don't simply break, because otherwise upload will continue with notes + } /* the sync of local change -> API and its response should not be cancellable because * otherwise an inconsistency in the data would occur. E.g. no "star" for an uploaded * change, a change could be uploaded twice etc */ withContext(scope.coroutineContext) { uploadEdit(edit, getIdProvider) } + if (BuildConfig.DEBUG && !UserLoginController.loggedIn) + break // slow uploading is much better to read in logs } } } @@ -53,24 +83,29 @@ class ElementEditsUploader( val editActionClassName = edit.action::class.simpleName!! try { + if (edit.type is ExternalSourceQuestType && !externalSourceQuestController.onUpload(edit)) + throw(ConflictException()) val updates = singleUploader.upload(edit, getIdProvider) - Log.d(TAG, "Uploaded a $editActionClassName") + Log.d(TAG, "Uploaded a $editActionClassName for ${edit.action.elementKeys}") uploadedChangeListener?.onUploaded(edit.type.name, edit.position) elementEditsController.markSynced(edit, updates) mapDataController.updateAll(updates) noteEditsController.updateElementIds(updates.idUpdates) - if (edit.action is IsRevertAction) { - statisticsController.subtractOne(edit.type.name, edit.position) - } else { - statisticsController.addOne(edit.type.name, edit.position) + if (prefs.getBoolean(Prefs.UPDATE_LOCAL_STATISTICS, true)) { + if (edit.action is IsRevertAction) { + statisticsController.subtractOne(edit.type.name, edit.position) + } else { + statisticsController.addOne(edit.type.name, edit.position) + } } } catch (e: ConflictException) { - Log.d(TAG, "Dropped a $editActionClassName: ${e.message}") + Log.d(TAG, "Dropped a $editActionClassName for ${edit.action.elementKeys}: ${e.message}") uploadedChangeListener?.onDiscarded(edit.type.name, edit.position) + externalSourceQuestController.onSyncEditFailed(edit) elementEditsController.markSyncFailed(edit) /* fetching the current version of the element(s) edited on conflict and persisting diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManager.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManager.kt index 1fdd85df85b..92c3bade4d7 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManager.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManager.kt @@ -65,13 +65,22 @@ class OpenChangesetsManager( } private fun createChangesetTags(type: ElementEditType, source: String) = - mapOf( - "comment" to type.changesetComment, - "created_by" to USER_AGENT, - "locale" to Locale.getDefault().toLanguageTag(), - QUESTTYPE_TAG_KEY to type.name, - "source" to source - ) + if (source.endsWith(",extra")) + mapOf( + "comment" to "Other edits in context of: ${type.changesetComment}".take(255), + "created_by" to USER_AGENT, + "locale" to Locale.getDefault().toLanguageTag(), + QUESTTYPE_TAG_KEY to type.name, + "source" to source.substringBefore(",extra") + ) + else + mapOf( + "comment" to type.changesetComment, + "created_by" to USER_AGENT, + "locale" to Locale.getDefault().toLanguageTag(), + QUESTTYPE_TAG_KEY to type.name, + "source" to source + ) companion object { private const val TAG = "ChangesetManager" diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/Element.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/Element.kt index a6baeb95d56..893ade0b702 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/Element.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/Element.kt @@ -10,6 +10,7 @@ sealed class Element { abstract val tags: Map abstract val timestampEdited: Long abstract val type: ElementType + abstract val key: ElementKey } @Serializable @@ -23,6 +24,7 @@ data class Node( ) : Element() { @SerialName("elementType") override val type get() = ElementType.NODE + override val key: ElementKey = ElementKey(ElementType.NODE, id) } @Serializable @@ -36,6 +38,7 @@ data class Way( ) : Element() { @SerialName("elementType") override val type = ElementType.WAY + override val key: ElementKey = ElementKey(ElementType.WAY, id) val isClosed get() = nodeIds.size >= 3 && nodeIds.first() == nodeIds.last() } @@ -51,6 +54,7 @@ data class Relation( ) : Element() { @SerialName("elementType") override val type = ElementType.RELATION + override val key: ElementKey = ElementKey(ElementType.RELATION, id) } @Serializable diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementDao.kt index 308998a9c18..c74f0c739d3 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementDao.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementDao.kt @@ -45,7 +45,7 @@ class ElementDao( fun getAll(bbox: BoundingBox): List { val nodes = nodeDao.getAll(bbox) - val nodeIds = nodes.map { it.id }.toSet() + val nodeIds = nodes.map { it.id }.toHashSet() val ways = wayDao.getAllForNodes(nodeIds) val wayIds = ways.map { it.id } val additionalWayNodeIds = ways diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementKey.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementKey.kt index d27e81c93d5..c275d236441 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementKey.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementKey.kt @@ -8,8 +8,6 @@ data class ElementKey(val type: ElementType, val id: Long) { override fun toString() = "${type.name}#$id" } -val Element.key get() = ElementKey(type, id) - val ElementGeometryEntry.key get() = ElementKey(elementType, elementId) val RelationMember.key get() = ElementKey(type, ref) diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiParser.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiParser.kt index e9ac1e52705..2a39a0aa535 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiParser.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiParser.kt @@ -64,7 +64,9 @@ private fun XmlReader.parseMapData(ignoreRelationTypes: Set): MutableMa END_ELEMENT -> when (localName) { "node" -> result.add(Node(id!!, position!!, tags.orEmpty(), version!!, timestamp!!)) "way" -> result.add(Way(id!!, nodes, tags.orEmpty(), version!!, timestamp!!)) - "relation" -> if (tags.orEmpty()["type"] !in ignoreRelationTypes) { + "relation" -> if (tags.orEmpty()["type"] !in ignoreRelationTypes + || (members.size <= 100 && tags.orEmpty()["route"] in allowRouteTypes) + ) { result.add(Relation(id!!, members, tags.orEmpty(), version!!, timestamp!!)) } } @@ -96,3 +98,5 @@ private fun XmlReader.parseElementUpdates(): Map Pair, Collection>, // used if the tile is not contained private val fetchNodes: (Collection) -> Collection, @@ -30,19 +32,16 @@ class MapDataCache( maxTiles, initialCapacity, { emptyList() }, // data is fetched using fetchMapData and put using spatialCache.replaceAllInBBox - Node::id, Node::position + Node::key, Node::position, ) // initial values obtained from a spot check: // approximately 80% of all elements were found to be nodes // approximately every second node is part of a way // more than 90% of elements are not part of a relation - private val nodesOutsideSpatialCache = HashMap(initialCapacity) - private val wayCache = HashMap(initialCapacity / 6) - private val relationCache = HashMap(initialCapacity / 10) - private val wayGeometryCache = HashMap(initialCapacity / 6) - private val relationGeometryCache = HashMap(initialCapacity / 10) - private val wayIdsByNodeIdCache = HashMap>(initialCapacity / 2) - private val relationIdsByElementKeyCache = HashMap>(initialCapacity / 10) + private val notSpatialCache = HashMap(initialCapacity / 4) + private val wayRelationGeometryCache = HashMap(initialCapacity / 5) + private val wayKeyByNodeKeyCache = HashMap>(initialCapacity / 2) + private val relationKeysByElementKeyCache = HashMap>(initialCapacity / 10) /** * Removes elements and geometries with keys in [deletedKeys] from cache and puts the @@ -57,65 +56,74 @@ class MapDataCache( bbox: BoundingBox? = null ) { synchronized(this) { val updatedNodes = updatedElements.filterIsInstance() - val deletedNodeIds = deletedKeys.mapNotNull { if (it.type == ElementType.NODE) it.id else null } + val deletedNodeKeys = deletedKeys.filter { it.type == ElementType.NODE } if (bbox == null) { // just update nodes if the containing tile - spatialCache.update(updatedOrAdded = updatedNodes, deleted = deletedNodeIds) + spatialCache.update(updatedOrAdded = updatedNodes, deleted = deletedNodeKeys) } else { // delete first, then put bbox and nodes to spatialCache (adds/clears tiles in bbox) - spatialCache.update(deleted = deletedNodeIds) + spatialCache.update(deleted = deletedNodeKeys) spatialCache.replaceAllInBBox(updatedNodes, bbox) } - // remove all cached nodes that are now in spatialCache - nodesOutsideSpatialCache.keys.removeAll { spatialCache.get(it) != null } // delete nodes, ways and relations for (key in deletedKeys) { when (key.type) { ElementType.NODE -> { - wayIdsByNodeIdCache.remove(key.id) - nodesOutsideSpatialCache.remove(key.id) + wayKeyByNodeKeyCache.remove(key) + notSpatialCache.remove(key) } ElementType.WAY -> { - val deletedWayNodeIds = wayCache.remove(key.id)?.nodeIds.orEmpty() + val deletedWayNodeIds = (notSpatialCache.remove(key) as? Way)?.nodeIds.orEmpty() for (nodeId in deletedWayNodeIds) { - wayIdsByNodeIdCache[nodeId]?.remove(key.id) + wayKeyByNodeKeyCache[ElementKey(ElementType.NODE, nodeId)]?.remove(key) } - wayGeometryCache.remove(key.id) + wayRelationGeometryCache.remove(key) } ElementType.RELATION -> { - val deletedRelationMembers = relationCache.remove(key.id)?.members.orEmpty() + val deletedRelationMembers = (notSpatialCache.remove(key) as? Relation)?.members.orEmpty() for (member in deletedRelationMembers) { - relationIdsByElementKeyCache[member.key]?.remove(key.id) + relationKeysByElementKeyCache[member.key]?.remove(key) } - relationGeometryCache.remove(key.id) + wayRelationGeometryCache.remove(key) } } } // update way and relation geometries for (entry in updatedGeometries) { - if (entry.elementType == ElementType.WAY) { - wayGeometryCache[entry.elementId] = entry.geometry - } else if (entry.elementType == ElementType.RELATION) { - relationGeometryCache[entry.elementId] = entry.geometry - } + if (entry.elementType != ElementType.NODE) + wayRelationGeometryCache[entry.reuseKey] = entry.geometry } - // add nodes that are not in spatialCache to nodeCache - updatedNodes.forEach { if (spatialCache.get(it.id) == null) nodesOutsideSpatialCache[it.id] = it } + if (bbox == null) + updatedNodes.forEach { + // updated nodes are either in spatialCache, then remove from notSpatialCache + // or the are not, then add to notSpatialCache + if (spatialCache.get(it.key) == null) notSpatialCache[it.key] = it + else notSpatialCache.remove(it.key) + } + else { + // spatialCache may have changed size, better remove all nodes that are not in spatialCache + + // remove all cached nodes that are now in spatialCache + notSpatialCache.keys.removeAll { it.type == ElementType.NODE && spatialCache.get(it) != null } + + // add nodes that are not in spatialCache to nodeCache + updatedNodes.forEach { if (spatialCache.get(it.key) == null) notSpatialCache[it.key] = it } + } // update ways val updatedWays = updatedElements.filterIsInstance() for (way in updatedWays) { // updated way may have different node ids than old one, so those need to be removed first - val oldWay = wayCache[way.id] + val oldWay = notSpatialCache[way.key] as? Way if (oldWay != null) { for (oldNodeId in oldWay.nodeIds) { - wayIdsByNodeIdCache[oldNodeId]?.remove(way.id) + wayKeyByNodeKeyCache[ElementKey(ElementType.NODE, oldNodeId)]?.remove(way.key) } } - wayCache[way.id] = way + notSpatialCache[way.key] = way // ...and then the new node ids added for (nodeId in way.nodeIds) { // only if the node is already in spatial cache, the way ids it refers to must be known: @@ -126,12 +134,14 @@ class MapDataCache( // or if an entry for that node already exists (cached from getWaysForNode). // Otherwise the cache may return an incomplete list of ways in getWaysForNode, // instead of fetching the correct list. - val wayIdsReferredByNode = if (spatialCache.get(nodeId) != null) { - wayIdsByNodeIdCache.getOrPut(nodeId) { ArrayList(2) } + val nodeKey = ElementKey(ElementType.NODE, nodeId) + val node = spatialCache.get(nodeKey) + val wayIdsReferredByNode = if (node != null) { + wayKeyByNodeKeyCache.getOrPut(node.key) { ArrayList(2) } } else { - wayIdsByNodeIdCache[nodeId] + wayKeyByNodeKeyCache[nodeKey] } - wayIdsReferredByNode?.add(way.id) + wayIdsReferredByNode?.add(way.key) } } @@ -142,35 +152,46 @@ class MapDataCache( // in spatialCache, or have a node / member in spatialCache (same reasoning as for ways) val (wayIds, relationIds) = determineWayAndRelationIdsWithElementsInSpatialCache() + lateinit var memberKey: ElementKey for (relation in updatedRelations) { // old relation may now have different members, so they need to be removed first - val oldRelation = relationCache[relation.id] + val oldRelation = notSpatialCache[relation.key] as? Relation if (oldRelation != null) { for (oldMember in oldRelation.members) { - relationIdsByElementKeyCache[oldMember.key]?.remove(relation.id) + relationKeysByElementKeyCache[oldMember.key]?.remove(relation.key) } } - relationCache[relation.id] = relation - + notSpatialCache[relation.key] = relation // ...and then the new members added for (member in relation.members) { - val memberKey = member.key + memberKey = member.key // only if the node member is already in the spatial cache or any node of a member // is, the relation ids it refers to must be known: // relationIdsByElementKeyCache is required for getMapDataWithGeometry(bbox), // because a relation is inside the bbox if it contains a member inside the bbox, // see comment above for wayIdsReferredByNode val isInSpatialCache = when (member.type) { - ElementType.NODE -> spatialCache.get(member.ref) != null - ElementType.WAY -> member.ref in wayIds + ElementType.NODE -> { + val node = spatialCache.get(memberKey) + if (node != null) { + memberKey = node.key + true + } else false + } + ElementType.WAY -> { + if (member.ref in wayIds) { + notSpatialCache[memberKey]?.let { memberKey = it.key } + true + } else false + } ElementType.RELATION -> member.ref in relationIds } val relationIdsReferredByMember = if (isInSpatialCache) { - relationIdsByElementKeyCache.getOrPut(memberKey) { ArrayList(2) } + relationKeysByElementKeyCache.getOrPut(memberKey) { ArrayList(2) } } else { - relationIdsByElementKeyCache[memberKey] + relationKeysByElementKeyCache[memberKey] } - relationIdsReferredByMember?.add(relation.id) + relationIdsReferredByMember?.add(relation.key) } } } @@ -185,10 +206,10 @@ class MapDataCache( id: Long, fetch: (ElementType, Long) -> Element? ): Element? = synchronized(this) { - when (type) { - ElementType.NODE -> spatialCache.get(id) ?: nodesOutsideSpatialCache.getOrPutIfNotNull(id) { fetch(type, id) as? Node } - ElementType.WAY -> wayCache.getOrPutIfNotNull(id) { fetch(type, id) as? Way } - ElementType.RELATION -> relationCache.getOrPutIfNotNull(id) { fetch(type, id) as? Relation } + val key = ElementKey(type, id) + when (key.type) { + ElementType.NODE -> spatialCache.get(key) ?: notSpatialCache.getOrPutIfNotNull(key) { fetch(key.type, key.id) } + else -> notSpatialCache.getOrPutIfNotNull(key) { fetch(key.type, key.id) } } } @@ -199,12 +220,13 @@ class MapDataCache( fun getGeometry( type: ElementType, id: Long, - fetch: (ElementType, Long) -> ElementGeometry? + fetch: (ElementType, Long) -> ElementGeometry?, + fetchNode: (Long) -> Node? ): ElementGeometry? = synchronized(this) { - return when (type) { - ElementType.NODE -> getCachedNode(id)?.let { ElementPointGeometry(it.position) } ?: fetch(type, id) - ElementType.WAY -> wayGeometryCache.getOrPutIfNotNull(id) { fetch(type, id) } - ElementType.RELATION -> relationGeometryCache.getOrPutIfNotNull(id) { fetch(type, id) } + val key = ElementKey(type, id) + when (type) { + ElementType.NODE -> (spatialCache.get(key) ?: notSpatialCache.getOrPutIfNotNull(key) { fetchNode(id) })?.let { ElementPointGeometry((it as Node).position) } + else -> wayRelationGeometryCache.getOrPutIfNotNull(key) { fetch(type, id) } } } @@ -220,9 +242,8 @@ class MapDataCache( ): List = synchronized(this) { val cachedElements = keys.mapNotNull { key -> when (key.type) { - ElementType.NODE -> getCachedNode(key.id) - ElementType.WAY -> wayCache[key.id] - ElementType.RELATION -> relationCache[key.id] + ElementType.NODE -> getCachedNode(key) + else -> notSpatialCache[key] } } @@ -230,15 +251,11 @@ class MapDataCache( if (keys.size == cachedElements.size) return cachedElements // otherwise, fetch the rest & save to cache - val cachedKeys = cachedElements.map { it.key }.toSet() + val cachedKeys = cachedElements.mapTo(HashSet(cachedElements.size)) { it.key } val keysToFetch = keys.filterNot { it in cachedKeys } val fetchedElements = fetch(keysToFetch) for (element in fetchedElements) { - when (element.type) { - ElementType.NODE -> nodesOutsideSpatialCache[element.id] = element as Node - ElementType.WAY -> wayCache[element.id] = element as Way - ElementType.RELATION -> relationCache[element.id] = element as Relation - } + notSpatialCache[element.key] = element } return cachedElements + fetchedElements } @@ -246,14 +263,14 @@ class MapDataCache( /** Gets the nodes with the given [ids] from cache. If any of the nodes are not cached, [fetch] * is called for the missing nodes. */ fun getNodes(ids: Collection, fetch: (Collection) -> List): List = synchronized(this) { - val cachedNodes = ids.mapNotNull { getCachedNode(it) } + val cachedNodes = ids.mapNotNull { getCachedNode(ElementKey(ElementType.NODE, it)) } if (ids.size == cachedNodes.size) return cachedNodes // not all in cache: must fetch the rest from db - val cachedNodeIds = cachedNodes.map { it.id }.toSet() + val cachedNodeIds = cachedNodes.mapTo(HashSet(cachedNodes.size)) { it.id } val missingNodeIds = ids.filterNot { it in cachedNodeIds } val fetchedNodes = fetch(missingNodeIds) - fetchedNodes.forEach { nodesOutsideSpatialCache[it.id] = it } + fetchedNodes.forEach { notSpatialCache[it.key] = it } return cachedNodes + fetchedNodes } @@ -281,15 +298,15 @@ class MapDataCache( */ fun getGeometries( keys: Collection, - fetch: (Collection) -> List + fetch: (Collection) -> List, + fetchNodes: (Collection) -> List ): List = synchronized(this) { // the implementation here is quite identical to the implementation in getElements, only // that geometries and not elements are returned and thus different caches are accessed val cachedEntries = keys.mapNotNull { key -> when (key.type) { - ElementType.NODE -> getCachedNode(key.id)?.let { ElementPointGeometry(it.position) } - ElementType.WAY -> wayGeometryCache[key.id] - ElementType.RELATION -> relationGeometryCache[key.id] + ElementType.NODE -> getCachedNode(key)?.let { ElementPointGeometry(it.position) } + else -> wayRelationGeometryCache[key] }?.let { ElementGeometryEntry(key.type, key.id, it) } } @@ -297,17 +314,16 @@ class MapDataCache( if (keys.size == cachedEntries.size) return cachedEntries // otherwise, fetch the rest & save to cache - val cachedKeys = cachedEntries.map { it.key }.toSet() + val cachedKeys = cachedEntries.mapTo(HashSet(cachedEntries.size)) { it.key } val keysToFetch = keys.filterNot { it in cachedKeys } - val fetchedEntries = fetch(keysToFetch) + val fetchedEntries = fetch(keysToFetch.filterNot { it.type == ElementType.NODE }) // only fetch non-nodes for (entry in fetchedEntries) { - when (entry.elementType) { - ElementType.WAY -> wayGeometryCache[entry.elementId] = entry.geometry - ElementType.RELATION -> relationGeometryCache[entry.elementId] = entry.geometry - else -> Unit - } + wayRelationGeometryCache[entry.key] = entry.geometry // no nodes fetched anyway } - return cachedEntries + fetchedEntries + // now fetch the nodes separately and add them to nodeCache + val nodes = fetchNodes(keysToFetch.mapNotNull { if (it.type == ElementType.NODE) it.id else null }) + nodes.forEach { notSpatialCache[it.key] = it } + return cachedEntries + fetchedEntries + nodes.map { it.toElementGeometryEntry() } } /** @@ -315,12 +331,12 @@ class MapDataCache( * or any way is missing in cache, [fetch] is called and the result cached. */ fun getWaysForNode(id: Long, fetch: (Long) -> List): List = synchronized(this) { - val wayIds = wayIdsByNodeIdCache.getOrPut(id) { + val wayIds = wayKeyByNodeKeyCache.getOrPut(ElementKey(ElementType.NODE, id)) { val ways = fetch(id) - for (way in ways) { wayCache[way.id] = way } - ways.map { it.id }.toMutableList() + for (way in ways) { notSpatialCache[way.key] = way } + ways.map { it.key }.toMutableList() } - return wayIds.mapNotNull { wayCache[it] } + return wayIds.mapNotNull { notSpatialCache[it] as? Way } } /** @@ -328,33 +344,32 @@ class MapDataCache( * not known, or any relation is missing in cache, [fetch] is called and the result cached. */ fun getRelationsForNode(id: Long, fetch: (Long) -> List) = - getRelationsForElement(ElementType.NODE, id) { fetch(id) } + getRelationsForElement(ElementKey(ElementType.NODE, id)) { fetch(id) } /** * Gets all relations for way with the given [id] from cache. If the list of relations is not * known, or any relation is missing in cache, [fetch] is called and the result cached. */ fun getRelationsForWay(id: Long, fetch: (Long) -> List) = - getRelationsForElement(ElementType.WAY, id) { fetch(id) } + getRelationsForElement(ElementKey(ElementType.WAY, id)) { fetch(id) } /** * Gets all relations for way with the given [id] from cache. If the list of relations is not * known, or any relation is missing in cache, [fetch] is called and the result cached. */ fun getRelationsForRelation(id: Long, fetch: (Long) -> List) = - getRelationsForElement(ElementType.RELATION, id) { fetch(id) } + getRelationsForElement(ElementKey(ElementType.RELATION, id)) { fetch(id) } private fun getRelationsForElement( - type: ElementType, - id: Long, + key: ElementKey, fetch: () -> List ): List = synchronized(this) { - val relationIds = relationIdsByElementKeyCache.getOrPut(ElementKey(type, id)) { + val relationIds = relationKeysByElementKeyCache.getOrPut(key) { val relations = fetch() - for (relation in relations) { relationCache[relation.id] = relation } - relations.map { it.id }.toMutableList() + for (relation in relations) { notSpatialCache[relation.key] = relation } + relations.map { it.key }.toMutableList() } - return relationIds.mapNotNull { relationCache[it] } + return relationIds.mapNotNull { notSpatialCache[it] as? Relation } } /** @@ -366,17 +381,11 @@ class MapDataCache( fun getMapDataWithGeometry(bbox: BoundingBox): MutableMapDataWithGeometry = synchronized(this) { val requiredTiles = bbox.enclosingTilesRect(tileZoom).asTilePosSequence().toList() val cachedTiles = spatialCache.getTiles() - val tilesToFetch = requiredTiles.filterNot { it in cachedTiles } - val tilesRectToFetch = tilesToFetch.minTileRect() - - val result = MutableMapDataWithGeometry() - result.boundingBox = bbox - val nodes: Collection - if (tilesRectToFetch != null) { - // fetch needed data - val fetchBBox = tilesRectToFetch.asBoundingBox(tileZoom) - val (elements, geometries) = fetchMapData(fetchBBox) + val tilesRectsToFetch = requiredTiles.filterNot { it in cachedTiles }.upToTwoMinTileRects() + val result: MutableMapDataWithGeometry + if (tilesRectsToFetch != null) { + Log.i(TAG, "need to fetch data in $tilesRectsToFetch from database") // get nodes from spatial cache // this may not contain all nodes, but tiles that were cached initially might // get dropped when the caches are updated @@ -384,47 +393,57 @@ class MapDataCache( // get(bbox) for tiles not in spatialCache calls spatialCache.fetch, but this is still // safe as tiles are replaced and properly filled as part of the following update - nodes = HashSet(spatialCache.get(bbox)) - update(updatedElements = elements, updatedGeometries = geometries, bbox = fetchBBox) - - // return data if we need exactly what was just fetched - if (fetchBBox == bbox) { - val nodeGeometryEntries = elements.filterIsInstance().map { it.toElementGeometryEntry() } - result.putAll(elements, geometries + nodeGeometryEntries) - return result + spatialCache.get(bbox).also { + result = MutableMapDataWithGeometry(min(it.size * 2, 1000)) + it.forEach { result.put(it, ElementPointGeometry(it.position)) } } - // add newly fetched nodes from elements - // getting nodes from spatialCache can cause issues, as tiles in the bbox may now be removed unexpectedly - // see https://github.com/streetcomplete/StreetComplete/issues/4980#issuecomment-1531960544 - for (element in elements) { - if (element !is Node) continue - if (element.position in bbox) nodes.add(element) + // fetch needed data and put it to cache + tilesRectsToFetch.forEach { tilesRect -> + val fetchBBox = tilesRect.asBoundingBox(tileZoom) + val (elements, geometries) = fetchMapData(fetchBBox) + update(updatedElements = elements, updatedGeometries = geometries, bbox = fetchBBox) + if (fetchBBox == bbox) { + // return data if we need exactly the bbox that was just fetched + result.putAll(elements, geometries + elements.filterIsInstance().map { it.toElementGeometryEntry() }) + result.boundingBox = bbox + return result + } + // add newly fetched nodes from elements + // getting nodes from spatialCache can cause issues, as tiles in the bbox may now be removed unexpectedly + // see https://github.com/streetcomplete/StreetComplete/issues/4980#issuecomment-1531960544 + for (element in elements) { + if (element !is Node) continue + if (element.position in bbox) result.put(element, ElementPointGeometry(element.position)) + } } } else { - nodes = spatialCache.get(bbox) + spatialCache.get(bbox).also { + result = MutableMapDataWithGeometry(it.size) + it.forEach { result.put(it, ElementPointGeometry(it.position)) } + } } + result.boundingBox = bbox - val wayIds = HashSet(nodes.size / 5) - val relationIds = HashSet(nodes.size / 10) - for (node in nodes) { - wayIdsByNodeIdCache[node.id]?.let { wayIds.addAll(it) } - relationIdsByElementKeyCache[node.key]?.let { relationIds.addAll(it) } - result.put(node, ElementPointGeometry(node.position)) + val wayKeys = HashSet(result.nodes.size / 5) + val relationKeys = HashSet(result.nodes.size / 10) + for (node in result.nodes) { + wayKeyByNodeKeyCache[node.key]?.let { wayKeys.addAll(it) } + relationKeysByElementKeyCache[node.key]?.let { relationKeys.addAll(it) } } val nodesToFetch = hashSetOf() - for (wayId in wayIds) { - val way = wayCache[wayId]!! - val wayGeometry = wayGeometryCache[wayId] + for (wayKey in wayKeys) { + val way = notSpatialCache[wayKey] as Way + val wayGeometry = wayRelationGeometryCache[wayKey] result.put(way, wayGeometry) - relationIdsByElementKeyCache[way.key]?.let { relationIds.addAll(it) } + relationKeysByElementKeyCache[way.key]?.let { relationKeys.addAll(it) } // find all nodes that are part of the way, but not in result if (wayGeometry?.getBounds()?.isCompletelyInside(bbox) == true) continue // no need to check for (nodeId in way.nodeIds) { if (result.getNode(nodeId) != null) continue - val cachedNode = getCachedNode(nodeId) + val cachedNode = getCachedNode(ElementKey(ElementType.NODE, nodeId)) if (cachedNode != null) { result.put(cachedNode, ElementPointGeometry(cachedNode.position)) continue @@ -434,19 +453,19 @@ class MapDataCache( } if (nodesToFetch.isNotEmpty()) { fetchNodes(nodesToFetch).forEach { - nodesOutsideSpatialCache[it.id] = it + notSpatialCache[it.key] = it result.put(it, ElementPointGeometry(it.position)) } } - for (relationId in relationIds) { - result.put(relationCache[relationId]!!, relationGeometryCache[relationId]) + for (relationKey in relationKeys) { + result.put(notSpatialCache[relationKey]!!, wayRelationGeometryCache[relationKey]) // don't add relations of relations, because elementDao.getAll(bbox) also isn't doing that } // trim if we fetched new data, and spatialCache is full // trim to 66%, so trim is (probably) not immediately called on next fetch - if (spatialCache.size >= maxTiles && tilesToFetch.isNotEmpty()) { + if (tilesRectsToFetch != null && spatialCache.size >= maxTiles) { trim((maxTiles * 2) / 3) } return result @@ -454,36 +473,43 @@ class MapDataCache( /** Clears the cache */ fun clear() { synchronized(this) { + Log.i(TAG, "clear cache") spatialCache.clear() - nodesOutsideSpatialCache.clear() - wayCache.clear() - relationCache.clear() - wayGeometryCache.clear() - relationGeometryCache.clear() - wayIdsByNodeIdCache.clear() - relationIdsByElementKeyCache.clear() + notSpatialCache.clear() + wayRelationGeometryCache.clear() + wayKeyByNodeKeyCache.clear() + relationKeysByElementKeyCache.clear() } } /** Reduces cache size to the given number of non-empty [tiles], and removes all data * not contained in the remaining tiles. */ fun trim(tiles: Int) { synchronized(this) { - spatialCache.trim(tiles) - nodesOutsideSpatialCache.clear() // simply clear nodeCache, as transferring some nodes from spatialCache is slow + Log.i(TAG, "trim to $tiles tiles") // ways and relations with at least one element in cache should not be removed val (wayIds, relationIds) = determineWayAndRelationIdsWithElementsInSpatialCache() - wayCache.keys.retainAll { it in wayIds } - relationCache.keys.retainAll { it in relationIds } - wayGeometryCache.keys.retainAll { it in wayIds } - relationGeometryCache.keys.retainAll { it in relationIds } + notSpatialCache.keys.retainAll { + when (it.type) { + ElementType.WAY -> it.id in wayIds + ElementType.RELATION -> it.id in relationIds + else -> false + } + } + wayRelationGeometryCache.keys.retainAll { + when (it.type) { + ElementType.WAY -> it.id in wayIds + ElementType.RELATION -> it.id in relationIds + else -> false + } + } // now clean up wayIdsByNodeIdCache and relationIdsByElementKeyCache - wayIdsByNodeIdCache.keys.retainAll { spatialCache.get(it) != null } - relationIdsByElementKeyCache.keys.retainAll { + wayKeyByNodeKeyCache.keys.retainAll { spatialCache.get(it) != null } + relationKeysByElementKeyCache.keys.retainAll { when (it.type) { - ElementType.NODE -> spatialCache.get(it.id) != null + ElementType.NODE -> spatialCache.get(ElementKey(ElementType.NODE, it.id)) != null ElementType.WAY -> it.id in wayIds ElementType.RELATION -> it.id in relationIds } @@ -498,23 +524,25 @@ class MapDataCache( // and now the other caches are outdated. So this method exists to find those elements that // are STILL referred to directly or indirectly by the spatial cache. - val wayIds = HashSet(wayCache.size) - for (way in wayCache.values) { - if (way.nodeIds.any { spatialCache.get(it) != null }) { + val wayIds = HashSet(notSpatialCache.size) + for (way in notSpatialCache.values) { + if (way !is Way) continue + if (way.nodeIds.any { spatialCache.get(ElementKey(ElementType.NODE, it)) != null }) { wayIds.add(way.id) } } fun RelationMember.isCachedWayOrNode(): Boolean = - type == ElementType.NODE && spatialCache.get(ref) != null + type == ElementType.NODE && spatialCache.get(key) != null || type == ElementType.WAY && ref in wayIds fun RelationMember.hasCachedMembers(): Boolean = type == ElementType.RELATION - && relationCache[ref]?.members?.any { it.isCachedWayOrNode() } == true + && (notSpatialCache[key] as? Relation)?.members?.any { it.isCachedWayOrNode() } == true - val relationIds = HashSet(relationCache.size) - for (relation in relationCache.values) { + val relationIds = HashSet(notSpatialCache.size / 3) + for (relation in notSpatialCache.values) { + if (relation !is Relation) continue if (relation.members.any { it.isCachedWayOrNode() || it.hasCachedMembers() }) { relationIds.add(relation.id) } @@ -534,5 +562,11 @@ class MapDataCache( private fun Node.toElementGeometryEntry() = ElementGeometryEntry(type, id, ElementPointGeometry(position)) - private fun getCachedNode(id: Long): Node? = spatialCache.get(id) ?: nodesOutsideSpatialCache[id] + private fun getCachedNode(key: ElementKey): Node? = spatialCache.get(key) ?: (notSpatialCache[key] as? Node) + + private val ElementGeometryEntry.reuseKey get() = key.let { notSpatialCache[it]?.key ?: it } + + companion object { + private const val TAG = "MapDataCache" + } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataController.kt index ff3f25f556e..85a50a9a135 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataController.kt @@ -18,7 +18,7 @@ class MapDataController internal constructor( private val elementDB: ElementDao, private val geometryDB: ElementGeometryDao, private val elementGeometryCreator: ElementGeometryCreator, - private val createdElementsController: CreatedElementsController + private val createdElementsController: CreatedElementsController, ) : MapDataRepository { /* Must be a singleton because there is a listener that should respond to a change in the @@ -67,7 +67,7 @@ class MapDataController internal constructor( geometryEntries = createGeometries(mapData, mapData) // don't use cache here, because if not everything is already cached, db call will be faster - oldElementKeys = elementDB.getAllKeys(mapData.boundingBox!!).toMutableSet() + oldElementKeys = elementDB.getAllKeys(mapData.boundingBox!!).toHashSet() for (element in mapData) { oldElementKeys.remove(element.key) } @@ -84,7 +84,7 @@ class MapDataController internal constructor( Log.i(TAG, "Persisted ${geometryEntries.size} and deleted ${oldElementKeys.size} elements and geometries" + - " in ${((nowAsEpochMilliseconds() - time) / 1000.0).format(1)}s" + " in ${((nowAsEpochMilliseconds() - time) / 1000.0).format(1)}s" ) val mapDataWithGeometry = MutableMapDataWithGeometry(mapData, geometryEntries) @@ -163,10 +163,10 @@ class MapDataController internal constructor( } fun getGeometry(type: ElementType, id: Long): ElementGeometry? = - cache.getGeometry(type, id, geometryDB::get) + cache.getGeometry(type, id, geometryDB::get, nodeDB::get) fun getGeometries(keys: Collection): List = - cache.getGeometries(keys, geometryDB::getAllEntries) + cache.getGeometries(keys, geometryDB::getAllEntries, nodeDB::getAll) fun getMapDataWithGeometry(bbox: BoundingBox): MutableMapDataWithGeometry = cache.getMapDataWithGeometry(bbox) @@ -200,7 +200,7 @@ class MapDataController internal constructor( override fun getWayComplete(id: Long): MapData? { val way = getWay(id) ?: return null - val nodeIds = way.nodeIds.toSet() + val nodeIds = way.nodeIds.toHashSet() val nodes = getNodes(nodeIds) if (nodes.size < nodeIds.size) return null return MutableMapData(nodes + way) @@ -208,7 +208,7 @@ class MapDataController internal constructor( override fun getRelationComplete(id: Long): MapData? { val relation = getRelation(id) ?: return null - val elementKeys = relation.members.map { it.key }.toSet() + val elementKeys = relation.members.mapTo(HashSet(relation.members.size)) { it.key } val elements = getAll(elementKeys) if (elements.size < elementKeys.size) return null return MutableMapData(elements + relation) @@ -256,7 +256,7 @@ class MapDataController internal constructor( fun clearCache() = synchronized(this) { cache.clear() } - fun trimCache() = synchronized(this) { cache.trim(SPATIAL_CACHE_TILES / 3) } + fun trimCache() = synchronized(this) { cache.trim(SPATIAL_CACHE_TILES / 4) } fun addListener(listener: Listener) { listeners.add(listener) diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataUpdates.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataUpdates.kt index 4e33990877a..9a4a6dbfbdb 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataUpdates.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataUpdates.kt @@ -24,7 +24,9 @@ fun createMapDataUpdates( val idUpdates = mutableListOf() for (element in elements) { - if (element is Relation && element.tags["type"] in ignoreRelationTypes) continue + if (element is Relation + && (element.tags["type"] in ignoreRelationTypes && (element.members.size > 100 || element.tags["route"] !in allowRouteTypes)) + ) continue val newElement = element.update(updates) if (newElement == null) { diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MutableMapDataWithGeometry.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MutableMapDataWithGeometry.kt index 0bed4805de4..36fc2902897 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MutableMapDataWithGeometry.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MutableMapDataWithGeometry.kt @@ -4,9 +4,9 @@ import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometryEntry import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry -class MutableMapDataWithGeometry() : MapDataWithGeometry { +class MutableMapDataWithGeometry(capacity: Int = 50) : MapDataWithGeometry { - constructor(elements: Iterable, geometryEntries: Iterable) : this() { + constructor(elements: Iterable, geometryEntries: Iterable) : this((elements as? Collection)?.size ?: 50) { putAll(elements, geometryEntries) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/NodeDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/NodeDao.kt index b910ada40ba..a5be779a02e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/NodeDao.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/NodeDao.kt @@ -1,5 +1,8 @@ package de.westnordost.streetcomplete.data.osm.mapdata +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types import de.westnordost.streetcomplete.data.CursorPosition import de.westnordost.streetcomplete.data.Database import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometryEntry @@ -13,9 +16,7 @@ import de.westnordost.streetcomplete.data.osm.mapdata.NodeTable.Columns.TIMESTAM import de.westnordost.streetcomplete.data.osm.mapdata.NodeTable.Columns.VERSION import de.westnordost.streetcomplete.data.osm.mapdata.NodeTable.NAME import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json +import de.westnordost.streetcomplete.util.ktx.toInternedMap /** Stores OSM nodes */ class NodeDao(private val db: Database) { @@ -42,7 +43,7 @@ class NodeDao(private val db: Database) { node.version, node.position.latitude, node.position.longitude, - if (node.tags.isNotEmpty()) Json.encodeToString(node.tags) else null, + if (node.tags.isNotEmpty()) jsonAdapter.toJson(node.tags) else null, node.timestampEdited, time ) @@ -88,13 +89,18 @@ class NodeDao(private val db: Database) { } fun getAll(bbox: BoundingBox): List = - db.query(NAME, where = inBoundsSql(bbox)) { it.toNode() } + db.query(NAME, columns = arrayOf(ID, LATITUDE, LONGITUDE, TAGS, VERSION, TIMESTAMP), where = inBoundsSql(bbox)) { it.toNode() } } +private val jsonAdapter: JsonAdapter> = Moshi.Builder().build() + .adapter(Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)) + private fun CursorPosition.toNode() = Node( getLong(ID), LatLon(getDouble(LATITUDE), getDouble(LONGITUDE)), - getStringOrNull(TAGS)?.let { Json.decodeFromString(it) } ?: emptyMap(), + // copying from the moshi-"LinkedTreeHashMap" to a normal HashMap is slightly slower than keeping + // the moshi map, but tag search then is a little faster, so overall it's better, plus using less memory + getStringOrNull(TAGS)?.let { jsonAdapter.fromJson(it)?.toInternedMap() } ?: emptyMap(), getInt(VERSION), getLong(TIMESTAMP), ) diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/RelationDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/RelationDao.kt index 1f804fc49f4..ec6f4d06a12 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/RelationDao.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/RelationDao.kt @@ -1,5 +1,8 @@ package de.westnordost.streetcomplete.data.osm.mapdata +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types import de.westnordost.streetcomplete.data.Database import de.westnordost.streetcomplete.data.osm.mapdata.RelationTables.Columns.ID import de.westnordost.streetcomplete.data.osm.mapdata.RelationTables.Columns.INDEX @@ -13,8 +16,7 @@ import de.westnordost.streetcomplete.data.osm.mapdata.RelationTables.Columns.VER import de.westnordost.streetcomplete.data.osm.mapdata.RelationTables.NAME import de.westnordost.streetcomplete.data.osm.mapdata.RelationTables.NAME_MEMBERS import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json +import de.westnordost.streetcomplete.util.ktx.toInternedMap /** Stores OSM relations */ class RelationDao(private val db: Database) { @@ -57,7 +59,7 @@ class RelationDao(private val db: Database) { arrayOf( relation.id, relation.version, - if (relation.tags.isNotEmpty()) Json.encodeToString(relation.tags) else null, + if (relation.tags.isNotEmpty()) jsonAdapter.toJson(relation.tags) else null, relation.timestampEdited, time ) @@ -71,14 +73,14 @@ class RelationDao(private val db: Database) { val idsString = ids.joinToString(",") return db.transaction { - val membersByRelationId = mutableMapOf>() + val membersByRelationId = hashMapOf>() db.query(NAME_MEMBERS, where = "$ID IN ($idsString)", orderBy = "$ID, $INDEX") { cursor -> val members = membersByRelationId.getOrPut(cursor.getLong(ID)) { ArrayList() } members.add( RelationMember( ElementType.valueOf(cursor.getString(TYPE)), cursor.getLong(REF), - cursor.getString(ROLE) + cursor.getString(ROLE).intern() ) ) } @@ -87,7 +89,7 @@ class RelationDao(private val db: Database) { Relation( cursor.getLong(ID), membersByRelationId.getValue(cursor.getLong(ID)), - cursor.getStringOrNull(TAGS)?.let { Json.decodeFromString(it) } ?: emptyMap(), + cursor.getStringOrNull(TAGS)?.let { jsonAdapter.fromJson(it)?.toInternedMap() } ?: emptyMap(), cursor.getInt(VERSION), cursor.getLong(TIMESTAMP) ) @@ -134,7 +136,7 @@ class RelationDao(private val db: Database) { wayIds: Collection = emptyList(), relationIds: Collection = emptyList() ): List = - getAll(getAllIdsForElements(nodeIds, wayIds, relationIds).toSet()) + getAll(getAllIdsForElements(nodeIds, wayIds, relationIds).toHashSet()) fun getAllIdsForElements( nodeIds: Collection = emptyList(), @@ -171,7 +173,10 @@ class RelationDao(private val db: Database) { columns = arrayOf(ID), where = "$TYPE = ? AND $REF = $elementId", args = arrayOf(elementType.name) - ) { it.getLong(ID) }.toSet() + ) { it.getLong(ID) }.toHashSet() getAll(ids) } } + +private val jsonAdapter: JsonAdapter> = Moshi.Builder().build() + .adapter(Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)) diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/WayDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/WayDao.kt index 676487b4296..93139bb29ef 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/WayDao.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/WayDao.kt @@ -1,5 +1,8 @@ package de.westnordost.streetcomplete.data.osm.mapdata +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types import de.westnordost.streetcomplete.data.Database import de.westnordost.streetcomplete.data.osm.mapdata.WayTables.Columns.ID import de.westnordost.streetcomplete.data.osm.mapdata.WayTables.Columns.INDEX @@ -11,9 +14,7 @@ import de.westnordost.streetcomplete.data.osm.mapdata.WayTables.Columns.VERSION import de.westnordost.streetcomplete.data.osm.mapdata.WayTables.NAME import de.westnordost.streetcomplete.data.osm.mapdata.WayTables.NAME_NODES import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json +import de.westnordost.streetcomplete.util.ktx.toInternedMap /** Stores OSM ways */ class WayDao(private val db: Database) { @@ -51,7 +52,7 @@ class WayDao(private val db: Database) { arrayOf( way.id, way.version, - if (way.tags.isNotEmpty()) Json.encodeToString(way.tags) else null, + if (way.tags.isNotEmpty()) jsonAdapter.toJson(way.tags) else null, way.timestampEdited, time ) @@ -65,8 +66,8 @@ class WayDao(private val db: Database) { val idsString = ids.joinToString(",") return db.transaction { - val nodeIdsByWayId = mutableMapOf>() - db.query(NAME_NODES, where = "$ID IN ($idsString)", orderBy = "$ID, $INDEX") { c -> + val nodeIdsByWayId = hashMapOf>() + db.query(NAME_NODES, where = "$ID IN ($idsString)", orderBy = "$ID, $INDEX", columns = arrayOf(ID, NODE_ID)) { c -> val nodeIds = nodeIdsByWayId.getOrPut(c.getLong(ID)) { ArrayList() } nodeIds.add(c.getLong(NODE_ID)) } @@ -75,8 +76,7 @@ class WayDao(private val db: Database) { Way( cursor.getLong(ID), nodeIdsByWayId.getValue(cursor.getLong(ID)), - cursor.getStringOrNull(TAGS)?.let { Json.decodeFromString(it) } - ?: emptyMap(), + cursor.getStringOrNull(TAGS)?.let { jsonAdapter.fromJson(it)?.toInternedMap() } ?: emptyMap(), cursor.getInt(VERSION), cursor.getLong(TIMESTAMP) ) @@ -103,8 +103,29 @@ class WayDao(private val db: Database) { fun getAllForNode(nodeId: Long): List = getAllForNodes(listOf(nodeId)) - fun getAllForNodes(nodeIds: Collection): List = - getAll(getAllIdsForNodes(nodeIds).toSet()) + // longer code, but 10-20% faster + fun getAllForNodes(nodeIds: Collection): List { + if (nodeIds.isEmpty()) return emptyList() + val idsString = nodeIds.joinToString(",") + + return db.transaction { + val nodeIdsByWayId = hashMapOf>() + db.query(NAME_NODES, where = "$ID IN (SELECT $ID FROM $NAME_NODES WHERE $NODE_ID IN ($idsString))", orderBy = "$ID, $INDEX", columns = arrayOf(ID, NODE_ID)) { c -> + val nodeIds2 = nodeIdsByWayId.getOrPut(c.getLong(ID)) { ArrayList() } + nodeIds2.add(c.getLong(NODE_ID)) + } + + db.query(NAME, where = "$ID IN (${nodeIdsByWayId.keys.joinToString(",")})") { cursor -> + Way( + cursor.getLong(ID), + nodeIdsByWayId.getValue(cursor.getLong(ID)), + cursor.getStringOrNull(TAGS)?.let { jsonAdapter.fromJson(it)?.toInternedMap() } ?: emptyMap(), + cursor.getInt(VERSION), + cursor.getLong(TIMESTAMP) + ) + } + } + } fun getAllIdsForNodes(nodeIds: Collection): List { if (nodeIds.isEmpty()) return emptyList() @@ -131,3 +152,6 @@ class WayDao(private val db: Database) { return nodeIds - nodeIdsWithWays.toHashSet() } } + +private val jsonAdapter: JsonAdapter> = Moshi.Builder().build() + .adapter(Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)) diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmElementQuestType.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmElementQuestType.kt index 0d7e0133f5b..5dc4653793d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmElementQuestType.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmElementQuestType.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.data.osm.osmquests +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.osm.edits.ElementEditType import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.mapdata.Element @@ -48,7 +49,7 @@ interface OsmElementQuestType : QuestType, ElementEditType { * ...should be deletable. * * By default: false.*/ - val isDeleteElementEnabled: Boolean get() = false + val isDeleteElementEnabled: Boolean get() = prefs.getBoolean(Prefs.EXPERT_MODE, false) /** Whether the user should be able to replace this element with another preset. Only * elements that are expected to be some kind of shop/amenity should be replaceable this way, @@ -99,4 +100,11 @@ interface OsmElementQuestType : QuestType, ElementEditType { fun applyAnswerTo(answer: T, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) override fun createForm(): AbstractOsmQuestForm + + /** tags to derive label of the dot, checked in given order. Use "label" to get the label + * using NameAndLocationLabel.getNameLabel. Use empty list to show no label. + * Ignored if [dotColor] is null. */ + val dotLabelSources: List get() = labelList } + +private val labelList = listOf("label") diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmFilterQuestType.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmFilterQuestType.kt index 8807ceedbc1..935ee616cfb 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmFilterQuestType.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmFilterQuestType.kt @@ -1,21 +1,36 @@ package de.westnordost.streetcomplete.data.osm.osmquests +import android.content.Context +import androidx.appcompat.app.AlertDialog +import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.quests.fullElementSelectionDialog +import de.westnordost.streetcomplete.quests.getPrefixedFullElementSelectionPref +import de.westnordost.streetcomplete.quests.getLabelSources /** Quest type where each quest refers to one OSM element where the element selection is based on * a simple [element filter expression][de.westnordost.streetcomplete.data.elementfilter.ElementFilterExpression]. */ abstract class OsmFilterQuestType : OsmElementQuestType { - val filter by lazy { elementFilter.toElementFilterExpression() } + val filter by lazy { + prefs.getString(getPrefixedFullElementSelectionPref(prefs), elementFilter)!!.toElementFilterExpression() + } - protected abstract val elementFilter: String + abstract val elementFilter: String override fun getApplicableElements(mapData: MapDataWithGeometry): Iterable = - mapData.filter(elementFilter).asIterable() + mapData.filter(prefs.getString(getPrefixedFullElementSelectionPref(prefs), elementFilter)!!).asIterable() - override fun isApplicableTo(element: Element) = filter.matches(element) + override fun isApplicableTo(element: Element): Boolean = filter.matches(element) + + override val hasQuestSettings: Boolean = true + + override fun getQuestSettingsDialog(context: Context): AlertDialog? = + fullElementSelectionDialog(context, prefs, this.getPrefixedFullElementSelectionPref(prefs), R.string.quest_settings_element_selection, elementFilter) + + override val dotLabelSources by lazy { getLabelSources(super.dotLabelSources.joinToString(", "), this, prefs) } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuest.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuest.kt index 654f3a4979a..7432654ff5c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuest.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuest.kt @@ -15,15 +15,15 @@ data class OsmQuest( override val elementType: ElementType, override val elementId: Long, override val geometry: ElementGeometry -) : Quest, OsmQuestDaoEntry { +) : Quest(), OsmQuestDaoEntry { - override val key: OsmQuestKey by lazy { OsmQuestKey(elementType, elementId, questTypeName) } + override val key: OsmQuestKey = OsmQuestKey(elementType, elementId, questTypeName.intern()) override val questTypeName: String get() = type.name override val position: LatLon get() = geometry.center - override val markerLocations: Collection by lazy { + override val markerLocations: Collection get() { if (geometry is ElementPolylinesGeometry) { val polyline = geometry.polylines[0] val length = polyline.measuredLength() @@ -39,13 +39,13 @@ data class OsmQuest( val between = (length - (2 * MARKER_FROM_END_DISTANCE)) / (count - 1) // space markers `between` apart, starting with `MARKER_FROM_END_DISTANCE` (the // final marker will end up at `MARKER_FROM_END_DISTANCE` from the other end) - return@lazy polyline.pointsOnPolylineFromStart( + return polyline.pointsOnPolylineFromStart( (0 until count).map { MARKER_FROM_END_DISTANCE + (it * between) } ) } } // fall through to a single marker in the middle - listOf(position) + return listOf(position) } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestController.kt index 3b633d976f8..d44084b1884 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestController.kt @@ -1,26 +1,43 @@ package de.westnordost.streetcomplete.data.osm.osmquests +import com.russhwolf.settings.ObservableSettings import de.westnordost.countryboundaries.CountryBoundaries import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.data.elementfilter.ElementsTypeFilter import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry import de.westnordost.streetcomplete.data.osm.mapdata.MutableMapDataWithGeometry import de.westnordost.streetcomplete.data.osm.mapdata.key import de.westnordost.streetcomplete.data.osmnotes.Note import de.westnordost.streetcomplete.data.osmnotes.edits.NotesWithEditsSource +import de.westnordost.streetcomplete.data.quest.AllCountries import de.westnordost.streetcomplete.data.quest.OsmQuestKey +import de.westnordost.streetcomplete.data.quest.QuestType import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry import de.westnordost.streetcomplete.quests.address.AddHousenumber +import de.westnordost.streetcomplete.quests.barrier_type.AddBarrierOnPath +import de.westnordost.streetcomplete.quests.barrier_type.AddBarrierOnRoad +import de.westnordost.streetcomplete.quests.building_entrance.AddEntrance +import de.westnordost.streetcomplete.quests.building_entrance_reference.AddEntranceReference +import de.westnordost.streetcomplete.quests.crossing.AddCrossing import de.westnordost.streetcomplete.quests.cycleway.AddCycleway +import de.westnordost.streetcomplete.quests.destination.AddDestination import de.westnordost.streetcomplete.quests.existence.CheckExistence +import de.westnordost.streetcomplete.quests.kerb_height.AddKerbHeight import de.westnordost.streetcomplete.quests.max_height.AddMaxHeight import de.westnordost.streetcomplete.quests.opening_hours.AddOpeningHours +import de.westnordost.streetcomplete.quests.piste_difficulty.AddPisteDifficulty +import de.westnordost.streetcomplete.quests.piste_lit.AddPisteLit +import de.westnordost.streetcomplete.quests.piste_ref.AddPisteRef import de.westnordost.streetcomplete.quests.place_name.AddPlaceName +import de.westnordost.streetcomplete.quests.roof_orientation.AddRoofOrientation import de.westnordost.streetcomplete.quests.shop_type.CheckShopExistence import de.westnordost.streetcomplete.util.Listeners import de.westnordost.streetcomplete.util.ktx.format @@ -38,6 +55,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -48,7 +66,8 @@ class OsmQuestController internal constructor( private val mapDataSource: MapDataWithEditsSource, private val notesSource: NotesWithEditsSource, private val questTypeRegistry: QuestTypeRegistry, - private val countryBoundaries: Lazy + private val countryBoundaries: Lazy, + private val prefs: ObservableSettings, ) : OsmQuestSource { private val listeners = Listeners() @@ -58,6 +77,31 @@ class OsmQuestController internal constructor( private val allQuestTypes get() = questTypeRegistry.filterIsInstance>() .sortedBy { it.chonkerIndex } + private val wayOnlyFilterQuestTypes by lazy { + // technically those could change if questTypeRegistry is reloaded, but that's unlikely enough to ignore it + // the filter step is slow when called on init, probably because filters are loaded by lazy + questTypeRegistry.filterIsInstance>() + .filter { it.filter.elementsTypes.size == 1 && it.filter.elementsTypes.single() == ElementsTypeFilter.WAYS } + .map { it.name }.toHashSet() + } + + // todo: re-evaluate whether this is worth it and maybe invert to blacklist + private val questsRequiringElementsWithoutTags = hashSetOf( + AddBarrierOnRoad::class.simpleName!!, + AddBarrierOnPath::class.simpleName!!, + AddCrossing::class.simpleName!!, + AddMaxHeight::class.simpleName!!, + AddEntrance::class.simpleName!!, + AddEntranceReference::class.simpleName!!, + AddHousenumber::class.simpleName!!, + AddDestination::class.simpleName!!, + AddPisteDifficulty::class.simpleName!!, + AddKerbHeight::class.simpleName!!, + AddPisteRef::class.simpleName!!, + AddPisteLit::class.simpleName!!, + AddRoofOrientation::class.simpleName!!, + ) + private val mapDataSourceListener = object : MapDataWithEditsSource.Listener { /** For the given elements, replace the current quests with the given ones. Called when @@ -93,7 +137,11 @@ class OsmQuestController internal constructor( visibleQuests = quests.filterVisible() } - onUpdated(added = visibleQuests, deleted = obsoleteQuestKeys) + val questKeysToDelete = lastAnsweredQuestKey?.let { + lastAnsweredQuestKey = null + obsoleteQuestKeys + it + } ?: obsoleteQuestKeys + onUpdated(added = visibleQuests, deleted = questKeysToDelete) } /** Replace all quests of the given types in the given bounding box with the given quests. @@ -131,6 +179,7 @@ class OsmQuestController internal constructor( init { mapDataSource.addListener(mapDataSourceListener) notesSource.addListener(notesSourceListener) + instance = this } private fun createQuestsForBBox( @@ -146,7 +195,11 @@ class OsmQuestController internal constructor( // tags. These quests are usually OsmFilterQuestType, where questType.filter.mayEvaluateToTrueWithNoTags // guarantees we can skip elements without tags completely. Also those quests don't use geometry. // This shortcut reduces time for creating quests by ~15-30%. - val onlyElementsWithTags = MutableMapDataWithGeometry(mapDataWithGeometry.filter { it.tags.isNotEmpty() }, emptyList()) + val onlyMapDataWithTags = MutableMapDataWithGeometry(mapDataWithGeometry.size).apply { + mapDataWithGeometry.forEach { if (it.tags.isNotEmpty()) put(it, mapDataWithGeometry.getGeometry(it.type, it.id)) } + boundingBox = mapDataWithGeometry.boundingBox + } + val onlyWaysWithTags = MutableMapDataWithGeometry(onlyMapDataWithTags.filter { it.type == ElementType.WAY }, emptyList()) val deferredQuests: List>> = questTypes.map { questType -> scope.async { @@ -159,10 +212,10 @@ class OsmQuestController internal constructor( val questTime = nowAsEpochMilliseconds() var questCount = 0 val mapDataToUse = if (questType is OsmFilterQuestType && !questType.filter.mayEvaluateToTrueWithNoTags) { - onlyElementsWithTags - } else { - mapDataWithGeometry - } + if (questType.name in wayOnlyFilterQuestTypes) onlyWaysWithTags + else onlyMapDataWithTags + } else if (questType.name in questsRequiringElementsWithoutTags) mapDataWithGeometry + else onlyMapDataWithTags for (element in questType.getApplicableElements(mapDataToUse)) { val geometry = mapDataWithGeometry.getGeometry(element.type, element.id) ?: continue @@ -191,41 +244,51 @@ class OsmQuestController internal constructor( questTypes: Collection> ): List> { val paddedBounds = geometry.getBounds().enlargedBy(ApplicationConstants.QUEST_FILTER_PADDING) - val lazyMapData by lazy { mapDataSource.getMapDataWithGeometry(paddedBounds) } + val lazyMapData by lazy { mapDataSource.getMapDataWithGeometry(paddedBounds).apply { + (this as? MutableMapDataWithGeometry)?.put(element, geometry) // this is specifically for tag editor to show the current version of the element, otherwise it should not matter + } } + val lazyTagOnlyMapData by lazy { MutableMapDataWithGeometry().apply { + lazyMapData.forEach { if (it.tags.isNotEmpty()) put(it, lazyMapData.getGeometry(it.type, it.id)) } + boundingBox = lazyMapData.boundingBox + } } return questTypes.map { questType -> scope.async { + if (element.tags.isEmpty() && questType.name !in questsRequiringElementsWithoutTags) return@async null + if (questType.enabledInCountries != AllCountries && !mayCreateQuest(questType, geometry, null)) return@async null // check whether it's disabled before creating the quest var appliesToElement = questType.isApplicableTo(element) if (appliesToElement == null) { Log.d(TAG, "${questType.name} requires surrounding map data to determine applicability to ${element.type.name}#${element.id}") - val mapData = withContext(Dispatchers.IO) { lazyMapData } + val mapData = withContext(Dispatchers.IO) { + if (questType.name in questsRequiringElementsWithoutTags) lazyMapData + else lazyTagOnlyMapData + } appliesToElement = questType.getApplicableElements(mapData) .any { it.id == element.id && it.type == element.type } } if (!appliesToElement) return@async null - if (mayCreateQuest(questType, geometry, null)) { - OsmQuest(questType, element.type, element.id, geometry) - } else { - null - } + OsmQuest(questType, element.type, element.id, geometry) } } } + suspend fun createNonPoiQuestsForElement(element: Element, geometry: ElementGeometry): List = + createQuestsForElementDeferred(element, geometry, allQuestTypes.filter { it.dotColor == null }).awaitAll().filterNotNull() + private fun getObsoleteQuestKeys( questsNow: Collection, questsPreviously: Collection, deletedQuestKeys: Collection ): List { - val previousQuestsByKey = mutableMapOf() - questsPreviously.associateByTo(previousQuestsByKey) { it.key } + val obsoleteQuestKeys = HashSet(questsPreviously.size, 0.9f) + questsPreviously.forEach { obsoleteQuestKeys.add(it.key) } for (quest in questsNow) { - previousQuestsByKey.remove(quest.key) + obsoleteQuestKeys.remove(quest.key) } // quests that were created previously for an element but now not anymore shall be deleted - return previousQuestsByKey.values.map { it.key } + deletedQuestKeys + return deletedQuestKeys + obsoleteQuestKeys } private fun updateQuests(questsNow: Collection, obsoleteQuestKeys: Collection) { @@ -267,13 +330,18 @@ class OsmQuestController internal constructor( return createOsmQuest(entry, geometry) } - override fun getAllInBBox(bbox: BoundingBox, questTypes: Collection?): List { + override fun getAllInBBox(bbox: BoundingBox, questTypes: Collection?): Collection { val hiddenPositions = getBlacklistedPositions(bbox) - val entries = db.getAllInBBox(bbox, questTypes).filter { + if (prefs.getBoolean(Prefs.DYNAMIC_QUEST_CREATION, false)) { + val mapData = mapDataSource.getMapDataWithGeometry(bbox.enlargedBy(ApplicationConstants.QUEST_FILTER_PADDING)) + val quests = createQuestsForBBox(bbox, mapData, questTypes?.filterIsInstance>() ?: allQuestTypes) + return quests.filter { it.position.truncateTo6Decimals() !in hiddenPositions } + } + val entries = db.getAllInBBox(bbox, questTypes?.map { it.name }).filter { it.position.truncateTo6Decimals() !in hiddenPositions } - val elementKeys = HashSet() + val elementKeys = HashSet(entries.size) entries.mapTo(elementKeys) { ElementKey(it.elementType, it.elementId) } val geometriesByKey = mapDataSource.getGeometries(elementKeys).associateBy { it.key } @@ -290,11 +358,13 @@ class OsmQuestController internal constructor( return OsmQuest(questType, entry.elementType, entry.elementId, geometry) } + /* -------------------------- OsmQuestsHiddenControllerController -------------------------- */ + private fun getBlacklistedPositions(bbox: BoundingBox): Set = notesSource .getAllPositions(bbox.enlargedBy(0.2)) .map { it.truncateTo6Decimals() } - .toSet() + .toHashSet() private fun isBlacklistedPosition(pos: LatLon): Boolean = pos.truncateTo6Decimals() in getBlacklistedPositions(BoundingBox(pos, pos)) @@ -330,8 +400,16 @@ class OsmQuestController internal constructor( listeners.forEach { it.onInvalidated() } } + private fun reloadQuestTypes() { + questTypeRegistry.reload() + onInvalidated() + } + companion object { private const val TAG = "OsmQuestController" + private var instance: OsmQuestController? = null + fun reloadQuestTypes() = instance?.reloadQuestTypes() + var lastAnsweredQuestKey: OsmQuestKey? = null // workaround for issues with dynamic quest creation } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestDao.kt index 94dac29c8ac..b0f20fac1ac 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestDao.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestDao.kt @@ -12,7 +12,6 @@ import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestTable.Columns.LA import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestTable.Columns.LONGITUDE import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestTable.Columns.QUEST_TYPE import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestTable.NAME -import de.westnordost.streetcomplete.data.queryIn import de.westnordost.streetcomplete.data.quest.OsmQuestKey /** Persists OsmQuest objects, or more specifically, OsmQuestEntry objects */ @@ -51,10 +50,10 @@ class OsmQuestDao(private val db: Database) { fun getAllForElements(keys: Collection): List { if (keys.isEmpty()) return emptyList() - return db.queryIn(NAME, - whereColumns = arrayOf(ELEMENT_TYPE, ELEMENT_ID), - whereArgs = keys.map { arrayOf(it.type.name, it.id) } - ) { it.toOsmQuestEntry() } + return db.query(NAME, + where = "$ELEMENT_ID IN (${keys.map { it.id }.joinToString(",")})", + // this is faster than queryIn... even without ID index + ) { it.toOsmQuestEntry() }.filter { ElementKey(it.elementType, it.elementId) in keys } } fun getAllInBBox(bounds: BoundingBox, questTypes: Collection? = null): List { diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestModule.kt index d4dd83f7308..2c63cc90f56 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestModule.kt @@ -9,5 +9,5 @@ val osmQuestModule = module { single { get() } - single { OsmQuestController(get(), get(), get(), get(), get(named("CountryBoundariesLazy"))) } + single { OsmQuestController(get(), get(), get(), get(), get(named("CountryBoundariesLazy")), get()) } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestSource.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestSource.kt index 45f19b505f7..b0abf1eccce 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestSource.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestSource.kt @@ -2,6 +2,7 @@ package de.westnordost.streetcomplete.data.osm.osmquests import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.quest.OsmQuestKey +import de.westnordost.streetcomplete.data.quest.QuestType interface OsmQuestSource { @@ -14,7 +15,7 @@ interface OsmQuestSource { fun get(key: OsmQuestKey): OsmQuest? /** Get all quests of optionally the given types in given bounding box */ - fun getAllInBBox(bbox: BoundingBox, questTypes: Collection? = null): List + fun getAllInBBox(bbox: BoundingBox, questTypes: Collection? = null): Collection fun addListener(listener: Listener) fun removeListener(listener: Listener) diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestTable.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestTable.kt index 55a2995d9ce..8996d5ace4a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestTable.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestTable.kt @@ -32,4 +32,10 @@ object OsmQuestTable { ${Columns.LONGITUDE} ); """ + + const val CREATE_ELEMENT_ID_INDEX_IF_NOT_EXISTS = """ + CREATE INDEX IF NOT EXISTS osm_quests_id_index ON $NAME ( + ${Columns.ELEMENT_ID} + ); + """ } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestsHiddenDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestsHiddenDao.kt index 23e1eaebdbe..fcab2b5256c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestsHiddenDao.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/osmquests/OsmQuestsHiddenDao.kt @@ -61,7 +61,7 @@ private fun OsmQuestKey.toPairs() = listOf( private fun CursorPosition.toOsmQuestKey() = OsmQuestKey( ElementType.valueOf(getString(ELEMENT_TYPE)), getLong(ELEMENT_ID), - getString(QUEST_TYPE) + getString(QUEST_TYPE).intern() ) private fun CursorPosition.toOsmQuestHiddenAt() = OsmQuestHiddenAt( diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NoteController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NoteController.kt index 674fcce5e7d..daf6d8284e5 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NoteController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NoteController.kt @@ -27,7 +27,7 @@ class NoteController( fun putAllForBBox(bbox: BoundingBox, notes: Collection) { val time = nowAsEpochMilliseconds() - val oldNotesById = mutableMapOf() + val oldNotesById = hashMapOf() val addedNotes = mutableListOf() val updatedNotes = mutableListOf() synchronized(this) { diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiClient.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiClient.kt index a4349192bed..32e1555c592 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiClient.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiClient.kt @@ -107,6 +107,28 @@ class NotesApiClient( } } + // copy of comment with different URL + suspend fun close(id: Long, comment: String): Note = wrapApiClientExceptions { + try { + val response = httpClient.post(baseUrl + "notes/$id/close") { + userLoginSource.accessToken?.let { bearerAuth(it) } + if (comment.isNotEmpty()) + parameter("text", comment) + expectSuccess = true + } + val source = response.bodyAsChannel().asSource().buffered() + return notesApiParser.parseNotes(source).single() + } catch (e: ClientRequestException) { + when (e.response.status) { + // hidden by moderator, does not exist (yet), has already been closed + HttpStatusCode.Gone, HttpStatusCode.NotFound, HttpStatusCode.Conflict -> { + throw ConflictException(e.message, e) + } + else -> throw e + } + } + } + /** * Retrieve all open notes in the given area * diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEdit.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEdit.kt index 11e99468323..c365e49ca19 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEdit.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEdit.kt @@ -44,5 +44,6 @@ data class NoteEdit( enum class NoteEditAction { CREATE, - COMMENT + COMMENT, + CLOSE } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsController.kt index a1511e2754c..23dfa5843fb 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsController.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.data.osmnotes.edits +import android.content.Context import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osm.mapdata.ElementIdUpdate import de.westnordost.streetcomplete.data.osm.mapdata.LatLon @@ -7,6 +8,10 @@ import de.westnordost.streetcomplete.data.osmnotes.Note import de.westnordost.streetcomplete.data.osmtracks.Trackpoint import de.westnordost.streetcomplete.util.Listeners import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds +import java.io.File +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter class NoteEditsController( private val editsDB: NoteEditsDao @@ -23,6 +28,8 @@ class NoteEditsController( text: String? = null, imagePaths: List = emptyList(), track: List = emptyList(), + isGpxNote: Boolean = false, + context: Context? = null ) { val edit = NoteEdit( 0, @@ -36,8 +43,12 @@ class NoteEditsController( imagePaths.isNotEmpty(), track, ) - synchronized(this) { editsDB.add(edit) } - onAddedEdit(edit) + if (isGpxNote) { + createGpxNote(text ?: "", imagePaths, position, track, context) + } else { + synchronized(this) { editsDB.add(edit) } + onAddedEdit(edit) + } } fun get(id: Long): NoteEdit? = @@ -124,6 +135,71 @@ class NoteEditsController( } } + // there is some xmlwriter, and even gpxTrackWriter + // maybe use this instead of the current ugly things, probably less prone to bugs caused by weird characters + private fun createGpxNote(note: String, imagePaths: List, position: LatLon, recordedTrack: List?, context: Context?) { + val path = context?.getExternalFilesDir(null) ?: return + path.mkdirs() + val fileName = "notes.gpx" + val gpxFile = File(path,fileName) + if (gpxFile.createNewFile()) // if this file did not exist + gpxFile.writeText("\n" + + "\n" + + "", Charsets.UTF_8) + // now delete the last 6 characters, which is <\gpx> + val oldText = gpxFile.readText(Charsets.UTF_8).dropLast(6) + // save image file names (this is not nice, but better than not keeping any reference to them + val imageText = if (imagePaths.isEmpty()) "" else + "\n images used: ${imagePaths.joinToString(", ") { it.substringAfterLast(File.separator) }}" + val trackFile: File? + if (recordedTrack != null && recordedTrack.isNotEmpty()) { + var i = 1 + while (File(path, "track_$i.gpx").exists()) { + i += 1 + } + trackFile = File(path, "track_$i.gpx") + val formatter = DateTimeFormatter + .ofPattern("yyyy_MM_dd'T'HH_mm_ss.SSSSSS'Z'") + .withZone(ZoneOffset.UTC) + val trackText = recordedTrack.map { + " \n" + + " \n" + + if (it.elevation == 0.0f) + "" + else { + " \"${it.elevation}\"\n" + + " \"${it.accuracy}\"\n" + } + + " " + } + trackFile.writeText("\n" + + "\n" + + " \n" + + " ${trackFile.name.substringBefore(".gpx")}\n" + + " \n" + + trackText.joinToString("\n") + "\n" + + " \n" + + " \n" + + "", Charsets.UTF_8) + } else trackFile = null + val trackText = if (trackFile == null) "" else + "\n attached track: ${trackFile.name}" + gpxFile.writeText(oldText +" \n" + + " " + (note + trackText + imageText).replace("&","&") + .replace("<","<") + .replace(">",">") + .replace("\"",""") + .replace("'","'") + "\n" + + " \n" + + "", Charsets.UTF_8) + } + /* ------------------------------------ Listeners ------------------------------------------- */ override fun addListener(listener: NoteEditsSource.Listener) { diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsDao.kt index 445f53cb529..88becec046b 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsDao.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsDao.kt @@ -16,7 +16,6 @@ import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsTable.Columns. import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsTable.Columns.TRACK import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsTable.Columns.TYPE import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsTable.NAME -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt index b3d89415eed..4f81099b191 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt @@ -5,6 +5,7 @@ import de.westnordost.streetcomplete.data.osmnotes.NoteController import de.westnordost.streetcomplete.data.osmnotes.NotesApiClient import de.westnordost.streetcomplete.data.osmnotes.PhotoServiceApiClient import de.westnordost.streetcomplete.data.osmnotes.deleteImages +import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.CLOSE import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.COMMENT import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.CREATE import de.westnordost.streetcomplete.data.osmtracks.Trackpoint @@ -78,6 +79,7 @@ class NoteEditsUploader( val note = when (edit.action) { CREATE -> notesApi.create(edit.position, text) COMMENT -> notesApi.comment(edit.noteId, text) + CLOSE -> notesApi.close(edit.noteId, text) } Log.d(TAG, diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NotesWithEditsSource.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NotesWithEditsSource.kt index bd5fdc004bc..43290bb1a49 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NotesWithEditsSource.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NotesWithEditsSource.kt @@ -5,11 +5,13 @@ import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osmnotes.Note import de.westnordost.streetcomplete.data.osmnotes.NoteComment import de.westnordost.streetcomplete.data.osmnotes.NoteController +import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.CLOSE import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.COMMENT import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.CREATE import de.westnordost.streetcomplete.data.user.User import de.westnordost.streetcomplete.data.user.UserDataSource import de.westnordost.streetcomplete.util.Listeners +import de.westnordost.streetcomplete.util.SpatialCache class NotesWithEditsSource( private val noteController: NoteController, @@ -23,6 +25,7 @@ class NotesWithEditsSource( fun onCleared() } private val listeners = Listeners() + private val noteCache = SpatialCache(16, 64, null, { getAllForCache(it) }, Note::id, Note::position) private val noteControllerListener = object : NoteController.Listener { override fun onUpdated(added: Collection, updated: Collection, deleted: Collection) { @@ -47,6 +50,9 @@ class NotesWithEditsSource( /* can't just get the associated note from DB and apply this edit to it because this * edit might just be the last in a long chain of edits, i.e. if several comments * are added to a note, or if a note is created through an edit (and then commented) */ + + // note has changed, so we need to fetch it from db and rebuild it + noteCache.update(deleted = listOf(edit.noteId)) val note = get(edit.noteId) ?: return if (edit.action == CREATE) { @@ -63,6 +69,8 @@ class NotesWithEditsSource( } override fun onDeletedEdits(edits: List) { + // remove from cache, as they need to be fetched from db again + noteCache.update(deleted = edits.filter { it.action != CREATE }.map { it.noteId }) callOnUpdated( updated = edits.filter { it.action != CREATE }.mapNotNull { get(it.noteId) }, deleted = edits.filter { it.action == CREATE }.map { it.noteId } @@ -76,6 +84,7 @@ class NotesWithEditsSource( } fun get(noteId: Long): Note? { + noteCache.get(noteId)?.let { return it } val noteEdits = noteEditsSource.getAllUnsyncedForNote(noteId) var note = noteController.get(noteId) for (noteEdit in noteEdits) { @@ -88,16 +97,23 @@ class NotesWithEditsSource( note = note.copy(comments = note.comments + noteEdit.createNoteComment()) } } + CLOSE -> { + if (note != null) { + note = note.copy(comments = note.comments + noteEdit.createNoteComment(NoteComment.Action.CLOSED), status = Note.Status.CLOSED) + } + } } } + note?.let { noteCache.update(updatedOrAdded = listOf(it)) } return note } - fun getAllPositions(bbox: BoundingBox): List = - noteController.getAllPositions(bbox) + - noteEditsSource.getAllUnsyncedPositions(bbox) + // this is used only for blacklisting quest positions, so we can do that (but ideally it should be renamed...) + fun getAllPositions(bbox: BoundingBox): List = noteCache.get(bbox).filterNot { it.isClosed }.map { it.position } + + fun getAll(bbox: BoundingBox): Collection = noteCache.get(bbox) - fun getAll(bbox: BoundingBox): Collection = + private fun getAllForCache(bbox: BoundingBox): Collection = editsAppliedToNotes( noteController.getAll(bbox), noteEditsSource.getAllUnsynced(bbox) @@ -128,6 +144,12 @@ class NotesWithEditsSource( notesById[id] = note.copy(comments = note.comments + noteEdit.createNoteComment()) } } + CLOSE -> { + val note = notesById[id] + if (note != null) { + notesById[id] = note.copy(comments = note.comments + noteEdit.createNoteComment(NoteComment.Action.CLOSED), status = Note.Status.CLOSED) + } + } } } return notesById.values @@ -164,10 +186,12 @@ class NotesWithEditsSource( } private fun callOnUpdated(added: Collection = emptyList(), updated: Collection = emptyList(), deleted: Collection = emptyList()) { + noteCache.update(added + updated, deleted) listeners.forEach { it.onUpdated(added, updated, deleted) } } private fun callOnCleared() { + noteCache.clear() listeners.forEach { it.onCleared() } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/NoteQuestsHiddenDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/NoteQuestsHiddenDao.kt index def2ec5888a..c5c0081ce05 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/NoteQuestsHiddenDao.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/NoteQuestsHiddenDao.kt @@ -11,7 +11,7 @@ import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds class NoteQuestsHiddenDao(private val db: Database) { fun add(noteId: Long) { - db.insert(NAME, listOf( + db.insertOrIgnore(NAME, listOf( NOTE_ID to noteId, TIMESTAMP to nowAsEpochMilliseconds() )) diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuest.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuest.kt index ea225cf7881..09d574a6af8 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuest.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuest.kt @@ -11,9 +11,9 @@ import de.westnordost.streetcomplete.data.quest.QuestType data class OsmNoteQuest( val id: Long, override val position: LatLon -) : Quest { +) : Quest() { override val type: QuestType get() = OsmNoteQuestType - override val key: OsmNoteQuestKey by lazy { OsmNoteQuestKey(id) } - override val markerLocations: Collection by lazy { listOf(position) } + override val key: OsmNoteQuestKey = OsmNoteQuestKey(id) + override val markerLocations: Collection get() = listOf(position) override val geometry: ElementGeometry get() = ElementPointGeometry(position) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuestController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuestController.kt index 4a87aac9ee7..8a20984572c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuestController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuestController.kt @@ -2,6 +2,7 @@ package de.westnordost.streetcomplete.data.osmnotes.notequests import com.russhwolf.settings.SettingsListener import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osmnotes.Note import de.westnordost.streetcomplete.data.osmnotes.NoteComment @@ -10,6 +11,7 @@ import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.data.user.UserDataSource import de.westnordost.streetcomplete.data.user.UserLoginSource import de.westnordost.streetcomplete.util.Listeners +import kotlinx.serialization.json.Json /** Used to get visible osm note quests */ class OsmNoteQuestController( @@ -26,8 +28,19 @@ class OsmNoteQuestController( private val showOnlyNotesPhrasedAsQuestions: Boolean get() = !prefs.showAllNotes + private val reallyAllNotes: Boolean get() = + prefs.reallyAllNotes + private val settingsListener: SettingsListener + private val blockedUserIds = hashSetOf() + private val blockedUserNames = hashSetOf() + + // store it, or it will get GCed and thus not work + private val prefsListener = prefs.prefs.addStringListener(Prefs.HIDE_NOTES_BY_USERS, "") { + reloadBlocks() + } + private val noteUpdatesListener = object : NotesWithEditsSource.Listener { override fun onUpdated(added: Collection, updated: Collection, deleted: Collection) { val quests = mutableListOf() @@ -65,6 +78,18 @@ class OsmNoteQuestController( userLoginSource.addListener(userLoginStatusListener) // a lot of notes become visible/invisible if this option is changed settingsListener = prefs.onAllShowNotesChanged { onInvalidated() } + reloadBlocks() + } + + private fun reloadBlocks() { + val blockedList: List = getRawBlockList(prefs) + blockedUserIds.clear() + blockedUserNames.clear() + blockedList.forEach { + val id = it.toLongOrNull() + if (id == null) blockedUserNames.add(it) + else blockedUserIds.add(id) + } } override fun get(questId: Long): OsmNoteQuest? = @@ -77,7 +102,7 @@ class OsmNoteQuestController( notes.mapNotNull { createQuestForNote(it) } private fun createQuestForNote(note: Note): OsmNoteQuest? = - if (note.shouldShowAsQuest(userDataSource.userId, showOnlyNotesPhrasedAsQuestions)) { + if (note.shouldShowAsQuest(userDataSource.userId, showOnlyNotesPhrasedAsQuestions, reallyAllNotes, blockedUserIds, blockedUserNames)) { OsmNoteQuest(note.id, note.position) } else { null @@ -107,8 +132,20 @@ class OsmNoteQuestController( private fun Note.shouldShowAsQuest( userId: Long, - showOnlyNotesPhrasedAsQuestions: Boolean + showOnlyNotesPhrasedAsQuestions: Boolean, + reallyAllNotes: Boolean, + blockedIds: Collection, + blockedNames: Collection, ): Boolean { + // don't show notes created by specific users + comments.firstOrNull()?.let { + if (blockedIds.contains(it.user?.id)) return false + if (blockedNames.contains(it.user?.displayName?.lowercase())) return false + } + + // If we've chosen that "all notes" means "ALL notes", then show this note too (we need no further checks, as it is not blocked nor closed) + if (reallyAllNotes && !showOnlyNotesPhrasedAsQuestions) return true + /* We usually don't show notes where either the user is the last responder, or the note was created with the app and has no replies. @@ -118,7 +155,7 @@ private fun Note.shouldShowAsQuest( if ( ( comments.last().isReplyFromUser(userId) || - (probablyCreatedByUserInThisApp(userId) && !hasReplies) + (probablyCreatedByUserInThisApp(userId, !showOnlyNotesPhrasedAsQuestions) && !hasReplies) ) && !comments.last().containsSurveyRequiredMarker() ) { @@ -171,9 +208,12 @@ private fun Note.containsSurveyRequiredMarker(): Boolean = private fun NoteComment.containsSurveyRequiredMarker(): Boolean = text?.contains("#surveyme", ignoreCase = true) == true -private fun Note.probablyCreatedByUserInThisApp(userId: Long): Boolean { +private fun Note.probablyCreatedByUserInThisApp(userId: Long, requireMatchingVersion: Boolean): Boolean { val firstComment = comments.first() - val isViaApp = firstComment.text?.contains("via " + ApplicationConstants.NAME) == true + val isViaApp = if (requireMatchingVersion) + firstComment.text?.contains("via " + ApplicationConstants.USER_AGENT) == true + else + firstComment.text?.contains("via " + ApplicationConstants.NAME) == true return firstComment.isFromUser(userId) && isViaApp } @@ -188,3 +228,11 @@ private val NoteComment.isReply: Boolean get() = private fun NoteComment.isFromUser(userId: Long): Boolean = user?.id == userId + +fun getRawBlockList(prefs: Preferences): List { + return try { + Json.decodeFromString(prefs.getString(Prefs.HIDE_NOTES_BY_USERS, "")) + } catch (e: Exception) { // why isn't it showing in the log any more? well, just catch all... + emptyList() + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/overlays/SelectedOverlayController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/overlays/SelectedOverlayController.kt index 1b20c362222..99b5549ae6d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/overlays/SelectedOverlayController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/overlays/SelectedOverlayController.kt @@ -1,8 +1,10 @@ package de.westnordost.streetcomplete.data.overlays import com.russhwolf.settings.SettingsListener +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.overlays.Overlay +import de.westnordost.streetcomplete.overlays.custom.CustomOverlay import de.westnordost.streetcomplete.util.Listeners class SelectedOverlayController( @@ -19,7 +21,14 @@ class SelectedOverlayController( override var selectedOverlay: Overlay? set(value) { - if (value != null && value in overlayRegistry) { + if (value?.title == 0) { + value.wikiLink?.toIntOrNull()?.let { prefs.putInt(Prefs.CUSTOM_OVERLAY_SELECTED_INDEX, it) } + if (prefs.selectedOverlayName == CustomOverlay::class.simpleName) { + listeners.forEach { it.onSelectedOverlayChanged() } + } + prefs.selectedOverlayName = CustomOverlay::class.simpleName + } + else if (value != null && value in overlayRegistry) { prefs.selectedOverlayName = value.name } else { prefs.selectedOverlayName = null diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/preferences/Preferences.kt b/app/src/main/java/de/westnordost/streetcomplete/data/preferences/Preferences.kt index bcbf5d920c4..bdef8e42de4 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/preferences/Preferences.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/preferences/Preferences.kt @@ -7,10 +7,11 @@ import com.russhwolf.settings.double import com.russhwolf.settings.int import com.russhwolf.settings.long import com.russhwolf.settings.nullableString +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.util.ktx.putStringOrNull -class Preferences(private val prefs: ObservableSettings) { +class Preferences(val prefs: ObservableSettings) { // application settings var language: String? by prefs.nullableString(LANGUAGE_SELECT) @@ -37,6 +38,21 @@ class Preferences(private val prefs: ObservableSettings) { ?: DEFAULT_RESURVEY_INTERVALS var showAllNotes: Boolean by prefs.boolean(SHOW_ALL_NOTES, false) + var reallyAllNotes: Boolean by prefs.boolean(Prefs.REALLY_ALL_NOTES, false) + + fun getBoolean(key: String, default: Boolean) = prefs.getBoolean(key, default) + fun putBoolean(key: String, value: Boolean) = prefs.putBoolean(key, value) + fun getString(key: String, default: String) = prefs.getString(key, default) + fun putString(key: String, value: String) = prefs.putString(key, value) + fun getLong(key: String, default: Long) = prefs.getLong(key, default) + fun getInt(key: String, default: Int) = prefs.getInt(key, default) + fun putInt(key: String, value: Int) = prefs.putInt(key, value) + fun getFloat(key: String, default: Float) = prefs.getFloat(key, default) + + var expertMode: Boolean by prefs.boolean(Prefs.EXPERT_MODE, false) + var showQuickSettings: Boolean by prefs.boolean(Prefs.QUICK_SETTINGS, false) + fun onShowQuickSettingsChanged(callback: (Boolean) -> Unit): SettingsListener = + prefs.addBooleanListener(Prefs.QUICK_SETTINGS, false, callback) fun onLanguageChanged(callback: (String?) -> Unit): SettingsListener = prefs.addStringOrNullListener(LANGUAGE_SELECT, callback) @@ -59,6 +75,9 @@ class Preferences(private val prefs: ObservableSettings) { fun onAllShowNotesChanged(callback: (Boolean) -> Unit): SettingsListener = prefs.addBooleanListener(SHOW_ALL_NOTES, false, callback) + fun onExpertModeChanged(callback: (Boolean) -> Unit): SettingsListener = + prefs.addBooleanListener(Prefs.EXPERT_MODE, false, callback) + fun onKeepScreenOnChanged(callback: (Boolean) -> Unit): SettingsListener = prefs.addBooleanListener(KEEP_SCREEN_ON, false, callback) @@ -210,7 +229,7 @@ class Preferences(private val prefs: ObservableSettings) { private const val AUTOSYNC = "autosync" private const val KEEP_SCREEN_ON = "display.keepScreenOn" private const val SHOW_ZOOM_BUTTONS = "display.zoomButtons" - private const val THEME_SELECT = "theme.select" + const val THEME_SELECT = "theme.select" private const val LANGUAGE_SELECT = "language.select" private const val RESURVEY_INTERVALS = "quests.resurveyIntervals" @@ -218,7 +237,7 @@ class Preferences(private val prefs: ObservableSettings) { private const val OSM_USER_ID = "osm.userid" private const val OSM_USER_NAME = "osm.username" private const val OSM_UNREAD_MESSAGES = "osm.unread_messages" - private const val OAUTH2_ACCESS_TOKEN = "oauth2.accessToken" + const val OAUTH2_ACCESS_TOKEN = "oauth2.accessToken" // old keys login keys private const val OAUTH1_ACCESS_TOKEN = "oauth.accessToken" @@ -252,7 +271,7 @@ class Preferences(private val prefs: ObservableSettings) { // quest & overlays private const val PREFERRED_LANGUAGE_FOR_NAMES = "preferredLanguageForNames" - private const val SELECTED_EDIT_TYPE_PRESET = "selectedQuestsPreset" + const val SELECTED_EDIT_TYPE_PRESET = "selectedQuestsPreset" private const val SELECTED_OVERLAY = "selectedOverlay" private const val LAST_PICKED_PREFIX = "imageListLastPicked." private const val LAST_EDIT_TIME = "changesets.lastChangeTime" diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/preferences/ResurveyIntervals.kt b/app/src/main/java/de/westnordost/streetcomplete/data/preferences/ResurveyIntervals.kt index 8aa2bc8dfba..bc4683f2e33 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/preferences/ResurveyIntervals.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/preferences/ResurveyIntervals.kt @@ -1,9 +1,14 @@ package de.westnordost.streetcomplete.data.preferences import com.russhwolf.settings.SettingsListener +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.data.elementfilter.filters.CompareTagAge +import de.westnordost.streetcomplete.data.elementfilter.filters.ElementFilter import de.westnordost.streetcomplete.data.elementfilter.filters.RelativeDate +import de.westnordost.streetcomplete.osm.toCheckDate enum class ResurveyIntervals(val multiplier: Float) { + EVEN_LESS_OFTEN(2.0f), LESS_OFTEN(2.0f), DEFAULT(1.0f), MORE_OFTEN(0.5f) @@ -17,6 +22,8 @@ class ResurveyIntervalsUpdater(private val prefs: Preferences) { fun update() { RelativeDate.MULTIPLIER = prefs.resurveyIntervals.multiplier + CompareTagAge.resurveyKeys = prefs.getString(Prefs.RESURVEY_KEYS, "").split(",").map { it.trim() } + CompareTagAge.resurveyDate = prefs.getString(Prefs.RESURVEY_DATE, "").toCheckDate() } init { diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/preferences/Theme.kt b/app/src/main/java/de/westnordost/streetcomplete/data/preferences/Theme.kt index b5cd26000ae..68490d58b6c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/preferences/Theme.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/preferences/Theme.kt @@ -4,4 +4,5 @@ enum class Theme { SYSTEM, LIGHT, DARK, + DARK_CONTRAST, } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/presets/EditTypePresetsController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/presets/EditTypePresetsController.kt index c2e561f53f8..c07262c70a2 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/presets/EditTypePresetsController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/presets/EditTypePresetsController.kt @@ -44,6 +44,8 @@ class EditTypePresetsController( selectedId = 0 } editTypePresetsDao.delete(presetId) + val presetSettings = prefs.prefs.keys.filter { it.startsWith("${presetId}_qs_") } + presetSettings.forEach { prefs.prefs.remove(it) } onDeleted(presetId) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/quest/DayNightCycle.kt b/app/src/main/java/de/westnordost/streetcomplete/data/quest/DayNightCycle.kt new file mode 100644 index 00000000000..1e3b34fa3f6 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/quest/DayNightCycle.kt @@ -0,0 +1,3 @@ +package de.westnordost.streetcomplete.data.quest + +enum class DayNightCycle { DAY_AND_NIGHT, ONLY_DAY, ONLY_NIGHT } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/quest/Quest.kt b/app/src/main/java/de/westnordost/streetcomplete/data/quest/Quest.kt index a8db3efc8d5..8e4be2285fc 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/quest/Quest.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/quest/Quest.kt @@ -2,13 +2,17 @@ package de.westnordost.streetcomplete.data.quest import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.screens.main.map.components.Pin /** Represents one task for the user to complete/correct */ -interface Quest { - val key: QuestKey - val position: LatLon - val markerLocations: Collection - val geometry: ElementGeometry +abstract class Quest { + abstract val key: QuestKey + abstract val position: LatLon + abstract val markerLocations: Collection + abstract val geometry: ElementGeometry - val type: QuestType + abstract val type: QuestType + + /** caching pins in the quest allows for faster setting of pins */ + var pins: List? = null } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestAutoSyncer.kt b/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestAutoSyncer.kt index ba200d89bf3..96975d17581 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestAutoSyncer.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestAutoSyncer.kt @@ -9,6 +9,7 @@ import android.net.ConnectivityManager import androidx.core.content.getSystemService import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.UnsyncedChangesCountSource import de.westnordost.streetcomplete.data.download.DownloadController import de.westnordost.streetcomplete.data.download.DownloadProgressSource @@ -41,12 +42,12 @@ class QuestAutoSyncer( private val mobileDataDownloadStrategy: MobileDataAutoDownloadStrategy, private val wifiDownloadStrategy: WifiAutoDownloadStrategy, private val context: Context, - private val unsyncedChangesCountSource: UnsyncedChangesCountSource, private val downloadProgressSource: DownloadProgressSource, private val userLoginSource: UserLoginSource, private val prefs: Preferences, private val teamModeQuestFilter: TeamModeQuestFilter, - private val downloadedTilesController: DownloadedTilesController + private val downloadedTilesController: DownloadedTilesController, + private val unsyncedChangesCountSource: UnsyncedChangesCountSource, ) : DefaultLifecycleObserver { private val coroutineScope = CoroutineScope(SupervisorJob() + CoroutineName("QuestAutoSyncer")) @@ -159,6 +160,7 @@ class QuestAutoSyncer( private fun triggerAutoDownload() { val pos = pos ?: return if (!isConnected) return + if (!prefs.getBoolean(Prefs.AUTO_DOWNLOAD, true)) return if (downloadProgressSource.isDownloadInProgress) return Log.i(TAG, "Checking whether to automatically download new quests at ${pos.latitude.format(7)},${pos.longitude.format(7)}") diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestKey.kt b/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestKey.kt index 9c1b9a174e5..2712711cc9d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestKey.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestKey.kt @@ -18,3 +18,7 @@ data class OsmQuestKey( val elementId: Long, val questTypeName: String ) : QuestKey() + +@Serializable +@SerialName("externalsource") +data class ExternalSourceQuestKey(val id: String, val source: String) : QuestKey() diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestModule.kt index cad63c53f9d..8cd63cd3774 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestModule.kt @@ -4,5 +4,5 @@ import org.koin.dsl.module val questModule = module { single { QuestAutoSyncer(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } - single { VisibleQuestsSource(get(), get(), get(), get(), get(), get(), get()) } + single { VisibleQuestsSource(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestType.kt b/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestType.kt index abd6a9a3ccf..a07d216b5d4 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestType.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestType.kt @@ -1,5 +1,10 @@ package de.westnordost.streetcomplete.data.quest +import androidx.appcompat.app.AlertDialog +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.runtime.Composable +import de.westnordost.streetcomplete.StreetCompleteApplication import de.westnordost.streetcomplete.data.osm.edits.EditType import de.westnordost.streetcomplete.quests.AbstractQuestForm @@ -12,6 +17,7 @@ import de.westnordost.streetcomplete.quests.AbstractQuestForm * Most QuestType inherit from [OsmElementQuestType][de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType] */ interface QuestType : EditType { + val prefs: SharedPreferences get() = StreetCompleteApplication.preferences /** Hint text to be shown when the user taps on the ℹ️ button */ val hint: Int? get() = null @@ -23,4 +29,19 @@ interface QuestType : EditType { /** The quest type can clean it's metadata that is older than the given timestamp here, if any */ fun deleteMetadataOlderThan(timestamp: Long) {} + + /** if the quest should only be shown during day-light os night-time hours */ + val dayNightCycle: DayNightCycle get() = DayNightCycle.DAY_AND_NIGHT + + fun getQuestSettingsDialog(context: Context): AlertDialog? = null + val hasQuestSettings: Boolean get() = false + @Composable + fun QuestSettings(context: Context, onDismissRequest: () -> Unit) { + getQuestSettingsDialog(context)?.show() + onDismissRequest() + } + + + /** color of the dot, which is used instead of a quest pin */ + val dotColor: String? get() = null } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestTypeRegistry.kt b/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestTypeRegistry.kt index de76675e881..8f10cb05271 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestTypeRegistry.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestTypeRegistry.kt @@ -2,6 +2,23 @@ package de.westnordost.streetcomplete.data.quest import de.westnordost.streetcomplete.data.ObjectTypeRegistry +import de.westnordost.countryboundaries.CountryBoundaries +import de.westnordost.osmfeatures.Feature +import de.westnordost.osmfeatures.FeatureDictionary +import de.westnordost.streetcomplete.data.meta.CountryInfo +import de.westnordost.streetcomplete.data.meta.CountryInfos +import de.westnordost.streetcomplete.data.meta.getByLocation +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.quests.custom.CustomQuestList +import de.westnordost.streetcomplete.quests.getQuestTypeList +import de.westnordost.streetcomplete.quests.osmose.OsmoseDao +import de.westnordost.streetcomplete.screens.measure.ArSupportChecker +import de.westnordost.streetcomplete.util.ktx.getFeature +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koin.core.qualifier.named + /** Every osm quest needs to be registered here. * * Could theoretically be done with Reflection, but that doesn't really work on Android. @@ -9,4 +26,31 @@ import de.westnordost.streetcomplete.data.ObjectTypeRegistry * It is also used to define a (display) order of the quest types and to assign an ordinal to each * quest type for serialization. */ -class QuestTypeRegistry(ordinalsAndEntries: List>) : ObjectTypeRegistry(ordinalsAndEntries) +class QuestTypeRegistry(initialOrdinalsAndEntries: List>, private val ordinalsAndEntries: MutableList> = initialOrdinalsAndEntries.toMutableList()) : ObjectTypeRegistry(ordinalsAndEntries), KoinComponent { + private val featureDictionary: Lazy by inject(named("FeatureDictionaryLazy")) + private val countryInfos: CountryInfos by inject() + private val countryBoundaries: Lazy by inject(named("CountryBoundariesLazy")) + private val arSupportChecker: ArSupportChecker by inject() + private val getFeature: (Element) -> Feature? = { featureDictionary.value.getFeature(it) } + private val getCountryInfoByLocation: (location: LatLon) -> CountryInfo = { location -> + countryInfos.getByLocation(countryBoundaries.value, location.longitude, location.latitude) + } + private val osmoseDao: OsmoseDao by inject() + private val customQuestList: CustomQuestList by inject() + + fun reload() { + ordinalsAndEntries.clear() + ordinalsAndEntries.addAll(getQuestTypeList( + arSupportChecker, + getCountryInfoByLocation, + getFeature, + osmoseDao, + customQuestList, + )) + byName.clear() + byOrdinal.clear() + ordinalByObject.clear() + objects.clear() + reloadInit() + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/quest/VisibleQuestsSource.kt b/app/src/main/java/de/westnordost/streetcomplete/data/quest/VisibleQuestsSource.kt index 057c89997f3..36ab74cf056 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/quest/VisibleQuestsSource.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/quest/VisibleQuestsSource.kt @@ -1,16 +1,25 @@ package de.westnordost.streetcomplete.data.quest +import com.russhwolf.settings.ObservableSettings +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.osm.edits.EditType import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuest import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestSource import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuest import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuestSource +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuest +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController +import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuestType +import de.westnordost.streetcomplete.data.visiblequests.LevelFilter import de.westnordost.streetcomplete.data.overlays.SelectedOverlaySource +import de.westnordost.streetcomplete.data.visiblequests.DayNightQuestFilter import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenSource import de.westnordost.streetcomplete.data.visiblequests.TeamModeQuestFilter import de.westnordost.streetcomplete.data.visiblequests.VisibleEditTypeSource import de.westnordost.streetcomplete.util.Listeners +import de.westnordost.streetcomplete.util.logs.Log +import de.westnordost.streetcomplete.util.math.enclosingBoundingBox import de.westnordost.streetcomplete.util.SpatialCache /** @@ -37,7 +46,11 @@ class VisibleQuestsSource( private val questsHiddenSource: QuestsHiddenSource, private val visibleEditTypeSource: VisibleEditTypeSource, private val teamModeQuestFilter: TeamModeQuestFilter, - private val selectedOverlaySource: SelectedOverlaySource + private val selectedOverlaySource: SelectedOverlaySource, + private val levelFilter: LevelFilter, + private val dayNightQuestFilter: DayNightQuestFilter, + private val prefs: ObservableSettings, + private val externalSourceQuestController: ExternalSourceQuestController ) { interface Listener { /** Called when given quests in the given group have been added/removed */ @@ -77,6 +90,7 @@ class VisibleQuestsSource( val quest = when (key) { is OsmQuestKey -> osmQuestSource.get(key) is OsmNoteQuestKey -> osmNoteQuestSource.get(key.noteId) + is ExternalSourceQuestKey -> externalSourceQuestController.get(key) } ?: return updateVisibleQuests(added = listOf(quest)) } @@ -107,8 +121,18 @@ class VisibleQuestsSource( private val selectedOverlayListener = object : SelectedOverlaySource.Listener { override fun onSelectedOverlayChanged() { - invalidate() + // no need to invalidate if overlay can't hide quests + if (prefs.getBoolean(Prefs.HIDE_OVERLAY_QUESTS, true)) + invalidate() + } + } + + private val otherQuestListener = object : ExternalSourceQuestController.QuestListener { + override fun onUpdated(addedQuests: Collection, deletedQuestKeys: Collection) { + val hideOverlayQuests = prefs.getBoolean(Prefs.HIDE_OVERLAY_QUESTS, true) + updateVisibleQuests(addedQuests.filter { isVisible(it, hideOverlayQuests) }, deletedQuestKeys) } + override fun onInvalidate() = invalidate() } private val cache = SpatialCache( @@ -125,6 +149,7 @@ class VisibleQuestsSource( visibleEditTypeSource.addListener(visibleEditTypeSourceListener) teamModeQuestFilter.addListener(teamModeQuestFilterListener) selectedOverlaySource.addListener(selectedOverlayListener) + externalSourceQuestController.addQuestListener(otherQuestListener) } fun getAll(bbox: BoundingBox): List = @@ -135,13 +160,18 @@ class VisibleQuestsSource( // we could just get all quests from the quest sources and then filter it with // isVisible(quest) but we can optimize here by querying only quests of types that are // currently visible - val visibleQuestTypeNames = questTypeRegistry.filter { isVisible(it) }.map { it.name } - if (visibleQuestTypeNames.isEmpty()) return listOf() + val hideOverlayQuests = prefs.getBoolean(Prefs.HIDE_OVERLAY_QUESTS, true) + val visibleQuestTypes = questTypeRegistry.filter { isVisible(it, hideOverlayQuests) } + println(hideOverlayQuests) + println(visibleQuestTypes) + if (visibleQuestTypes.isEmpty()) return emptyList() val quests = - osmQuestSource.getAllInBBox(bbox, visibleQuestTypeNames) + - osmNoteQuestSource.getAllInBBox(bbox) + osmQuestSource.getAllInBBox(bbox, visibleQuestTypes) + + osmNoteQuestSource.getAllInBBox(bbox) + + externalSourceQuestController.getAllInBBox(bbox, visibleQuestTypes) + println(quests) return quests.filter { isVisible(it.key) && isVisibleInTeamMode(it) } } @@ -149,22 +179,43 @@ class VisibleQuestsSource( val quest = cache.get(questKey) ?: when (questKey) { is OsmNoteQuestKey -> osmNoteQuestSource.get(questKey.noteId) is OsmQuestKey -> osmQuestSource.get(questKey) + is ExternalSourceQuestKey -> externalSourceQuestController.get(questKey) } ?: return null - return if (isVisible(quest)) quest else null + return if (isVisible(quest, prefs.getBoolean(Prefs.HIDE_OVERLAY_QUESTS, true))) quest else null } - private fun isVisible(quest: Quest): Boolean = - isVisible(quest.key) && isVisibleInTeamMode(quest) && isVisible(quest.type) + private fun isVisible(quest: Quest, hideOverlayQuests: Boolean): Boolean = + isVisible(quest.key) && isVisibleInTeamMode(quest) && isVisible(quest.type, hideOverlayQuests) - private fun isVisible(questType: QuestType): Boolean = + private fun isVisible(questType: QuestType, hideOverlayQuests: Boolean): Boolean = visibleEditTypeSource.isVisible(questType) && - selectedOverlaySource.selectedOverlay?.let { questType.name !in it.hidesQuestTypes } ?: true + selectedOverlaySource.selectedOverlay?.let { !hideOverlayQuests || questType.name !in it.hidesQuestTypes } ?: true private fun isVisible(questKey: QuestKey): Boolean = questsHiddenSource.get(questKey) == null private fun isVisibleInTeamMode(quest: Quest): Boolean = - teamModeQuestFilter.isVisible(quest) + teamModeQuestFilter.isVisible(quest) && levelFilter.isVisible(quest) && dayNightQuestFilter.isVisible(quest) + + fun getNearbyQuests(quest: Quest, distance: Double): Collection { + val bbox = quest.position.enclosingBoundingBox(distance) + return when (prefs.getInt(Prefs.SHOW_NEARBY_QUESTS, 0)) { + 1 -> getAll(bbox) + 2 -> (osmQuestSource.getAllInBBox(bbox) + + externalSourceQuestController.getAllInBBox(bbox) + + osmNoteQuestSource.getAllInBBox(bbox) + ).filter { isVisible(it.key) && isVisibleInTeamMode(it) } + 3 -> (osmQuestSource.getAllInBBox(bbox) + + externalSourceQuestController.getAllInBBox(bbox) + + osmNoteQuestSource.getAllInBBox(bbox) + ).filter { isVisibleInTeamMode(it) } + else -> emptyList() + } + } + + fun clearCachedQuestPins() { + cache.getItems().forEach { it.pins = null } + } fun addListener(listener: Listener) { listeners.add(listener) @@ -182,9 +233,13 @@ class VisibleQuestsSource( deleted: Collection = emptyList() ) { synchronized(this) { - val addedVisible = added.filter(::isVisible) + val hideOverlayQuests = prefs.getBoolean(Prefs.HIDE_OVERLAY_QUESTS, true) + val addedVisible = added.filter { isVisible(it, hideOverlayQuests) } if (addedVisible.isEmpty() && deleted.isEmpty()) return + if (addedVisible.size > 10 || deleted.size > 10) Log.i(TAG, "added ${addedVisible.size}, deleted ${deleted.size}") + else Log.i(TAG, "added ${addedVisible.map { it.key }}, deleted: $deleted") + cache.update(addedVisible, deleted) listeners.forEach { it.onUpdated(addedVisible, deleted) } } @@ -206,3 +261,5 @@ private const val SPATIAL_CACHE_TILE_ZOOM = 16 private const val SPATIAL_CACHE_TILES = 128 // in a city this is the approximate number of quests in ~30 tiles on default visibilities private const val SPATIAL_CACHE_INITIAL_CAPACITY = 10000 + +private const val TAG = "VisibleQuestsSource" diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/sync/SyncNotification.kt b/app/src/main/java/de/westnordost/streetcomplete/data/sync/SyncNotification.kt index ecf106256cb..2046661aa55 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/sync/SyncNotification.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/sync/SyncNotification.kt @@ -9,7 +9,6 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat.IMPORTANCE_LOW import androidx.core.app.PendingIntentCompat -import de.westnordost.streetcomplete.ApplicationConstants.NAME import de.westnordost.streetcomplete.ApplicationConstants.NOTIFICATIONS_CHANNEL_SYNC import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.screens.main.MainActivity @@ -33,7 +32,7 @@ fun createSyncNotification(context: Context, cancelIntent: PendingIntent): Notif return NotificationCompat.Builder(context, NOTIFICATIONS_CHANNEL_SYNC) .setSmallIcon(R.mipmap.ic_notification) - .setContentTitle(NAME) + .setContentTitle(context.resources.getString(R.string.app_name)) .setTicker(context.resources.getString(R.string.notification_syncing)) .setContentIntent(cancelIntent) .setOngoing(true) diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/upload/UploadModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/upload/UploadModule.kt index 3903ebdc2f5..8b597f70bef 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/upload/UploadModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/upload/UploadModule.kt @@ -8,9 +8,9 @@ import org.koin.core.qualifier.named import org.koin.dsl.module val uploadModule = module { - factory { VersionIsBannedChecker(get(), "https://streetcomplete.app/banned_versions.txt", ApplicationConstants.USER_AGENT) } + factory { VersionIsBannedChecker(get(), BANNED_VERSION_URL, ApplicationConstants.USER_AGENT) } - single { Uploader(get(), get(), get(), get(), get(), get(), get(named("SerializeSync"))) } + single { Uploader(get(), get(), get(), get(), get(), get(), get(named("SerializeSync")), get(), get()) } /* uploading and downloading should be serialized, i.e. may not run in parallel, to avoid * certain race-condition. * @@ -27,3 +27,4 @@ val uploadModule = module { worker { UploadWorker(get(), androidContext(), get()) } } +const val BANNED_VERSION_URL = "https://streetcomplete.mnalis.com/streetcomplete/banned_versions.txt" diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/upload/Uploader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/upload/Uploader.kt index f8020bcdd77..f505da962e2 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/upload/Uploader.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/upload/Uploader.kt @@ -1,19 +1,30 @@ package de.westnordost.streetcomplete.data.upload +import android.content.Context +import android.widget.Toast +import androidx.core.content.ContextCompat +import com.russhwolf.settings.ObservableSettings import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.BuildConfig import de.westnordost.streetcomplete.data.AuthorizationException +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesController import de.westnordost.streetcomplete.data.download.tiles.enclosingTilePos import de.westnordost.streetcomplete.data.osm.edits.upload.ElementEditsUploader import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsUploader +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController import de.westnordost.streetcomplete.data.user.UserLoginController import de.westnordost.streetcomplete.data.user.UserLoginSource import de.westnordost.streetcomplete.util.Listeners +import de.westnordost.streetcomplete.util.ktx.toast import de.westnordost.streetcomplete.util.logs.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject class Uploader( private val noteEditsUploader: NoteEditsUploader, @@ -22,8 +33,11 @@ class Uploader( private val userLoginSource: UserLoginSource, private val versionIsBannedChecker: VersionIsBannedChecker, private val userLoginController: UserLoginController, - private val mutex: Mutex -) : UploadProgressSource { + private val mutex: Mutex, + private val externalSourceQuestController: ExternalSourceQuestController, + private val prefs: ObservableSettings, +) : UploadProgressSource, KoinComponent { + private val context: Context by inject() private val listeners = Listeners() @@ -59,10 +73,21 @@ class Uploader( val banned = bannedInfo if (banned is IsBanned) { throw VersionBannedException(banned.reason) + } else if (banned is UnknownIfBanned) { + val old = prefs.getInt(Prefs.BAN_CHECK_ERROR_COUNT, 0) + prefs.putInt(Prefs.BAN_CHECK_ERROR_COUNT, old + 1) + } else + prefs.putInt(Prefs.BAN_CHECK_ERROR_COUNT, 0) + if (prefs.getInt(Prefs.BAN_CHECK_ERROR_COUNT, 0) > 10) { + try { + ContextCompat.getMainExecutor(context).execute { + context.toast(R.string.ban_check_fails, Toast.LENGTH_LONG) + } + } catch (_: Exception) { } } // let's fail early in case of no authorization - if (!userLoginSource.isLoggedIn) { + if (!userLoginSource.isLoggedIn && !BuildConfig.DEBUG) { throw AuthorizationException("User is not authorized") } @@ -71,8 +96,10 @@ class Uploader( mutex.withLock { // element edit and note edit uploader must run in sequence because the notes may need // to be updated if the element edit uploader creates new elements to which notes refer - elementEditsUploader.upload() + elementEditsUploader.upload(this) + if (!userLoginSource.isLoggedIn) return@withLock // avoid the 2 below in debug apk noteEditsUploader.upload() + externalSourceQuestController.upload() } Log.i(TAG, "Finished upload") } catch (e: CancellationException) { diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/upload/VersionIsBannedChecker.kt b/app/src/main/java/de/westnordost/streetcomplete/data/upload/VersionIsBannedChecker.kt index 6a7f4baf4ad..5defeab257b 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/upload/VersionIsBannedChecker.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/upload/VersionIsBannedChecker.kt @@ -25,6 +25,7 @@ class VersionIsBannedChecker( } catch (e: Exception) { // if there is an io exception, never mind then...! (The unreachability of the above // internet address should not lead to this app being unusable!) + return UnknownIfBanned } return IsNotBanned } @@ -37,3 +38,4 @@ sealed interface BannedInfo data class IsBanned(val reason: String?) : BannedInfo data object IsNotBanned : BannedInfo +data object UnknownIfBanned : BannedInfo diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserLoginController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserLoginController.kt index 20ec2c62e88..b47ac7e81ef 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserLoginController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserLoginController.kt @@ -9,7 +9,10 @@ class UserLoginController( private val listeners = Listeners() - override val isLoggedIn: Boolean get() = accessToken != null + override val isLoggedIn: Boolean get() { + loggedIn = accessToken != null + return loggedIn + } override val accessToken: String? get() = prefs.oAuth2AccessToken @@ -31,4 +34,8 @@ class UserLoginController( override fun removeListener(listener: UserLoginSource.Listener) { listeners.remove(listener) } + + companion object { + var loggedIn = true // used for debugging: allows fake-uploading edits of logged out, always making them as success + } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/DayNightQuestFilter.kt b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/DayNightQuestFilter.kt new file mode 100644 index 00000000000..80eda026413 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/DayNightQuestFilter.kt @@ -0,0 +1,33 @@ +package de.westnordost.streetcomplete.data.visiblequests + +import com.russhwolf.settings.ObservableSettings +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.data.quest.DayNightCycle.DAY_AND_NIGHT +import de.westnordost.streetcomplete.data.quest.DayNightCycle.ONLY_DAY +import de.westnordost.streetcomplete.data.quest.DayNightCycle.ONLY_NIGHT +import de.westnordost.streetcomplete.data.quest.Quest +import de.westnordost.streetcomplete.util.isDay + +class DayNightQuestFilter internal constructor( + private val prefs: ObservableSettings +) { + var isEnabled = false + private set + + fun reload() { + isEnabled = Prefs.DayNightBehavior.valueOf(prefs.getString(Prefs.DAY_NIGHT_BEHAVIOR, "IGNORE")) == Prefs.DayNightBehavior.VISIBILITY + } + + /* + Might be an idea to add a listener so this is reevaluated occasionally, or something like that. + However, I think it's reevaluated everytime the displayed quests are updated? + */ + fun isVisible(quest: Quest): Boolean { + if (!isEnabled) return true + return when (quest.type.dayNightCycle) { + DAY_AND_NIGHT -> true + ONLY_DAY -> isDay(quest.position) + ONLY_NIGHT -> !isDay(quest.position) + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/HideQuestController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/HideQuestController.kt index c3d4ed209ff..d590bff4db0 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/HideQuestController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/HideQuestController.kt @@ -4,4 +4,5 @@ import de.westnordost.streetcomplete.data.quest.QuestKey interface HideQuestController { fun hide(key: QuestKey) + fun tempHide(key: QuestKey) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/LevelFilter.kt b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/LevelFilter.kt new file mode 100644 index 00000000000..ee09e683929 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/LevelFilter.kt @@ -0,0 +1,172 @@ +package de.westnordost.streetcomplete.data.visiblequests + +import android.content.Context +import android.view.LayoutInflater +import android.widget.ScrollView +import androidx.appcompat.app.AlertDialog +import com.russhwolf.settings.ObservableSettings +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuest +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuest +import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.overlays.SelectedOverlayController +import de.westnordost.streetcomplete.data.overlays.SelectedOverlaySource +import de.westnordost.streetcomplete.data.quest.Quest +import de.westnordost.streetcomplete.data.quest.VisibleQuestsSource +import de.westnordost.streetcomplete.databinding.DialogLevelFilterBinding +import de.westnordost.streetcomplete.osm.level.LevelTypes +import de.westnordost.streetcomplete.osm.level.parseSelectableLevels +import de.westnordost.streetcomplete.screens.main.map.maplibre.CameraPosition +import de.westnordost.streetcomplete.util.math.enclosingBoundingBox +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.math.ceil +import kotlin.math.floor + +/** Controller for filtering all quests that are hidden because they are on the wrong level */ +class LevelFilter internal constructor(private val prefs: ObservableSettings) : KoinComponent { + var isEnabled = false + private set + var allowedLevel: String? = null + private set + lateinit var allowedLevelTags: Set + private set + + private val mapDataSource: MapDataWithEditsSource by inject() + private val visibleEditTypeController: VisibleEditTypeController by inject() + private val visibleQuestsSource: VisibleQuestsSource by inject() + private val selectedOverlaySource: SelectedOverlaySource by inject() + + init { reload() } + + private fun reload() { + allowedLevel = prefs.getString(Prefs.ALLOWED_LEVEL, "").let { if (it.isBlank()) null else it.trim() } + allowedLevelTags = prefs.getString(Prefs.ALLOWED_LEVEL_TAGS, "level,repeat_on,level:ref").split(",").toHashSet() + } + + fun isVisible(quest: Quest): Boolean = + !isEnabled || when (quest) { + is OsmQuest -> levelAllowed(mapDataSource.get(quest.elementType, quest.elementId)) + is ExternalSourceQuest -> levelAllowed(quest.elementKey?.let { mapDataSource.get(it.type, it.id) }) + else -> true + } + + fun levelAllowed(element: Element?): Boolean { + if (!isEnabled) return true + val tags = element?.tags ?: return true + val levelTags = tags.filterKeys { allowedLevelTags.contains(it) } + if (levelTags.isEmpty()) return allowedLevel == null + val allowedLevel = allowedLevel ?: return false + levelTags.values.forEach { value -> + val levels = value.split(";") + if (allowedLevel == "*") return true // we have anything in an allowed tag, that's enough + if (allowedLevel.startsWith('<')) { + val maxLevel = allowedLevel.substring(1).trim().toFloatOrNull() + if (maxLevel != null) + levels.forEach { level -> + level.toFloatOrNull()?.let { if (it < maxLevel) return true } + } + } + if (allowedLevel.startsWith('>')) { + val minLevel = allowedLevel.substring(1).trim().toFloatOrNull() + if (minLevel != null) + levels.forEach { level -> + level.toFloatOrNull()?.let { if (it > minLevel) return true } + } + } + if (levels.contains(allowedLevel)) return true + if (value == allowedLevel) return true // maybe user entered 0;1 + } + return false + } + + fun showLevelFilterDialog(context: Context, camera: CameraPosition?) { + val builder = AlertDialog.Builder(context) + val binding = DialogLevelFilterBinding.inflate(LayoutInflater.from(context)) + builder.setTitle(R.string.level_filter_title) + binding.level.setText(prefs.getString(Prefs.ALLOWED_LEVEL, "")) + binding.enableSwitch.isChecked = isEnabled + val levelTags = prefs.getString(Prefs.ALLOWED_LEVEL_TAGS, "level,repeat_on,level:ref").split(",") + val allowedLevelTypes = LevelTypes.entries.filter { levelTags.contains(it.tag) } + binding.plus.setOnClickListener { + val selectableLevels = getLevelsInView(camera?.position?.enclosingBoundingBox(50.0), allowedLevelTypes) + val oldText = binding.level.text?.toString() + val currentLevel = oldText?.let { "[\\d.+-]+".toRegex().find(it)?.value } + val currentLevelNumber = currentLevel?.toDoubleOrNull() + val newLevel = if (currentLevelNumber == null) { + selectableLevels.find { it >= 0 } ?: selectableLevels.firstOrNull() ?: 0.0 + } else { + val nextInt = floor(currentLevelNumber + 1.0) + selectableLevels.find { it > currentLevelNumber && it < nextInt } ?: nextInt + } + binding.level.setText(oldText?.replace(currentLevel ?: oldText, newLevel.toNiceString()) ?: newLevel.toNiceString()) + } + binding.minus.setOnClickListener { + val selectableLevels = getLevelsInView(camera?.position?.enclosingBoundingBox(50.0), allowedLevelTypes) + val oldText = binding.level.text?.toString() + val currentLevel = oldText?.let { "[\\d.+-]+".toRegex().find(it)?.value } + val currentLevelNumber = currentLevel?.toDoubleOrNull() + val newLevel = if (currentLevelNumber == null) { + selectableLevels.findLast { it <= 0 } ?: selectableLevels.firstOrNull() ?: 0.0 + } else { + val prevInt = ceil(currentLevelNumber - 1.0) + selectableLevels.findLast { it < currentLevelNumber && it > prevInt } ?: prevInt + } + binding.level.setText(oldText?.replace(currentLevel ?: oldText, newLevel.toNiceString()) ?: newLevel.toNiceString()) + } + + binding.levelBox.isChecked = allowedLevelTypes.contains(LevelTypes.LEVEL) + binding.repeatOnBox.isChecked = allowedLevelTypes.contains(LevelTypes.REPEAT_ON) + binding.levelRefBox.isChecked = allowedLevelTypes.contains(LevelTypes.LEVEL_REF) + binding.addrFloorBox.isChecked = allowedLevelTypes.contains(LevelTypes.ADDR_FLOOR) + + builder.setView(ScrollView(context).apply { addView(binding.root) }) + builder.setNegativeButton(android.R.string.cancel, null) + builder.setPositiveButton(android.R.string.ok) { _, _ -> + val levelTagList = mutableListOf() + if (binding.levelBox.isChecked) levelTagList.add("level") + if ( binding.repeatOnBox.isChecked) levelTagList.add("repeat_on") + if (binding.levelRefBox.isChecked) levelTagList.add("level:ref") + if (binding.addrFloorBox.isChecked) levelTagList.add("addr:floor") + prefs.putString(Prefs.ALLOWED_LEVEL_TAGS, levelTagList.joinToString(",")) + prefs.putString(Prefs.ALLOWED_LEVEL, binding.level.text.toString()) + isEnabled = binding.enableSwitch.isChecked + reload() + + val overlayController = selectedOverlaySource as? SelectedOverlayController + val tempOverlay = overlayController?.selectedOverlay + if (tempOverlay != null) { + // reload overlay (if enabled), also triggers quest reload unless HIDE_OVERLAY_QUESTS disabled + overlayController.selectedOverlay = null + overlayController.selectedOverlay = tempOverlay + if (!prefs.getBoolean(Prefs.HIDE_OVERLAY_QUESTS, true)) + visibleEditTypeController.setVisibilities(emptyMap()) // trigger reload + } else { + visibleEditTypeController.setVisibilities(emptyMap()) // trigger reload + } + } + builder.show() + } + + private fun getLevelsInView(displayedArea: BoundingBox?, allowed: List): List { + val tags = if (displayedArea != null) { + visibleQuestsSource.getAll(displayedArea).mapNotNull { + when (it) { + is OsmQuest -> mapDataSource.get(it.elementType, it.elementId) + is ExternalSourceQuest -> it.elementKey?.let { mapDataSource.get(it.type, it.id) } + else -> null + }?.tags + } + } else emptyList() + return parseSelectableLevels(tags, allowed) + } + + private fun Double.toNiceString(): String { + if (toInt().toDouble() == this) return toInt().toString() + return toString() + } + +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/QuestTypeOrderController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/QuestTypeOrderController.kt index 7083d422268..b7ebdb3907f 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/QuestTypeOrderController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/QuestTypeOrderController.kt @@ -85,7 +85,7 @@ class QuestTypeOrderController( private fun onQuestTypeOrderAdded(item: QuestType, toAfter: QuestType) { listeners.forEach { it.onQuestTypeOrderAdded(item, toAfter) } } - private fun onQuestTypeOrderChanged() { + fun onQuestTypeOrderChanged() { listeners.forEach { it.onQuestTypeOrdersChanged() } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/QuestsHiddenController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/QuestsHiddenController.kt index 3cfab5919b8..fc8122426d9 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/QuestsHiddenController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/QuestsHiddenController.kt @@ -1,16 +1,20 @@ package de.westnordost.streetcomplete.data.visiblequests +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceHiddenDao import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestsHiddenDao import de.westnordost.streetcomplete.data.osmnotes.notequests.NoteQuestsHiddenDao +import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey import de.westnordost.streetcomplete.data.quest.OsmNoteQuestKey import de.westnordost.streetcomplete.data.quest.OsmQuestKey import de.westnordost.streetcomplete.data.quest.QuestKey import de.westnordost.streetcomplete.util.Listeners +import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds /** Controller for managing which quests have been hidden by user interaction. */ class QuestsHiddenController( private val osmDb: OsmQuestsHiddenDao, private val notesDb: NoteQuestsHiddenDao, + private val externalDb: ExternalSourceHiddenDao, ) : QuestsHiddenSource, HideQuestController { /* Must be a singleton because there is a listener that should respond to a change in the @@ -21,19 +25,23 @@ class QuestsHiddenController( private val cache: MutableMap by lazy { val allOsmHidden = osmDb.getAll() val allNotesHidden = notesDb.getAll() + val allExternalHidden = externalDb.getAll() val result = HashMap(allOsmHidden.size + allNotesHidden.size) allOsmHidden.forEach { result[it.key] = it.timestamp } allNotesHidden.forEach { result[OsmNoteQuestKey(it.noteId)] = it.timestamp } + allExternalHidden.forEach { result[it.first] = it.second } result } /** Mark the quest as hidden by user interaction */ override fun hide(key: QuestKey) { + if (cache.contains(key)) return // SCEE allows accessing and hiding already hidden quests val timestamp: Long synchronized(this) { when (key) { is OsmQuestKey -> osmDb.add(key) is OsmNoteQuestKey -> notesDb.add(key.noteId) + is ExternalSourceQuestKey -> externalDb.add(key) } timestamp = getTimestamp(key) ?: return cache[key] = timestamp @@ -41,6 +49,11 @@ class QuestsHiddenController( listeners.forEach { it.onHid(key, timestamp) } } + override fun tempHide(key: QuestKey) { + val timestamp = nowAsEpochMilliseconds() + listeners.forEach { it.onHid(key, timestamp) } + } + /** Un-hide the given quest. Returns whether it was hid before */ fun unhide(key: QuestKey): Boolean { val timestamp: Long @@ -49,6 +62,7 @@ class QuestsHiddenController( val result = when (key) { is OsmQuestKey -> osmDb.delete(key) is OsmNoteQuestKey -> notesDb.delete(key.noteId) + is ExternalSourceQuestKey -> externalDb.delete(key) } if (!result) return false cache.remove(key) @@ -61,13 +75,14 @@ class QuestsHiddenController( when (key) { is OsmQuestKey -> osmDb.getTimestamp(key) is OsmNoteQuestKey -> notesDb.getTimestamp(key.noteId) + is ExternalSourceQuestKey -> externalDb.getTimestamp(key) } /** Un-hides all previously hidden quests by user interaction */ fun unhideAll(): Int { val unhidCount: Int synchronized(this) { - unhidCount = osmDb.deleteAll() + notesDb.deleteAll() + unhidCount = osmDb.deleteAll() + notesDb.deleteAll() + externalDb.deleteAll() cache.clear() } listeners.forEach { it.onUnhidAll() } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/TeamModeQuestFilter.kt b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/TeamModeQuestFilter.kt index f657b11c398..976b1e5b8b7 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/TeamModeQuestFilter.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/TeamModeQuestFilter.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.data.visiblequests import de.westnordost.streetcomplete.data.osm.created_elements.CreatedElementsSource import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuest import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuest +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuest import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.data.quest.Quest import de.westnordost.streetcomplete.util.Listeners @@ -35,6 +36,7 @@ class TeamModeQuestFilter internal constructor( private val Quest.stableId: Long get() = when (this) { is OsmQuest -> elementId is OsmNoteQuest -> id + is ExternalSourceQuest -> id.hashCode().toLong() else -> 0 } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/VisibleEditTypeController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/VisibleEditTypeController.kt index 1950f55dca6..f4ecbcec00a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/VisibleEditTypeController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/VisibleEditTypeController.kt @@ -105,7 +105,7 @@ class VisibleEditTypeController( private fun onVisibilityChanged(editType: EditType, visible: Boolean) { listeners.forEach { it.onVisibilityChanged(editType, visible) } } - private fun onVisibilitiesChanged() { + fun onVisibilitiesChanged() { listeners.forEach { it.onVisibilitiesChanged() } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/VisibleQuestsModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/VisibleQuestsModule.kt index 42ee843a62a..3a3f1c9eb61 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/VisibleQuestsModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/visiblequests/VisibleQuestsModule.kt @@ -10,9 +10,11 @@ val visibleQuestsModule = module { single { QuestTypeOrderController(get(), get(), get()) } single { TeamModeQuestFilter(get(), get()) } + single { LevelFilter(get()) } + single { DayNightQuestFilter(get()) } single { get() } - single { QuestsHiddenController(get(), get()) } + single { QuestsHiddenController(get(), get(), get()) } single { get() } single { VisibleEditTypeController(get(), get(), get()) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/Place.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/Place.kt index 3cd2b66c536..21987f8d6cf 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/osm/Place.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/osm/Place.kt @@ -239,7 +239,7 @@ fun Feature.applyReplacePlaceTo(tags: Tags) { } // generated by "make update" from https://github.com/mnalis/StreetComplete-taginfo-categorize/ -private val KEYS_THAT_SHOULD_BE_REMOVED_WHEN_PLACE_IS_REPLACED = listOf( +val KEYS_THAT_SHOULD_BE_REMOVED_WHEN_PLACE_IS_REPLACED = listOf( "shop_?[1-9]?(:.*)?", "craft_?[1-9]?", "amenity_?[1-9]?", "club_?[1-9]?", "old_amenity", "old_shop", "information", "leisure", "office_?[1-9]?", "tourism", // popular shop=* / craft=* subkeys @@ -364,4 +364,4 @@ private val KEYS_THAT_SHOULD_BE_REMOVED_WHEN_PLACE_IS_REPLACED = listOf( "Comments?", "comments?", "entrance:(width|step_count|kerb:height)", "fenced", "motor_vehicle", ) .flatMap { listOf(it, "source:$it", "check_date:$it") } - .map { it.toRegex() } + .map { it.intern().toRegex() } diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/ResurveyUtils.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/ResurveyUtils.kt index 05854917d58..35285888673 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/osm/ResurveyUtils.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/osm/ResurveyUtils.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.osm +import de.westnordost.streetcomplete.data.elementfilter.filters.CompareTagAge import de.westnordost.streetcomplete.util.ktx.systemTimeNow import de.westnordost.streetcomplete.util.ktx.toLocalDate import kotlinx.datetime.LocalDate @@ -48,7 +49,7 @@ fun Tags.updateWithCheckDate(key: String, value: String) { * previously collected by another surveyor - we don't want to destroy other people's data. * Also, to avoid ambiguities, we should also update (existence) check date. */ - if (previousValue == value || hasCheckDateForKey(key) || hasCheckDate()) { + if (previousValue == value || hasCheckDateForKey(key) || hasCheckDate() || CompareTagAge.resurveyKeys.contains(key)) { updateCheckDateForKey(key) } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/WaysCrossing.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/WaysCrossing.kt index b0b3a7b2041..2fd56c4b112 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/osm/WaysCrossing.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/osm/WaysCrossing.kt @@ -115,8 +115,8 @@ private fun MutableMap>.removeEndNodes() { } /** groups the sequence of ways to a map of node id -> list of ways */ -private fun Sequence.groupByNodeIds(): MutableMap> { - val result = mutableMapOf>() +fun Sequence.groupByNodeIds(): MutableMap> { + val result = hashMapOf>() forEach { way -> way.nodeIds.forEach { nodeId -> result.getOrPut(nodeId, { mutableListOf() }).add(way) @@ -134,4 +134,4 @@ private fun List.anyCrossesAnyOf(other: List, vertex: LatLon): B /** Returns whether any of the points in this list are on different sides of the line spanned * by p0 and p1 and the line spanned by p1 and p2 */ private fun List.anyAreOnDifferentSidesOf(p0: LatLon, p1: LatLon, p2: LatLon): Boolean = - map { it.isRightOf(p0, p1, p2) }.toSet().size > 1 + mapTo(HashSet(size)) { it.isRightOf(p0, p1, p2) }.size > 1 diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/building/BuildingType.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/building/BuildingType.kt index c9b7bf32f78..e26f869e2ec 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/osm/building/BuildingType.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/osm/building/BuildingType.kt @@ -35,6 +35,7 @@ enum class BuildingType(val osmKey: String?, val osmValue: String?) { GRANDSTAND ("building", "grandstand"), TRAIN_STATION ("building", "train_station"), TRANSPORTATION ("building", "transportation"), + TRANSIT_SHELTER ("amenity", "shelter"), FIRE_STATION ("building", "fire_station"), UNIVERSITY ("building", "university"), GOVERNMENT ("building", "government"), @@ -47,6 +48,7 @@ enum class BuildingType(val osmKey: String?, val osmValue: String?) { PAGODA ("building", "pagoda"), SYNAGOGUE ("building", "synagogue"), SHRINE ("building", "shrine"), + PRESBYTERY ("building", "presbytery"), CARPORT ("building", "carport"), GARAGE ("building", "garage"), @@ -57,6 +59,10 @@ enum class BuildingType(val osmKey: String?, val osmValue: String?) { FARM_AUXILIARY ("building", "farm_auxiliary"), SILO ("man_made", "silo"), GREENHOUSE ("building", "greenhouse"), + BARN ("building", "barn"), + COWSHED ("building", "cowshed"), + STABLE ("building", "stable"), + STY ("building", "sty"), OUTBUILDING ("building", "outbuilding"), SHED ("building", "shed"), @@ -65,6 +71,7 @@ enum class BuildingType(val osmKey: String?, val osmValue: String?) { BRIDGE ("building", "bridge"), TOILETS ("building", "toilets"), SERVICE ("building", "service"), + TRANSFORMER_TOWER ("building", "transformer_tower"), HANGAR ("building", "hangar"), BUNKER ("building", "bunker"), BOATHOUSE ("building", "boathouse"), @@ -72,6 +79,10 @@ enum class BuildingType(val osmKey: String?, val osmValue: String?) { TENT ("building", "tent"), TOMB ("building", "tomb"), TOWER ("man_made", "tower"), + RIDING_HALL ("building", "riding_hall"), + SPORTS_HALL ("building", "sports_hall"), + DIGESTER ("building", "digester"), + ELEVATOR ("building", "elevator"), RESIDENTIAL ("building", "residential"), COMMERCIAL ("building", "commercial"), diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/building/BuildingTypeCategory.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/building/BuildingTypeCategory.kt index f0a43344fef..f7852990d68 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/osm/building/BuildingTypeCategory.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/osm/building/BuildingTypeCategory.kt @@ -15,22 +15,22 @@ enum class BuildingTypeCategory(val type: BuildingType?, val subTypes: List R.string.quest_buildingType_stadium GRANDSTAND -> R.string.quest_buildingType_grandstand TRAIN_STATION -> R.string.quest_buildingType_train_station + TRANSIT_SHELTER -> R.string.quest_buildingType_transit_shelter TRANSPORTATION -> R.string.quest_buildingType_transportation FIRE_STATION -> R.string.quest_buildingType_fire_station UNIVERSITY -> R.string.quest_buildingType_university @@ -80,6 +81,16 @@ private val BuildingType.titleResId: Int get() = when (this) { RELIGIOUS -> R.string.quest_buildingType_religious GUARDHOUSE -> R.string.quest_buildingType_guardhouse CONSTRUCTION -> R.string.quest_buildingType_under_construction + DIGESTER -> R.string.quest_buildingType_digester + SPORTS_HALL -> R.string.quest_buildingType_sports_hall + RIDING_HALL -> R.string.quest_buildingType_riding_hall + PRESBYTERY -> R.string.quest_buildingType_presbytery + BARN -> R.string.quest_buildingType_barn + COWSHED -> R.string.quest_buildingType_cowshed + STABLE -> R.string.quest_buildingType_stable + STY -> R.string.quest_buildingType_sty + TRANSFORMER_TOWER -> R.string.quest_buildingType_transformer_tower + ELEVATOR -> R.string.quest_buildingType_elevator } private val BuildingType.descriptionResId: Int? get() = when (this) { @@ -138,6 +149,7 @@ val BuildingType.iconResId: Int get() = when (this) { GRANDSTAND -> R.drawable.ic_sport_volleyball TRAIN_STATION -> R.drawable.ic_building_train_station TRANSPORTATION -> R.drawable.ic_building_transportation + TRANSIT_SHELTER -> R.drawable.ic_building_transportation FIRE_STATION -> R.drawable.ic_building_fire_truck UNIVERSITY -> R.drawable.ic_building_university GOVERNMENT -> R.drawable.ic_building_historic @@ -179,5 +191,15 @@ val BuildingType.iconResId: Int get() = when (this) { CIVIC -> R.drawable.ic_building_civic RELIGIOUS -> R.drawable.ic_building_temple GUARDHOUSE -> R.drawable.ic_building_guardhouse + DIGESTER -> R.drawable.ic_building_storage_tank + SPORTS_HALL -> R.drawable.ic_sport_volleyball + RIDING_HALL -> R.drawable.ic_sport_equestrian + PRESBYTERY -> R.drawable.ic_religion_christian + BARN -> R.drawable.ic_building_barn + COWSHED -> R.drawable.ic_building_barn + STABLE -> R.drawable.ic_building_barn + STY -> R.drawable.ic_building_barn + TRANSFORMER_TOWER -> R.drawable.ic_building_service + ELEVATOR -> R.drawable.ic_building_bridge CONSTRUCTION -> R.drawable.ic_building_construction } diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/cycleway/Cycleway.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/cycleway/Cycleway.kt index 3046da12463..0a0bd745e34 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/osm/cycleway/Cycleway.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/osm/cycleway/Cycleway.kt @@ -167,8 +167,8 @@ fun getSelectableCycleways( ): List { val dir = direction?.takeUnless { it == BOTH } ?: Direction.getDefault(isRightSide, isLeftHandTraffic) val cycleways = mutableListOf( - NONE, SEPARATE, EXCLUSIVE_LANE, ADVISORY_LANE, UNSPECIFIED_LANE, SUGGESTION_LANE, + SEPARATE, NONE, TRACK, SIDEWALK_EXPLICIT, SIDEWALK_OK, PICTOGRAMS, BUSWAY, SHOULDER ) diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/kerb/KerbUtil.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/kerb/KerbUtil.kt index 601348bd0fb..8c5713be800 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/osm/kerb/KerbUtil.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/osm/kerb/KerbUtil.kt @@ -69,7 +69,7 @@ fun Node.couldBeAKerb(): Boolean = tags.keys.all { key -> * 2. the shared nodes at intersections of barrier=kerb ways with footways * 3. certain shared nodes between a footway=sidewalk and a footway=crossing */ fun MapData.findAllKerbNodes(): Iterable { - val footwayNodes = mutableSetOf() + val footwayNodes = hashSetOf() ways.asSequence() .filter { footwaysFilter.matches(it) } .flatMap { it.nodeIds } @@ -112,7 +112,7 @@ private fun MapData.findCrossingKerbEndNodeIds(ways: Collection): Set .filter { it.tags["footway"] == "crossing" || it.tags["footway"] == "access_aisle" } .flatMap { it.nodeIds.firstAndLast() } - val connectionsById = mutableMapOf() + val connectionsById = hashMapOf() for (id in crossingEndNodeIds) { val count = connectionsById[id] ?: 0 connectionsById[id] = count + 1 diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/level/LevelParser.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/level/LevelParser.kt index 03e75bd0e2c..ef6c5cc7f64 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/osm/level/LevelParser.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/osm/level/LevelParser.kt @@ -2,22 +2,22 @@ package de.westnordost.streetcomplete.osm.level /** get for which level(s) the element with the given tags is defined, if any. * repeat_on is interpreted the same way as level */ -fun parseLevelsOrNull(tags: Map): List? { - val levels = tags["level"]?.toLevelsOrNull() - val repeatOns = tags["repeat_on"]?.toLevelsOrNull() - return if (levels == null) { - if (repeatOns == null) null else repeatOns - } else { - if (repeatOns == null) levels else levels + repeatOns +fun parseLevelsOrNull(tags: Map, allowed: List = defaultTypes): List? { + val resultDelegate = lazy { mutableListOf() } + val result by resultDelegate + allowed.forEach { + tags[it.tag]?.toLevelsOrNull()?.let { result.addAll(it) } } + return if (resultDelegate.isInitialized()) result + else null } /** get levels that would appear on level filter buttons like in JOSM for the elements with the * given tags */ -fun parseSelectableLevels(tagsList: Iterable>): List { +fun parseSelectableLevels(tagsList: Iterable>, allowed: List = defaultTypes): List { val allLevels = mutableSetOf() for (tags in tagsList) { - val levels = parseLevelsOrNull(tags) ?: continue + val levels = parseLevelsOrNull(tags, allowed) ?: continue for (level in levels) { when (level) { is LevelRange -> allLevels.addAll(level.getSelectableLevels()) @@ -45,3 +45,9 @@ private fun String.toLevelOrNull(): Level? { } private val levelRegex = Regex("([+-]?\\d+(?:\\.\\d+)?)(?:-([+-]?\\d+(?:\\.\\d+)?))?") + +enum class LevelTypes(val tag: String) { + LEVEL("level"), REPEAT_ON("repeat_on"), LEVEL_REF("level:ref"), ADDR_FLOOR("addr:floor") +} + +private val defaultTypes = listOf(LevelTypes.LEVEL, LevelTypes.REPEAT_ON) diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/surface/Surface.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/surface/Surface.kt index 80440036fc1..11809b87432 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/osm/surface/Surface.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/osm/surface/Surface.kt @@ -27,6 +27,8 @@ enum class Surface(val osmValue: String?) { ARTIFICIAL_TURF("artificial_turf"), RUBBER("rubber"), ACRYLIC("acrylic"), + STEPPING_STONES("stepping_stones"), + CHIPSEAL("chipseal"), // generic surfaces PAVED("paved"), @@ -35,7 +37,6 @@ enum class Surface(val osmValue: String?) { // extra values, handled as synonyms (not selectable) EARTH("earth"), // synonym of "dirt" - CHIPSEAL("chipseal"), // subtype/synonym of asphalt METAL_GRID("metal_grid"), // more specific than "metal" // these values ideally would be removed from OpenStreetMap, but while they remain @@ -70,9 +71,9 @@ val SELECTABLE_PITCH_SURFACES = listOf( val SELECTABLE_WAY_SURFACES = listOf( // paved surfaces ASPHALT, PAVING_STONES, CONCRETE, CONCRETE_PLATES, CONCRETE_LANES, - SETT, UNHEWN_COBBLESTONE, GRASS_PAVER, WOOD, METAL, + SETT, UNHEWN_COBBLESTONE, GRASS_PAVER, WOOD, METAL, METAL_GRID, CHIPSEAL, // unpaved surfaces - COMPACTED, FINE_GRAVEL, GRAVEL, PEBBLES, WOODCHIPS, + COMPACTED, FINE_GRAVEL, GRAVEL, PEBBLES, WOODCHIPS, STEPPING_STONES, // ground surfaces DIRT, MUD, GRASS, SAND, ROCK, // generic surfaces diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/surface/SurfaceItem.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/surface/SurfaceItem.kt index d9564f0417c..2914ea4b4d6 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/osm/surface/SurfaceItem.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/osm/surface/SurfaceItem.kt @@ -26,7 +26,8 @@ fun Surface.asStreetSideItem(resources: Resources): StreetSideDisplayItem R.string.quest_surface_value_asphalt + ASPHALT -> R.string.quest_surface_value_asphalt + CHIPSEAL -> R.string.quest_surface_value_chipseal CONCRETE -> R.string.quest_surface_value_concrete CONCRETE_PLATES -> R.string.quest_surface_value_concrete_plates CONCRETE_LANES -> R.string.quest_surface_value_concrete_lanes @@ -40,7 +41,7 @@ val Surface.titleResId: Int get() = when (this) { GRASS_PAVER -> R.string.quest_surface_value_grass_paver WOOD -> R.string.quest_surface_value_wood WOODCHIPS -> R.string.quest_surface_value_woodchips - METAL, METAL_GRID -> R.string.quest_surface_value_metal + METAL -> R.string.quest_surface_value_metal GRAVEL -> R.string.quest_surface_value_gravel PEBBLES -> R.string.quest_surface_value_pebblestone GRASS -> R.string.quest_surface_value_grass @@ -54,10 +55,13 @@ val Surface.titleResId: Int get() = when (this) { UNPAVED -> R.string.quest_surface_value_unpaved GROUND -> R.string.quest_surface_value_ground UNKNOWN -> R.string.unknown_surface_title + METAL_GRID -> R.string.quest_surface_value_metal_grid + STEPPING_STONES -> R.string.quest_surface_value_stepping_stones } val Surface.iconResId: Int get() = when (this) { - ASPHALT, CHIPSEAL -> R.drawable.surface_asphalt + ASPHALT -> R.drawable.surface_asphalt + CHIPSEAL -> R.drawable.surface_chipseal CONCRETE -> R.drawable.surface_concrete CONCRETE_PLATES -> R.drawable.surface_concrete_plates CONCRETE_LANES -> R.drawable.surface_concrete_lanes @@ -71,7 +75,7 @@ val Surface.iconResId: Int get() = when (this) { GRASS_PAVER -> R.drawable.surface_grass_paver WOOD -> R.drawable.surface_wood WOODCHIPS -> R.drawable.surface_woodchips - METAL, METAL_GRID -> R.drawable.surface_metal + METAL -> R.drawable.surface_metal GRAVEL -> R.drawable.surface_gravel PEBBLES -> R.drawable.surface_pebblestone GRASS -> R.drawable.surface_grass @@ -85,4 +89,6 @@ val Surface.iconResId: Int get() = when (this) { UNPAVED -> R.drawable.surface_unpaved_area GROUND -> R.drawable.surface_ground_area UNKNOWN -> R.drawable.space_128dp + METAL_GRID -> R.drawable.surface_metal_grid + STEPPING_STONES -> R.drawable.surface_stepping_stones } 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 4f8ab0b53ea..aaa0c082930 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/overlays/AbstractOverlayForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/overlays/AbstractOverlayForm.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.overlays +import android.app.DatePickerDialog import android.content.res.Configuration import android.content.res.Resources import android.graphics.PointF @@ -9,6 +10,7 @@ import android.view.LayoutInflater import android.view.Menu import android.view.View import android.view.ViewGroup +import android.widget.EditText import android.widget.PopupMenu import android.widget.RelativeLayout import androidx.annotation.UiThread @@ -22,7 +24,9 @@ import androidx.fragment.app.Fragment import androidx.viewbinding.ViewBinding import de.westnordost.countryboundaries.CountryBoundaries import de.westnordost.osmfeatures.FeatureDictionary +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.location.RecentLocationStore import de.westnordost.streetcomplete.data.meta.CountryInfo import de.westnordost.streetcomplete.data.meta.CountryInfos @@ -32,6 +36,9 @@ 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.StringMapChangesBuilder +import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTagsAction import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry import de.westnordost.streetcomplete.data.osm.geometry.ElementPolylinesGeometry @@ -40,19 +47,34 @@ 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.mapdata.Way -import de.westnordost.streetcomplete.data.osm.mapdata.key import de.westnordost.streetcomplete.data.overlays.OverlayRegistry +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.quest.QuestKey import de.westnordost.streetcomplete.databinding.FragmentOverlayBinding +import de.westnordost.streetcomplete.osm.ALL_PATHS +import de.westnordost.streetcomplete.osm.ALL_ROADS +import de.westnordost.streetcomplete.overlays.custom.CustomOverlayForm +import de.westnordost.streetcomplete.overlays.street_parking.LaneNarrowingTrafficCalmingForm +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm import de.westnordost.streetcomplete.screens.main.bottom_sheet.IsCloseableBottomSheet import de.westnordost.streetcomplete.screens.main.bottom_sheet.IsMapOrientationAware +import de.westnordost.streetcomplete.util.AccessManagerDialog import de.westnordost.streetcomplete.util.FragmentViewBindingPropertyDelegate +import de.westnordost.streetcomplete.util.accessKeys import de.westnordost.streetcomplete.util.getNameAndLocationSpanned +import de.westnordost.streetcomplete.util.dialogs.setViewWithDefaultPadding +import de.westnordost.streetcomplete.util.ktx.containsAnyKey +import de.westnordost.streetcomplete.util.ktx.isArea import de.westnordost.streetcomplete.util.ktx.isSplittable import de.westnordost.streetcomplete.util.ktx.popIn import de.westnordost.streetcomplete.util.ktx.popOut import de.westnordost.streetcomplete.util.ktx.setMargins +import de.westnordost.streetcomplete.util.ktx.systemTimeNow +import de.westnordost.streetcomplete.util.ktx.toInstant +import de.westnordost.streetcomplete.util.ktx.toLocalDate import de.westnordost.streetcomplete.util.ktx.toast import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope +import de.westnordost.streetcomplete.util.logs.Log import de.westnordost.streetcomplete.view.CharSequenceText import de.westnordost.streetcomplete.view.ResText import de.westnordost.streetcomplete.view.RoundRectOutlineProvider @@ -64,10 +86,15 @@ import de.westnordost.streetcomplete.view.insets_animation.respectSystemInsets import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import kotlinx.datetime.toJavaLocalDate import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.koin.android.ext.android.inject import org.koin.core.qualifier.named +import java.time.format.DateTimeFormatter import java.util.Locale /** Abstract base class for any form displayed for an overlay */ @@ -83,6 +110,7 @@ abstract class AbstractOverlayForm : private val recentLocationStore: RecentLocationStore by inject() private val featureDictionaryLazy: Lazy by inject(named("FeatureDictionaryLazy")) protected val featureDictionary: FeatureDictionary get() = featureDictionaryLazy.value + private val prefs: Preferences by inject() private var _countryInfo: CountryInfo? = null // lazy but resettable because based on lateinit var get() { if (field == null) { @@ -159,6 +187,9 @@ abstract class AbstractOverlayForm : fun getMapPositionAt(screenPos: PointF): LatLon? fun getPointOf(pos: LatLon): PointF? + + /** Called when the user chose to edit tags */ + fun onEditTags(element: Element, geometry: ElementGeometry, questKey: QuestKey? = null, editTypeName: String?) } private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener @@ -203,7 +234,7 @@ abstract class AbstractOverlayForm : setTitleHintLabel( element?.let { getNameAndLocationSpanned(it, resources, featureDictionary) } ) - setObjNote(element?.tags?.get("note")) + setObjNote(element?.tags?.get("note"), element?.tags?.get("fixme") ?: element?.tags?.get("FIXME")) binding.moreButton.setOnClickListener { showOtherAnswers() @@ -252,17 +283,22 @@ abstract class AbstractOverlayForm : _binding = null } - protected fun setObjNote(text: CharSequence?) { + protected fun setObjNote(text: CharSequence?, fixmeText: CharSequence?) { binding.noteLabel.text = text + binding.fixmeLabel.text = if (prefs.expertMode) fixmeText else null val titleHintLayout = (binding.titleHintLabelContainer.layoutParams as? RelativeLayout.LayoutParams) titleHintLayout?.removeRule(RelativeLayout.ABOVE) titleHintLayout?.addRule(RelativeLayout.ABOVE, - if (binding.noteLabel.text.isEmpty()) + if (binding.noteLabel.text.isEmpty() && binding.fixmeLabel.text.isEmpty()) binding.speechbubbleContentContainer.id else binding.speechbubbleNoteContainer.id ) - binding.speechbubbleNoteContainer.isGone = binding.noteLabel.text.isEmpty() + binding.titleNoteLabel.isGone = binding.noteLabel.text.isEmpty() + binding.noteLabel.isGone = binding.noteLabel.text.isEmpty() + binding.titleFixmeLabel.isGone = binding.fixmeLabel.text.isEmpty() + binding.fixmeLabel.isGone = binding.fixmeLabel.text.isEmpty() + binding.speechbubbleNoteContainer.isGone = binding.noteLabel.text.isEmpty() && binding.fixmeLabel.text.isEmpty() } /* --------------------------------- IsCloseableBottomSheet ------------------------------- */ @@ -393,10 +429,22 @@ abstract class AbstractOverlayForm : if (element.isSplittable()) { answers.add(AnswerItem(R.string.split_way) { splitWay(element) }) } - + if (prefs.getBoolean(Prefs.EXPERT_MODE, false) && element is Node + && this !is LaneNarrowingTrafficCalmingForm + && otherAnswers.none { (it.title as? ResText)?.resId == R.string.quest_generic_answer_does_not_exist }) + answers.add(createDeleteElementAnswer(element)) + if (prefs.getBoolean(Prefs.EXPERT_MODE, false)) { + createItsDemolishedAnswer()?.let { answers.add(it) } + createConstructionAnswer()?.let { answers.add(it) } + createAccessManagerAnswer()?.let { answers.add(it) } + } + if (prefs.getBoolean(Prefs.EXPERT_MODE, false) && this !is CustomOverlayForm) + answers.add(AnswerItem(R.string.quest_generic_answer_show_edit_tags) { editTags(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()) { + && (prefs.getBoolean(Prefs.EXPERT_MODE, false) || + (mapDataWithEditsSource.getWaysForNode(element.id).isEmpty() + && mapDataWithEditsSource.getRelationsForNode(element.id).isEmpty()) + )) { answers.add(AnswerItem(R.string.move_node) { moveNode() }) } } @@ -405,6 +453,10 @@ abstract class AbstractOverlayForm : return answers } + protected fun editTags(element: Element, elementGeometry: ElementGeometry? = null, editTypeName: String? = null) { + listener?.onEditTags(element, elementGeometry ?: geometry, editTypeName = editTypeName) + } + protected fun splitWay(element: Element) { listener?.onSplitWay(overlay, element as Way, geometry as ElementPolylinesGeometry) } @@ -413,6 +465,103 @@ abstract class AbstractOverlayForm : listener?.onMoveNode(overlay, element as Node) } + private fun createDeleteElementAnswer(node: Node): AnswerItem { + return AnswerItem(R.string.quest_generic_answer_does_not_exist) { + AlertDialog.Builder(requireContext()) + .setMessage(R.string.osm_element_gone_description) + .setPositiveButton(R.string.osm_element_gone_confirmation) { _, _ -> viewLifecycleScope.launch { solve(DeletePoiNodeAction(node), geometry, true) } } + .setNeutralButton(R.string.leave_note) { _, _ -> composeNote(node) } + .show() + } + } + + private fun createAccessManagerAnswer(): AnswerItem? { + val element = element ?: return null + if (!"ways with highway ~ ${(ALL_ROADS + ALL_PATHS).joinToString("|")}".toElementFilterExpression().matches(element)) return null + val title = if (element.tags.containsAnyKey(*accessKeys)) + R.string.manage_access + else R.string.add_access + return AnswerItem(title) { + AccessManagerDialog(requireContext(), element.tags) { + viewLifecycleScope.launch { solve(UpdateElementTagsAction(element, it.create()), geometry, true) } + }.show() + } + } + + private fun createConstructionAnswer(): AnswerItem? { + val element = element ?: return null + if (!AbstractOsmQuestForm.elementWithoutAccessTagsFilter.matches(element) + || !element.tags.containsKey("highway") + || element.tags["highway"] == "construction" + ) return null + return AnswerItem(R.string.quest_construction) { + val tomorrow = systemTimeNow().toLocalDate().plus(1, DateTimeUnit.DAY) + val p = DatePickerDialog(requireContext(), { _, y, m, d -> + val finishDate = LocalDate(y, m + 1, d) + val today = systemTimeNow().toLocalDate() + val builder = StringMapChangesBuilder(element.tags) + val diff = finishDate.toEpochDays() - today.toEpochDays() + if (diff <= 0) return@DatePickerDialog // don't even bother to tell the user if they are trying to enter wrong data + + // for short construction up to a few months it's better to use conditional access + // as per https://wiki.openstreetmap.org/wiki/Tag:highway%3Dconstruction + if (diff < 200) { // we arbitrarily set the few months to 200 days + val f = DateTimeFormatter.ofPattern("MMM dd yyyy", Locale.US) + builder["access:conditional"] = + "no @ (${f.format(today.toJavaLocalDate())}-${f.format(finishDate.toJavaLocalDate())})" + viewLifecycleScope.launch { solve(UpdateElementTagsAction(element, builder.create()), geometry, true) } + } else { + // if we actually change the highway to construction, we let the user set a construction value + val t = EditText(requireContext()).apply { + setText(element.tags["highway"]) + } + val f = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.US) + builder["opening_date"] = f.format(finishDate.toJavaLocalDate()) + builder["highway"] = "construction" + AlertDialog.Builder(requireContext()) + .setTitle(R.string.quest_construction_value) + .setViewWithDefaultPadding(t) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + t.text.toString().takeIf { it.isNotBlank() } + ?.let { builder["construction"] = it } + viewLifecycleScope.launch { solve(UpdateElementTagsAction(element, builder.create()), geometry, true) } + } + .show() + } + }, tomorrow.year, tomorrow.monthNumber - 1, tomorrow.dayOfMonth) + p.datePicker.minDate = tomorrow.toInstant().toEpochMilliseconds() + p.show() + } + } + + private fun createItsDemolishedAnswer(): AnswerItem? { + val element = element ?: return null + if (!element.isArea()) return null + return if (AbstractOsmQuestForm.demolishableBuildingsFilter.matches(element)) + AnswerItem(R.string.quest_generic_answer_does_not_exist) { + AlertDialog.Builder(requireContext()) + .setItems(arrayOf(requireContext().getString(R.string.quest_building_demolished), requireContext().getString(R.string.leave_note))) { di, i -> + di.dismiss() + if (i == 0) { + viewLifecycleScope.launch { + val builder = StringMapChangesBuilder(element.tags) + builder["demolished:building"] = builder["building"] ?: "yes" + builder.remove("building") + builder.keys.toList().filter { it.matches(Regex("^(building:|roof:).*")) } + .forEach { builder.remove(it) } + solve(UpdateElementTagsAction(element, builder.create()), geometry, true) + } + } else { + composeNote(element) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + else null + } + protected fun composeNote(element: Element) { val overlayTitle = englishResources.getString(overlay.title) val hintLabel = getNameAndLocationSpanned(element, englishResources, featureDictionary) @@ -426,16 +575,17 @@ abstract class AbstractOverlayForm : /* -------------------------------------- Apply edit -------------------------------------- */ - private suspend fun solve(action: ElementEditAction, geometry: ElementGeometry) { + private suspend fun solve(action: ElementEditAction, geometry: ElementGeometry, extra: Boolean = false) { + Log.i(TAG, "solve ${overlay.name} for ${element?.key}, extra: $extra") + val source = if (extra) "survey,extra" else "survey" setLocked(true) val isSurvey = checkIsSurvey(geometry, recentLocationStore.get()) if (!isSurvey && !confirmIsSurvey(requireContext())) { setLocked(false) return } - withContext(Dispatchers.IO) { - addElementEditsController.add(overlay, geometry, "survey", action, isSurvey) + addElementEditsController.add(overlay, geometry, source, action, isSurvey) } listener?.onEdited(overlay, geometry) } @@ -491,3 +641,5 @@ data class AnswerItem(val titleResourceId: Int, override val action: () -> Unit) data class AnswerItem2(val titleString: String, override val action: () -> Unit) : IAnswerItem { override val title: Text get() = CharSequenceText(titleString) } + +private const val TAG = "AbstractOverlayForm" diff --git a/app/src/main/java/de/westnordost/streetcomplete/overlays/OverlaysModule.kt b/app/src/main/java/de/westnordost/streetcomplete/overlays/OverlaysModule.kt index 0d4d5858028..77a691b0791 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/overlays/OverlaysModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/overlays/OverlaysModule.kt @@ -1,6 +1,8 @@ package de.westnordost.streetcomplete.overlays +import com.russhwolf.settings.ObservableSettings import de.westnordost.countryboundaries.CountryBoundaries +import de.westnordost.streetcomplete.ApplicationConstants.EE_QUEST_OFFSET import de.westnordost.osmfeatures.Feature import de.westnordost.osmfeatures.FeatureDictionary import de.westnordost.streetcomplete.data.meta.CountryInfo @@ -9,11 +11,13 @@ import de.westnordost.streetcomplete.data.meta.getByLocation import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.overlays.OverlayRegistry +import de.westnordost.streetcomplete.overlays.custom.CustomOverlay import de.westnordost.streetcomplete.overlays.address.AddressOverlay import de.westnordost.streetcomplete.overlays.buildings.BuildingsOverlay import de.westnordost.streetcomplete.overlays.cycleway.CyclewayOverlay import de.westnordost.streetcomplete.overlays.mtb_scale.MtbScaleOverlay import de.westnordost.streetcomplete.overlays.places.PlacesOverlay +import de.westnordost.streetcomplete.overlays.restriction.RestrictionOverlay import de.westnordost.streetcomplete.overlays.sidewalk.SidewalkOverlay import de.westnordost.streetcomplete.overlays.street_parking.StreetParkingOverlay import de.westnordost.streetcomplete.overlays.surface.SurfaceOverlay @@ -40,7 +44,8 @@ val overlaysModule = module { }, { element -> get>(named("FeatureDictionaryLazy")).value.getFeature(element) - } + }, + get(), ) } } @@ -49,6 +54,7 @@ fun overlaysRegistry( getCountryInfoByLocation: (LatLon) -> CountryInfo, getCountryCodeByLocation: (LatLon) -> String?, getFeature: (Element) -> Feature?, + prefs: ObservableSettings, ) = OverlayRegistry(listOf( 0 to WayLitOverlay(), @@ -61,4 +67,6 @@ fun overlaysRegistry( 8 to ThingsOverlay(getFeature), 7 to BuildingsOverlay(), 9 to MtbScaleOverlay(), + (EE_QUEST_OFFSET + 1) to RestrictionOverlay(), + (EE_QUEST_OFFSET + 0) to CustomOverlay(prefs), )) diff --git a/app/src/main/java/de/westnordost/streetcomplete/overlays/Style.kt b/app/src/main/java/de/westnordost/streetcomplete/overlays/Style.kt index 268018562e6..73bc81c4dd8 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/overlays/Style.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/overlays/Style.kt @@ -37,4 +37,6 @@ data class PointStyle( val icon: Int?, /** label to show on the point */ val label: String? = null, + /** color to use for the icon */ + val color: String? = null, ) : Style diff --git a/app/src/main/java/de/westnordost/streetcomplete/overlays/buildings/BuildingsOverlay.kt b/app/src/main/java/de/westnordost/streetcomplete/overlays/buildings/BuildingsOverlay.kt index 619a0c48671..2b0bdbd8491 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/overlays/buildings/BuildingsOverlay.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/overlays/buildings/BuildingsOverlay.kt @@ -85,22 +85,22 @@ class BuildingsOverlay : Overlay { Color.CYAN // parking, sheds, outbuildings in general... - OUTBUILDING, CARPORT, GARAGE, GARAGES, SHED, BOATHOUSE, SERVICE, ALLOTMENT_HOUSE, + OUTBUILDING, CARPORT, GARAGE, GARAGES, SHED, BOATHOUSE, SERVICE, TRANSFORMER_TOWER, ALLOTMENT_HOUSE, TENT, CONTAINER, GUARDHOUSE, -> // 11% Color.LIME // commercial, industrial, farm buildings COMMERCIAL, KIOSK, RETAIL, OFFICE, BRIDGE, HOTEL, PARKING, - INDUSTRIAL, WAREHOUSE, HANGAR, STORAGE_TANK, - FARM_AUXILIARY, SILO, GREENHOUSE, + INDUSTRIAL, WAREHOUSE, HANGAR, STORAGE_TANK, DIGESTER, + FARM_AUXILIARY, BARN, COWSHED, STABLE, STY, SILO, GREENHOUSE, ROOF -> // 5% Color.GOLD // amenity buildings - TRAIN_STATION, TRANSPORTATION, + TRAIN_STATION, TRANSPORTATION, TRANSIT_SHELTER, ELEVATOR, CIVIC, GOVERNMENT, FIRE_STATION, HOSPITAL, - KINDERGARTEN, SCHOOL, COLLEGE, UNIVERSITY, SPORTS_CENTRE, STADIUM, GRANDSTAND, - RELIGIOUS, CHURCH, CHAPEL, CATHEDRAL, MOSQUE, TEMPLE, PAGODA, SYNAGOGUE, SHRINE, + KINDERGARTEN, SCHOOL, COLLEGE, UNIVERSITY, SPORTS_CENTRE, STADIUM, GRANDSTAND, SPORTS_HALL, RIDING_HALL, + RELIGIOUS, CHURCH, CHAPEL, CATHEDRAL, PRESBYTERY, MOSQUE, TEMPLE, PAGODA, SYNAGOGUE, SHRINE, TOILETS, -> // 2% Color.ORANGE diff --git a/app/src/main/java/de/westnordost/streetcomplete/overlays/custom/CustomOverlay.kt b/app/src/main/java/de/westnordost/streetcomplete/overlays/custom/CustomOverlay.kt new file mode 100644 index 00000000000..84234b857f8 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/overlays/custom/CustomOverlay.kt @@ -0,0 +1,140 @@ +package de.westnordost.streetcomplete.overlays.custom + +import android.content.SharedPreferences +import com.russhwolf.settings.ObservableSettings +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.ElementFilterExpression +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Node +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.overlays.Overlay +import de.westnordost.streetcomplete.overlays.PointStyle +import de.westnordost.streetcomplete.overlays.PolygonStyle +import de.westnordost.streetcomplete.overlays.PolylineStyle +import de.westnordost.streetcomplete.overlays.Style +import de.westnordost.streetcomplete.data.elementfilter.ParseException +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.overlays.Color +import de.westnordost.streetcomplete.overlays.StrokeStyle +import de.westnordost.streetcomplete.util.getNameLabel +import de.westnordost.streetcomplete.util.ktx.isArea +import kotlin.math.abs + +class CustomOverlay(val prefs: ObservableSettings) : Overlay { + + override val title = R.string.custom_overlay_title + override val icon = R.drawable.ic_custom_overlay + override val changesetComment = "Edit user-defined element selection" + override val wikiLink: String = "Tags" + override val isCreateNodeEnabled get() = prefs.getString(Prefs.CUSTOM_OVERLAY_IDX_FILTER, "").startsWith("nodes") + + override fun getStyledElements(mapData: MapDataWithGeometry): Sequence> { + val filter = try { + prefs.getString(getCurrentCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_FILTER, prefs), "").toElementFilterExpression() + } catch (e: ParseException) { return emptySequence() } + val colorKeyPref = prefs.getString(getCurrentCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_COLOR_KEY, prefs), "").let { + if (it.startsWith("!")) it.substringAfter("!") + else it + } + val colorKeySelector = try { colorKeyPref.takeIf { it.isNotBlank() }?.toRegex() } + catch (_: Exception) { null } + val dashFilter = try { + val string = prefs.getString(getCurrentCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_DASH_FILTER, prefs), "").takeIf { it.isNotBlank() } + string?.let { "ways with $it".toElementFilterExpression() } + } catch (_: Exception) { null } + val missingColor = if (prefs.getBoolean(getCurrentCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_HIGHLIGHT_MISSING_DATA, prefs), true)) + Color.DATA_REQUESTED + else + Color.INVISIBLE + return mapData + .filter(filter) + .map { it to getStyle(it, colorKeySelector, dashFilter, missingColor) } + } + + override fun createForm(element: Element?) = CustomOverlayForm() +} + +private fun getStyle(element: Element, colorKeySelector: Regex?, dashFilter: ElementFilterExpression?, defaultMissingColor: String): Style { + val color by lazy { + if (colorKeySelector == null) Color.LIME + else { + val colorString = element.tags.mapNotNull { + // derive color from all matching tags + if (it.key.matches(colorKeySelector)) it.value + it.key + else null + }.sorted().joinToString() // sort because tags hashMap doesn't have a defined order + if (colorString.isEmpty()) defaultMissingColor + else createColorFromString(colorString) + } + } + + var leftColor = "" + var rightColor = "" + var centerColor: String? = null + // get left/right style if there is some match + if (colorKeySelector != null && element !is Node && !element.isArea()) { // avoid doing needless work here + val leftColorTags = mutableListOf() + val rightColorTags = mutableListOf() + val centerColorTags = mutableListOf() + for ((k, v) in element.tags) { + if (!k.matches(colorKeySelector)) continue + // create color in a way that left, right and both match in color -> strip side from tags + if (v == "both" || k.contains(":both")) { + val t = v + k.replace(":both", "") + leftColorTags.add(t) + rightColorTags.add(t) + continue + } + if (v == "right" || k.contains(":right")) { + rightColorTags.add(v + k.replace(":right", "")) + continue + } + if (v == "left" || k.contains(":left")) { + leftColorTags.add(v + k.replace(":left", "")) + continue + } + // only use a center color if there is a match that is not related to left/right/both + centerColorTags.add(v + k) + } + // make sure to use all matching color tags + if (leftColorTags.isNotEmpty()) + leftColor = createColorFromString(leftColorTags.sorted().joinToString()) + if (rightColorTags.isNotEmpty()) + rightColor = createColorFromString(rightColorTags.sorted().joinToString()) + if (centerColorTags.isNotEmpty()) + centerColor = createColorFromString(centerColorTags.sorted().joinToString()) + } + + + return when { +// element is Node -> PointStyle(R.drawable.ic_custom_overlay_node, getNameLabel(element.tags), color) + // MapLibre can only use colors with sdf icons, not with normal images + element is Node -> PointStyle(R.drawable.ic_preset_maki_circle, getNameLabel(element.tags), color) + element.isArea() -> PolygonStyle(color, label = getNameLabel(element.tags)) + // no labels for lines, because this often leads to duplicate labels e.g. for roads + leftColor.isNotEmpty() || rightColor.isNotEmpty() -> PolylineStyle( + stroke = centerColor?.let { StrokeStyle(it, dashFilter?.matches(element) == true) }, + strokeLeft = leftColor.takeIf { it.isNotEmpty() }?.let { StrokeStyle(it) }, + strokeRight = rightColor.takeIf { it.isNotEmpty() }?.let { StrokeStyle(it) } + ) + else -> PolylineStyle(StrokeStyle(color, dashFilter?.matches(element) == true)) + } +} + +private fun createColorFromString(string: String): String { + val c = abs(string.hashCode()).toString(16) + return when { + c.length >= 6 -> "#${c.subSequence(c.length - 6, c.length)}" + else -> createColorFromString("${c}1") // the 1 is there to avoid very similar colors for numbers + } +} + +fun getIndexedCustomOverlayPref(pref: String, index: Int) = pref.replace("idx", index.toString()) +fun getCurrentCustomOverlayPref(pref: String, prefs: ObservableSettings) = getIndexedCustomOverlayPref(pref, prefs.getInt(Prefs.CUSTOM_OVERLAY_SELECTED_INDEX, 0)) +fun getCustomOverlayIndices(prefs: SharedPreferences) = prefs.getString(Prefs.CUSTOM_OVERLAY_INDICES, "0")!! + .split(",").mapNotNull { it.toIntOrNull() } +fun getCustomOverlayIndices(prefs: Preferences) = prefs.getString(Prefs.CUSTOM_OVERLAY_INDICES, "0") + .split(",").mapNotNull { it.toIntOrNull() } diff --git a/app/src/main/java/de/westnordost/streetcomplete/overlays/custom/CustomOverlayForm.kt b/app/src/main/java/de/westnordost/streetcomplete/overlays/custom/CustomOverlayForm.kt new file mode 100644 index 00000000000..2646bcef67a --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/overlays/custom/CustomOverlayForm.kt @@ -0,0 +1,51 @@ +package de.westnordost.streetcomplete.overlays.custom + +import android.os.Bundle +import android.view.View +import androidx.core.view.isGone +import com.russhwolf.settings.ObservableSettings +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.FragmentOverlayCustomBinding +import de.westnordost.streetcomplete.overlays.AbstractOverlayForm +import org.koin.android.ext.android.inject + +class CustomOverlayForm : AbstractOverlayForm() { + private val prefs: ObservableSettings by inject() + + override val contentLayoutResId = R.layout.fragment_overlay_custom + private val binding by contentViewBinding(FragmentOverlayCustomBinding::bind) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val colorKeyPref = prefs.getString(getCurrentCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_COLOR_KEY, prefs), "") + val colorKeySelector = try { + val actualColorKeyPref = if (colorKeyPref.startsWith("!")) + colorKeyPref.substringAfter("!") + else colorKeyPref + actualColorKeyPref.takeIf { it.isNotEmpty() }?.toRegex() + } catch (_: Exception) { null } + val colorTags = if (colorKeySelector != null) + element?.tags?.filter { it.key.matches(colorKeySelector) } + else null + if (colorTags != null) + binding.text.text = colorTags.entries.sortedBy { it.key }.joinToString("\n") { "${it.key} = ${it.value}" } + else + binding.text.isGone = true + binding.editButton.setOnClickListener { + if (colorKeyPref.startsWith("!") && !colorKeyPref.contains(' ')) + focusKey = colorKeyPref + element?.let { editTags(it, editTypeName = overlay.name) } + } + } + + override fun hasChanges() = false + + override fun isFormComplete() = false + + override fun onClickOk() {} + + companion object { + var focusKey: String? = null + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/overlays/places/PlacesOverlayForm.kt b/app/src/main/java/de/westnordost/streetcomplete/overlays/places/PlacesOverlayForm.kt index e154241943e..4d9f5e64f5a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/overlays/places/PlacesOverlayForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/overlays/places/PlacesOverlayForm.kt @@ -121,6 +121,8 @@ class PlacesOverlayForm : AbstractOverlayForm() { { it.toElement().isPlace() || it.id == "shop/vacant" }, ::onSelectedFeature, POPULAR_PLACE_FEATURE_IDS, + false, + geometry.center ).show() } diff --git a/app/src/main/java/de/westnordost/streetcomplete/overlays/restriction/RestrictionOverlay.kt b/app/src/main/java/de/westnordost/streetcomplete/overlays/restriction/RestrictionOverlay.kt new file mode 100644 index 00000000000..091fc76f8a7 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/overlays/restriction/RestrictionOverlay.kt @@ -0,0 +1,147 @@ +package de.westnordost.streetcomplete.overlays.restriction + +import android.graphics.Color.parseColor +import androidx.core.graphics.ColorUtils +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Node +import de.westnordost.streetcomplete.data.osm.mapdata.Relation +import de.westnordost.streetcomplete.data.osm.mapdata.Way +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.osm.ALL_ROADS +import de.westnordost.streetcomplete.overlays.AbstractOverlayForm +import de.westnordost.streetcomplete.overlays.Color +import de.westnordost.streetcomplete.overlays.Overlay +import de.westnordost.streetcomplete.overlays.PointStyle +import de.westnordost.streetcomplete.overlays.PolylineStyle +import de.westnordost.streetcomplete.overlays.StrokeStyle +import de.westnordost.streetcomplete.overlays.Style +import de.westnordost.streetcomplete.quests.max_weight.MaxWeightSign +import de.westnordost.streetcomplete.quests.max_weight.osmKey +import de.westnordost.streetcomplete.util.ktx.containsAnyKey +import de.westnordost.streetcomplete.util.ktx.isArea +import de.westnordost.streetcomplete.util.ktx.toHexColor + +class RestrictionOverlay : Overlay { + // show restriction icons? will need to add property for rotation / angle + // but according to tangram docs, angle is a number or string, this would need a function... + override fun getStyledElements(mapData: MapDataWithGeometry): Sequence> { + val restrictions = mapData.relations.filter { it.tags["type"] == "restriction" } + val restrictionsByWayMemberId = HashMap>(restrictions.size) + restrictions.forEach { restriction -> + for (member in restriction.members) { + if (member.type != ElementType.WAY) continue + val list = restrictionsByWayMemberId.getOrPut(member.ref) { ArrayList(2) } + list.add(restriction) + } + } + + // don't highlight via nodes... or do they matter? + // actually the do matter in some cases, e.g. no-u-turn with from and to being the same way + // ideally the via nodes would have the correct icon, and then of course need rotation too + return mapData.filter("ways with highway ~ ${ALL_ROADS.joinToString("|")}") + .mapNotNull { way -> getWayStyle(way as Way, restrictionsByWayMemberId)?.let { way to it } } + + mapData.nodes.mapNotNull { node -> getNodeStyle(node)?.let { node to it } } + } + + override fun createForm(element: Element?): AbstractOverlayForm = + if (element is Way) RestrictionOverlayWayForm() + else RestrictionOverlayNodeForm() // node or null when inserting + + override val changesetComment: String = "Specify traffic restrictions" + override val icon: Int = R.drawable.ic_overlay_restriction + override val title: Int = R.string.restriction_overlay_title + override val wikiLink: String = "Relation:restriction" + override val isCreateNodeEnabled = true + + // todo: better coloring if there are multiple restrictions on the same way + // merge any 2 restrictions? + // always take a "first" one? + // sth else, like dashed way? + private fun getWayStyle(way: Way, restrictionsByWayMemberId: Map>): Style? { + // don't allow selecting areas + if (way.isArea()) return null + val relations = restrictionsByWayMemberId[way.id] + if (relations == null) { + // no turn restriction, but maybe weight +// val color = if (way.tags.keys.filter { it.startsWith("max") }.any { key -> maxWeightKeys.any { key.startsWith(it) } }) + val color = if (way.tags.containsAnyKey(*maxWeightKeys)) + Color.TEAL + else Color.INVISIBLE + return PolylineStyle(StrokeStyle(color)) + } + + // merge colors if we have 2 relations on one way + val color = if (relations.size == 2) { + val colors = relations.map { it.getColor(way.id) } + if (colors.first() == colors.last()) + colors.first() + else + ColorUtils.blendARGB(parseColor(colors.first()), parseColor(colors.last()), 0.5f).toHexColor() + } else + relations.first().getColor(way.id) + return PolylineStyle(StrokeStyle(color)) + } + + private fun getNodeStyle(node: Node): Style? { + val highway = node.tags["highway"] ?: return null + val icon = when (highway) { + "stop" -> R.drawable.ic_restriction_stop + "give_way" -> R.drawable.ic_restriction_give_way + else -> return null + } + return PointStyle(icon) + } +} + +private fun Relation.getColor(wayId: Long): String { + if (!isSupportedTurnRestriction()) return Color.BLACK + val role = members.firstOrNull { it.type == ElementType.WAY && it.ref == wayId }?.role ?: return Color.INVISIBLE + return getColor(role, getRestrictionType()!!) + //.replace("#", "#90") // make it transparent for at least some support of multiple relations on a single way + // nope, unfortunately we can't simply make it transparent here, because MapLibre doesn't understand colors with alpha channel +} + +private fun getColor(role: String, restriction: String): String = when { + restriction.startsWith("no_") && role == "from" -> Color.ORANGE + restriction.startsWith("no_") && role == "to" -> darkerOrange + restriction.startsWith("only_") && role == "from" -> Color.GOLD + restriction.startsWith("only_") && role == "to" -> darkerGold + role == "via" -> Color.LIME + else -> Color.BLACK +} + +// support restrictions with 1 from way, 1 to way, 1 via node or 1+ via ways +// and additionally, ways need to be connected (but that is more complicated, and not checked) +// there are some more restrictions which are not supported currently, e.g. no_entry, stop, give_way +fun Relation.isSupportedTurnRestriction(): Boolean { + if (tags["type"] != "restriction") return false + if (getRestrictionType() !in turnRestrictionTypes) return false + if (members.count { it.type == ElementType.WAY && it.role == "from" } != 1) return false + if (members.count { it.type == ElementType.WAY && it.role == "to" } != 1) return false + val viaWayCount = members.count { it.type == ElementType.WAY && it.role == "via" } + val viaNodeCount = members.count { it.type == ElementType.NODE && it.role == "via" } + if (viaNodeCount > 1) return false + if (viaNodeCount != 0 && viaWayCount != 0) return false + return true +} + +fun Relation.getRestrictionType() = tags["restriction"] ?: tags["restriction:conditional"]?.substringBefore("@")?.trim() + ?: tags.entries.firstOrNull { it.key.substringAfter("restriction:").substringBefore(":conditional") in onlyTurnRestrictionSet }?.value?.substringBefore("@")?.trim() + +val turnRestrictionTypes = linkedSetOf( + "no_right_turn", + "no_left_turn", + "no_u_turn", + "no_straight_on", + "only_right_turn", + "only_left_turn", + "only_straight_on", +) + +private val maxWeightKeys = MaxWeightSign.entries.map { it.osmKey }.toTypedArray() + +private val darkerGold = ColorUtils.blendARGB(parseColor(Color.GOLD), parseColor(Color.BLACK), 0.75f).toHexColor() +private val darkerOrange = ColorUtils.blendARGB(parseColor(Color.ORANGE), parseColor(Color.BLACK), 0.75f).toHexColor() diff --git a/app/src/main/java/de/westnordost/streetcomplete/overlays/restriction/RestrictionOverlayNodeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/overlays/restriction/RestrictionOverlayNodeForm.kt new file mode 100644 index 00000000000..76e696ed018 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/overlays/restriction/RestrictionOverlayNodeForm.kt @@ -0,0 +1,371 @@ +package de.westnordost.streetcomplete.overlays.restriction + +import android.content.res.Configuration +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.os.Bundle +import android.view.View +import android.widget.ImageView +import androidx.core.content.ContextCompat +import androidx.core.view.children +import androidx.core.view.doOnLayout +import androidx.core.view.isGone +import androidx.core.view.isVisible +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression +import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource +import de.westnordost.streetcomplete.data.osm.edits.create.createNodeAction +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChangesBuilder +import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTagsAction +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.mapdata.filter +import de.westnordost.streetcomplete.databinding.FragmentOverlayRestrictionNodeBinding +import de.westnordost.streetcomplete.osm.ALL_ROADS +import de.westnordost.streetcomplete.osm.isNotOnewayForCyclists +import de.westnordost.streetcomplete.osm.isOneway +import de.westnordost.streetcomplete.overlays.AbstractOverlayForm +import de.westnordost.streetcomplete.screens.main.bottom_sheet.IsMapOrientationAware +import de.westnordost.streetcomplete.screens.main.bottom_sheet.IsMapPositionAware +import de.westnordost.streetcomplete.util.ktx.dpToPx +import de.westnordost.streetcomplete.util.ktx.firstAndLast +import de.westnordost.streetcomplete.util.math.PositionOnWay +import de.westnordost.streetcomplete.util.math.PositionOnWaySegment +import de.westnordost.streetcomplete.util.math.VertexOfWay +import de.westnordost.streetcomplete.util.math.enclosingBoundingBox +import de.westnordost.streetcomplete.util.math.getPositionOnWays +import de.westnordost.streetcomplete.util.math.initialBearingTo +import de.westnordost.streetcomplete.view.ResImage +import de.westnordost.streetcomplete.view.ResText +import de.westnordost.streetcomplete.view.RotatedCircleDrawable +import de.westnordost.streetcomplete.view.image_select.ImageListPickerDialog +import de.westnordost.streetcomplete.view.image_select.Item2 +import de.westnordost.streetcomplete.view.setImage +import de.westnordost.streetcomplete.view.setText +import org.koin.android.ext.android.inject + +// some stuff taken from LaneNarrowingTrafficCalmingForm +class RestrictionOverlayNodeForm : AbstractOverlayForm(), IsMapPositionAware, IsMapOrientationAware { + + private val mapDataWithEditsSource: MapDataWithEditsSource by inject() + override val contentLayoutResId = R.layout.fragment_overlay_restriction_node + private val binding by contentViewBinding(FragmentOverlayRestrictionNodeBinding::bind) + private val items = listOf( + Item2(Type.GIVE_WAY, ResImage(R.drawable.ic_restriction_give_way), ResText(R.string.restriction_overlay_sign_give_way)), + Item2(Type.STOP, ResImage(R.drawable.ic_restriction_stop), ResText(R.string.restriction_overlay_sign_stop)), + Item2(Type.ALL_WAY_STOP, ResImage(R.drawable.ic_restriction_stop), ResText(R.string.restriction_overlay_sign_stop_all_way)), + ) + private val selectableItems = items.filterNot { it.value == Type.ALL_WAY_STOP } + + private var positionOnWay: PositionOnWay? = null + set(value) { + field = value + if (value != null) { + setMarkerPosition(value.position) + setMarkerVisibility(true) + } else { + setMarkerVisibility(false) + setMarkerPosition(null) + } + } + private var roads: Collection>>? = null + private val waysFilter = """ + ways with + area != yes + and ( + highway ~ ${ALL_ROADS.joinToString("|")}|cycleway + or ( + highway ~ path|footpath|bridleway + and bicycle ~ yes|designated + ) + ) + """.toElementFilterExpression() + private var mapRotation = 0.0 + + private var data: MapDataWithGeometry? = null + private var direction: Direction? = null + private var type: Type? = null + set(value) { + if (field == value) return + field = value + checkCurrentCursorPosition() + if (element == null) { + if (type == Type.GIVE_WAY) setMarkerIcon(R.drawable.ic_restriction_give_way) + else setMarkerIcon(R.drawable.ic_restriction_stop) + } + checkIsFormComplete() + updateForm() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.selectedCellView.setOnClickListener { + ImageListPickerDialog(requireContext(), selectableItems) { item -> + type = item.value + }.show() + } + binding.selectTextView.setOnClickListener { + ImageListPickerDialog(requireContext(), selectableItems) { item -> + type = item.value + }.show() + } + binding.dropDownArrowImageView.setOnClickListener { + ImageListPickerDialog(requireContext(), selectableItems) { item -> + type = item.value + }.show() + } + if (savedInstanceState != null) onLoadInstanceState(savedInstanceState) + + if (element == null) { + view.doOnLayout { + initCreatingPointOnWay() + checkCurrentCursorPosition() + } + setMarkerIcon(R.drawable.ic_restriction_stop) + setMarkerVisibility(false) + } else { + val td = getTypeAndDirection(element!!.tags) + type = td.first + direction = td.second + updateForm() + } + } + + private fun initCreatingPointOnWay() { + data = mapDataWithEditsSource.getMapDataWithGeometry(geometry.center.enclosingBoundingBox(100.0)) + val data = data ?: return + roads = data + .filter(waysFilter) + .filterIsInstance() + .map { way -> + val positions = way.nodeIds.map { data.getNode(it)!!.position } + way to positions + }.toList() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + checkCurrentCursorPosition() + } + + override fun onMapMoved(position: LatLon) { + if (element != null) return + checkCurrentCursorPosition() + } + + private fun updateForm() { + // show correct icon + val item = items.firstOrNull { it.value == type } + if (item != null) { + binding.selectedCellView.isVisible = true + binding.selectedCellView.setImage(item.image) + binding.selectTextView.setText(item.title) + } else { + binding.selectedCellView.isGone = true + } + // and direction + if (type == Type.ALL_WAY_STOP) { + binding.directionText.isGone = true + binding.directionContainer.isGone = true + } else if (type != null) { + setDirectionImages() + } + } + + private fun setDirectionImages() { + val element = element + val pow = positionOnWay + val way = when { + element != null -> mapDataWithEditsSource.getWaysForNode(element.id).firstOrNull { waysFilter.matches(it) } + pow is VertexOfWay -> mapDataWithEditsSource.getWay(pow.wayIds.first()) + pow is PositionOnWaySegment -> mapDataWithEditsSource.getWay(pow.wayId) + else -> null + } + if (way?.tags?.let { + if (it["highway"] in ALL_ROADS) + isOneway(it) && !isNotOnewayForCyclists(it, countryInfo.isLeftHandTraffic) + else // cycleways, though doesn't catch oneway = yes and oneway:bicycle = no + isOneway(it) || it["oneway:bicycle"] in listOf("yes", "-1") + } != false) { + binding.directionText.isGone = true + binding.directionContainer.isGone = true + return + } + val wayRotation = getWayRotation() + binding.directionText.isVisible = true + binding.directionContainer.isVisible = true + binding.directionContainer.removeAllViews() + binding.directionContainer.addView(ImageView(requireContext()).apply { + // forward + val drawable = RotatedCircleDrawable(context.getDrawable(R.drawable.ic_oneway_yes)!!) + drawable.rotation = (mapRotation + wayRotation).toFloat() + setImageDrawable(drawable) + if (direction == Direction.FORWARD) + setColorFilter(ContextCompat.getColor(requireContext(), R.color.accent)) + else colorFilter = null + setOnClickListener { + direction = Direction.FORWARD + binding.directionContainer.children.forEach { (it as ImageView).colorFilter = null } + setColorFilter(ContextCompat.getColor(requireContext(), R.color.accent)) + checkIsFormComplete() + } + }) + binding.directionContainer.addView(ImageView(requireContext()).apply { + // backward + val drawable = RotatedCircleDrawable(context.getDrawable(R.drawable.ic_oneway_yes_reverse)!!) + drawable.rotation = (mapRotation + wayRotation).toFloat() + setImageDrawable(drawable) + if (direction == Direction.BACKWARD) + setColorFilter(ContextCompat.getColor(requireContext(), R.color.accent)) + else colorFilter = null + setOnClickListener { + direction = Direction.BACKWARD + binding.directionContainer.children.forEach { (it as ImageView).colorFilter = null } + setColorFilter(ContextCompat.getColor(requireContext(), R.color.accent)) + checkIsFormComplete() + } + }) + } + + private fun checkCurrentCursorPosition() { + val roads = roads ?: return + val metersPerPixel = metersPerPixel ?: return + val maxDistance = metersPerPixel * requireContext().resources.dpToPx(24) + val snapToVertexDistance = metersPerPixel * requireContext().resources.dpToPx(12) + val pos = geometry.center.getPositionOnWays(roads, maxDistance, snapToVertexDistance) + if (pos is VertexOfWay) { + val node = mapDataWithEditsSource.getNode(pos.nodeId)!! + if (node.tags.containsKey("highway") || node.tags.containsKey("crossing")) + return + } + // get number of roads on this vertex + // but count only 1 road if count is 2 and it's an end node of both + val wayCountOnVertex = if (pos !is VertexOfWay) null + else { + val r = roads.filter { it.first.nodeIds.contains(pos.nodeId) } + if (r.size == 2 && r.all { it.first.nodeIds.firstAndLast().contains(pos.nodeId) }) 1 + else r.size + } + positionOnWay = when (type) { + Type.GIVE_WAY -> { + if (wayCountOnVertex != null && wayCountOnVertex > 1) + null // don't allow on more than a single way + else pos + } + Type.STOP -> { + if (wayCountOnVertex != null && wayCountOnVertex > 1) + type = Type.ALL_WAY_STOP // no normal stop if there is more than one way + pos + } + Type.ALL_WAY_STOP -> { + if (wayCountOnVertex == null || wayCountOnVertex == 1) + type = Type.STOP // normal stop if there is only one way + pos + } + else -> pos + } + checkIsFormComplete() + updateForm() + } + + override fun hasChanges(): Boolean { + val td = element?.let { getTypeAndDirection(it.tags) } + return td?.first != type || td?.second != direction + } + + override fun isFormComplete(): Boolean = type != null && hasChanges() && (element != null || positionOnWay != null) + + override fun onClickOk() { + val element = element + val positionOnWay = positionOnWay + val direction = direction + val type = type ?: return + val editAction = if (element != null) { + val tagChanges = StringMapChangesBuilder(element.tags) + applyTo(tagChanges, type, direction) + UpdateElementTagsAction(element, tagChanges.create()) + } else if (positionOnWay != null) { + createNodeAction(positionOnWay, mapDataWithEditsSource) { applyTo(it, type, direction) } + } else null + if (editAction != null) + applyEdit(editAction) + } + + private fun onLoadInstanceState(inState: Bundle) { + val selectedIndex = inState.getInt(SELECTED_INDEX) + type = if (selectedIndex != -1) items[selectedIndex].value else null + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(SELECTED_INDEX, items.indexOfFirst { it.value == type }) + } + + override fun onMapOrientation(rotation: Double, tilt: Double) { + mapRotation = -rotation + } + + private fun getWayRotation(): Double { + val element = element as? Node + if (element != null) { + val way = mapDataWithEditsSource.getWaysForNode(element.id).firstOrNull { waysFilter.matches(it) } ?: return 0.0 + val index = way.nodeIds.indexOf(element.id) + return if (index != way.nodeIds.lastIndex) + element.position.initialBearingTo(mapDataWithEditsSource.getNode(way.nodeIds[index + 1])!!.position) + else + mapDataWithEditsSource.getNode(way.nodeIds[index - 1])!!.position.initialBearingTo(element.position) + } else { + val pow = positionOnWay ?: return 0.0 + if (pow is PositionOnWaySegment) return pow.segment.first.initialBearingTo(pow.segment.second) + else if (pow is VertexOfWay) { + val way = mapDataWithEditsSource.getWay(pow.wayIds.first())!! + val index = way.nodeIds.indexOf(pow.nodeId) + return if (index != way.nodeIds.lastIndex) + pow.position.initialBearingTo(mapDataWithEditsSource.getNode(way.nodeIds[index + 1])!!.position) + else + mapDataWithEditsSource.getNode(way.nodeIds[index - 1])!!.position.initialBearingTo(pow.position) + } + } + return 0.0 + } + + companion object { + private const val SELECTED_INDEX = "selected_index" + } +} + +private fun applyTo(tagChanges: StringMapChangesBuilder, type: Type, direction: Direction?) { + val newDirection = direction?.takeIf { type != Type.ALL_WAY_STOP }?.osmValue + if (tagChanges["direction"] != newDirection) { + if (newDirection == null) + tagChanges.remove("direction") + else tagChanges["direction"] = newDirection + } + val newHighway = if (type == Type.GIVE_WAY) "give_way" + else "stop" + if (tagChanges["highway"] != newHighway) + tagChanges["highway"] = newHighway + if (type == Type.ALL_WAY_STOP) tagChanges["stop"] = "all" // according to wiki, also minor is possible, but it seems that it's not used on intersection nodes + else if (type == Type.GIVE_WAY) tagChanges.remove("stop") +} + +private fun getTypeAndDirection(tags: Map): Pair { + val type = when { + tags["highway"] == "give_way" -> Type.GIVE_WAY + // direction = both seems to be used like stop = all + tags["highway"] == "stop" && (tags["stop"] == "all" || tags["direction"] == "both") -> Type.ALL_WAY_STOP + tags["highway"] == "stop" -> Type.STOP + else -> null + } + val direction = when (type) { + Type.GIVE_WAY, Type.STOP -> tags["direction"]?.let { dir -> Direction.values().firstOrNull { it.osmValue == dir } } + else -> null + } + return type to direction +} + +private enum class Type { GIVE_WAY, STOP, ALL_WAY_STOP } +private enum class Direction(val osmValue: String) { FORWARD("forward"), BACKWARD("backward") } diff --git a/app/src/main/java/de/westnordost/streetcomplete/overlays/restriction/RestrictionOverlayWayForm.kt b/app/src/main/java/de/westnordost/streetcomplete/overlays/restriction/RestrictionOverlayWayForm.kt new file mode 100644 index 00000000000..21484d71332 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/overlays/restriction/RestrictionOverlayWayForm.kt @@ -0,0 +1,757 @@ +package de.westnordost.streetcomplete.overlays.restriction + +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.widget.AdapterView +import android.widget.AdapterView.OnItemSelectedListener +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.EditText +import android.widget.Spinner +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toDrawable +import androidx.core.view.isGone +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.lifecycleScope +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.meta.CountryInfo +import de.westnordost.streetcomplete.data.meta.WeightMeasurementUnit +import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource +import de.westnordost.streetcomplete.data.osm.edits.create.CreateRelationAction +import de.westnordost.streetcomplete.data.osm.edits.delete.DeleteRelationAction +import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTagsAction +import de.westnordost.streetcomplete.data.osm.edits.update_tags.createChanges +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometryCreator +import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry +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.LatLon +import de.westnordost.streetcomplete.data.osm.mapdata.Node +import de.westnordost.streetcomplete.data.osm.mapdata.Relation +import de.westnordost.streetcomplete.data.osm.mapdata.RelationMember +import de.westnordost.streetcomplete.data.osm.mapdata.Way +import de.westnordost.streetcomplete.data.osm.mapdata.key +import de.westnordost.streetcomplete.databinding.FragmentOverlayRestrictionWayBinding +import de.westnordost.streetcomplete.osm.ALL_ROADS +import de.westnordost.streetcomplete.overlays.AbstractOverlayForm +import de.westnordost.streetcomplete.overlays.AnswerItem +import de.westnordost.streetcomplete.quests.max_weight.ImperialPounds +import de.westnordost.streetcomplete.quests.max_weight.MaxWeightSign +import de.westnordost.streetcomplete.quests.max_weight.MetricTons +import de.westnordost.streetcomplete.quests.max_weight.ShortTons +import de.westnordost.streetcomplete.quests.max_weight.asItem +import de.westnordost.streetcomplete.quests.max_weight.getLayoutResourceId +import de.westnordost.streetcomplete.quests.max_weight.osmKey +import de.westnordost.streetcomplete.screens.main.MainActivity +import de.westnordost.streetcomplete.screens.main.map.MainMapFragment +import de.westnordost.streetcomplete.screens.main.map.Marker +import de.westnordost.streetcomplete.util.ktx.containsAny +import de.westnordost.streetcomplete.util.ktx.createBitmap +import de.westnordost.streetcomplete.util.ktx.dpToPx +import de.westnordost.streetcomplete.util.ktx.firstAndLast +import de.westnordost.streetcomplete.util.ktx.showKeyboard +import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope +import de.westnordost.streetcomplete.util.math.distanceToArcs +import de.westnordost.streetcomplete.util.math.enclosingBoundingBox +import de.westnordost.streetcomplete.util.math.finalBearingTo +import de.westnordost.streetcomplete.util.dialogs.showAddConditionalDialog +import de.westnordost.streetcomplete.util.dialogs.showOtherConditionalDialog +import de.westnordost.streetcomplete.util.getNameAndLocationSpanned +import de.westnordost.streetcomplete.view.ArrayImageAdapter +import de.westnordost.streetcomplete.view.DrawableImage +import de.westnordost.streetcomplete.view.ResImage +import de.westnordost.streetcomplete.view.image_select.ImageListPickerDialog +import de.westnordost.streetcomplete.view.image_select.Item2 +import de.westnordost.streetcomplete.view.inputfilter.acceptDecimalDigits +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject + +// todo +// save instance state +// save selection mode, selected restriction, current restriction +// show anything if there is no restriction? looks awfully empty +// more restriction types like oneway, length, height +// don't allow adding turn restriction if none can be added (much work for little gain) +// allow setting via ways, and allow choosing via node if from and to are the same +// often needed for no_u_turn, but might be much work +// allow adding conditional-only restriction for weight (works for turn only) +// form grows too high with many restrictions (maybe scrollview?) +class RestrictionOverlayWayForm : AbstractOverlayForm() { + + private val mapDataSource: MapDataWithEditsSource by inject() + private val mapFragment by lazy { + (activity as? MainActivity)?.supportFragmentManager?.fragments?.filterIsInstance()?.singleOrNull() + } + override val contentLayoutResId = R.layout.fragment_overlay_restriction_way + private val binding by contentViewBinding(FragmentOverlayRestrictionWayBinding::bind) + private val maxWeightInput: EditText? get() = binding.maxWeightContainer.findViewById(R.id.maxWeightInput) + private val weightUnitSelect: Spinner? get() = binding.maxWeightContainer.findViewById(R.id.weightUnitSelect) + + private val originalRestrictions by lazy { + val turnRestrictions = mapDataSource.getRelationsForWay(element!!.id).filter { it.tags["type"] == "restriction" } + .map { TurnRestriction(it) } + val weightRestrictions = getWeightRestrictions(element as Way) + turnRestrictions + weightRestrictions + } + + // unchanged restriction from originalRestrictions + private var selectedRestriction: Restriction? = null + set(value) { + field = value + showOtherRestrictionsList() + if (value != null) + currentRestriction = value + } + + // (currently) can't be set to null + private var currentRestriction: Restriction? = null + set(value) { + if (field == value) return + val oldValue = field + field = value + checkIsFormComplete() + // can't add restriction if sth is changed + if (field != selectedRestriction) + binding.addRestriction.isGone = true + when (value) { + is WeightRestriction -> { + if (oldValue is WeightRestriction && oldValue.way == value.way && oldValue.sign == value.sign) + // no need to change form if only weight changed + // especially reloading the input while typing is annoying! + return + showWeightRestrictionUi(value) + via = null + mapFragment?.highlightGeometry(geometry) + } + is TurnRestriction -> { + if (value.relation.isSupportedTurnRestriction()) { + showFullTurnRestrictionUi(value) + showTurnRestrictionOnMap(value) + } else { + showUnsupportedTurnRestriction(value) + } + getGeometry(value.relation)?.let { mapFragment?.highlightGeometry(it) } + } + null -> { } // should not happen + } + binding.conditionalButton.isVisible = true + if (value is TurnRestriction && value.relation.id == 0L) + binding.removeRestriction.isInvisible = true + else binding.removeRestriction.isVisible = true + } + + // only used for turn restriction + private var via: Pair? = null + set(value) { + field?.first?.let { mapFragment?.deleteMarkerForCurrentHighlighting(ElementPointGeometry(it)) } + field = value + val tags = (currentRestriction as? TurnRestriction)?.relation?.tags ?: emptyMap() + val icon = getIconForTurnRestriction(tags.getShortRestrictionValue() ?: "") + value?.let { mapFragment?.putMarkersForCurrentHighlighting(listOf(Marker(ElementPointGeometry(it.first), icon, null, null, it.second))) } + } + + // enabled when adding turn restriction, cannot be disabled + private var turnRestrictionSelectionMode: Boolean = false + set(value) { + field = value + if (value) { + mapFragment?.hideOverlay() + mapFragment?.highlightGeometry(geometry) // highlight initially selected way only + binding.turnRestrictionContainer.isVisible = true + binding.maxWeightContainer.isGone = true + binding.exceptions.text = getString(R.string.restriction_overlay_exceptions, getString(R.string.overlay_none)) + binding.infoText.setText(R.string.restriction_overlay_select_way) + binding.infoText.isVisible = true + binding.addRestriction.isGone = true + } + } + + override val otherAnswers get() = listOfNotNull( + relationDetailsAnswer(), + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launch { originalRestrictions } // load restrictions in background, so ui thread needs to wait less + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (originalRestrictions.isNotEmpty() && selectedRestriction == null) { + selectedRestriction = getInitialRestriction() + } + binding.addRestriction.setOnClickListener { onClickAddRestriction() } + + binding.turnRestrictionTypeSpinner.adapter = ArrayImageAdapter(requireContext(), turnRestrictionTypeList.map { getIconForTurnRestriction(it) }, 80) + binding.turnRestrictionTypeSpinner.onItemSelectedListener = object : OnItemSelectedListener { + override fun onItemSelected(p0: AdapterView<*>?, p1: View?, p2: Int, p3: Long) { + val oldRestriction = currentRestriction as? TurnRestriction ?: return + val newTags = oldRestriction.relation.tags.toMutableMap() + val conditionalKey = if (newTags.containsKey("restriction:conditional")) "restriction:conditional" + else newTags.keys.firstOrNull { it.startsWith("restriction:") && it.endsWith(":conditional") } + if (conditionalKey != null && !newTags.containsKey(conditionalKey.substringBefore(":conditional"))) { + val old = newTags[conditionalKey]!!.substringBefore("@").trim() + newTags[conditionalKey] = newTags[conditionalKey]!!.replace(old, turnRestrictionTypeList[p2]) + } else { + if (newTags.containsKey("restriction")) + newTags["restriction"] = turnRestrictionTypeList[p2] + else { + val k = newTags.keys.firstOrNull { key -> key.startsWith("restriction:") && onlyTurnRestriction.any { key.endsWith(it) } } + ?: "restriction" + newTags[k] = turnRestrictionTypeList[p2] + } + } + if (newTags != oldRestriction.relation.tags) + currentRestriction = TurnRestriction(oldRestriction.relation.copy(tags = newTags)) + } + override fun onNothingSelected(p0: AdapterView<*>?) { } + } + binding.implicitSwitch.isChecked = true + binding.removeRestriction.setOnClickListener { onClickedDelete() } + } + + private fun getInitialRestriction(): Restriction? { + // prefer supported and complete turn restrictions + originalRestrictions + .firstOrNull { it is TurnRestriction && it.relation.isSupportedTurnRestriction() && it.relation.isRelationComplete() } + ?.let { return it } + + // then weight restriction + originalRestrictions.firstOrNull { it is WeightRestriction }?.let { return it } + + // just take the first one + return originalRestrictions.firstOrNull() + } + + private fun showOtherRestrictionsList() { + val restrictions = originalRestrictions.filterNot { it == selectedRestriction } + if (restrictions.isEmpty()) return // if we show it once, no need to hide again + binding.otherRestrictions.isVisible = true + binding.otherRestrictions.removeAllViews() + binding.otherRestrictions.addView(TextView(requireContext()).apply { + setText(R.string.restriction_overlay_other_restrictions) + }) + for (restriction in restrictions) { + binding.otherRestrictions.addView(Button(requireContext()).apply { + text = when (restriction) { + is TurnRestriction -> restriction.relation.members.filter { it.type == ElementType.WAY && it.ref == element!!.id } + .joinToString(", ") { it.role } + is WeightRestriction -> restriction.weight + } + val drawable = restriction.getDrawable(layoutInflater, countryInfo) + val height = context.resources.dpToPx(56).toInt() + val resizedDrawable = drawable + ?.createBitmap(height, drawable.intrinsicWidth * height / drawable.intrinsicHeight) + ?.toDrawable(context.resources) + + setCompoundDrawablesWithIntrinsicBounds(resizedDrawable, null, null, null) + setOnClickListener { selectedRestriction = restriction } + }) + } + } + + override fun hasChanges(): Boolean = + currentRestriction != null && currentRestriction != selectedRestriction + + override fun isFormComplete(): Boolean { + val restriction = currentRestriction ?: return false + if (!hasChanges()) return false + if (restriction is WeightRestriction && restriction.weight.replace(',', '.').toDoubleOrNull() == null) return false + return true + } + + override fun onClickOk() { + val restriction = currentRestriction ?: return + when (restriction) { + is WeightRestriction -> { + val input = restriction.weight.replace(',', '.').toDouble() + val weight = when (countryInfo.weightLimitUnits[weightUnitSelect?.selectedItemPosition ?: 0]) { + WeightMeasurementUnit.SHORT_TON -> ShortTons(input) + WeightMeasurementUnit.POUND -> ImperialPounds(input.toInt()) + WeightMeasurementUnit.METRIC_TON -> MetricTons(input) + } + val changes = restriction.way.tags.createChanges(element!!.tags) + changes[restriction.sign.osmKey] = weight.toString() + applyEdit(UpdateElementTagsAction(restriction.way, changes.create())) + } + is TurnRestriction -> { + val rel = restriction.relation + val geometry = getGeometry(rel) ?: geometry + if (rel.id == 0L) { + applyEdit(CreateRelationAction(rel.tags, rel.members), geometry) + } else { + val oldRelation = (selectedRestriction as? TurnRestriction)?.relation ?: return + applyEdit(UpdateElementTagsAction(oldRelation, rel.tags.createChanges(oldRelation.tags).create()), geometry) + } + } + } + } + + override fun onClickMapAt(position: LatLon, clickAreaSizeInMeters: Double): Boolean { + if (!turnRestrictionSelectionMode) return false + val bbox = position.enclosingBoundingBox(clickAreaSizeInMeters.coerceAtLeast(10.0)) + val data = mapDataSource.getMapDataWithGeometry(bbox) + val initialWay = element as Way // this must be correct + + // first and last nodes, but only if they are shared by at least 3 roads (otherwise a restriction doesn't make sense) + val firstAndLastNodes = initialWay.nodeIds.firstAndLast().sorted().filter { mapDataSource.getWaysForNode(it).count { it.tags["highway"] in ALL_ROADS } > 2 } + if (firstAndLastNodes.isEmpty()) return true + val eligibleWays = data.ways.mapNotNull { + if (it.id == initialWay.id || it.isClosed) return@mapNotNull null + if (it.tags["highway"] !in ALL_ROADS) return@mapNotNull null + val fl = it.nodeIds.firstAndLast() // exactly one of first and last nodes need to be shared + if (!fl.containsAny(firstAndLastNodes)) return@mapNotNull null + val geometry = data.getWayGeometry(it.id) as? ElementPolylinesGeometry ?: return@mapNotNull null + it to geometry + } + + val otherWay = eligibleWays.minByOrNull { position.distanceToArcs(it.second.polylines.single()) }?.first ?: return true + val initialWayAsMember = RelationMember(initialWay.type, initialWay.id, "from") + val otherWayAsMember = RelationMember(otherWay.type, otherWay.id, "to") + // ignore ways that have same start and end points + val viaNode = otherWay.nodeIds.firstAndLast().singleOrNull { it in firstAndLastNodes }?.let { mapDataSource.getNode(it) } ?: return true + val viaNodeAsMember = RelationMember(viaNode.type, viaNode.id, "via") + val newTags = (currentRestriction as? TurnRestriction)?.relation?.tags?.toMutableMap() ?: mutableMapOf() + newTags["type"] = "restriction" + if (!binding.implicitSwitch.isChecked) + newTags["explicit"] = "yes" + newTags["restriction"] = turnRestrictionTypeList[binding.turnRestrictionTypeSpinner.selectedItemPosition] + val newRelation = Relation(0L, listOf(initialWayAsMember, otherWayAsMember, viaNodeAsMember), newTags) + binding.swapFromToRoles.isVisible = true + currentRestriction = TurnRestriction(newRelation) + return true + } + + private fun onClickAddRestriction() { + val res = listOf( + Item2(RestrictionType.TURN, ResImage(R.drawable.ic_overlay_restriction)), + Item2(RestrictionType.WEIGHT, ResImage(R.drawable.ic_quest_max_weight)), + ) + ImageListPickerDialog(requireContext(), res) { + when (it.value) { + RestrictionType.TURN -> turnRestrictionSelectionMode = true + RestrictionType.WEIGHT -> { + val items = MaxWeightSign.entries.mapNotNull { sign -> + if (originalRestrictions.any { it is WeightRestriction && it.sign == sign }) + null + else sign.asItem(layoutInflater, countryInfo.countryCode) + } + ImageListPickerDialog(requireContext(), items) { sign -> + currentRestriction = WeightRestriction(element as Way, sign.value!!, "") + val units = countryInfo.weightLimitUnits.map { it.displayString } + weightUnitSelect?.adapter = ArrayAdapter(requireContext(), R.layout.spinner_item_centered, units) + weightUnitSelect?.setSelection(0) + + viewLifecycleScope.launch { + delay(20) + maxWeightInput?.requestFocus() + maxWeightInput?.showKeyboard() + } + }.show() + } + null -> { } + } + }.show() + } + + private fun displayConditionalRestrictions(text: String) { + if (text.isNotBlank()) { + binding.infoText.text = text + binding.infoText.isVisible = true + binding.conditionalButton.setText(R.string.restriction_overlay_remove_conditional_restrictions) + binding.conditionalButton.setOnClickListener { onClickRemoveConditional() } + } else { + binding.infoText.isGone = true + binding.conditionalButton.setOnClickListener { onClickAddConditional() } + binding.conditionalButton.setText(R.string.access_manager_button_add_conditional) + } + } + + private fun onClickRemoveConditional() { + val restriction = currentRestriction ?: return + when (restriction) { + is TurnRestriction -> { + val newTags = restriction.relation.tags.toMutableMap() + val oldConditionalKey = if (newTags.containsKey("restriction:conditional")) "restriction:conditional" + else newTags.keys.firstOrNull { it.startsWith("restriction:") && it.endsWith(":conditional") } ?: "restriction:conditional" + if (!newTags.containsKey(oldConditionalKey.substringBefore(":conditional"))) + newTags[oldConditionalKey.substringBefore(":conditional")] = newTags.getShortRestrictionValue()!! + newTags.remove(oldConditionalKey) + currentRestriction = TurnRestriction(restriction.relation.copy(tags = newTags)) + } + is WeightRestriction -> { + val newTags = restriction.way.tags.toMutableMap() + val weightKey = restriction.sign.osmKey + newTags.remove("$weightKey:conditional") + currentRestriction = WeightRestriction(restriction.way.copy(tags = newTags), restriction.sign, restriction.weight) + } + } + } + + private fun onClickAddConditional() { + val restriction = currentRestriction ?: return + when (restriction) { + is TurnRestriction -> { + val newTags = restriction.relation.tags.toMutableMap() + // either it's only conditional, then value is same as restriction, or it's an exception then it's "none" + val restrictionKey = if (newTags.containsKey("restriction")) "restriction" + else newTags.keys.firstOrNull { key -> onlyTurnRestriction.any { key =="restriction:$it" } } ?: "restriction" + val values = newTags[restrictionKey]?.let { listOf(it, "none") } + ?: listOf(turnRestrictionTypeList[binding.turnRestrictionTypeSpinner.selectedItemPosition]) + showAddConditionalDialog(requireContext(), listOf("$restrictionKey:conditional"), values, null) { _, v -> + newTags["$restrictionKey:conditional"] = v + if (!v.startsWith("none")) newTags.remove(restrictionKey) + currentRestriction = TurnRestriction(restriction.relation.copy(tags = newTags)) + } + } + is WeightRestriction -> { + val weightKey = restriction.sign.osmKey + val newTags = restriction.way.tags.toMutableMap() + // no values because it needs to be free-form (enter weight, maybe unit for some country, also none + // -> can't set number-only input type + showOtherConditionalDialog(requireContext(), listOf("$weightKey:conditional"), null, null) { _, v -> + newTags["$weightKey:conditional"] = v + if (!v.startsWith("none")) newTags.remove(weightKey) + currentRestriction = WeightRestriction(restriction.way.copy(tags = newTags), restriction.sign, restriction.weight) + } + } + } + } + + private fun onClickedDelete() { + val restriction = currentRestriction ?: return + when (restriction) { + is TurnRestriction -> { + if (restriction.relation.id == 0L) return + AlertDialog.Builder(requireContext()) + .setMessage(R.string.quest_generic_confirmation_title) + .setPositiveButton(R.string.osm_element_gone_confirmation) { _, _ -> + applyEdit(DeleteRelationAction(restriction.relation), getGeometry(restriction.relation) ?: geometry) + } + .setNeutralButton(R.string.leave_note) { _, _ -> composeNote(restriction.relation) } + .show() + } + is WeightRestriction -> { + // delete this and conditional tags, but only apply if there are actually changes + val changes = restriction.way.tags.createChanges(element!!.tags) + changes.remove(restriction.sign.osmKey) + changes.keys.filter { it.startsWith("${restriction.sign.osmKey}:") }.forEach { + changes.remove(it) + } + if (!changes.hasChanges) return + + AlertDialog.Builder(requireContext()) + .setMessage(R.string.quest_generic_confirmation_title) + .setPositiveButton(R.string.quest_generic_confirmation_yes) { _, _ -> + applyEdit(UpdateElementTagsAction(restriction.way, changes.create())) + } + .setNeutralButton(R.string.leave_note) { _, _ -> composeNote(restriction.way) } + .show() + } + } + } + + // ---------------- weight restriction ---------------------- + + private fun showWeightRestrictionUi(restriction: WeightRestriction) { + binding.turnRestrictionContainer.isGone = true + binding.maxWeightContainer.isVisible = true + binding.maxWeightContainer.removeAllViews() + val item = restriction.sign.asItem(layoutInflater, countryInfo.countryCode) + layoutInflater.inflate(item.value!!.getLayoutResourceId(countryInfo.countryCode), binding.maxWeightContainer) + val units = countryInfo.weightLimitUnits + weightUnitSelect?.adapter = ArrayAdapter(requireContext(), R.layout.spinner_item_centered, units.map { it.displayString }) + weightUnitSelect?.setSelection(0) + if (restriction.weight.toDoubleOrNull() == null) { + when { + restriction.weight.replace(',', '.').toDoubleOrNull() != null -> + maxWeightInput?.setText(restriction.weight.replace(',', '.')) + restriction.weight.endsWith("lbs") -> { + val w = restriction.weight.substringBefore("lbs").trim() + if (w.toDoubleOrNull() != null) { + maxWeightInput?.setText(w) + val idx = units.indexOfFirst { it == WeightMeasurementUnit.POUND } + if (idx != -1) + weightUnitSelect?.setSelection(idx) + } + } + restriction.weight.endsWith("st") -> { + val w = restriction.weight.substringBefore("st").trim() + if (w.toDoubleOrNull() != null) { + maxWeightInput?.setText(w) + val idx = units.indexOfFirst { it == WeightMeasurementUnit.SHORT_TON } + if (idx != -1) + weightUnitSelect?.setSelection(idx) + } + } + restriction.weight.endsWith("t") -> { + val w = restriction.weight.substringBefore("t").trim() + if (w.toDoubleOrNull() != null) + maxWeightInput?.setText(w) + } + else -> { } // don't fill unrecognized values + } + } else maxWeightInput?.setText(restriction.weight) + maxWeightInput?.filters = arrayOf(acceptDecimalDigits(6, 2)) + binding.maxWeightContainer.setOnClickListener { + maxWeightInput?.requestFocus() + maxWeightInput?.showKeyboard() + } + maxWeightInput?.doAfterTextChanged { + currentRestriction = WeightRestriction(restriction.way, restriction.sign, it.toString()) + } + + // show other restrictions based on this maxweight key + val restrictionInfo = restriction.way.tags + .filterKeys { it.startsWith("${restriction.sign.osmKey}:") } + .map { "${it.key} = ${it.value}" } + .joinToString("\n") + displayConditionalRestrictions(restrictionInfo) + + // todo: only-button for maxweight:hgv and stuff + // but needs to work a bit different than for turn because of the maxweight keys + } + + // ---------------- turn restriction ---------------------- + + private fun showFullTurnRestrictionUi(restriction: TurnRestriction) { + binding.turnRestrictionContainer.isVisible = true + binding.maxWeightContainer.isGone = true + + // set up switch + binding.implicitSwitch.isChecked = restriction.relation.tags["implicit"] != "yes" + binding.implicitSwitch.setOnCheckedChangeListener { _, b -> + val oldRestriction = currentRestriction as? TurnRestriction ?: return@setOnCheckedChangeListener + val newTags = oldRestriction.relation.tags.toMutableMap() + if (b) + newTags.remove("implicit") + else newTags["implicit"] = "yes" + currentRestriction = TurnRestriction(oldRestriction.relation.copy(tags = newTags)) + } + + // set spinner value + val idx = turnRestrictionTypeList.indexOf(restriction.relation.tags.getShortRestrictionValue()) + if (idx != binding.turnRestrictionTypeSpinner.selectedItemPosition) { + // if -1 selected (unknown restriction), view gets very small... not nice, but not worth the work + // without the post it doesn't work... though it used to work in a previous version of the form, wtf? + binding.turnRestrictionTypeSpinner.post { binding.turnRestrictionTypeSpinner.setSelection(idx) } + } + + // todo: switching roles for an existing relation requires another new action... + // maybe do that later, but currently this is only allowed when adding a new relation + binding.swapFromToRoles.setOnClickListener { + val rel = (currentRestriction as? TurnRestriction)?.relation ?: return@setOnClickListener + val newMembers = rel.members.map { when (it.role) { + "from" -> it.copy(role = "to") + "to" -> it.copy(role = "from") + else -> it + } } + currentRestriction = TurnRestriction(rel.copy(members = newMembers)) + } + binding.exceptions.setOnClickListener { showTurnRestrictionExceptionsDialog() } + + // set exceptions + val args = restriction.relation.tags["except"]?.replace(";", ", ") ?: getString(R.string.overlay_none) + binding.exceptions.text = getString(R.string.restriction_overlay_exceptions, args) + + // show other restriction parts like conditional or unknown values + val restrictionInfo = restriction.relation.tags + .filterKeys { key -> key.startsWith("restriction:") && onlyTurnRestriction.none { key.endsWith(it) } } + .map { "${it.key} = ${it.value}" } + .joinToString("\n") + displayConditionalRestrictions(restrictionInfo) + + // show only-restriction (restriction:hgv and similar) + binding.onlyButton.isVisible = true + val onlyFor = restriction.relation.tags.keys + .firstOrNull { it.startsWith("restriction") && it.substringAfter("restriction:").substringBefore(":conditional") in onlyTurnRestriction } + val onlyForText = onlyFor?.substringAfter("restriction:")?.substringBefore(":conditional") ?: "-" + binding.onlyButton.text = getString(R.string.restriction_overlay_only_for, onlyForText) + binding.onlyButton.setOnClickListener { + // move restriction and restriction:conditional + val d = AlertDialog.Builder(requireContext()) + .setSingleChoiceItems(onlyTurnRestriction.toTypedArray(), onlyTurnRestriction.indexOf(onlyFor)) { d, i -> + // using tags may not have been the best decision here... but whatever + val newOnly = onlyTurnRestriction[i] + val switchFrom = onlyFor?.let { ":$it" } ?: "" + val newTags = restriction.relation.tags.toMutableMap() + newTags.remove("restriction$switchFrom")?.let { newTags["restriction:$newOnly"] = it } + newTags.remove("restriction$switchFrom:conditional")?.let { newTags["restriction:$newOnly:conditional"] = it } + d.dismiss() + currentRestriction = TurnRestriction(restriction.relation.copy(tags = newTags)) + } + .setNegativeButton(android.R.string.cancel, null) + if (onlyFor != null) + d.setNeutralButton(R.string.delete_confirmation) { _, _ -> + val newTags = restriction.relation.tags.toMutableMap() + newTags.remove("restriction:$onlyFor")?.let { newTags["restriction"] = it } + newTags.remove("restriction:$onlyFor:conditional")?.let { newTags["restriction:conditional"] = it } + currentRestriction = TurnRestriction(restriction.relation.copy(tags = newTags)) + } + d.show() + } + } + + // get bearing of last segment of "from" member for via icon + private fun showTurnRestrictionOnMap(restriction: TurnRestriction) { + val members = restriction.relation.members.map { it.role to (mapDataSource.get(it.type, it.ref) ?: return) } + val viaMembers = members.filter { it.first == "via" }.map { it.second } + val from = members.singleOrNull { it.first == "from" }?.second as? Way ?: return // only one from member supported + val isFirst = viaMembers.any { it is Node && it.id == from.nodeIds.first() || it is Way && it.nodeIds.firstAndLast().contains(from.nodeIds.first()) } + val isLast = viaMembers.any { it is Node && it.id == from.nodeIds.last() || it is Way && it.nodeIds.firstAndLast().contains(from.nodeIds.last()) } + if (isFirst == isLast) return // should not happen + val nodeIdsForBearing = if (isFirst) from.nodeIds.take(2).reversed() else from.nodeIds.takeLast(2) + val nodesForBearing = nodeIdsForBearing.map { mapDataSource.getNode(it)!! } + val bearing = nodesForBearing.first().position.finalBearingTo(nodesForBearing.last().position) + via = nodesForBearing.last().position to bearing + } + + private fun showUnsupportedTurnRestriction(restriction: TurnRestriction) { + val rel = restriction.relation + binding.turnRestrictionContainer.isVisible = true + binding.maxWeightContainer.isGone = true + binding.infoText.isVisible = true + + if (rel.isRelationComplete()) { + binding.infoText.text = getString(R.string.restriction_overlay_relation_unsupported, rel.getDetailsText()) + } else { + binding.infoText.setText(R.string.restriction_overlay_relation_incomplete) + } + } + + private fun showTurnRestrictionExceptionsDialog() { + val restriction = currentRestriction as? TurnRestriction ?: return + val selectedExceptions = restriction.relation.tags["except"]?.split(";").orEmpty() + val selected = exceptions.map { it in selectedExceptions } + val newSelected = selected.toMutableList() + AlertDialog.Builder(requireContext()) + .setMultiChoiceItems(exceptions.toTypedArray(), selected.toBooleanArray()) { _, i, s -> + newSelected[i] = s + } + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + if (selected == newSelected) return@setPositiveButton + val newTags = restriction.relation.tags.toMutableMap() + newTags["except"] = newSelected.zip(exceptions).mapNotNull { if (it.first) it.second else null }.joinToString(";") + currentRestriction = TurnRestriction(restriction.relation.copy(tags = newTags)) + } + .show() + } + + // ---------------- other answers ---------------------- + + private fun relationDetailsAnswer(): AnswerItem? { + val restriction = currentRestriction as? TurnRestriction ?: return null + return if (restriction.relation.id == 0L) null + else AnswerItem(R.string.restriction_overlay_show_details) { + AlertDialog.Builder(requireContext()) + .setMessage(restriction.relation.getDetailsText()) + .setPositiveButton(android.R.string.ok, null) + .setNeutralButton(R.string.quest_generic_answer_show_edit_tags) { _, _ -> + editTags(restriction.relation, elementGeometry = getGeometry(restriction.relation), editTypeName = overlay.name) + } + .show() + } + } + + // ---------------- relation stuff used for turn restriction ---------------------- + + private fun getGeometry(rel: Relation): ElementGeometry? { + if (rel.id != 0L) + return mapDataSource.getGeometry(rel.type, rel.id) + val ways = rel.members.mapNotNull { if (it.type == ElementType.WAY) it.key else null } + return ElementGeometryCreator().create(rel, mapDataSource.getGeometries(ways).associate { it.elementId to (it.geometry as ElementPolylinesGeometry).polylines.single() }) + } + + private fun Relation.isRelationComplete(): Boolean = + members.all { mapDataSource.get(it.type, it.ref) != null } + + private fun Relation.getDetailsText(): String { + val tagsText = tags.entries.sortedBy { it.key }.joinToString("\n") { "${it.key} = ${it.value}" } + val membersText = members.joinToString("\n") { member -> + val element = mapDataSource.get(member.type, member.ref)!! + val memberDetails = getNameAndLocationSpanned(element, resources, featureDictionary, false) + ?.let { "${element.key}: $it" } ?: element.key.toString() + "${member.role}: $memberDetails" + } + return "$tagsText\n\n$membersText" + } +} + +// accessing restrictionTypes by index is absurdly complicated... the set is ordered, so wtf? +private val turnRestrictionTypeList = turnRestrictionTypes.toList() + +// most used according to taginfo +private val exceptions = listOf( + "bicycle", "psv", "bus", "emergency", "agricultural", "hgv", "moped", "destination", "motorcar" +) + +// restriction:* list from wiki +private val onlyTurnRestriction = listOf( + "hgv", "caravan", "motorcar", "bus", "agricultural", "motorcycle", "bicycle", "hazmat" +) + +val onlyTurnRestrictionSet = onlyTurnRestriction.toHashSet() + +// actually this may be country specific! +private fun getIconForTurnRestriction(type: String?) = when(type) { + "no_right_turn" -> R.drawable.ic_restriction_no_right_turn + "no_left_turn" -> R.drawable.ic_restriction_no_left_turn + "no_u_turn" -> R.drawable.ic_restriction_no_u_turn + "no_straight_on" -> R.drawable.ic_restriction_no_straight_on + "only_right_turn" -> R.drawable.ic_restriction_only_right_turn + "only_left_turn" -> R.drawable.ic_restriction_only_left_turn + "only_straight_on" -> R.drawable.ic_restriction_only_straight_on + else -> R.drawable.ic_restriction_unknown // currently the note icon, but half size +} + +fun Map.getShortRestrictionValue(): String? { + get("restriction")?.let { return it } + get("restriction:conditional")?.let { return it.substringBefore("@").trim() } + entries.firstOrNull { it.key.startsWith("restriction:") }?.let { return it.value.substringBefore("@").trim() } // restriction:hgv and similar, may be conditional + return null +} + +// todo: switch form changing tags to sth else, this is getting way too complicated to handle +private sealed interface Restriction { + val type: RestrictionType + val element: Element + fun getDrawable(inflater: LayoutInflater, countryInfo: CountryInfo): Drawable? +} + +private data class TurnRestriction(val relation: Relation) : Restriction { + override val type = RestrictionType.TURN + override val element get () = relation + override fun getDrawable(inflater: LayoutInflater, countryInfo: CountryInfo) = + ContextCompat.getDrawable(inflater.context, getIconForTurnRestriction(relation.tags.getShortRestrictionValue())) +} + +private data class WeightRestriction(val way: Way, val sign: MaxWeightSign, val weight: String) : Restriction { + override val type = RestrictionType.WEIGHT + override val element get () = way + override fun getDrawable(inflater: LayoutInflater, countryInfo: CountryInfo) = + (sign.asItem(inflater, countryInfo.countryCode).image as? DrawableImage)?.drawable +} + +private enum class RestrictionType { TURN, WEIGHT } + +private fun getWeightRestrictions(way: Way): List { + val restrictions = mutableListOf() + for (sign in MaxWeightSign.entries) { + val key = if (way.tags.containsKey(sign.osmKey)) sign.osmKey + else way.tags.keys.firstOrNull { it == "${sign.osmKey}:conditional" } ?: continue + val weight = way.tags[key]!! + restrictions.add(WeightRestriction(way, sign, weight)) + } + return restrictions +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/overlays/surface/SurfaceOverlay.kt b/app/src/main/java/de/westnordost/streetcomplete/overlays/surface/SurfaceOverlay.kt index 4181cfb64c6..d3a72df8581 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/overlays/surface/SurfaceOverlay.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/overlays/surface/SurfaceOverlay.kt @@ -108,7 +108,7 @@ private val Surface.color get() = when (this) { -> Color.AQUAMARINE COMPACTED, FINE_GRAVEL -> Color.TEAL - DIRT, SOIL, EARTH, MUD, GROUND, WOODCHIPS + DIRT, SOIL, EARTH, MUD, GROUND, WOODCHIPS, STEPPING_STONES -> Color.ORANGE GRASS -> Color.LIME // greenish colour for grass is deliberate diff --git a/app/src/main/java/de/westnordost/streetcomplete/overlays/things/ThingsOverlayForm.kt b/app/src/main/java/de/westnordost/streetcomplete/overlays/things/ThingsOverlayForm.kt index 539f202cdf8..2cfdce2106a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/overlays/things/ThingsOverlayForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/overlays/things/ThingsOverlayForm.kt @@ -113,7 +113,9 @@ class ThingsOverlayForm : AbstractOverlayForm() { featureCtrl.feature?.name, { it.toElement().isThing() }, ::onSelectedFeature, - POPULAR_THING_FEATURE_IDS + POPULAR_THING_FEATURE_IDS, + false, + geometry.center ).show() } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/AAddLocalizedNameForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/AAddLocalizedNameForm.kt index 81af74b7281..7368206f6d4 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/AAddLocalizedNameForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/AAddLocalizedNameForm.kt @@ -15,7 +15,6 @@ import de.westnordost.streetcomplete.osm.LocalizedName import de.westnordost.streetcomplete.view.AdapterDataChangedWatcher import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import org.koin.android.ext.android.inject import java.util.Queue abstract class AAddLocalizedNameForm : AbstractOsmQuestForm() { @@ -23,8 +22,6 @@ abstract class AAddLocalizedNameForm : AbstractOsmQuestForm() { protected abstract val addLanguageButton: View protected abstract val namesList: RecyclerView - private val prefs: Preferences by inject() - open val adapterRowLayoutResId = R.layout.row_localizedname protected var adapter: LocalizedNameAdapter? = null diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/AGroupedImageListQuestForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/AGroupedImageListQuestForm.kt index 85b0a607e8a..083c28131b2 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/AGroupedImageListQuestForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/AGroupedImageListQuestForm.kt @@ -12,7 +12,6 @@ import de.westnordost.streetcomplete.databinding.QuestGenericListBinding import de.westnordost.streetcomplete.util.takeFavourites import de.westnordost.streetcomplete.view.image_select.GroupableDisplayItem import de.westnordost.streetcomplete.view.image_select.GroupedImageSelectAdapter -import org.koin.android.ext.android.inject /** * Abstract class for quests with a grouped list of images and one to select. @@ -24,8 +23,6 @@ abstract class AGroupedImageListQuestForm : AbstractOsmQuestForm() { final override val contentLayoutResId = R.layout.quest_generic_list private val binding by contentViewBinding(QuestGenericListBinding::bind) - private val prefs: Preferences by inject() - override val defaultExpanded = false protected lateinit var imageSelector: GroupedImageSelectAdapter @@ -92,7 +89,7 @@ abstract class AGroupedImageListQuestForm : AbstractOsmQuestForm() { private fun getInitialItems(): List> = prefs.getLastPicked(this::class.simpleName!!) .map { itemsByString[it] } - .takeFavourites(n = 6, history = 50, first = 1, pad = topItems) + .takeFavourites(n = 6, history = 50, first = 2, pad = topItems) override fun onClickOk() { val item = selectedItem!! diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/AImageListQuestForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/AImageListQuestForm.kt index 13812ef1da3..10f28e68ebc 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/AImageListQuestForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/AImageListQuestForm.kt @@ -4,13 +4,14 @@ import android.os.Bundle import android.view.View import androidx.core.view.isGone import androidx.recyclerview.widget.GridLayoutManager +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.databinding.QuestGenericListBinding +import de.westnordost.streetcomplete.util.logs.Log import de.westnordost.streetcomplete.util.takeFavourites import de.westnordost.streetcomplete.view.image_select.DisplayItem import de.westnordost.streetcomplete.view.image_select.ImageSelectAdapter -import org.koin.android.ext.android.inject /** * Abstract class for quests with a list of images and one or several to select. @@ -26,8 +27,6 @@ abstract class AImageListQuestForm : AbstractOsmQuestForm() { final override val contentLayoutResId = R.layout.quest_generic_list private val binding by contentViewBinding(QuestGenericListBinding::bind) - private val prefs: Preferences by inject() - override val defaultExpanded = false protected open val descriptionResId: Int? = null @@ -102,10 +101,10 @@ abstract class AImageListQuestForm : AbstractOsmQuestForm() { override fun isFormComplete() = imageSelector.selectedIndices.isNotEmpty() private fun moveFavouritesToFront(originalList: List>): List> { - if (originalList.size > itemsPerRow && moveFavoritesToFront) { + if (originalList.size > prefs.getInt(Prefs.FAVS_FIRST_MIN_LINES, 1) * 2 * itemsPerRow && moveFavoritesToFront) { val favourites = prefs.getLastPicked(this::class.simpleName!!) .map { itemsByString[it] } - .takeFavourites(n = itemsPerRow, history = 50) + .takeFavourites(n = 2 * itemsPerRow, history = 50, first = 2) return (favourites + originalList).distinct() } else { return originalList diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/AMultiValueQuestForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/AMultiValueQuestForm.kt new file mode 100644 index 00000000000..8263c83c572 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/AMultiValueQuestForm.kt @@ -0,0 +1,123 @@ +package de.westnordost.streetcomplete.quests + +import android.os.Bundle +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.TextView +import androidx.core.view.doOnLayout +import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.QuestMultiValueBinding +import de.westnordost.streetcomplete.util.ktx.dpToPx +import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope +import de.westnordost.streetcomplete.util.takeFavourites +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** form for adding multiple values to a single key */ +abstract class AMultiValueQuestForm : AbstractOsmQuestForm() { + + override val contentLayoutResId = R.layout.quest_multi_value + private val binding by contentViewBinding(QuestMultiValueBinding::bind) + + /** convert the multi-value string answer to type T */ + abstract fun stringToAnswer(answerString: String): T + + /** + * provide suggestions, loaded once and stored in companion object + * shown below all other suggestions + */ + abstract fun getConstantSuggestions(): Collection + + /** + * provide suggestions, loaded every time the form is opened + * shown above all other suggestions + */ + open fun getVariableSuggestions(): Collection = emptyList() + + /** text for the addValueButton */ + abstract val addAnotherValueResId: Int + + open val onlyAllowSuggestions = false + + private val values = mutableSetOf() + + private val value get() = binding.valueInput.text?.toString().orEmpty().trim() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.addValueButton.setText(addAnotherValueResId) + + binding.valueInput.setAdapter( + ArrayAdapter( + requireContext(), + android.R.layout.simple_dropdown_item_1line, + (getVariableSuggestions() + lastPickedAnswers + getSuggestions()).distinct() + ) + ) + binding.valueInput.onItemClickListener = AdapterView.OnItemClickListener { _, t, _, _ -> + val value = (t as? TextView)?.text?.toString() ?: return@OnItemClickListener + addValue(value) + } + + binding.valueInput.doAfterTextChanged { + if (it.toString().endsWith("\n")) + addValue(it.toString()) + checkIsFormComplete() + } + binding.valueInput.doOnLayout { binding.valueInput.dropDownWidth = binding.valueInput.width - requireContext().resources.dpToPx(60).toInt() } + + binding.addValueButton.setOnClickListener { + if (!isFormComplete() || binding.valueInput.text.isBlank()) return@setOnClickListener + addValue(value) + } + showSuggestions() + } + + override fun onClickOk() { + values.removeAll { it.isBlank() } + if (values.isNotEmpty()) prefs.addLastPicked(javaClass.simpleName, values.toList()) + if (value.isNotBlank()) prefs.addLastPicked(javaClass.simpleName, value) + if (value.isBlank()) + applyAnswer(stringToAnswer(values.joinToString(";"))) + else + applyAnswer(stringToAnswer((values + listOf(value)).joinToString(";"))) + } + + override fun isFormComplete() = (value.isNotBlank() || values.isNotEmpty()) && !value.contains(";") + && !values.contains(value) + && (!onlyAllowSuggestions || (values.all { getSuggestions().contains(it) } && (getSuggestions().contains(value) || value.isBlank()))) + + private fun addValue(value: String) { + val modifiedValue = value.trim() + if (modifiedValue.isEmpty()) return + if (!values.add(modifiedValue)) return // we don't want duplicates + onAddedValue(modifiedValue) + } + + private fun onAddedValue(value: String) { + binding.currentValues.text = values.joinToString(";") + binding.valueInput.text.clear() + (binding.valueInput.adapter as ArrayAdapter).remove(value) + showSuggestions() + } + + private val lastPickedAnswers by lazy { + prefs.getLastPicked(javaClass.simpleName).takeFavourites(20, 50, 1) + } + + private fun showSuggestions() { + viewLifecycleScope.launch { + delay(30) // delay, because otherwise it sometimes doesn't work properly + binding.valueInput.showDropDown() + } + } + + private fun getSuggestions(): Collection = + suggestions.getOrPut(this::class.simpleName!!) { getConstantSuggestions() } + + companion object { + private val suggestions = hashMapOf>() + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/ANameWithSuggestionsForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/ANameWithSuggestionsForm.kt index cd7702d1b25..6161fb197b7 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/ANameWithSuggestionsForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/ANameWithSuggestionsForm.kt @@ -11,7 +11,7 @@ import de.westnordost.streetcomplete.util.ktx.nonBlankTextOrNull abstract class ANameWithSuggestionsForm : AbstractOsmQuestForm() { final override val contentLayoutResId = R.layout.quest_name_suggestion - private val binding by contentViewBinding(QuestNameSuggestionBinding::bind) + protected val binding by contentViewBinding(QuestNameSuggestionBinding::bind) protected val name get() = binding.nameInput.nonBlankTextOrNull diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/AStreetSideSelectForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/AStreetSideSelectForm.kt index 4ae2bf03260..3da1f2150cc 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/AStreetSideSelectForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/AStreetSideSelectForm.kt @@ -10,7 +10,6 @@ import de.westnordost.streetcomplete.util.math.getOrientationAtCenterLineInDegre import de.westnordost.streetcomplete.view.ResImage import de.westnordost.streetcomplete.view.controller.StreetSideDisplayItem import de.westnordost.streetcomplete.view.controller.StreetSideSelectWithLastAnswerButtonViewController -import org.koin.android.ext.android.inject /** Abstract base class for any quest answer form in which the user selects items for the left and * the right side of the street */ @@ -19,8 +18,6 @@ abstract class AStreetSideSelectForm : AbstractOsmQuestForm() { override val contentLayoutResId = R.layout.quest_street_side_puzzle_with_last_answer_button private val binding by contentViewBinding(QuestStreetSidePuzzleWithLastAnswerButtonBinding::bind) - private val prefs: Preferences by inject() - override val contentPadding = false protected lateinit var streetSideSelect: StreetSideSelectWithLastAnswerButtonViewController diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/AbstractExternalSourceQuestForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/AbstractExternalSourceQuestForm.kt new file mode 100644 index 00000000000..1059e511ec4 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/AbstractExternalSourceQuestForm.kt @@ -0,0 +1,218 @@ +package de.westnordost.streetcomplete.quests + +import android.os.Bundle +import android.view.Menu +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.appcompat.app.AlertDialog +import androidx.core.view.children +import de.westnordost.osmfeatures.FeatureDictionary +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction +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.geometry.ElementPointGeometry +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.externalsource.ExternalSourceQuestController +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestType +import de.westnordost.streetcomplete.data.location.RecentLocationStore +import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey +import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenController +import de.westnordost.streetcomplete.util.getNameAndLocationSpanned +import de.westnordost.streetcomplete.util.ktx.isSplittable +import de.westnordost.streetcomplete.util.ktx.popIn +import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope +import de.westnordost.streetcomplete.view.checkIsSurvey +import de.westnordost.streetcomplete.view.confirmIsSurvey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject +import org.koin.core.qualifier.named + +abstract class AbstractExternalSourceQuestForm : AbstractQuestForm(), IsShowingQuestDetails { + // overridable by child classes + open val otherAnswers = listOf() + open val buttonPanelAnswers = listOf() + private val elementEditsController: ElementEditsController by inject() + private val otherQuestController: ExternalSourceQuestController by inject() + protected val mapDataSource: MapDataWithEditsSource by inject() + private val featureDictionary: Lazy by inject(named("FeatureDictionaryLazy")) + private val recentLocationStore: RecentLocationStore by inject() + private val questsHiddenController: QuestsHiddenController by inject() + + protected var element: Element? = null + private val dummyElement by lazy { Node(0, LatLon(0.0, 0.0)) } + private val externalQuestType by lazy { questType as ExternalSourceQuestType } + + private val listener: AbstractOsmQuestForm.Listener? get() = parentFragment as? AbstractOsmQuestForm.Listener ?: activity as? AbstractOsmQuestForm.Listener + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (prefs.getBoolean(Prefs.SHOW_HIDE_BUTTON, false)) { + floatingBottomView2.popIn() + floatingBottomView2.setOnClickListener { + tempHideQuest() + } + floatingBottomView2.setOnLongClickListener { + hideQuest() + true + } + } + // set element if available + otherQuestController.get(questKey as ExternalSourceQuestKey)?.elementKey?.let { key -> + element = mapDataSource.get(key.type, key.id) + } + setObjNote(element?.tags?.get("note"), element?.tags?.get("fixme") ?: element?.tags?.get("FIXME")) + element?.let { setTitleHintLabel(getNameAndLocationSpanned(it, resources, featureDictionary.value)) } + } + + override fun onStart() { + super.onStart() + updateButtonPanel() + } + + protected fun updateButtonPanel() { + val answers = assembleOtherAnswers() + val otherAnswersItem = if (answers.size == 1) { + answers.single() + } else { + AnswerItem(R.string.quest_generic_otherAnswers) { showOtherAnswers() } + } + setButtonPanelAnswers(listOf(otherAnswersItem) + buttonPanelAnswers) + } + + private fun assembleOtherAnswers(): List { + val answers = mutableListOf() + + answers.add(AnswerItem(R.string.quest_generic_answer_notApplicable) { onClickCantSay() }) + val e = element + if (e != null) { + if ((otherAnswers + buttonPanelAnswers).none { it.titleResourceId == R.string.quest_generic_answer_show_edit_tags }) + answers.add(AnswerItem(R.string.quest_generic_answer_show_edit_tags) { editTags(e) }) + if (e is Node) { + answers.add(AnswerItem(R.string.quest_generic_answer_does_not_exist) { deletePoiNode(e) }) + answers.add(AnswerItem(R.string.move_node) { onClickMoveNodeAnswer() }) + } + if (e.isSplittable()) { + answers.add(AnswerItem(R.string.quest_generic_answer_differs_along_the_way) { onClickSplitWayAnswer() }) + } + } + answers.addAll(otherAnswers) + return answers + } + + protected fun deletePoiNode(node: Node) { + AlertDialog.Builder(requireContext()) + .setMessage(R.string.osm_element_gone_description) + .setPositiveButton(R.string.osm_element_gone_confirmation) { _, _ -> onDeletePoiNodeConfirmed(node) } + .setNeutralButton(R.string.leave_note) { _, _ -> composeNote() } + .show() + } + + private fun onDeletePoiNodeConfirmed(node: Node) { + viewLifecycleScope.launch { editElement(DeletePoiNodeAction(node)) } + } + + private fun onClickMoveNodeAnswer() { + context?.let { AlertDialog.Builder(it) + .setMessage("${getString(R.string.quest_move_node_message)}\n${getString(R.string.move_node_message_external_source_addition)}") + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + listener?.onMoveNode(externalQuestType, element as Node) + } + .show() + } + } + + private fun onClickSplitWayAnswer() { + context?.let { AlertDialog.Builder(it) + .setMessage(R.string.quest_split_way_description) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + listener?.onSplitWay(externalQuestType, element as Way, geometry as ElementPolylinesGeometry) + } + .show() + } + } + + private fun showOtherAnswers() { + val otherAnswersButton = view?.findViewById(R.id.buttonPanel)?.children?.firstOrNull() ?: return + val answers = assembleOtherAnswers() + val popup = PopupMenu(requireContext(), otherAnswersButton) + for (i in answers.indices) { + val otherAnswer = answers[i] + val order = answers.size - i + popup.menu.add(Menu.NONE, i, order, otherAnswer.titleResourceId) + } + popup.show() + + popup.setOnMenuItemClickListener { item -> + answers[item.itemId].action() + true + } + } + + private fun onClickCantSay() { + context?.let { AlertDialog.Builder(it) + .setTitle(R.string.quest_leave_new_note_title) + .setMessage(R.string.quest_leave_new_note_description) + .setNegativeButton(R.string.quest_leave_new_note_no) { _, _ -> hideQuest() } + .setPositiveButton(R.string.quest_leave_new_note_yes) { _, _ -> composeNote() } + .show() + } + } + + protected fun composeNote() { + val questTitle = resources.getString(externalQuestType.title) + val actualTitle = getCurrentTitle() + val show = if (actualTitle.startsWith(questTitle)) actualTitle + else "$questTitle / $actualTitle" // both may contain relevant information + val leaveNoteContext = "Unable to answer \"$show\"" + listener?.onComposeNote(externalQuestType, element ?: dummyElement, geometry, leaveNoteContext) + } + + protected fun tempHideQuest() { + viewLifecycleScope.launch { + withContext(Dispatchers.IO) { questsHiddenController.tempHide(questKey) } + listener?.onQuestHidden(questKey) + } + } + + protected fun hideQuest() { + viewLifecycleScope.launch { + withContext(Dispatchers.IO) { questsHiddenController.hide(questKey as ExternalSourceQuestKey) } + listener?.onQuestHidden(questKey) + } + } + + protected fun editTags(e: Element) { + val geo = if (e is Node) ElementPointGeometry(e.position) else mapDataSource.getGeometry(e.type, e.id) + ?: geometry // fall back to quest geometry for cases where the element has no geometry (e.g. osmose quest for relation containing only node members) + listener?.onEditTags(e, geo, questKey) + } + + protected suspend fun editElement(action: ElementEditAction) { + // currently no way to set source to "survey,extra" because even other answers are likely part of the quest for both existing quests + setLocked(true) + val isSurvey = checkIsSurvey(geometry, recentLocationStore.get()) + if (!isSurvey && !confirmIsSurvey(requireContext())) { + setLocked(false) + return + } + tempHideQuest() // make it disappear. the questType should take care the quest does not appear again + + withContext(Dispatchers.IO) { + elementEditsController.add(externalQuestType, geometry, "survey", action, isSurvey, questKey) + } + listener?.onEdited(externalQuestType, geometry) + } +} 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 9d281a0fa7e..a814f876415 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.quests +import android.app.DatePickerDialog import android.content.res.Configuration import android.content.res.Resources import android.location.Location @@ -7,13 +8,16 @@ import android.os.Bundle import android.view.Menu import android.view.View import android.view.ViewGroup +import android.widget.EditText import android.widget.PopupMenu import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.core.view.children import de.westnordost.osmfeatures.Feature import de.westnordost.osmfeatures.FeatureDictionary +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.location.RecentLocationStore import de.westnordost.streetcomplete.data.osm.edits.AddElementEditsController import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction @@ -31,26 +35,47 @@ 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.OsmElementQuestType +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestController import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsController +import de.westnordost.streetcomplete.data.quest.OsmQuestKey import de.westnordost.streetcomplete.data.quest.QuestKey import de.westnordost.streetcomplete.data.visiblequests.HideQuestController import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenController import de.westnordost.streetcomplete.osm.applyReplacePlaceTo import de.westnordost.streetcomplete.osm.isPlaceOrDisusedPlace +import de.westnordost.streetcomplete.osm.ALL_PATHS +import de.westnordost.streetcomplete.osm.ALL_ROADS +import de.westnordost.streetcomplete.quests.custom.CustomQuestList import de.westnordost.streetcomplete.quests.shop_type.ShopGoneDialog +import de.westnordost.streetcomplete.util.AccessManagerDialog import de.westnordost.streetcomplete.util.getNameAndLocationSpanned +import de.westnordost.streetcomplete.util.accessKeys +import de.westnordost.streetcomplete.util.dialogs.setViewWithDefaultPadding +import de.westnordost.streetcomplete.util.ktx.containsAnyKey +import de.westnordost.streetcomplete.util.ktx.isArea import de.westnordost.streetcomplete.util.ktx.isSplittable +import de.westnordost.streetcomplete.util.ktx.popIn +import de.westnordost.streetcomplete.util.ktx.systemTimeNow +import de.westnordost.streetcomplete.util.ktx.toInstant +import de.westnordost.streetcomplete.util.ktx.toLocalDate import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope +import de.westnordost.streetcomplete.util.logs.Log import de.westnordost.streetcomplete.view.add import de.westnordost.streetcomplete.view.checkIsSurvey import de.westnordost.streetcomplete.view.confirmIsSurvey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import kotlinx.datetime.toJavaLocalDate +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.koin.android.ext.android.inject import org.koin.core.qualifier.named +import java.time.format.DateTimeFormatter import java.util.Locale /** Abstract base class for any bottom sheet with which the user answers a specific quest(ion) */ @@ -63,6 +88,8 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta private val featureDictionaryLazy: Lazy by inject(named("FeatureDictionaryLazy")) private val mapDataWithEditsSource: MapDataWithEditsSource by inject() private val recentLocationStore: RecentLocationStore by inject() + private val customQuestList: CustomQuestList by inject() + private val osmQuestController: OsmQuestController by inject() protected val featureDictionary: FeatureDictionary get() = featureDictionaryLazy.value @@ -86,7 +113,7 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta open val otherAnswers = listOf() open val buttonPanelAnswers = listOf() - interface Listener { + interface Listener { // this is also used in AbstractOtherQuestForm for convenience /** The GPS position at which the user is displayed at */ val displayedMapLocation: Location? @@ -104,6 +131,9 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta /** Called when the user chose to hide the quest instead */ fun onQuestHidden(questKey: QuestKey) + + /** Called when the user chose to edit tags */ + fun onEditTags(element: Element, geometry: ElementGeometry, questKey: QuestKey?, editTypeName: String? = null) } private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener @@ -119,7 +149,18 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta setTitle(getString(osmElementQuestType.getTitle(element.tags))) setTitleHintLabel(getNameAndLocationSpanned(element, resources, featureDictionary)) - setObjNote(element.tags["note"]) + setObjNote(element.tags["note"], element.tags["fixme"] ?: element.tags["FIXME"]) + + if (!TagEditor.showingTagEditor && prefs.getBoolean(Prefs.SHOW_HIDE_BUTTON, false)) { + floatingBottomView2.popIn() + floatingBottomView2.setOnClickListener { + tempHideQuest() + } + floatingBottomView2.setOnLongClickListener { + hideQuest() + true + } + } } override fun onStart() { @@ -135,16 +176,33 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta private fun assembleOtherAnswers(): List { val answers = mutableListOf() + if (TagEditor.showingTagEditor) { + // only allow few of the quest specific other answers in tag edit mode + createItsPrivateAnswer()?.let { answers.add(it) } + answers.addAll(otherAnswers) + return answers + } answers.add(AnswerItem(R.string.quest_generic_answer_notApplicable) { onClickCantSay() }) + if (prefs.getBoolean(Prefs.EXPERT_MODE, false)) + answers.add(AnswerItem(R.string.quest_generic_answer_show_edit_tags) { listener?.onEditTags(element, geometry, questKey) }) + if (element.isSplittable()) { answers.add(AnswerItem(R.string.quest_generic_answer_differs_along_the_way) { onClickSplitWayAnswer() }) } createDeleteOrReplaceElementAnswer()?.let { answers.add(it) } + if (prefs.getBoolean(Prefs.EXPERT_MODE, false)) { + createItsPrivateAnswer()?.let { answers.add(it) } + createItsDemolishedAnswer()?.let { answers.add(it) } + createConstructionAnswer()?.let { answers.add(it) } + createAccessManagerAnswer()?.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()) { + && (prefs.getBoolean(Prefs.EXPERT_MODE, false) || + (mapDataWithEditsSource.getWaysForNode(element.id).isEmpty() + && mapDataWithEditsSource.getRelationsForNode(element.id).isEmpty()) + )) { answers.add(AnswerItem(R.string.move_node) { onClickMoveNodeAnswer() }) } @@ -156,15 +214,12 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta val isDeletePoiEnabled = osmElementQuestType.isDeleteElementEnabled && element.type == ElementType.NODE val isReplacePlaceEnabled = osmElementQuestType.isReplacePlaceEnabled if (!isDeletePoiEnabled && !isReplacePlaceEnabled) return null - check(!(isDeletePoiEnabled && isReplacePlaceEnabled)) { - "Only isDeleteElementEnabled OR isReplaceShopEnabled may be true at the same time" - } return AnswerItem(R.string.quest_generic_answer_does_not_exist) { - if (isDeletePoiEnabled) { + if (isReplacePlaceEnabled) { + replacePlace() // allow both being enabled, but prefer replace over delete + } else { deletePoiNode() - } else if (isReplacePlaceEnabled) { - replacePlace() } } } @@ -187,12 +242,27 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta } protected fun onClickCantSay() { - context?.let { AlertDialog.Builder(it) - .setTitle(R.string.quest_leave_new_note_title) - .setMessage(R.string.quest_leave_new_note_description) - .setNegativeButton(R.string.quest_leave_new_note_no) { _, _ -> hideQuest() } - .setPositiveButton(R.string.quest_leave_new_note_yes) { _, _ -> composeNote() } - .show() + context?.let { + val b = AlertDialog.Builder(it) + .setTitle(R.string.quest_leave_new_note_title) + .setMessage(R.string.quest_leave_new_note_description) + .setNegativeButton(R.string.quest_leave_new_note_no) { _, _ -> hideQuest() } + .setPositiveButton(R.string.quest_leave_new_note_yes) { _, _ -> composeNote() } + if (prefs.getBoolean(Prefs.CREATE_EXTERNAL_QUESTS, false)) + b.setNeutralButton(R.string.create_custom_quest_button) { _, _ -> + val text = EditText(it) + text.isSingleLine = true + AlertDialog.Builder(it) + .setTitle(R.string.create_custom_quest_title_message) + .setViewWithDefaultPadding(text) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + customQuestList.addEntry(element, text.text.toString()) + listener?.onQuestHidden(questKey) // abuse this to close the quest form + } + .show() + } + b.show() } } @@ -218,9 +288,14 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta } } - protected fun applyAnswer(answer: T) { + protected fun applyAnswer(answer: T, extra: Boolean = false) { viewLifecycleScope.launch { - solve(UpdateElementTagsAction(element, createQuestChanges(answer))) + if (TagEditor.showingTagEditor) { + val changesBuilder = StringMapChangesBuilder(element.tags) + osmElementQuestType.applyAnswerTo(answer, changesBuilder, geometry, element.timestampEdited) + TagEditor.changes = changesBuilder.create() + } else + solve(UpdateElementTagsAction(element, createQuestChanges(answer)), extra) } } @@ -246,6 +321,13 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta listener?.onComposeNote(osmElementQuestType, element, geometry, leaveNoteContext) } + protected fun tempHideQuest() { + viewLifecycleScope.launch { + withContext(Dispatchers.IO) { hideQuestController.tempHide(questKey) } + listener?.onQuestHidden(questKey) + } + } + protected fun hideQuest() { viewLifecycleScope.launch { withContext(Dispatchers.IO) { hideQuestController.hide(questKey) } @@ -253,60 +335,167 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta } } - protected fun replacePlace() { + protected fun replacePlace(extra: Boolean = true) { if (element.isPlaceOrDisusedPlace()) { ShopGoneDialog( requireContext(), element, countryOrSubdivisionCode, featureDictionary, - onSelectedFeatureFn = this::onShopReplacementSelected, - onLeaveNoteFn = this::composeNote + onSelectedFeatureFn = { onShopReplacementSelected(it, extra) }, + onLeaveNoteFn = this::composeNote, + geometry.center ).show() } else { composeNote() } } - private fun onShopReplacementSelected(feature: Feature) { + private fun onShopReplacementSelected(feature: Feature, extra: Boolean = true) { viewLifecycleScope.launch { val builder = StringMapChangesBuilder(element.tags) feature.applyReplacePlaceTo(builder) - solve(UpdateElementTagsAction(element, builder.create())) + solve(UpdateElementTagsAction(element, builder.create()), extra) } } - protected fun deletePoiNode() { + protected fun deletePoiNode(extra: Boolean = true) { AlertDialog.Builder(requireContext()) .setMessage(R.string.osm_element_gone_description) - .setPositiveButton(R.string.osm_element_gone_confirmation) { _, _ -> onDeletePoiNodeConfirmed() } + .setPositiveButton(R.string.osm_element_gone_confirmation) { _, _ -> onDeletePoiNodeConfirmed(extra) } .setNeutralButton(R.string.leave_note) { _, _ -> composeNote() } .show() } - private fun onDeletePoiNodeConfirmed() { + private fun onDeletePoiNodeConfirmed(extra: Boolean = true) { viewLifecycleScope.launch { - solve(DeletePoiNodeAction(element as Node)) + solve(DeletePoiNodeAction(element as Node), extra) } } - private suspend fun solve(action: ElementEditAction) { + private fun createItsPrivateAnswer(): AnswerItem? { + return if (elementWithoutAccessTagsFilter.matches(element) && thingsWithMaybeAccessFilter.matches(element)) + AnswerItem(R.string.quest_private) { + viewLifecycleScope.launch { + val builder = StringMapChangesBuilder(element.tags) + builder["access"] = "private" + solve(UpdateElementTagsAction(element, builder.create()), true) + } + } + else null + } + + private fun createAccessManagerAnswer(): AnswerItem? { + if (!"ways with highway ~ ${(ALL_ROADS + ALL_PATHS).joinToString("|")}".toElementFilterExpression().matches(element)) return null + val title = if (element.tags.containsAnyKey(*accessKeys)) + R.string.manage_access + else R.string.add_access + return AnswerItem(title) { + AccessManagerDialog(requireContext(), element.tags) { + viewLifecycleScope.launch { solve(UpdateElementTagsAction(element, it.create()), true) } + }.show() + } + } + + private fun createConstructionAnswer(): AnswerItem? { + if (!elementWithoutAccessTagsFilter.matches(element) + || !element.tags.containsKey("highway") + || element.tags["highway"] == "construction" + ) return null + return AnswerItem(R.string.quest_construction) { + val tomorrow = systemTimeNow().toLocalDate().plus(1, DateTimeUnit.DAY) + val p = DatePickerDialog(requireContext(), { _, y, m, d -> + val finishDate = LocalDate(y, m + 1, d) + val today = systemTimeNow().toLocalDate() + val builder = StringMapChangesBuilder(element.tags) + val diff = finishDate.toEpochDays() - today.toEpochDays() + if (diff <= 0) return@DatePickerDialog // don't even bother to tell the user if they are trying to enter wrong data + + // for short construction up to a few months it's better to use conditional access + // as per https://wiki.openstreetmap.org/wiki/Tag:highway%3Dconstruction + if (diff < 200) { // we arbitrarily set the few months to 200 days + val f = DateTimeFormatter.ofPattern("MMM dd yyyy", Locale.US) + builder["access:conditional"] = "no @ (${f.format(today.toJavaLocalDate())}-${f.format(finishDate.toJavaLocalDate())})" + viewLifecycleScope.launch { solve(UpdateElementTagsAction(element, builder.create()), true) } + } else { + // if we actually change the highway to construction, we let the user set a construction value + val t = EditText(requireContext()).apply { + setText(element.tags["highway"]) + } + val f = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.US) + builder["opening_date"] = f.format(finishDate.toJavaLocalDate()) + builder["highway"] = "construction" + AlertDialog.Builder(requireContext()) + .setTitle(R.string.quest_construction_value) + .setViewWithDefaultPadding(t) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + t.text.toString().takeIf { it.isNotBlank() }?.let { builder["construction"] = it } + viewLifecycleScope.launch { solve(UpdateElementTagsAction(element, builder.create()), true) } + } + .show() + } + }, tomorrow.year, tomorrow.monthNumber - 1, tomorrow.dayOfMonth) + p.datePicker.minDate = tomorrow.toInstant().toEpochMilliseconds() + p.show() + } + } + + private fun createItsDemolishedAnswer(): AnswerItem? { + if (!element.isArea()) return null + return if (demolishableBuildingsFilter.matches(element)) + AnswerItem(R.string.quest_generic_answer_does_not_exist) { + AlertDialog.Builder(requireContext()) + .setItems(arrayOf(requireContext().getString(R.string.quest_building_demolished), requireContext().getString(R.string.leave_note))) { di, i -> + di.dismiss() + if (i == 0) { + viewLifecycleScope.launch { + val builder = StringMapChangesBuilder(element.tags) + builder["demolished:building"] = builder["building"] ?: "yes" + builder.remove("building") + builder.keys.toList().filter { it.matches(Regex("^(building:|roof:).*")) } + .forEach { builder.remove(it) } + solve(UpdateElementTagsAction(element, builder.create()), true) + } + } else { + composeNote() + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + else null + } + + private suspend fun solve(action: ElementEditAction, extra: Boolean = false) { + Log.i(TAG, "solve ${questType.name} for ${element.key}, extra: $extra, in TagEditor: ${TagEditor.showingTagEditor}") + if (TagEditor.showingTagEditor) return + + // really bad hacky way of using separate changesets for some "other answers", + // but doesn't require changing database stuff and commit can be reverted without breaking stuff + val source = if (extra) "survey,extra" else "survey" + setLocked(true) val isSurvey = checkIsSurvey(geometry, recentLocationStore.get()) if (!isSurvey && !confirmIsSurvey(requireContext())) { setLocked(false) return } + if (prefs.getBoolean(Prefs.DYNAMIC_QUEST_CREATION, false)) + // necessary because otherwise pins may remain if quest is not in database + OsmQuestController.lastAnsweredQuestKey = questKey as? OsmQuestKey + + val l = listener // form is closed after adding the edit, so the listener may already be null when called withContext(Dispatchers.IO) { if (action is UpdateElementTagsAction && !action.changes.isValid()) { val questTitle = englishResources.getString(osmElementQuestType.getTitle(element.tags)) val text = createNoteTextForTooLongTags(questTitle, element.type, element.id, action.changes.changes) noteEditsController.add(0, NoteEditAction.CREATE, geometry.center, text) } else { - addElementEditsController.add(osmElementQuestType, geometry, "survey", action, isSurvey) + addElementEditsController.add(osmElementQuestType, geometry, source, action, isSurvey) } } - listener?.onEdited(osmElementQuestType, geometry) + l?.onEdited(osmElementQuestType, geometry) } companion object { @@ -315,5 +504,46 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta fun createArguments(element: Element) = bundleOf( ARG_ELEMENT to Json.encodeToString(element) ) + + // check the most common access tags + val elementWithoutAccessTagsFilter = """ +nodes, ways, relations with + !access + and !access:conditional + and !bicycle + and !bicycle:conditional + and !foot + and !foot:conditional + and !vehicle + and !vehicle:conditional + and !motor_vehicle + and !motor_vehicle:conditional + and !motorcycle + and !motorcycle:conditional + and !horse + and !bus + and !hgv + and !motorcar + and !psv + and !ski + """.toElementFilterExpression() + + // in some cases changing building to demolished:building is not enough + val demolishableBuildingsFilter = """ +ways, relations with building + and building !~ no|construction|ruins|collapsed|damaged|proposed|ruin|destroyed + and !building:demolished + and !building:razed + and !shop and !amenity and !historic and !craft and !healthcare and !office and !attraction and !tourism + """.toElementFilterExpression() + + private val thingsWithMaybeAccessFilter = """ +nodes, ways with + amenity ~ recycling|bicycle_parking|bench|picnic_table + or leisure ~ track|pitch + or highway ~ path|footway + """.toElementFilterExpression() } } + +private const val TAG = "AbstractOsmQuestForm" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/AbstractQuestForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/AbstractQuestForm.kt index 392ad2dc654..78edbe5bd22 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/AbstractQuestForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/AbstractQuestForm.kt @@ -9,6 +9,7 @@ import android.widget.ImageView import androidx.annotation.AnyThread import androidx.core.os.bundleOf import androidx.core.view.isGone +import androidx.core.view.size import androidx.core.widget.NestedScrollView import androidx.viewbinding.ViewBinding import de.westnordost.countryboundaries.CountryBoundaries @@ -60,7 +61,9 @@ abstract class AbstractQuestForm : override val bottomSheetTitle get() = binding.speechBubbleTitleContainer override val bottomSheetContent get() = binding.speechbubbleContentContainer override val floatingBottomView get() = binding.okButtonContainer + override val floatingBottomView2 get() = binding.hideButton protected val scrollView: NestedScrollView get() = binding.scrollView + override val hideButtonBottomMarginDp get() = if (binding.buttonPanel.size > 3) 32 else 8 private var startedOnce = false @@ -138,6 +141,7 @@ abstract class AbstractQuestForm : if (binding.content.childCount == 0) { binding.content.visibility = View.GONE } + } override fun onStart() { @@ -158,6 +162,8 @@ abstract class AbstractQuestForm : binding.titleLabel.text = text } + protected fun getCurrentTitle(): CharSequence = binding.titleLabel.text + protected fun setTitleHintLabel(text: CharSequence?) { binding.titleHintLabel.isGone = text == null binding.titleHintLabel.text = text @@ -169,9 +175,14 @@ abstract class AbstractQuestForm : updateInfoButtonVisibility() } - protected fun setObjNote(text: CharSequence?) { + protected fun setObjNote(text: CharSequence?, fixmeText: CharSequence?) { binding.noteLabel.text = text - binding.speechbubbleNoteContainer.isGone = binding.noteLabel.text.isEmpty() + binding.fixmeLabel.text = if (prefs.expertMode) fixmeText else null + binding.titleNoteLabel.isGone = binding.noteLabel.text.isEmpty() + binding.noteLabel.isGone = binding.noteLabel.text.isEmpty() + binding.titleFixmeLabel.isGone = binding.fixmeLabel.text.isEmpty() + binding.fixmeLabel.isGone = binding.fixmeLabel.text.isEmpty() + binding.speechbubbleNoteContainer.isGone = binding.noteLabel.text.isEmpty() && binding.fixmeLabel.text.isEmpty() } protected fun setHintImages(images: List) { binding.infoPictures.isGone = images.isEmpty() diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/LeaveNoteInsteadFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/LeaveNoteInsteadFragment.kt index 4ab5a5fc61e..76976fc45f9 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/LeaveNoteInsteadFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/LeaveNoteInsteadFragment.kt @@ -1,11 +1,13 @@ package de.westnordost.streetcomplete.quests +import android.annotation.SuppressLint import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.core.view.isGone +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.ApplicationConstants import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.mapdata.ElementType @@ -20,7 +22,6 @@ import de.westnordost.streetcomplete.util.viewBinding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.koin.android.ext.android.inject @@ -39,8 +40,16 @@ class LeaveNoteInsteadFragment : AbstractCreateNoteFragment() { override val bottomSheetTitle get() = binding.speechBubbleTitleContainer override val bottomSheetContent get() = binding.speechbubbleContentContainer override val floatingBottomView get() = binding.okButtonContainer - override val okButton get() = binding.okButton + override val floatingBottomView2 get() = binding.hideButton override val okButtonContainer get() = binding.okButtonContainer + override val gpxButton get() = if (prefs.getBoolean(Prefs.SWAP_GPX_NOTE_BUTTONS, false) && prefs.getBoolean(Prefs.GPX_BUTTON, false)) + binding.okButton + else + binding.hideButton + override val okButton get() = if (prefs.getBoolean(Prefs.SWAP_GPX_NOTE_BUTTONS, false) && prefs.getBoolean(Prefs.GPX_BUTTON, false)) + binding.hideButton + else + binding.okButton private val contentBinding by viewBinding(FormLeaveNoteBinding::bind, R.id.content) @@ -71,11 +80,17 @@ class LeaveNoteInsteadFragment : AbstractCreateNoteFragment() { return binding.root } + @SuppressLint("SetTextI18n") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.buttonPanel.isGone = true contentBinding.descriptionLabel.isGone = true binding.titleLabel.text = getString(R.string.map_btn_create_note) + if (prefs.getBoolean(Prefs.GPX_BUTTON, false)) { + binding.okButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0,0,0,0) + gpxButton.text = "GPX" + okButton.text = "OSM" + } } override fun onDestroyView() { @@ -83,15 +98,15 @@ class LeaveNoteInsteadFragment : AbstractCreateNoteFragment() { _binding = null } - override fun onComposedNote(text: String, imagePaths: List) { + override fun onComposedNote(text: String, imagePaths: List, isGpxNote: Boolean) { val fullText = mutableListOf() leaveNoteContext?.let { fullText += it } fullText += "– https://osm.org/${elementType.name.lowercase()}/$elementId" - fullText += "via ${ApplicationConstants.USER_AGENT}:\n\n$text" + fullText += if (isGpxNote) "\n$text" else "via ${ApplicationConstants.USER_AGENT}:\n\n$text" viewLifecycleScope.launch { withContext(Dispatchers.IO) { - noteEditsController.add(0, NoteEditAction.CREATE, position, fullText.joinToString(" "), imagePaths) + noteEditsController.add(0, NoteEditAction.CREATE, position, fullText.joinToString(" "), imagePaths, emptyList(), isGpxNote, context) } listener?.onCreatedNote(position) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/LocalizedNameAdapter.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/LocalizedNameAdapter.kt index 8f12cf0074c..a3444d3439f 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/LocalizedNameAdapter.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/LocalizedNameAdapter.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.quests import android.content.Context import android.graphics.Typeface import android.os.LocaleList +import android.text.InputType import android.view.LayoutInflater import android.view.Menu.NONE import android.view.View @@ -18,7 +19,9 @@ import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.RecyclerView +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.StreetCompleteApplication import de.westnordost.streetcomplete.data.meta.AbbreviationsByLocale import de.westnordost.streetcomplete.osm.LocalizedName import de.westnordost.streetcomplete.view.controller.AutoCorrectAbbreviationsViewController @@ -320,6 +323,9 @@ class LocalizedNameAdapter( updateNameSuggestions() updateAbbreviations() updateHintLocales(Locale.forLanguageTag(languageTag)) + + if (StreetCompleteApplication.preferences.getBoolean(Prefs.CAPS_WORD_NAME_INPUT, false)) + input.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE or InputType.TYPE_TEXT_FLAG_CAP_WORDS } private fun updateNameSuggestions() { diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/NoAnswerFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/NoAnswerFragment.kt new file mode 100644 index 00000000000..28baef0fe73 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/NoAnswerFragment.kt @@ -0,0 +1,5 @@ +package de.westnordost.streetcomplete.quests + +class NoAnswerFragment : AbstractOsmQuestForm() { + override val buttonPanelAnswers = listOf() +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/QuestSettingsDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/QuestSettingsDialog.kt new file mode 100644 index 00000000000..c8f45fa4a9e --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/QuestSettingsDialog.kt @@ -0,0 +1,284 @@ +package de.westnordost.streetcomplete.quests + +import androidx.appcompat.app.AlertDialog +import android.content.Context +import android.content.SharedPreferences +import android.text.InputType +import android.text.method.LinkMovementMethod +import android.util.TypedValue +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.core.content.edit +import androidx.core.text.HtmlCompat +import androidx.core.widget.doAfterTextChanged +import com.github.difflib.text.DiffRow.Tag +import com.github.difflib.text.DiffRowGenerator +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.ParseException +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestController +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.util.dialogs.setViewWithDefaultPadding +import de.westnordost.streetcomplete.util.ktx.dpToPx +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.regex.PatternSyntaxException + +// restarts are typically necessary on changes of element selection because the filter is created by lazy +// quests settings should follow the pattern: qs__, e.g. "qs_AddLevel_more_levels" +// when to call reloadQuestTypes: if whatever is changed is not read from settings every time, or if dynamic quest creation is enabled + +/** for setting values of a single key, comma separated */ +fun singleTypeElementSelectionDialog( + context: Context, + prefs: SharedPreferences, + pref: String, + defaultValue: String, + messageId: Int, + onChanged: () -> Unit = { OsmQuestController.reloadQuestTypes() } +): AlertDialog { + val textInput = EditText(context) + val dialog = dialog(context, messageId, prefs.getString(pref, defaultValue)!!.replace("|",", "), textInput) + .setPositiveButton(android.R.string.ok) { _, _ -> + val prefText = textInput.text.toString().split(",").joinToString("|") { it.trim() } + if (prefs.getString(pref, defaultValue) == prefText) return@setPositiveButton + prefs.edit().putString(pref, prefText).apply() + onChanged() + } + .setNeutralButton(R.string.quest_settings_reset) { _, _ -> + prefs.edit().remove(pref).apply() + onChanged() + } + .create() + textInput.doAfterTextChanged { + val button = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + button?.isEnabled = textInput.text.toString().let { + it.lowercase().matches(valueRegex) + && !it.trim().endsWith(',') + && !it.contains(",,") + && it.isNotEmpty() } + } + dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.isEnabled = prefs.contains(pref) } + return dialog +} + +/** for setting values of a single number */ +fun numberSelectionDialog(context: Context, prefs: SharedPreferences, pref: String, defaultValue: Int, messageId: Int): AlertDialog { + val numberInput = EditText(context) + numberInput.inputType = InputType.TYPE_CLASS_NUMBER + numberInput.setText(prefs.getInt(pref, defaultValue).toString()) + val dialog = AlertDialog.Builder(context) + .setMessage(messageId) + .setViewWithDefaultPadding(numberInput) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _,_ -> + numberInput.text.toString().toIntOrNull()?.let { + if (it == prefs.getInt(pref, it)) return@setPositiveButton + prefs.edit().putInt(pref, it).apply() + if (prefs.getBoolean(Prefs.DYNAMIC_QUEST_CREATION, false)) + OsmQuestController.reloadQuestTypes() + } + } + .setNeutralButton(R.string.quest_settings_reset) { _, _ -> + prefs.edit().remove(pref).apply() + if (prefs.getBoolean(Prefs.DYNAMIC_QUEST_CREATION, false)) + OsmQuestController.reloadQuestTypes() + } + .create() + numberInput.doAfterTextChanged { + val button = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + button?.isEnabled = numberInput.text.toString().let { it.toIntOrNull() != null } + } + dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.isEnabled = prefs.contains(pref) } + return dialog +} + +/** For setting full element selection. + * This will check validity of input and only allow saving selection can be parsed. + */ +fun fullElementSelectionDialog(context: Context, prefs: SharedPreferences, pref: String, messageId: Int, defaultValue: String): AlertDialog { + val textInput = EditText(context) + val checkPrefix = if (pref.endsWith("_full_element_selection")) "" else "nodes with " + + val message = HtmlCompat.fromHtml(context.getString(messageId), HtmlCompat.FROM_HTML_MODE_LEGACY) + + val dialog = dialog(context, messageId, prefs.getString(pref, defaultValue.trimIndent())!!, textInput) + .setPositiveButton(android.R.string.ok) { _, _ -> + if (textInput.text.toString() == prefs.getString(pref, defaultValue.trimIndent())) return@setPositiveButton + prefs.edit().putString(pref, textInput.text.toString()).apply() + OsmQuestController.reloadQuestTypes() + } + .setNeutralButton(R.string.quest_settings_reset) { _, _ -> + prefs.edit().remove(pref).apply() + OsmQuestController.reloadQuestTypes() + } + .setMessage(message) + .setView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(textInput) + if (prefs.contains(pref)) + addView(getDiffButton(context, defaultValue) { textInput.text.toString() }) + }) + .create() + textInput.doAfterTextChanged { + val button = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + val isValidFilterExpression by lazy { + try { + (checkPrefix + it).toElementFilterExpression() + toastyJob?.cancel() + true + } catch(e: ParseException) { + delayedToast(e.message, context) + false + } catch(e: PatternSyntaxException) { + delayedToast(e.message, context) + false + } + } + button?.isEnabled = textInput.text.toString().let { + // check other stuff first, because creation filter expression is relatively slow + (checkPrefix.isEmpty() || it.lowercase().matches(elementSelectionRegex)) + && it.count { c -> c == '('} == it.count { c -> c == ')'} + && (it.contains('=') || it.contains('~') || it.contains('!')) + && isValidFilterExpression + } + } + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.isEnabled = prefs.contains(pref) // disable reset button if setting is default + dialog.findViewById(android.R.id.message)?.movementMethod = LinkMovementMethod.getInstance() // make the link actually open a browser + } + return dialog +} + +private var toastyJob: Job? = null +private fun delayedToast(message: String?, context: Context) { + toastyJob?.cancel() + toastyJob = GlobalScope.launch(Dispatchers.IO) { + delay(3000) + withContext(Dispatchers.Main) { Toast.makeText(context, "Error: $message", Toast.LENGTH_LONG).show() } + } +} + +private fun getDiffButton(context: Context, defaultText: String, getCurrentText: () -> String) = + Button(context).apply { + setText(R.string.quest_settings_highlight_changes_button) + setOnClickListener { + val drg = DiffRowGenerator.create() + .showInlineDiffs(true) + .mergeOriginalRevised(true) + .inlineDiffByWord(true) + .ignoreWhiteSpaces(true) + .oldTag { f -> if (f) "" else "" } + .newTag { f -> if (f) "" else "" } + .build() + val thatSpace = " " + val newDefault = defaultText.replace("|", "$thatSpace|$thatSpace") // replace with (nearly) invisible space, so word differences are used + val newCurrent = getCurrentText().replace("|", "$thatSpace|$thatSpace") + val diffRows = drg.generateDiffRows(newDefault.split("\n"), newCurrent.split("\n")) + val diffText = diffRows.mapNotNull { + if (it.tag == Tag.EQUAL) return@mapNotNull null + it.oldLine + }.joinToString("
") + AlertDialog.Builder(context) + .setMessage(HtmlCompat.fromHtml(diffText, HtmlCompat.FROM_HTML_MODE_LEGACY)) + .setNegativeButton(R.string.close, null) + .show() + } + } + +fun booleanQuestSettingsDialog(context: Context, prefs: SharedPreferences, pref: String, messageId: Int, answerYes: Int, answerNo: Int): AlertDialog = + AlertDialog.Builder(context) + .setMessage(messageId) + .setNeutralButton(android.R.string.cancel, null) + .setPositiveButton(answerYes) { _,_ -> + prefs.edit().putBoolean(pref, true).apply() + OsmQuestController.reloadQuestTypes() + } + .setNegativeButton(answerNo) { _,_ -> + prefs.edit().putBoolean(pref, false).apply() + OsmQuestController.reloadQuestTypes() + } + .create() + +private fun dialog(context: Context, messageId: Int, initialValue: String, input: EditText): AlertDialog.Builder { + input.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE + val padding = context.resources.dpToPx(8).toInt() + input.setPadding(2 * padding, padding, 2 * padding, padding) // should be less than default padding to allow more text per line + input.setText(initialValue) + input.maxLines = 15 // if lines are not limited, the edit text might get so big that buttons are off screen (thanks, google for allowing this) + return AlertDialog.Builder(context) + .setMessage(messageId) + .setView(input) + .setNegativeButton(android.R.string.cancel, null) +} + +fun getLabelOrElementSelectionDialog(context: Context, questType: OsmFilterQuestType<*>, prefs: SharedPreferences): AlertDialog { + val description = TextView(context).apply { + setText(R.string.quest_settings_dot_labels_message) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f) + } + val prefWithPrefix = getPrefixedLabelSourcePref(questType, prefs) + val labels = EditText(context).apply { + setText(prefs.getString(prefWithPrefix, questType.dotLabelSources.joinToString(", "))) + } + var d: AlertDialog? = null + d = AlertDialog.Builder(context) + .setViewWithDefaultPadding(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(description) + addView(labels) + addView(Button(context).apply { + setText(R.string.element_selection_button) + setOnClickListener { + fullElementSelectionDialog(context, prefs, questType.getPrefixedFullElementSelectionPref(prefs), R.string.quest_settings_element_selection, questType.elementFilter).show() + d?.dismiss() + } + }) + }) + .setPositiveButton(android.R.string.ok) { _, _ -> + labels.text.toString().split(",") + prefs.edit { putString(prefWithPrefix, labels.text.toString()) } + OsmQuestController.reloadQuestTypes() + } + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.quest_settings_reset) { _, _ -> + prefs.edit().remove(prefWithPrefix).apply() + OsmQuestController.reloadQuestTypes() + }.create() + d.setOnShowListener { d.getButton(AlertDialog.BUTTON_NEUTRAL)?.isEnabled = prefs.contains(prefWithPrefix) } // disable reset button if setting is default + return d +} + +fun getLabelSources(defaultValue: String, questType: OsmFilterQuestType<*>, prefs: SharedPreferences) = + prefs.getString(getPrefixedLabelSourcePref(questType, prefs), + defaultValue + )!!.split(",").map { it.trim() } + +private fun getPrefixedLabelSourcePref(questType: OsmElementQuestType<*>, prefs: SharedPreferences) = "${questPrefix(prefs)}qs_${questType.name}_label_sources" + +fun questPrefix(prefs: SharedPreferences) = if (prefs.getBoolean(Prefs.QUEST_SETTINGS_PER_PRESET, false)) + prefs.getLong(Preferences.SELECTED_EDIT_TYPE_PRESET, 0).toString() + "_" +else + "" + +fun questPrefix(prefs: Preferences) = if (prefs.getBoolean(Prefs.QUEST_SETTINGS_PER_PRESET, false)) + prefs.getLong(Preferences.SELECTED_EDIT_TYPE_PRESET, 0).toString() + "_" +else + "" + +fun OsmElementQuestType<*>.getPrefixedFullElementSelectionPref(prefs: SharedPreferences) = "${questPrefix(prefs)}qs_${name}_full_element_selection" + +private val valueRegex = "[a-z\\d_?,/\\s]+".toRegex() + +// relax a little bit? but e.g. A-Z is very uncommon and might lead to mistakes +private val elementSelectionRegex = "[a-z\\d_=!?\"~*\\[\\]()|:.,<>\\s+-]+".toRegex() diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/QuestsModule.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/QuestsModule.kt index 0dedd049e54..c4092a9dc0d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/QuestsModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/QuestsModule.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.quests import de.westnordost.countryboundaries.CountryBoundaries import de.westnordost.osmfeatures.Feature import de.westnordost.osmfeatures.FeatureDictionary +import de.westnordost.streetcomplete.ApplicationConstants.EE_QUEST_OFFSET import de.westnordost.streetcomplete.data.meta.CountryInfo import de.westnordost.streetcomplete.data.meta.CountryInfos import de.westnordost.streetcomplete.data.meta.getByLocation @@ -19,18 +20,22 @@ import de.westnordost.streetcomplete.quests.air_conditioning.AddAirConditioning import de.westnordost.streetcomplete.quests.air_pump.AddAirCompressor import de.westnordost.streetcomplete.quests.amenity_cover.AddAmenityCover import de.westnordost.streetcomplete.quests.amenity_indoor.AddIsAmenityIndoor +import de.westnordost.streetcomplete.quests.artwork.AddArtworkType import de.westnordost.streetcomplete.quests.atm_cashin.AddAtmCashIn import de.westnordost.streetcomplete.quests.atm_operator.AddAtmOperator import de.westnordost.streetcomplete.quests.baby_changing_table.AddBabyChangingTable import de.westnordost.streetcomplete.quests.barrier_bicycle_barrier_installation.AddBicycleBarrierInstallation import de.westnordost.streetcomplete.quests.barrier_bicycle_barrier_type.AddBicycleBarrierType +import de.westnordost.streetcomplete.quests.barrier_height.AddBarrierHeight import de.westnordost.streetcomplete.quests.barrier_opening.AddBarrierOpening +import de.westnordost.streetcomplete.quests.barrier_locked.AddBarrierLocked import de.westnordost.streetcomplete.quests.barrier_type.AddBarrierOnPath import de.westnordost.streetcomplete.quests.barrier_type.AddBarrierOnRoad import de.westnordost.streetcomplete.quests.barrier_type.AddBarrierType import de.westnordost.streetcomplete.quests.barrier_type.AddStileType import de.westnordost.streetcomplete.quests.bbq_fuel.AddBbqFuel import de.westnordost.streetcomplete.quests.bench_backrest.AddBenchBackrest +import de.westnordost.streetcomplete.quests.bench_material.AddBenchMaterial import de.westnordost.streetcomplete.quests.bicycle_repair_station.AddBicycleRepairStationServices import de.westnordost.streetcomplete.quests.bike_parking_capacity.AddBikeParkingCapacity import de.westnordost.streetcomplete.quests.bike_parking_cover.AddBikeParkingCover @@ -44,10 +49,13 @@ import de.westnordost.streetcomplete.quests.board_name.AddBoardName import de.westnordost.streetcomplete.quests.board_type.AddBoardType import de.westnordost.streetcomplete.quests.boat_rental.AddBoatRental import de.westnordost.streetcomplete.quests.bollard_type.AddBollardType +import de.westnordost.streetcomplete.quests.brewery.AddBrewery import de.westnordost.streetcomplete.quests.bridge_structure.AddBridgeStructure +import de.westnordost.streetcomplete.quests.building_colour.AddBuildingColour import de.westnordost.streetcomplete.quests.building_entrance.AddEntrance import de.westnordost.streetcomplete.quests.building_entrance_reference.AddEntranceReference import de.westnordost.streetcomplete.quests.building_levels.AddBuildingLevels +import de.westnordost.streetcomplete.quests.building_material.AddBuildingMaterial import de.westnordost.streetcomplete.quests.building_type.AddBuildingType import de.westnordost.streetcomplete.quests.building_underground.AddIsBuildingUnderground import de.westnordost.streetcomplete.quests.bus_stop_bench.AddBenchStatusOnBusStop @@ -62,18 +70,25 @@ import de.westnordost.streetcomplete.quests.camping.AddCampPower import de.westnordost.streetcomplete.quests.camping.AddCampShower import de.westnordost.streetcomplete.quests.camping.AddCampType import de.westnordost.streetcomplete.quests.car_wash_type.AddCarWashType +import de.westnordost.streetcomplete.quests.caravan_site_type.AddCaravanSiteType import de.westnordost.streetcomplete.quests.charging_station_capacity.AddChargingStationCapacity import de.westnordost.streetcomplete.quests.charging_station_operator.AddChargingStationOperator import de.westnordost.streetcomplete.quests.clothing_bin_operator.AddClothingBinOperator import de.westnordost.streetcomplete.quests.construction.MarkCompletedBuildingConstruction import de.westnordost.streetcomplete.quests.construction.MarkCompletedHighwayConstruction +import de.westnordost.streetcomplete.quests.contact.AddContactPhone +import de.westnordost.streetcomplete.quests.contact.AddContactWebsite import de.westnordost.streetcomplete.quests.crossing.AddCrossing import de.westnordost.streetcomplete.quests.crossing_island.AddCrossingIsland import de.westnordost.streetcomplete.quests.crossing_kerb_height.AddCrossingKerbHeight import de.westnordost.streetcomplete.quests.crossing_markings.AddCrossingMarkings import de.westnordost.streetcomplete.quests.crossing_signals.AddCrossingSignals +import de.westnordost.streetcomplete.quests.cuisine.AddCuisine +import de.westnordost.streetcomplete.quests.custom.CustomQuest +import de.westnordost.streetcomplete.quests.custom.CustomQuestList import de.westnordost.streetcomplete.quests.cycleway.AddCycleway import de.westnordost.streetcomplete.quests.defibrillator.AddDefibrillatorLocation +import de.westnordost.streetcomplete.quests.destination.AddDestination import de.westnordost.streetcomplete.quests.diet_type.AddGlutenFree import de.westnordost.streetcomplete.quests.diet_type.AddHalal import de.westnordost.streetcomplete.quests.diet_type.AddKosher @@ -91,17 +106,26 @@ import de.westnordost.streetcomplete.quests.fire_hydrant_ref.AddFireHydrantRef import de.westnordost.streetcomplete.quests.foot.AddProhibitedForPedestrians import de.westnordost.streetcomplete.quests.fuel_service.AddFuelSelfService import de.westnordost.streetcomplete.quests.general_fee.AddGeneralFee +import de.westnordost.streetcomplete.quests.general_ref.AddGeneralRef import de.westnordost.streetcomplete.quests.grit_bin_seasonal.AddGritBinSeasonal +import de.westnordost.streetcomplete.quests.guidepost.AddGuidepostEle +import de.westnordost.streetcomplete.quests.guidepost.AddGuidepostName +import de.westnordost.streetcomplete.quests.guidepost_sport.AddGuidepostSports import de.westnordost.streetcomplete.quests.hairdresser.AddHairdresserCustomers import de.westnordost.streetcomplete.quests.handrail.AddHandrail +import de.westnordost.streetcomplete.quests.healthcare_speciality.AddHealthcareSpeciality import de.westnordost.streetcomplete.quests.incline_direction.AddBicycleIncline import de.westnordost.streetcomplete.quests.incline_direction.AddStepsIncline import de.westnordost.streetcomplete.quests.internet_access.AddInternetAccess import de.westnordost.streetcomplete.quests.kerb_height.AddKerbHeight +import de.westnordost.streetcomplete.quests.lamp_type.AddLampType +import de.westnordost.streetcomplete.quests.lamp_mount.AddLampMount import de.westnordost.streetcomplete.quests.lanes.AddLanes import de.westnordost.streetcomplete.quests.leaf_detail.AddForestLeafType import de.westnordost.streetcomplete.quests.leaf_detail.AddTreeLeafType import de.westnordost.streetcomplete.quests.level.AddLevel +import de.westnordost.streetcomplete.quests.map.AddMapSize +import de.westnordost.streetcomplete.quests.map.AddMapType import de.westnordost.streetcomplete.quests.max_height.AddMaxHeight import de.westnordost.streetcomplete.quests.max_height.AddMaxPhysicalHeight import de.westnordost.streetcomplete.quests.max_speed.AddMaxSpeed @@ -114,36 +138,63 @@ import de.westnordost.streetcomplete.quests.oneway.AddOneway import de.westnordost.streetcomplete.quests.opening_hours.AddOpeningHours import de.westnordost.streetcomplete.quests.opening_hours_signed.CheckOpeningHoursSigned import de.westnordost.streetcomplete.quests.orchard_produce.AddOrchardProduce +import de.westnordost.streetcomplete.quests.osmose.OsmoseDao +import de.westnordost.streetcomplete.quests.osmose.OsmoseQuest import de.westnordost.streetcomplete.quests.parcel_locker_brand.AddParcelLockerBrand import de.westnordost.streetcomplete.quests.parking_access.AddBikeParkingAccess import de.westnordost.streetcomplete.quests.parking_access.AddParkingAccess +import de.westnordost.streetcomplete.quests.parking_capacity.AddDisabledParkingCapacity +import de.westnordost.streetcomplete.quests.parking_capacity.AddParkingCapacity import de.westnordost.streetcomplete.quests.parking_fee.AddBikeParkingFee import de.westnordost.streetcomplete.quests.parking_fee.AddParkingFee +import de.westnordost.streetcomplete.quests.parking_orientation.AddParkingOrientation import de.westnordost.streetcomplete.quests.parking_type.AddParkingType +import de.westnordost.streetcomplete.quests.pharmacy.AddIsPharmacyDispensing +import de.westnordost.streetcomplete.quests.piste_difficulty.AddPisteDifficulty +import de.westnordost.streetcomplete.quests.piste_lit.AddPisteLit +import de.westnordost.streetcomplete.quests.piste_ref.AddPisteRef import de.westnordost.streetcomplete.quests.pitch_lit.AddPitchLit import de.westnordost.streetcomplete.quests.place_name.AddPlaceName import de.westnordost.streetcomplete.quests.playground_access.AddPlaygroundAccess import de.westnordost.streetcomplete.quests.police_type.AddPoliceType +import de.westnordost.streetcomplete.quests.post_office.AddPostOfficeType import de.westnordost.streetcomplete.quests.postbox_collection_times.AddPostboxCollectionTimes import de.westnordost.streetcomplete.quests.postbox_ref.AddPostboxRef import de.westnordost.streetcomplete.quests.postbox_royal_cypher.AddPostboxRoyalCypher import de.westnordost.streetcomplete.quests.power_attachment.AddPowerAttachment import de.westnordost.streetcomplete.quests.powerpoles_material.AddPowerPolesMaterial import de.westnordost.streetcomplete.quests.railway_crossing.AddRailwayCrossingBarrier +import de.westnordost.streetcomplete.quests.railway_platform_ref.AddRailwayPlatformRef import de.westnordost.streetcomplete.quests.recycling.AddRecyclingType import de.westnordost.streetcomplete.quests.recycling_glass.DetermineRecyclingGlass import de.westnordost.streetcomplete.quests.recycling_material.AddRecyclingContainerMaterials import de.westnordost.streetcomplete.quests.religion.AddReligionToPlaceOfWorship import de.westnordost.streetcomplete.quests.religion.AddReligionToWaysideShrine import de.westnordost.streetcomplete.quests.road_name.AddRoadName +import de.westnordost.streetcomplete.quests.roof_colour.AddRoofColour +import de.westnordost.streetcomplete.quests.roof_orientation.AddRoofOrientation import de.westnordost.streetcomplete.quests.roof_shape.AddRoofShape import de.westnordost.streetcomplete.quests.sanitary_dump_station.AddSanitaryDumpStation +import de.westnordost.streetcomplete.quests.seating.AddOutdoorSeatingType import de.westnordost.streetcomplete.quests.seating.AddSeating import de.westnordost.streetcomplete.quests.segregated.AddCyclewaySegregation import de.westnordost.streetcomplete.quests.self_service.AddSelfServiceLaundry +import de.westnordost.streetcomplete.quests.service_building.AddServiceBuildingOperator +import de.westnordost.streetcomplete.quests.service_building.AddServiceBuildingType +import de.westnordost.streetcomplete.quests.shelter_type.AddShelterType import de.westnordost.streetcomplete.quests.shop_type.CheckShopExistence import de.westnordost.streetcomplete.quests.shop_type.CheckShopType import de.westnordost.streetcomplete.quests.shop_type.SpecifyShopType +import de.westnordost.streetcomplete.quests.show_poi.ShowBicycleStuff +import de.westnordost.streetcomplete.quests.show_poi.ShowBusiness +import de.westnordost.streetcomplete.quests.show_poi.ShowCamera +import de.westnordost.streetcomplete.quests.show_poi.ShowFixme +import de.westnordost.streetcomplete.quests.show_poi.ShowMachine +import de.westnordost.streetcomplete.quests.show_poi.ShowOther +import de.westnordost.streetcomplete.quests.show_poi.ShowRecycling +import de.westnordost.streetcomplete.quests.show_poi.ShowSeating +import de.westnordost.streetcomplete.quests.show_poi.ShowTrafficStuff +import de.westnordost.streetcomplete.quests.show_poi.ShowVacant import de.westnordost.streetcomplete.quests.sidewalk.AddSidewalk import de.westnordost.streetcomplete.quests.smoking.AddSmoking import de.westnordost.streetcomplete.quests.smoothness.AddPathSmoothness @@ -152,6 +203,7 @@ import de.westnordost.streetcomplete.quests.sport.AddSport import de.westnordost.streetcomplete.quests.step_count.AddStepCount import de.westnordost.streetcomplete.quests.step_count.AddStepCountStile import de.westnordost.streetcomplete.quests.steps_ramp.AddStepsRamp +import de.westnordost.streetcomplete.quests.street_cabinet.AddStreetCabinetType import de.westnordost.streetcomplete.quests.summit.AddSummitCross import de.westnordost.streetcomplete.quests.summit.AddSummitRegister import de.westnordost.streetcomplete.quests.surface.AddCyclewayPartSurface @@ -172,6 +224,13 @@ import de.westnordost.streetcomplete.quests.traffic_calming_type.AddTrafficCalmi import de.westnordost.streetcomplete.quests.traffic_signals_button.AddTrafficSignalsButton import de.westnordost.streetcomplete.quests.traffic_signals_sound.AddTrafficSignalsSound import de.westnordost.streetcomplete.quests.traffic_signals_vibrate.AddTrafficSignalsVibration +import de.westnordost.streetcomplete.quests.trail_visibility.AddTrailVisibility +import de.westnordost.streetcomplete.quests.tree.AddTreeGenus +import de.westnordost.streetcomplete.quests.sac_scale.AddSacScale +import de.westnordost.streetcomplete.quests.sauna_availability.AddSaunaAvailability +import de.westnordost.streetcomplete.quests.swimming_pool_availability.AddSwimmingPoolAvailability +import de.westnordost.streetcomplete.quests.valves.AddValves +import de.westnordost.streetcomplete.quests.via_ferrata_scale.AddViaFerrataScale import de.westnordost.streetcomplete.quests.way_lit.AddWayLit import de.westnordost.streetcomplete.quests.wheelchair_access.AddWheelchairAccessBusiness import de.westnordost.streetcomplete.quests.wheelchair_access.AddWheelchairAccessOutside @@ -179,14 +238,18 @@ import de.westnordost.streetcomplete.quests.wheelchair_access.AddWheelchairAcces import de.westnordost.streetcomplete.quests.wheelchair_access.AddWheelchairAccessToilets import de.westnordost.streetcomplete.quests.wheelchair_access.AddWheelchairAccessToiletsPart import de.westnordost.streetcomplete.quests.width.AddCyclewayWidth +import de.westnordost.streetcomplete.quests.width.AddFootwayWidth import de.westnordost.streetcomplete.quests.width.AddRoadWidth import de.westnordost.streetcomplete.screens.measure.ArSupportChecker import de.westnordost.streetcomplete.util.ktx.getFeature +import org.koin.android.ext.koin.androidContext import org.koin.core.qualifier.named import org.koin.dsl.module val questsModule = module { factory { NameSuggestionsSource(get()) } + single { CustomQuestList(androidContext()) } + single { OsmoseDao(get(), get()) } single { questTypeRegistry( @@ -198,7 +261,9 @@ val questsModule = module { }, { element -> get>(named("FeatureDictionaryLazy")).value.getFeature(element) - } + }, + get(), + get(), ) } } @@ -207,7 +272,23 @@ fun questTypeRegistry( arSupportChecker: ArSupportChecker, getCountryInfoByLocation: (LatLon) -> CountryInfo, getFeature: (Element) -> Feature?, -) = QuestTypeRegistry(listOf( + osmoseDao: OsmoseDao, + customQuestList: CustomQuestList, +) = QuestTypeRegistry(getQuestTypeList( + arSupportChecker, + getCountryInfoByLocation, + getFeature, + osmoseDao, + customQuestList, +)) + +fun getQuestTypeList( + arSupportChecker: ArSupportChecker, + getCountryInfoByLocation: (location: LatLon) -> CountryInfo, + getFeature: (Element) -> Feature?, + osmoseDao: OsmoseDao, + customQuestList: CustomQuestList, +) = listOf( /* The quest types are primarily sorted by how easy they can be solved: @@ -528,4 +609,64 @@ fun questTypeRegistry( /* at the very last because it can be difficult to ascertain during day. used by OsmAnd if "Street lighting" is enabled. (Configure map, Map rendering, Details) */ 154 to AddWayLit(), -)) + + // quests added in SCEE + EE_QUEST_OFFSET + 0 to AddBenchMaterial(), + EE_QUEST_OFFSET + 27 to AddBuildingColour(), + EE_QUEST_OFFSET + 49 to AddBuildingMaterial(), + EE_QUEST_OFFSET + 24 to AddRoofColour(), + EE_QUEST_OFFSET + 56 to AddRoofOrientation(), + EE_QUEST_OFFSET + 1 to AddContactPhone(), + EE_QUEST_OFFSET + 2 to AddContactWebsite(), + EE_QUEST_OFFSET + 4 to AddCuisine(), + EE_QUEST_OFFSET + 32 to AddBrewery(), + EE_QUEST_OFFSET + 5 to AddHealthcareSpeciality(), + EE_QUEST_OFFSET + 6 to AddServiceBuildingType(), + EE_QUEST_OFFSET + 7 to AddServiceBuildingOperator(), + EE_QUEST_OFFSET + 29 to AddStreetCabinetType(), + EE_QUEST_OFFSET + 8 to AddOutdoorSeatingType(), + EE_QUEST_OFFSET + 51 to AddValves(), + EE_QUEST_OFFSET + 25 to AddDestination(), + EE_QUEST_OFFSET + 22 to AddArtworkType(), + EE_QUEST_OFFSET + 23 to AddRailwayPlatformRef(), + EE_QUEST_OFFSET + 33 to AddTrailVisibility(), + EE_QUEST_OFFSET + 48 to AddSacScale(), + EE_QUEST_OFFSET + 9 to AddTreeGenus(), + EE_QUEST_OFFSET + 39 to AddBarrierLocked(), + EE_QUEST_OFFSET + 26 to AddIsPharmacyDispensing(), + EE_QUEST_OFFSET + 42 to AddGeneralRef(), + EE_QUEST_OFFSET + 43 to AddGuidepostName(), + EE_QUEST_OFFSET + 44 to AddGuidepostEle(), + EE_QUEST_OFFSET + 30 to AddShelterType(), + EE_QUEST_OFFSET + 28 to AddFootwayWidth(arSupportChecker), + EE_QUEST_OFFSET + 41 to AddGuidepostSports(), + EE_QUEST_OFFSET + 31 to AddViaFerrataScale(), + EE_QUEST_OFFSET + 37 to AddMapType(), + EE_QUEST_OFFSET + 38 to AddMapSize(), + EE_QUEST_OFFSET + 34 to AddBarrierHeight(arSupportChecker), + EE_QUEST_OFFSET + 40 to AddPisteLit(), + EE_QUEST_OFFSET + 35 to AddPisteRef(), + EE_QUEST_OFFSET + 36 to AddPisteDifficulty(), + EE_QUEST_OFFSET + 45 to AddParkingCapacity(), + EE_QUEST_OFFSET + 46 to AddDisabledParkingCapacity(), + EE_QUEST_OFFSET + 47 to AddParkingOrientation(), + EE_QUEST_OFFSET + 50 to AddCaravanSiteType(), + EE_QUEST_OFFSET + 52 to AddSaunaAvailability(), + EE_QUEST_OFFSET + 53 to AddSwimmingPoolAvailability(), + EE_QUEST_OFFSET + 54 to AddLampType(), + EE_QUEST_OFFSET + 55 to AddPostOfficeType(), + EE_QUEST_OFFSET + 57 to AddLampMount(), + EE_QUEST_OFFSET + 10 to OsmoseQuest(osmoseDao), + EE_QUEST_OFFSET + 11 to CustomQuest(customQuestList), + // POI quests + EE_QUEST_OFFSET + 12 to ShowBusiness(), + EE_QUEST_OFFSET + 13 to ShowBicycleStuff(), + EE_QUEST_OFFSET + 14 to ShowTrafficStuff(), + EE_QUEST_OFFSET + 15 to ShowOther(), + EE_QUEST_OFFSET + 16 to ShowRecycling(), + EE_QUEST_OFFSET + 17 to ShowVacant(), + EE_QUEST_OFFSET + 18 to ShowMachine(), + EE_QUEST_OFFSET + 19 to ShowSeating(), + EE_QUEST_OFFSET + 20 to ShowCamera(), + EE_QUEST_OFFSET + 21 to ShowFixme(), +) diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/TagEditor.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/TagEditor.kt new file mode 100644 index 00000000000..b1e5ec9c982 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/TagEditor.kt @@ -0,0 +1,499 @@ +package de.westnordost.streetcomplete.quests + +import android.annotation.SuppressLint +import android.app.ActionBar.LayoutParams +import android.graphics.Paint +import android.icu.text.DateFormat +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.GridLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.annotation.UiThread +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import androidx.core.os.bundleOf +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.get +import androidx.core.view.size +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import androidx.recyclerview.widget.LinearLayoutManager +import com.russhwolf.settings.ObservableSettings +import de.westnordost.osmfeatures.FeatureDictionary +import de.westnordost.osmfeatures.GeometryType +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +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.update_tags.StringMapChanges +import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTagsAction +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuest +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestController +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController +import de.westnordost.streetcomplete.data.location.RecentLocationStore +import de.westnordost.streetcomplete.data.osm.edits.update_tags.createChanges +import de.westnordost.streetcomplete.data.osm.mapdata.Node +import de.westnordost.streetcomplete.data.overlays.OverlayRegistry +import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey +import de.westnordost.streetcomplete.data.quest.OsmQuestKey +import de.westnordost.streetcomplete.data.quest.QuestKey +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry +import de.westnordost.streetcomplete.databinding.EditTagsBinding +import de.westnordost.streetcomplete.overlays.custom.CustomOverlayForm +import de.westnordost.streetcomplete.screens.main.bottom_sheet.InsertNodeTagEditor +import de.westnordost.streetcomplete.screens.main.bottom_sheet.IsCloseableBottomSheet +import de.westnordost.streetcomplete.util.EditTagsAdapter +import de.westnordost.streetcomplete.util.getLanguagesForFeatureDictionary +import de.westnordost.streetcomplete.util.ktx.copy +import de.westnordost.streetcomplete.util.ktx.geometryType +import de.westnordost.streetcomplete.util.ktx.hideKeyboard +import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds +import de.westnordost.streetcomplete.util.ktx.openUri +import de.westnordost.streetcomplete.util.ktx.popIn +import de.westnordost.streetcomplete.util.ktx.popOut +import de.westnordost.streetcomplete.util.ktx.showKeyboard +import de.westnordost.streetcomplete.util.ktx.updateMargins +import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope +import de.westnordost.streetcomplete.util.math.enclosingBoundingBox +import de.westnordost.streetcomplete.view.checkIsSurvey +import de.westnordost.streetcomplete.view.confirmIsSurvey +import de.westnordost.streetcomplete.view.insets_animation.respectSystemInsets +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +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.Date +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.min + +// todo: ideas for improvements +// ability to copy and paste everything +// see https://stackoverflow.com/questions/19177231/android-copy-paste-from-clipboard-manager +// button that copies all tags into clipboard: tagsList.joinToString("\n") { "${it.first} = ${it.second}" } +// and one that pastes clipboard into tags: newTags.putAll(clipboard.toTags()) +// only show button if clipboard contains data that can be parsed to tags +// undo button, for undoing delete or paste (and maybe other changes? but will not work well with typing) + +open class TagEditor : Fragment(), IsCloseableBottomSheet { + private var _binding: EditTagsBinding? = null + protected val binding: EditTagsBinding get() = _binding!! + private var updateQuestsJob: Job? = null + private var minBottomInset = Int.MAX_VALUE + + private val osmQuestController: OsmQuestController by inject() + protected val prefs: ObservableSettings by inject() + protected val elementEditsController: ElementEditsController by inject() + private val featureDictionary: Lazy by inject(named("FeatureDictionaryLazy")) + protected val mapDataSource: MapDataWithEditsSource by inject() + private val externalSourceQuestController: ExternalSourceQuestController by inject() + private val questTypeRegistry: QuestTypeRegistry by inject() + private val overlayRegistry: OverlayRegistry by inject() + protected val recentLocationStore: RecentLocationStore by inject() + + protected lateinit var originalElement: Element + protected lateinit var element: Element // element with adjusted tags and edit date + protected val newTags = ConcurrentHashMap() + protected val tagList = mutableListOf>() // sorted list of tags from newTags, need to keep in sync manually + private lateinit var geometry: ElementGeometry + protected var questKey: QuestKey? = null + protected var editTypeName: String? = null + private val keyboardButton by lazy { ImageView(requireContext()).apply { + setImageResource(android.R.drawable.ic_menu_edit) // is there no nice default keyboard drawable? + scaleX = 0.8f + scaleY = 0.8f + layoutParams = questIconParameters + setOnClickListener { requireActivity().currentFocus?.showKeyboard() } + } } + + // those 2 are lazy because resources require context to be initialized + private val questIconWidth by lazy { (resources.displayMetrics.density * 56 + 0.5f).toInt() } + private val questIconParameters by lazy { LinearLayout.LayoutParams(questIconWidth, questIconWidth).apply { + val margin = (resources.displayMetrics.density * 2 + 0.5f).toInt() + setMargins(margin, margin, margin, margin) + } } + + private val listener: AbstractOsmQuestForm.Listener? get() = parentFragment as? AbstractOsmQuestForm.Listener ?: activity as? AbstractOsmQuestForm.Listener + private lateinit var deferredQuests: Deferred> + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val args = requireArguments() + originalElement = Json.decodeFromString(args.getString(ARG_ELEMENT)!!) + geometry = Json.decodeFromString(args.getString(ARG_GEOMETRY)!!) + questKey = args.getString(ARG_QUEST_KEY)?.let { Json.decodeFromString(it) } + editTypeName = args.getString(ARG_EDIT_TYPE_NAME) + newTags.putAll(originalElement.tags) + tagList.addAll(newTags.toList().sortedBy { it.first }) + element = originalElement.copy(tags = newTags, timestampEdited = nowAsEpochMilliseconds()) // we don't want resurvey quests, user can just edit tag or delete and get quest again + showingTagEditor = true + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + // definitely worth calling early! because it should be finished once we want to fill the quest list (in most cases) + deferredQuests = viewLifecycleScope.async(Dispatchers.IO) { + // create quests if we have dynamic quest creation on or a new POI, otherwise just load from db + // this is much faster, but actually may contain resurvey quests... whatever (for now) + if (prefs.getBoolean(Prefs.DYNAMIC_QUEST_CREATION, false) || element.id == 0L) + osmQuestController.createNonPoiQuestsForElement(element, geometry) + else + osmQuestController.getAllInBBox(geometry.center.enclosingBoundingBox(0.01)) + .filter { it.elementType == element.type && it.elementId == element.id && it.type.dotColor == null } + } + _binding = EditTagsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // move if keyboard is shown + // interestingly this is called several times on showing/hiding keyboard + view.respectSystemInsets { + val keyboardShowing = minBottomInset < it.bottom + val binding = _binding ?: return@respectSystemInsets + binding.editorContainer.updateMargins(bottom = it.bottom) + if (keyboardShowing) { + // setting layout params or requestLayout is unneeded? though some sources say it is... + binding.questsGrid.layoutParams.height = questIconWidth + binding.elementInfo.layoutParams.height = 0 + } else { + binding.questsGrid.layoutParams.height = GridLayout.LayoutParams.WRAP_CONTENT + binding.elementInfo.layoutParams.height = LayoutParams.WRAP_CONTENT + } + minBottomInset = min(it.bottom, minBottomInset) + if (keyboardShowing || activity?.currentFocus == null) + binding.questsGrid.removeView(keyboardButton) + else if (binding.questsGrid.size > 1 && binding.questsGrid[1] != keyboardButton) + binding.questsGrid.addView(keyboardButton, 1) + } + val date = Date(originalElement.timestampEdited) + val dateText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + DateFormat.getDateTimeInstance().format(date) + else + date.toString() + binding.elementInfo.text = resources.getString(R.string.tag_editor_last_edited, dateText) + binding.elementInfo.layoutParams.height = LayoutParams.WRAP_CONTENT + if (element.id > 0) { + binding.elementInfo.setTextColor(ContextCompat.getColor(requireContext(), R.color.link)) + binding.elementInfo.paintFlags = binding.elementInfo.paintFlags or Paint.UNDERLINE_TEXT_FLAG + binding.elementInfo.setOnClickListener { + val url = "https://www.openstreetmap.org/${element.type.name.lowercase()}/${element.id}/history" + AlertDialog.Builder(requireContext()) + .setTitle(R.string.open_url) + .setMessage(url) + .setPositiveButton(android.R.string.ok) { _, _ -> openUri(url) } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + } + + // fill recyclerview and quests view + binding.editTags.layoutManager = LinearLayoutManager(requireContext()) + val geometryType = if (element is Node && (this is InsertNodeTagEditor || mapDataSource.getWaysForNode(element.id).isNotEmpty())) GeometryType.VERTEX + else element.geometryType + binding.editTags.adapter = EditTagsAdapter(tagList, newTags, geometryType, featureDictionary.value, requireContext(), prefs) { + viewLifecycleScope.launch(Dispatchers.IO) { updateQuests(750) } + showOk() + }.apply { setHasStableIds(true) } + + binding.okButton.setOnClickListener { + if (!tagsChangedAndOk()) return@setOnClickListener // for now keep the button visible and just do nothing if invalid + newTags.keys.removeAll { it.isBlank() } // if value is not blank ok button is disabled, so we discard only empty lines here + newTags.filterKeys { it != it.trim() }.forEach { + newTags.remove(it.key) + newTags[it.key.trim()] = it.value + } // trim keys + newTags.filterValues { it != it.trim() }.forEach { newTags[it.key] = it.value.trim() } // trim values + showingTagEditor = false + viewLifecycleScope.launch { applyEdit() } // tags are updated, and the different timestamp should not matter + } + + binding.questsGrid.columnCount = resources.displayMetrics.widthPixels / (questIconWidth + 3 * (resources.displayMetrics.density * 2 + 0.5f).toInt()) // last part is for the margins of icons and view + // add "new tag" button + binding.questsGrid.addView(ImageButton(requireContext()).apply { + setImageResource(R.drawable.ic_add_24dp) + setBackgroundColor(ContextCompat.getColor(context, R.color.background)) + layoutParams = questIconParameters + setOnClickListener { + if (tagList.lastOrNull() == emptyEntry) return@setOnClickListener + tagList.add(emptyEntry) + newTags[""] = "" + binding.editTags.adapter?.notifyItemInserted(tagList.lastIndex) + // clearing focus is necessary to avoid crash java.lang.IllegalArgumentException: Scrapped or attached views may not be recycled. isScrap:false isAttached:true androidx.recyclerview.widget.RecyclerView + activity?.currentFocus?.clearFocus() + binding.editTags.scrollToPosition(tagList.lastIndex) + binding.editTags.post { + // focus new view (does not change keyboard state) + // use post to avoid NPE because view does not exist: https://stackoverflow.com/a/54751851 + (binding.editTags.findViewHolderForAdapterPosition(tagList.lastIndex) as? EditTagsAdapter.ViewHolder)?.keyView?.requestFocus() + } + showOk() + } + }) + binding.editTags.viewTreeObserver.addOnGlobalFocusChangeListener { _, _ -> + val binding = _binding ?: return@addOnGlobalFocusChangeListener + if (activity?.currentFocus == null || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && WindowInsetsCompat.toWindowInsetsCompat(binding.root.rootWindowInsets).isVisible(WindowInsetsCompat.Type.ime()) + )) + binding.questsGrid.removeView(keyboardButton) + else if (binding.questsGrid.size < 2 || binding.questsGrid[1] != keyboardButton) + binding.questsGrid.addView(keyboardButton, 1) + } + + if (element.id == 0L) { + val previousTagsForFeature: Map? = try { featureDictionary.value + .getByTags( + tags = newTags, + isSuggestion = false, + languages = getLanguagesForFeatureDictionary(resources.configuration) + ).firstOrNull() + ?.let { prefs.getString(Prefs.CREATE_NODE_LAST_TAGS_FOR_FEATURE + it, "") } + ?.let { Json.decodeFromString(it) } + } catch (e: Exception) { null } + if (previousTagsForFeature?.isNotEmpty() == true && previousTagsForFeature != newTags) + binding.questsGrid.addView(ImageView(requireContext()).apply { + setImageResource(R.drawable.ic_undo_24dp) + scaleX = -0.7f // mirror to have a redo icon + scaleY = 0.7f // and make a little smaller, looks weird otherwise + layoutParams = questIconParameters + setOnClickListener { + previousTagsForFeature.forEach { newTags[it.key] = it.value } + binding.editTags.adapter?.notifyDataSetChanged() + tagList.clear() + tagList.addAll(newTags.toList().sortedBy { it.first }) + viewLifecycleScope.launch(Dispatchers.IO) { updateQuests(0) } + showOk() + } + }) + } + + viewLifecycleScope.launch(Dispatchers.IO) { waitForQuests() } + focusKey() + showOk() + } + + private suspend fun waitForQuests() { + val quests = deferredQuests.await() + activity?.runOnUiThread { quests.forEach { q -> + val icon = ImageView(requireContext()) + icon.setImageResource(q.type.icon) + icon.layoutParams = questIconParameters + icon.tag = q.type.name + icon.setOnClickListener { showQuest(q) } + _binding?.questsGrid?.addView(icon) + } } + } + + // focus value field of a key if desired, create entry if it doesn't exist + private fun focusKey() { + val focus = CustomOverlayForm.focusKey?.takeIf { it.startsWith("!") } ?: return + CustomOverlayForm.focusKey = null + val tag = focus.substringAfterLast("!") + if (!newTags.containsKey(tag)) { + tagList.add(tag to "") // don't add it to newTags, this will happen if the user changes anything + binding.editTags.adapter?.notifyItemInserted(tagList.lastIndex) + } + // select that field + val position = tagList.indexOfLast { it.first == tag } + viewLifecycleScope.launch(Dispatchers.IO) { + delay(30) + activity?.runOnUiThread { + val view = (binding.editTags.findViewHolderForAdapterPosition(position) as? EditTagsAdapter.ViewHolder)?.valueView ?: return@runOnUiThread + view.requestFocus() + if (newTags.containsKey(tag)) + view.selectAll() + } + } + } + + @UiThread + @SuppressLint("NotifyDataSetChanged") + private fun showQuest(quest: OsmQuest) { + val f = quest.type.createForm() + if (f.arguments == null) f.arguments = bundleOf() + val initialMapRotation = arguments?.getDouble(ARG_MAP_ROTATION) ?: 0.0 + val initialMapTilt = arguments?.getDouble(ARG_MAP_TILT) ?: 0.0 + val args = AbstractQuestForm.createArguments(quest.key, quest.type, quest.geometry, initialMapRotation, initialMapTilt) + f.requireArguments().putAll(args) + val osmArgs = AbstractOsmQuestForm.createArguments(element) + f.requireArguments().putAll(osmArgs) + activity?.currentFocus?.hideKeyboard() + parentFragmentManager.commit { // in parent fragment, because this handles the callbacks + add(id, f, null) // add the quest instead of replacing tag editor, so that changes aren't lost + addToBackStack(null) + } + // hide tag editor while quest is shown + binding.editTags.visibility = View.GONE + binding.questsGrid.visibility = View.GONE + binding.okButton.visibility = View.GONE + binding.elementInfo.visibility = View.GONE + + viewLifecycleScope.launch { + // this thread waits while the quest form is showing + // quest sets changes when answering, but does nothing else + // when the quest form is closed without answer, changes are set to empty (in main fragment) + changes = null + withContext(Dispatchers.IO) { while (changes == null) { delay(50) } } + val ch = changes + binding.editTags.visibility = View.VISIBLE + binding.questsGrid.visibility = View.VISIBLE + binding.elementInfo.visibility = View.VISIBLE + f.onClickClose { + parentFragmentManager.popBackStack() + changes = null + } + if (ch?.isEmpty() != false) { // usually changes is set to null after this check, but better be safe as the order is not strict + showOk() + return@launch + } + ch.applyTo(newTags) // apply before showOk() + showOk() + tagList.clear() + tagList.addAll(newTags.toList()) + tagList.sortBy { it.first } + if (tagList.contains(emptyEntry)) { + tagList.remove(emptyEntry) + tagList.add(emptyEntry) + } + binding.editTags.adapter?.notifyDataSetChanged() + // remove the quest immediately, because answering again may crash + binding.questsGrid.removeView(binding.questsGrid.findViewWithTag(quest.type.name)) + withContext(Dispatchers.IO) { updateQuests() } + } + } + + protected open suspend fun applyEdit() { + val isSurvey = checkIsSurvey(geometry, recentLocationStore.get()) + if (!isSurvey && !confirmIsSurvey(requireContext())) + return + val builder = element.tags.createChanges(originalElement.tags) + + val action = UpdateElementTagsAction(originalElement, builder.create()) + val questKey = questKey + val editType = when { + questKey is ExternalSourceQuestKey -> externalSourceQuestController.getQuestType(questKey)!! + editTypeName != null -> (overlayRegistry.getByName(editTypeName!!) ?: questTypeRegistry.getByName(editTypeName!!)) as ElementEditType + else -> tagEdit + } + if (questKey is OsmQuestKey && prefs.getBoolean(Prefs.DYNAMIC_QUEST_CREATION, false)) + OsmQuestController.lastAnsweredQuestKey = questKey + // always use "survey", because either it's tag editor or some external quest that's most like supposed to allow this + elementEditsController.add(editType, geometry, "survey", action, isSurvey, questKey) + listener?.onEdited(editType, geometry) + } + + private fun tagsChangedAndOk(): Boolean = + originalElement.tags != HashMap().apply { + putAll(newTags) + entries.removeAll { it.key.isBlank() && it.value.isBlank() } + } + && newTags.none { + (it.key.isBlank() && it.value.isNotBlank()) || (it.value.isBlank() && it.key.isNotBlank()) + } + && newTags.keys.all { it.length < 255 } + && newTags.values.all { it.length < 255 } + && (newTags.isNotEmpty() || mapDataSource.getWaysForNode(originalElement.id).isNotEmpty()) // allow deleting all tags if node is part of a way + && newTags.keys.none { problematicKeyCharacters.containsMatchIn(it) } + + private fun showOk() = requireActivity().runOnUiThread { if (tagsChangedAndOk()) binding.okButton.popIn() else binding.okButton.popOut() } + + private suspend fun updateQuests(waitMillis: Long = 0) { + updateQuestsJob?.cancel() + delay(waitMillis) + updateQuestsJob = viewLifecycleScope.launch(Dispatchers.IO) { + if (context == null) return@launch // maybe this happens when form is closed while waiting + val q = osmQuestController.createNonPoiQuestsForElement(element, geometry).map { q -> + val icon = ImageView(requireContext()).apply { setImageResource(q.type.icon) } + icon.layoutParams = questIconParameters + icon.tag = q.type.name + icon.setOnClickListener { showQuest(q) } + if (!isActive) return@launch + icon + } + if (!isActive) return@launch + activity?.runOnUiThread { + // form might be closed while quests were created, so we better not crash on binding == null + val binding = _binding ?: return@runOnUiThread + val viewsToKeep = if (binding.questsGrid.size > 1 && binding.questsGrid[1] == keyboardButton) 2 + else 1 + binding.questsGrid.removeViews(viewsToKeep, binding.questsGrid.childCount - viewsToKeep) // remove all quest views + q.forEach { binding.questsGrid.addView(it) } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onClickClose(onConfirmed: () -> Unit) { + if (originalElement.tags == newTags) { + showingTagEditor = false + return onConfirmed() + } + AlertDialog.Builder(requireContext()) + .setMessage(R.string.confirmation_discard_title) + .setPositiveButton(R.string.confirmation_discard_positive) { _, _ -> + showingTagEditor = false + onConfirmed() + } + .setNegativeButton(R.string.short_no_answer_on_button, null) + .show() + } + + override fun onClickMapAt(position: LatLon, clickAreaSizeInMeters: Double): Boolean = false + + companion object { + private const val ARG_ELEMENT = "element" + private const val ARG_GEOMETRY = "geometry" + private const val ARG_MAP_ROTATION = "map_rotation" + private const val ARG_MAP_TILT = "map_tilt" + private const val ARG_QUEST_KEY = "quest_key" + private const val ARG_EDIT_TYPE_NAME = "edit_type_name" + + fun createArguments(element: Element, geometry: ElementGeometry, rotation: Double?, tilt: Double?, questKey: QuestKey? = null, editTypeName: String? = null) = bundleOf( + ARG_ELEMENT to Json.encodeToString(element), + ARG_GEOMETRY to Json.encodeToString(geometry), + ARG_MAP_ROTATION to rotation, + ARG_MAP_TILT to tilt, + ARG_QUEST_KEY to Json.encodeToString(questKey), + ARG_EDIT_TYPE_NAME to editTypeName + ) + + var changes: StringMapChanges? = null + var showingTagEditor = false + } +} + + +val tagEdit = object : ElementEditType { + override val changesetComment = "Edit element" + override val icon = R.drawable.ic_edit_tags + override val title = R.string.quest_generic_answer_show_edit_tags + override val wikiLink: String? = null + override val name = "TagEdit" +} + +private val emptyEntry = "" to "" + +// characters that should not be in keys, see https://taginfo.openstreetmap.org/reports/characters_in_keys +private val problematicKeyCharacters = "[\\s=+/&<>;'\"?%#@,\\\\]".toRegex() diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/address/AddHousenumber.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/address/AddHousenumber.kt index 050b1c3c22c..6fbd2328c71 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/address/AddHousenumber.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/address/AddHousenumber.kt @@ -124,7 +124,7 @@ class AddHousenumber : OsmElementQuestType { for (areaWithAddress in areasWithAddresses + areasWithAddressesOnOutline) { val nearbyBuildings = buildingPositions.getAll(areaWithAddress.getBounds()) val buildingPositionsInArea = nearbyBuildings.filter { it.isInMultipolygon(areaWithAddress.polygons) } - val buildingsInArea = buildingPositionsInArea.mapNotNull { buildingsByCenterPosition[it] }.toSet() + val buildingsInArea = buildingPositionsInArea.mapNotNullTo(HashSet(buildingPositionsInArea.size)) { buildingsByCenterPosition[it] } buildings.removeAll(buildingsInArea) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/amenity_cover/AddAmenityCover.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/amenity_cover/AddAmenityCover.kt index f06111aae08..fe1d376db79 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/amenity_cover/AddAmenityCover.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/amenity_cover/AddAmenityCover.kt @@ -2,11 +2,10 @@ package de.westnordost.streetcomplete.quests.amenity_cover import de.westnordost.osmfeatures.Feature import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry -import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.OUTDOORS import de.westnordost.streetcomplete.osm.Tags import de.westnordost.streetcomplete.quests.YesNoQuestForm @@ -15,16 +14,16 @@ import de.westnordost.streetcomplete.util.ktx.toYesNo class AddAmenityCover( private val getFeature: (Element) -> Feature? -) : OsmElementQuestType { +) : OsmFilterQuestType() { - private val nodesFilter by lazy { """ + override val elementFilter = """ nodes with (leisure = picnic_table or amenity = bbq) and access !~ private|no and !covered and (!seasonal or seasonal = no) - """.toElementFilterExpression() } + """ override val changesetComment = "Specify whether various amenities are covered" override val wikiLink = "Key:covered" override val icon = R.drawable.ic_quest_picnic_table_cover @@ -33,12 +32,6 @@ class AddAmenityCover( override fun getTitle(tags: Map) = R.string.quest_amenityCover_title - override fun getApplicableElements(mapData: MapDataWithGeometry): Iterable = - mapData.filter { isApplicableTo(it) } - - override fun isApplicableTo(element: Element) = - nodesFilter.matches(element) && getFeature(element) != null - override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry): Sequence { /* put markers for objects that are exactly the same as for which this quest is asking for e.g. it's a ticket validator? -> display other ticket validators. Etc. */ diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/artwork/AddArtworkType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/artwork/AddArtworkType.kt new file mode 100644 index 00000000000..9faa2aa22e5 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/artwork/AddArtworkType.kt @@ -0,0 +1,33 @@ +package de.westnordost.streetcomplete.quests.artwork + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.CITIZEN +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.updateWithCheckDate + +class AddArtworkType : OsmFilterQuestType() { + + override val elementFilter = "nodes, ways with tourism = artwork and !artwork_type" + + override val changesetComment = "Survey artwork type" + override val wikiLink = "Key:artwork_type" + override val icon = R.drawable.ic_quest_memorial + override val achievements = listOf(CITIZEN) + override val defaultDisabledMessage: Int = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_artwork_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("nodes, ways with tourism = artwork") + + override fun createForm() = AddArtworkTypeForm() + + override fun applyAnswerTo(answer: String, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags.updateWithCheckDate("artwork_type", answer) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/artwork/AddArtworkTypeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/artwork/AddArtworkTypeForm.kt new file mode 100644 index 00000000000..fb36e4337bf --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/artwork/AddArtworkTypeForm.kt @@ -0,0 +1,24 @@ +package de.westnordost.streetcomplete.quests.artwork + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AListQuestForm +import de.westnordost.streetcomplete.quests.TextItem + +class AddArtworkTypeForm : AListQuestForm() { + override val items = listOf( + TextItem("sculpture", R.string.quest_artwork_sculpture), + TextItem("statue", R.string.quest_artwork_statue), + TextItem("bust", R.string.quest_artwork_bust), + TextItem("architecture", R.string.quest_artwork_architecture), + TextItem("relief", R.string.quest_artwork_relief), + TextItem("mural", R.string.quest_artwork_mural), + TextItem("fountain", R.string.quest_artwork_fountain), + TextItem("installation", R.string.quest_artwork_installation), + TextItem("stone", R.string.quest_artwork_stone), + TextItem("mosaic", R.string.quest_artwork_mosaic), + TextItem("graffiti", R.string.quest_artwork_graffiti), + TextItem("painting", R.string.quest_artwork_painting), + TextItem("land_art", R.string.quest_artwork_land_art), + ) + +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_bicycle_barrier_type/AddBicycleBarrierTypeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_bicycle_barrier_type/AddBicycleBarrierTypeForm.kt index 071309c9c96..4a5d381ed14 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_bicycle_barrier_type/AddBicycleBarrierTypeForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_bicycle_barrier_type/AddBicycleBarrierTypeForm.kt @@ -17,7 +17,7 @@ class AddBicycleBarrierTypeForm : override val otherAnswers = listOf( AnswerItem(R.string.quest_barrier_bicycle_type_not_cycle_barrier) { - applyAnswer(BarrierTypeIsNotBicycleBarrier) + applyAnswer(BarrierTypeIsNotBicycleBarrier, true) }, ) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_height/AddBarrierHeight.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_height/AddBarrierHeight.kt new file mode 100644 index 00000000000..b7ff3ad5df7 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_height/AddBarrierHeight.kt @@ -0,0 +1,41 @@ +package de.westnordost.streetcomplete.quests.barrier_height + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.screens.measure.ArSupportChecker + +class AddBarrierHeight( + private val checkArSupport: ArSupportChecker +) : OsmFilterQuestType() { + + override val elementFilter = """ + ways with + barrier ~ fence|guard_rail|handrail|hedge|wall|cable_barrier + and !height + """ + + override val changesetComment = "Specify barrier heights" + override val wikiLink = "Key:height" + override val icon = R.drawable.ic_quest_barrier_height + override val achievements = listOf(EditTypeAchievement.PEDESTRIAN) + override val defaultDisabledMessage: Int + get() = if (!checkArSupport()) R.string.default_disabled_msg_no_ar else R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map): Int { + return R.string.quest_barrier_height_title + } + + override fun createForm() = AddBarrierHeightForm() + + override fun applyAnswerTo(answer: BarrierHeightAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["height"] = answer.height.toOsmValue() + if (answer.isARMeasurement) { + tags["source:height"] = "ARCore" + } else { + tags.remove("source:height") + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_height/AddBarrierHeightForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_height/AddBarrierHeightForm.kt new file mode 100644 index 00000000000..240038b2ea6 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_height/AddBarrierHeightForm.kt @@ -0,0 +1,78 @@ +package de.westnordost.streetcomplete.quests.barrier_height + +import android.content.ActivityNotFoundException +import android.os.Bundle +import android.view.View +import androidx.core.view.isGone +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.QuestLengthBinding +import de.westnordost.streetcomplete.osm.Length +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.screens.measure.ArSupportChecker +import de.westnordost.streetcomplete.screens.measure.MeasureContract +import de.westnordost.streetcomplete.util.ktx.openUri +import de.westnordost.streetcomplete.view.controller.LengthInputViewController +import org.koin.android.ext.android.inject + +class AddBarrierHeightForm : AbstractOsmQuestForm() { + + override val contentLayoutResId = R.layout.quest_length + private val binding by contentViewBinding(QuestLengthBinding::bind) + private val launcher = registerForActivityResult(MeasureContract(), ::onMeasured) + private val checkArSupport: ArSupportChecker by inject() + private var isARMeasurement: Boolean = false + private lateinit var lengthInput: LengthInputViewController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + savedInstanceState?.let { isARMeasurement = it.getBoolean(AR) } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + lengthInput = binding.lengthInput.let { + LengthInputViewController(it.unitSelect, it.metersContainer, it.metersInput, it.feetInchesContainer, it.feetInput, it.inchesInput) + } + lengthInput.unitSelectItemResId = R.layout.spinner_item_centered_large + lengthInput.isCompactMode = true + lengthInput.maxFeetDigits = 2 + lengthInput.maxMeterDigits = Pair(2, 2) + lengthInput.selectableUnits = countryInfo.lengthUnits + lengthInput.onInputChanged = { + isARMeasurement = false + checkIsFormComplete() + } + binding.measureButton.isGone = !checkArSupport() + binding.measureButton.setOnClickListener { takeMeasurement() } + } + + private fun takeMeasurement() { + val lengthUnit = lengthInput.unit ?: return + try { + launcher.launch(MeasureContract.Params(lengthUnit, true)) + } catch (e: ActivityNotFoundException) { + context?.openUri("market://details?id=de.westnordost.streetmeasure") + } + } + + private fun onMeasured(length: Length?) { + lengthInput.length = length + isARMeasurement = true + } + + override fun isFormComplete(): Boolean = lengthInput.length != null + + override fun onClickOk() { + applyAnswer(BarrierHeightAnswer(lengthInput.length!!, isARMeasurement)) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(AR, isARMeasurement) + } + + companion object { + private const val AR = "ar" + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_height/BarrierHeightAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_height/BarrierHeightAnswer.kt new file mode 100644 index 00000000000..cbe67402d09 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_height/BarrierHeightAnswer.kt @@ -0,0 +1,5 @@ +package de.westnordost.streetcomplete.quests.barrier_height + +import de.westnordost.streetcomplete.osm.Length + +data class BarrierHeightAnswer(val height: Length, val isARMeasurement: Boolean) diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_locked/AddBarrierLocked.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_locked/AddBarrierLocked.kt new file mode 100644 index 00000000000..733b4d4cfc7 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_locked/AddBarrierLocked.kt @@ -0,0 +1,31 @@ +package de.westnordost.streetcomplete.quests.barrier_locked + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags + +class AddBarrierLocked : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + barrier ~ bump_gate|chain|door|gate|swing_gate|sliding_gate|sliding_beam|wicket_gate + and ( + !locked + or locked = yes and locked older today -5 years + or locked older today -10 years + ) + """ + override val changesetComment = "Add whether barriers are locked" + override val wikiLink = "Key:locked" + override val icon = R.drawable.ic_quest_barrier_locked + override val defaultDisabledMessage: Int = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_barrier_locked_title + + override fun createForm() = AddBarrierLockedForm() + + override fun applyAnswerTo(answer: BarrierLockedAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + answer.applyTo(tags) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_locked/AddBarrierLockedForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_locked/AddBarrierLockedForm.kt new file mode 100644 index 00000000000..34bb101f790 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_locked/AddBarrierLockedForm.kt @@ -0,0 +1,94 @@ +package de.westnordost.streetcomplete.quests.barrier_locked + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.QuestFeeHoursBinding +import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHours +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.quests.barrier_locked.AddBarrierLockedForm.Mode.LOCKED_AT_HOURS +import de.westnordost.streetcomplete.quests.barrier_locked.AddBarrierLockedForm.Mode.LOCKED_YES_NO +import de.westnordost.streetcomplete.view.controller.TimeRestriction.AT_ANY_TIME +import de.westnordost.streetcomplete.view.controller.TimeRestriction.EXCEPT_AT_HOURS +import de.westnordost.streetcomplete.view.controller.TimeRestriction.ONLY_AT_HOURS +import de.westnordost.streetcomplete.view.controller.TimeRestrictionSelectViewController + +class AddBarrierLockedForm : AbstractOsmQuestForm() { + + private var lockedAtHoursSelect: TimeRestrictionSelectViewController? = null + + override val buttonPanelAnswers get() = + if (mode == LOCKED_YES_NO) listOf( + AnswerItem(R.string.quest_generic_hasFeature_no) { applyAnswer(NotLocked) }, + AnswerItem(R.string.quest_generic_hasFeature_yes) { applyAnswer(Locked) } + ) + else emptyList() + + override val otherAnswers = listOf( + AnswerItem(R.string.quest_fee_answer_hours) { mode = LOCKED_AT_HOURS }, + ) + + private var mode: Mode = LOCKED_YES_NO + set(value) { + if (field == value) return + field = value + updateContentView() + updateButtonPanel() + } + + private fun updateContentView() { + clearViewControllers() + + if (mode == LOCKED_AT_HOURS) { + val binding = QuestFeeHoursBinding.bind(setContentView(R.layout.quest_fee_hours)) + + lockedAtHoursSelect = TimeRestrictionSelectViewController( + binding.timeRestrictionSelect.selectAtHours, + binding.timeRestrictionSelect.openingHoursList, + binding.timeRestrictionSelect.addTimesButton + ).also { + it.firstDayOfWorkweek = countryInfo.firstDayOfWorkweek + it.regularShoppingDays = countryInfo.regularShoppingDays + it.locale = countryInfo.userPreferredLocale + it.onInputChanged = { checkIsFormComplete() } + // user already answered that it depends on the time, so don't show the "at any time" option + it.selectableTimeRestrictions = listOf(ONLY_AT_HOURS, EXCEPT_AT_HOURS) + } + } + } + + private fun clearViewControllers() { + lockedAtHoursSelect = null + } + + override fun onDestroyView() { + super.onDestroyView() + clearViewControllers() + } + + override fun onClickOk() { + when (mode) { + LOCKED_AT_HOURS -> { + val hours = lockedAtHoursSelect!!.times.toOpeningHours() + val locked = when (lockedAtHoursSelect!!.timeRestriction) { + AT_ANY_TIME -> Locked + ONLY_AT_HOURS -> LockedAtHours(hours) + EXCEPT_AT_HOURS -> LockedExceptAtHours(hours) + } + applyAnswer(locked) + } + else -> {} + } + } + + override fun isRejectingClose() = when (mode) { + LOCKED_AT_HOURS -> lockedAtHoursSelect!!.isComplete + else -> false + } + + override fun isFormComplete() = when (mode) { + LOCKED_AT_HOURS -> lockedAtHoursSelect!!.isComplete + else -> false + } + + private enum class Mode { LOCKED_YES_NO, LOCKED_AT_HOURS } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_locked/BarrierLockedAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_locked/BarrierLockedAnswer.kt new file mode 100644 index 00000000000..bcaa94b9685 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/barrier_locked/BarrierLockedAnswer.kt @@ -0,0 +1,33 @@ +package de.westnordost.streetcomplete.quests.barrier_locked + +import de.westnordost.osm_opening_hours.model.OpeningHours +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.updateWithCheckDate + +sealed interface BarrierLockedAnswer + +object Locked : BarrierLockedAnswer +object NotLocked : BarrierLockedAnswer +data class LockedAtHours(val hours: OpeningHours) : BarrierLockedAnswer +data class LockedExceptAtHours(val hours: OpeningHours) : BarrierLockedAnswer + +fun BarrierLockedAnswer.applyTo(tags: Tags) { + when (this) { + is Locked -> { + tags.updateWithCheckDate("locked", "yes") + tags.remove("locked:conditional") + } + is NotLocked -> { + tags.updateWithCheckDate("locked", "no") + tags.remove("locked:conditional") + } + is LockedAtHours -> { + tags.updateWithCheckDate("locked", "no") + tags["locked:conditional"] = "yes @ ($hours)" + } + is LockedExceptAtHours -> { + tags.updateWithCheckDate("locked", "yes") + tags["locked:conditional"] = "no @ ($hours)" + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/bench_backrest/AddBenchBackrest.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/bench_backrest/AddBenchBackrest.kt index 3b9b53d89ae..9eaadae515f 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/bench_backrest/AddBenchBackrest.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/bench_backrest/AddBenchBackrest.kt @@ -33,7 +33,7 @@ class AddBenchBackrest : OsmFilterQuestType() { override fun getTitle(tags: Map) = R.string.quest_bench_backrest_title override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = - getMapData().filter("nodes, ways with amenity = bench or leisure = picnic_table") + getMapData().filter("nodes, ways with amenity = bench or leisure = picnic_table or amenity = lounger") override fun createForm() = AddBenchBackrestForm() diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/bench_backrest/AddBenchBackrestForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/bench_backrest/AddBenchBackrestForm.kt index 7bdedc5867d..38ed7487eb6 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/bench_backrest/AddBenchBackrestForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/bench_backrest/AddBenchBackrestForm.kt @@ -15,6 +15,6 @@ class AddBenchBackrestForm : AbstractOsmQuestForm() { ) override val otherAnswers = listOf( - AnswerItem(R.string.quest_bench_answer_picnic_table) { applyAnswer(PICNIC_TABLE) } + AnswerItem(R.string.quest_bench_answer_picnic_table) { applyAnswer(PICNIC_TABLE, true) } ) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/bench_material/AddBenchMaterial.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/bench_material/AddBenchMaterial.kt new file mode 100644 index 00000000000..69195a48bfb --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/bench_material/AddBenchMaterial.kt @@ -0,0 +1,42 @@ +package de.westnordost.streetcomplete.quests.bench_material + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement +import de.westnordost.streetcomplete.osm.Tags + +class AddBenchMaterial : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + (amenity = bench or leisure = picnic_table or amenity = lounger) + and (!area or area = no) + and !material + and access !~ private|no + """ + override val changesetComment = "Add material information to benches" + override val wikiLink = "Tag:amenity=bench" + override val icon = R.drawable.ic_quest_bench_material + override val isDeleteElementEnabled = true + override val achievements = listOf(EditTypeAchievement.PEDESTRIAN, EditTypeAchievement.OUTDOORS) + override val defaultDisabledMessage: Int = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_benchMaterial_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("nodes, ways with amenity = bench or leisure = picnic_table or amenity = lounger") + + override fun createForm() = AddBenchMaterialForm() + + override fun applyAnswerTo(answer: BenchMaterial, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + if (answer == BenchMaterial.PICNIC) { + tags.remove("amenity") + tags["leisure"] = "picnic_table" + } else + tags["material"] = answer.osmValue + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/bench_material/AddBenchMaterialForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/bench_material/AddBenchMaterialForm.kt new file mode 100644 index 00000000000..b30a12235d5 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/bench_material/AddBenchMaterialForm.kt @@ -0,0 +1,34 @@ +package de.westnordost.streetcomplete.quests.bench_material + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AImageListQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.quests.bench_material.BenchMaterial.WOOD +import de.westnordost.streetcomplete.quests.bench_material.BenchMaterial.METAL +import de.westnordost.streetcomplete.quests.bench_material.BenchMaterial.PLASTIC +import de.westnordost.streetcomplete.quests.bench_material.BenchMaterial.CONCRETE +import de.westnordost.streetcomplete.quests.bench_material.BenchMaterial.STONE +import de.westnordost.streetcomplete.quests.bench_material.BenchMaterial.BRICK +import de.westnordost.streetcomplete.view.image_select.Item + +class AddBenchMaterialForm : AImageListQuestForm() { + + override val items = listOf( + Item(WOOD, R.drawable.bench_wood, R.string.quest_material_wood), + Item(METAL, R.drawable.bench_metal, R.string.quest_material_metal), + Item(PLASTIC, R.drawable.bench_plastic, R.string.quest_material_plastic), + Item(CONCRETE, R.drawable.bench_concrete, R.string.quest_material_concrete), + Item(STONE, R.drawable.bench_stone, R.string.quest_material_stone), + Item(BRICK, R.drawable.bench_brick, R.string.quest_material_brick) + ) + + override val otherAnswers by lazy { if (element.tags["amenity"] == "bench") + listOf(AnswerItem(R.string.quest_bench_answer_picnic_table) { applyAnswer(BenchMaterial.PICNIC, true) }) + else emptyList() } + + override val itemsPerRow = 3 + + override fun onClickOk(selectedItems: List) { + applyAnswer(selectedItems.single()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/bench_material/BenchMaterial.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/bench_material/BenchMaterial.kt new file mode 100644 index 00000000000..8d5cf8d64af --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/bench_material/BenchMaterial.kt @@ -0,0 +1,11 @@ +package de.westnordost.streetcomplete.quests.bench_material + +enum class BenchMaterial(val osmValue: String) { + WOOD("wood"), + METAL("metal"), + PLASTIC("plastic"), + CONCRETE("concrete"), + STONE("stone"), + BRICK("brick"), + PICNIC("") +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/bike_parking_type/BikeParkingType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/bike_parking_type/BikeParkingType.kt index 36e53120058..ecbf387fc31 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/bike_parking_type/BikeParkingType.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/bike_parking_type/BikeParkingType.kt @@ -8,6 +8,7 @@ enum class BikeParkingType(val osmValue: String) { LOCKERS("lockers"), BUILDING("building"), HANDLEBAR_HOLDER("handlebar_holder"), + SADDLE_HOLDER("saddle_holder"), TWO_TIER("two-tier"), FLOOR("floor"), } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/bike_parking_type/BikeParkingTypeItem.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/bike_parking_type/BikeParkingTypeItem.kt index 9cc427c6a3e..9c196b87886 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/bike_parking_type/BikeParkingTypeItem.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/bike_parking_type/BikeParkingTypeItem.kt @@ -14,6 +14,7 @@ private val BikeParkingType.titleResId: Int get() = when (this) { LOCKERS -> R.string.quest_bicycle_parking_type_locker BUILDING -> R.string.quest_bicycle_parking_type_building HANDLEBAR_HOLDER -> R.string.quest_bicycle_parking_type_handlebarholder + SADDLE_HOLDER -> R.string.quest_bicycle_parking_type_saddleholder TWO_TIER -> R.string.quest_bicycle_parking_type_two_tier FLOOR -> R.string.quest_bicycle_parking_type_floor } @@ -26,6 +27,7 @@ private val BikeParkingType.iconResId: Int get() = when (this) { LOCKERS -> R.drawable.bicycle_parking_type_lockers BUILDING -> R.drawable.bicycle_parking_type_building HANDLEBAR_HOLDER -> R.drawable.bicycle_parking_type_handlebarholder + SADDLE_HOLDER -> R.drawable.bicycle_parking_type_saddleholder TWO_TIER -> R.drawable.bicycle_parking_type_two_tier FLOOR -> R.drawable.bicycle_parking_type_floor } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/bollard_type/AddBollardTypeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/bollard_type/AddBollardTypeForm.kt index b1f413b5723..75007f8e5e4 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/bollard_type/AddBollardTypeForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/bollard_type/AddBollardTypeForm.kt @@ -15,7 +15,7 @@ class AddBollardTypeForm : AImageListQuestForm() override val otherAnswers = listOf( AnswerItem(R.string.quest_bollard_type_not_bollard) { - applyAnswer(BarrierTypeIsNotBollard) + applyAnswer(BarrierTypeIsNotBollard, true) }, ) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/brewery/AddBrewery.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/brewery/AddBrewery.kt new file mode 100644 index 00000000000..1bb44b7c82a --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/brewery/AddBrewery.kt @@ -0,0 +1,47 @@ +package de.westnordost.streetcomplete.quests.brewery + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.isPlace + +class AddBrewery : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + amenity ~ bar|biergarten|pub|restaurant|nightclub + and drink:beer != no + and ( + brewery ~ yes|no + or !brewery + or brewery older today -6 years + ) + """ + override val changesetComment = "Add brewery" + override val wikiLink = "Key:brewery" + override val icon = R.drawable.ic_quest_brewery + override val isReplacePlaceEnabled = true + override val defaultDisabledMessage = R.string.default_disabled_msg_go_inside + + override fun getTitle(tags: Map) = R.string.quest_brewery_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().asSequence().filter { it.isPlace() } + + override fun createForm() = AddBreweryForm() + + override fun applyAnswerTo(answer: BreweryAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + when (answer) { + is NoBeerAnswer -> { + tags["drink:beer"] = "no" + if (tags["brewery"] != "no") // don't remove brewery=no + tags.remove("brewery") + } + is ManyBeersAnswer -> tags["brewery"] = "various" + is BreweryStringAnswer -> tags["brewery"] = answer.brewery + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/brewery/AddBreweryForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/brewery/AddBreweryForm.kt new file mode 100644 index 00000000000..68471bef885 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/brewery/AddBreweryForm.kt @@ -0,0 +1,38 @@ +package de.westnordost.streetcomplete.quests.brewery + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.quests.AMultiValueQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.util.math.enlargedBy +import org.koin.android.ext.android.inject + +class AddBreweryForm : AMultiValueQuestForm() { + + private val mapDataSource: MapDataWithEditsSource by inject() + + override fun stringToAnswer(answerString: String) = BreweryStringAnswer(answerString) + + override fun getConstantSuggestions() = + requireContext().assets.open("brewery/brewerySuggestions.txt").bufferedReader().readLines() + + override val addAnotherValueResId = R.string.quest_brewery_add_more + + override fun getVariableSuggestions(): Collection { + val data = mapDataSource.getMapDataWithGeometry(geometry.getBounds().enlargedBy(100.0)) + val suggestions = hashSetOf() + data.filter("nodes, ways with brewery").forEach { + it.tags["brewery"]?.let { suggestions.addAll(it.split(";")) } + } + suggestions.remove("yes") + suggestions.remove("various") + suggestions.remove("no") + return suggestions + } + + override val otherAnswers = listOf( + AnswerItem(R.string.quest_brewery_is_not_available) { applyAnswer(NoBeerAnswer) }, + AnswerItem(R.string.quest_brewery_is_various) { applyAnswer(ManyBeersAnswer) } + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/brewery/BreweryAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/brewery/BreweryAnswer.kt new file mode 100644 index 00000000000..6a750d5cc3a --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/brewery/BreweryAnswer.kt @@ -0,0 +1,7 @@ +package de.westnordost.streetcomplete.quests.brewery + +sealed interface BreweryAnswer + +data class BreweryStringAnswer(val brewery: String) : BreweryAnswer +object ManyBeersAnswer : BreweryAnswer +object NoBeerAnswer : BreweryAnswer diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/building_colour/AddBuildingColour.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/building_colour/AddBuildingColour.kt new file mode 100644 index 00000000000..0214c3bf885 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/building_colour/AddBuildingColour.kt @@ -0,0 +1,38 @@ +package de.westnordost.streetcomplete.quests.building_colour + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags + +class AddBuildingColour : OsmFilterQuestType() { + + override val elementFilter = """ + ways, relations with + ((building and building !~ no|construction|roof|carport) + or (building:part and building:part !~ no|construction|roof|carport)) + and !building:colour + and (!indoor or indoor = no) + and wall !~ no + """ + override val changesetComment = "Specify building colour" + override val wikiLink = "Key:building:colour" + override val icon = R.drawable.ic_quest_building_colour + override val defaultDisabledMessage: Int = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = when { + tags.containsKey("building:part") -> R.string.quest_buildingPartColour_title + else -> R.string.quest_buildingColour_title + } + + override fun createForm() = AddBuildingColourForm() + + override fun applyAnswerTo( + answer: BuildingColour, + tags: Tags, + geometry: ElementGeometry, + timestampEdited: Long, + ) { + tags["building:colour"] = answer.osmValue + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/building_colour/AddBuildingColourForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/building_colour/AddBuildingColourForm.kt new file mode 100644 index 00000000000..c754b165900 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/building_colour/AddBuildingColourForm.kt @@ -0,0 +1,24 @@ +package de.westnordost.streetcomplete.quests.building_colour + +import android.os.Bundle +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AImageListQuestForm +import de.westnordost.streetcomplete.view.image_select.DisplayItem + +class AddBuildingColourForm : AImageListQuestForm() { + + override val items: List> + get() { + val context = requireContext() + return BuildingColour.values().map { it.asItem(context) } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imageSelector.cellLayoutId = R.layout.cell_icon_select_with_label_below + } + + override fun onClickOk(selectedItems: List) { + applyAnswer(selectedItems.single()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/building_colour/BuildingColour.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/building_colour/BuildingColour.kt new file mode 100644 index 00000000000..7a123185079 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/building_colour/BuildingColour.kt @@ -0,0 +1,38 @@ +package de.westnordost.streetcomplete.quests.building_colour + +import de.westnordost.streetcomplete.view.image_select.OsmColour + +enum class BuildingColour(override val osmValue: String, override val androidValue: String?) : + OsmColour { + // Top used building colours + WHITE("white", "#ffffff"), + GREY80("#cccccc", null), + BEIGEISH("#eecfaf", null), + GREY("grey", "#808080"), + BROWN("brown", "#a52a2a"), + RED("red", "#ff0000"), + YELLOW("yellow", "#ffff00"), + BEIGE("beige", "#f5f5dc"), + BLACK("black", "#000000"), + GREEN("green", "#008000"), + ORANGE("orange", "#ffa500"), + BLUE("blue", "#0000ff"), + POO("#85552e", null), + LIGHT_GREY("lightgrey", "#d3d3d3"), + SILVER("silver", "#c0c0c0"), + TAN("tan", "#d2b48c"), + YELLOWISH("#ffe0a0", null), + LIGHT_YELLOW("lightyellow", "#ffffe0"), + SLATE_GREY("#708090", null), + REDDISH("#ff9e6b", null), + + // Rest of the recommended 3D palette + MAROON("maroon", "#800000"), + OLIVE("olive", "#808000"), + TEAL("teal", "#008080"), + NAVY("navy", "#000080"), + PURPLE("purple", "#800080"), + LIME("lime", "#00ff00"), + AQUA("aqua", "#00ffff"), + FUCHSIA("fuchsia", "#ff00ff"), +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/building_colour/BuildingColourItem.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/building_colour/BuildingColourItem.kt new file mode 100644 index 00000000000..97e08830026 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/building_colour/BuildingColourItem.kt @@ -0,0 +1,17 @@ +package de.westnordost.streetcomplete.quests.building_colour + +import android.content.Context +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.view.image_select.DisplayItem +import de.westnordost.streetcomplete.view.image_select.FilteredDisplayItem + +fun BuildingColour.asItem(context: Context): DisplayItem = + BuildingColourDisplayItem(this, context) + +class BuildingColourDisplayItem(buildingColour: BuildingColour, context: Context) : + FilteredDisplayItem(buildingColour, context) { + + init { + iconResId = R.drawable.ic_building_colour + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/building_entrance/AddEntranceForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/building_entrance/AddEntranceForm.kt index 5203a3a01d1..a3a47deb6c2 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/building_entrance/AddEntranceForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/building_entrance/AddEntranceForm.kt @@ -7,6 +7,7 @@ import de.westnordost.streetcomplete.quests.building_entrance.EntranceExistsAnsw import de.westnordost.streetcomplete.quests.building_entrance.EntranceExistsAnswer.EXIT import de.westnordost.streetcomplete.quests.building_entrance.EntranceExistsAnswer.GENERIC import de.westnordost.streetcomplete.quests.building_entrance.EntranceExistsAnswer.MAIN +import de.westnordost.streetcomplete.quests.building_entrance.EntranceExistsAnswer.PARKING import de.westnordost.streetcomplete.quests.building_entrance.EntranceExistsAnswer.SERVICE import de.westnordost.streetcomplete.quests.building_entrance.EntranceExistsAnswer.SHOP import de.westnordost.streetcomplete.quests.building_entrance.EntranceExistsAnswer.STAIRCASE @@ -19,6 +20,7 @@ class AddEntranceForm : AListQuestForm() { TextItem(EXIT, R.string.quest_building_entrance_exit), TextItem(EMERGENCY_EXIT, R.string.quest_building_entrance_emergency_exit), TextItem(SHOP, R.string.quest_building_entrance_shop), + TextItem(PARKING, R.string.quest_building_entrance_parking), TextItem(GENERIC, R.string.quest_building_entrance_yes), TextItem(DeadEnd, R.string.quest_building_entrance_dead_end), ) diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/building_entrance/EntranceAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/building_entrance/EntranceAnswer.kt index 07b5817336b..663e7612e4e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/building_entrance/EntranceAnswer.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/building_entrance/EntranceAnswer.kt @@ -11,5 +11,6 @@ enum class EntranceExistsAnswer(val osmValue: String) : EntranceAnswer { EMERGENCY_EXIT("emergency"), EXIT("exit"), SHOP("shop"), + PARKING("parking"), GENERIC("yes"), } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/building_entrance_reference/AddEntranceReference.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/building_entrance_reference/AddEntranceReference.kt index 9c67020badc..2762738a6ca 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/building_entrance_reference/AddEntranceReference.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/building_entrance_reference/AddEntranceReference.kt @@ -87,8 +87,8 @@ class AddEntranceReference : OsmElementQuestType { val result = mutableListOf() for (building in buildings) { val buildingsWayNodeIds = when (building) { - is Way -> building.nodeIds.toSet() - is Relation -> building.getMultipolygonNodeIds(mapData).toSet() + is Way -> building.nodeIds.toHashSet() + is Relation -> building.getMultipolygonNodeIds(mapData).toHashSet() else -> emptyList() } val buildingEntrances = buildingsWayNodeIds.mapNotNull { mapData.getNode(it) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/building_levels/AddBuildingLevels.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/building_levels/AddBuildingLevels.kt index aea0aadb56d..7b00c9297b7 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/building_levels/AddBuildingLevels.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/building_levels/AddBuildingLevels.kt @@ -1,11 +1,16 @@ package de.westnordost.streetcomplete.quests.building_levels +import android.content.Context +import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestController import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.BUILDING import de.westnordost.streetcomplete.osm.BUILDINGS_WITH_LEVELS import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.questPrefix class AddBuildingLevels : OsmFilterQuestType() { @@ -13,8 +18,11 @@ class AddBuildingLevels : OsmFilterQuestType() { ways, relations with building ~ ${BUILDINGS_WITH_LEVELS.joinToString("|")} and ( - !building:levels - or !roof:levels and !roof:height and roof:shape and roof:shape != flat + !building:levels + ${if (prefs.getBoolean(questPrefix(prefs) + MANDATORY_ROOF_LEVELS, true)) + "or !roof:levels and !roof:height and roof:shape and roof:shape != flat" + else "" + } ) and !(height and roof:height) and !building:min_level @@ -41,4 +49,28 @@ class AddBuildingLevels : OsmFilterQuestType() { tags["building:levels"] = answer.levels.toString() answer.roofLevels?.let { tags["roof:levels"] = it.toString() } } + + override val hasQuestSettings = true + + override fun getQuestSettingsDialog(context: Context): AlertDialog { + val array = arrayOf( + context.getString(R.string.quest_settings_building_levels_mandatory_roof), + context.getString(R.string.quest_settings_building_levels_optional_roof) + ) + return AlertDialog.Builder(context) + .setSingleChoiceItems(array, if (prefs.getBoolean(questPrefix(prefs) + MANDATORY_ROOF_LEVELS, true)) 0 else 1) { d, i -> + if (i == 0) + prefs.edit { remove(questPrefix(prefs) + MANDATORY_ROOF_LEVELS) } + else + prefs.edit { putBoolean(questPrefix(prefs) + MANDATORY_ROOF_LEVELS, false) } + d.dismiss() + OsmQuestController.reloadQuestTypes() + } + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.element_selection_button) { _, _ -> + super.getQuestSettingsDialog(context)?.show() + }.create() + } } + +const val MANDATORY_ROOF_LEVELS = "qs_AddBuildingLevels_mandatory_roof_levels" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/building_levels/AddBuildingLevelsForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/building_levels/AddBuildingLevelsForm.kt index f2cd9f42f05..deb8ae3fe12 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/building_levels/AddBuildingLevelsForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/building_levels/AddBuildingLevelsForm.kt @@ -11,16 +11,15 @@ import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.databinding.QuestBuildingLevelsBinding import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.quests.questPrefix import de.westnordost.streetcomplete.ui.util.content import de.westnordost.streetcomplete.util.takeFavourites -import org.koin.android.ext.android.inject class AddBuildingLevelsForm : AbstractOsmQuestForm() { override val contentLayoutResId = R.layout.quest_building_levels private val binding by contentViewBinding(QuestBuildingLevelsBinding::bind) - private val prefs: Preferences by inject() private lateinit var levels: MutableState private lateinit var roofLevels: MutableState override val otherAnswers = listOf( @@ -82,7 +81,7 @@ class AddBuildingLevelsForm : AbstractOsmQuestForm() { override fun isFormComplete(): Boolean { val roofShape = element.tags["roof:shape"] val hasNonFlatRoofShape = roofShape != null && roofShape != "flat" - val roofLevelsAreOptional = countryInfo.roofsAreUsuallyFlat && !hasNonFlatRoofShape + val roofLevelsAreOptional = !prefs.getBoolean(questPrefix(prefs) + MANDATORY_ROOF_LEVELS, true) || (countryInfo.roofsAreUsuallyFlat && !hasNonFlatRoofShape) return levels.value.isValidLevel() && ( diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/building_material/AddBuildingMaterial.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/building_material/AddBuildingMaterial.kt new file mode 100644 index 00000000000..643ddd0666f --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/building_material/AddBuildingMaterial.kt @@ -0,0 +1,39 @@ +package de.westnordost.streetcomplete.quests.building_material + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags + +class AddBuildingMaterial : OsmFilterQuestType() { + + override val elementFilter = """ + ways, relations with + ((building and building !~ no|construction|roof|carport) + or (building:part and building:part !~ no|construction|roof|carport)) + and !building:material + and indoor != no + and wall != no + """ + override val changesetComment = "Specify building material" + override val wikiLink = "Key:building:material" + override val icon = R.drawable.ic_quest_building_material + + override val defaultDisabledMessage = R.string.default_disabled_msg_difficult_and_time_consuming + + override fun getTitle(tags: Map) = when { + tags.containsKey("building:part") -> R.string.quest_buildingPartMaterial_title + else -> R.string.quest_buildingMaterial_title + } + + override fun createForm() = AddBuildingMaterialForm() + + override fun applyAnswerTo( + answer: BuildingMaterial, + tags: Tags, + geometry: ElementGeometry, + timestampEdited: Long, + ) { + tags["building:material"] = answer.osmValue + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/building_material/AddBuildingMaterialForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/building_material/AddBuildingMaterialForm.kt new file mode 100644 index 00000000000..5ebff5f5076 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/building_material/AddBuildingMaterialForm.kt @@ -0,0 +1,21 @@ +package de.westnordost.streetcomplete.quests.building_material + +import android.os.Bundle +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AImageListQuestForm + +class AddBuildingMaterialForm : AImageListQuestForm() { + + override val items = BuildingMaterial.entries.map { it.asItem() } + + override val itemsPerRow = 3 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imageSelector.cellLayoutId = R.layout.cell_labeled_icon_select + } + + override fun onClickOk(selectedItems: List) { + applyAnswer(selectedItems.single()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/building_material/BuildingMaterial.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/building_material/BuildingMaterial.kt new file mode 100644 index 00000000000..8cf882033b9 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/building_material/BuildingMaterial.kt @@ -0,0 +1,133 @@ +package de.westnordost.streetcomplete.quests.building_material + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.view.image_select.DisplayItem +import de.westnordost.streetcomplete.view.image_select.Item + +enum class BuildingMaterial( + val osmValue: String, + @DrawableRes val imageResId: Int, + @StringRes val titleResId: Int, +) { + CEMENT_BLOCK( + osmValue = "cement_block", + imageResId = R.drawable.building_material_cement_block, + titleResId = R.string.quest_material_cement_block + ), + BRICK( + osmValue = "brick", + imageResId = R.drawable.building_material_brick, + titleResId = R.string.quest_material_brick + ), + PLASTER( + osmValue = "plaster", + imageResId = R.drawable.building_material_plaster, + titleResId = R.string.quest_material_plaster + ), + WOOD( + osmValue = "wood", + imageResId = R.drawable.building_material_wood, + titleResId = R.string.quest_material_wood + ), + CONCRETE( + osmValue = "concrete", + imageResId = R.drawable.building_material_concrete, + titleResId = R.string.quest_material_concrete + ), + METAL( + osmValue = "metal", + imageResId = R.drawable.building_material_metal, + titleResId = R.string.quest_material_metal + ), + STONE( + osmValue = "stone", + imageResId = R.drawable.building_material_stone, + titleResId = R.string.quest_material_stone + ), + GLASS( + osmValue = "glass", + imageResId = R.drawable.building_material_glass, + titleResId = R.string.quest_material_glass + ), + MIRROR( + osmValue = "mirror", + imageResId = R.drawable.building_material_mirror, + titleResId = R.string.quest_material_mirror + ), + MUD( + osmValue = "mud", + imageResId = R.drawable.building_material_mud, + titleResId = R.string.quest_material_mud + ), + PLASTIC( + osmValue = "plastic", + imageResId = R.drawable.building_material_plastic, + titleResId = R.string.quest_material_plastic + ), + TIMBER_FRAMING( + osmValue = "timber_framing", + imageResId = R.drawable.building_material_timber_framing, + titleResId = R.string.quest_material_timber_framing + ), + SANDSTONE( + osmValue = "sandstone", + imageResId = R.drawable.building_material_sandstone, + titleResId = R.string.quest_material_sandstone + ), + CLAY( + osmValue = "clay", + imageResId = R.drawable.building_material_clay, + titleResId = R.string.quest_material_clay + ), + REED( + osmValue = "reed", + imageResId = R.drawable.building_material_reed, + titleResId = R.string.quest_material_reed + ), + LOAM( + osmValue = "loam", + imageResId = R.drawable.building_material_loam, + titleResId = R.string.quest_material_loam + ), + MARBLE( + osmValue = "marble", + imageResId = R.drawable.building_material_marble, + titleResId = R.string.quest_material_marble + ), + SLATE( + osmValue = "slate", + imageResId = R.drawable.building_material_slate, + titleResId = R.string.quest_material_slate + ), + VINYL( + osmValue = "vinyl", + imageResId = R.drawable.building_material_vinyl, + titleResId = R.string.quest_material_vinyl + ), + LIMESTONE( + osmValue = "limestone", + imageResId = R.drawable.building_material_limestone, + titleResId = R.string.quest_material_limestone + ), + TILES( + osmValue = "tiles", + imageResId = R.drawable.building_material_tiles, + titleResId = R.string.quest_material_tiles + ), + BAMBOO( + osmValue = "bamboo", + imageResId = R.drawable.building_material_bamboo, + titleResId = R.string.quest_material_bamboo + ), + ADOBE( + osmValue = "adobe", + imageResId = R.drawable.building_material_adobe, + titleResId = R.string.quest_material_adobe + ) +} + +fun Collection.toItems() = map { it.asItem() } + +fun BuildingMaterial.asItem(): DisplayItem = Item(this, imageResId, titleResId) diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/bus_stop_lit/AddBusStopLit.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/bus_stop_lit/AddBusStopLit.kt index 50e4b949ea8..a00c3741242 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/bus_stop_lit/AddBusStopLit.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/bus_stop_lit/AddBusStopLit.kt @@ -4,6 +4,7 @@ import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.PEDESTRIAN +import de.westnordost.streetcomplete.data.quest.DayNightCycle.ONLY_NIGHT import de.westnordost.streetcomplete.osm.Tags import de.westnordost.streetcomplete.osm.updateWithCheckDate import de.westnordost.streetcomplete.quests.YesNoQuestForm @@ -32,6 +33,7 @@ class AddBusStopLit : OsmFilterQuestType() { override val wikiLink = "Key:lit" override val icon = R.drawable.ic_quest_bus_stop_lit override val achievements = listOf(PEDESTRIAN) + override val dayNightCycle = ONLY_NIGHT override fun getTitle(tags: Map) = R.string.quest_busStopLit_title2 diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/caravan_site_type/AddCaravanSiteType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/caravan_site_type/AddCaravanSiteType.kt new file mode 100644 index 00000000000..3479fc2dc79 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/caravan_site_type/AddCaravanSiteType.kt @@ -0,0 +1,36 @@ +package de.westnordost.streetcomplete.quests.caravan_site_type + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.OUTDOORS +import de.westnordost.streetcomplete.osm.Tags + +class AddCaravanSiteType : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways, relations with + tourism = caravan_site and ( + !caravan_site:type + ) + """ + override val changesetComment = "Add caravan site type info" + override val defaultDisabledMessage = R.string.default_disabled_msg_caravanSiteType + override val wikiLink = "Key:caravan_site:type" + override val icon = R.drawable.ic_quest_caravan_site + override val achievements = listOf(OUTDOORS) + + override fun getTitle(tags: Map) = R.string.quest_caravanSiteType_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("nodes, ways, relations with tourism ~ caravan_site|camp_site") + + override fun createForm() = AddCaravanSiteTypeForm() + + override fun applyAnswerTo(answer: String, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["caravan_site:type"] = answer + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/caravan_site_type/AddCaravanSiteTypeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/caravan_site_type/AddCaravanSiteTypeForm.kt new file mode 100644 index 00000000000..ceb6b3319c6 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/caravan_site_type/AddCaravanSiteTypeForm.kt @@ -0,0 +1,23 @@ +package de.westnordost.streetcomplete.quests.caravan_site_type + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AListQuestForm +import de.westnordost.streetcomplete.quests.TextItem + +class AddCaravanSiteTypeForm : AListQuestForm() { + override val items = listOf( + TextItem("village", R.string.quest_caravanSiteType_village), + TextItem("town", R.string.quest_caravanSiteType_town), + TextItem("river", R.string.quest_caravanSiteType_river), + TextItem("lake", R.string.quest_caravanSiteType_lake), + TextItem("parking_lot", R.string.quest_caravanSiteType_parking_lot), + TextItem("harbour", R.string.quest_caravanSiteType_harbour), + TextItem("winery", R.string.quest_caravanSiteType_winery), + TextItem("camp_site", R.string.quest_caravanSiteType_camp_site), + TextItem("museum", R.string.quest_caravanSiteType_museum), + TextItem("restaurant", R.string.quest_caravanSiteType_restaurant), + TextItem("farm", R.string.quest_caravanSiteType_farm), + TextItem("beach", R.string.quest_caravanSiteType_beach), + TextItem("supermarket", R.string.quest_caravanSiteType_supermarket), + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/contact/AddContactPhone.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/contact/AddContactPhone.kt new file mode 100644 index 00000000000..dd92b7e0da3 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/contact/AddContactPhone.kt @@ -0,0 +1,62 @@ +package de.westnordost.streetcomplete.quests.contact + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.isPlace + +class AddContactPhone : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways, relations with + ( + tourism = information and information = office + or craft + or healthcare + or """.trimIndent() + + PLACES_FOR_CONTACT_QUESTS + + "\n) and !phone and !contact:phone and !contact:mobile and !brand and (name or operator)" + + override val changesetComment = "Add phone number" + override val wikiLink = "Key:phone" + override val icon = R.drawable.ic_quest_phone + override val defaultDisabledMessage: Int = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_contact_phone + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().asSequence().filter { it.isPlace() } + + override fun createForm() = AddContactPhoneForm() + + override val isReplacePlaceEnabled: Boolean = true + + override fun applyAnswerTo(answer: String, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["phone"] = answer + } + +} + +val PLACES_FOR_CONTACT_QUESTS = mapOf( + "amenity" to arrayOf( + "restaurant", "cafe", "internet_cafe", + "cinema", "townhall", "embassy", "community_centre", "youth_centre", "library", + "dentist", "doctors", "clinic", "veterinary", "animal_shelter", + "arts_centre", "ferry_terminal", "prep_school", "dojo" + ), + "leisure" to arrayOf("fitness_centre", "bowling_alley", "sports_centre", "escape_game"), + "office" to arrayOf( + "insurance", "government", "travel_agent", "tax_advisor", "religion", "employment_agency", + "lawyer", "estate_agent", "therapist", "notary" + ), + "shop" to arrayOf( + "beauty", "massage", "hairdresser", "wool", "tattoo", "electrical", "glaziery", "tailor", + "computer", "electronics", "hifi", "bicycle", "outdoor", "sports", "art", "craft", "model", + "musical_instrument", "camera", "books", "travel_agency", "cheese", "chocolate", "coffee", "health_food" + ), + "tourism" to arrayOf("zoo", "aquarium", "gallery", "museum", "alpine_hut", "camp_site", "caravan_site"), +).map { it.key + " ~ " + it.value.joinToString("|") }.joinToString("\n or ") diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/contact/AddContactPhoneForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/contact/AddContactPhoneForm.kt new file mode 100644 index 00000000000..3a8f3ae5e81 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/contact/AddContactPhoneForm.kt @@ -0,0 +1,41 @@ +package de.westnordost.streetcomplete.quests.contact + +import android.os.Bundle +import android.text.InputType +import android.view.View +import androidx.core.widget.doAfterTextChanged + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.QuestContactBinding +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm + +class AddContactPhoneForm : AbstractOsmQuestForm() { + + override val contentLayoutResId = R.layout.quest_contact + private val binding by contentViewBinding(QuestContactBinding::bind) + + private val contact get() = binding.nameInput.text?.toString().orEmpty().trim() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.nameInput.inputType = InputType.TYPE_CLASS_PHONE + + binding.nameInput.setText(prefill) + binding.nameInput.doAfterTextChanged { checkIsFormComplete() } + } + + override fun onClickOk() { + prefill = if (contact.contains(" ") && contact.substringBefore(" ").length <= 5) + contact.substringBefore(" ") + " " + else + "+" + applyAnswer(contact) + } + + + override fun isFormComplete() = contact.isNotEmpty() && binding.nameInput.text?.toString().orEmpty() != prefill + + companion object { + private var prefill = "+" + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/contact/AddContactWebsite.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/contact/AddContactWebsite.kt new file mode 100644 index 00000000000..5fb645c3061 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/contact/AddContactWebsite.kt @@ -0,0 +1,39 @@ +package de.westnordost.streetcomplete.quests.contact + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.isPlace + +class AddContactWebsite : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways, relations with + ( + tourism = information and information = office + or """.trimIndent() + + PLACES_FOR_CONTACT_QUESTS + + "\n) and !website and !contact:website and !contact:facebook and !contact:instagram and !brand and (name or operator)" + + override val changesetComment = "Add website" + override val wikiLink = "Key:website" + override val icon = R.drawable.ic_quest_website + override val defaultDisabledMessage: Int = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_contact_website + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().asSequence().filter { it.isPlace() } + + override fun createForm() = AddContactWebsiteForm() + + override val isReplacePlaceEnabled: Boolean = true + + override fun applyAnswerTo(answer: String, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["website"] = answer + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/contact/AddContactWebsiteForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/contact/AddContactWebsiteForm.kt new file mode 100644 index 00000000000..046ca00a728 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/contact/AddContactWebsiteForm.kt @@ -0,0 +1,49 @@ +package de.westnordost.streetcomplete.quests.contact + +import android.os.Bundle +import android.text.InputType +import android.view.View +import androidx.core.widget.doAfterTextChanged + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.QuestContactBinding +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm + +class AddContactWebsiteForm : AbstractOsmQuestForm() { + + override val contentLayoutResId = R.layout.quest_contact + private val binding by contentViewBinding(QuestContactBinding::bind) + + private val contact get() = binding.nameInput.text?.toString().orEmpty().trim() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.nameInput.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI + + binding.nameInput.setText(prefill) + binding.nameInput.doAfterTextChanged { + val s = binding.nameInput.selectionStart + if (binding.nameInput.text.toString().any { it.isUpperCase() }) { + binding.nameInput.setText(binding.nameInput.text.toString().lowercase()) + binding.nameInput.setSelection(s) + } + checkIsFormComplete() + } + } + + override fun onClickOk() { + prefill = if (contact.contains("//")) + contact.substringBefore("//") + "//" + else + "" + applyAnswer(contact) + } + + + override fun isFormComplete() = contact.isNotEmpty() && contact != prefill && contact.contains('.') + + companion object { + private var prefill = "http://" + } + +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/crossing_markings/AddCrossingMarkings.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/crossing_markings/AddCrossingMarkings.kt index f494f6a421a..4d2d6c0d86a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/crossing_markings/AddCrossingMarkings.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/crossing_markings/AddCrossingMarkings.kt @@ -1,5 +1,7 @@ package de.westnordost.streetcomplete.quests.crossing_markings +import android.content.Context +import androidx.appcompat.app.AlertDialog import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry @@ -9,19 +11,18 @@ import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.PEDESTRIAN import de.westnordost.streetcomplete.osm.Tags import de.westnordost.streetcomplete.osm.isCrossing -import de.westnordost.streetcomplete.quests.YesNoQuestForm -import de.westnordost.streetcomplete.util.ktx.toYesNo +import de.westnordost.streetcomplete.osm.updateCheckDateForKey -class AddCrossingMarkings : OsmElementQuestType { +class AddCrossingMarkings : OsmElementQuestType { private val crossingFilter by lazy { """ nodes with highway = crossing and foot != no - and !crossing:markings - and (!crossing or crossing = island) + and $crossingMarkingExpression and (!crossing:signals or crossing:signals = no) """.toElementFilterExpression() } + /* only looking for crossings that have no crossing=* at all set because if the crossing was * - if it had markings, it would be tagged with "marked","zebra" or "uncontrolled" * - if it hadn't, it would be tagged with "unmarked" @@ -35,7 +36,7 @@ class AddCrossingMarkings : OsmElementQuestType { or highway = service and service = driveway """.toElementFilterExpression() } - override val changesetComment = "Specify whether pedestrian crossings have markings" + override val changesetComment = "Specify type or existence of pedestrian crossing markings" override val wikiLink = "Key:crossing:markings" override val icon = R.drawable.ic_quest_pedestrian_crossing override val achievements = listOf(PEDESTRIAN) @@ -57,13 +58,35 @@ class AddCrossingMarkings : OsmElementQuestType { override fun isApplicableTo(element: Element): Boolean? = if (!crossingFilter.matches(element)) false else null - override fun createForm() = YesNoQuestForm() + override fun createForm() = + if (prefs.getBoolean(PREF_CROSSING_MARKING_EXTENDED, false)) { + AddCrossingMarkingsForm() + } else { + AddCrossingMarkingsYesNoForm() + } + + override fun applyAnswerTo(answer: CrossingMarkings, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["crossing:markings"] = answer.osmValue + } + + override val hasQuestSettings: Boolean = true + + override fun getQuestSettingsDialog(context: Context): AlertDialog = + AlertDialog.Builder(context) + .setMessage(R.string.pref_quest_pedestrian_crossing_markings_extended) + .setPositiveButton(R.string.quest_generic_hasFeature_yes) { _, _ -> + prefs.edit().putBoolean(PREF_CROSSING_MARKING_EXTENDED, true).apply() + } + .setNegativeButton(R.string.quest_generic_hasFeature_no) { _, _ -> + prefs.edit().putBoolean(PREF_CROSSING_MARKING_EXTENDED, false).apply() + } + .create() - override fun applyAnswerTo(answer: Boolean, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { - tags["crossing:markings"] = answer.toYesNo() - /* We only tag yes/no, however, in countries where depending on the kind of marking, - * different traffic rules apply, it makes sense to ask which marking it is. But to know - * which kinds exist per country needs research. (Whose results should be added to the - * wiki page for crossing:markings first) */ + private val crossingMarkingExpression = if (prefs.getBoolean(PREF_CROSSING_MARKING_EXTENDED, false)) { + "( !crossing:markings or crossing:markings=yes )" + } else { + "!crossing:markings and (!crossing or crossing = island )" } } + +private const val PREF_CROSSING_MARKING_EXTENDED = "qs_AddCrossingMarkings_extended" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/crossing_markings/AddCrossingMarkingsForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/crossing_markings/AddCrossingMarkingsForm.kt new file mode 100644 index 00000000000..995a763313f --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/crossing_markings/AddCrossingMarkingsForm.kt @@ -0,0 +1,23 @@ +package de.westnordost.streetcomplete.quests.crossing_markings + +import android.os.Bundle +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AImageListQuestForm + +class AddCrossingMarkingsForm : AImageListQuestForm() { + + override val items = CrossingMarkings.entries + .filter { it!=CrossingMarkings.YES } + .map { it.asItem() } + + override val itemsPerRow = 3 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imageSelector.cellLayoutId = R.layout.cell_labeled_icon_select + } + + override fun onClickOk(selectedItems: List) { + applyAnswer(selectedItems.single()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/crossing_markings/AddCrossingMarkingsYesNoForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/crossing_markings/AddCrossingMarkingsYesNoForm.kt new file mode 100644 index 00000000000..e462fb7e503 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/crossing_markings/AddCrossingMarkingsYesNoForm.kt @@ -0,0 +1,13 @@ +package de.westnordost.streetcomplete.quests.crossing_markings + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem + +class AddCrossingMarkingsYesNoForm : AbstractOsmQuestForm() { + + override val buttonPanelAnswers = listOf( + AnswerItem(R.string.quest_generic_hasFeature_no) { applyAnswer(CrossingMarkings.NO) }, + AnswerItem(R.string.quest_generic_hasFeature_yes) { applyAnswer(CrossingMarkings.YES) } + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/crossing_markings/CrossingMarkings.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/crossing_markings/CrossingMarkings.kt new file mode 100644 index 00000000000..d4a14401e42 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/crossing_markings/CrossingMarkings.kt @@ -0,0 +1,93 @@ +package de.westnordost.streetcomplete.quests.crossing_markings + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.view.image_select.DisplayItem +import de.westnordost.streetcomplete.view.image_select.Item + +enum class CrossingMarkings( + val osmValue: String, + @DrawableRes val imageResId: Int?, + @StringRes val titleResId: Int?, +) { + YES( + osmValue = "yes", + imageResId = null, + titleResId = null + ), + NO( + osmValue = "no", + imageResId = R.drawable.crossing_markings_no, + titleResId = R.string.quest_crossing_marking_value_no + ), + ZEBRA( + osmValue = "zebra", + imageResId = R.drawable.crossing_markings_zebra, + titleResId = R.string.quest_crossing_marking_value_zebra + ), + LINES( + osmValue = "lines", + imageResId = R.drawable.crossing_markings_lines, + titleResId = R.string.quest_crossing_marking_value_lines + ), + LADDER( + osmValue = "ladder", + imageResId = R.drawable.crossing_markings_ladder, + titleResId = R.string.quest_crossing_marking_value_ladder + ), + DASHES( + osmValue = "dashes", + imageResId = R.drawable.crossing_markings_dashes, + titleResId = R.string.quest_crossing_marking_value_dashes + ), + DOTS( + osmValue = "dots", + imageResId = R.drawable.crossing_markings_dots, + titleResId = R.string.quest_crossing_marking_value_dots + ), + SURFACE( + osmValue = "surface", + imageResId = R.drawable.crossing_markings_surface, + titleResId = R.string.quest_crossing_marking_value_surface + ), + LADDER_SKEWED( + osmValue = "ladder:skewed", + imageResId = R.drawable.crossing_markings_ladder_skewed, + titleResId = R.string.quest_crossing_marking_value_ladder_skewed + ), + ZEBRA_PAIRED( + osmValue = "zebra:paired", + imageResId = R.drawable.crossing_markings_zebra_paired, + titleResId = R.string.quest_crossing_marking_value_zebra_paired + ), + ZEBRA_BICOLOUR( + osmValue = "zebra:bicolour", + imageResId = R.drawable.crossing_markings_zebra_bicolour, + titleResId = R.string.quest_crossing_marking_value_zebra_bicolour + ), + ZEBRA_DOUBLE( + osmValue = "zebra:double", + imageResId = R.drawable.crossing_markings_zebra_double, + titleResId = R.string.quest_crossing_marking_value_zebra_double + ), + LADDER_PAIRED( + osmValue = "ladder:paired", + imageResId = R.drawable.crossing_markings_ladder_paired, + titleResId = R.string.quest_crossing_marking_value_ladder_paired + ), + ZEBRA_DOTS( + osmValue = "zebra;dots", + imageResId = R.drawable.crossing_markings_zebra_dots, + titleResId = R.string.quest_crossing_marking_value_zebra_dots + ), + PICTOGRAMS( + osmValue = "pictograms", + imageResId = R.drawable.crossing_markings_pictograms, + titleResId = R.string.quest_crossing_marking_value_pictograms + ) +} + +fun Collection.toItems() = map { it.asItem() } + +fun CrossingMarkings.asItem(): DisplayItem = Item(this, imageResId, titleResId) diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/cuisine/AddCuisine.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/cuisine/AddCuisine.kt new file mode 100644 index 00000000000..9b8dd4b325e --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/cuisine/AddCuisine.kt @@ -0,0 +1,38 @@ +package de.westnordost.streetcomplete.quests.cuisine + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.isPlace + +class AddCuisine : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + ( + amenity ~ restaurant|fast_food + or (amenity = pub and food = yes) + ) + and !cuisine + """ + override val changesetComment = "Add cuisine" + override val wikiLink = "Key:cuisine" + override val icon = R.drawable.ic_quest_restaurant + override val isReplacePlaceEnabled = true + override val defaultDisabledMessage = R.string.default_disabled_msg_go_inside + + override fun getTitle(tags: Map) = R.string.quest_cuisine_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().asSequence().filter { it.isPlace() } + + override fun createForm() = AddCuisineForm() + + override fun applyAnswerTo(answer: String, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["cuisine"] = answer + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/cuisine/AddCuisineForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/cuisine/AddCuisineForm.kt new file mode 100644 index 00000000000..32ed95fae42 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/cuisine/AddCuisineForm.kt @@ -0,0 +1,15 @@ +package de.westnordost.streetcomplete.quests.cuisine + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AMultiValueQuestForm + + +class AddCuisineForm : AMultiValueQuestForm() { + + override fun stringToAnswer(answerString: String) = answerString + + override fun getConstantSuggestions() = + requireContext().assets.open("cuisine/cuisineSuggestions.txt").bufferedReader().readLines() + + override val addAnotherValueResId = R.string.quest_cuisine_add_more +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/custom/CustomQuest.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/custom/CustomQuest.kt new file mode 100644 index 00000000000..e58e02e4646 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/custom/CustomQuest.kt @@ -0,0 +1,152 @@ +package de.westnordost.streetcomplete.quests.custom + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.AlertDialog +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.edits.ElementEdit +import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuest +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestType +import de.westnordost.streetcomplete.util.ktx.getActivity +import kotlinx.io.IOException +import org.koin.compose.koinInject +import java.io.File + +class CustomQuest(private val customQuestList: CustomQuestList) : ExternalSourceQuestType { + + override val changesetComment = "Edit user-defined list of elements" + override val wikiLink = "Tags" + override val icon = R.drawable.ic_quest_custom + override val defaultDisabledMessage = R.string.quest_custom_quest_message + + override fun getTitle(tags: Map): Int = R.string.quest_custom_quest_title + + override val source: String = "custom" + + override suspend fun download(bbox: BoundingBox) = getQuests(bbox) + + override var downloadEnabled = true // it's not actually a download, so no need to ever disable + + override suspend fun upload() { customQuestList.deleteSolved() } + + override fun getQuests(bbox: BoundingBox): Collection = customQuestList.get(bbox) + + override fun get(id: String): ExternalSourceQuest? = customQuestList.getQuest(id) + + override fun onAddedEdit(edit: ElementEdit, id: String) = customQuestList.markSolved(id) + + override fun onDeletedEdit(edit: ElementEdit, id: String?) { + if (edit.isSynced) return // if it's a real undo, can't undelete the line any more + id?.let { customQuestList.markSolved(it, false) } + } + + override fun onSyncedEdit(edit: ElementEdit, id: String?) { + id?.let { customQuestList.markSolved(it) } // just mark as solved, and bunch-delete in the end + } + + override fun onSyncEditFailed(edit: ElementEdit, id: String?) { + id?.let { customQuestList.markSolved(it, false) } + } + + override suspend fun onUpload(edit: ElementEdit, id: String?): Boolean = true + + override fun deleteQuest(id: String): Boolean = customQuestList.delete(id) + + override fun deleteMetadataOlderThan(timestamp: Long) { } + + override val hasQuestSettings: Boolean = true + + @Composable + override fun QuestSettings(context: Context, onDismissRequest: () -> Unit) { + val file = File(context.getExternalFilesDir(null), FILENAME_CUSTOM_QUEST) + val activity = LocalContext.current.getActivity()!! + val customQuestList: CustomQuestList = koinInject() + val importIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "text/comma-separated-values" + } + val exportIntent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_TITLE, FILENAME_CUSTOM_QUEST) + type = "text/comma-separated-values" + } + val importFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode != Activity.RESULT_OK || it.data == null) + return@rememberLauncherForActivityResult + val uri = it.data?.data ?: return@rememberLauncherForActivityResult + readFromUriToExternalFile(uri, file.name, activity) + customQuestList.reload() + onDismissRequest() + } + val exportFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode != Activity.RESULT_OK || it.data == null) + return@rememberLauncherForActivityResult + val uri = it.data?.data ?: return@rememberLauncherForActivityResult + writeFromExternalFileToUri(file.name, uri, activity) + onDismissRequest() + } + AlertDialog( + onDismissRequest = onDismissRequest, + buttons = { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + TextButton({ importFileLauncher.launch(importIntent) }) { + Text(stringResource(R.string.tree_custom_quest_import)) + } + if (file.exists()) + TextButton({ exportFileLauncher.launch(exportIntent) }) { + Text(stringResource(R.string.tree_custom_quest_export)) + } + TextButton(onDismissRequest) { Text(stringResource(android.R.string.cancel)) } + } + }, + title = { Text(stringResource(R.string.pref_custom_title)) }, + text = { Text(stringResource(R.string.tree_custom_quest_import_export_message)) } + ) + } + + // todo: don't force override any more + override fun getQuestSettingsDialog(context: Context): AlertDialog? = null + + override fun createForm() = CustomQuestForm() +} + +fun readFromUriToExternalFile(uri: Uri, filename: String, activity: Activity) { + try { + activity.contentResolver?.openInputStream(uri)?.use { it.bufferedReader().use { reader -> + File(activity.getExternalFilesDir(null), filename).writeText(reader.readText()) + } } + } catch (_: IOException) { + AlertDialog.Builder(activity) + .setMessage(R.string.pref_save_file_error) + .setPositiveButton(android.R.string.ok, null) + .show() + } +} + +fun writeFromExternalFileToUri(filename: String, uri: Uri, activity: Activity) { + try { + activity.contentResolver?.openOutputStream(uri)?.use { it.bufferedWriter().use { writer -> + writer.write(File(activity.getExternalFilesDir(null), filename).readText()) + } } + } catch (_: IOException) { + AlertDialog.Builder(activity) + .setMessage(R.string.pref_save_file_error) + .setPositiveButton(android.R.string.ok, null) + .show() + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/custom/CustomQuestForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/custom/CustomQuestForm.kt new file mode 100644 index 00000000000..bd6408a000d --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/custom/CustomQuestForm.kt @@ -0,0 +1,72 @@ +package de.westnordost.streetcomplete.quests.custom + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.commit +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController +import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey +import de.westnordost.streetcomplete.quests.AbstractExternalSourceQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.screens.main.MainActivity +import de.westnordost.streetcomplete.screens.main.bottom_sheet.CreatePoiFragment +import de.westnordost.streetcomplete.screens.main.bottom_sheet.toTags +import de.westnordost.streetcomplete.util.ktx.toast +import org.koin.android.ext.android.inject + +class CustomQuestForm : AbstractExternalSourceQuestForm() { + +// switch back to this form if some sort of longer text field should be added +// override val contentLayoutResId = R.layout.quest_osmose_custom_quest +// private val binding by contentViewBinding(QuestOsmoseCustomQuestBinding::bind) + private lateinit var entryId: String + private var tagsText: String? = null + private var pos: LatLon? = null + + private val questController: ExternalSourceQuestController by inject() + private val customQuestList: CustomQuestList by inject() + + override val buttonPanelAnswers by lazy { + val t = tagsText + val p = pos + listOfNotNull( + AnswerItem(R.string.quest_custom_quest_remove) { questController.delete(questKey as ExternalSourceQuestKey) }, + if (t != null && p != null) + AnswerItem(R.string.quest_custom_quest_add_node) { + val f = CreatePoiFragment.createWithPrefill(t, p, questKey) + parentFragmentManager.commit { + replace(id, f, "bottom_sheet") + addToBackStack("bottom_sheet") + } + (activity as? MainActivity)?.offsetPos(p) + } + else null + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + entryId = (questKey as ExternalSourceQuestKey).id + val entry = customQuestList.getEntry(entryId) + if (entry == null) { + context?.toast(R.string.quest_custom_quest_osmose_not_found) + questController.delete(questKey as ExternalSourceQuestKey) + return + } + val text = entry.text + + if (text.contains("addNode")) { + setTitle(resources.getString(R.string.quest_custom_quest_title) + " ${text.substringBefore("addNode")}") + val tags = text.substringAfter("addNode").replace(",", "\n").toTags() + tagsText = tags.map { "${it.key}=${it.value}" }.joinToString("\n") + pos = entry.position ?: entry.elementKey?.let { mapDataSource.getGeometry(it.type, it.id)?.center } + if (pos == null) { + setTitleHintLabel(getString(R.string.quest_custom_quest_add_node_text, null)) // should never happen, because we can locate the quest + return + } + setTitleHintLabel(getString(R.string.quest_custom_quest_add_node_text, "\n$tagsText")) + } else + setTitle(resources.getString(R.string.quest_custom_quest_title) + " $text") + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/custom/CustomQuestList.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/custom/CustomQuestList.kt new file mode 100644 index 00000000000..540c91df8d5 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/custom/CustomQuestList.kt @@ -0,0 +1,179 @@ +package de.westnordost.streetcomplete.quests.custom + +import android.content.Context +import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource +import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuest +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestType +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry +import de.westnordost.streetcomplete.util.math.contains +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.File +import java.io.IOException +import kotlin.Exception + +class CustomQuestList(context: Context) : KoinComponent { + private val entriesById by lazy { + // need to load by lazy, because there is a problem if mapDataWithEditsSource is accessed early + val m = hashMapOf() + load(m) + m + } + private val path = context.getExternalFilesDir(null) + + private val mapDataWithEditsSource: MapDataWithEditsSource by inject() + private val questTypeRegistry: QuestTypeRegistry by inject() + private val questController: ExternalSourceQuestController by inject() + + init { + val oldfile = File(path, FILENAME_OLD) + if (oldfile.exists()) + oldfile.renameTo(File(path, FILENAME_CUSTOM_QUEST)) + } + + fun reload() = load(entriesById) + + fun load(m: MutableMap) { + val file = File(path, FILENAME_CUSTOM_QUEST) + m.clear() + if (!file.exists()) { + try { + file.parentFile?.mkdirs() + file.createNewFile() + } catch (_: IOException) { + // sometimes can't be created, don't show an error message in this case + return + } + } + m.putAll(file.readLines().asReversed().mapNotNull { line -> + val rawText = line.substringAfter(',').substringAfter(',') + val text = if (rawText.endsWith(",solved")) + rawText.substringBeforeLast(',') + else rawText + val id = line.getId() + if (id == null) null + else + id to CustomQuestEntry(id).also { + it.text = text + it.solved = rawText.endsWith(",solved") + } + }) + } + + fun addEntry(element: Element, message: String) { + val id = "${element.type},${element.id}".getId() ?: return + if (entriesById.containsKey(id)) return + val entry = CustomQuestEntry(id).apply { text = message } + entriesById[id] = entry + val file = File(path, FILENAME_CUSTOM_QUEST) + file.appendText("\n$id,$message") + getQuest(id)?.let { questController.addQuests(listOf(it)) } + } + + fun getEntry(id: String) = entriesById[id] + + fun getQuest(id: String): ExternalSourceQuest? { + val entry = getEntry(id) ?: return null + if (entry.solved) return null + val geometry = entry.elementKey?.let { mapDataWithEditsSource.getGeometry(it.type, it.id) } + ?: entry.position?.let { ElementPointGeometry(it) } ?: return null + return ExternalSourceQuest( + id, + geometry, + questTypeRegistry.getByName(CustomQuest::class.simpleName!!) as ExternalSourceQuestType, + geometry.center + ).apply { entry.elementKey?.let { elementKey = it } } + } + + fun get(bbox: BoundingBox): List { + val type = questTypeRegistry.getByName(CustomQuest::class.simpleName!!) as ExternalSourceQuestType + return entriesById.values.mapNotNull { entry -> + if (entry.solved) return@mapNotNull null + val geometry = entry.elementKey?.let { mapDataWithEditsSource.getGeometry(it.type, it.id) } + ?: entry.position?.let { ElementPointGeometry(it) } ?: return@mapNotNull null + if (geometry.center !in bbox) return@mapNotNull null + ExternalSourceQuest(entry.id, geometry, type, geometry.center) + } + } + + fun markSolved(id: String, solved: Boolean = true) { + if (entriesById[id]?.solved == solved) return + entriesById[id]?.solved = solved + val file = File(path, FILENAME_CUSTOM_QUEST) + val lines = file.readLines().toMutableList() + var lineToChange = -1 + for (i in lines.indices) { + if (lines[i].getId() == id + && ((solved && !lines[i].endsWith(",solved")) + || !solved && lines[i].endsWith(",solved")) + ) { + lineToChange = i + break + } + } + if (lineToChange == -1) return // should not happen, but crashes also should not happen + lines[lineToChange] = if (solved) lines[lineToChange] + ",solved" + else lines[lineToChange].substringBeforeLast(',') + file.writeText(lines.joinToString("\n")) + } + + + fun deleteSolved() { delete(entriesById.filterValues { it.solved }.map { it.key }) } + + fun delete(id: String) = delete(listOf(id)) + + fun delete(idList: List): Boolean { + if (idList.isEmpty()) return false + val ids = idList.toMutableSet() + val deletedAny = entriesById.keys.removeAll(ids) + val file = File(path, FILENAME_CUSTOM_QUEST) + val lines = file.readLines().toMutableList() + val iterator = lines.iterator() + while (iterator.hasNext()) { + val id = iterator.next().getId() + if (id in ids) { + iterator.remove() + ids.remove(id) + if (ids.isEmpty()) break + } + } + file.writeText(lines.joinToString("\n")) + return deletedAny + } +} + +private fun String.getId(): String? { + val first = substringBefore(',').trim() + val second = substringAfter(',').substringBefore(',').trim() + return if ((first.matches(nodeWayRelation) && second.toLongOrNull() != null) || (first.toDoubleOrNull() != null && second.toDoubleOrNull() != null)) + "${first.uppercase()},${second.uppercase()}" + else null +} + +data class CustomQuestEntry(val id: String ) { + val elementKey = try { + ElementKey(ElementType.valueOf(id.substringBefore(',').uppercase()), + id.substringAfter(',').substringBefore(',').toLong()) + } catch (e: Exception) { + null + } + val position = try { + LatLon(id.substringBefore(',').toDouble(), id.substringAfter(',').substringBefore(',').toDouble()) + } catch (e: Exception) { + null + } + var text: String = "" + var solved: Boolean = false +} + +const val FILENAME_CUSTOM_QUEST = "custom_quest.csv" +private const val FILENAME_OLD = "external.csv" + +private val nodeWayRelation = "node|way|relation".toRegex(RegexOption.IGNORE_CASE) diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/destination/AddDestination.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/destination/AddDestination.kt new file mode 100644 index 00000000000..41a9537c4e3 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/destination/AddDestination.kt @@ -0,0 +1,259 @@ +package de.westnordost.streetcomplete.quests.destination + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Relation +import de.westnordost.streetcomplete.data.osm.mapdata.Way +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.groupByNodeIds +import de.westnordost.streetcomplete.osm.isForwardOneway +import de.westnordost.streetcomplete.osm.isReversedOneway +import de.westnordost.streetcomplete.quests.questPrefix +import de.westnordost.streetcomplete.quests.singleTypeElementSelectionDialog +import de.westnordost.streetcomplete.util.ktx.allExceptFirstAndLast +import de.westnordost.streetcomplete.util.math.finalBearingTo +import de.westnordost.streetcomplete.util.math.initialBearingTo +import de.westnordost.streetcomplete.util.math.isCompletelyInside +import de.westnordost.streetcomplete.util.math.normalizeDegrees +import kotlin.math.abs + +class AddDestination : OsmElementQuestType> { + + // need to filter elements with not-counting lanes + // later lanes could be counted from available data if possible + private val roadsFilter by lazy { """ + ways with + highway ~ ${prefs.getString(questPrefix(prefs) + PREF_DESTINATION_ROADS, ROADS_FOR_DESTINATION.joinToString("|"))} + and !destination and !~ destination:.* + and junction !~ roundabout|circular + and (oneway = yes or (!oneway and (!lanes or lanes = 2))) + and cycleway != lane and !cycleway:lane and !cycleway:lanes and !bicycle:lanes and cycleway:left != lane and cycleway:right != lane and cycleway:both != lane and cycleway != opposite_lane + and !motorcycle:lanes + """.toElementFilterExpression() + } + + // this filter must contain all ways matched by roadFilter + // essentially these are the starting roads from which roads on the roadsFilter are reached + // is there any reason not to use ALL_ROADS? + private val branchingOffFromFilter by lazy { """ + ways with + highway ~ ${prefs.getString(questPrefix(prefs) + PREF_DESTINATION_ROADS, ROADS_FOR_DESTINATION.joinToString("|"))} + """.toElementFilterExpression() } + + override val changesetComment = "Add destination" + override val wikiLink = "Key:destination" + override val icon = R.drawable.ic_quest_destination // not nice, but ok for now + override val defaultDisabledMessage = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_destination_title + + override fun getApplicableElements(mapData: MapDataWithGeometry): Iterable { + // we need the bbox because we only want ways fully in bbox (less strict in overlay...) + val bbox = mapData.boundingBox ?: return emptyList() + // we need from-members of restrictions + val restrictionsByFromMemberId = hashMapOf() + // and we don't want to tag destination on to-members of destination_sign + val destinationSignToMembersIds = hashSetOf() + mapData.relations.forEach { rel -> + when (rel.tags["type"]) { + "destination_sign" -> rel.members.forEach { if (it.type == ElementType.WAY && it.role == "to") destinationSignToMembersIds.add(it.ref) } + // technically a way could be member of more than 1 restriction with same role (in different directions), but ignore it for now + "restriction" -> rel.members.forEach { if (it.type == ElementType.WAY && it.role == "from") restrictionsByFromMemberId[it.ref] = rel } + } + } + + // this needs to contain all roads matching roadFilter! + val roads = mapData.ways.filter { branchingOffFromFilter.matches(it) } + val roadsByNodeId = mapData.ways.filter { branchingOffFromFilter.matches(it) }.asSequence().groupByNodeIds() + + // maybe could be done without separating oneway and twoway here + val (oneWayCandidates, twoWayCandidates) = roads.filter { + roadsFilter.matches(it) + && it.id !in destinationSignToMembersIds + && mapData.getWayGeometry(it.id)?.getBounds()?.isCompletelyInside(bbox) == true // because otherwise filter below may fail + && it.nodeIds.allExceptFirstAndLast().all { roadsByNodeId[it]!!.size == 1 } // ignore roads with branches not at the end (need to split, but that's for the overlay) + } + .partition { it.tags["oneway"] == "yes" } // other oneway tags are ignored by the filter + if (oneWayCandidates.isEmpty() && twoWayCandidates.isEmpty()) return emptyList() + + // oneway = yes -> only care about first node + val oneWayCandidatesByFirstNodeId = oneWayCandidates.groupBy { it.nodeIds.first() } + // not oneway -> need both end nodes + val twoWayCandidatesByEndNodeIds = hashMapOf>() + twoWayCandidates.forEach { + twoWayCandidatesByEndNodeIds.getOrPut(it.nodeIds.first()) { mutableListOf() }.add(it) + twoWayCandidatesByEndNodeIds.getOrPut(it.nodeIds.last()) { mutableListOf() }.add(it) + } + + // remove nodes that are not start/end nodes for a candidate + roadsByNodeId.keys.retainAll(oneWayCandidatesByFirstNodeId.keys + twoWayCandidatesByEndNodeIds.keys) // only keep nodes where a candidate starts or ends + + // find + val eligibleWays = hashSetOf() + + for ((nodeId, ways) in roadsByNodeId) { + if (ways.size < 2) continue // we need at least 2 ways, or it doesn't make sense + + val oneWay = oneWayCandidatesByFirstNodeId[nodeId] ?: emptyList() + val twoWay = twoWayCandidatesByEndNodeIds[nodeId] ?: emptyList() + val candidates = oneWay + twoWay + + // check if we can go from way to at any of the other ways + for (way in ways) { + val otherWays = ways - way + + // if none of the other ways is a candidate, there is nothing to do + if (otherWays.none { it in candidates }) continue + + // if we can't travel to this node on this way, there is nothing to do + if (!way.allowsFromAnyNeighboringNodeTo(nodeId)) continue + + // if relation exists, way is a from-member -> do nothing if it's only_ and the via node is nodeId + val rel = restrictionsByFromMemberId[way.id] + if (rel != null + && rel.tags["restriction"]?.startsWith("only_") == true + && rel.members.any { it.type == ElementType.NODE && it.ref == nodeId && it.role == "via" } + ) continue + + // we want the bearing when going towards nodeId on way for the turn degrees check + val wayBearings = way.getAllowedBearingGoingTo(nodeId, mapData) + + val otherAvailableWays = mutableListOf() + for (otherWay in otherWays) { + // ignore way if we can't enter + if (!otherWay.allowsToAnyNeighboringNodeFrom(nodeId)) continue + + // ignore way if we are not allowed by restriction (actually this ignores via ways...) + if (rel != null + && rel.tags["restriction"]?.startsWith("no_") == true + && rel.members.any { it.type == ElementType.WAY && it.ref == otherWay.id && it.role == "to" } + ) continue + + // ignore if we would need to turn by more than 115° + // we want the bearing when leaving from nodeId on otherWay + val otherWayBearings = try { + otherWay.getAllowedBearingStartingAt(nodeId, mapData) + } catch (e: NullPointerException) { + // node not in mapData + // for some reason this sometimes happens on uploading a split way action + // but why only ever for this quest? missing data should affect others too + // data issue? cache issue? another scee "optimization" issue? + emptyList() + } + if (wayBearings.any { b -> otherWayBearings.any { abs(normalizeDegrees(b - it, -180.0)) < 115 } }) + otherAvailableWays.add(otherWay) + } + + // take candidates from otherWays, but only if there are at least 2 otherWays or + // a single one that goes through nodeId + if (otherAvailableWays.size > 1 || otherAvailableWays.any { it.nodeIds.allExceptFirstAndLast().contains(nodeId) }) + eligibleWays.addAll(otherAvailableWays.filter { it in candidates }) + } + } + return eligibleWays + } + + override fun isApplicableTo(element: Element): Boolean? = + if (roadsFilter.matches(element)) null + else false + + override fun createForm() = AddDestinationForm() + + override fun applyAnswerTo(answer: Pair, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + answer.first?.applyTo(tags, false) + answer.second?.applyTo(tags, true) + } + + override val hasQuestSettings = true + + override fun getQuestSettingsDialog(context: Context): AlertDialog { + return singleTypeElementSelectionDialog(context, + prefs, + questPrefix(prefs) + PREF_DESTINATION_ROADS, + ROADS_FOR_DESTINATION.joinToString("|"), + R.string.quest_settings_eligible_highways) + } +} + +private const val PREF_DESTINATION_ROADS = "qs_AddDestination_road_selection" +private val ROADS_FOR_DESTINATION = listOf("motorway", "motorway_link", "trunk", "trunk_link", + "primary", "primary_link", "secondary", "secondary_link", "tertiary", "tertiary_link") + +// returns bearings going from [nodeId] to a neighboring node +// but only towards nodes allowed by oneway +private fun Way.getAllowedBearingStartingAt(nodeId: Long, mapData: MapDataWithGeometry): List { + if (!allowsToAnyNeighboringNodeFrom(nodeId)) return emptyList() + val startPos = mapData.getNode(nodeId)!!.position + val nodeIndex = nodeIds.indexOf(nodeId) + // ways in mapData are complete, so we must have all way nodes + return when (nodeIndex) { + 0 -> { + val p = mapData.getNode(nodeIds[1])?.position + require(p != null) { "node ${nodeIds[1]} not in mapData, but it's in way $this" } + listOf(startPos.initialBearingTo(p)) + } + nodeIds.lastIndex -> listOf(startPos.initialBearingTo(mapData.getNode(nodeIds[nodeIndex - 1])!!.position)) + else -> listOfNotNull( + if (!isReversedOneway(tags)) // i to i+1 not allowed if reverse + startPos.initialBearingTo(mapData.getNode(nodeIds[nodeIndex + 1])!!.position) + else null, + if (!isForwardOneway(tags)) // i to i-1 not allowed if forward + startPos.initialBearingTo(mapData.getNode(nodeIds[nodeIndex - 1])!!.position) + else null, + ) + } +} + +// returns bearings going from a neighboring node to [nodeId] +// but only for directions allowed by oneway +private fun Way.getAllowedBearingGoingTo(nodeId: Long, mapData: MapDataWithGeometry): List { + if (!allowsFromAnyNeighboringNodeTo(nodeId)) return emptyList() + val endPos = mapData.getNode(nodeId)!!.position + val nodeIndex = nodeIds.indexOf(nodeId) + // ways in mapData are complete, so we must have all way nodes + return when (nodeIndex) { + 0 -> { + val n = mapData.getNode(nodeIds[1]) + require(n != null) { "node ${nodeIds[1]} not in mapData, but it's in way $this" } + listOf(n.position.finalBearingTo(endPos)) + } + nodeIds.lastIndex -> listOf(mapData.getNode(nodeIds[nodeIndex - 1])!!.position.finalBearingTo(endPos)) + else -> listOfNotNull( + if (!isForwardOneway(tags)) // i+1 to i not allowed if forward + mapData.getNode(nodeIds[nodeIndex + 1])!!.position.finalBearingTo(endPos) + else null, + if (!isReversedOneway(tags)) // i-1 to i not allowed if reverse + mapData.getNode(nodeIds[nodeIndex - 1])!!.position.finalBearingTo(endPos) + else null, + ) + } +} + +private fun Way.allowsFromAnyNeighboringNodeTo(nodeId: Long): Boolean { + val onewayForward = isForwardOneway(tags) + val onewayBackward = isReversedOneway(tags) + if (!onewayForward && !onewayBackward) return true + return when (nodeIds.indexOf(nodeId)) { + 0 -> onewayBackward + nodeIds.lastIndex -> onewayForward + else -> true + } +} + +private fun Way.allowsToAnyNeighboringNodeFrom(nodeId: Long): Boolean { + val onewayForward = isForwardOneway(tags) + val onewayBackward = isReversedOneway(tags) + if (!onewayForward && !onewayBackward) return true + return when (nodeIds.indexOf(nodeId)) { + 0 -> onewayForward + nodeIds.lastIndex -> onewayBackward + else -> true + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/destination/AddDestinationForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/destination/AddDestinationForm.kt new file mode 100644 index 00000000000..ca9288d4315 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/destination/AddDestinationForm.kt @@ -0,0 +1,369 @@ +package de.westnordost.streetcomplete.quests.destination + +import android.content.Context +import android.graphics.Color +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.os.Bundle +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup.LayoutParams +import android.widget.AdapterView +import android.widget.Button +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.AnyThread +import androidx.core.content.ContextCompat +import androidx.core.view.doOnLayout +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource +import de.westnordost.streetcomplete.data.osm.geometry.ElementPolylinesGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.databinding.QuestDestinationBinding +import de.westnordost.streetcomplete.databinding.QuestDestinationLaneBinding +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.isOneway +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.quests.lanes.LineStyle +import de.westnordost.streetcomplete.util.SearchAdapter +import de.westnordost.streetcomplete.util.ktx.dpToPx +import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope +import de.westnordost.streetcomplete.util.math.enlargedBy +import de.westnordost.streetcomplete.util.math.getOrientationAtCenterLineInDegrees +import de.westnordost.streetcomplete.util.takeFavourites +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject + +class AddDestinationForm : AbstractOsmQuestForm>() { + + private val mapDataSource: MapDataWithEditsSource by inject() +// private val questTypeRegistry: QuestTypeRegistry by inject() + + override val contentLayoutResId = R.layout.quest_destination + private val binding by contentViewBinding(QuestDestinationBinding::bind) + + /* // todo: add later, once more lanes are allowed + override val otherAnswers get() = listOf(AnswerItem(R.string.quest_lanes_title) { // todo: text + // show lanes quest, because just removing lanes doesn't necessarily show lanes quest! + (activity as? MainActivity) + val lanesQuestType = questTypeRegistry.getByName("AddLanes")!! + val key = (questKey as OsmQuestKey).copy(questTypeName = lanesQuestType.name) + val f = AddLanesForm() + f.arguments = createArguments(key, lanesQuestType, geometry, 0f, 0f) // looks like lanes form gets correct orientation anyway + val osmArgs = createArguments(element) + f.requireArguments().putAll(osmArgs) + parentFragmentManager.commit { + replace(id, f, "bottom_sheet") + addToBackStack("bottom_sheet") + } + }) +*/ + private var currentLane = 0 + private var currentIsBackward = false + private var forward: DestinationLanes? = null + private var backward: DestinationLanes? = null + private val currentDestinations: MutableSet get() { + val lanes = if (currentIsBackward) backward else forward + return lanes!!.get(currentLane) // should never be null, as it's set in showInput + } + + private var wayRotation: Float = 0f + + private val destination get() = binding.destinationInput.text?.toString().orEmpty().trim() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + wayRotation = (geometry as ElementPolylinesGeometry).getOrientationAtCenterLineInDegrees() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.destinationInput.setAdapter(SearchAdapter(requireContext(), { getSuggestions(it) }, { it })) + binding.destinationInput.onItemClickListener = AdapterView.OnItemClickListener { _, t, _, _ -> + val destination = (t as? TextView)?.text?.toString() ?: return@OnItemClickListener + if (!currentDestinations.add(destination)) return@OnItemClickListener // we don't want duplicates + onAddedDestination() + } + + binding.destinationInput.doAfterTextChanged { + if (it.toString().endsWith("\n")) + finishCurrentDestination() + checkIsFormComplete() + } + binding.destinationInput.doOnLayout { binding.destinationInput.dropDownWidth = binding.destinationInput.width - requireContext().resources.dpToPx(60).toInt() } + + binding.addDestination.setOnClickListener { + if (binding.destinationInput.text.isBlank()) return@setOnClickListener + onAddedDestination() + } + + if (isOneway(element.tags)) { + currentIsBackward = false + showInput(getLaneCountInCurrentDirection()) + } else { + // show side selector + binding.destinationInput.isGone = true + binding.addDestination.isGone = true + binding.lanesContainer.isGone = true + binding.sideSelect.root.isVisible = true + + // and make it work, this is essentially a condensed copy of AddLanesForm.setStreetSideLayout + val puzzleView = binding.sideSelect.puzzleView + lifecycle.addObserver(puzzleView) + puzzleView.isShowingLaneMarkings = true + puzzleView.isShowingBothSides = true + puzzleView.isForwardTraffic = !countryInfo.isLeftHandTraffic + val edgeLine = countryInfo.edgeLineStyle + puzzleView.edgeLineColor = + if (edgeLine.contains("yellow")) Color.YELLOW else Color.WHITE + puzzleView.edgeLineStyle = when { + !edgeLine.contains("dashes") -> LineStyle.CONTINUOUS + edgeLine.contains("short") -> LineStyle.SHORT_DASHES + else -> LineStyle.DASHES + } + puzzleView.centerLineColor = if (countryInfo.centerLineStyle.contains("yellow")) Color.YELLOW else Color.WHITE + val forwardLanes = getLaneCountInCurrentDirection() + currentIsBackward = true + val backwardLanes = getLaneCountInCurrentDirection() + if (countryInfo.isLeftHandTraffic) + puzzleView.setLaneCounts(forwardLanes, backwardLanes, false) + else + puzzleView.setLaneCounts(backwardLanes, forwardLanes, false) + // and set the click listener + puzzleView.onClickListener = null + puzzleView.onClickSideListener = { isRight -> + currentIsBackward = !isRight + if (countryInfo.isLeftHandTraffic) + currentIsBackward = !currentIsBackward + showInput(getLaneCountInCurrentDirection()) + // maybe: hide side selector, and have a button to show it again? + } + } + + // start loading the lazy thing now that everything else is done + viewLifecycleScope.launch(Dispatchers.IO) { suggestions } + } + + private fun getLaneCountInCurrentDirection(): Int { + if (currentIsBackward) + element.tags["lanes:backward"]?.toIntOrNull()?.let { return it } + else + element.tags["lanes:forward"]?.toIntOrNull()?.let { return it } + val lanes = element.tags["lanes"]?.toIntOrNull() + if (isOneway(element.tags)) return lanes ?: 1 + return ((lanes ?: 2) / 2).coerceAtLeast(1) + } + + // todo: two lanes in one direction is not yet working properly + // orientation is confusing + // when selecting other side and going back the marks are missing and "all lanes" is showing again + // and sometimes the current destination view stays when switching sides + private fun showInput(laneCount: Int) { + // initialize forward/backward if necessary + if (currentIsBackward) { + if (backward?.count != laneCount) backward = DestinationLanes(laneCount) + } else { + if (forward?.count != laneCount) forward = DestinationLanes(laneCount) + } + + if (laneCount == 1) { + currentLane = 1 + binding.destinationInput.isVisible = true + binding.addDestination.isVisible = true + binding.lanesContainer.isGone = true + binding.currentDestinations.text = currentDestinations.joinToString(", ") + binding.destinationInput.requestFocus() + viewLifecycleScope.launch { + delay(30) +// binding.destinationInput.showDropDown() // working in cuisine form, but not here? + binding.destinationInput.setText("") // but this works + } + return + } + + binding.destinationInput.isGone = true + binding.addDestination.isGone = true + binding.lanesContainer.isVisible = true + binding.lanesContainer.removeAllViews() + // hide the whole container after selecting all lanes + binding.lanesContainer.addView(Button(requireContext()).apply { + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + setText(R.string.quest_destination_all_lanes_button) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + setTextColor(ContextCompat.getColor(requireContext(), R.color.button_bar_button_text)) + setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.background)) + setOnClickListener { showInput(1) } + tag = 0 + }) + + repeat(laneCount) { idx -> + val lane = idx + 1 + val b = QuestDestinationLaneBinding.inflate(layoutInflater) + b.lane.setOnClickListener { + // remove "all lanes" button if lane tapped + // and show the lanes input + binding.lanesContainer.findViewWithTag(0)?.let { + binding.destinationInput.isVisible = true + binding.addDestination.isVisible = true + binding.lanesContainer.removeView(it) + } + if (currentLane == lane) return@setOnClickListener + + b.lane.colorFilter = PorterDuffColorFilter(ContextCompat.getColor(requireContext(), R.color.accent), PorterDuff.Mode.MULTIPLY) + val previousView: View? = binding.lanesContainer.findViewWithTag(currentLane) + previousView?.findViewById(R.id.lane)?.colorFilter = null + + if (currentLane != 0) { + finishCurrentDestination() + // set remove / checkmark + if (currentDestinations.isEmpty()) + previousView?.findViewById(R.id.check)?.isGone = true + else + previousView?.findViewById(R.id.check)?.isVisible = true + } + + currentLane = lane + + binding.currentDestinations.text = currentDestinations.joinToString(", ") + binding.destinationInput.requestFocus() + } + b.root.tag = lane + binding.lanesContainer.addView(b.root) + } + } + + @AnyThread + override fun onMapOrientation(rotation: Double, tilt: Double) { + val mapRotation = rotation.toFloat() + val mapTilt = tilt.toFloat() + + binding.sideSelect.puzzleViewRotateContainer.streetRotation = wayRotation - mapRotation + binding.sideSelect.littleCompass.root.rotation = -mapRotation + binding.sideSelect.littleCompass.root.rotationX = mapTilt + } + + override fun onClickOk() { + finishCurrentDestination() + // one side is complete, but the other may not be, e.g. if the user clicked the wrong side + if (forward?.isComplete == false) forward = null + if (backward?.isComplete == false) backward = null + if (forward == null && backward == null) return // should never happen + applyAnswer(forward to backward) + + prefs.addLastPicked(javaClass.simpleName, getAllCurrentDestinations().toList()) + } + + override fun isFormComplete(): Boolean { + val forwardComplete = forward?.isComplete ?: false + val backwardComplete = backward?.isComplete ?: false + val forwardEmpty = forward?.isEmpty ?: true + val backwardEmpty = backward?.isEmpty ?: true + + if ((forwardComplete && backwardComplete) + || (forwardComplete && backwardEmpty) + || (forwardEmpty && backwardComplete) + ) return true + + if (binding.destinationInput.text.isNullOrBlank()) return false + return if (currentIsBackward) + backward?.isCompleteExcept(currentLane) == true && (forwardComplete || forwardEmpty) + else + forward?.isCompleteExcept(currentLane) == true && (backwardComplete || backwardEmpty) + } + + override fun isRejectingClose() = isFormComplete() || binding.destinationInput.text.isNotBlank() || backward?.isEmpty == false || forward?.isEmpty == false + + private fun finishCurrentDestination() { + currentDestinations.removeAll { it.isBlank() } + if (destination.isNotBlank()) currentDestinations.add(destination) + binding.destinationInput.text.clear() + setCurrentDestinationsView() + checkIsFormComplete() + } + + private fun onAddedDestination() { + finishCurrentDestination() + if (binding.lanesContainer.isGone) + viewLifecycleScope.launch { + delay(30) + binding.destinationInput.showDropDown() + } + } + + private fun setCurrentDestinationsView() { + binding.currentDestinations.text = currentDestinations.joinToString(", ") + } + + private fun getAllCurrentDestinations(): Set { + val destinations = hashSetOf() + forward?.let { destinations.addAll(it.getDestinations()) } + backward?.let { destinations.addAll(it.getDestinations()) } + return destinations + } + + private fun getSuggestions(search: String) = (getAllCurrentDestinations() + suggestions) + .filter { it.startsWith(search, true) && it !in currentDestinations } + + private val suggestions by lazy { + val data = mapDataSource.getMapDataWithGeometry(geometry.getBounds().enlargedBy(100.0)) + val suggestions = hashSetOf() + data.filter("ways, relations with destination or destination:forward or destination:backward or destination:lanes") + .forEach { + it.tags["destination"]?.let { suggestions.addAll(it.split(";")) } + it.tags["destination:forward"]?.let { suggestions.addAll(it.split(";")) } + it.tags["destination:backward"]?.let { suggestions.addAll(it.split(";")) } + it.tags["destination:lanes"]?.let { suggestions.addAll(it.split(";", "|")) } + } + (suggestions + lastPickedAnswers).distinct() + } + + private val lastPickedAnswers by lazy { + prefs.getLastPicked(javaClass.simpleName).takeFavourites(20, 50, 1) + } +} + +class DestinationLanes(val count: Int) { + init { require(count > 0) { "count $count must be positive" } } + private val destinationsByLane = hashMapOf>() + fun set(lane: Int, destinations: MutableSet) { + checkLane(lane) + destinationsByLane[lane] = destinations + } + fun get(lane: Int): MutableSet { + checkLane(lane) + return destinationsByLane.getOrPut(lane) { mutableSetOf() } + } + fun getDestinations() = destinationsByLane.values.flatten() + + val isEmpty get() = (1..count).all { destinationsByLane[it].isNullOrEmpty() } + val isComplete get() = (1..count).none { destinationsByLane[it].isNullOrEmpty() } + fun isCompleteExcept(lane: Int) = (1..count).filterNot { it == lane }.none { destinationsByLane[it].isNullOrEmpty() } + + private fun laneString(): String? { + if (!isComplete) return null + return destinationsByLane.entries.sortedBy { it.key } + .map { it.value }.joinToString("|") { it.joinToString(";") } + } + + private fun checkLane(lane: Int) = require(lane in 1..count) {"tried to access lane $lane outside laneCount $count" } + + // todo: for lane count also cycleways need to be considered + // but careful about sides! + // anyway, currently such cases are simply ignored by the filter + fun applyTo(tags: Tags, isBackward: Boolean) { + if (!isComplete) throw (IllegalStateException("cannot apply an incomplete destination answer")) + val tag = if (count > 1) "destination:lanes" else "destination" + if (isOneway(tags)) { + tags[tag] = laneString()!! + return + } + val forwardBackward = if (isBackward) ":backward" else ":forward" + tags[tag + forwardBackward] = laneString()!! + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/existence/CheckExistence.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/existence/CheckExistence.kt index ba8f3d6f5cd..0aeff8e310e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/existence/CheckExistence.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/existence/CheckExistence.kt @@ -2,11 +2,10 @@ package de.westnordost.streetcomplete.quests.existence import de.westnordost.osmfeatures.Feature import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry -import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.CITIZEN import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.OUTDOORS import de.westnordost.streetcomplete.osm.LAST_CHECK_DATE_KEYS @@ -16,9 +15,9 @@ import de.westnordost.streetcomplete.util.ktx.containsAll class CheckExistence( private val getFeature: (Element) -> Feature? -) : OsmElementQuestType { +) : OsmFilterQuestType() { - private val nodesFilter by lazy { """ + override val elementFilter = """ nodes with (( ( amenity = atm @@ -81,7 +80,7 @@ class CheckExistence( and (!seasonal or seasonal = no) and (!intermittent or intermittent = no) and (!permanent or permanent = yes) - """.toElementFilterExpression() } + """ // - traffic_calming = table is often used as a property of a crossing: we don't want the app // to delete the crossing if the table is not there anymore, so exclude that // - postboxes are in 4 years category so that postbox collection times is asked instead more often @@ -97,12 +96,6 @@ class CheckExistence( override fun getTitle(tags: Map) = R.string.quest_existence_title2 - override fun getApplicableElements(mapData: MapDataWithGeometry): Iterable = - mapData.filter { isApplicableTo(it) } - - override fun isApplicableTo(element: Element) = - nodesFilter.matches(element) && getFeature(element) != null - override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry): Sequence { /* put markers for objects that are exactly the same as for which this quest is asking for e.g. it's a ticket validator? -> display other ticket validators. Etc. */ diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/existence/CheckExistenceForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/existence/CheckExistenceForm.kt index a7340bea64c..cd1fa3eec50 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/existence/CheckExistenceForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/existence/CheckExistenceForm.kt @@ -7,7 +7,7 @@ import de.westnordost.streetcomplete.quests.AnswerItem class CheckExistenceForm : AbstractOsmQuestForm() { override val buttonPanelAnswers = listOf( - AnswerItem(R.string.quest_generic_hasFeature_no) { deletePoiNode() }, + AnswerItem(R.string.quest_generic_hasFeature_no) { deletePoiNode(false) }, // avoid showing edit with "edits in context of" changeset message AnswerItem(R.string.quest_generic_hasFeature_yes) { applyAnswer(Unit) } ) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/fire_hydrant_diameter/AddFireHydrantDiameter.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/fire_hydrant_diameter/AddFireHydrantDiameter.kt index 7ad1f0c79da..20b5e4b665f 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/fire_hydrant_diameter/AddFireHydrantDiameter.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/fire_hydrant_diameter/AddFireHydrantDiameter.kt @@ -6,9 +6,11 @@ import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry import de.westnordost.streetcomplete.data.osm.mapdata.filter import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.quest.AllCountries import de.westnordost.streetcomplete.data.quest.NoCountriesExcept import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.LIFESAVER import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.Prefs class AddFireHydrantDiameter : OsmFilterQuestType() { @@ -29,7 +31,7 @@ class AddFireHydrantDiameter : OsmFilterQuestType() { /* NOTE: if any countries that (sometimes) use anything else than millimeters as hydrant diameters are added, the code in the form needs to be adapted */ // source: https://commons.wikimedia.org/wiki/Category:Fire_hydrant_signs_by_country - override val enabledInCountries = NoCountriesExcept( + override val enabledInCountries = if (prefs.getBoolean(Prefs.OVERRIDE_COUNTRY_RESTRICTIONS, false)) AllCountries else NoCountriesExcept( "DE", "BE", "LU", // not "AT", - see https://community.openstreetmap.org/t/streetcomplete-quest-zu-hydrantendurchmesser-in-osterreich/108899 "GB", "IE", diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/fire_hydrant_diameter/AddFireHydrantDiameterForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/fire_hydrant_diameter/AddFireHydrantDiameterForm.kt index 7af77d24491..edf52f17e36 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/fire_hydrant_diameter/AddFireHydrantDiameterForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/fire_hydrant_diameter/AddFireHydrantDiameterForm.kt @@ -15,12 +15,9 @@ import de.westnordost.streetcomplete.quests.fire_hydrant_diameter.FireHydrantDia import de.westnordost.streetcomplete.quests.fire_hydrant_diameter.FireHydrantDiameterMeasurementUnit.MILLIMETER import de.westnordost.streetcomplete.util.ktx.intOrNull import de.westnordost.streetcomplete.util.takeFavourites -import org.koin.android.ext.android.inject class AddFireHydrantDiameterForm : AbstractOsmQuestForm() { - private val prefs: Preferences by inject() - override val otherAnswers = listOf( AnswerItem(R.string.quest_generic_answer_noSign) { confirmNoSign() } ) diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/general_ref/AddGeneralRef.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/general_ref/AddGeneralRef.kt new file mode 100644 index 00000000000..c71f01cd192 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/general_ref/AddGeneralRef.kt @@ -0,0 +1,58 @@ +package de.westnordost.streetcomplete.quests.general_ref + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.OUTDOORS +import de.westnordost.streetcomplete.osm.Tags + +class AddGeneralRef : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + ( + (information = guidepost or guidepost) and guidepost != simple and hiking = yes + or railway = subway_entrance and highway != elevator + or building ~ service|transformer_tower and power = substation + or man_made = street_cabinet + or highway = street_lamp + or golf = hole + ) + and !ref + and noref != yes + and ref:signed != no + and !~"ref:.*" + """ + override val changesetComment = "Specify refs" + override val wikiLink = "Key:ref" + override val icon = R.drawable.ic_quest_general_ref + override val isDeleteElementEnabled = true + override val achievements = listOf(OUTDOORS) + + override fun getTitle(tags: Map) = R.string.quest_genericRef_title + + // substation buildings are not highlighted because those are usually far apart + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter(""" + nodes with + information ~ guidepost|map + or railway = subway_entrance + or man_made = street_cabinet + or highway = street_lamp + """) + override val highlightedElementsRadius: Double get() = 200.0 + + override val defaultDisabledMessage: Int = R.string.quest_generalRef_disabled_msg + + override fun createForm() = AddGeneralRefForm() + + override fun applyAnswerTo(answer: GeneralRefAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + when (answer) { + is NoVisibleGeneralRef -> tags["ref:signed"] = "no" + is GeneralRef -> tags["ref"] = answer.ref + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/general_ref/AddGeneralRefForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/general_ref/AddGeneralRefForm.kt new file mode 100644 index 00000000000..c1f6abcd9e1 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/general_ref/AddGeneralRefForm.kt @@ -0,0 +1,47 @@ +package de.westnordost.streetcomplete.quests.general_ref + +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.QuestGeneralRefBinding +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.util.ktx.nonBlankTextOrNull + +class AddGeneralRefForm : AbstractOsmQuestForm() { + + override val contentLayoutResId = R.layout.quest_general_ref + private val binding by contentViewBinding(QuestGeneralRefBinding::bind) + + override val otherAnswers = listOf( + AnswerItem(R.string.quest_ref_answer_noRef) { confirmNoRef() } + ) + + private val ref get() = binding.refInput.nonBlankTextOrNull + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.refInput.doAfterTextChanged { checkIsFormComplete() } + + if (element.tags.containsKey("guidepost") || element.tags["information"] == "guidepost") { + binding.tvHint.setText(R.string.quest_guidepostRef_hint) + } + } + + override fun onClickOk() { + applyAnswer(GeneralRef(ref!!)) + } + + private fun confirmNoRef() { + val ctx = context ?: return + AlertDialog.Builder(ctx) + .setTitle(R.string.quest_generic_confirmation_title) + .setPositiveButton(R.string.quest_generic_confirmation_yes) { _, _ -> applyAnswer(NoVisibleGeneralRef) } + .setNegativeButton(R.string.quest_generic_confirmation_no, null) + .show() + } + + override fun isFormComplete() = ref != null +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/general_ref/GeneralRefAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/general_ref/GeneralRefAnswer.kt new file mode 100644 index 00000000000..fa72a035146 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/general_ref/GeneralRefAnswer.kt @@ -0,0 +1,6 @@ +package de.westnordost.streetcomplete.quests.general_ref + +sealed interface GeneralRefAnswer + +data class GeneralRef(val ref: String) : GeneralRefAnswer +object NoVisibleGeneralRef : GeneralRefAnswer diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/AddGuidepostEle.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/AddGuidepostEle.kt new file mode 100644 index 00000000000..c4dafa44edf --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/AddGuidepostEle.kt @@ -0,0 +1,40 @@ +package de.westnordost.streetcomplete.quests.guidepost + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.OUTDOORS +import de.westnordost.streetcomplete.osm.Tags + +class AddGuidepostEle : OsmFilterQuestType() { + + override val elementFilter = """ + nodes with + (information = guidepost or guidepost) and guidepost != simple + and !ele and !~"ele:.*" + and hiking = yes + """ + override val changesetComment = "Specify guidepost elevation" + override val wikiLink = "Tag:information=guidepost" + override val icon = R.drawable.ic_quest_guidepost_ele + override val isDeleteElementEnabled = true + override val achievements = listOf(OUTDOORS) + + override fun getTitle(tags: Map) = R.string.quest_guidepostEle_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("nodes with information = guidepost") + + override val highlightedElementsRadius: Double get() = 200.0 + override val defaultDisabledMessage: Int = R.string.quest_guidepost_disabled_msg + + override fun createForm() = AddGuidepostEleForm() + + override fun applyAnswerTo(answer: String, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["ele"] = answer + + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/AddGuidepostEleForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/AddGuidepostEleForm.kt new file mode 100644 index 00000000000..2e1b23d25e1 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/AddGuidepostEleForm.kt @@ -0,0 +1,29 @@ +package de.westnordost.streetcomplete.quests.guidepost + +import android.os.Bundle +import android.view.View +import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.QuestGuidepostEleBinding +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.util.ktx.nonBlankTextOrNull + +class AddGuidepostEleForm : AbstractOsmQuestForm() { + + override val contentLayoutResId = R.layout.quest_guidepost_ele + private val binding by contentViewBinding(QuestGuidepostEleBinding::bind) + + private val ele get() = binding.refInput.nonBlankTextOrNull + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.refInput.doAfterTextChanged { checkIsFormComplete() } + } + + override fun onClickOk() { + applyAnswer(ele!!) + } + + + override fun isFormComplete() = ele != null +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/AddGuidepostName.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/AddGuidepostName.kt new file mode 100644 index 00000000000..91187e351fa --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/AddGuidepostName.kt @@ -0,0 +1,44 @@ +package de.westnordost.streetcomplete.quests.guidepost + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.OUTDOORS +import de.westnordost.streetcomplete.osm.Tags + +class AddGuidepostName : OsmFilterQuestType() { + + override val elementFilter = """ + nodes with + (information = guidepost or guidepost) and guidepost != simple + and !name and noname != yes and !~"name:.*" + and hiking = yes + """ + override val changesetComment = "Specify guidepost name" + override val wikiLink = "Tag:information=guidepost" + override val icon = R.drawable.ic_quest_guidepost_name + override val isDeleteElementEnabled = true + override val achievements = listOf(OUTDOORS) + + override fun getTitle(tags: Map) = R.string.quest_guidepostName_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("nodes with information ~ guidepost|map") + + + override val defaultDisabledMessage: Int = R.string.quest_guidepost_disabled_msg + + override val highlightedElementsRadius: Double get() = 200.0 + + override fun createForm() = AddGuidepostNameForm() + + override fun applyAnswerTo(answer: GuidepostNameAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + when (answer) { + is NoVisibleGuidepostName -> tags["name:signed"] = "no" + is GuidepostName -> tags["name"] = answer.name + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/AddGuidepostNameForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/AddGuidepostNameForm.kt new file mode 100644 index 00000000000..c22920bfbde --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/AddGuidepostNameForm.kt @@ -0,0 +1,43 @@ +package de.westnordost.streetcomplete.quests.guidepost + +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.QuestGuidepostNameBinding +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.util.ktx.nonBlankTextOrNull + +class AddGuidepostNameForm : AbstractOsmQuestForm() { + + override val contentLayoutResId = R.layout.quest_guidepost_name + private val binding by contentViewBinding(QuestGuidepostNameBinding::bind) + + override val otherAnswers = listOf( + AnswerItem(R.string.quest_placeName_no_name_answer) { confirmNoRef() } + ) + + private val name get() = binding.nameInput.nonBlankTextOrNull + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.nameInput.doAfterTextChanged { checkIsFormComplete() } + } + + override fun onClickOk() { + applyAnswer(GuidepostName(name!!)) + } + + private fun confirmNoRef() { + val ctx = context ?: return + AlertDialog.Builder(ctx) + .setTitle(R.string.quest_generic_confirmation_title) + .setPositiveButton(R.string.quest_generic_confirmation_yes) { _, _ -> applyAnswer(NoVisibleGuidepostName) } + .setNegativeButton(R.string.quest_generic_confirmation_no, null) + .show() + } + + override fun isFormComplete() = name != null +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/GuidepostNameAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/GuidepostNameAnswer.kt new file mode 100644 index 00000000000..c0c68cea82b --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost/GuidepostNameAnswer.kt @@ -0,0 +1,6 @@ +package de.westnordost.streetcomplete.quests.guidepost + +sealed interface GuidepostNameAnswer + +data class GuidepostName(val name: String) : GuidepostNameAnswer +object NoVisibleGuidepostName : GuidepostNameAnswer diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/AddGuidepostSports.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/AddGuidepostSports.kt new file mode 100644 index 00000000000..2021c1e325e --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/AddGuidepostSports.kt @@ -0,0 +1,46 @@ +package de.westnordost.streetcomplete.quests.guidepost_sport + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags + +class AddGuidepostSports : OsmFilterQuestType() { + + override val elementFilter = + """ + nodes with + tourism = information + and information ~ guidepost|route_marker + and !hiking and !bicycle and !mtb and !climbing and !horse and !nordic_walking and !ski and !inline_skates and !running + and !disused + and !guidepost + """ + + override val changesetComment = "Specify what kind of guidepost" + override val wikiLink = "Tag:information=guidepost" + override val icon = R.drawable.ic_quest_guidepost_sport + override val isDeleteElementEnabled = true + override val defaultDisabledMessage = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_guidepost_sports_title + + override fun createForm() = AddGuidepostSportsForm() + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("nodes with tourism = information and information ~ guidepost|route_marker") + + override fun applyAnswerTo(answer: GuidepostSportsAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + if (answer is IsSimpleGuidepost) { + applySimpleGuidepostAnswer(tags) + } else if (answer is SelectedGuidepostSports) { + answer.selectedSports.forEach { tags[it.key] = "yes" } + } + } + private fun applySimpleGuidepostAnswer(tags: Tags) { + tags["guidepost"] = "simple" + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/AddGuidepostSportsForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/AddGuidepostSportsForm.kt new file mode 100644 index 00000000000..705f502ed32 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/AddGuidepostSportsForm.kt @@ -0,0 +1,58 @@ +package de.westnordost.streetcomplete.quests.guidepost_sport + +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AImageListQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.view.image_select.ImageListPickerDialog +import de.westnordost.streetcomplete.view.image_select.ImageSelectAdapter +import de.westnordost.streetcomplete.view.image_select.Item + +class AddGuidepostSportsForm : AImageListQuestForm, GuidepostSportsAnswer>() { + + override val descriptionResId = R.string.quest_guidepost_sports_note + + override val otherAnswers = listOf( + AnswerItem(R.string.quest_guidepost_sports_answer_simple) { confirmJustSimple() } + ) + + override val items get() = GuidepostSport.selectableValues.map { it.asItem() } + override val itemsPerRow = 3 + override val maxSelectableItems = -1 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imageSelector.cellLayoutId = R.layout.cell_icon_select_with_label_below + imageSelector.listeners.add(object : ImageSelectAdapter.OnItemSelectionListener { + override fun onIndexSelected(index: Int) { + val value = imageSelector.items[index].value!! + } + + override fun onIndexDeselected(index: Int) {} + }) + } + + private fun showPickItemForItemAtIndexDialog(index: Int, items: List>>) { + val ctx = context ?: return + ImageListPickerDialog(ctx, items, R.layout.cell_icon_select_with_label_below, 3) { selected -> + val newList = imageSelector.items.toMutableList() + newList[index] = selected + imageSelector.items = newList + }.show() + } + + override fun onClickOk(selectedItems: List>) { + val answer = SelectedGuidepostSports(selectedItems.flatten()) + applyAnswer(answer) + } + + private fun confirmJustSimple() { + activity?.let { AlertDialog.Builder(it) + .setMessage(R.string.quest_guidepost_sports_answer_simple_description) + .setPositiveButton(R.string.quest_generic_confirmation_yes) { _, _ -> applyAnswer(IsSimpleGuidepost) } + .setNegativeButton(R.string.quest_generic_confirmation_no, null) + .show() + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/GuidepostSport.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/GuidepostSport.kt new file mode 100644 index 00000000000..cc987e5cd66 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/GuidepostSport.kt @@ -0,0 +1,20 @@ +package de.westnordost.streetcomplete.quests.guidepost_sport + +enum class GuidepostSport(val key: String) { + HIKING("hiking"), + BICYCLE("bicycle"), + MTB("mtb"), + CLIMBING("climbing"), + HORSE("horse"), + NORDIC_WALKING("nordic_walking"), + SKI("ski"), + INLINE_SKATING("inline_skating"), + RUNNING("running"), + WINTER_HIKING("winter_hiking"); + + companion object { + val selectableValues = listOf( + HIKING, BICYCLE, MTB, CLIMBING, HORSE, NORDIC_WALKING, SKI, INLINE_SKATING, RUNNING, WINTER_HIKING + ) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/GuidepostSportsAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/GuidepostSportsAnswer.kt new file mode 100644 index 00000000000..670858c67ff --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/GuidepostSportsAnswer.kt @@ -0,0 +1,7 @@ +package de.westnordost.streetcomplete.quests.guidepost_sport + + +sealed interface GuidepostSportsAnswer + +object IsSimpleGuidepost : GuidepostSportsAnswer +data class SelectedGuidepostSports(val selectedSports: List) : GuidepostSportsAnswer diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/GuidepostSporttem.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/GuidepostSporttem.kt new file mode 100644 index 00000000000..aa9c5cb24b9 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/guidepost_sport/GuidepostSporttem.kt @@ -0,0 +1,34 @@ +package de.westnordost.streetcomplete.quests.guidepost_sport + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.guidepost_sport.GuidepostSport.* +import de.westnordost.streetcomplete.view.image_select.Item + +fun GuidepostSport.asItem(): Item> = + Item(listOf(this), iconResId, titleResId) + +private val GuidepostSport.iconResId: Int get() = when (this) { + HIKING -> R.drawable.ic_guidepost_hiking + BICYCLE -> R.drawable.ic_guidepost_cycling + MTB -> R.drawable.ic_guidepost_mtb + CLIMBING -> R.drawable.ic_guidepost_climbing + HORSE -> R.drawable.ic_guidepost_horse_riding + NORDIC_WALKING -> R.drawable.ic_guidepost_nordic_walking + SKI -> R.drawable.ic_guidepost_ski + INLINE_SKATING -> R.drawable.ic_guidepost_inline_skating + RUNNING -> R.drawable.ic_guidepost_running + WINTER_HIKING -> R.drawable.ic_guidepost_snow_shoe_hiking +} + +private val GuidepostSport.titleResId: Int get() = when (this) { + HIKING -> R.string.quest_guidepost_sports_hiking + BICYCLE -> R.string.quest_guidepost_sports_bicycle + MTB -> R.string.quest_guidepost_sports_mtb + CLIMBING -> R.string.quest_guidepost_sports_climbing + HORSE -> R.string.quest_guidepost_sports_horse + NORDIC_WALKING -> R.string.quest_guidepost_sports_nordic_walking + SKI -> R.string.quest_guidepost_sports_ski + INLINE_SKATING -> R.string.quest_guidepost_sports_inline_skating + RUNNING -> R.string.quest_guidepost_sports_running + WINTER_HIKING -> R.string.quest_guidepost_sports_winter_hiking +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/healthcare_speciality/AddHealthcareSpeciality.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/healthcare_speciality/AddHealthcareSpeciality.kt new file mode 100644 index 00000000000..01f605c1e4e --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/healthcare_speciality/AddHealthcareSpeciality.kt @@ -0,0 +1,27 @@ +package de.westnordost.streetcomplete.quests.healthcare_speciality + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags + +class AddHealthcareSpeciality : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + amenity = doctors + and name and !healthcare:speciality + """ + override val changesetComment = "Add healthcare specialities" + override val wikiLink = "Key:healthcare:speciality" + override val icon = R.drawable.ic_quest_healthcare_speciality + override val defaultDisabledMessage = R.string.quest_healthcare_speciality_disabled_message + + override fun getTitle(tags: Map) = R.string.quest_healthcare_speciality_title + + override fun createForm() = MedicalSpecialityTypeForm() + + override fun applyAnswerTo(answer: String, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["healthcare:speciality"] = answer + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/healthcare_speciality/AddHealthcareSpecialityForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/healthcare_speciality/AddHealthcareSpecialityForm.kt new file mode 100644 index 00000000000..125c07b5ee6 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/healthcare_speciality/AddHealthcareSpecialityForm.kt @@ -0,0 +1,310 @@ +package de.westnordost.streetcomplete.quests.healthcare_speciality + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.widget.RadioButton +import androidx.core.os.bundleOf +import androidx.fragment.app.commit +import de.westnordost.osmfeatures.Feature +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.ViewShopTypeBinding +import de.westnordost.streetcomplete.quests.AMultiValueQuestForm +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.quests.TagEditor +import de.westnordost.streetcomplete.util.ktx.geometryType +import de.westnordost.streetcomplete.util.ktx.hideKeyboard +import de.westnordost.streetcomplete.util.takeFavourites +import de.westnordost.streetcomplete.view.controller.FeatureViewController +import de.westnordost.streetcomplete.view.dialogs.SearchFeaturesDialog + +class AddHealthcareSpecialityForm : AMultiValueQuestForm() { + + override fun stringToAnswer(answerString: String) = answerString + + // the hacky UI switch breaks when using tag editor... + override val otherAnswers get() = if (TagEditor.showingTagEditor) emptyList() else listOf(AnswerItem(R.string.quest_healthcare_speciality_switch_ui) { + val f = MedicalSpecialityTypeForm() + if (f.arguments == null) f.arguments = bundleOf() + val args = createArguments(questKey, questType, geometry, 0.0, 0.0) + f.requireArguments().putAll(args) + val osmArgs = createArguments(element) + f.requireArguments().putAll(osmArgs) + activity?.currentFocus?.hideKeyboard() + parentFragmentManager.commit { + replace(id, f, "bottom_sheet") + addToBackStack("bottom_sheet") + } + }) + + override val onlyAllowSuggestions = true + + override val addAnotherValueResId = R.string.quest_healthcare_speciality_add_more + + override fun getConstantSuggestions() = + (healthcareSpecialityFromWiki.split("\n").mapNotNull { + if (it.isBlank()) null + else it.trim() + } + healthcareSpecialityValuesFromTaginfo.split("\n").mapNotNull { + if (it.isBlank()) null + else it.trim() + }).toSet() + +} + + +class MedicalSpecialityTypeForm : AbstractOsmQuestForm() { + + override val contentLayoutResId = R.layout.view_shop_type // TODO? + private val binding by contentViewBinding(ViewShopTypeBinding::bind) + + private lateinit var radioButtons: List + private var selectedRadioButtonId: Int = 0 + private lateinit var featureCtrl: FeatureViewController + + // the hacky UI switch breaks when using tag editor... + override val otherAnswers = if (TagEditor.showingTagEditor) emptyList() else listOf(AnswerItem(R.string.quest_healthcare_speciality_switch_ui) { + val f = AddHealthcareSpecialityForm() + if (f.arguments == null) f.arguments = bundleOf() + val args = createArguments(questKey, questType, geometry, 0.0, 0.0) + f.requireArguments().putAll(args) + val osmArgs = createArguments(element) + f.requireArguments().putAll(osmArgs) + activity?.currentFocus?.hideKeyboard() + parentFragmentManager.commit { + replace(id, f, "bottom_sheet") + addToBackStack("bottom_sheet") + } + }) + + private val lastPickedAnswers by lazy { + prefs.getLastPicked(javaClass.simpleName).takeFavourites(12, 50, 1) + } + + override fun onAttach(ctx: Context) { + super.onAttach(ctx) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + radioButtons = listOf(binding.vacantRadioButton, binding.replaceRadioButton, binding.leaveNoteRadioButton) + for (radioButton in radioButtons) { + radioButton.setOnClickListener { selectRadioButton(it) } + } + + featureCtrl = FeatureViewController(featureDictionary, binding.featureView.textView, binding.featureView.iconView) + featureCtrl.countryOrSubdivisionCode = countryOrSubdivisionCode + + binding.featureView.root.background = null + binding.featureContainer.setOnClickListener { + selectRadioButton(binding.replaceRadioButton) + + SearchFeaturesDialog( + requireContext(), + featureDictionary, + element.geometryType, + countryOrSubdivisionCode, + featureCtrl.feature?.name, + ::filterOnlySpecialitiesOfMedicalDoctors, + ::onSelectedFeature, + getSuggestions(), + false, + geometry.center + ).show() + } + } + + private fun filterOnlySpecialitiesOfMedicalDoctors(feature: Feature): Boolean { + if (!feature.tags.containsKey("healthcare:speciality")) { + return false + } + return feature.tags["amenity"] == "doctors" + } + + private fun onSelectedFeature(feature: Feature) { + featureCtrl.feature = feature + checkIsFormComplete() + } + + override fun onClickOk() { + when (selectedRadioButtonId) { + R.id.vacantRadioButton -> composeNote() + R.id.leaveNoteRadioButton -> composeNote() + R.id.replaceRadioButton -> { + applyAnswer(featureCtrl.feature!!.addTags["healthcare:speciality"]!!) + prefs.addLastPicked(javaClass.simpleName, featureCtrl.feature!!.id) + } + } + } + + override fun isFormComplete() = when (selectedRadioButtonId) { + R.id.vacantRadioButton, + R.id.leaveNoteRadioButton -> true + R.id.replaceRadioButton -> featureCtrl.feature != null + else -> false + } + + private fun selectRadioButton(radioButton: View) { + selectedRadioButtonId = radioButton.id + for (b in radioButtons) { + b.isChecked = selectedRadioButtonId == b.id + } + checkIsFormComplete() + } + + private fun getSuggestions(): List { + if (lastPickedAnswers.size >= 12) return lastPickedAnswers + return (lastPickedAnswers + listOf( + // based on https://taginfo.openstreetmap.org/keys/healthcare%3Aspeciality#values + // with alternative medicine skipped + "amenity/doctors/general", + // chiropractic - skipped (alternative medicine) + "amenity/doctors/ophthalmology", + "amenity/doctors/paediatrics", + "amenity/doctors/gynaecology", + //biology skipped as that is value for laboratory + // "amenity/dentist", would require changes in SCEE + // psychiatry - https://github.com/openstreetmap/id-tagging-schema/issues/778 + "amenity/doctors/orthopaedics", + "amenity/doctors/internal", + // "healthcare/dentist/orthodontics", may require changes in SCEE + "amenity/doctors/dermatology", + // osteopathy - skipped (alternative medicine) + "amenity/doctors/otolaryngology", + "amenity/doctors/radiology", + // vaccination? that is tagged differently, right? TODO + "amenity/doctors/cardiology", + "amenity/doctors/surgery", // TODO? really for doctors? Maybe that is used primarily for hospitals? + // physiotherapy + // urology + // emergency + // dialysis + ) + ).distinct().take(12) + } +} + + +const val healthcareSpecialityFromWiki = """ +allergology +anaesthetics +cardiology +cardiothoracic_surgery +child_psychiatry +community +dermatology +dermatovenereology +diagnostic_radiology +emergency +endocrinology +gastroenterology +general +geriatrics +gynaecology +haematology +hepatology +infectious_diseases +intensive +internal +maxillofacial_surgery +nephrology +neurology +neuropsychiatry +neurosurgery +nuclear +occupational +oncology +ophthalmology +orthodontics +orthopaedics +otolaryngology +paediatric_surgery +paediatrics +palliative +pathology +physiatry +plastic_surgery +podiatry +proctology +psychiatry +pulmonology +radiology +radiotherapy +rheumatology +stomatology +surgery +transplant +trauma +tropical +urology +vascular_surgery +""" + +const val healthcareSpecialityValuesFromTaginfo = """ +general +chiropractic +ophthalmology +paediatrics +biology +gynaecology +psychiatry +dentist +orthopaedics +internal +dermatology +orthodontics +vaccination +osteopathy +otolaryngology +radiology +surgery +cardiology +urology +physiotherapy +dentistry +emergency +dialysis +covid19 +community +neurology +acupuncture +plastic_surgery +traditional_chinese_medicine +weight_loss +intensive +naturopathy +oncology +physiatry +homeopathy +clinic +blood_check +occupational +gastroenterology +child_psychiatry +dental_oral_maxillo_facial_surgery +podiatry +maternity +pulmonology +optometry +fertility +endocrinology +massage_therapy +dermatovenereology +stomatology +psychotherapist +family_medicine +diagnostic_radiology +general;emergency +kinesitherapy +pathology +trauma +nephrology +behavior +psychology +geriatrics +ayurveda +anaesthetics +otorhinolaryngology +""" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_mount/AddLampMount.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_mount/AddLampMount.kt new file mode 100644 index 00000000000..39018034cce --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_mount/AddLampMount.kt @@ -0,0 +1,44 @@ +package de.westnordost.streetcomplete.quests.lamp_mount + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement +import de.westnordost.streetcomplete.osm.Tags + +class AddLampMount : OsmFilterQuestType() { + + override val elementFilter = """ + nodes with + highway = street_lamp + and !lamp_mount + and !support + """ + override val changesetComment = "Add lamp mount" + override val defaultDisabledMessage = R.string.quest_lampMount_disabled_msg + override val wikiLink = "Key:lamp_mount" + override val icon = R.drawable.ic_quest_lamp_mount + override val isReplacePlaceEnabled = true + override val achievements = listOf(EditTypeAchievement.CITIZEN) + + override fun getTitle(tags: Map) = R.string.quest_lampMount_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("nodes with highway = street_lamp") + + override fun createForm() = AddLampMountForm() + + override fun applyAnswerTo(answer: LampMountAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + when (answer) { + is LampMount -> { + tags["lamp_mount"] = answer.mount + } + is Support -> { + tags["support"] = answer.mount + } + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_mount/AddLampMountForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_mount/AddLampMountForm.kt new file mode 100644 index 00000000000..1cddfb3adcb --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_mount/AddLampMountForm.kt @@ -0,0 +1,19 @@ +package de.westnordost.streetcomplete.quests.lamp_mount + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AListQuestForm +import de.westnordost.streetcomplete.quests.TextItem + +class AddLampMountForm : AListQuestForm() { + override val items: List> = listOf( + TextItem(LampMount("straight_mast"), R.string.quest_lampMount_straightMast), + TextItem(LampMount("bent_mast"), R.string.quest_lampMount_bentMast), + TextItem(LampMount("suspended"), R.string.quest_lampMount_suspended), + TextItem(LampMount("angled_mast"), R.string.quest_lampMount_angledMast), + TextItem(LampMount("high_mast"), R.string.quest_lampMount_highMast), + TextItem(LampMount("bollard"), R.string.quest_lampMount_bollard), + TextItem(LampMount("wall"), R.string.quest_lampMount_wall), + TextItem(Support("ceiling"), R.string.quest_lampMount_ceiling), + TextItem(Support("street_furniture:transit_shelter"), R.string.quest_lampMount_transitShelter), + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_mount/LampMountAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_mount/LampMountAnswer.kt new file mode 100644 index 00000000000..18fa140a156 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_mount/LampMountAnswer.kt @@ -0,0 +1,6 @@ +package de.westnordost.streetcomplete.quests.lamp_mount + +sealed interface LampMountAnswer + +data class LampMount(val mount: String) : LampMountAnswer +data class Support(val mount: String) : LampMountAnswer diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_type/AddLampType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_type/AddLampType.kt new file mode 100644 index 00000000000..2c5893e6c94 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_type/AddLampType.kt @@ -0,0 +1,42 @@ +package de.westnordost.streetcomplete.quests.lamp_type + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.isPlace +import de.westnordost.streetcomplete.quests.seating.AddOutdoorSeatingTypeForm + +class AddLampType : OsmFilterQuestType() { + + override val elementFilter = """ + nodes with + highway = street_lamp + and (!lamp_type or lamp_type ~ electric|floodlight|sodium|solar_lamp) + and (!light:method or light:method ~ electric|discharge|sodium) + """ + override val changesetComment = "Add lamp type" + override val defaultDisabledMessage = R.string.quest_lampType_disabled_msg + override val wikiLink = "Key:lamp_type" + override val icon = R.drawable.ic_quest_lamp_type + override val isReplacePlaceEnabled = true + override val achievements = listOf(EditTypeAchievement.CITIZEN) + + override fun getTitle(tags: Map) = R.string.quest_lampType_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("nodes with highway = street_lamp") + + override fun createForm() = AddLampTypeForm() + + override fun applyAnswerTo(answer: String, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["lamp_type"] = answer + if (tags["light:method"] in listOf("electric", "discharge", "sodium")) { + tags.remove("light:method") + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_type/AddLampTypeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_type/AddLampTypeForm.kt new file mode 100644 index 00000000000..17733b19314 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/lamp_type/AddLampTypeForm.kt @@ -0,0 +1,19 @@ +package de.westnordost.streetcomplete.quests.lamp_type + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AListQuestForm +import de.westnordost.streetcomplete.quests.TextItem + +class AddLampTypeForm : AListQuestForm() { + override val items = listOf( + TextItem("led", R.string.quest_lampType_led), + TextItem("high_pressure_sodium", R.string.quest_lampType_highPressureSodium), + TextItem("low_pressure_sodium", R.string.quest_lampType_lowPressureSodium), + TextItem("gaslight", R.string.quest_lampType_gaslight), + TextItem("fluorescent", R.string.quest_lampType_fluorescent), + TextItem("incandescent", R.string.quest_lampType_incandescent), + TextItem("metal-halide", R.string.quest_lampType_metalHalide), + TextItem("mercury", R.string.quest_lampType_mercury), + TextItem("halogen", R.string.quest_lampType_halogen), + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/lanes/AddLanes.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/lanes/AddLanes.kt index 09905a820b8..df8d82ca6ad 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/lanes/AddLanes.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/lanes/AddLanes.kt @@ -43,7 +43,7 @@ class AddLanes : OsmFilterQuestType() { laneCount?.let { tags["lanes"] = it.toString() } - val isMarked = answer !is UnmarkedLanes + val isMarked = answer is MarkedLanes || answer is MarkedLanesSides // if there is just one lane, the information whether it is marked or not is irrelevant // (if there are no more than one lane, there are no markings to separate them) when { @@ -70,16 +70,16 @@ class AddLanes : OsmFilterQuestType() { } when (answer) { - is MarkedLanes -> { - if (answer.count == 1) { + is MarkedLanes, is UnmarkedLanesKnowLaneCount -> { + if (answer.total == 1) { tags.remove("lanes:forward") tags.remove("lanes:backward") } else { if (tags.containsKey("lanes:forward")) { - tags["lanes:forward"] = (answer.count / 2).toString() + tags["lanes:forward"] = (answer.total!! / 2).toString() } if (tags.containsKey("lanes:backward")) { - tags["lanes:backward"] = (answer.count / 2).toString() + tags["lanes:backward"] = (answer.total!! / 2).toString() } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/lanes/AddLanesForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/lanes/AddLanesForm.kt index b32268507d1..0270509f71c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/lanes/AddLanesForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/lanes/AddLanesForm.kt @@ -17,6 +17,7 @@ import de.westnordost.streetcomplete.quests.AnswerItem import de.westnordost.streetcomplete.quests.lanes.LanesType.MARKED import de.westnordost.streetcomplete.quests.lanes.LanesType.MARKED_SIDES import de.westnordost.streetcomplete.quests.lanes.LanesType.UNMARKED +import de.westnordost.streetcomplete.quests.lanes.LanesType.UNMARKED_KNOWN_LANE_COUNT import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope import de.westnordost.streetcomplete.util.math.getOrientationAtCenterLineInDegrees import de.westnordost.streetcomplete.view.dialogs.ValuePickerDialog @@ -118,6 +119,7 @@ class AddLanesForm : AbstractOsmQuestForm() { val backwardLanes = if (isLeftHandTraffic) rightSide else leftSide applyAnswer(MarkedLanesSides(forwardLanes, backwardLanes, hasCenterLeftTurnLane)) } + UNMARKED_KNOWN_LANE_COUNT -> applyAnswer(UnmarkedLanesKnowLaneCount(totalLanes)) null -> {} } } @@ -154,6 +156,12 @@ class AddLanesForm : AbstractOsmQuestForm() { checkIsFormComplete() askLanesAndSwitchToStreetSideLayout() } + laneSelectBinding.unmarkedLanesKnownLaneCountButton.setOnClickListener { + selectedLanesType = UNMARKED_KNOWN_LANE_COUNT + unmarkedLanesButton.isSelected = false + checkIsFormComplete() + askLanesAndSwitchToStreetSideLayout() + } laneSelectBinding.markedLanesOddButton.isGone = isOneway laneSelectBinding.markedLanesOddButton.setOnClickListener { @@ -189,7 +197,7 @@ class AddLanesForm : AbstractOsmQuestForm() { lifecycle.addObserver(puzzleView) when (selectedLanesType) { - MARKED -> { + MARKED, UNMARKED_KNOWN_LANE_COUNT -> { puzzleView.onClickListener = this::selectTotalNumberOfLanes puzzleView.onClickSideListener = null } @@ -256,11 +264,11 @@ class AddLanesForm : AbstractOsmQuestForm() { private suspend fun askForTotalNumberOfLanes(): Int { val currentLaneCount = rightSide + leftSide - return if (selectedLanesType == MARKED) { + return if (selectedLanesType == MARKED || selectedLanesType == UNMARKED_KNOWN_LANE_COUNT) { if (isOneway) { showSelectMarkedLanesDialogForOneSide(currentLaneCount.takeIf { it > 0 }) } else { - showSelectMarkedLanesDialogForBothSides(currentLaneCount.takeIf { it > 0 }) + showSelectMarkedLanesDialogForBothSides(currentLaneCount.takeIf { it > 0 } ?: 2) } } else { throw IllegalStateException() @@ -281,10 +289,16 @@ class AddLanesForm : AbstractOsmQuestForm() { private suspend fun showSelectMarkedLanesDialogForBothSides(selectedValue: Int?): Int = suspendCancellableCoroutine { cont -> ValuePickerDialog(requireContext(), - listOf(2, 4, 6, 8, 10, 12, 14), + listOf(1, 2, 4, 6, 8, 10, 12, 14), selectedValue, null, R.layout.quest_lanes_select_lanes, - { cont.resume(it) } + { + if (it == 1 && selectedLanesType == MARKED && !isOneway) + // workaround for only one lane being shown + // for 1 lane tagging is the same as UNMARKED_KNOWN_LANE_COUNT anyway + selectedLanesType = UNMARKED_KNOWN_LANE_COUNT + cont.resume(it) + } ).show() } @@ -311,5 +325,6 @@ class AddLanesForm : AbstractOsmQuestForm() { private enum class LanesType { MARKED, MARKED_SIDES, - UNMARKED + UNMARKED, + UNMARKED_KNOWN_LANE_COUNT } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/lanes/LanesAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/lanes/LanesAnswer.kt index 3a86331d578..a27a7c9f76c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/lanes/LanesAnswer.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/lanes/LanesAnswer.kt @@ -4,10 +4,12 @@ sealed interface LanesAnswer data class MarkedLanes(val count: Int) : LanesAnswer data object UnmarkedLanes : LanesAnswer +data class UnmarkedLanesKnowLaneCount(val count: Int) : LanesAnswer data class MarkedLanesSides(val forward: Int, val backward: Int, val centerLeftTurnLane: Boolean) : LanesAnswer val LanesAnswer.total: Int? get() = when (this) { is MarkedLanes -> count + is UnmarkedLanesKnowLaneCount -> count is UnmarkedLanes -> null is MarkedLanesSides -> forward + backward + (if (centerLeftTurnLane) 1 else 0) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/AddForestLeafType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/AddForestLeafType.kt index 934ab1e6f37..3769dec901e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/AddForestLeafType.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/AddForestLeafType.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.quests.leaf_detail +import android.content.Context import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry @@ -9,6 +10,7 @@ import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.OUTDOORS import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.booleanQuestSettingsDialog import de.westnordost.streetcomplete.util.math.measuredMultiPolygonArea class AddForestLeafType : OsmElementQuestType { @@ -20,6 +22,10 @@ class AddForestLeafType : OsmElementQuestType { ways with natural = tree_row and !leaf_type """.toElementFilterExpression() } + private val nodeFilter by lazy { """ + nodes with natural = tree and !leaf_type and !species and !genus + """.toElementFilterExpression() } + override val changesetComment = "Specify leaf types" override val wikiLink = "Key:leaf_type" override val icon = R.drawable.ic_quest_leaf @@ -36,10 +42,12 @@ class AddForestLeafType : OsmElementQuestType { area > 0.0 && area < 10000 } val treeRows = mapData.filter { wayFilter.matches(it) } - return forests + treeRows + return if (prefs.getBoolean(SINGLE_TREES_PREF, false)) forests + treeRows + mapData.filter { nodeFilter.matches(it) } + else forests + treeRows } override fun isApplicableTo(element: Element): Boolean? { + if (prefs.getBoolean(SINGLE_TREES_PREF, false) && nodeFilter.matches(element)) return true if (wayFilter.matches(element)) return true // tree rows // for areas, we don't want to show things larger than x m², we need the geometry for that if (!areaFilter.matches(element)) return false @@ -51,4 +59,14 @@ class AddForestLeafType : OsmElementQuestType { override fun applyAnswerTo(answer: ForestLeafType, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { tags["leaf_type"] = answer.osmValue } + + override val hasQuestSettings = true + + override fun getQuestSettingsDialog(context: Context) = + booleanQuestSettingsDialog(context, prefs, SINGLE_TREES_PREF, + R.string.quest_settings_leaf_type_single_tree_message, R.string.quest_settings_leaf_type_single_tree_yes, + R.string.quest_settings_leaf_type_single_tree_no + ) } + +private const val SINGLE_TREES_PREF = "qs_AddForestLeafType_single_trees" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/AddForestLeafTypeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/AddForestLeafTypeForm.kt index 718078c50f8..17718f6caac 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/AddForestLeafTypeForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/AddForestLeafTypeForm.kt @@ -1,10 +1,17 @@ package de.westnordost.streetcomplete.quests.leaf_detail +import de.westnordost.streetcomplete.data.osm.mapdata.Node import de.westnordost.streetcomplete.quests.AImageListQuestForm class AddForestLeafTypeForm : AImageListQuestForm() { - override val items = ForestLeafType.entries.map { it.asItem() } + override val items by lazy { + val types = if (element is Node) + listOf(ForestLeafType.NEEDLELEAVED, ForestLeafType.BROADLEAVED) + else + ForestLeafType.entries + types.map { it.asItem() } + } override val itemsPerRow = 3 override fun onClickOk(selectedItems: List) { diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/AddTreeLeafTypeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/AddTreeLeafTypeForm.kt index 0e146135be3..1316cd85b86 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/AddTreeLeafTypeForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/AddTreeLeafTypeForm.kt @@ -16,7 +16,7 @@ class AddTreeLeafTypeForm : override val otherAnswers = listOf( AnswerItem(R.string.quest_leafType_tree_is_just_a_stump) { - applyAnswer(NotTreeButStump) + applyAnswer(NotTreeButStump, true) }, ) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/TreeLeafType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/TreeLeafType.kt index 6f1d5134351..4900dfb8167 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/TreeLeafType.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/leaf_detail/TreeLeafType.kt @@ -2,7 +2,7 @@ package de.westnordost.streetcomplete.quests.leaf_detail sealed interface TreeLeafTypeAnswer -enum class TreeLeafType(val osmValue: String) : TreeLeafTypeAnswer { +enum class TreeLeafType(val osmValue: String): TreeLeafTypeAnswer { NEEDLELEAVED("needleleaved"), BROADLEAVED("broadleaved"), } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/level/AddLevel.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/level/AddLevel.kt index 5ad601ffba7..87dd19ca344 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/level/AddLevel.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/level/AddLevel.kt @@ -1,5 +1,7 @@ package de.westnordost.streetcomplete.quests.level +import androidx.appcompat.app.AlertDialog +import android.content.Context import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry @@ -7,9 +9,11 @@ import de.westnordost.streetcomplete.data.osm.geometry.ElementPolygonsGeometry import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestController import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.CITIZEN import de.westnordost.streetcomplete.osm.Tags import de.westnordost.streetcomplete.osm.isPlace +import de.westnordost.streetcomplete.quests.questPrefix import de.westnordost.streetcomplete.util.math.contains import de.westnordost.streetcomplete.util.math.isInMultipolygon @@ -24,21 +28,43 @@ class AddLevel : OsmElementQuestType { or railway = station or amenity = bus_station or public_transport = station + ${if (prefs.getBoolean(questPrefix(prefs) + PREF_MORE_LEVELS, false)) "or (building and building:levels != 1 and building !~ roof|house|detached|carport)" else ""} """.toElementFilterExpression() } - private val thingsWithLevelFilter by lazy { """ + private val thingsWithLevelOrDoctorsFilter by lazy { """ nodes, ways, relations with level + ${if (prefs.getBoolean(questPrefix(prefs) + PREF_MORE_LEVELS, false)) """ + or ( + amenity ~ doctors|dentist + or healthcare ~ doctor|dentist|psychotherapist|physiotherapist + ) """ + else ""} """.toElementFilterExpression() } /* only nodes because ways/relations are not likely to be floating around freely in a mall * outline */ - private val filter by lazy { """ + private val shopsAndMoreFilter by lazy { """ + nodes with + ( + (shop and shop !~ no|vacant|mall) + or craft + or (amenity and amenity !~ parking|parking_entrance) + or leisure + or office + or tourism + or healthcare + or (man_made = surveillance and surveillance:type = camera) + ) + and !level + """.toElementFilterExpression()} + + private val shopFilter by lazy { """ nodes with !level and (name or brand or noname = yes or name:signed = no) """.toElementFilterExpression() } - override val changesetComment = "Determine on which level shops are in a building" + override val changesetComment = "Determine on which level elements are in a building" override val wikiLink = "Key:level" override val icon = R.drawable.ic_quest_level /* disabled because in a mall with multiple levels, if there are nodes with no level defined, @@ -51,50 +77,63 @@ class AddLevel : OsmElementQuestType { override fun getTitle(tags: Map) = R.string.quest_level_title2 override fun getApplicableElements(mapData: MapDataWithGeometry): Iterable { - // get geometry of all malls in the area + val moreLevels = prefs.getBoolean(questPrefix(prefs) + PREF_MORE_LEVELS, false) + // get all shops that have no level tagged + val shopsWithoutLevel = if (moreLevels) mapData.filter { shopsAndMoreFilter.matches(it) }.toMutableList() + else mapData.filter { shopFilter.matches(it) && it.isPlace() }.toMutableList() + if (shopsWithoutLevel.isEmpty()) return emptyList() + + val result = mutableListOf() + if (moreLevels) { + // add doctors, independent of the building they're in + // and remove them from shops without level + shopsWithoutLevel.removeAll { + if (it.isDoctor()) result.add(it) + else false + } + } + if (shopsWithoutLevel.isEmpty()) return emptyList() + + // get geometry of all malls (or buildings) in the area val mallGeometries = mapData .filter { mallFilter.matches(it) } .mapNotNull { mapData.getGeometry(it.type, it.id) as? ElementPolygonsGeometry } - if (mallGeometries.isEmpty()) return emptyList() + .toMutableList() + if (mallGeometries.isEmpty()) return result - // get all shops that have level tagged - val thingsWithLevel = mapData.filter { thingsWithLevelFilter.matches(it) } - if (thingsWithLevel.isEmpty()) return emptyList() + // get all shops that have level tagged or are doctors + val thingsWithLevel = mapData.filter { thingsWithLevelOrDoctorsFilter.matches(it) } + if (thingsWithLevel.isEmpty()) return result // with this, find malls that contain shops that have different levels tagged - val multiLevelMallGeometries = mallGeometries.filter { mallGeometry -> + mallGeometries.retainAll { mallGeometry -> var level: String? = null for (shop in thingsWithLevel) { val pos = mapData.getGeometry(shop.type, shop.id)?.center ?: continue - if (!mallGeometry.getBounds().contains(pos)) continue + if (!mallGeometry.getBounds().contains(pos)) continue // crude filter first for performance reasons if (!pos.isInMultipolygon(mallGeometry.polygons)) continue if (shop.tags.containsKey("level")) { if (level != null) { - if (level != shop.tags["level"]) return@filter true + if (level != shop.tags["level"]) return@retainAll true } else { level = shop.tags["level"] } } } - return@filter false + return@retainAll false } - if (multiLevelMallGeometries.isEmpty()) return emptyList() - - // now, return all shops that have no level tagged and are inside those multi-level malls - val shopsWithoutLevel = mapData - .filter { filter.matches(it) && it.isPlace() } - .toMutableList() - if (shopsWithoutLevel.isEmpty()) return emptyList() - - val result = mutableListOf() + if (mallGeometries.isEmpty()) return result - for (mallGeometry in multiLevelMallGeometries) { + // find places inside remaining mallGeometries, but not on outline + for (mallGeometry in mallGeometries) { val it = shopsWithoutLevel.iterator() + val mallNodePositions = mallGeometry.polygons.flatten().toHashSet() while (it.hasNext()) { val shop = it.next() val pos = mapData.getGeometry(shop.type, shop.id)?.center ?: continue if (!mallGeometry.getBounds().contains(pos)) continue + if (mallNodePositions.contains(pos)) continue if (!pos.isInMultipolygon(mallGeometry.polygons)) continue result.add(shop) @@ -105,15 +144,44 @@ class AddLevel : OsmElementQuestType { } override fun isApplicableTo(element: Element): Boolean? { - if (!filter.matches(element) || !element.isPlace()) return false + if (prefs.getBoolean(questPrefix(prefs) + PREF_MORE_LEVELS, false)) { + if (!shopsAndMoreFilter.matches(element)) return false + } else { + if (!shopFilter.matches(element) || !element.isPlace()) return false + } + // doctors are frequently at non-ground level + if (element.isDoctor() && prefs.getBoolean(questPrefix(prefs) + PREF_MORE_LEVELS, false) && !element.tags.containsKey("level")) return true // for shops with no level, we actually need to look at geometry in order to find if it is // contained within any multi-level mall return null } + private fun Element.isDoctor() = tags["amenity"] in doctorAmenity || tags["healthcare"] in doctorHealthcare + override fun createForm() = AddLevelForm() override fun applyAnswerTo(answer: String, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { tags["level"] = answer } + + override val hasQuestSettings = true + + override fun getQuestSettingsDialog(context: Context): AlertDialog = + AlertDialog.Builder(context) + .setTitle(R.string.quest_settings_level_title) + .setNegativeButton(android.R.string.cancel, null) + .setItems( + arrayOf(context.getString(R.string.quest_settings_level_default), context.getString(R.string.quest_settings_level_more)) + ) { _, i -> + if (i == 1) prefs.edit().putBoolean(questPrefix(prefs) + PREF_MORE_LEVELS, true).apply() + else prefs.edit().remove(questPrefix(prefs) + PREF_MORE_LEVELS).apply() + OsmQuestController.reloadQuestTypes() + } + .create() + } + +private val doctorAmenity = hashSetOf("doctors", "dentist") +private val doctorHealthcare = hashSetOf("doctor", "dentist", "psychotherapist", "physiotherapist") + +const val PREF_MORE_LEVELS = "qs_AddLevel_more_levels" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/level/AddLevelForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/level/AddLevelForm.kt index e624a94916c..c91072135d9 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/level/AddLevelForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/level/AddLevelForm.kt @@ -55,9 +55,15 @@ class AddLevelForm : AbstractOsmQuestForm() { val bbox = geometry.center.enclosingBoundingBox(50.0) val mapData = withContext(Dispatchers.IO) { mapDataSource.getMapDataWithGeometry(bbox) } - val shopsWithLevels = mapData.filter { - it.tags["level"] != null && it.isPlaceOrDisusedPlace() - } + val shopsWithLevels = if (prefs.getBoolean(PREF_MORE_LEVELS, false)) + mapData.filter { e -> + e.tags["level"] != null + && (e.isPlaceOrDisusedPlace() || getIcon(featureDictionary, e) != null) + } + else + mapData.filter { + it.tags["level"] != null && it.isPlaceOrDisusedPlace() + } shopElementsAndGeometry = shopsWithLevels.mapNotNull { e -> mapData.getGeometry(e.type, e.id)?.let { geometry -> e to geometry } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/map/AddMapSize.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/map/AddMapSize.kt new file mode 100644 index 00000000000..c435410bac3 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/map/AddMapSize.kt @@ -0,0 +1,30 @@ +package de.westnordost.streetcomplete.quests.map + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement +import de.westnordost.streetcomplete.osm.Tags + +class AddMapSize : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + tourism = information + and information = map + and !map_size + """ + override val changesetComment = "Add what area a map covers" + override val defaultDisabledMessage = R.string.default_disabled_msg_ee + override val wikiLink = "Key:map_size" + override val icon = R.drawable.ic_quest_map_size + override val achievements = listOf(EditTypeAchievement.OUTDOORS) + + override fun getTitle(tags: Map) = R.string.quest_mapSize_title + + override fun createForm() = AddMapSizeForm() + + override fun applyAnswerTo(answer: String, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["map_size"] = answer + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/map/AddMapSizeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/map/AddMapSizeForm.kt new file mode 100644 index 00000000000..cf61297b5e5 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/map/AddMapSizeForm.kt @@ -0,0 +1,14 @@ +package de.westnordost.streetcomplete.quests.map + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AListQuestForm +import de.westnordost.streetcomplete.quests.TextItem + +class AddMapSizeForm : AListQuestForm() { + override val items = listOf( + TextItem("site", R.string.quest_mapSize_site), + TextItem("city", R.string.quest_mapSize_city), + TextItem("landscape", R.string.quest_mapSize_landscape), + TextItem("region", R.string.quest_mapSize_region), + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/map/AddMapType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/map/AddMapType.kt new file mode 100644 index 00000000000..f0e10456719 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/map/AddMapType.kt @@ -0,0 +1,30 @@ +package de.westnordost.streetcomplete.quests.map + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement +import de.westnordost.streetcomplete.osm.Tags + +class AddMapType : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + tourism = information + and information = map + and !map_type + """ + override val changesetComment = "Add map type" + override val defaultDisabledMessage = R.string.default_disabled_msg_ee + override val wikiLink = "Key:map_type" + override val icon = R.drawable.ic_quest_map_type + override val achievements = listOf(EditTypeAchievement.OUTDOORS) + + override fun getTitle(tags: Map) = R.string.quest_mapType_title + + override fun applyAnswerTo(answer: MapType, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["map_type"] = answer.osmValue + } + + override fun createForm() = AddMapTypeForm() +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/map/AddMapTypeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/map/AddMapTypeForm.kt new file mode 100644 index 00000000000..91e3ffa8d4f --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/map/AddMapTypeForm.kt @@ -0,0 +1,23 @@ +package de.westnordost.streetcomplete.quests.map + +import android.os.Bundle +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AImageListQuestForm +import de.westnordost.streetcomplete.view.image_select.DisplayItem + +class AddMapTypeForm : AImageListQuestForm() { + + override val items: List> get() = MapType.values().toItems() + + override val itemsPerRow = 1 + override val moveFavoritesToFront = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imageSelector.cellLayoutId = R.layout.cell_labeled_icon_select_via_ferrata_scale + } + + override fun onClickOk(selectedItems: List) { + applyAnswer(selectedItems.first()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/map/MapType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/map/MapType.kt new file mode 100644 index 00000000000..2d9b8c149e7 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/map/MapType.kt @@ -0,0 +1,40 @@ +package de.westnordost.streetcomplete.quests.map + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.map.MapType.* +import de.westnordost.streetcomplete.view.image_select.GroupableDisplayItem +import de.westnordost.streetcomplete.view.image_select.Item + +enum class MapType(val osmValue: String) { + TOPO("topo"), + STREET("street"), + SCHEME("scheme"), + TOPOSCOPE("toposcope") +} + +fun Array.toItems() = map { it.asItem() } + +fun MapType.asItem(): GroupableDisplayItem { + return Item(this, imageResId, titleResId, descriptionResId) +} + +private val MapType.imageResId: Int get() = when (this) { + TOPO -> R.drawable.map_type_topo + STREET -> R.drawable.map_type_street + SCHEME -> R.drawable.map_type_scheme + TOPOSCOPE -> R.drawable.map_type_toposcope +} + +private val MapType.titleResId: Int get() = when (this) { + TOPO -> R.string.quest_mapType_topo_title + STREET -> R.string.quest_mapType_street_title + SCHEME -> R.string.quest_mapType_scheme_title + TOPOSCOPE -> R.string.quest_mapType_toposcope_title +} + +private val MapType.descriptionResId: Int get() = when (this) { + TOPO -> R.string.quest_mapType_topo_description + STREET -> R.string.quest_mapType_street_description + SCHEME -> R.string.quest_mapType_scheme_description + TOPOSCOPE -> R.string.quest_mapType_toposcope_description +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/max_height/AddMaxHeight.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/max_height/AddMaxHeight.kt index 60b79cc4646..7cd32b305b0 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/max_height/AddMaxHeight.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/max_height/AddMaxHeight.kt @@ -6,6 +6,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.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Way import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.CAR import de.westnordost.streetcomplete.osm.ALL_PATHS @@ -123,7 +124,7 @@ class AddMaxHeight : OsmElementQuestType { // , that is in a layer above this way bridgeLayer > layer // and with which it does not share any node (=connects) (#2555) - && !bridge.nodeIds.toSet().containsAny(way.nodeIds) + && !bridge.nodeIds.toHashSet().containsAny(way.nodeIds) // , it intersects && bridgeGeometry != null && bridgeGeometry.intersects(geometry) } @@ -152,6 +153,19 @@ class AddMaxHeight : OsmElementQuestType { override fun createForm() = AddMaxHeightForm() + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry): Sequence { + val mapData = getMapData() + val bridges = mapData.ways.filter { bridgeFilter.matches(it) } + val layer = element.tags["layer"]?.toIntOrNull() ?: 0 + val geometry = mapData.getWayGeometry(element.id) as? ElementPolylinesGeometry ?: return emptySequence() + return bridges.filter { bridge -> + val bridgeGeometry = mapData.getWayGeometry(bridge.id) as? ElementPolylinesGeometry ?: return@filter false + (bridge.tags["layer"]?.toIntOrNull() ?: 0) > layer + && !bridge.nodeIds.toSet().containsAny((element as Way).nodeIds) + && bridgeGeometry.intersects(geometry) + }.asSequence() + } + override fun applyAnswerTo(answer: MaxHeightAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { when (answer) { is MaxHeight -> { @@ -159,6 +173,8 @@ class AddMaxHeight : OsmElementQuestType { } is NoMaxHeightSign -> { tags["maxheight:signed"] = "no" + if (answer.isTallEnough == true) tags["maxheight"] = "default" + else if (answer.isTallEnough == false) tags["maxheight"] = "below_default" } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/max_height/AddMaxHeightForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/max_height/AddMaxHeightForm.kt index be22a445e21..06ed95e48b7 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/max_height/AddMaxHeightForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/max_height/AddMaxHeightForm.kt @@ -67,9 +67,10 @@ class AddMaxHeightForm : AbstractOsmQuestForm() { private fun confirmNoSign() { activity?.let { AlertDialog.Builder(requireContext()) - .setTitle(R.string.quest_generic_confirmation_title) - .setPositiveButton(R.string.quest_generic_confirmation_yes) { _, _ -> applyAnswer(NoMaxHeightSign) } - .setNegativeButton(R.string.quest_generic_confirmation_no, null) + .setMessage(R.string.quest_maxheight_answer_noSign_question) + .setPositiveButton(R.string.quest_maxheight_answer_noSign_question_yes) { _, _ -> applyAnswer(NoMaxHeightSign(true)) } + .setNegativeButton(R.string.quest_maxheight_answer_noSign_question_no) { _, _ -> applyAnswer(NoMaxHeightSign(false)) } + .setNeutralButton(R.string.quest_maxheight_answer_noSign_question_unclear) { _, _ -> applyAnswer(NoMaxHeightSign(null)) } .show() } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/max_height/MaxHeightAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/max_height/MaxHeightAnswer.kt index 73a3b7261ca..2184e623238 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/max_height/MaxHeightAnswer.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/max_height/MaxHeightAnswer.kt @@ -5,4 +5,4 @@ import de.westnordost.streetcomplete.osm.Length sealed interface MaxHeightAnswer data class MaxHeight(val value: Length) : MaxHeightAnswer -data object NoMaxHeightSign : MaxHeightAnswer +data class NoMaxHeightSign(val isTallEnough: Boolean? = null) : MaxHeightAnswer diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/max_speed/AddMaxSpeed.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/max_speed/AddMaxSpeed.kt index 2d10f54861a..84ebf603cdb 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/max_speed/AddMaxSpeed.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/max_speed/AddMaxSpeed.kt @@ -13,7 +13,7 @@ import de.westnordost.streetcomplete.osm.Tags import de.westnordost.streetcomplete.osm.surface.UNPAVED_SURFACES import de.westnordost.streetcomplete.util.ktx.toYesNo -class AddMaxSpeed : OsmFilterQuestType() { +class AddMaxSpeed : OsmFilterQuestType?>>() { override val elementFilter = """ ways with @@ -43,7 +43,11 @@ class AddMaxSpeed : OsmFilterQuestType() { override fun createForm() = AddMaxSpeedForm() - override fun applyAnswerTo(answer: MaxSpeedAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + override fun applyAnswerTo(answer: Pair?>, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + if (answer.second != null) { + tags[answer.second!!.first] = answer.second!!.second + } + val answer = answer.first when (answer) { is MaxSpeedSign -> { tags["maxspeed"] = answer.value.toString() diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/max_speed/AddMaxSpeedForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/max_speed/AddMaxSpeedForm.kt index 18242910634..84a43179be3 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/max_speed/AddMaxSpeedForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/max_speed/AddMaxSpeedForm.kt @@ -1,6 +1,7 @@ package de.westnordost.streetcomplete.quests.max_speed import android.os.Bundle +import android.text.InputType import android.view.View import android.view.inputmethod.EditorInfo import android.widget.ArrayAdapter @@ -14,6 +15,7 @@ import androidx.appcompat.app.AlertDialog import androidx.core.view.children import androidx.core.view.isGone import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.meta.SpeedMeasurementUnit import de.westnordost.streetcomplete.data.meta.SpeedMeasurementUnit.KILOMETERS_PER_HOUR @@ -32,8 +34,9 @@ import de.westnordost.streetcomplete.util.ktx.advisorySpeedLimitSignLayoutResId import de.westnordost.streetcomplete.util.ktx.intOrNull import de.westnordost.streetcomplete.util.ktx.livingStreetSignDrawableResId import de.westnordost.streetcomplete.util.ktx.showKeyboard +import de.westnordost.streetcomplete.util.dialogs.showAddConditionalDialog -class AddMaxSpeedForm : AbstractOsmQuestForm() { +class AddMaxSpeedForm : AbstractOsmQuestForm?>>() { override val contentLayoutResId = R.layout.quest_maxspeed private val binding by contentViewBinding(QuestMaxspeedBinding::bind) @@ -43,6 +46,8 @@ class AddMaxSpeedForm : AbstractOsmQuestForm() { if (countryInfo.hasAdvisorySpeedLimitSign) { result.add(AnswerItem(R.string.quest_maxspeed_answer_advisory_speed_limit) { switchToAdvisorySpeedLimit() }) } + if (prefs.getBoolean(Prefs.EXPERT_MODE, false)) + result.add(AnswerItem(R.string.quest_maxspeed_conditional) { addConditional() }) return result } @@ -69,6 +74,70 @@ class AddMaxSpeedForm : AbstractOsmQuestForm() { binding.speedTypeSelect.setOnCheckedChangeListener { _, checkedId -> setSpeedType(getSpeedType(checkedId)) } } + private fun addConditional() { + // first require selecting sth for normal limit + if (!isFormComplete()) { + AlertDialog.Builder(requireContext()) + .setMessage(R.string.quest_maxspeed_conditional_limited_enter_default) + .setPositiveButton(android.R.string.ok, null) + .show() + return + } + // then copy whatever answering is doing: + + // if nsl: ask dual carriageway + if (speedType == NSL) { + askIsDualCarriageway( + onYes = { showConditionalDialog("nsl_dual") }, + onNo = { showConditionalDialog("nsl_single") } + ) + return + } + // if no sign: ask urban/rural + if (speedType == NO_SIGN) { + val highwayTag = element.tags["highway"]!! + if (ROADS_WITH_DEFINITE_SPEED_LIMIT.contains(highwayTag)) { + showConditionalDialog(highwayTag) + } else if (countryInfo.countryCode == "GB") { + askIsDualCarriageway( + onYes = { showConditionalDialog("nsl_dual") }, + onNo = { + determineLit( + onYes = { showConditionalDialog("nsl_restricted", true) }, + onNo = { showConditionalDialog("nsl_single", false) } + ) + } + ) + } else { + askUrbanOrRural( + onUrban = { showConditionalDialog("urban") }, + onRural = { showConditionalDialog("rural") } + ) + } + return + } + showConditionalDialog() + } + + private fun showConditionalDialog(noSignAnswer: String? = null, lit: Boolean? = null) { + showAddConditionalDialog(requireContext(), listOf("maxspeed", "maxspeed:hgv", "maxspeed:psv", "maxspeed:bus"), null, InputType.TYPE_CLASS_NUMBER) { k, v -> + val speedText = v.substringBefore("@").trim() + val speedNumber = speedText.toIntOrNull() ?: return@showAddConditionalDialog + val speed = when (speedUnitSelect?.selectedItem as SpeedMeasurementUnit? ?: speedUnits.first()) { + KILOMETERS_PER_HOUR -> Kmh(speedNumber) + MILES_PER_HOUR -> Mph(speedNumber) + } + val value = v.replaceFirst(speedText, speed.toString()) + when (speedType) { + NO_SIGN -> applyNoSignAnswer(noSignAnswer!!, null, k to value) + NSL -> applyNoSignAnswer(noSignAnswer!!, lit, k to value) + LIVING_STREET -> applyAnswer(IsLivingStreet to (k to value)) + else -> applySpeedLimitFormAnswer(k to value) + } + } + return + } + override fun onClickOk() { if (speedType == NO_SIGN) { val slowZoneLikely = countryInfo.hasSlowZone && element.tags["highway"] == "residential" @@ -79,7 +148,7 @@ class AddMaxSpeedForm : AbstractOsmQuestForm() { confirmNoSign { determineImplicitMaxspeedType() } } } else if (speedType == LIVING_STREET) { - applyAnswer(IsLivingStreet) + applyAnswer(IsLivingStreet to null) } else if (speedType == NSL) { askIsDualCarriageway( onYes = { applyNoSignAnswer("nsl_dual") }, @@ -193,16 +262,16 @@ class AddMaxSpeedForm : AbstractOsmQuestForm() { } } - private fun applySpeedLimitFormAnswer() { + private fun applySpeedLimitFormAnswer(conditional: Pair? = null) { val speed = getSpeedFromInput()!! when (speedType) { - ADVISORY -> applyAnswer(AdvisorySpeedSign(speed)) + ADVISORY -> applyAnswer(AdvisorySpeedSign(speed) to conditional) ZONE -> { val zoneX = speed.toValue() LAST_INPUT_SLOW_ZONE = zoneX - applyAnswer(MaxSpeedZone(speed, countryInfo.countryCode, "zone$zoneX")) + applyAnswer(MaxSpeedZone(speed, countryInfo.countryCode, "zone$zoneX") to conditional) } - SIGN -> applyAnswer(MaxSpeedSign(speed)) + SIGN -> applyAnswer(MaxSpeedSign(speed) to conditional) else -> throw IllegalStateException() } } @@ -314,8 +383,8 @@ class AddMaxSpeedForm : AbstractOsmQuestForm() { } } - private fun applyNoSignAnswer(roadType: String, lit: Boolean? = null) { - applyAnswer(ImplicitMaxSpeed(countryInfo.countryCode, roadType, lit)) + private fun applyNoSignAnswer(roadType: String, lit: Boolean? = null, conditional: Pair? = null) { + applyAnswer(ImplicitMaxSpeed(countryInfo.countryCode, roadType, lit) to conditional) } companion object { diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/max_weight/AddMaxWeight.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/max_weight/AddMaxWeight.kt index 7bd9e4dd7d0..7b1d145b12c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/max_weight/AddMaxWeight.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/max_weight/AddMaxWeight.kt @@ -50,7 +50,7 @@ class AddMaxWeight : OsmFilterQuestType() { } } -private val MaxWeightSign.osmKey get() = when (this) { +val MaxWeightSign.osmKey get() = when (this) { MAX_WEIGHT -> "maxweight" MAX_GROSS_VEHICLE_MASS -> "maxweightrating" MAX_AXLE_LOAD -> "maxaxleload" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/note_discussion/AttachPhotoFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/note_discussion/AttachPhotoFragment.kt index 006402a4716..8f67afa8cf5 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/note_discussion/AttachPhotoFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/note_discussion/AttachPhotoFragment.kt @@ -7,6 +7,7 @@ import android.graphics.Bitmap import android.os.Bundle import android.os.Environment import android.view.View +import android.widget.RelativeLayout import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.FileProvider import androidx.core.view.isGone @@ -14,7 +15,9 @@ import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface.TAG_GPS_IMG_DIRECTION import androidx.exifinterface.media.ExifInterface.TAG_GPS_IMG_DIRECTION_REF import androidx.fragment.app.Fragment +import com.russhwolf.settings.ObservableSettings import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osmnotes.deleteImages import de.westnordost.streetcomplete.databinding.FragmentAttachPhotoBinding @@ -24,6 +27,7 @@ import de.westnordost.streetcomplete.util.ktx.toast import de.westnordost.streetcomplete.util.logs.Log import de.westnordost.streetcomplete.util.viewBinding import de.westnordost.streetcomplete.view.AdapterDataChangedWatcher +import org.koin.android.ext.android.inject import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -31,6 +35,7 @@ import java.io.IOException class AttachPhotoFragment : Fragment(R.layout.fragment_attach_photo) { private val binding by viewBinding(FragmentAttachPhotoBinding::bind) + private val prefs: ObservableSettings by inject() private val takePhoto = registerForActivityResult(ActivityResultContracts.TakePicture(), ::onTookPhoto) @@ -60,6 +65,10 @@ class AttachPhotoFragment : Fragment(R.layout.fragment_attach_photo) { binding.attachedGpxView.isGone = !hasGpxAttached updateHintVisibility() + + // use default StreetComplete layout (no margin) if there is no GPX button + if (!prefs.getBoolean(Prefs.GPX_BUTTON, false)) + (binding.takePhotoButton.layoutParams as RelativeLayout.LayoutParams).marginStart = 0 } private fun updateHintVisibility() { @@ -100,6 +109,12 @@ class AttachPhotoFragment : Fragment(R.layout.fragment_attach_photo) { } if (file != null) { val exif = ExifInterface(file) + val dir = activity?.getExternalFilesDir(null) + if (prefs.getBoolean(Prefs.SAVE_PHOTOS, false) && dir != null) { + val target = File(dir.absolutePath + File.separator + "full_photos", file.name) + target.parentFile?.mkdirs() + file.copyTo(target) + } rescaleImageFile(file) copyDirectionExifData(file, exif) diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/note_discussion/NoteDiscussionForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/note_discussion/NoteDiscussionForm.kt index b96de2b78ee..6c73b344008 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/note_discussion/NoteDiscussionForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/note_discussion/NoteDiscussionForm.kt @@ -8,9 +8,11 @@ import android.text.format.DateUtils.MINUTE_IN_MILLIS import android.view.View import android.view.ViewGroup import androidx.core.view.isGone +import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osmnotes.NoteComment @@ -27,6 +29,7 @@ import de.westnordost.streetcomplete.databinding.QuestNoteDiscussionItemsBinding import de.westnordost.streetcomplete.quests.AbstractQuestForm import de.westnordost.streetcomplete.quests.AnswerItem import de.westnordost.streetcomplete.util.ktx.createBitmap +import de.westnordost.streetcomplete.util.ktx.popIn import de.westnordost.streetcomplete.util.ktx.nonBlankTextOrNull import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope @@ -91,6 +94,19 @@ class NoteDiscussionForm : AbstractQuestForm() { val comments = withContext(Dispatchers.IO) { noteSource.get(noteId) }!!.comments inflateNoteDiscussion(comments) } + + if (prefs.getBoolean(Prefs.SHOW_HIDE_BUTTON, false)) { + floatingBottomView2.popIn() + floatingBottomView2.setOnClickListener { + tempHideQuest() + } + floatingBottomView2.setOnLongClickListener { + hideQuest() + true + } + } + if (prefs.getBoolean(Prefs.EXPERT_MODE, false)) + binding.closeNoteCheckBox.isVisible = true } private fun inflateNoteDiscussion(comments: List) { @@ -108,11 +124,13 @@ class NoteDiscussionForm : AbstractQuestForm() { } override fun onClickOk() { - require(noteText != null) { "NoteQuest has been answered with an empty comment!" } + val close = binding.closeNoteCheckBox.isChecked + require(noteText != null || close) { "NoteQuest has been answered with an empty comment!" } val imagePaths = attachPhotoFragment?.imagePaths.orEmpty() viewLifecycleScope.launch { withContext(Dispatchers.IO) { - noteEditsController.add(noteId, NoteEditAction.COMMENT, geometry.center, noteText, imagePaths) + val action = if (close) NoteEditAction.CLOSE else NoteEditAction.COMMENT + noteEditsController.add(noteId, action, geometry.center, noteText, imagePaths) } listener?.onNoteQuestSolved(questType, noteId, geometry.center) } @@ -128,6 +146,12 @@ class NoteDiscussionForm : AbstractQuestForm() { } } + private fun tempHideQuest() { + viewLifecycleScope.launch { + withContext(Dispatchers.IO) { hiddenQuestsController.tempHide(questKey) } + } + } + override fun onDiscard() { attachPhotoFragment?.deleteImages() } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHours.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHours.kt index ecf923abb8d..4b77108e0ca 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHours.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHours.kt @@ -1,7 +1,9 @@ package de.westnordost.streetcomplete.quests.opening_hours +import android.content.Context import de.westnordost.osm_opening_hours.parser.toOpeningHoursOrNull import de.westnordost.osmfeatures.Feature +import androidx.appcompat.app.AlertDialog import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.elementfilter.filters.RelativeDate import de.westnordost.streetcomplete.data.elementfilter.filters.TagOlderThan @@ -16,6 +18,9 @@ import de.westnordost.streetcomplete.osm.isPlaceOrDisusedPlace import de.westnordost.streetcomplete.osm.opening_hours.parser.isSupportedOpeningHours import de.westnordost.streetcomplete.osm.updateCheckDateForKey import de.westnordost.streetcomplete.osm.updateWithCheckDate +import de.westnordost.streetcomplete.quests.booleanQuestSettingsDialog +import de.westnordost.streetcomplete.quests.fullElementSelectionDialog +import de.westnordost.streetcomplete.quests.getPrefixedFullElementSelectionPref class AddOpeningHours( private val getFeature: (Element) -> Feature? @@ -23,8 +28,14 @@ class AddOpeningHours( /* See also AddWheelchairAccessBusiness and AddPlaceName, which has a similar list and is/should be ordered in the same way for better overview */ - private val filter by lazy { (""" + private val filterString by lazy { (""" nodes, ways with + ( + name or brand or noname = yes or name:signed = no + or barrier + or amenity ~ toilets|bicycle_rental + ) + and ( ( ( @@ -113,13 +124,10 @@ class AddOpeningHours( ) ) and access !~ private|no - and ( - name or brand or noname = yes or name:signed = no - or barrier - or amenity ~ toilets|bicycle_rental - ) and opening_hours:signed != no - """).toElementFilterExpression() } + """) } + + private val filter by lazy { prefs.getString(getPrefixedFullElementSelectionPref(prefs), filterString)!!.toElementFilterExpression() } override val changesetComment = "Survey opening hours" override val wikiLink = "Key:opening_hours" @@ -155,6 +163,7 @@ class AddOpeningHours( // invalid opening_hours rules -> applicable because we want to ask for opening hours again // be strict val oh = ohStr.toOpeningHoursOrNull(lenient = false) ?: return true + if (prefs.getBoolean(RESURVEY_ALL_OPENING_HOURS, false)) return true // only display supported rules, however, those that are supported but have colliding // weekdays should be shown (->resurveyed), as they are likely mistakes return oh.rules.all { rule -> rule.isSupportedOpeningHours() } && !oh.containsTimePoints() @@ -191,4 +200,25 @@ class AddOpeningHours( tags.containsKey("name") || tags.containsKey("brand") private fun hasFeatureName(element: Element) = getFeature(element)?.name != null + + override val hasQuestSettings: Boolean = true + + override fun getQuestSettingsDialog(context: Context) = + AlertDialog.Builder(context) + .setTitle(R.string.quest_settings_what_to_edit) + .setPositiveButton(R.string.quest_settings_resurvey_all_opening_hours_title) { _, _ -> + booleanQuestSettingsDialog(context, prefs, RESURVEY_ALL_OPENING_HOURS, + R.string.quest_settings_resurvey_all_opening_hours_message, + R.string.quest_settings_resurvey_all_opening_hours_yes, + R.string.quest_settings_resurvey_all_opening_hours_no + ).show() + } + .setNegativeButton(R.string.element_selection_button) { _, _ -> + fullElementSelectionDialog(context, prefs, getPrefixedFullElementSelectionPref(prefs), + R.string.quest_settings_element_selection, filterString + ).show() + } + .create() } + +private const val RESURVEY_ALL_OPENING_HOURS = "qs_AddOpeningHours_resurvey_all" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHoursForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHoursForm.kt index 658196cde27..5a17d15d6c6 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHoursForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHoursForm.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.quests.opening_hours import android.os.Bundle import android.view.Menu.NONE import android.view.View +import android.widget.ArrayAdapter import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.core.view.isGone @@ -19,6 +20,7 @@ import de.westnordost.streetcomplete.quests.AnswerItem import de.westnordost.streetcomplete.quests.opening_hours.adapter.OpeningHoursAdapter import de.westnordost.streetcomplete.quests.opening_hours.adapter.OpeningMonthsRow import de.westnordost.streetcomplete.quests.opening_hours.adapter.OpeningWeekdaysRow +import de.westnordost.streetcomplete.util.takeFavourites import de.westnordost.streetcomplete.view.AdapterDataChangedWatcher import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -141,12 +143,22 @@ class AddOpeningHoursForm : AbstractOsmQuestForm() { private fun showInputCommentDialog() { val dialogBinding = QuestOpeningHoursCommentBinding.inflate(layoutInflater) + val lastValues = prefs.getLastPicked(javaClass.simpleName).takeFavourites(3, 5, 2) + dialogBinding.commentInput.setAdapter( + ArrayAdapter( + requireContext(), + android.R.layout.simple_dropdown_item_1line, + lastValues + ) + ) AlertDialog.Builder(requireContext()) .setTitle(R.string.quest_openingHours_comment_title) .setView(dialogBinding.root) .setPositiveButton(android.R.string.ok) { _, _ -> - val txt = dialogBinding.commentInput.text.toString().replace("\"", "").trim() + val txtRaw = dialogBinding.commentInput.text.toString() + prefs.addLastPicked(javaClass.simpleName, txtRaw) + val txt = txtRaw.replace("\"", "").trim() if (txt.isEmpty()) { AlertDialog.Builder(requireContext()) .setMessage(R.string.quest_openingHours_emptyAnswer) @@ -157,6 +169,10 @@ class AddOpeningHoursForm : AbstractOsmQuestForm() { } } .setNegativeButton(android.R.string.cancel, null) + .create().apply { setOnShowListener { + dialogBinding.commentInput.requestFocus() + dialogBinding.commentInput.showDropDown() + } } .show() } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours_signed/CheckOpeningHoursSigned.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours_signed/CheckOpeningHoursSigned.kt index bab321062fa..9a3d796b550 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours_signed/CheckOpeningHoursSigned.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours_signed/CheckOpeningHoursSigned.kt @@ -54,7 +54,7 @@ class CheckOpeningHoursSigned( mapData.filter { isApplicableTo(it) } override fun isApplicableTo(element: Element): Boolean = - filter.matches(element) && hasName(element) + filter.matches(element) override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = getMapData().asSequence().filter { it.isPlaceOrDisusedPlace() } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/osmose/OsmoseDao.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/osmose/OsmoseDao.kt new file mode 100644 index 00000000000..4d4054870ac --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/osmose/OsmoseDao.kt @@ -0,0 +1,338 @@ +package de.westnordost.streetcomplete.quests.osmose + +import android.database.sqlite.SQLiteException +import com.russhwolf.settings.ObservableSettings +import de.westnordost.streetcomplete.ApplicationConstants.USER_AGENT +import de.westnordost.streetcomplete.data.ConflictAlgorithm +import de.westnordost.streetcomplete.data.CursorPosition +import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.Database +import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource +import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry +import de.westnordost.streetcomplete.data.osm.geometry.ElementPolygonsGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuest +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestType +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry +import de.westnordost.streetcomplete.quests.osmose.OsmoseTable.Columns.CLASS +import de.westnordost.streetcomplete.quests.osmose.OsmoseTable.Columns.ELEMENTS +import de.westnordost.streetcomplete.quests.osmose.OsmoseTable.Columns.ANSWERED +import de.westnordost.streetcomplete.quests.osmose.OsmoseTable.Columns.ITEM +import de.westnordost.streetcomplete.quests.osmose.OsmoseTable.Columns.LATITUDE +import de.westnordost.streetcomplete.quests.osmose.OsmoseTable.Columns.LEVEL +import de.westnordost.streetcomplete.quests.osmose.OsmoseTable.Columns.LONGITUDE +import de.westnordost.streetcomplete.quests.osmose.OsmoseTable.Columns.SUBTITLE +import de.westnordost.streetcomplete.quests.osmose.OsmoseTable.Columns.TIMESTAMP +import de.westnordost.streetcomplete.quests.osmose.OsmoseTable.Columns.TITLE +import de.westnordost.streetcomplete.quests.osmose.OsmoseTable.Columns.UUID +import de.westnordost.streetcomplete.quests.osmose.OsmoseTable.NAME +import de.westnordost.streetcomplete.quests.questPrefix +import de.westnordost.streetcomplete.util.getSelectedLocales +import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds +import de.westnordost.streetcomplete.util.logs.Log +import de.westnordost.streetcomplete.util.math.measuredMultiPolygonArea +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.request +import io.ktor.client.request.url +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.IOException + +class OsmoseDao( + private val db: Database, + private val prefs: Preferences, +) : KoinComponent { + private val client by lazy { HttpClient() } + + private val mapDataWithEditsSource: MapDataWithEditsSource by inject() + private val questTypeRegistry: QuestTypeRegistry by inject() + + private val ignoredItems = hashSetOf() + private val ignoredItemClassCombinations = hashSetOf() + private val ignoredSubtitles = hashSetOf() + private val allowedLevels = hashSetOf() + init { reloadIgnoredItems() } + fun reloadIgnoredItems() { + val ignored = prefs.getString(questPrefix(prefs) + PREF_OSMOSE_ITEMS, OSMOSE_DEFAULT_IGNORED_ITEMS).split("§§") + ignoredItems.clear() + ignoredItemClassCombinations.clear() + ignoredSubtitles.clear() + val itemClassRegex = "\\d+/\\d+".toRegex() + ignored.forEach { + val i = it.trim().ifEmpty { return@forEach } + when { + i.toIntOrNull() != null -> ignoredItems.add(i.toInt()) + i.contains('/') && i.matches(itemClassRegex) -> ignoredItemClassCombinations.add(i) + else -> ignoredSubtitles.add(i) + } + } + allowedLevels.clear() + allowedLevels.addAll( + prefs.getString(questPrefix(prefs) + PREF_OSMOSE_LEVEL, "").split("%2C").mapNotNull { it.toIntOrNull() } + ) + } + + suspend fun download(bbox: BoundingBox): List { + // https://osmose.openstreetmap.fr/api/0.3/issues.csv?zoom=18&item=xxxx&level=1&limit=500&bbox=16.412324309349064%2C48.18403988244578%2C16.41940534114838%2C48.1871908341706 + // replace bbox + val csvUrl = "https://osmose.openstreetmap.fr/api/0.3/issues.csv" + val zoom = 16 // what is the use? + val level = prefs.getString(questPrefix(prefs) + PREF_OSMOSE_LEVEL, "") + if (level.isEmpty()) return emptyList() + val url = "$csvUrl?zoom=$zoom&item=xxxx&level=$level&limit=500&bbox=${bbox.min.longitude}%2C${bbox.min.latitude}%2C${bbox.max.longitude}%2C${bbox.max.latitude}" + val requestBuilder = HttpRequestBuilder() + requestBuilder.url(url) + requestBuilder.header("User-Agent", USER_AGENT) + if (prefs.getBoolean(PREF_OSMOSE_APP_LANGUAGE, false)) { + val locale = getSelectedLocales(prefs)[0] + if (locale != null) + requestBuilder.header("Accept-Language", locale.toString()) + } + Log.d(TAG, "downloading for bbox: $bbox using request $url") + val issues = mutableListOf() + try { + val response = client.get(requestBuilder) + val body: String = response.body() ?: return emptyList() + // drop first, it's just column names + // drop last, it's an empty line + // trim each line because there was some additional newline in logs (maybe windows line endings?) + val bodylines = body.split("\n").drop(1).dropLast(1) + Log.d(TAG, "got ${bodylines.size} problems") + + val downloadTimestamp = nowAsEpochMilliseconds() + db.replaceMany(NAME, + arrayOf(UUID, ITEM, CLASS, LEVEL, TITLE, SUBTITLE, LATITUDE, LONGITUDE, ELEMENTS, ANSWERED, TIMESTAMP), + bodylines.mapNotNull { + val split = it.trim().split(splitRegex) // from https://stackoverflow.com/questions/53997728/parse-csv-to-kotlin-list + if (split.size != 14) { + Log.i(TAG, "skip line, not split into 14 items: $split") + null + } else { + val item = split[2].toIntOrNull() + val itemClass = split[3].toIntOrNull() + val itemLevel = split[4].toIntOrNull() + val lat = split[11].toDoubleOrNull() + val lon = split[12].toDoubleOrNull() + if (item == null || itemClass == null || itemLevel == null || lat == null || lon == null) { + Log.i(TAG, "skip line, could not parse some numbers: $split") + return@mapNotNull null + } + issues.add(OsmoseIssue( + split[0], item, itemClass, itemLevel, split[5], split[6], LatLon(lat, lon), parseElementKeys(split[13]) + )) + arrayOf(split[0], item, itemClass, itemLevel, split[5], split[6], lat, lon, split[13], 0, downloadTimestamp) + } + } + ) + db.delete(NAME, where = "${inBoundsSql(bbox)} AND $TIMESTAMP < $downloadTimestamp") // delete old issues inside the bbox + } catch (e: Exception) { + Log.e(TAG, "error while downloading / inserting: ${e.message}", e) + } + return issues.mapNotNull { it.toQuest() } + } + + fun getQuest(uuid: String): ExternalSourceQuest? = + db.queryOne(NAME, where = "$UUID = '$uuid' AND $ANSWERED = 0") { it.toOsmoseIssue().toQuest() } + + fun getIssue(uuid: String): OsmoseIssue? = + db.queryOne(NAME, where = "$UUID = '$uuid' AND $ANSWERED = 0") { c -> c.toOsmoseIssue().takeIf { !it.isIgnored() } } + + fun getAllQuests(bbox: BoundingBox): List = + db.query(NAME, where = "${inBoundsSql(bbox)} AND $ANSWERED = 0") { + it.toOsmoseIssue() + }.mapNotNull { it.toQuest() } + + private fun OsmoseIssue.toQuest(): ExternalSourceQuest? = + if (isIgnored()) null + else + ExternalSourceQuest( + uuid, + if (elements.size == 1) mapDataWithEditsSource.getGeometry(elements.single().type, elements.single().id) ?: ElementPointGeometry(position) + else ElementPointGeometry(position), + questTypeRegistry.getByName(OsmoseQuest::class.simpleName!!) as ExternalSourceQuestType, + position + ).apply { if (elements.size == 1) elementKey = elements.single() } + // same area limitation as AddForestLeafType + .takeIf { ((it.geometry as? ElementPolygonsGeometry)?.polygons?.measuredMultiPolygonArea() ?: 0.0) < 10000 } + + suspend fun reportChange(uuid: String, falsePositive: Boolean) { + val url = "https://osmose.openstreetmap.fr/api/0.3/issue/$uuid/" + + if (falsePositive) "false" + else "done" + val requestBuilder = HttpRequestBuilder() + requestBuilder.header("User-Agent", USER_AGENT) + requestBuilder.url(url) + try { + client.get(requestBuilder) + db.delete(NAME, where = "$UUID = '$uuid'") + } catch (e: IOException) { + // just do nothing, so it's later tried again (hopefully...) + Log.i(TAG, "error while uploading: ${e.message} to $url") + } + } + + // no need to report done here, as each "done" should be connected to an element edit + suspend fun reportFalsePositives() { + try { + db.query(NAME, where = "$ANSWERED = 1") { + Pair(it.getString(UUID), it.getInt(ANSWERED) == 1) + }.forEach { reportChange(it.first, it.second) } + } catch (e: SQLiteException) { + // SQLiteException: no such table: osmose_issues_v2 (code 1): , while compiling: SELECT * FROM osmose_issues_v2 WHERE answered = 1 + // user didn't even enable osmose quest -> in this case unused osmose quest should not cause a crash + // but actually: why isn't table created? it's in database helper init! + Log.w(TAG, "Osmose table not found when trying to report false positives") + } + } + + // assume it exists if it's unclear + suspend fun doesIssueStillExist(uuid: String): Boolean { + val url = "https://osmose.openstreetmap.fr/api/0.3/issue/$uuid" + val requestBuilder = HttpRequestBuilder() + requestBuilder.header("User-Agent", USER_AGENT) + requestBuilder.url(url) + return try { + val r = client.get(requestBuilder) + val body: String = r.body() + if (body.contains("not a valid uuid") || body.contains("not present in database")) { + db.delete(NAME, where = "$UUID = '$uuid'") + false + } else true + } catch (e: IOException) { + // just do nothing, so it's later tried again (hopefully...) + Log.i(TAG, "error checking existence of $uuid: ${e.message}") + true + } + } + + fun setAsFalsePositive(uuid: String) { + db.update(NAME, values = listOf(ANSWERED to 1), where = "$UUID = '$uuid'", + conflictAlgorithm = ConflictAlgorithm.IGNORE + ) + } + + fun setDone(uuid: String) { + db.update(NAME, values = listOf(ANSWERED to -1), where = "$UUID = '$uuid'", + conflictAlgorithm = ConflictAlgorithm.IGNORE + ) + } + + fun setNotAnswered(uuid: String) { + db.update(NAME, values = listOf(ANSWERED to 0), where = "$UUID = '$uuid'", + conflictAlgorithm = ConflictAlgorithm.IGNORE + ) + } + + fun delete(uuid: String) = db.delete(NAME, where = "$UUID = '$uuid'") > 0 + + fun deleteOlderThan(timestamp: Long) { + db.delete(NAME, where = "$TIMESTAMP < $timestamp") + } + + fun clear() { + db.delete(NAME) + } + + private fun OsmoseIssue.isIgnored() = item in ignoredItems + || level !in allowedLevels + || subtitle in ignoredSubtitles + || "$item/$itemClass" in ignoredItemClassCombinations + +} + +private const val TAG = "OsmoseDao" + +data class OsmoseIssue( + val uuid: String, + val item: Int, + val itemClass: Int, + val level: Int, + val title: String, + val subtitle: String, + val position: LatLon, + val elements: List, +) + +private fun CursorPosition.toOsmoseIssue() = OsmoseIssue( + getString(UUID), + getInt(ITEM), + getInt(CLASS), + getInt(LEVEL), + getString(TITLE).intern(), + getString(SUBTITLE).intern(), + LatLon(getDouble(LATITUDE), getDouble(LONGITUDE)), + parseElementKeys(getString(ELEMENTS)) +) + +private fun parseElementKeys(elementString: String): List { + return try { + elementString.split("_").mapNotNull { e -> + when { + e.startsWith("node") -> e.substringAfter("node").toLongOrNull()?.let { ElementKey(ElementType.NODE, it) } + e.startsWith("way") -> e.substringAfter("way").toLongOrNull()?.let { ElementKey(ElementType.WAY, it) } + e.startsWith("relation") -> e.substringAfter("relation").toLongOrNull()?.let { ElementKey(ElementType.RELATION, it) } + else -> null + } + } + } catch (e: Exception) { + Log.w(TAG, "could not parse element string: $elementString") + emptyList() + } +} + +object OsmoseTable { + const val NAME = "osmose_issues_v2" + private const val NAME_INDEX = "osmose_issues_spatial_index" + + object Columns { + const val UUID = "uuid" + const val ITEM = "item" + const val CLASS = "class" + const val LEVEL = "level" + const val TITLE = "title" + const val SUBTITLE = "subtitle" + const val LATITUDE = "latitude" + const val LONGITUDE = "longitude" + const val ELEMENTS = "elements" + const val ANSWERED = "answered" + const val TIMESTAMP = "download_timestamp" + } + + const val CREATE_IF_NOT_EXISTS = """ + CREATE TABLE IF NOT EXISTS $NAME ( + $UUID varchar(255) PRIMARY KEY NOT NULL, + $ITEM int NOT NULL, + $CLASS int NOT NULL, + $LEVEL int NOT NULL, + $TITLE text, + $SUBTITLE text, + $LATITUDE float NOT NULL, + $LONGITUDE float NOT NULL, + $ELEMENTS text, + $ANSWERED int NOT NULL, + $TIMESTAMP int NOT NULL + ); + """ + + const val CREATE_SPATIAL_INDEX_IF_NOT_EXISTS = """ + CREATE INDEX IF NOT EXISTS $NAME_INDEX ON $NAME ( + $LATITUDE, + $LONGITUDE + ); + """ + +} + +private val splitRegex = ",(?=([^\"]*\"[^\"]*\")*[^\"]*$)".toRegex() + +private fun inBoundsSql(bbox: BoundingBox): String = """ + ($LATITUDE BETWEEN ${bbox.min.latitude} AND ${bbox.max.latitude}) AND + ($LONGITUDE BETWEEN ${bbox.min.longitude} AND ${bbox.max.longitude}) +""".trimIndent() diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/osmose/OsmoseForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/osmose/OsmoseForm.kt new file mode 100644 index 00000000000..3a9dc7117d1 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/osmose/OsmoseForm.kt @@ -0,0 +1,162 @@ +package de.westnordost.streetcomplete.quests.osmose + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.View +import android.widget.Button +import android.widget.LinearLayout +import androidx.appcompat.app.AlertDialog +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AbstractExternalSourceQuestForm +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController +import de.westnordost.streetcomplete.data.osm.geometry.ElementPolylinesGeometry +import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey +import de.westnordost.streetcomplete.databinding.QuestOsmoseCustomQuestBinding +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.quests.questPrefix +import de.westnordost.streetcomplete.screens.main.MainActivity +import de.westnordost.streetcomplete.screens.main.map.MainMapFragment +import de.westnordost.streetcomplete.screens.main.map.Marker +import de.westnordost.streetcomplete.screens.main.map.ShowsGeometryMarkers +import de.westnordost.streetcomplete.util.dialogs.setViewWithDefaultPadding +import de.westnordost.streetcomplete.util.ktx.arrayOfNotNull +import de.westnordost.streetcomplete.util.ktx.toast +import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject + +@SuppressLint("SetTextI18n") // android studio complains, but that's element type and id and probably should not be translated +class OsmoseForm : AbstractExternalSourceQuestForm() { + + private val osmoseDao: OsmoseDao by inject() + + private val issue: OsmoseIssue? by lazy { + val key = questKey as ExternalSourceQuestKey + osmoseDao.getIssue(key.id) + } + + private val questController: ExternalSourceQuestController by inject() + + override val buttonPanelAnswers by lazy { + val issue = issue + if (issue == null) emptyList() + else + listOfNotNull( + if (issue.elements.isEmpty()) null + else if (issue.elements.size == 1) { + val e = mapDataSource.get(issue.elements.single().type, issue.elements.single().id) + if (e == null) null + else + AnswerItem(R.string.quest_generic_answer_show_edit_tags) { editTags(e) } + } else { + val elements = issue.elements.mapNotNull { mapDataSource.get(it.type, it.id) } + if (elements.isEmpty()) null + else + AnswerItem(R.string.quest_generic_answer_show_edit_tags) { + val l = LinearLayout(requireContext()).apply { orientation = LinearLayout.VERTICAL } + var d: AlertDialog? = null + elements.forEach { e -> + l.addView(Button(requireContext()).apply { + text = "${e.type} ${e.id}" + setOnClickListener { + editTags(e) + d?.dismiss() + } + }) + } + d = AlertDialog.Builder(requireContext()) + .setTitle(R.string.quest_osmose_select_element) + .setViewWithDefaultPadding(l) + .setNegativeButton(android.R.string.cancel, null) + .create() + d?.show() + } + }, + AnswerItem(R.string.quest_osmose_false_positive) { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.quest_osmose_false_positive) + .setMessage(R.string.quest_osmose_no_undo) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.quest_generic_confirmation_yes) { _,_ -> + osmoseDao.setAsFalsePositive(issue.uuid) + tempHideQuest() // will still not be shown again, as osmoseDao doesn't create a quest from that any more + // todo: do some kind of edit, so it can be undone? the edit could be deleted on upload (see also ExternalSourceModule commented stuff) + } + .show() + } + ) + } + + override val contentLayoutResId = R.layout.quest_osmose_custom_quest + private val binding by contentViewBinding(QuestOsmoseCustomQuestBinding::bind) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val issue = issue + if (issue == null) { + context?.toast(R.string.quest_custom_quest_osmose_not_found) + questController.delete(questKey as ExternalSourceQuestKey) + return + } + setTitle(resources.getString(R.string.quest_osmose_title) + " ${issue.title}") + binding.description.text = resources.getString(R.string.quest_osmose_message_for_element, "${issue.item}/${issue.itemClass}", issue.subtitle) + + if (issue.elements.size > 1) viewLifecycleScope.launch { highlightElements() } + updateButtonPanel() + } + + private fun highlightElements() { + val issue = issue ?: return + val elementsAndGeometry = issue.elements.mapNotNull { mapDataSource.get(it.type, it.id) }.mapNotNull { e -> mapDataSource.getGeometry(e.type, e.id)?.let { e to it } } + + if (prefs.getBoolean(Prefs.SHOW_WAY_DIRECTION, false) && elementsAndGeometry.any { it.second is ElementPolylinesGeometry }) { + // show geometry containing way direction together with normal one. not nice looking, but: + // normal one contains way labels, which are necessary for editing + // this here contains the arrows + // and adding arrows to "normal" highlighted ways in special cases only is maybe work for later + val mapFragment = (activity as? MainActivity)?.supportFragmentManager?.fragments?.filterIsInstance()?.singleOrNull() + mapFragment?.highlightGeometries(elementsAndGeometry.map { it.second }) + } + + val showsGeometryMarkersListener = activity as? ShowsGeometryMarkers ?: return + showsGeometryMarkersListener.putMarkersForCurrentHighlighting(elementsAndGeometry.map { + Marker(it.second, null, "${it.first.type} ${it.first.id}") + }) + } + + override val otherAnswers: List by lazy { listOfNotNull( + AnswerItem(R.string.quest_osmose_hide_type) { showIgnoreDialog() }, + AnswerItem(R.string.quest_osmose_delete_this_issue) { + questController.delete(questKey as ExternalSourceQuestKey) + }, + ) + } + + private fun showIgnoreDialog() { + val issue = issue ?: return + AlertDialog.Builder(requireContext()) + .setTitle(R.string.quest_osmose_hide_type) + .setItems(arrayOfNotNull("item: ${issue.item}", "item/class: ${issue.item}/${issue.itemClass}", "subtitle: ${issue.subtitle}".takeIf { issue.subtitle.isNotBlank() })) { _, i -> + when (i) { + 0 -> issue.item.toString() + 1 -> "${issue.item}/${issue.itemClass}" + 2 -> issue.subtitle + else -> null + }?.let { addToIgnoreList(it) } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun addToIgnoreList(item: String) { + val types = prefs.getString(questPrefix(prefs) + PREF_OSMOSE_ITEMS, OSMOSE_DEFAULT_IGNORED_ITEMS) + .split("§§") + .mapNotNull { if (it.isNotBlank()) it.trim() else null } + .toMutableSet() + types.add(item) + prefs.putString(questPrefix(prefs) + PREF_OSMOSE_ITEMS,types.sorted().joinToString("§§")) + osmoseDao.reloadIgnoredItems() + questController.invalidate() + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/osmose/OsmoseQuest.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/osmose/OsmoseQuest.kt new file mode 100644 index 00000000000..8321d956838 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/osmose/OsmoseQuest.kt @@ -0,0 +1,208 @@ +package de.westnordost.streetcomplete.quests.osmose + +import androidx.appcompat.app.AlertDialog +import android.content.Context +import android.widget.Button +import android.widget.CheckBox +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.appcompat.widget.SwitchCompat +import androidx.core.content.edit +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.edits.ElementEdit +import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuest +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestType +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestController +import de.westnordost.streetcomplete.data.quest.Countries +import de.westnordost.streetcomplete.quests.questPrefix +import de.westnordost.streetcomplete.util.dialogs.setViewWithDefaultPadding +import de.westnordost.streetcomplete.util.ktx.dpToPx +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +class OsmoseQuest(private val osmoseDao: OsmoseDao) : ExternalSourceQuestType { + + override fun getTitle(tags: Map) = R.string.quest_osmose_title + + override suspend fun download(bbox: BoundingBox) = osmoseDao.download(bbox) + + override suspend fun upload() = osmoseDao.reportFalsePositives() + + override fun deleteMetadataOlderThan(timestamp: Long) = osmoseDao.deleteOlderThan(timestamp) + + override fun getQuests(bbox: BoundingBox) = osmoseDao.getAllQuests(bbox) + + override fun get(id: String): ExternalSourceQuest? = osmoseDao.getQuest(id) + + override fun deleteQuest(id: String): Boolean = osmoseDao.delete(id) + + override fun onAddedEdit(edit: ElementEdit, id: String) = osmoseDao.setDone(id) + + override fun onDeletedEdit(edit: ElementEdit, id: String?) { + if (edit.isSynced) return // already reported as done + if (id != null) + osmoseDao.setNotAnswered(id) + } + + override fun onSyncEditFailed(edit: ElementEdit, id: String?) { + if (id != null) osmoseDao.delete(id) + } + + override suspend fun onUpload(edit: ElementEdit, id: String?): Boolean { + // check whether issue still exists before uploading + if (id == null) return true // if we don't have an id, assume it's ok + return osmoseDao.doesIssueStillExist(id) + } + + override fun onSyncedEdit(edit: ElementEdit, id: String?) { + if (id != null) + GlobalScope.launch { osmoseDao.reportChange(id, false) } // edits are never false positive + } + + override val enabledInCountries: Countries + get() = super.enabledInCountries + + override val changesetComment = "Fix osmose issues" + override val wikiLink = "Osmose" + override val icon = R.drawable.ic_quest_osmose + override val defaultDisabledMessage = R.string.quest_osmose_message + override val source = "osmose" + + override fun createForm() = OsmoseForm() + + override fun getQuestSettingsDialog(context: Context): AlertDialog { + val levels = prefs.getString(questPrefix(prefs) + PREF_OSMOSE_LEVEL, "")!!.split("%2C").mapNotNull { it.toIntOrNull() } + val high = CheckBox(context).apply { + setText(R.string.quest_settings_osmose_level_high) + isChecked = levels.contains(1) + } + val medium = CheckBox(context).apply { + setText(R.string.quest_settings_osmose_level_medium) + isChecked = levels.contains(2) + } + val low = CheckBox(context).apply { + setText(R.string.quest_settings_osmose_level_low) + isChecked = levels.contains(3) + } + val hide = Button(context).apply { + setText(R.string.quest_osmose_settings_items) + setOnClickListener {showIgnoredItemsDialog(context) } + } + val appLanguage = SwitchCompat(context).apply { + setText(R.string.quest_osmose_use_app_language) + isChecked = prefs.getBoolean(PREF_OSMOSE_APP_LANGUAGE, false) + } + val appLanguageInfo = TextView(context).apply { + setText(R.string.quest_osmose_use_app_language_information) + val padding = context.resources.dpToPx(8).toInt() + setPadding(padding, 0, padding, 0) + } + val layout = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(TextView(context).apply { setText(R.string.quest_settings_osmose_level_title) }) + addView(high) + addView(medium) + addView(low) + addView(hide) + addView(appLanguage) + addView(appLanguageInfo) + } + + return AlertDialog.Builder(context) + .setTitle(context.resources.getString(R.string.quest_osmose_title, "…")) + .setViewWithDefaultPadding(ScrollView(context).apply { addView(layout) }) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + val levelString = listOfNotNull( + if (high.isChecked) 1 else null, + if (medium.isChecked) 2 else null, + if (low.isChecked) 3 else null, + ).takeIf { it.isNotEmpty() }?.joinToString("%2C") ?: "" + if (levelString != prefs.getString(questPrefix(prefs) + PREF_OSMOSE_LEVEL, OSMOSE_DEFAULT_IGNORED_ITEMS)) { + prefs.edit { putString(questPrefix(prefs) + PREF_OSMOSE_LEVEL, levelString) } + downloadEnabled = levelString != "" + osmoseDao.reloadIgnoredItems() + OsmQuestController.reloadQuestTypes() // actually this is doing a bit more than necessary, but whatever + } + prefs.edit { putBoolean(PREF_OSMOSE_APP_LANGUAGE, appLanguage.isChecked) } + } + .create() + } + + // dialog broken if list is long and button text is long + // but that actually looks like an Android issue,,, + // anyway, with current short button text all buttons are in one line, and there should be no problem + private fun showIgnoredItemsDialog(context: Context) { + val pref = questPrefix(prefs) + PREF_OSMOSE_ITEMS + val items = prefs.getString(pref, OSMOSE_DEFAULT_IGNORED_ITEMS)!!.split("§§").filter { it.isNotEmpty() }.toTypedArray() + val itemsForRemoval = mutableSetOf() + var d: AlertDialog? = null + d = AlertDialog.Builder(context) + .setMultiChoiceItems(items, null) { _, i, x -> + if (x) itemsForRemoval.add(items[i]) + else itemsForRemoval.remove(items[i]) + d?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = itemsForRemoval.isNotEmpty() + } + .setPositiveButton(R.string.quest_osmose_remove_checked) { _, _ -> + prefs.edit { putString(pref, items.filterNot { it in itemsForRemoval }.joinToString("§§")) } + osmoseDao.reloadIgnoredItems() + OsmQuestController.reloadQuestTypes() + } + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.quest_settings_reset) { _, _ -> + prefs.edit { remove(pref) } + osmoseDao.reloadIgnoredItems() + OsmQuestController.reloadQuestTypes() + }.create() + d.show() + d.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = itemsForRemoval.isNotEmpty() + d.getButton(AlertDialog.BUTTON_NEUTRAL)?.isEnabled = prefs.contains(pref) + } +} + +const val PREF_OSMOSE_ITEMS = "qs_OsmoseQuest_blocked_items" +const val PREF_OSMOSE_LEVEL = "qs_OsmoseQuest_level" +const val PREF_OSMOSE_APP_LANGUAGE = "qs_OsmoseQuest_app_language" // do not use the quest settings prefix here, as it doesn't make sense for language + +// items that have associated SC quests/overlays are disabled by default +// same for issues related to ignored relation types +// §§ is used as separator +const val OSMOSE_DEFAULT_IGNORED_ITEMS = + "3230/32301" + "§§" + // "Probably only for bottles, not any type of glass" + "4061/40610" + "§§" + // "object needs review" (fixme poi "quest") + "7130/71301" + "§§" + // "Missing maxheight tag" + "2060/1" + "§§" + // "addr:housenumber or addr:housename without addr:street, addr:district, addr:neighbourhood, addr:quarter, addr:suburb, addr:place or addr:hamlet must be in a associatedStreet relation" + "3250" + "§§" + // "Invalid Opening Hours" (will be not be asked immediately, but frequently re-surveyed, at least of the option is on) + "shop=yes is unspecific. Please replace ''yes'' by a specific value." + "§§" + +// alternative for all languages: 9002/9002007 and contains "shop=yes" or "shop = yes" (thanks, translator) + "barrier=yes is unspecific. Please replace ''yes'' by a specific value." + "§§" + + "traffic_calming=yes is unspecific. Please replace ''yes'' by a specific value" + "§§" + + "amenity=recycling without recycling:*" + "§§" + +// alternative for all languages: 9001/9001001 and contains "recycling:*" + "amenity=recycling without recycling_type=container or recycling_type=centre" + "§§" + +// alternative for all languages: 9001/9001001 and contains all 3 tags + "emergency=fire_hydrant without fire_hydrant:type" + "§§" + +// alternative for all languages: 9001/9001001 and contains "emergency=fire_hydrant" and "fire_hydrant:type" + "Combined foot- and cycleway without segregated." + "§§" + +// alternative for all languages: 9001/9001001 and contains "segregated" + "leisure=pitch without sport" + "§§" + +// alternative for all languages and types: 9001/9001001 and contains "leisure=pitch" and "sport" + "The tag `parking:lane:both` is deprecated in favour of `parking:both`" + "§§" + + "The tag `parking:lane:left` is deprecated in favour of `parking:left`" + "§§" + + "The tag `parking:lane:right` is deprecated in favour of `parking:right`" + "§§" + +// alternative for all languages and types: 4010 and contains "parking:lane:*" and "parking:" + "The tag `parking:orientation` is deprecated in favour of `orientation`" + "§§" + + "Same value of cycleway:left and cycleway:right" + "§§" + // there is no quest, but SC may cause this and does not understand the "fix" +// alternative for all languages: 9001 and contains "cycleway:left" and "cycleway:right" +// "tracktype=grade4 together with surface=asphalt" -> how to do it properly? current system won't work, or needs blacklisting all combinations +// alternative for all languages and types: 9001/9001001 and contains "tracktype=" and "surface=" + "female=yes together with male=yes" + "§§" + // this is not necessarily the same as unisex + // relation-related stuff below + "1260" + "§§" + // Osmosis_Relation_Public_Transport + "2140" + "§§" + // missing tags on public transport relations / stops + "1140" + "§§" + // missing tag or role + "1200" + "§§" + // 1-member relation + "9007" + "§§" // various relation related issues, usually missing tags + diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/parking_capacity/AddDisabledParkingCapacity.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_capacity/AddDisabledParkingCapacity.kt new file mode 100644 index 00000000000..bbbda1370dd --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_capacity/AddDisabledParkingCapacity.kt @@ -0,0 +1,37 @@ +package de.westnordost.streetcomplete.quests.parking_capacity + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.WHEELCHAIR +import de.westnordost.streetcomplete.osm.Tags + +class AddDisabledParkingCapacity : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + amenity = parking + and access !~ private|no + and !capacity:disabled + """ + + override val changesetComment = "Specify disabled parking capacities" + override val wikiLink = "Key:capacity:disabled" + override val icon = R.drawable.ic_quest_parking_capacity_disabled + override val achievements = listOf(WHEELCHAIR) + override val defaultDisabledMessage = R.string.quest_parking_capacity_disabled_default_disabled_msg + + override fun getTitle(tags: Map) = R.string.quest_parking_capacity_disabled_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("nodes, ways with amenity = parking") + + override fun createForm() = AddDisabledParkingCapacityForm() + + override fun applyAnswerTo(answer: String, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["capacity:disabled"] = answer + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/parking_capacity/AddDisabledParkingCapacityForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_capacity/AddDisabledParkingCapacityForm.kt new file mode 100644 index 00000000000..59348e58de3 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_capacity/AddDisabledParkingCapacityForm.kt @@ -0,0 +1,42 @@ +package de.westnordost.streetcomplete.quests.parking_capacity + +import android.os.Bundle +import android.view.View +import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.QuestDisabledParkingCapacityBinding +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.util.ktx.intOrNull + +class AddDisabledParkingCapacityForm : AbstractOsmQuestForm() { + + override val contentLayoutResId = R.layout.quest_disabled_parking_capacity + private val binding by contentViewBinding(QuestDisabledParkingCapacityBinding::bind) + + private val capacity get() = binding.capacityInput.intOrNull ?: 0 + + override val otherAnswers = mutableListOf() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.capacityInput.doAfterTextChanged { checkIsFormComplete() } + if (element.tags["capacity:disabled"] != "yes") { + otherAnswers.add(AnswerItem(R.string.quest_parking_capacity_disabled_answer_yes) { + applyAnswer( + "yes" + ) + }) + } + } + + override fun isFormComplete() = binding.capacityInput.intOrNull != null + + override fun onClickOk() { + if (capacity == 0) { + applyAnswer("no") + } else { + applyAnswer(capacity.toString()) + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/parking_capacity/AddParkingCapacity.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_capacity/AddParkingCapacity.kt new file mode 100644 index 00000000000..7f2d2883466 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_capacity/AddParkingCapacity.kt @@ -0,0 +1,39 @@ +package de.westnordost.streetcomplete.quests.parking_capacity + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.CAR +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.updateWithCheckDate + +class AddParkingCapacity : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + amenity = parking + and parking = surface + and access !~ private|no + and !capacity + """ + + override val changesetComment = "Specify parking capacities" + override val wikiLink = "Tag:amenity=parking" + override val icon = R.drawable.ic_quest_parking_capacity + override val achievements = listOf(CAR) + override val defaultDisabledMessage = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_parking_capacity_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("nodes, ways with amenity = parking") + + override fun createForm() = AddParkingCapacityForm() + + override fun applyAnswerTo(answer: Int, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags.updateWithCheckDate("capacity", answer.toString()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/parking_capacity/AddParkingCapacityForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_capacity/AddParkingCapacityForm.kt new file mode 100644 index 00000000000..bbfe63e38c5 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_capacity/AddParkingCapacityForm.kt @@ -0,0 +1,28 @@ +package de.westnordost.streetcomplete.quests.parking_capacity + +import android.os.Bundle +import android.view.View +import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.QuestCarParkingCapacityBinding +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.util.ktx.intOrNull + +class AddParkingCapacityForm : AbstractOsmQuestForm() { + + override val contentLayoutResId = R.layout.quest_car_parking_capacity + private val binding by contentViewBinding(QuestCarParkingCapacityBinding::bind) + + private val capacity get() = binding.capacityInput.intOrNull ?: 0 + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.capacityInput.doAfterTextChanged { checkIsFormComplete() } + } + + override fun isFormComplete() = capacity > 0 + + override fun onClickOk() { + applyAnswer(capacity) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/parking_orientation/AddParkingOrientation.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_orientation/AddParkingOrientation.kt new file mode 100644 index 00000000000..077cc426892 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_orientation/AddParkingOrientation.kt @@ -0,0 +1,32 @@ +package de.westnordost.streetcomplete.quests.parking_orientation + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.CAR +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.street_parking.ParkingOrientation + +class AddParkingOrientation : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways, relations with + amenity = parking + and parking ~ "lane|street_side|on_kerb|half_on_kerb" + and !orientation + """ + override val changesetComment = "Specify parking orientation" + override val wikiLink = "Key:orientation" + override val icon = R.drawable.ic_quest_parking_orientation + override val achievements = listOf(CAR) + + override fun getTitle(tags: Map) = R.string.quest_parking_orientation_title + + override fun createForm() = AddParkingOrientationForm() + + override val defaultDisabledMessage = R.string.default_disabled_msg_ee + + override fun applyAnswerTo(answer: ParkingOrientation, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["orientation"] = answer.osmValue + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/parking_orientation/AddParkingOrientationForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_orientation/AddParkingOrientationForm.kt new file mode 100644 index 00000000000..3c9c7826f2d --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_orientation/AddParkingOrientationForm.kt @@ -0,0 +1,12 @@ +package de.westnordost.streetcomplete.quests.parking_orientation + +import de.westnordost.streetcomplete.osm.street_parking.ParkingOrientation +import de.westnordost.streetcomplete.quests.AImageListQuestForm +class AddParkingOrientationForm : AImageListQuestForm() { + override val items get() = ParkingOrientation.values().map { it.asItem(requireContext(), element.tags["parking"]) } + override val itemsPerRow = 3 + + override fun onClickOk(selectedItems: List) { + applyAnswer(selectedItems.single()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/parking_orientation/ParkingOrientationItem.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_orientation/ParkingOrientationItem.kt new file mode 100644 index 00000000000..9a29b054765 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_orientation/ParkingOrientationItem.kt @@ -0,0 +1,47 @@ +package de.westnordost.streetcomplete.quests.parking_orientation + +import android.content.Context +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.osm.street_parking.ParkingOrientation +import de.westnordost.streetcomplete.osm.street_parking.ParkingOrientation.DIAGONAL +import de.westnordost.streetcomplete.osm.street_parking.ParkingOrientation.PARALLEL +import de.westnordost.streetcomplete.osm.street_parking.ParkingOrientation.PERPENDICULAR +import de.westnordost.streetcomplete.osm.street_parking.StreetParkingDrawable +import de.westnordost.streetcomplete.util.ktx.asBitmapDrawable +import de.westnordost.streetcomplete.view.DrawableImage +import de.westnordost.streetcomplete.view.ResText +import de.westnordost.streetcomplete.view.image_select.Item2 +import de.westnordost.streetcomplete.osm.street_parking.ParkingOrientation.DIAGONAL as DISPLAY_DIAGONAL +import de.westnordost.streetcomplete.osm.street_parking.ParkingOrientation.PARALLEL as DISPLAY_PARALLEL +import de.westnordost.streetcomplete.osm.street_parking.ParkingOrientation.PERPENDICULAR as DISPLAY_PERPENDICULAR +import de.westnordost.streetcomplete.osm.street_parking.ParkingPosition.HALF_ON_STREET as DISPLAY_HALF_ON_STREET +import de.westnordost.streetcomplete.osm.street_parking.ParkingPosition.OFF_STREET as DISPLAY_OFF_STREET +import de.westnordost.streetcomplete.osm.street_parking.ParkingPosition.ON_STREET as DISPLAY_ON_STREET +import de.westnordost.streetcomplete.osm.street_parking.ParkingPosition.STREET_SIDE as DISPLAY_STREET_SIDE + +fun ParkingOrientation.asItem(context: Context, parkingTagValue: String?): Item2 { + val orientationDisplayVal = when (this) { + PARALLEL -> DISPLAY_PARALLEL + DIAGONAL -> DISPLAY_DIAGONAL + PERPENDICULAR -> DISPLAY_PERPENDICULAR + } + val positionDisplayVal = when (parkingTagValue) { + "street_side" -> DISPLAY_STREET_SIDE + "on_kerb" -> DISPLAY_OFF_STREET + "half_on_kerb" -> DISPLAY_HALF_ON_STREET + else -> DISPLAY_ON_STREET + } + val drawable = DrawableImage(StreetParkingDrawable(context, orientationDisplayVal, positionDisplayVal, false, 128, 128, R.drawable.ic_car1).asBitmapDrawable(context.resources)) + return Item2(this, drawable, ResText(titleResId), null) +} +private val ParkingOrientation.titleResId: Int get() = when (this) { + PARALLEL -> R.string.street_parking_parallel + DIAGONAL -> R.string.street_parking_diagonal + PERPENDICULAR -> R.string.street_parking_perpendicular + } + +val ParkingOrientation.osmValue get() = when (this) { + PARALLEL -> "parallel" + DIAGONAL -> "diagonal" + PERPENDICULAR -> "perpendicular" +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/pharmacy/AddIsPharmacyDispensing.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/pharmacy/AddIsPharmacyDispensing.kt new file mode 100644 index 00000000000..49600d22fd9 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/pharmacy/AddIsPharmacyDispensing.kt @@ -0,0 +1,44 @@ +package de.westnordost.streetcomplete.quests.pharmacy + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.quest.AllCountriesExcept +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.CITIZEN +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.isPlace +import de.westnordost.streetcomplete.osm.updateWithCheckDate +import de.westnordost.streetcomplete.quests.YesNoQuestForm +import de.westnordost.streetcomplete.util.ktx.toYesNo + +class AddIsPharmacyDispensing : OsmFilterQuestType() { + + override val elementFilter = """ + nodes,ways with + ( + amenity = pharmacy + or healthcare = pharmacy + ) + and (!dispensing or dispensing older today -8 years) + """ + override val changesetComment = "Determine whether pharmacies are dispensing prescription drugs" + override val wikiLink = "Key:dispensing" + override val icon = R.drawable.ic_quest_pharmacy + override val achievements = listOf(CITIZEN) + override val defaultDisabledMessage = R.string.default_disabled_msg_go_inside_regional_warning + override val enabledInCountries = AllCountriesExcept("AT", "DE", "FR", "PL") + + override fun getTitle(tags: Map) = R.string.quest_is_pharmacy_dispensing_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().asSequence().filter { it.isPlace() } + + override fun createForm() = YesNoQuestForm() + + override fun applyAnswerTo(answer: Boolean, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags.updateWithCheckDate("dispensing", answer.toYesNo()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/piste_difficulty/AddPisteDifficulty.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_difficulty/AddPisteDifficulty.kt new file mode 100644 index 00000000000..1961591199b --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_difficulty/AddPisteDifficulty.kt @@ -0,0 +1,55 @@ +package de.westnordost.streetcomplete.quests.piste_difficulty + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.fullElementSelectionDialog +import de.westnordost.streetcomplete.quests.getPrefixedFullElementSelectionPref +import de.westnordost.streetcomplete.util.isWinter + +class AddPisteDifficulty : OsmElementQuestType { + + val elementFilter = """ + ways, relations with + piste:type ~ downhill|nordic + and !piste:difficulty + """ + private val filter by lazy { elementFilter.toElementFilterExpression() } + + override val changesetComment = "Add piste difficulty" + override val wikiLink = "Key:piste:difficulty" + override val icon = R.drawable.ic_quest_piste_difficulty + override val defaultDisabledMessage: Int = R.string.default_disabled_msg_ee + + override fun getApplicableElements(mapData: MapDataWithGeometry): Iterable { + return if (isWinter(mapData.nodes.firstOrNull()?.position)) mapData.filter(filter).asIterable() + else emptyList() + } + + override fun isApplicableTo(element: Element) = if (filter.matches(element)) null else false + + override fun getTitle(tags: Map) = R.string.quest_piste_difficulty_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry): Sequence { + val mapData = getMapData() + return mapData.filter("ways, relations with piste:type") + } + + override fun createForm() = AddPisteDifficultyForm() + + override fun applyAnswerTo(answer: PisteDifficulty, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["piste:difficulty"] = answer.osmValue + } + + override val hasQuestSettings: Boolean = true + + override fun getQuestSettingsDialog(context: Context): AlertDialog = + fullElementSelectionDialog(context, prefs, this.getPrefixedFullElementSelectionPref(prefs), R.string.quest_settings_element_selection, elementFilter) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/piste_difficulty/AddPisteDifficultyForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_difficulty/AddPisteDifficultyForm.kt new file mode 100644 index 00000000000..d9bc4f2fecb --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_difficulty/AddPisteDifficultyForm.kt @@ -0,0 +1,21 @@ +package de.westnordost.streetcomplete.quests.piste_difficulty + +import android.os.Bundle +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AImageListQuestForm + +class AddPisteDifficultyForm : AImageListQuestForm() { + + override val items get() = PisteDifficulty.values().mapNotNull { it.asItem(countryInfo.countryCode) } + override val itemsPerRow = 2 + override val moveFavoritesToFront = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imageSelector.cellLayoutId = R.layout.cell_labeled_icon_select_with_description + } + + override fun onClickOk(selectedItems: List) { + applyAnswer(selectedItems.single()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/piste_difficulty/PisteDifficulty.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_difficulty/PisteDifficulty.kt new file mode 100644 index 00000000000..545df104ad0 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_difficulty/PisteDifficulty.kt @@ -0,0 +1,11 @@ +package de.westnordost.streetcomplete.quests.piste_difficulty + +enum class PisteDifficulty(val osmValue: String) { + NOVICE("novice"), + EASY("easy"), + INTERMEDIATE("intermediate"), + ADVANCED("advanced"), + EXPERT("expert"), + FREERIDE("freeride"), + EXTREME("extreme") +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/piste_difficulty/PisteDifficultyItem.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_difficulty/PisteDifficultyItem.kt new file mode 100644 index 00000000000..9e223ef370f --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_difficulty/PisteDifficultyItem.kt @@ -0,0 +1,38 @@ +package de.westnordost.streetcomplete.quests.piste_difficulty + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.piste_difficulty.PisteDifficulty.* +import de.westnordost.streetcomplete.view.image_select.Item + +fun PisteDifficulty.asItem(countryCode: String) = when { + this == NOVICE && countryCode in listOf("JP", "US", "CA", "NZ", "AU") -> null + this == EXPERT && countryCode == "JP" -> null + this == FREERIDE && countryCode == "JP" -> null + this == EXTREME && countryCode == "JP" -> null + else -> Item(this, getIconResId(countryCode), titleResId) +} + +private val PisteDifficulty.titleResId: Int get() = when (this) { + NOVICE -> R.string.quest_piste_difficulty_novice + EASY -> R.string.quest_piste_difficulty_easy + INTERMEDIATE -> R.string.quest_piste_difficulty_intermediate + ADVANCED -> R.string.quest_piste_difficulty_advanced + EXPERT -> R.string.quest_piste_difficulty_expert + FREERIDE -> R.string.quest_piste_difficulty_freeride + EXTREME -> R.string.quest_piste_difficulty_extreme +} + +private fun PisteDifficulty.getIconResId(countryCode: String): Int = when (this) { + NOVICE -> R.drawable.ic_quest_piste_difficulty_novice + EASY -> if (countryCode in listOf("JP", "US", "CA", "NZ", "AU")) R.drawable.ic_quest_piste_difficulty_novice + else R.drawable.ic_quest_piste_difficulty_easy + INTERMEDIATE -> if (countryCode in listOf("JP", "US", "CA", "NZ", "AU")) R.drawable.ic_quest_piste_difficulty_blue_square + else R.drawable.ic_quest_piste_difficulty_intermediate + ADVANCED -> if (countryCode in listOf("US", "CA", "NZ", "AU", "FI", "SE", "NO")) R.drawable.ic_quest_piste_difficulty_black_diamond + else R.drawable.ic_quest_piste_difficulty_advanced + EXPERT -> if (countryCode in listOf("US", "CA", "NZ", "AU", "FI", "SE", "NO")) R.drawable.ic_quest_piste_difficulty_double_black_diamond + else R.drawable.ic_quest_piste_difficulty_expert + FREERIDE -> if (countryCode in listOf("JP", "US", "CA", "NZ", "AU")) R.drawable.ic_quest_piste_difficulty_orange_oval + else R.drawable.ic_quest_piste_difficulty_freeride + EXTREME -> R.drawable.ic_quest_piste_difficulty_extreme +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/piste_lit/AddPisteLit.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_lit/AddPisteLit.kt new file mode 100644 index 00000000000..e2777cd06db --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_lit/AddPisteLit.kt @@ -0,0 +1,57 @@ +package de.westnordost.streetcomplete.quests.piste_lit + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.updateWithCheckDate +import de.westnordost.streetcomplete.quests.YesNoQuestForm +import de.westnordost.streetcomplete.quests.fullElementSelectionDialog +import de.westnordost.streetcomplete.quests.getPrefixedFullElementSelectionPref +import de.westnordost.streetcomplete.util.isWinter +import de.westnordost.streetcomplete.util.ktx.toYesNo + +class AddPisteLit : OsmElementQuestType { + + private val elementFilter = """ + ways, relations with + piste:type ~ downhill|nordic|sled|ski_jump|ice_skate + and ( + !piste:lit + or piste:lit = no and lit older today -8 years + or piste:lit older today -16 years + ) + """ + private val filter by lazy { elementFilter.toElementFilterExpression() } + + override val changesetComment = "Specify whether pistes are lit" + override val wikiLink = "Key:piste:lit" + override val icon = R.drawable.ic_quest_piste_lit + override val defaultDisabledMessage = R.string.default_disabled_msg_ee + + override fun getApplicableElements(mapData: MapDataWithGeometry): Iterable { + return if (isWinter(mapData.nodes.firstOrNull()?.position)) mapData.filter(filter).asIterable() + else emptyList() + } + + override fun isApplicableTo(element: Element) = if (filter.matches(element)) null else false + + override fun getTitle(tags: Map) = R.string.quest_piste_lit_title + + override fun createForm() = YesNoQuestForm() + + override fun applyAnswerTo(answer: Boolean, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags.updateWithCheckDate("piste:lit", answer.toYesNo()) + } + + override val hasQuestSettings: Boolean = true + + override fun getQuestSettingsDialog(context: Context): AlertDialog = + fullElementSelectionDialog(context, prefs, this.getPrefixedFullElementSelectionPref(prefs), R.string.quest_settings_element_selection, elementFilter) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/piste_ref/AddPisteRef.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_ref/AddPisteRef.kt new file mode 100644 index 00000000000..ff57ae1cbb1 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_ref/AddPisteRef.kt @@ -0,0 +1,57 @@ +package de.westnordost.streetcomplete.quests.piste_ref + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.fullElementSelectionDialog +import de.westnordost.streetcomplete.quests.getPrefixedFullElementSelectionPref +import de.westnordost.streetcomplete.util.isWinter + +class AddPisteRef : OsmElementQuestType { + + private val elementFilter = """ + ways, relations with + piste:type = downhill + and !ref + and !piste:ref + """ + private val filter by lazy { elementFilter.toElementFilterExpression() } + + override val changesetComment = "Survey piste ref" + override val wikiLink = "Key:piste:ref" + override val icon = R.drawable.ic_quest_piste_ref + override val defaultDisabledMessage = R.string.default_disabled_msg_ee + + override fun getApplicableElements(mapData: MapDataWithGeometry): Iterable { + return if (isWinter(mapData.nodes.firstOrNull()?.position)) mapData.filter(filter).asIterable() + else emptyList() + } + + override fun isApplicableTo(element: Element) = if (filter.matches(element)) null else false + + override fun getTitle(tags: Map) = R.string.quest_piste_ref_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("ways, relations with piste:type = downhill") + + override fun createForm() = AddPisteRefForm() + + override fun applyAnswerTo(answer: PisteRefAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + when (answer) { + is PisteRef -> tags["piste:ref"] = answer.ref + is PisteConnection -> tags["piste:type"] = "connection" + } + } + + override val hasQuestSettings: Boolean = true + + override fun getQuestSettingsDialog(context: Context): AlertDialog = + fullElementSelectionDialog(context, prefs, this.getPrefixedFullElementSelectionPref(prefs), R.string.quest_settings_element_selection, elementFilter) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/piste_ref/AddPisteRefForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_ref/AddPisteRefForm.kt new file mode 100644 index 00000000000..df1a4f182ab --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_ref/AddPisteRefForm.kt @@ -0,0 +1,62 @@ +package de.westnordost.streetcomplete.quests.piste_ref + +import android.graphics.Color +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.OvalShape +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.ViewPisteRefBinding +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.util.ktx.nonBlankTextOrNull + +class AddPisteRefForm : AbstractOsmQuestForm() { + + override val contentLayoutResId = R.layout.view_piste_ref + private val binding by contentViewBinding(ViewPisteRefBinding::bind) + + override val otherAnswers get() = + listOfNotNull( + AnswerItem(R.string.quest_piste_ref_connection) { confirmPisteConnection() } + ) + + private val ref get() = binding.pisteRefInput.nonBlankTextOrNull + + override fun onClickOk() { + applyAnswer(PisteRef(ref!!)) + } + + private fun confirmPisteConnection() { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.quest_generic_confirmation_title) + .setPositiveButton(R.string.quest_generic_confirmation_yes) { _, _ -> applyAnswer( + PisteConnection + ) } + .setNegativeButton(R.string.quest_generic_confirmation_no, null) + .show() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val pisteDifficulty = element.tags["piste:difficulty"] + val color = getColorForPisteDifficulty(pisteDifficulty) + binding.pisteRefInput.background = ShapeDrawable(OvalShape()) + binding.pisteRefInput.background.setTint(color) + binding.pisteRefInput.doAfterTextChanged { checkIsFormComplete() } + } + + private fun getColorForPisteDifficulty(difficulty: String?): Int { + return when (difficulty) { + "novice" -> Color.parseColor("#008351") + "easy" -> Color.parseColor("#2255BB") + "intermediate" -> Color.parseColor("#C1121C") + "advanced" -> Color.parseColor("#000000") + else -> Color.parseColor("#8e9291") + } + } + + override fun isFormComplete() = ref != null +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/piste_ref/PisteRefAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_ref/PisteRefAnswer.kt new file mode 100644 index 00000000000..9a435a65bc2 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/piste_ref/PisteRefAnswer.kt @@ -0,0 +1,7 @@ +package de.westnordost.streetcomplete.quests.piste_ref + +sealed interface PisteRefAnswer + +object PisteConnection : PisteRefAnswer + +data class PisteRef(val ref: String) : PisteRefAnswer diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/place_name/AddPlaceName.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/place_name/AddPlaceName.kt index 4f653fe8fb0..3f9dadd3adf 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/place_name/AddPlaceName.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/place_name/AddPlaceName.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.quests.place_name +import android.content.Context import de.westnordost.osmfeatures.Feature import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression @@ -11,6 +12,8 @@ import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement. import de.westnordost.streetcomplete.osm.Tags import de.westnordost.streetcomplete.osm.applyTo import de.westnordost.streetcomplete.osm.isPlaceOrDisusedPlace +import de.westnordost.streetcomplete.quests.fullElementSelectionDialog +import de.westnordost.streetcomplete.quests.questPrefix class AddPlaceName( private val getFeature: (Element) -> Feature? @@ -31,82 +34,7 @@ class AddPlaceName( // So when adding other tags to the common list keep in mind that they need to be appropriate for all those quests. // Independent tags can by added in the "name only" tab. - mapOf( - "amenity" to arrayOf( - // common - "restaurant", "cafe", "ice_cream", "fast_food", "bar", "pub", "biergarten", // eat & drink - "food_court", "nightclub", - "cinema", "planetarium", "casino", // amenities - "townhall", "courthouse", "embassy", "community_centre", "youth_centre", "library", // civic - "driving_school", "music_school", "prep_school", "language_school", "dive_centre", // learning - "dancing_school", "ski_school", "flight_school", "surf_school", "sailing_school", - "cooking_school", - "bank", "bureau_de_change", "money_transfer", "post_office", "marketplace", // commercial - "internet_cafe", "payment_centre", - "car_wash", "car_rental", "fuel", // car stuff - "dentist", "doctors", "clinic", "pharmacy", "veterinary", // health - "animal_boarding", "animal_shelter", "animal_breeding", // animals - "coworking_space", // work - - // name & opening hours - "boat_rental", - - // name & wheelchair - "theatre", // culture - "conference_centre", "arts_centre", // events - "police", "ranger_station", // civic - "ferry_terminal", // transport - "place_of_worship", // religious - "hospital", // health care - "brothel", "gambling", "love_hotel", "stripclub", // bad stuff - - // name only - "studio", // culture - "events_venue", "exhibition_centre", "music_venue", // events - "prison", "fire_station", // civic - "social_facility", "nursing_home", "childcare", "retirement_home", "social_centre", // social - "monastery", // religious - "kindergarten", "school", "college", "university", "research_institute", // education - ), - "tourism" to arrayOf( - // common - "zoo", "aquarium", "theme_park", "gallery", "museum", - - // name & wheelchair - "attraction", - "hotel", "guest_house", "motel", "hostel", "alpine_hut", "apartment", "resort", "camp_site", "caravan_site", "chalet" // accommodations - - // and tourism = information, see above - ), - "leisure" to arrayOf( - // common - "fitness_centre", "golf_course", "water_park", "miniature_golf", "bowling_alley", - "amusement_arcade", "adult_gaming_centre", "tanning_salon", - - // name & wheelchair - "sports_centre", "stadium", - - // name only - "dance", "nature_reserve", "marina", "horse_riding", - ), - "landuse" to arrayOf( - "cemetery", "allotments" - ), - "military" to arrayOf( - "airfield", "barracks", "training_area" - ), - "healthcare" to arrayOf( - // common - "pharmacy", "doctor", "clinic", "dentist", "centre", "physiotherapist", - "laboratory", "alternative", "psychotherapist", "optometrist", "podiatrist", - "nurse", "counselling", "speech_therapist", "blood_donation", "sample_collection", - "occupational_therapist", "dialysis", "vaccination_centre", "audiologist", - "blood_bank", "nutrition_counselling", - - // name & wheelchair - "rehabilitation", "hospice", "midwife", "birthing_centre" - ), - ).map { it.key + " ~ " + it.value.joinToString("|") }.joinToString("\n or ") + "\n" + """ + prefs.getString(questPrefix(prefs) + PREF_ELEMENTS, NAME_PLACES)+ "\n" + """ ) and !name and !brand and noname != yes and name:signed != no """).toElementFilterExpression() } @@ -138,6 +66,97 @@ class AddPlaceName( is PlaceName -> { answer.localizedNames.applyTo(tags) } + is FeatureName -> { + for (addTag in answer.feature.addTags) + tags[addTag.key] = addTag.value + } + is BrandName -> { + tags["brand"] = answer.name + tags["name"] = answer.name + } } } + + override val hasQuestSettings = true + + override fun getQuestSettingsDialog(context: Context) = + fullElementSelectionDialog(context, prefs, questPrefix(prefs) + PREF_ELEMENTS, R.string.quest_settings_element_selection, NAME_PLACES) } + +private val NAME_PLACES = mapOf( + "amenity" to arrayOf( + // common + "restaurant", "cafe", "ice_cream", "fast_food", "bar", "pub", "biergarten", // eat & drink + "food_court", "nightclub", + "cinema", "planetarium", "casino", // amenities + "townhall", "courthouse", "embassy", "community_centre", "youth_centre", "library", // civic + "driving_school", "music_school", "prep_school", "language_school", "dive_centre", // learning + "dancing_school", "ski_school", "flight_school", "surf_school", "sailing_school", + "cooking_school", + "bank", "bureau_de_change", "money_transfer", "post_office", "marketplace", // commercial + "internet_cafe", "payment_centre", + "car_wash", "car_rental", "fuel", // car stuff + "dentist", "doctors", "clinic", "pharmacy", "veterinary", // health + "animal_boarding", "animal_shelter", "animal_breeding", // animals + "coworking_space", // work + + // name & opening hours + "boat_rental", + + // name & wheelchair + "theatre", // culture + "conference_centre", "arts_centre", // events + "police", "ranger_station", // civic + "ferry_terminal", // transport + "place_of_worship", // religious + "hospital", // health care + "brothel", "gambling", "love_hotel", "stripclub", // bad stuff + + // name only + "studio", // culture + "events_venue", "exhibition_centre", "music_venue", // events + "prison", "fire_station", // civic + "social_facility", "nursing_home", "childcare", "retirement_home", "social_centre", // social + "monastery", // religious + "kindergarten", "school", "college", "university", "research_institute", // education + ), + "tourism" to arrayOf( + // common + "zoo", "aquarium", "theme_park", "gallery", "museum", + + // name & wheelchair + "attraction", + "hotel", "guest_house", "motel", "hostel", "alpine_hut", "apartment", "resort", "camp_site", "caravan_site", "chalet" // accommodations + + // and tourism = information, see above + ), + "leisure" to arrayOf( + // common + "fitness_centre", "golf_course", "water_park", "miniature_golf", "bowling_alley", + "amusement_arcade", "adult_gaming_centre", "tanning_salon", + + // name & wheelchair + "sports_centre", "stadium", + + // name only + "dance", "nature_reserve", "marina", "horse_riding", + ), + "landuse" to arrayOf( + "cemetery", "allotments" + ), + "military" to arrayOf( + "airfield", "barracks", "training_area" + ), + "healthcare" to arrayOf( + // common + "pharmacy", "doctor", "clinic", "dentist", "centre", "physiotherapist", + "laboratory", "alternative", "psychotherapist", "optometrist", "podiatrist", + "nurse", "counselling", "speech_therapist", "blood_donation", "sample_collection", + "occupational_therapist", "dialysis", "vaccination_centre", "audiologist", + "blood_bank", "nutrition_counselling", + + // name & wheelchair + "rehabilitation", "hospice", "midwife", "birthing_centre" + ), +).map { it.key + " ~ " + it.value.joinToString("|") }.joinToString("\n or ") +private const val PREF_ELEMENTS = "qs_AddPlaceName_element_selection" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/place_name/AddPlaceNameForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/place_name/AddPlaceNameForm.kt index c787e38a7af..c7a5b812dff 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/place_name/AddPlaceNameForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/place_name/AddPlaceNameForm.kt @@ -1,11 +1,18 @@ package de.westnordost.streetcomplete.quests.place_name +import android.widget.AutoCompleteTextView import androidx.appcompat.app.AlertDialog +import androidx.core.widget.doAfterTextChanged +import de.westnordost.osmfeatures.Feature +import de.westnordost.osmfeatures.GeometryType import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.databinding.QuestLocalizednameBinding import de.westnordost.streetcomplete.osm.LocalizedName import de.westnordost.streetcomplete.quests.AAddLocalizedNameForm import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.util.SearchAdapter +import de.westnordost.streetcomplete.util.getLanguagesForFeatureDictionary +import de.westnordost.streetcomplete.util.ktx.showKeyboard class AddPlaceNameForm : AAddLocalizedNameForm() { @@ -15,10 +22,62 @@ class AddPlaceNameForm : AAddLocalizedNameForm() { override val addLanguageButton get() = binding.addLanguageButton override val namesList get() = binding.namesList - override val otherAnswers = listOf( - AnswerItem(R.string.quest_placeName_no_name_answer) { confirmNoName() } + override val otherAnswers get() = listOfNotNull( + AnswerItem(R.string.quest_placeName_no_name_answer) { confirmNoName() }, + createBrandAnswer() ) + private fun createBrandAnswer(): AnswerItem? { + val ctx = context ?: return null + if (!element.tags.containsKey("shop") && !element.tags.containsKey("amenity") + && !element.tags.containsKey("leisure") && !element.tags.containsKey("tourism")) return null + return AnswerItem(R.string.quest_name_brand) { + val languages = getLanguagesForFeatureDictionary(ctx.resources.configuration) + val searchAdapter = SearchAdapter(ctx, { search -> + featureDictionary.getByTerm( + search = search, + languages = languages, + country = countryOrSubdivisionCode, + geometry = GeometryType.POINT + ).filter { + it.addTags.containsKey("brand") && when { + element.tags.containsKey("amenity") -> it.addTags["amenity"] == element.tags["amenity"] + element.tags.containsKey("shop") -> it.addTags["shop"] == element.tags["shop"] + element.tags.containsKey("leisure") -> it.addTags["leisure"] == element.tags["leisure"] + element.tags.containsKey("tourism") -> it.addTags["tourism"] == element.tags["tourism"] + else -> false + } }.toList() + }, { it.name }) + var feature: Feature? = null + var dialog: AlertDialog? = null + val textField = layoutInflater.inflate(R.layout.quest_name_suggestion, null) as AutoCompleteTextView + textField.setAdapter(searchAdapter) + textField.doAfterTextChanged { + dialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = !it?.toString().isNullOrBlank() + } + textField.setOnItemClickListener { _, _, i, _ -> feature = searchAdapter.getItem(i) } + dialog = AlertDialog.Builder(ctx) + .setTitle(R.string.quest_name_brand) + .setView(textField) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + val f = feature + val text = textField.text.toString() + if (text == f?.name) + applyAnswer(FeatureName(f)) + else + applyAnswer(BrandName(text)) + } + .create() + dialog.setOnShowListener { + textField.requestFocus() + textField.showKeyboard() + } + dialog.show() + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + } + } + override fun onClickOk(names: List) { applyAnswer(PlaceName(names)) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/place_name/PlaceNameAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/place_name/PlaceNameAnswer.kt index 54fdd589903..226ecfb8236 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/place_name/PlaceNameAnswer.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/place_name/PlaceNameAnswer.kt @@ -1,8 +1,11 @@ package de.westnordost.streetcomplete.quests.place_name +import de.westnordost.osmfeatures.Feature import de.westnordost.streetcomplete.osm.LocalizedName sealed interface PlaceNameAnswer data class PlaceName(val localizedNames: List) : PlaceNameAnswer data object NoPlaceNameSign : PlaceNameAnswer +data class FeatureName(val feature: Feature) : PlaceNameAnswer +data class BrandName(val name: String) : PlaceNameAnswer diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/post_office/AddPostOfficeType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/post_office/AddPostOfficeType.kt new file mode 100644 index 00000000000..a1050ab35fc --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/post_office/AddPostOfficeType.kt @@ -0,0 +1,36 @@ +package de.westnordost.streetcomplete.quests.post_office + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement +import de.westnordost.streetcomplete.osm.Tags + +class AddPostOfficeType : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + amenity = post_office + and !post_office + """ + override val changesetComment = "Add post office" + override val defaultDisabledMessage = R.string.default_disabled_msg_ee + override val wikiLink = "Key:post_office" + override val icon = R.drawable.ic_quest_post_office + override val isReplacePlaceEnabled = true + override val achievements = listOf(EditTypeAchievement.CITIZEN) + + override fun getTitle(tags: Map) = R.string.quest_postOffice_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("nodes with amenity = post_office or post_office") + + override fun createForm() = AddPostOfficeTypeForm() + + override fun applyAnswerTo(answer: String, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["post_office"] = answer + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/post_office/AddPostOfficeTypeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/post_office/AddPostOfficeTypeForm.kt new file mode 100644 index 00000000000..5ddb49f6433 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/post_office/AddPostOfficeTypeForm.kt @@ -0,0 +1,13 @@ +package de.westnordost.streetcomplete.quests.post_office + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AListQuestForm +import de.westnordost.streetcomplete.quests.TextItem + +class AddPostOfficeTypeForm : AListQuestForm() { + override val items = listOf( + TextItem("bureau", R.string.quest_postOffice_bureau), + TextItem("post_annex", R.string.quest_postOffice_postAnnex), + TextItem("post_partner", R.string.quest_postOffice_postPartner), + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/powerpoles_material/AddPowerPolesMaterial.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/powerpoles_material/AddPowerPolesMaterial.kt index 0ba8d9b48c5..ab5956924f7 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/powerpoles_material/AddPowerPolesMaterial.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/powerpoles_material/AddPowerPolesMaterial.kt @@ -36,6 +36,8 @@ class AddPowerPolesMaterial : OsmFilterQuestType() { override fun createForm() = AddPowerPolesMaterialForm() + override val isDeleteElementEnabled = false + override fun applyAnswerTo(answer: PowerPolesMaterialAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { if (answer is PowerPolesMaterial) { tags["material"] = answer.osmValue diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/railway_platform_ref/AddRailwayPlatformRef.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/railway_platform_ref/AddRailwayPlatformRef.kt new file mode 100644 index 00000000000..379ef5de792 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/railway_platform_ref/AddRailwayPlatformRef.kt @@ -0,0 +1,103 @@ +package de.westnordost.streetcomplete.quests.railway_platform_ref + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.geometry.ElementPolygonsGeometry +import de.westnordost.streetcomplete.data.osm.geometry.ElementPolylinesGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.data.quest.NoCountriesExcept +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.util.math.distanceTo + +class AddRailwayPlatformRef : OsmElementQuestType { + + // inspired by https://github.com/streetcomplete/StreetComplete/pull/4315 + comments + private val platformFilter = """ + ways with + public_transport = platform + and railway ~ platform|platform_edge + and !ref + and !~ "ref:.*" + and !local_ref + and !name + and noref != yes + and tram != yes + and subway != yes + and light_rail != yes + and monorail != yes + """.toElementFilterExpression() + + private val railwayFilter = "ways with railway = rail".toElementFilterExpression() + + // this is somewhat slow if there are matching platforms and a lot of railways, but that's rather + // uncommon + override fun getApplicableElements(mapData: MapDataWithGeometry): Iterable { + val railwayLineGeometries = mapData.ways.mapNotNull { + // only geometries that are "normal" railways + if (railwayFilter.matches(it)) + (mapData.getWayGeometry(it.id) as? ElementPolylinesGeometry)?.polylines?.singleOrNull() + else null + } + if (railwayLineGeometries.isEmpty()) return emptyList() + + // this also finds lines on non-matching levels / layers, but that's ok for now + return mapData.ways.filter { platformFilter.matches(it) }.mapNotNull { platform -> + val platformLineGeometry = mapData.getWayGeometry(platform.id)?.let { + // only normal ways or areas + if (it is ElementPolylinesGeometry) + it.polylines.singleOrNull() + else (it as? ElementPolygonsGeometry)?.polygons?.singleOrNull() + } ?: return@mapNotNull null + var nearbyRailways = 0 + // we want at least two nearby railways, because for a single track there is no need for asking ref + railwayLineGeometries.forEach { + if (it.distanceTo(platformLineGeometry) < 10.0) { + nearbyRailways++ + if (nearbyRailways > 1) + return@mapNotNull platform + } + } + null + } + } + + override fun isApplicableTo(element: Element): Boolean? = + if (platformFilter.matches(element)) + null + else + false + + override val changesetComment = "Specify railway platform refs" + override val wikiLink = "Tag:railway=platform" + override val icon = R.drawable.ic_quest_railway_platform_ref + override val achievements = listOf(EditTypeAchievement.CITIZEN) + override val enabledInCountries = NoCountriesExcept("DE", "FR", "CH", "AT") + override val defaultDisabledMessage = R.string.quest_disabled_msg_railway_platform_ref + + override fun getTitle(tags: Map) = R.string.quest_railwayPlatformRef_title + + override fun createForm() = AddRailwayPlatformRefForm() + + // don't allow sth like not signed, because railway platforms should always be signed... + override fun applyAnswerTo(answer: String, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags[prefs.getString(PREF_KEY, "ref")!!] = answer + } + + override val hasQuestSettings = true + + override fun getQuestSettingsDialog(context: Context) = + AlertDialog.Builder(context) + .setMessage(R.string.quest_railwayPlatformRef_message) + .setPositiveButton("local_ref") { _, _ -> prefs.edit { putString(PREF_KEY, "local_ref") }} + .setNegativeButton("ref") { _, _ -> prefs.edit { remove(PREF_KEY) }} + .create() +} + +private const val PREF_KEY = "qs_AddRailwayPlatformRef_key" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/railway_platform_ref/AddRailwayPlatformRefForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/railway_platform_ref/AddRailwayPlatformRefForm.kt new file mode 100644 index 00000000000..819dc904224 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/railway_platform_ref/AddRailwayPlatformRefForm.kt @@ -0,0 +1,35 @@ +package de.westnordost.streetcomplete.quests.railway_platform_ref + +import android.os.Bundle +import android.text.InputType +import android.view.View +import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.QuestRefBinding +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.util.ktx.nonBlankTextOrNull + +class AddRailwayPlatformRefForm : AbstractOsmQuestForm() { + + override val contentLayoutResId = R.layout.quest_ref + private val binding by contentViewBinding(QuestRefBinding::bind) + + private val ref get() = binding.refInput.nonBlankTextOrNull + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.refInput.inputType = InputType.TYPE_CLASS_NUMBER + binding.refInput.doAfterTextChanged { checkIsFormComplete() } + } + + override val buttonPanelAnswers = listOf( + AnswerItem(R.string.quest_railwayPlatformRef_abc) { binding.refInput.inputType = InputType.TYPE_CLASS_TEXT } + ) + + override fun onClickOk() { + applyAnswer(ref!!) + } + + override fun isFormComplete() = ref != null +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/roof_colour/AddRoofColour.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_colour/AddRoofColour.kt new file mode 100644 index 00000000000..63402024eaa --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_colour/AddRoofColour.kt @@ -0,0 +1,39 @@ +package de.westnordost.streetcomplete.quests.roof_colour + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.BUILDING +import de.westnordost.streetcomplete.osm.Tags + +class AddRoofColour : OsmFilterQuestType() { + + override val elementFilter = """ + ways, relations with + roof:shape + and roof:shape != flat + and !roof:colour + and building + and building !~ no|construction + and location != underground + and ruins != yes + """ + override val changesetComment = "Specify roof colour" + override val wikiLink = "Key:roof:colour" + override val icon = R.drawable.ic_quest_roof_colour + override val achievements = listOf(BUILDING) + override val defaultDisabledMessage = R.string.default_disabled_msg_roof + + override fun getTitle(tags: Map) = R.string.quest_roofColour_title + + override fun createForm() = AddRoofColourForm() + + override fun applyAnswerTo( + answer: RoofColour, + tags: Tags, + geometry: ElementGeometry, + timestampEdited: Long, + ) { + tags["roof:colour"] = answer.osmValue + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/roof_colour/AddRoofColourForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_colour/AddRoofColourForm.kt new file mode 100644 index 00000000000..ee45a443030 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_colour/AddRoofColourForm.kt @@ -0,0 +1,27 @@ +package de.westnordost.streetcomplete.quests.roof_colour + +import android.os.Bundle +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AImageListQuestForm +import de.westnordost.streetcomplete.quests.roof_shape.RoofShape +import de.westnordost.streetcomplete.view.image_select.DisplayItem + +class AddRoofColourForm : AImageListQuestForm() { + + override val items: List> + get() { + val context = requireContext() + val shape = element.tags["roof:shape"] + val roofShape = RoofShape.values().firstOrNull { it.osmValue == shape } + return RoofColour.values().map { it.asItem(context, roofShape) } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imageSelector.cellLayoutId = R.layout.cell_icon_select_with_label_below + } + + override fun onClickOk(selectedItems: List) { + applyAnswer(selectedItems.single()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/roof_colour/RoofColour.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_colour/RoofColour.kt new file mode 100644 index 00000000000..50d4f5cf730 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_colour/RoofColour.kt @@ -0,0 +1,32 @@ +package de.westnordost.streetcomplete.quests.roof_colour + +import de.westnordost.streetcomplete.view.image_select.OsmColour + +enum class RoofColour(override val osmValue: String, override val androidValue: String?) : + OsmColour { + // Top used roof colours + DARK_GREY("darkgrey", "#a9a9a9"), + GREY("grey", "#808080"), + LIGHT_GREY("lightgrey", "#d3d3d3"), + RED("red", "#ff0000"), + BROWN("brown", "#a52a2a"), + MAROON("maroon", "#800000"), + BLACK("black", "#000000"), + WHITE("white", "#ffffff"), + SILVER("silver", "#c0c0c0"), + BLUE("blue", "#0000ff"), + SALMON("salmon", "#fa8072"), + DESERT_SAND("#bbad8e", null), + MOCHA("#938870", null), + + // Rest of the recommended 3D palette + OLIVE("olive", "#808000"), + GREEN("green", "#008000"), + TEAL("teal", "#008080"), + NAVY("navy", "#000080"), + PURPLE("purple", "#800080"), + YELLOW("yellow", "#ffff00"), + LIME("lime", "#00ff00"), + AQUA("aqua", "#00ffff"), + FUCHSIA("fuchsia", "#ff00ff"), +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/roof_colour/RoofColourItem.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_colour/RoofColourItem.kt new file mode 100644 index 00000000000..34b1147bc0d --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_colour/RoofColourItem.kt @@ -0,0 +1,39 @@ +package de.westnordost.streetcomplete.quests.roof_colour + +import android.content.Context +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.roof_shape.RoofShape +import de.westnordost.streetcomplete.view.image_select.DisplayItem +import de.westnordost.streetcomplete.view.image_select.FilteredDisplayItem + +fun RoofColour.asItem(context: Context, roofShape: RoofShape?): DisplayItem = + RoofColourDisplayItem(this, context, roofShape) + +class RoofColourDisplayItem(roofColour: RoofColour, context: Context, roofShape: RoofShape?) : + FilteredDisplayItem(roofColour, context) { + + init { + iconResId = roofShape?.colorIconResId ?: R.drawable.ic_roof_colour_gabled + } +} + +private val RoofShape.colorIconResId: Int? + get() = when (this) { + RoofShape.GABLED -> R.drawable.ic_roof_colour_gabled + RoofShape.HIPPED -> R.drawable.ic_roof_colour_hipped + RoofShape.FLAT -> R.drawable.ic_roof_colour_flat + RoofShape.PYRAMIDAL -> R.drawable.ic_roof_colour_pyramidal + RoofShape.HALF_HIPPED -> R.drawable.ic_roof_colour_half_hipped + RoofShape.SKILLION -> R.drawable.ic_roof_colour_skillion + RoofShape.GAMBREL -> R.drawable.ic_roof_colour_gambrel + RoofShape.ROUND -> R.drawable.ic_roof_colour_round + RoofShape.DOUBLE_SALTBOX -> R.drawable.ic_roof_colour_double_saltbox + RoofShape.SALTBOX -> R.drawable.ic_roof_colour_saltbox + RoofShape.MANSARD -> R.drawable.ic_roof_colour_mansard + RoofShape.DOME -> R.drawable.ic_roof_colour_dome + RoofShape.QUADRUPLE_SALTBOX -> R.drawable.ic_roof_colour_quadruple_saltbox + RoofShape.ROUND_GABLED -> R.drawable.ic_roof_colour_round_gabled + RoofShape.ONION -> R.drawable.ic_roof_colour_onion + RoofShape.CONE -> R.drawable.ic_roof_colour_cone + else -> null +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/roof_orientation/AddRoofOrientation.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_orientation/AddRoofOrientation.kt new file mode 100644 index 00000000000..38182c3a296 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_orientation/AddRoofOrientation.kt @@ -0,0 +1,157 @@ +package de.westnordost.streetcomplete.quests.roof_orientation + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.BUILDING +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.util.math.flatDistanceTo +import de.westnordost.streetcomplete.util.math.flatDistanceToArc +import de.westnordost.streetcomplete.util.math.measuredLength +import kotlin.math.abs +import kotlin.math.max + +class AddRoofOrientation : OsmElementQuestType { + + private val roofsFilter by lazy { """ + ways with + roof:shape = gabled + and !roof:orientation + and !roof:direction + and building + and building !~ no|construction + and location != underground + and ruins != yes + """.toElementFilterExpression() } + + override val changesetComment = "Add roof orientation" + override val wikiLink = "Key:roof:orientation" + override val icon = R.drawable.ic_quest_roof_orientation + override val achievements = listOf(BUILDING) + override val defaultDisabledMessage = R.string.default_disabled_msg_roof + + override fun getTitle(tags: Map) = R.string.quest_roofOrientation_title + + override fun getApplicableElements(mapData: MapDataWithGeometry): Iterable = + mapData.ways.filter { way -> + if (!way.isClosed || way.nodeIds.size !in 5..20 || !roofsFilter.matches(way)) { + return@filter false + } + + val nodeIds = way.nodeIds.dropLast(1) // last equals first for closed ways + val points = nodeIds.mapNotNull { mapData.getNode(it)?.position } + isRectangularOutline(points) + } + + override fun isApplicableTo(element: Element) = + if (roofsFilter.matches(element)) null else false + + override fun createForm() = AddRoofOrientationForm() + + override fun applyAnswerTo( + answer: String, + tags: Tags, + geometry: ElementGeometry, + timestampEdited: Long, + ) { + tags["roof:orientation"] = answer + } +} + +private fun isRectangularOutline(points: List): Boolean { + val rectangle = findAllQuadrangles(points) + .filter { isNearlyRectangular(it) } + .maxByOrNull { it.circumference } + + if (rectangle == null || isNearlySquare(rectangle)) { + return false + } + + // Exclude rectangles that differ too much from the whole outline + if (rectangle.circumference < points.circumference() * 0.75) { + return false + } + + // Check that all other points lie near the rectangle's sides + val remainingPoints = points.toSet() - rectangle.toSet() + if (remainingPoints.isEmpty()) { + return true + } + + val sides = rectangle.sidesWithLengths() + return remainingPoints.all { point -> + sides.any { (side, length) -> point.flatDistanceToArc(side) < 0.1 * length } + } +} + +/** Returns all 4-point-subsets that could form a rectangle */ +private fun findAllQuadrangles(points: List): Sequence = sequence { + val n = points.size + for (i in 0 until n - 3) { + for (j in i + 1 until n - 2) { + for (k in j + 1 until n - 1) { + for (l in k + 1 until n) { + yield(Quadrangle(points[i], points[j], points[k], points[l])) + } + } + } + } +} + +private fun approximatelyEqual(length1: Double, length2: Double, tolerance: Double): Boolean = + abs(length1 - length2) <= tolerance + +/** + * Returns true if the four corners of the quadrangle [q] form a rectangle within an allowed tolerance. + */ +private fun isNearlyRectangular(q: Quadrangle): Boolean { + if ( + !approximatelyEqual(q.sideA, q.sideC, 0.1 * q.maxBD) || + !approximatelyEqual(q.sideB, q.sideD, 0.1 * q.maxAC) + ) { + return false + } + + val diagonal1 = q.corner0.flatDistanceTo(q.corner2) + val diagonal2 = q.corner1.flatDistanceTo(q.corner3) + + return approximatelyEqual(diagonal1, diagonal2, 0.1 * max(diagonal1, diagonal2)) +} + +/** + * Returns true if the four corners of the quadrangle [q] form a square within an allowed tolerance. + */ +private fun isNearlySquare(q: Quadrangle): Boolean = + approximatelyEqual(q.maxAC, q.maxBD, 0.1 * max(q.maxAC, q.maxBD)) + +private fun List.circumference() = (this + first()).measuredLength() +private fun LatLon.flatDistanceToArc(arc: Pair) = flatDistanceToArc(arc.first, arc.second) + +private data class Quadrangle( + val corner0: LatLon, + val corner1: LatLon, + val corner2: LatLon, + val corner3: LatLon, +) { + val sideA = corner0.flatDistanceTo(corner1) + val sideB = corner1.flatDistanceTo(corner2) + val sideC = corner2.flatDistanceTo(corner3) + val sideD = corner3.flatDistanceTo(corner0) + + val maxAC = max(sideA, sideC) + val maxBD = max(sideB, sideD) + + val circumference = sideA + sideB + sideC + sideD +} + +private fun Quadrangle.toSet() = setOf(corner0, corner1, corner2, corner3) +private fun Quadrangle.sidesWithLengths() = setOf( + Pair(corner0 to corner1, sideA), + Pair(corner1 to corner2, sideB), + Pair(corner2 to corner3, sideC), + Pair(corner3 to corner0, sideD), +) diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/roof_orientation/AddRoofOrientationForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_orientation/AddRoofOrientationForm.kt new file mode 100644 index 00000000000..e841dedb465 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_orientation/AddRoofOrientationForm.kt @@ -0,0 +1,12 @@ +package de.westnordost.streetcomplete.quests.roof_orientation + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AListQuestForm +import de.westnordost.streetcomplete.quests.TextItem + +class AddRoofOrientationForm : AListQuestForm() { + override val items = listOf( + TextItem("along", R.string.quest_roofOrientation_along), + TextItem("across", R.string.quest_roofOrientation_across), + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/roof_shape/AddRoofShape.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_shape/AddRoofShape.kt index 227359b1f76..1059ab4eb4e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/roof_shape/AddRoofShape.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_shape/AddRoofShape.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.quests.roof_shape +import android.content.Context import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.meta.CountryInfo @@ -11,6 +12,8 @@ import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.BUILDING import de.westnordost.streetcomplete.osm.BUILDINGS_WITH_LEVELS import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.numberSelectionDialog +import de.westnordost.streetcomplete.quests.questPrefix class AddRoofShape( private val getCountryInfoByLocation: (location: LatLon) -> CountryInfo, @@ -41,7 +44,7 @@ class AddRoofShape( filter.matches(element) && ( (element.tags["roof:levels"]?.toFloatOrNull() ?: 0f) > 0f || roofsAreUsuallyFlatAt(element, mapData) == false - ) + ) && levelsOk(element) } override fun isApplicableTo(element: Element): Boolean? { @@ -50,9 +53,13 @@ class AddRoofShape( the quest should only be shown in certain countries. But whether the element is in a certain country cannot be ascertained without the element's geometry */ if ((element.tags["roof:levels"]?.toFloatOrNull() ?: 0f) == 0f) return null - return true + return levelsOk(element) } + private fun levelsOk(element: Element): Boolean = + ((element.tags["building:levels"]?.toIntOrNull() ?: 0) - + (element.tags["roof:levels"]?.toIntOrNull() ?: 0)) <= prefs.getInt(questPrefix(prefs) + PREF_ROOF_SHAPE_MAX_LEVELS, 99) + private fun roofsAreUsuallyFlatAt(element: Element, mapData: MapDataWithGeometry): Boolean? { val center = mapData.getGeometry(element.type, element.id)?.center ?: return null return getCountryInfoByLocation(center).roofsAreUsuallyFlat @@ -61,4 +68,13 @@ class AddRoofShape( override fun applyAnswerTo(answer: RoofShape, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { tags["roof:shape"] = answer.osmValue } + + override val hasQuestSettings = true + + override fun getQuestSettingsDialog(context: Context) = numberSelectionDialog( + context, prefs, questPrefix(prefs) + PREF_ROOF_SHAPE_MAX_LEVELS, 99, R.string.quest_settings_max_roof_levels + ) + } + +private const val PREF_ROOF_SHAPE_MAX_LEVELS = "qs_AddRoofShape_max_levels" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/roof_shape/RoofShape.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_shape/RoofShape.kt index a76b254023e..99ed63e270b 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/roof_shape/RoofShape.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_shape/RoofShape.kt @@ -21,5 +21,12 @@ enum class RoofShape(val osmValue: String) { ONION("onion"), CONE("cone"), + SAWTOOTH("sawtooth"), + HIPPED_AND_GABLED("hipped-and-gabled"), + CROSSPITCHED("crosspitched"), + SIDE_HIPPED("side_hipped"), + SIDE_HALF_HIPPED("side_half-hipped"), + GABLED_HEIGHT_MOVED("gabled_height_moved"), + MANY("many"), } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/roof_shape/RoofShapeItem.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_shape/RoofShapeItem.kt index aa05a262c1b..119b0c6b748 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/roof_shape/RoofShapeItem.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/roof_shape/RoofShapeItem.kt @@ -18,6 +18,12 @@ import de.westnordost.streetcomplete.quests.roof_shape.RoofShape.ROUND import de.westnordost.streetcomplete.quests.roof_shape.RoofShape.ROUND_GABLED import de.westnordost.streetcomplete.quests.roof_shape.RoofShape.SALTBOX import de.westnordost.streetcomplete.quests.roof_shape.RoofShape.SKILLION +import de.westnordost.streetcomplete.quests.roof_shape.RoofShape.SAWTOOTH +import de.westnordost.streetcomplete.quests.roof_shape.RoofShape.GABLED_HEIGHT_MOVED +import de.westnordost.streetcomplete.quests.roof_shape.RoofShape.CROSSPITCHED +import de.westnordost.streetcomplete.quests.roof_shape.RoofShape.HIPPED_AND_GABLED +import de.westnordost.streetcomplete.quests.roof_shape.RoofShape.SIDE_HIPPED +import de.westnordost.streetcomplete.quests.roof_shape.RoofShape.SIDE_HALF_HIPPED import de.westnordost.streetcomplete.view.image_select.DisplayItem import de.westnordost.streetcomplete.view.image_select.Item @@ -43,5 +49,11 @@ private val RoofShape.iconResId: Int? get() = when (this) { ROUND_GABLED -> R.drawable.ic_roof_round_gabled ONION -> R.drawable.ic_roof_onion CONE -> R.drawable.ic_roof_cone + SAWTOOTH -> R.drawable.ic_roof_sawtooth + SIDE_HIPPED -> R.drawable.ic_roof_side_hipped + SIDE_HALF_HIPPED -> R.drawable.ic_roof_side_half_hipped + CROSSPITCHED -> R.drawable.ic_roof_crosspitched + HIPPED_AND_GABLED -> R.drawable.ic_roof_hip_and_gable + GABLED_HEIGHT_MOVED -> R.drawable.ic_roof_gabled_height_moved MANY -> null } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/sac_scale/AddSacScale.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/sac_scale/AddSacScale.kt new file mode 100644 index 00000000000..dd70faaa7e0 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/sac_scale/AddSacScale.kt @@ -0,0 +1,110 @@ +package de.westnordost.streetcomplete.quests.sac_scale + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType +import de.westnordost.streetcomplete.data.osm.mapdata.MapData +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Way +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.getPrefixedFullElementSelectionPref + +private const val PREF_SAC_SCALE_WITHOUT_RELATION = "quest_sac_scale_without_relation" + +class AddSacScale : OsmElementQuestType { + + private val elementFilter = """ + ways with + highway ~ path + and !sac_scale + and access !~ no|private + and foot !~ no|private + and (!lit or lit = no) + and surface ~ "grass|sand|dirt|soil|fine_gravel|compacted|wood|gravel|pebblestone|rock|ground|earth|mud|woodchips|snow|ice|salt|stone" + """ + val filter by lazy { + prefs.getString(getPrefixedFullElementSelectionPref(prefs), elementFilter)!! + .toElementFilterExpression() + } + + override val changesetComment = "Specify SAC Scale" + override val wikiLink = "Key:sac_scale" + override val icon = R.drawable.ic_quest_sac_scale + override val defaultDisabledMessage = R.string.default_disabled_msg_sacScale + + override fun getTitle(tags: Map) = R.string.quest_sacScale_title + + override fun getApplicableElements(mapData: MapDataWithGeometry): Iterable = + if (isSacScaleWithoutRelation) { + mapData.filter(filter).asIterable() + } else { + mapData.relations.filter { + it.tags["route"] == "hiking" + }.map { + mapData.getAllWayInRelation(it.id).filter { way -> + filter.matches(way) + } + }.flatten() + } + + + override fun isApplicableTo(element: Element): Boolean = + filter.matches(element) + + override fun getHighlightedElements( + element: Element, + getMapData: () -> MapDataWithGeometry + ) = getMapData().filter("ways with highway and sac_scale") + + override fun createForm() = AddSacScaleForm() + + override fun applyAnswerTo( + answer: SacScale, + tags: Tags, + geometry: ElementGeometry, + timestampEdited: Long + ) { + tags["sac_scale"] = answer.osmValue + } + + override val hasQuestSettings: Boolean = true + + override fun getQuestSettingsDialog(context: Context): AlertDialog = + AlertDialog.Builder(context) + .setMessage(R.string.pref_quest_sac_scale_without_relation) + .setPositiveButton(R.string.quest_generic_hasFeature_yes) { _, _ -> + prefs.edit().putBoolean(PREF_SAC_SCALE_WITHOUT_RELATION, true).apply() + } + .setNegativeButton(R.string.quest_generic_hasFeature_no) { _, _ -> + prefs.edit().putBoolean(PREF_SAC_SCALE_WITHOUT_RELATION, false).apply() + } + .setNeutralButton(R.string.quest_settings_reset) { _, _ -> + prefs.edit { remove(PREF_SAC_SCALE_WITHOUT_RELATION) } + } + .create() + + private val isSacScaleWithoutRelation + get() = prefs.getBoolean(PREF_SAC_SCALE_WITHOUT_RELATION, false) + + + private fun MapData.getAllWayInRelation(id: Long): List { + val mutableList = mutableListOf() + + getRelation(id)?.members?.forEach { member -> + when (member.type) { + ElementType.NODE -> Unit + ElementType.WAY -> getWay(member.ref)?.let { mutableList.add(it) } + + ElementType.RELATION -> mutableList.addAll(getAllWayInRelation(member.ref)) + } + } + return mutableList + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/sac_scale/AddSacScaleForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/sac_scale/AddSacScaleForm.kt new file mode 100644 index 00000000000..9090fbe559b --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/sac_scale/AddSacScaleForm.kt @@ -0,0 +1,25 @@ +package de.westnordost.streetcomplete.quests.sac_scale + +import android.os.Bundle +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AImageListQuestForm +import de.westnordost.streetcomplete.quests.via_ferrata_scale.ViaFerrataScale +import de.westnordost.streetcomplete.view.image_select.DisplayItem + +class AddSacScaleForm : AImageListQuestForm() { + + override val items: List> get() = SacScale.entries.toItems() + + override val itemsPerRow = 1 + + override val moveFavoritesToFront = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imageSelector.cellLayoutId = R.layout.cell_labeled_icon_select_sac_scale + } + + override fun onClickOk(selectedItems: List) { + applyAnswer(selectedItems.first()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/sac_scale/SacScale.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/sac_scale/SacScale.kt new file mode 100644 index 00000000000..53f26d8d0f3 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/sac_scale/SacScale.kt @@ -0,0 +1,64 @@ +package de.westnordost.streetcomplete.quests.sac_scale + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.sac_scale.SacScale.* +import de.westnordost.streetcomplete.view.image_select.GroupableDisplayItem +import de.westnordost.streetcomplete.view.image_select.Item + +enum class SacScale( + val osmValue: String, + @DrawableRes val imageResId: Int, + @StringRes val titleResId: Int, + @StringRes val descriptionResId: Int, +) { + STROLLING( + osmValue = "strolling", + imageResId = R.drawable.sac_scale_strolling, + titleResId = R.string.quest_sacScale_strolling, + descriptionResId = R.string.quest_sacScale_strolling_description + ), + HIKING( + osmValue = "hiking", + imageResId = R.drawable.sac_scale_t1, + titleResId = R.string.quest_sacScale_one, + descriptionResId = R.string.quest_sacScale_one_description + ), + MOUNTAIN_HIKING( + osmValue = "mountain_hiking", + imageResId = R.drawable.sac_scale_t2, + titleResId = R.string.quest_sacScale_two, + descriptionResId = R.string.quest_sacScale_two_description + ), + DEMANDING_MOUNTAIN_HIKING( + osmValue = "demanding_mountain_hiking", + imageResId = R.drawable.sac_scale_t3, + titleResId = R.string.quest_sacScale_three, + descriptionResId = R.string.quest_sacScale_three_description + ), + ALPINE_HIKING( + osmValue = "alpine_hiking", + imageResId = R.drawable.sac_scale_t4, + titleResId = R.string.quest_sacScale_four, + descriptionResId = R.string.quest_sacScale_four_description + ), + DEMANDING_ALPINE_HIKING( + osmValue = "demanding_alpine_hiking", + imageResId = R.drawable.sac_scale_t5, + titleResId = R.string.quest_sacScale_five, + descriptionResId = R.string.quest_sacScale_five_description + ), + DIFFICULT_ALPINE_HIKING( + osmValue = "difficult_alpine_hiking", + imageResId = R.drawable.sac_scale_t6, + titleResId = R.string.quest_sacScale_six, + descriptionResId = R.string.quest_sacScale_six_description + ) +} + +fun Collection.toItems() = map { it.asItem() } + +fun SacScale.asItem(): GroupableDisplayItem { + return Item(this, imageResId, titleResId, descriptionResId) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/sauna_availability/AddSaunaAvailability.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/sauna_availability/AddSaunaAvailability.kt new file mode 100644 index 00000000000..34ba7e47d75 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/sauna_availability/AddSaunaAvailability.kt @@ -0,0 +1,33 @@ +package de.westnordost.streetcomplete.quests.sauna_availability + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.YesNoQuestForm +import de.westnordost.streetcomplete.util.ktx.toYesNo + +class AddSaunaAvailability : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + ( + leisure ~ fitness_centre + or leisure = sports_hall and sport = swimming + or tourism ~ camp_site|hotel + ) + and !sauna + """ + override val changesetComment = "Survey sauna availabilities" + override val wikiLink = "Key:sauna" + override val icon = R.drawable.ic_quest_sauna + override val defaultDisabledMessage: Int = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_saunaAvailability_title + + override fun createForm() = YesNoQuestForm() + + override fun applyAnswerTo(answer: Boolean, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["sauna"] = answer.toYesNo() + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/seating/AddOutdoorSeatingType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/seating/AddOutdoorSeatingType.kt new file mode 100644 index 00000000000..55ea1f3e37c --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/seating/AddOutdoorSeatingType.kt @@ -0,0 +1,36 @@ +package de.westnordost.streetcomplete.quests.seating + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.isPlace + +class AddOutdoorSeatingType : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + outdoor_seating = yes + """ + override val changesetComment = "Add outdoor seating info" + override val defaultDisabledMessage = R.string.default_disabled_msg_seasonal + override val wikiLink = "Key:outdoor_seating" + override val icon = R.drawable.ic_quest_seating_type + override val isReplacePlaceEnabled = true + override val achievements = listOf(EditTypeAchievement.CITIZEN) + + override fun getTitle(tags: Map) = R.string.quest_outdoor_seating_name_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().asSequence().filter { it.isPlace() } + + override fun createForm() = AddOutdoorSeatingTypeForm() + + override fun applyAnswerTo(answer: String, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["outdoor_seating"] = answer + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/seating/AddOutdoorSeatingTypeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/seating/AddOutdoorSeatingTypeForm.kt new file mode 100644 index 00000000000..64a613d477f --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/seating/AddOutdoorSeatingTypeForm.kt @@ -0,0 +1,21 @@ +package de.westnordost.streetcomplete.quests.seating + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AListQuestForm +import de.westnordost.streetcomplete.quests.TextItem + +class AddOutdoorSeatingTypeForm : AListQuestForm() { + override val items = listOf( + TextItem("parklet", R.string.quest_seating_parklet), + TextItem("pedestrian_zone", R.string.quest_seating_pedestrian_zone), + TextItem("street", R.string.quest_seating_street), + TextItem("sidewalk", R.string.quest_seating_sidewalk), + TextItem("patio", R.string.quest_seating_patio), + TextItem("terrace", R.string.quest_seating_terrace), + TextItem("balcony", R.string.quest_seating_balcony), + TextItem("veranda", R.string.quest_seating_veranda), + TextItem("roof", R.string.quest_seating_roof), + TextItem("garden", R.string.quest_seating_garden), + TextItem("beach", R.string.quest_seating_beach), + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/seating/AddSeating.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/seating/AddSeating.kt index 64126c03172..ba3558fc4bd 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/seating/AddSeating.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/seating/AddSeating.kt @@ -36,7 +36,7 @@ class AddSeating : OsmFilterQuestType() { override fun createForm() = AddSeatingForm() override fun applyAnswerTo(answer: Seating, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { - if (answer == Seating.NO) tags["takeaway"] = "only" + if (answer == Seating.TAKEAWAY_ONLY) tags["takeaway"] = "only" tags["outdoor_seating"] = answer.hasOutdoorSeating.toYesNo() tags["indoor_seating"] = answer.hasIndoorSeating.toYesNo() } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/seating/AddSeatingForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/seating/AddSeatingForm.kt index 213541f8c20..625fc72dbcb 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/seating/AddSeatingForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/seating/AddSeatingForm.kt @@ -7,12 +7,14 @@ import de.westnordost.streetcomplete.quests.seating.Seating.INDOOR_AND_OUTDOOR import de.westnordost.streetcomplete.quests.seating.Seating.NO import de.westnordost.streetcomplete.quests.seating.Seating.ONLY_INDOOR import de.westnordost.streetcomplete.quests.seating.Seating.ONLY_OUTDOOR +import de.westnordost.streetcomplete.quests.seating.Seating.TAKEAWAY_ONLY class AddSeatingForm : AListQuestForm() { override val items = listOf( TextItem(INDOOR_AND_OUTDOOR, R.string.quest_seating_indoor_and_outdoor), TextItem(ONLY_INDOOR, R.string.quest_seating_indoor_only), TextItem(ONLY_OUTDOOR, R.string.quest_seating_outdoor_only), - TextItem(NO, R.string.quest_seating_takeaway), + TextItem(TAKEAWAY_ONLY, R.string.quest_seating_takeaway), + TextItem(NO, R.string.quest_seating_no), ) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/seating/Seating.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/seating/Seating.kt index 2c75215b265..4286861204b 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/seating/Seating.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/seating/Seating.kt @@ -2,6 +2,7 @@ package de.westnordost.streetcomplete.quests.seating enum class Seating(val hasOutdoorSeating: Boolean, val hasIndoorSeating: Boolean) { NO(false, false), + TAKEAWAY_ONLY(false, false), ONLY_INDOOR(false, true), ONLY_OUTDOOR(true, false), INDOOR_AND_OUTDOOR(true, true), diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/AddServiceBuildingOperator.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/AddServiceBuildingOperator.kt new file mode 100644 index 00000000000..82b446fb09e --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/AddServiceBuildingOperator.kt @@ -0,0 +1,42 @@ +package de.westnordost.streetcomplete.quests.service_building + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags + +class AddServiceBuildingOperator : OsmFilterQuestType() { + + override val elementFilter = """ + ways, relations with + building ~ service|transformer_tower + and !operator + and !name + and !brand + and disused != yes and abandoned != yes and !construction + """ + override val changesetComment = "Add service building operator" + override val wikiLink = "Tag:building=service" + override val icon = R.drawable.ic_quest_service_building + override val defaultDisabledMessage: Int = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_service_building_operator_title + + override fun createForm() = AddServiceBuildingOperatorForm() + + override fun applyAnswerTo(answer: ServiceBuildingOperatorAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + when (answer) { + is ServiceBuildingOperator -> { + tags["operator"] = answer.name + } + is DisusedServiceBuilding -> { + tags["disused"] = "yes" + tags.keys.toList().filter { it.matches(Regex("^(power|service|man_made|substation|pipeline|utility|railway)$")) } + .forEach { + tags["disused:" + it] = tags[it] ?: "yes" + tags.remove(it) + } + } + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/AddServiceBuildingOperatorForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/AddServiceBuildingOperatorForm.kt new file mode 100644 index 00000000000..45b5df4acb1 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/AddServiceBuildingOperatorForm.kt @@ -0,0 +1,39 @@ +package de.westnordost.streetcomplete.quests.service_building + +import android.os.Bundle +import android.view.View +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.ANameWithSuggestionsForm +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.util.takeFavourites + +class AddServiceBuildingOperatorForm : ANameWithSuggestionsForm() { + + // make proper list like ATM operators? + override val suggestions: List get() = (lastPickedAnswers + OPERATORS).distinct() + + override val otherAnswers = listOf( + AnswerItem(R.string.quest_disused) { applyAnswer(DisusedServiceBuilding) } + ) + + override fun onClickOk() { + prefs.addLastPicked(javaClass.simpleName, name!!) + applyAnswer(ServiceBuildingOperator(name!!)) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.nameInput.showDropDown() + } + + private val lastPickedAnswers by lazy { + prefs.getLastPicked(javaClass.simpleName).takeFavourites(50, 50, 1) + } +} + +private val OPERATORS = listOf( + "Wiener Netze", "Wien Energie", "Wienstrom", "EVN", "Netz Niederösterreich GmbH", "Netz OÖ", + "Salzburg AG", "KNG-Kärnten Netz GmbH", "Energie Steiermark", + "ÖBB", "GKB", "Wiener Linien", + "e.on", "DPMB", +) diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/AddServiceBuildingType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/AddServiceBuildingType.kt new file mode 100644 index 00000000000..df584ffaa22 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/AddServiceBuildingType.kt @@ -0,0 +1,36 @@ +package de.westnordost.streetcomplete.quests.service_building + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags + +class AddServiceBuildingType : OsmFilterQuestType() { + + override val elementFilter = """ + ways, relations with + building ~ service|transformer_tower + and !power and !disused:power and !abandoned:power and !was:power and !construction:power + and !service and !disused:service and !abandoned:service and !was:service and !construction:service + and !man_made and !disused:man_made and !abandoned:man_made and !was:man_made and !construction:man_made + and !substation and !disused:substation and !abandoned:substation and !was:substation and !construction:substation + and !pipeline and !disused:pipeline and !abandoned:pipeline and !was:pipeline and !construction:pipeline + and !utility and !disused:utility and !abandoned:utility and !was:utility and !construction:utility + and !railway and !disused:railway and !abandoned:railway and !was:railway and !construction:railway + and disused != yes and abandoned != yes and !construction + """ + override val changesetComment = "Add service building type" + override val wikiLink = "Tag:building=service" + override val icon = R.drawable.ic_quest_service_building + override val defaultDisabledMessage: Int = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_service_building_type_title + + override fun createForm() = AddServiceBuildingTypeForm() + + override fun applyAnswerTo(answer: ServiceBuildingType, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + answer.tags.forEach { tags[it.first] = it.second } + if (answer == ServiceBuildingType.VENTILATION_SHAFT || answer == ServiceBuildingType.RAILWAY_VENTILATION_SHAFT) + tags.remove("building") // see https://wiki.openstreetmap.org/wiki/Tag:man_made%3Dventilation_shaft + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/AddServiceBuildingTypeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/AddServiceBuildingTypeForm.kt new file mode 100644 index 00000000000..8bf31c5bc44 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/AddServiceBuildingTypeForm.kt @@ -0,0 +1,42 @@ +package de.westnordost.streetcomplete.quests.service_building + +import android.os.Bundle +import android.view.View +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.quests.AGroupedImageListQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem + +class AddServiceBuildingTypeForm : AGroupedImageListQuestForm() { + + override val otherAnswers = listOf( + AnswerItem(R.string.quest_disused) { applyAnswer(ServiceBuildingType.DISUSED) } + ) + + override val topItems = listOf( + ServiceBuildingType.MINOR_SUBSTATION, + ServiceBuildingType.GAS_PRESSURE_REGULATION, + ServiceBuildingType.VENTILATION_SHAFT, + ServiceBuildingType.WATER_WELL, + ServiceBuildingType.HEATING, + ).toItems() + + override val allItems = ServiceBuildingTypeCategory.values().toItems() + + override val itemsPerRow = 1 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imageSelector.groupCellLayoutId = R.layout.cell_labeled_icon_select_with_description_group + imageSelector.cellLayoutId = R.layout.cell_labeled_icon_select_with_description + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + element.tags["operator"]?.let { setTitle(resources.getString((questType as OsmElementQuestType<*>).getTitle(element.tags)) + " ($it)") } + } + + override fun onClickOk(value: ServiceBuildingType) { + applyAnswer(value) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/ServiceBuildingOperatorAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/ServiceBuildingOperatorAnswer.kt new file mode 100644 index 00000000000..1e591f55942 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/ServiceBuildingOperatorAnswer.kt @@ -0,0 +1,6 @@ +package de.westnordost.streetcomplete.quests.service_building + +sealed interface ServiceBuildingOperatorAnswer + +data class ServiceBuildingOperator(val name: String) : ServiceBuildingOperatorAnswer +data object DisusedServiceBuilding : ServiceBuildingOperatorAnswer diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/ServiceBuildingType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/ServiceBuildingType.kt new file mode 100644 index 00000000000..4a8a6cdda10 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/service_building/ServiceBuildingType.kt @@ -0,0 +1,166 @@ +package de.westnordost.streetcomplete.quests.service_building + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.service_building.ServiceBuildingType.* +import de.westnordost.streetcomplete.view.image_select.GroupableDisplayItem +import de.westnordost.streetcomplete.view.image_select.Item + +enum class ServiceBuildingType(val tags: List>) { + POWER(listOf("utility" to "power")), + TELECOM(listOf("utility" to "telecom")), + WATER(listOf("utility" to "water")), + GAS(listOf("utility" to "gas")), + SEWERAGE(listOf("utility" to "sewerage", "substance" to "sewage")), // can be pumping stations or treatment plants + HEATING(listOf("utility" to "heating")), + VENTILATION_SHAFT(listOf("man_made" to "ventilation")), // building tag removed in AddServiceBuildingType.applyAnswerTo + MONITORING_STATION(listOf("man_made" to "monitoring_station")), + // POWER + MINOR_SUBSTATION(listOf("utility" to "power", "power" to "substation", "substation" to "minor_distribution")), + SUBSTATION(listOf("utility" to "power", "power" to "substation", "substation" to "distribution")), + INDUSTRIAL_SUBSTATION(listOf("utility" to "power", "power" to "substation", "substation" to "industrial")), + TRACTION_SUBSTATION(listOf("utility" to "power", "power" to "substation", "substation" to "traction")), + SWITCHGEAR(listOf("utility" to "power", "power" to "switchgear")), + PLANT(listOf("utility" to "power", "power" to "plant")), + //GAS + GAS_PRESSURE_REGULATION(listOf("utility" to "gas", "pipeline" to "substation", "substation" to "distribution", "substance" to "gas")), + GAS_PUMPING_STATION(listOf("utility" to "gas", "man_made" to "pumping_station", "substance" to "gas")), + // WATER + WATER_WELL(listOf("utility" to "water", "man_made" to "water_well", "substance" to "water")), + COVERED_RESERVOIR(listOf("utility" to "water", "man_made" to "reservoir_covered", "substance" to "water")), + WATER_PUMPING_STATION(listOf("utility" to "water", "man_made" to "pumping_station", "substance" to "water")), + // OIL + OIL_PUMPING_STATION(listOf("utility" to "oil", "man_made" to "pumping_station", "substance" to "oil")), + // RAILWAY + RAILWAY_VENTILATION_SHAFT(listOf("service" to "ventilation", "railway" to "ventilation_shaft")), + RAILWAY_SIGNAL_BOX(listOf("building" to "industrial", "railway" to "signal_box")), + RAILWAY_ENGINE_SHED(listOf("building" to "industrial", "railway" to "engine_shed")), + RAILWAY_WASH(listOf("building" to "industrial", "railway" to "wash")), + // TELECOM + INTERNET_EXCHANGE(listOf("utility" to "communication", "telecom" to "internet_exchange")), + TELECOM_EXCHANGE(listOf("utility" to "communication", "telecom" to "exchange")), + // DISUSED + DISUSED(listOf("disused" to "yes")), +} + +enum class ServiceBuildingTypeCategory(val type: ServiceBuildingType?, val subTypes: List) { + POWER(ServiceBuildingType.POWER, listOf(MINOR_SUBSTATION, SUBSTATION, INDUSTRIAL_SUBSTATION, TRACTION_SUBSTATION, SWITCHGEAR, PLANT)), + WATER(ServiceBuildingType.WATER, listOf(WATER_WELL, COVERED_RESERVOIR, WATER_PUMPING_STATION)), + GAS(ServiceBuildingType.GAS, listOf(GAS_PUMPING_STATION, GAS_PRESSURE_REGULATION)), + TELECOM(ServiceBuildingType.TELECOM, listOf(TELECOM_EXCHANGE, INTERNET_EXCHANGE)), + RAILWAY(null, listOf(RAILWAY_VENTILATION_SHAFT, RAILWAY_SIGNAL_BOX, RAILWAY_ENGINE_SHED, RAILWAY_WASH)), + OTHER_SERVICE(null, listOf(OIL_PUMPING_STATION, SEWERAGE, HEATING, VENTILATION_SHAFT, MONITORING_STATION)), +} + +fun Collection.toItems() = map { it.asItem() } +fun Array.toItems() = map { it.asItem() } + +fun ServiceBuildingType.asItem(): GroupableDisplayItem { + return Item(this, iconResId, titleResId, descriptionResId) +} + +fun ServiceBuildingTypeCategory.asItem(): GroupableDisplayItem { + return Item(type, iconResId, titleResId, null, subTypes.toItems()) +} + +private val ServiceBuildingType.titleResId: Int get() = when (this) { + POWER -> R.string.quest_utility_power + MINOR_SUBSTATION -> R.string.quest_service_building_type_minor_substation + SUBSTATION -> R.string.quest_service_building_type_substation + INDUSTRIAL_SUBSTATION -> R.string.quest_service_building_type_industrial_substation + TRACTION_SUBSTATION -> R.string.quest_service_building_type_traction_substation + SWITCHGEAR -> R.string.quest_service_building_type_switchgear + PLANT -> R.string.quest_service_building_type_plant + WATER -> R.string.quest_utility_water + WATER_WELL -> R.string.quest_service_building_type_well + COVERED_RESERVOIR -> R.string.quest_service_building_type_reservoir + WATER_PUMPING_STATION -> R.string.quest_service_building_type_pump + SEWERAGE -> R.string.quest_utility_sewerage + OIL_PUMPING_STATION -> R.string.quest_service_building_oil_pumping_station + GAS -> R.string.quest_utility_gas + GAS_PRESSURE_REGULATION -> R.string.quest_service_building_type_pressure + GAS_PUMPING_STATION -> R.string.quest_service_building_gas_pumping_station + TELECOM -> R.string.quest_utility_telecom + TELECOM_EXCHANGE -> R.string.quest_service_building_telecom_exchange + INTERNET_EXCHANGE -> R.string.quest_service_building_internet_exchange + RAILWAY_VENTILATION_SHAFT -> R.string.quest_service_building_railway_ventilation_shaft + RAILWAY_SIGNAL_BOX -> R.string.quest_service_building_railway_signal_box + RAILWAY_ENGINE_SHED -> R.string.quest_service_building_railway_engine_shed + RAILWAY_WASH -> R.string.quest_service_building_railway_wash + VENTILATION_SHAFT -> R.string.quest_service_building_ventilation + HEATING -> R.string.quest_service_building_heating + MONITORING_STATION -> R.string.quest_service_building_monitoring_station + DISUSED -> R.string.quest_disused +} + +private val ServiceBuildingType.descriptionResId: Int? get() = when (this) { + MINOR_SUBSTATION -> R.string.quest_service_building_type_minor_substation_description + SUBSTATION -> R.string.quest_service_building_type_substation_description + INDUSTRIAL_SUBSTATION -> R.string.quest_service_building_type_industrial_substation_description + TRACTION_SUBSTATION -> R.string.quest_service_building_type_traction_substation_description + SWITCHGEAR -> R.string.quest_service_building_type_switchgear_description + WATER_WELL -> R.string.quest_service_building_type_well_description + COVERED_RESERVOIR -> R.string.quest_service_building_type_reservoir_description + WATER_PUMPING_STATION -> R.string.quest_service_building_type_pump_description + SEWERAGE -> R.string.quest_service_building_sewerage_description + OIL_PUMPING_STATION -> R.string.quest_service_building_oil_pumping_station_description + GAS_PRESSURE_REGULATION -> R.string.quest_service_building_type_pressure_description + GAS_PUMPING_STATION -> R.string.quest_service_building_gas_pumping_station_description + TELECOM_EXCHANGE -> R.string.quest_service_building_telecom_exchange_description + INTERNET_EXCHANGE -> R.string.quest_service_building_internet_exchange_description + RAILWAY_VENTILATION_SHAFT -> R.string.quest_service_building_railway_ventilation_shaft_description + RAILWAY_SIGNAL_BOX -> R.string.quest_service_building_railway_signal_box_description + RAILWAY_ENGINE_SHED -> R.string.quest_service_building_railway_engine_shed_description + RAILWAY_WASH -> R.string.quest_service_building_railway_wash_description + VENTILATION_SHAFT -> R.string.quest_service_building_ventilation_description + HEATING -> R.string.quest_service_building_heating_description + MONITORING_STATION -> R.string.quest_service_building_monitoring_station_description + else -> null +} + +private val ServiceBuildingType.iconResId: Int get() = when (this) { + POWER -> R.drawable.ic_quest_service_building_power + WATER -> R.drawable.ic_quest_service_building_water + TELECOM -> R.drawable.ic_quest_service_building_telecom + GAS -> R.drawable.ic_quest_building_service_gas + SEWERAGE -> R.drawable.ic_quest_service_building_sewerage + MINOR_SUBSTATION -> R.drawable.ic_quest_service_building_minor_substation + SUBSTATION -> R.drawable.ic_quest_service_building_substation + INDUSTRIAL_SUBSTATION -> R.drawable.ic_quest_service_building_industrial_substation + TRACTION_SUBSTATION -> R.drawable.ic_quest_service_building_traction_substation + SWITCHGEAR -> R.drawable.ic_quest_service_building_switchgear + PLANT -> R.drawable.ic_quest_service_building_power_plant + GAS_PRESSURE_REGULATION -> R.drawable.ic_quest_building_service_gas_pressure + GAS_PUMPING_STATION -> R.drawable.ic_quest_building_service_gas_pump + WATER_WELL -> R.drawable.ic_quest_service_building_water_well + COVERED_RESERVOIR -> R.drawable.ic_quest_service_reservoir_covered + WATER_PUMPING_STATION -> R.drawable.ic_quest_service_building_water_pump + OIL_PUMPING_STATION -> R.drawable.ic_quest_service_building_oil_pump + RAILWAY_VENTILATION_SHAFT -> R.drawable.ic_quest_service_building_railway_ventilation + RAILWAY_SIGNAL_BOX -> R.drawable.ic_quest_service_building_railway_signal_box + RAILWAY_ENGINE_SHED -> R.drawable.ic_quest_service_building_railway_engine_shed + RAILWAY_WASH -> R.drawable.ic_quest_service_building_railway_wash + HEATING -> R.drawable.ic_quest_service_building_heating + VENTILATION_SHAFT -> R.drawable.ic_quest_service_building_ventilation + TELECOM_EXCHANGE -> R.drawable.ic_quest_service_building_telecom_exchange + INTERNET_EXCHANGE -> R.drawable.ic_quest_service_building_internet_exchange + MONITORING_STATION -> R.drawable.ic_quest_service_building_monitoring + DISUSED -> R.drawable.ic_quest_service_building +} + +private val ServiceBuildingTypeCategory.titleResId: Int get() = when (this) { + ServiceBuildingTypeCategory.POWER -> R.string.quest_utility_power + ServiceBuildingTypeCategory.WATER -> R.string.quest_utility_water + ServiceBuildingTypeCategory.GAS -> R.string.quest_utility_gas + ServiceBuildingTypeCategory.TELECOM -> R.string.quest_utility_telecom + ServiceBuildingTypeCategory.RAILWAY -> R.string.quest_service_building_railway + ServiceBuildingTypeCategory.OTHER_SERVICE -> R.string.quest_service_building_other +} + +private val ServiceBuildingTypeCategory.iconResId: Int get() = when (this) { + ServiceBuildingTypeCategory.POWER -> R.drawable.ic_quest_service_building_power + ServiceBuildingTypeCategory.WATER -> R.drawable.ic_quest_service_building_water + ServiceBuildingTypeCategory.GAS -> R.drawable.ic_quest_building_service_gas + ServiceBuildingTypeCategory.TELECOM -> R.drawable.ic_quest_service_building_telecom + ServiceBuildingTypeCategory.RAILWAY -> R.drawable.ic_quest_service_building_railway + ServiceBuildingTypeCategory.OTHER_SERVICE -> R.drawable.ic_quest_service_building_other +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/shelter_type/AddShelterType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/shelter_type/AddShelterType.kt new file mode 100644 index 00000000000..9c53b5dad7a --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/shelter_type/AddShelterType.kt @@ -0,0 +1,36 @@ +package de.westnordost.streetcomplete.quests.shelter_type + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement +import de.westnordost.streetcomplete.osm.Tags + +class AddShelterType : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + amenity = shelter + and !shelter_type + """ + override val changesetComment = "Specify shelter types" + override val wikiLink = "Key:shelter_type" + override val icon = R.drawable.ic_quest_shelter_type + override val isDeleteElementEnabled = true + override val achievements = listOf(EditTypeAchievement.OUTDOORS) + override val defaultDisabledMessage: Int = R.string.quest_shelter_type_disabled_msg + + override fun getTitle(tags: Map) = R.string.quest_shelter_type_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("nodes, ways with amenity = shelter") + + override fun createForm() = AddShelterTypeForm() + + override fun applyAnswerTo(answer: ShelterType, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["shelter_type"] = answer.osmValue + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/shelter_type/AddShelterTypeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/shelter_type/AddShelterTypeForm.kt new file mode 100644 index 00000000000..3b04e47e6dd --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/shelter_type/AddShelterTypeForm.kt @@ -0,0 +1,39 @@ +package de.westnordost.streetcomplete.quests.shelter_type + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AImageListQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.quests.shelter_type.ShelterType.BASIC_HUT +import de.westnordost.streetcomplete.quests.shelter_type.ShelterType.FIELD_SHELTER +import de.westnordost.streetcomplete.quests.shelter_type.ShelterType.GAZEBO +import de.westnordost.streetcomplete.quests.shelter_type.ShelterType.LEAN_TO +import de.westnordost.streetcomplete.quests.shelter_type.ShelterType.PICNIC_SHELTER +import de.westnordost.streetcomplete.quests.shelter_type.ShelterType.PUBLIC_TRANSPORT +import de.westnordost.streetcomplete.quests.shelter_type.ShelterType.ROCK_SHELTER +import de.westnordost.streetcomplete.quests.shelter_type.ShelterType.SUN_SHELTER +import de.westnordost.streetcomplete.quests.shelter_type.ShelterType.WEATHER_SHELTER +import de.westnordost.streetcomplete.view.image_select.Item + +class AddShelterTypeForm : AImageListQuestForm() { + + override val items = listOf( + Item(PUBLIC_TRANSPORT, R.drawable.shelter_type_public_transport, R.string.quest_shelter_type_public_transport), + Item(PICNIC_SHELTER, R.drawable.shelter_type_picnic_shelter, R.string.quest_shelter_type_picnic_shelter), + Item(GAZEBO, R.drawable.shelter_type_gazebo, R.string.quest_shelter_type_gazebo), + Item(LEAN_TO, R.drawable.shelter_type_lean_to, R.string.quest_shelter_type_lean_to), + Item(BASIC_HUT, R.drawable.shelter_type_basic_hut, R.string.quest_shelter_type_basic_hut), + Item(SUN_SHELTER, R.drawable.shelter_type_sun_shelter, R.string.quest_shelter_type_sun_shelter), + Item(FIELD_SHELTER, R.drawable.shelter_type_field_shelter, R.string.quest_shelter_type_field_shelter), + Item(ROCK_SHELTER, R.drawable.shelter_type_rock_shelter, R.string.quest_shelter_type_rock_shelter) + ) + + override val otherAnswers = listOf( + AnswerItem(R.string.quest_shelter_type_is_weather_shelter) { applyAnswer(WEATHER_SHELTER) } + ) + + override val itemsPerRow = 3 + + override fun onClickOk(selectedItems: List) { + applyAnswer(selectedItems.single()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/shelter_type/ShelterType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/shelter_type/ShelterType.kt new file mode 100644 index 00000000000..f174784d915 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/shelter_type/ShelterType.kt @@ -0,0 +1,13 @@ +package de.westnordost.streetcomplete.quests.shelter_type + +enum class ShelterType(val osmValue: String) { + PUBLIC_TRANSPORT("public_transport"), + PICNIC_SHELTER("picnic_shelter"), + GAZEBO("gazebo"), + LEAN_TO("lean_to"), + BASIC_HUT("basic_hut"), + SUN_SHELTER("sun_shelter"), + FIELD_SHELTER("field_shelter"), + ROCK_SHELTER("rock_shelter"), + WEATHER_SHELTER("weather_shelter") +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/CheckShopExistence.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/CheckShopExistence.kt index ee782693c53..410451756d6 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/CheckShopExistence.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/CheckShopExistence.kt @@ -49,8 +49,7 @@ class CheckShopExistence( override fun isApplicableTo(element: Element): Boolean = filter.matches(element) && - element.isPlace() && - hasName(element) + element.isPlace() override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = getMapData().asSequence().filter { it.isPlaceOrDisusedPlace() } @@ -60,11 +59,4 @@ class CheckShopExistence( override fun applyAnswerTo(answer: Unit, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { tags.updateCheckDate() } - - private fun hasName(element: Element) = hasProperName(element.tags) || hasFeatureName(element) - - private fun hasProperName(tags: Map): Boolean = - tags.containsKey("name") || tags.containsKey("brand") - - private fun hasFeatureName(element: Element) = getFeature(element)?.name != null } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/CheckShopExistenceForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/CheckShopExistenceForm.kt index a533d8e31c0..2b731cb3b31 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/CheckShopExistenceForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/CheckShopExistenceForm.kt @@ -6,7 +6,7 @@ import de.westnordost.streetcomplete.quests.AnswerItem class CheckShopExistenceForm : AbstractOsmQuestForm() { override val buttonPanelAnswers = listOf( - AnswerItem(R.string.quest_generic_hasFeature_no) { replacePlace() }, + AnswerItem(R.string.quest_generic_hasFeature_no) { replacePlace(false) }, // false to avoid showing edit with "edits in context of" changeset message AnswerItem(R.string.quest_generic_hasFeature_yes) { applyAnswer(Unit) }, ) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/ShopGoneDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/ShopGoneDialog.kt index c3a2dd2b619..a6b25e8a3bb 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/ShopGoneDialog.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/ShopGoneDialog.kt @@ -10,6 +10,7 @@ import de.westnordost.osmfeatures.Feature import de.westnordost.osmfeatures.FeatureDictionary import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.databinding.DialogShopGoneBinding import de.westnordost.streetcomplete.databinding.ViewShopTypeBinding import de.westnordost.streetcomplete.osm.POPULAR_PLACE_FEATURE_IDS @@ -27,7 +28,8 @@ class ShopGoneDialog( private val countryCode: String?, private val featureDictionary: FeatureDictionary, private val onSelectedFeatureFn: (Feature) -> Unit, - private val onLeaveNoteFn: () -> Unit + private val onLeaveNoteFn: () -> Unit, + private val pos: LatLon? = null, ) : AlertDialog(context) { private val binding: ViewShopTypeBinding @@ -60,7 +62,8 @@ class ShopGoneDialog( { it.toElement().isPlace() }, ::onSelectedFeature, POPULAR_PLACE_FEATURE_IDS, - true + true, + pos ).show() } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/ShopTypeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/ShopTypeForm.kt index 9fb30d9ac54..16e617f4c64 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/ShopTypeForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/ShopTypeForm.kt @@ -5,6 +5,7 @@ import android.view.View import android.widget.RadioButton import de.westnordost.osmfeatures.Feature import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType import de.westnordost.streetcomplete.databinding.ViewShopTypeBinding import de.westnordost.streetcomplete.osm.POPULAR_PLACE_FEATURE_IDS import de.westnordost.streetcomplete.osm.isPlace @@ -47,8 +48,15 @@ class ShopTypeForm : AbstractOsmQuestForm() { { it.toElement().isPlace() }, ::onSelectedFeature, POPULAR_PLACE_FEATURE_IDS, + false, + geometry.center ).show() } + if (questType is SpecifyShopType) { + val titlePlus = if (element.tags["shop"] == null) " (amenity=${element.tags["amenity"]})" + else " (shop=${element.tags["shop"]})" + setTitle(resources.getString((questType as OsmElementQuestType<*>).getTitle(element.tags)) + titlePlus) + } } private fun onSelectedFeature(feature: Feature) { @@ -58,7 +66,7 @@ class ShopTypeForm : AbstractOsmQuestForm() { override fun onClickOk() { when (selectedRadioButtonId) { - R.id.vacantRadioButton -> applyAnswer(IsShopVacant) + R.id.vacantRadioButton -> applyAnswer(IsShopVacant, true) R.id.leaveNoteRadioButton -> composeNote() R.id.replaceRadioButton -> applyAnswer(ShopType(featureCtrl.feature!!)) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/SpecifyShopType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/SpecifyShopType.kt index 6b57e75d636..1ab6b5061c2 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/SpecifyShopType.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/shop_type/SpecifyShopType.kt @@ -15,7 +15,7 @@ class SpecifyShopType : OsmFilterQuestType() { override val elementFilter = """ nodes, ways with ( - shop ~ yes|hobby + shop ~ yes|hobby|fixme|retail and !man_made and !historic and !military @@ -29,6 +29,8 @@ class SpecifyShopType : OsmFilterQuestType() { and !craft and !healthcare and !office + ) or ( + amenity ~ shop|shopping and !shop ) """ override val changesetComment = "Survey shop types" @@ -46,6 +48,8 @@ class SpecifyShopType : OsmFilterQuestType() { override fun applyAnswerTo(answer: ShopTypeAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { tags.removeCheckDates() + if (tags["amenity"] in listOf("shop", "shopping")) + tags.remove("amenity") when (answer) { is IsShopVacant -> { tags["disused:shop"] = tags["shop"] ?: "yes" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowBicycleStuff.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowBicycleStuff.kt new file mode 100644 index 00000000000..b7abb9119cc --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowBicycleStuff.kt @@ -0,0 +1,36 @@ +package de.westnordost.streetcomplete.quests.show_poi + +import android.content.Context +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.NoAnswerFragment +import de.westnordost.streetcomplete.quests.getLabelOrElementSelectionDialog + +class ShowBicycleStuff : OsmFilterQuestType() { + override val elementFilter = """ + nodes, ways, relations with + amenity ~ bicycle_parking|bicycle_rental|bicycle_repair_station|compressed_air + """ + override val changesetComment = "Adjust bicycle related elements" + override val wikiLink = "Tag:amenity=bicycle_parking" + override val icon = R.drawable.ic_quest_poi_bicycle + override val dotColor = "mediumorchid" + override val defaultDisabledMessage = R.string.default_disabled_msg_poi_bike + + override fun getTitle(tags: Map) = + R.string.quest_poi_cycling_title + + override fun createForm() = NoAnswerFragment() + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter(filter) + + override fun applyAnswerTo(answer: Boolean, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) {} + + override fun getQuestSettingsDialog(context: Context) = getLabelOrElementSelectionDialog(context, this, prefs) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowBusiness.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowBusiness.kt new file mode 100644 index 00000000000..2b94e87f538 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowBusiness.kt @@ -0,0 +1,82 @@ +package de.westnordost.streetcomplete.quests.show_poi + +import android.content.Context +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.isPlace +import de.westnordost.streetcomplete.quests.NoAnswerFragment +import de.westnordost.streetcomplete.quests.getLabelOrElementSelectionDialog + +class ShowBusiness : OsmFilterQuestType() { + override val elementFilter = """ + nodes, ways, relations with + ( + shop and shop !~ no|vacant + or craft + or office + or tourism = information and information = office + or healthcare + or """.trimIndent() + + + // The common list is shared by the name quest, the opening hours quest and the wheelchair quest. + // So when adding other tags to the common list keep in mind that they need to be appropriate for all those quests. + // Independent tags can by added in the "wheelchair only" tab. + + mapOf( + "amenity" to arrayOf( + "restaurant", "cafe", "ice_cream", "fast_food", "bar", "pub", "biergarten", "food_court", "nightclub", // eat & drink + "cinema", "planetarium", "casino", // amenities + "bank", "bureau_de_change", "money_transfer", "post_office", "marketplace", "internet_cafe", "payment_centre", // commercial + "car_wash", "car_rental", "fuel", // car stuff + "dentist", "doctors", "clinic", "pharmacy", "veterinary", // health + "animal_boarding", "animal_shelter", "animal_breeding", // animals + "coworking_space", "prep_school", "dojo", + + "boat_rental", + "theatre", // culture + "conference_centre", "arts_centre", // events + "ferry_terminal", // transport + "hospital", // health care + + "studio", // culture + "events_venue", "exhibition_centre", "music_venue", // events + "social_facility", "nursing_home", "childcare", "retirement_home", "social_centre", // social + "driving_school", "dive_centre", "language_school", "music_school", // learning + "brothel", "gambling", "love_hotel", "stripclub" // bad stuff + ), + "tourism" to arrayOf( + "zoo", "aquarium", "theme_park", "gallery", "museum", "attraction", + "hotel", "guest_house", "motel", "hostel", "alpine_hut", "apartment", "resort", "camp_site", "caravan_site", "chalet" // accommodations + ), + "leisure" to arrayOf( + "fitness_centre", "golf_course", "water_park", "miniature_golf", "bowling_alley", + "amusement_arcade", "adult_gaming_centre", "tanning_salon", "escape_game", + "sauna", "trampoline_park" + + ) + ).map { it.key + " ~ " + it.value.joinToString("|") }.joinToString("\n or ") + + "\n)" + + override val changesetComment = "Adjust shops and similar" + override val wikiLink = "Key:shop" + override val icon = R.drawable.ic_quest_poi_business + override val dotColor = "sandybrown" + override val isReplacePlaceEnabled = true + override val defaultDisabledMessage = R.string.default_disabled_msg_poi_business + + override fun getTitle(tags: Map) = R.string.quest_poi_business_title + + override fun createForm() = NoAnswerFragment() + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().asSequence().filter { it.isPlace() } + + override fun applyAnswerTo(answer: Boolean, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) {} + + override fun getQuestSettingsDialog(context: Context) = getLabelOrElementSelectionDialog(context, this, prefs) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowCamera.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowCamera.kt new file mode 100644 index 00000000000..9e1d015fe45 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowCamera.kt @@ -0,0 +1,38 @@ +package de.westnordost.streetcomplete.quests.show_poi + +import android.content.Context +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.NoAnswerFragment +import de.westnordost.streetcomplete.quests.getLabelOrElementSelectionDialog +import de.westnordost.streetcomplete.quests.getLabelSources + +class ShowCamera : OsmFilterQuestType() { + override val elementFilter = """ + nodes, ways, relations with + man_made = surveillance + """ + override val changesetComment = "Adjust surveillance cameras" + override val wikiLink = "Tag:surveillance:type" + override val icon = R.drawable.ic_quest_poi_camera + override val dotColor = "mediumvioletred" + override val defaultDisabledMessage = R.string.default_disabled_msg_poi_camera + override val dotLabelSources = getLabelSources("", this, prefs) + + override fun getTitle(tags: Map) = + R.string.quest_poi_camera_title + + override fun createForm() = NoAnswerFragment() + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter(filter) + + override fun applyAnswerTo(answer: Boolean, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) {} + + override fun getQuestSettingsDialog(context: Context) = getLabelOrElementSelectionDialog(context, this, prefs) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowFixme.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowFixme.kt new file mode 100644 index 00000000000..334b7c9ec82 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowFixme.kt @@ -0,0 +1,44 @@ +package de.westnordost.streetcomplete.quests.show_poi + +import android.content.Context +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.questPrefix +import de.westnordost.streetcomplete.quests.singleTypeElementSelectionDialog + +class ShowFixme : OsmFilterQuestType() { + override val elementFilter = """ + nodes, ways, relations with + (fixme or FIXME) + and fixme !~ "${prefs.getString(questPrefix(prefs) + PREF_FIXME_IGNORE, FIXME_IGNORE_DEFAULT)}" + and FIXME !~ "${prefs.getString(questPrefix(prefs) + PREF_FIXME_IGNORE, FIXME_IGNORE_DEFAULT)}" + """ + override val changesetComment = "Remove/adjust fixme" + override val wikiLink = "Key:fixme" + override val icon = R.drawable.ic_quest_poi_fixme + override val dotColor = "red" + override val defaultDisabledMessage = R.string.default_disabled_msg_poi_fixme + override val dotLabelSources = listOf("fixme", "FIXME") + + override fun getTitle(tags: Map) = R.string.quest_fixme_title + + override fun createForm() = ShowFixmeAnswerForm() + + override fun applyAnswerTo(answer: Boolean, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + if (!answer) { + tags.remove("fixme") + tags.remove("FIXME") + } + } + + override val hasQuestSettings = true + + // actual ignoring of stuff happens when downloading + override fun getQuestSettingsDialog(context: Context) = + singleTypeElementSelectionDialog(context, prefs, questPrefix(prefs) + PREF_FIXME_IGNORE, FIXME_IGNORE_DEFAULT, R.string.quest_settings_fixme_title) +} + +private const val PREF_FIXME_IGNORE = "qs_ShowFixme_ignore_values" +private const val FIXME_IGNORE_DEFAULT = "yes|continue|continue?" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowFixmeAnswerForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowFixmeAnswerForm.kt new file mode 100644 index 00000000000..e8a5c7b84ed --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowFixmeAnswerForm.kt @@ -0,0 +1,22 @@ +package de.westnordost.streetcomplete.quests.show_poi + +import android.os.Bundle +import android.view.View +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem + +class ShowFixmeAnswerForm : AbstractOsmQuestForm() { + + override val buttonPanelAnswers = listOf( + AnswerItem(R.string.quest_fixme_remove) { applyAnswer(false) } + ) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + (element.tags["fixme"] ?: element.tags["FIXME"]) + ?.let { setTitle(resources.getString((questType as OsmElementQuestType<*>).getTitle(element.tags)) + " ($it)") } + } + +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowMachine.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowMachine.kt new file mode 100644 index 00000000000..6ae9c87bc6b --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowMachine.kt @@ -0,0 +1,44 @@ +package de.westnordost.streetcomplete.quests.show_poi + +import android.content.Context +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.NoAnswerFragment +import de.westnordost.streetcomplete.quests.getLabelOrElementSelectionDialog +import de.westnordost.streetcomplete.quests.getLabelSources + +class ShowMachine : OsmFilterQuestType() { + override val elementFilter = """ + nodes, ways with + amenity ~ vending_machine|atm|telephone|charging_station|device_charging_station|photo_booth + or atm = yes and (amenity or shop) + """ + override val changesetComment = "Adjust vending machine or similar" + override val wikiLink = "Tag:amenity=vending_machine" + override val icon = R.drawable.ic_quest_poi_machine + override val dotColor = "blue" + override val defaultDisabledMessage = R.string.default_disabled_msg_poi_machine + override val dotLabelSources = getLabelSources("vending", this, prefs) + + override fun getTitle(tags: Map) = + if (!tags["atm"].isNullOrEmpty() && tags["atm"] != "no") + R.string.quest_poi_has_atm_title + else if (tags["amenity"].equals("vending_machine")) + R.string.quest_poi_vending_title + else + R.string.quest_poi_machine_title + + override fun createForm() = ShowMachineAnswerForm() + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter(filter) + + override fun applyAnswerTo(answer: Boolean, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) {} + + override fun getQuestSettingsDialog(context: Context) = getLabelOrElementSelectionDialog(context, this, prefs) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowMachineAnswerForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowMachineAnswerForm.kt new file mode 100644 index 00000000000..c9f83b1fe16 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowMachineAnswerForm.kt @@ -0,0 +1,19 @@ +package de.westnordost.streetcomplete.quests.show_poi + +import android.os.Bundle +import android.view.View +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem + +class ShowMachineAnswerForm : AbstractOsmQuestForm() { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (element.tags["amenity"] != "vending_machine") return + val vending = element.tags["vending"] ?: return + setTitle(resources.getString((questType as OsmElementQuestType<*>).getTitle(element.tags)) + " $vending") + } + +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowOther.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowOther.kt new file mode 100644 index 00000000000..b5bd46533ce --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowOther.kt @@ -0,0 +1,71 @@ +package de.westnordost.streetcomplete.quests.show_poi + +import android.content.Context +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.NoAnswerFragment +import de.westnordost.streetcomplete.quests.getLabelOrElementSelectionDialog + +class ShowOther : OsmFilterQuestType() { + override val elementFilter = """ + nodes, ways, relations with + ( + playground + or historic + or club + or information and information !~ office + or tourism ~ viewpoint|artwork|wilderness_hut + or summit:cross = yes + or """.trimIndent() + + + mapOf( + "amenity" to arrayOf( + "place_of_worship", "toilets", "prison", "fire_station", "police", "ranger_station", + "townhall", "courthouse", "embassy", "community_centre", "youth_centre", "library", + "monastery", "kindergarten", "school", "college", "university", "research_institute", + "drinking_water", "shower", "post_box", "bbq", "grit_bin", "clock", "hunting_stand", + "fountain", "public_bookcase" + ), + "leisure" to arrayOf( + "sports_centre", "stadium", "marina", "horse_riding", + "dance", "nature_reserve", "pitch", "playground", "fitness_station" + ), + "landuse" to arrayOf( + "cemetery", "allotments" + ), + "military" to arrayOf( + "airfield", "barracks", "training_area" + ), + "emergency" to arrayOf( + "fire_hydrant", "defibrillator", "phone", "life_ring", + "fire_extinguisher", "water_tank", "suction_point" + ), + "man_made" to arrayOf( + "communications_tower", "cross", "flagpole", "insect_hotel", "mast", "obelisk", + "tower", "water_tap", "water_tower", "water_well" + ) + ).map { it.key + " ~ " + it.value.joinToString("|") }.joinToString("\n or ") + + "\n)" + + override val changesetComment = "Adjust public POIs and similar" + override val wikiLink = "nope" + override val icon = R.drawable.ic_quest_poi_other + override val dotColor = "gold" + override val defaultDisabledMessage = R.string.default_disabled_msg_poi_other + + override fun getTitle(tags: Map) = R.string.quest_poi_misc_title + + override fun createForm() = NoAnswerFragment() + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter(filter) + + override fun applyAnswerTo(answer: Boolean, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) {} + + override fun getQuestSettingsDialog(context: Context) = getLabelOrElementSelectionDialog(context, this, prefs) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowRecycling.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowRecycling.kt new file mode 100644 index 00000000000..1a0f72fdc30 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowRecycling.kt @@ -0,0 +1,43 @@ +package de.westnordost.streetcomplete.quests.show_poi + +import android.content.Context +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.getLabelOrElementSelectionDialog +import de.westnordost.streetcomplete.quests.getLabelSources + +class ShowRecycling : OsmFilterQuestType() { + override val elementFilter = """ + nodes, ways, relations with + amenity ~ recycling|waste_basket|waste_disposal|waste_transfer_station|sanitary_dump_station + """ + override val changesetComment = "Adjust recycling related elements" + override val wikiLink = "Key:amenity=recycling" + override val icon = R.drawable.ic_quest_poi_recycling + override val dotColor = "green" + override val defaultDisabledMessage = R.string.default_disabled_msg_poi_recycling + override val dotLabelSources = getLabelSources( "", this, prefs) + + override fun getTitle(tags: Map) = + R.string.quest_poi_recycling_title + + override fun createForm() = ShowRecyclingAnswerForm() + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter(filter) + + override fun applyAnswerTo(answer: Boolean, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + if (answer) { + tags["amenity"] = "vending_machine" + tags["vending"] = "excrement_bags" + tags["bin"] = "yes" + } + } + + override fun getQuestSettingsDialog(context: Context) = getLabelOrElementSelectionDialog(context, this, prefs) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowRecyclingAnswerForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowRecyclingAnswerForm.kt new file mode 100644 index 00000000000..512227af112 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowRecyclingAnswerForm.kt @@ -0,0 +1,31 @@ +package de.westnordost.streetcomplete.quests.show_poi + +import android.os.Bundle +import android.view.View +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem + +class ShowRecyclingAnswerForm : AbstractOsmQuestForm() { + + override val buttonPanelAnswers = mutableListOf() + + override fun onCreate(savedInstanceState: android.os.Bundle?) { + super.onCreate(savedInstanceState) + if (element.tags["amenity"] == "waste_basket") + buttonPanelAnswers.add(AnswerItem(R.string.quest_recycling_excrement_bag_dispenser) { applyAnswer(true) }) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val recycling = element.tags.mapNotNull { + if (it.value == "yes" && it.key.startsWith("recycling:")) + it.key.substringAfter("recycling:") + else null + }.sorted().joinToString(", ") + if (recycling.isNotEmpty()) + setTitle(resources.getString((questType as OsmElementQuestType<*>).getTitle(element.tags)) + " $recycling") + } + +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowSeating.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowSeating.kt new file mode 100644 index 00000000000..c27f764fb70 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowSeating.kt @@ -0,0 +1,40 @@ +package de.westnordost.streetcomplete.quests.show_poi + +import android.content.Context +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.NoAnswerFragment +import de.westnordost.streetcomplete.quests.getLabelOrElementSelectionDialog +import de.westnordost.streetcomplete.quests.getLabelSources + +class ShowSeating : OsmFilterQuestType() { + override val elementFilter = """ + nodes, ways, relations with + amenity ~ bench|lounger|table + or leisure ~ picnic_table|bleachers + or tourism = picnic_site + """ + override val changesetComment = "Adjust benches and similar" + override val wikiLink = "Tag:amenity=bench" + override val icon = R.drawable.ic_quest_poi_seating + override val dotColor = "chocolate" + override val defaultDisabledMessage = R.string.default_disabled_msg_poi_bench + override val dotLabelSources = getLabelSources( "", this, prefs) + + override fun getTitle(tags: Map) = + R.string.quest_poi_seating_title + + override fun createForm() = NoAnswerFragment() + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter(filter) + + override fun applyAnswerTo(answer: Boolean, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) {} + + override fun getQuestSettingsDialog(context: Context) = getLabelOrElementSelectionDialog(context, this, prefs) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowTrafficStuff.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowTrafficStuff.kt new file mode 100644 index 00000000000..dcb36358337 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowTrafficStuff.kt @@ -0,0 +1,47 @@ +package de.westnordost.streetcomplete.quests.show_poi + +import android.content.Context +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.getLabelOrElementSelectionDialog +import de.westnordost.streetcomplete.quests.getLabelSources + +class ShowTrafficStuff : OsmFilterQuestType() { + override val elementFilter = """ + nodes, ways with + barrier and barrier !~ wall|fence|retaining_wall|hedge + or traffic_calming + or traffic_sign + or crossing + or entrance + or public_transport + or highway ~ crossing|stop|give_way|elevator|traffic_signals|turning_circle + or amenity ~ taxi|parking|parking_entrance|motorcycle_parking + """ + + override val changesetComment = "Adjust traffic related elements" + override val wikiLink = "Key:traffic_calming" + override val icon = R.drawable.ic_quest_poi_traffic + override val dotColor = "deepskyblue" + override val defaultDisabledMessage = R.string.default_disabled_msg_poi_traffic + override val dotLabelSources = getLabelSources( "", this, prefs) + + override fun getTitle(tags: Map) = R.string.quest_poi_traffic_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter(filter) + + override fun createForm() = ShowTrafficStuffAnswerForm() + + override fun applyAnswerTo(answer: Boolean, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + if (answer) + tags["traffic_calming"] = "table" + } + + override fun getQuestSettingsDialog(context: Context) = getLabelOrElementSelectionDialog(context, this, prefs) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowTrafficStuffAnswerForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowTrafficStuffAnswerForm.kt new file mode 100644 index 00000000000..0b2220d354c --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowTrafficStuffAnswerForm.kt @@ -0,0 +1,29 @@ +package de.westnordost.streetcomplete.quests.show_poi + +import android.os.Bundle +import android.view.View +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem + +class ShowTrafficStuffAnswerForm : AbstractOsmQuestForm() { + + override val buttonPanelAnswers = mutableListOf() + + override fun onCreate(savedInstanceState: android.os.Bundle?) { + super.onCreate(savedInstanceState) + if (element.tags["traffic_calming"] == null && element.tags["crossing"] != null) + buttonPanelAnswers.add(AnswerItem(R.string.quest_traffic_stuff_raised) { applyAnswer(true) }) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if ((!element.tags["crossing"].isNullOrBlank() && !element.tags["traffic_calming"].isNullOrBlank()) + || element.tags["type"] == "restriction" + || element.tags["highway"] == "elevator") { + setTitle(resources.getString((questType as OsmElementQuestType<*>).getTitle(element.tags)) + " ${element.tags.entries}") + } + } + +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowVacant.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowVacant.kt new file mode 100644 index 00000000000..4860628a89b --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/show_poi/ShowVacant.kt @@ -0,0 +1,51 @@ +package de.westnordost.streetcomplete.quests.show_poi + +import android.content.Context +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.applyReplacePlaceTo +import de.westnordost.streetcomplete.osm.isPlace +import de.westnordost.streetcomplete.osm.updateCheckDate +import de.westnordost.streetcomplete.quests.getLabelOrElementSelectionDialog +import de.westnordost.streetcomplete.quests.getLabelSources +import de.westnordost.streetcomplete.quests.shop_type.IsShopVacant +import de.westnordost.streetcomplete.quests.shop_type.ShopType +import de.westnordost.streetcomplete.quests.shop_type.ShopTypeAnswer +import de.westnordost.streetcomplete.quests.shop_type.ShopTypeForm + +class ShowVacant : OsmFilterQuestType() { + override val elementFilter = """ + nodes, ways, relations with + shop = vacant + or disused:shop + or disused:amenity + or disused:office + """ + override val changesetComment = "Adjust vacant places" + override val wikiLink = "Key:disused:" + override val icon = R.drawable.ic_quest_poi_vacant + override val dotColor = "grey" + override val defaultDisabledMessage = R.string.default_disabled_msg_poi_vacant + override val dotLabelSources = getLabelSources("label", this, prefs) + + override fun getTitle(tags: Map) = + R.string.quest_poi_vacant_title + + override fun createForm() = ShopTypeForm() + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().asSequence().filter { it.isPlace() } + + override fun applyAnswerTo(answer: ShopTypeAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + when (answer) { + is IsShopVacant -> tags.updateCheckDate() + is ShopType -> answer.feature.applyReplacePlaceTo(tags) + } + } + + override fun getQuestSettingsDialog(context: Context) = getLabelOrElementSelectionDialog(context, this, prefs) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/sidewalk/AddSidewalk.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/sidewalk/AddSidewalk.kt index b3ee1597f31..7374b49603c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/sidewalk/AddSidewalk.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/sidewalk/AddSidewalk.kt @@ -1,5 +1,7 @@ package de.westnordost.streetcomplete.quests.sidewalk +import androidx.appcompat.app.AlertDialog +import android.content.Context import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry @@ -16,6 +18,8 @@ import de.westnordost.streetcomplete.osm.sidewalk.any import de.westnordost.streetcomplete.osm.sidewalk.applyTo import de.westnordost.streetcomplete.osm.sidewalk.parseSidewalkSides import de.westnordost.streetcomplete.osm.surface.UNPAVED_SURFACES +import de.westnordost.streetcomplete.quests.questPrefix +import de.westnordost.streetcomplete.quests.singleTypeElementSelectionDialog class AddSidewalk : OsmElementQuestType { override val changesetComment = "Specify whether roads have sidewalks" @@ -50,57 +54,72 @@ class AddSidewalk : OsmElementQuestType { override fun applyAnswerTo(answer: LeftAndRightSidewalk, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { answer.applyTo(tags) } -} -// streets that may have sidewalk tagging -private val roadsFilter by lazy { """ - ways with - ( - ( - highway ~ trunk|trunk_link|primary|primary_link|secondary|secondary_link|tertiary|tertiary_link|unclassified|residential|service|busway - and motorroad != yes - and expressway != yes - and foot != no - ) - or - ( - highway ~ motorway|motorway_link|trunk|trunk_link|primary|primary_link|secondary|secondary_link|tertiary|tertiary_link|unclassified|residential|service|busway - and (foot ~ yes|designated or bicycle ~ yes|designated) - ) - ) - and area != yes - and access !~ private|no -""".toElementFilterExpression() } + // streets that may have sidewalk tagging + private val roadsFilter by lazy { """ + ways with + ( + ( + highway ~ trunk|trunk_link|primary|primary_link|secondary|secondary_link|tertiary|tertiary_link|unclassified|residential|service|busway + and motorroad != yes + and expressway != yes + and foot != no + ) + or + ( + highway ~ motorway|motorway_link|trunk|trunk_link|primary|primary_link|secondary|secondary_link|tertiary|tertiary_link|unclassified|residential|service|busway + and (foot ~ yes|designated or bicycle ~ yes|designated) + ) + ) + and area != yes + and access !~ private|no + """.toElementFilterExpression() } + + // streets that do not have sidewalk tagging yet + /* the filter additionally filters out ways that are unlikely to have sidewalks: + * + * + unpaved roads + * + roads that are probably not developed enough to have sidewalk (i.e. country roads) + * + roads with a very low speed limit + * + Also, anything explicitly tagged as no pedestrians or explicitly tagged that the sidewalk + * is mapped as a separate way OR that is tagged with that the cycleway is separate. If the + * cycleway is separate, the sidewalk is too for sure + */ + private val untaggedRoadsFilter by lazy { """ + ways with + highway ~ ${prefs.getString(questPrefix(prefs) + PREF_SIDEWALK_HIGHWAY_SELECTION, ROADS_WITH_SIDEWALK.joinToString("|"))} + and (!sidewalk or sidewalk = none) and !sidewalk:both and !sidewalk:left and !sidewalk:right + and (!maxspeed or maxspeed > 9 or maxspeed ~ [A-Z].*) + and surface !~ ${UNPAVED_SURFACES.joinToString("|")} + and ( + lit = yes + or highway = residential + or ~"${(MAXSPEED_TYPE_KEYS + "maxspeed").joinToString("|")}" ~ ".*:(urban|.*zone.*|nsl_restricted)" + or maxspeed <= 60 + or (foot ~ yes|designated and highway ~ motorway|motorway_link|trunk|trunk_link|primary|primary_link|secondary|secondary_link) + ) + and ~foot|bicycle|bicycle:backward|bicycle:forward !~ use_sidepath + and ~cycleway|cycleway:left|cycleway:right|cycleway:both !~ separate + """.toElementFilterExpression() } + override val hasQuestSettings = true -// streets that do not have sidewalk tagging yet -/* the filter additionally filters out ways that are unlikely to have sidewalks: - * - * + unpaved roads - * + roads that are probably not developed enough to have sidewalk (i.e. country roads) - * + roads with a very low speed limit - * + Also, anything explicitly tagged as no pedestrians or explicitly tagged that the sidewalk - * is mapped as a separate way OR that is tagged with that the cycleway is separate. If the - * cycleway is separate, the sidewalk is too for sure - */ -private val untaggedRoadsFilter by lazy { """ - ways with - highway ~ motorway|motorway_link|trunk|trunk_link|primary|primary_link|secondary|secondary_link|tertiary|tertiary_link|unclassified|residential - and !sidewalk and !sidewalk:both and !sidewalk:left and !sidewalk:right - and (!maxspeed or maxspeed > 9 or maxspeed ~ [A-Z].*) - and surface !~ ${UNPAVED_SURFACES.joinToString("|")} - and ( - lit = yes - or highway = residential - or ~"${(MAXSPEED_TYPE_KEYS + "maxspeed").joinToString("|")}" ~ ".*:(urban|.*zone.*|nsl_restricted)" - or maxspeed <= 60 - or (foot ~ yes|designated and highway ~ motorway|motorway_link|trunk|trunk_link|primary|primary_link|secondary|secondary_link) - ) - and ~foot|bicycle|bicycle:backward|bicycle:forward !~ use_sidepath - and ~cycleway|cycleway:left|cycleway:right|cycleway:both !~ separate -""".toElementFilterExpression() } + // min distance selection or element selection + override fun getQuestSettingsDialog(context: Context): AlertDialog = + singleTypeElementSelectionDialog(context, + prefs, + questPrefix(prefs) + PREF_SIDEWALK_HIGHWAY_SELECTION, + ROADS_WITH_SIDEWALK.joinToString("|"), + R.string.quest_settings_eligible_highways) +} private fun Element.hasInvalidOrIncompleteSidewalkTags(): Boolean { val sides = parseSidewalkSides(tags) ?: return false if (sides.any { it == INVALID || it == null }) return true return false } + +private val ROADS_WITH_SIDEWALK = arrayOf( + "motorway","motorway_link","trunk","trunk_link","primary","primary_link","secondary", + "secondary_link","tertiary","tertiary_link","unclassified","residential") + +private const val PREF_SIDEWALK_HIGHWAY_SELECTION = "qs_AddSidewalk_highway_selection" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/AddPathSmoothness.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/AddPathSmoothness.kt index 901b62399d3..aa33b5485ee 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/AddPathSmoothness.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/AddPathSmoothness.kt @@ -1,7 +1,11 @@ package de.westnordost.streetcomplete.quests.smoothness import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Way import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.BICYCLIST import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.WHEELCHAIR @@ -35,6 +39,15 @@ class AddPathSmoothness : OsmFilterQuestType() { override fun createForm() = AddSmoothnessForm() + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry): Sequence { + val nodes = (element as Way).nodeIds + return getMapData().nodes.asSequence().filter { it.id in nodes && barrierFilter.matches(it) } + } + + private val barrierFilter by lazy { + "nodes with barrier or traffic_calming or (kerb and kerb !~ no|flush)".toElementFilterExpression() + } + override fun applyAnswerTo(answer: SmoothnessAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { answer.applyTo(tags) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/AddRoadSmoothness.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/AddRoadSmoothness.kt index 6a05008f898..421e13a8ba2 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/AddRoadSmoothness.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/AddRoadSmoothness.kt @@ -1,7 +1,11 @@ package de.westnordost.streetcomplete.quests.smoothness import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Way import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.BICYCLIST import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.CAR @@ -38,6 +42,15 @@ class AddRoadSmoothness : OsmFilterQuestType() { override fun createForm() = AddSmoothnessForm() + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry): Sequence { + val nodes = (element as Way).nodeIds + return getMapData().nodes.asSequence().filter { it.id in nodes && barrierFilter.matches(it) } + } + + private val barrierFilter by lazy { + "nodes with barrier or traffic_calming".toElementFilterExpression() + } + override fun applyAnswerTo(answer: SmoothnessAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { if (answer is IsActuallyStepsAnswer) throw IllegalStateException() answer.applyTo(tags) diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/AddSmoothnessForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/AddSmoothnessForm.kt index c8afe6601e9..cc5b0c210d0 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/AddSmoothnessForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/AddSmoothnessForm.kt @@ -9,6 +9,7 @@ import android.view.View import androidx.annotation.DrawableRes import androidx.appcompat.app.AlertDialog import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType import de.westnordost.streetcomplete.osm.surface.Surface import de.westnordost.streetcomplete.osm.surface.asItem import de.westnordost.streetcomplete.quests.AImageListQuestForm @@ -20,14 +21,19 @@ import de.westnordost.streetcomplete.view.image_select.ItemViewHolder class AddSmoothnessForm : AImageListQuestForm() { override val otherAnswers get() = listOfNotNull( - AnswerItem(R.string.quest_smoothness_wrong_surface) { surfaceWrong() }, + if (Surface.values().find { it.osmValue == surfaceTag } != null) + AnswerItem(R.string.quest_smoothness_wrong_surface) { surfaceWrong() } + else null, createConvertToStepsAnswer(), AnswerItem(R.string.quest_smoothness_obstacle) { showObstacleHint() } ) private val surfaceTag get() = element.tags["surface"] - override val items get() = Smoothness.entries.toItems(requireContext(), surfaceTag!!) + override val items get() = if (surfaceTag in SURFACES_FOR_SMOOTHNESS) + Smoothness.entries.toItems(requireContext(), surfaceTag!!) + else + Smoothness.entries.toGenericItems(requireContext()) override val itemsPerRow = 1 @@ -46,6 +52,9 @@ class AddSmoothnessForm : AImageListQuestForm() { stringBuilder.replaceEmojiWithImageSpan(context, "🚗", R.drawable.ic_smoothness_car) stringBuilder.replaceEmojiWithImageSpan(context, "🚙", R.drawable.ic_smoothness_suv) setHint(stringBuilder) + + + setTitle(resources.getString((questType as OsmElementQuestType<*>).getTitle(element.tags)) + " (${element.tags["surface"]})") } override val moveFavoritesToFront = false @@ -68,6 +77,10 @@ class AddSmoothnessForm : AImageListQuestForm() { } private fun showWrongSurfaceDialog(surface: Surface) { + if (dontShowAgain) { + applyAnswer(WrongSurfaceAnswer, true) + return + } val inflater = LayoutInflater.from(requireContext()) val inner = inflater.inflate(R.layout.dialog_quest_smoothness_wrong_surface, null, false) ItemViewHolder(inner.findViewById(R.id.item_view)).bind(surface.asItem()) @@ -75,18 +88,26 @@ class AddSmoothnessForm : AImageListQuestForm() { AlertDialog.Builder(requireContext()) .setView(inner) .setPositiveButton(R.string.quest_generic_hasFeature_yes_leave_note) { _, _ -> composeNote() } - .setNegativeButton(R.string.quest_generic_hasFeature_no) { _, _ -> applyAnswer(WrongSurfaceAnswer) } + .setNegativeButton(R.string.quest_generic_hasFeature_no) { _, _ -> applyAnswer(WrongSurfaceAnswer, true) } + .setNeutralButton(R.string.dialog_session_dont_show_again) { _, _ -> + dontShowAgain = true + applyAnswer(WrongSurfaceAnswer, true) + } .show() } private fun createConvertToStepsAnswer(): AnswerItem? = if (element.couldBeSteps()) { AnswerItem(R.string.quest_generic_answer_is_actually_steps) { - applyAnswer(IsActuallyStepsAnswer) + applyAnswer(IsActuallyStepsAnswer, true) } } else { null } + + companion object { + private var dontShowAgain = false + } } private fun SpannableStringBuilder.replaceEmojiWithImageSpan( diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/SmoothnessItem.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/SmoothnessItem.kt index 858eecc82d2..c2709460518 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/SmoothnessItem.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/smoothness/SmoothnessItem.kt @@ -23,6 +23,15 @@ import de.westnordost.streetcomplete.view.image_select.Item2 fun Iterable.toItems(context: Context, surface: String) = mapNotNull { it.asItem(context, surface) } +fun Iterable.toGenericItems(context: Context) = + mapNotNull { it.asGenericItem(context) } + +fun Smoothness.asGenericItem(context: Context): DisplayItem? = + when(this) { + EXCELLENT, GOOD -> { asItem(context, "asphalt") } + else -> { asItem(context, "gravel") } + } + // return null if not a valid combination fun Smoothness.asItem(context: Context, surface: String): DisplayItem? { val imageResId = getImageResId(surface) ?: return null @@ -72,7 +81,7 @@ val Smoothness.titleResId get() = when (this) { } fun Smoothness.getDescriptionResId(surface: String): Int? = when (surface) { - "asphalt", "concrete", "concrete:plates" -> pavedDescriptionResId + "asphalt", "chipseal", "concrete", "concrete:plates" -> pavedDescriptionResId "sett" -> settDescriptionResId "paving_stones" -> pavingStonesDescriptionResId "compacted", "gravel", "fine_gravel" -> compactedOrGravelDescriptionResId @@ -87,7 +96,7 @@ private val Smoothness.descriptionResIdFallback: Int? get() = when (this) { } fun Smoothness.getImageResId(surface: String): Int? = when (surface) { - "asphalt" -> asphaltImageResId + "asphalt", "chipseal" -> asphaltImageResId "concrete", "concrete:plates" -> concreteImageResId "sett" -> settImageResId "paving_stones" -> pavingStonesImageResId diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/step_count/AddStepCount.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/step_count/AddStepCount.kt index 3572c0855b8..ff680c6be38 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/step_count/AddStepCount.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/step_count/AddStepCount.kt @@ -1,20 +1,28 @@ package de.westnordost.streetcomplete.quests.step_count +import android.content.Context import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry -import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.PEDESTRIAN import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.data.osm.geometry.ElementPolylinesGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.quests.numberSelectionDialog +import de.westnordost.streetcomplete.quests.questPrefix +import de.westnordost.streetcomplete.util.math.measuredLength -class AddStepCount : OsmFilterQuestType() { +class AddStepCount : OsmElementQuestType { - override val elementFilter = """ + val elementFilter by lazy { """ ways with highway = steps and (!indoor or indoor = no) and access !~ private|no and (!conveying or conveying = no) and !step_count - """ + """.toElementFilterExpression() } override val changesetComment = "Specify step counts" override val wikiLink = "Key:step_count" override val icon = R.drawable.ic_quest_steps_count @@ -24,9 +32,32 @@ class AddStepCount : OsmFilterQuestType() { override fun getTitle(tags: Map) = R.string.quest_step_count_title + override fun isApplicableTo(element: Element): Boolean? { + if (!elementFilter.matches(element)) return false + return null + } + + override fun getApplicableElements(mapData: MapDataWithGeometry): Iterable { + return mapData.filter { element -> + if (!elementFilter.matches(element)) return@filter false + val geometry = mapData.getWayGeometry(element.id) as? ElementPolylinesGeometry + val totalLength = geometry?.polylines?.sumOf { it.measuredLength() } ?: return@filter true + totalLength <= prefs.getInt(questPrefix(prefs) + PREF_MAX_STEPS_LENGTH, 999) + } + } + override fun createForm() = AddStepCountForm() override fun applyAnswerTo(answer: Int, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { tags["step_count"] = answer.toString() } + + override val hasQuestSettings = true + + override fun getQuestSettingsDialog(context: Context) = numberSelectionDialog( + context, prefs, questPrefix(prefs) + PREF_MAX_STEPS_LENGTH, 999, R.string.quest_settings_max_steps_length + ) + } + +private const val PREF_MAX_STEPS_LENGTH = "qs_AddStepCount_max_length" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/street_cabinet/AddStreetCabinetType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/street_cabinet/AddStreetCabinetType.kt new file mode 100644 index 00000000000..1ee30e40eb7 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/street_cabinet/AddStreetCabinetType.kt @@ -0,0 +1,40 @@ +package de.westnordost.streetcomplete.quests.street_cabinet + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags + +class AddStreetCabinetType : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + man_made = street_cabinet + and !street_cabinet + and !utility + """ + override val changesetComment = "Add street cabinet type" + override val wikiLink = "Tag:man_made=street_cabinet" + override val icon = R.drawable.ic_quest_street_cabinet + override val defaultDisabledMessage: Int = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_street_cabinet_type_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter(""" + nodes, ways with + ( + man_made = street_cabinet + or building ~ service|transformer_tower + ) + """) + + override fun createForm() = AddStreetCabinetTypeForm() + + override fun applyAnswerTo(answer: StreetCabinetType, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags[answer.osmKey] = answer.osmValue + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/street_cabinet/AddStreetCabinetTypeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/street_cabinet/AddStreetCabinetTypeForm.kt new file mode 100644 index 00000000000..7b0d17dcef7 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/street_cabinet/AddStreetCabinetTypeForm.kt @@ -0,0 +1,28 @@ +package de.westnordost.streetcomplete.quests.street_cabinet + +import android.os.Bundle +import android.view.View +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType +import de.westnordost.streetcomplete.quests.AImageListQuestForm + +class AddStreetCabinetTypeForm : AImageListQuestForm() { + + override val items = StreetCabinetType.values().map { it.asItem() } + override val itemsPerRow = 4 + override val moveFavoritesToFront = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imageSelector.cellLayoutId = R.layout.cell_icon_select_with_label_below + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + element.tags["operator"]?.let { setTitle(resources.getString((questType as OsmElementQuestType<*>).getTitle(element.tags)) + " ($it)") } + } + + override fun onClickOk(selectedItems: List) { + applyAnswer(selectedItems.single()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/street_cabinet/StreetCabinetType.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/street_cabinet/StreetCabinetType.kt new file mode 100644 index 00000000000..5cf87bce025 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/street_cabinet/StreetCabinetType.kt @@ -0,0 +1,16 @@ +package de.westnordost.streetcomplete.quests.street_cabinet + +enum class StreetCabinetType(val osmKey: String, val osmValue: String) { + POWER("utility", "power"), + TELECOM("utility", "telecom"), + TRAFFIC_CONTROL("street_cabinet", "traffic_control"), + POSTAL_SERVICE("street_cabinet", "postal_service"), + GAS("utility", "gas"), + STREET_LIGHTING("utility", "street_lighting"), + TRANSPORT_MANAGEMENT("street_cabinet", "transport_management"), + TRAFFIC_MONITORING("street_cabinet", "traffic_monitoring"), + WASTE("street_cabinet", "waste"), + TELEVISION("utility", "television"), + WATER("utility", "water"), + SEWERAGE("utility", "sewerage"); +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/street_cabinet/StreetCabinetTypeItem.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/street_cabinet/StreetCabinetTypeItem.kt new file mode 100644 index 00000000000..9ed51e0841b --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/street_cabinet/StreetCabinetTypeItem.kt @@ -0,0 +1,37 @@ +package de.westnordost.streetcomplete.quests.street_cabinet + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.street_cabinet.StreetCabinetType.* +import de.westnordost.streetcomplete.view.image_select.Item + +fun StreetCabinetType.asItem() = Item(this, iconResId, titleResId) + +private val StreetCabinetType.titleResId: Int get() = when (this) { + POWER -> R.string.quest_utility_power + TELECOM -> R.string.quest_utility_telecom + POSTAL_SERVICE -> R.string.quest_street_cabinet_postal_service + TRAFFIC_CONTROL -> R.string.quest_street_cabinet_traffic_control + TRAFFIC_MONITORING -> R.string.quest_street_cabinet_traffic_monitoring + TRANSPORT_MANAGEMENT -> R.string.quest_street_cabinet_transport_management + WASTE -> R.string.quest_street_cabinet_waste + TELEVISION -> R.string.quest_street_cabinet_television + GAS -> R.string.quest_utility_gas + STREET_LIGHTING -> R.string.quest_street_cabinet_street_lighting + WATER -> R.string.quest_utility_water + SEWERAGE -> R.string.quest_utility_sewerage +} + +private val StreetCabinetType.iconResId: Int get() = when (this) { + POWER -> R.drawable.quest_street_cabinet_power + TELECOM -> R.drawable.quest_street_cabinet_telecom + POSTAL_SERVICE -> R.drawable.quest_street_cabinet_postal_service + TRAFFIC_CONTROL -> R.drawable.quest_street_cabinet_traffic_control + TRAFFIC_MONITORING -> R.drawable.quest_street_cabinet_traffic_monitoring + TRANSPORT_MANAGEMENT -> R.drawable.quest_street_cabinet_transport_management + WASTE -> R.drawable.quest_street_cabinet_waste + TELEVISION -> R.drawable.quest_street_cabinet_television + GAS -> R.drawable.quest_street_cabinet_gas + STREET_LIGHTING -> R.drawable.quest_street_cabinet_street_lighting + WATER -> R.drawable.quest_street_cabinet_water + SEWERAGE -> R.drawable.quest_street_cabinet_sewerage +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddPathSurface.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddPathSurface.kt index cb35917f99f..e8b1189bef4 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddPathSurface.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddPathSurface.kt @@ -1,5 +1,7 @@ package de.westnordost.streetcomplete.quests.surface +import androidx.appcompat.app.AlertDialog +import android.content.Context import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType @@ -11,11 +13,14 @@ import de.westnordost.streetcomplete.osm.Tags import de.westnordost.streetcomplete.osm.changeToSteps import de.westnordost.streetcomplete.osm.surface.INVALID_SURFACES import de.westnordost.streetcomplete.osm.surface.applyTo +import de.westnordost.streetcomplete.quests.booleanQuestSettingsDialog +import de.westnordost.streetcomplete.quests.questPrefix +import de.westnordost.streetcomplete.quests.singleTypeElementSelectionDialog class AddPathSurface : OsmFilterQuestType() { override val elementFilter = """ - ways with highway ~ path|footway|cycleway|bridleway|steps + ways with highway ~ ${prefs.getString("${questPrefix(prefs)}qs_${name}_highway_selection", HIGHWAY_TYPES)} and segregated != yes and access !~ private|no and (!conveying or conveying = no) @@ -56,4 +61,11 @@ class AddPathSurface : OsmFilterQuestType() { } } } + + override val hasQuestSettings = true + + override fun getQuestSettingsDialog(context: Context): AlertDialog = + singleTypeElementSelectionDialog(context, prefs, "${questPrefix(prefs)}qs_${name}_highway_selection", HIGHWAY_TYPES, R.string.quest_settings_eligible_highways) } + +private const val HIGHWAY_TYPES = "path|footway|cycleway|bridleway|steps" diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddPathSurfaceForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddPathSurfaceForm.kt index 44d6395d9e9..425df0aa14b 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddPathSurfaceForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddPathSurfaceForm.kt @@ -26,7 +26,7 @@ class AddPathSurfaceForm : AImageListQuestForm( private fun createConvertToStepsAnswer(): AnswerItem? = if (element.couldBeSteps()) { AnswerItem(R.string.quest_generic_answer_is_actually_steps) { - applyAnswer(IsActuallyStepsAnswer) + applyAnswer(IsActuallyStepsAnswer, true) } } else { null @@ -37,7 +37,7 @@ class AddPathSurfaceForm : AImageListQuestForm( if (way.tags["indoor"] == "yes") return null return AnswerItem(R.string.quest_generic_answer_is_indoors) { - applyAnswer(IsIndoorsAnswer) + applyAnswer(IsIndoorsAnswer, true) } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddRoadSurface.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddRoadSurface.kt index f68a4c311d1..175d290b405 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddRoadSurface.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddRoadSurface.kt @@ -1,5 +1,7 @@ package de.westnordost.streetcomplete.quests.surface +import androidx.appcompat.app.AlertDialog +import android.content.Context import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType @@ -11,17 +13,15 @@ import de.westnordost.streetcomplete.osm.surface.INVALID_SURFACES_FOR_TRACKTYPES import de.westnordost.streetcomplete.osm.surface.Surface import de.westnordost.streetcomplete.osm.surface.UNPAVED_SURFACES import de.westnordost.streetcomplete.osm.surface.applyTo +import de.westnordost.streetcomplete.quests.booleanQuestSettingsDialog +import de.westnordost.streetcomplete.quests.fullElementSelectionDialog +import de.westnordost.streetcomplete.quests.questPrefix class AddRoadSurface : OsmFilterQuestType() { override val elementFilter = """ ways with ( - highway ~ ${listOf( - "primary", "primary_link", "secondary", "secondary_link", "tertiary", "tertiary_link", - "unclassified", "residential", "living_street", "pedestrian", "track", "busway", - ).joinToString("|") - } - or highway = service and service !~ driveway|slipway + ${prefs.getString("${questPrefix(prefs)}qs_${name}_element_selection", highwaySelection)} ) and ( !surface @@ -58,4 +58,18 @@ class AddRoadSurface : OsmFilterQuestType() { override fun applyAnswerTo(answer: Surface, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { answer.applyTo(tags) } + + override val hasQuestSettings = true + + override fun getQuestSettingsDialog(context: Context): AlertDialog = + fullElementSelectionDialog(context, prefs, "${questPrefix(prefs)}qs_${name}_element_selection", R.string.quest_settings_element_selection, highwaySelection) } + +private val highwaySelection = """ + highway ~ ${listOf( + "primary", "primary_link", "secondary", "secondary_link", "tertiary", "tertiary_link", + "unclassified", "residential", "living_street", "pedestrian", "track", "busway" +).joinToString("|") +} + or highway = service and service !~ driveway|slipway +""".trimIndent() diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddSidewalkSurface.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddSidewalkSurface.kt index 19ed845769f..ea9a8bf93bc 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddSidewalkSurface.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddSidewalkSurface.kt @@ -2,6 +2,9 @@ package de.westnordost.streetcomplete.quests.surface import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.PEDESTRIAN import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.WHEELCHAIR @@ -39,6 +42,16 @@ class AddSidewalkSurface : OsmFilterQuestType() { override fun createForm() = AddSidewalkSurfaceForm() + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter(""" + ways with ( + highway ~ cycleway|path + or highway ~ footway|bridleway and bicycle ~ yes|designated + ) + and bicycle !~ no|private + and access !~ no|private + """) + override fun applyAnswerTo(answer: SidewalkSurfaceAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { when (answer) { is SidewalkIsDifferent -> { diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddSidewalkSurfaceForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddSidewalkSurfaceForm.kt index 870a9ebfc20..8c41f772f77 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddSidewalkSurfaceForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/surface/AddSidewalkSurfaceForm.kt @@ -23,7 +23,7 @@ class AddSidewalkSurfaceForm : AStreetSideSelectForm> = SELECTABLE_WAY_SURFACES.toItems() override val otherAnswers = listOf( - AnswerItem(R.string.quest_sidewalk_answer_different) { applyAnswer(SidewalkIsDifferent) } + AnswerItem(R.string.quest_sidewalk_answer_different) { applyAnswer(SidewalkIsDifferent, true) } ) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/swimming_pool_availability/AddSwimmingPoolAvailability.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/swimming_pool_availability/AddSwimmingPoolAvailability.kt new file mode 100644 index 00000000000..7b577e3d44a --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/swimming_pool_availability/AddSwimmingPoolAvailability.kt @@ -0,0 +1,47 @@ +package de.westnordost.streetcomplete.quests.swimming_pool_availability + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.osm.isPlaceOrDisusedPlace +import de.westnordost.streetcomplete.osm.updateWithCheckDate + +class AddSwimmingPoolAvailability : OsmFilterQuestType() { + + override val elementFilter = """ + nodes, ways with + ( + leisure = resort + or (leisure = sports_hall and sport = swimming) + or tourism ~ camp_site|hotel + ) + and !swimming_pool + """ + override val changesetComment = "Survey whether places have a swimming pool" + override val wikiLink = "Key:swimming_pool" + override val icon = R.drawable.ic_quest_swimming_pool + override val isReplacePlaceEnabled = true + override val defaultDisabledMessage = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_swimmingPoolAvailability_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter(""" + nodes, ways with + ( + leisure ~ resort|swimming_pool + or (leisure = sports_hall and sport = swimming) + or tourism ~ camp_site|hotel + ) + """) + + override fun createForm() = AddSwimmingPoolAvailabilityForm() + + override fun applyAnswerTo(answer: SwimmingPoolAvailability, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags.updateWithCheckDate("swimming_pool", answer.osmValue) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/swimming_pool_availability/AddSwimmingPoolAvailabilityForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/swimming_pool_availability/AddSwimmingPoolAvailabilityForm.kt new file mode 100644 index 00000000000..1a76526bfb3 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/swimming_pool_availability/AddSwimmingPoolAvailabilityForm.kt @@ -0,0 +1,18 @@ +package de.westnordost.streetcomplete.quests.swimming_pool_availability + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AListQuestForm +import de.westnordost.streetcomplete.quests.TextItem +import de.westnordost.streetcomplete.quests.swimming_pool_availability.SwimmingPoolAvailability.INDOOR_AND_OUTDOOR +import de.westnordost.streetcomplete.quests.swimming_pool_availability.SwimmingPoolAvailability.NO +import de.westnordost.streetcomplete.quests.swimming_pool_availability.SwimmingPoolAvailability.ONLY_INDOOR +import de.westnordost.streetcomplete.quests.swimming_pool_availability.SwimmingPoolAvailability.ONLY_OUTDOOR + +class AddSwimmingPoolAvailabilityForm : AListQuestForm() { + override val items = listOf( + TextItem(INDOOR_AND_OUTDOOR, R.string.quest_swimming_pool_indoor_and_outdoor), + TextItem(ONLY_INDOOR, R.string.quest_swimming_pool_indoor_only), + TextItem(ONLY_OUTDOOR, R.string.quest_swimming_pool_outdoor_only), + TextItem(NO, R.string.quest_swimming_pool_no), + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/swimming_pool_availability/SwimmingPoolAvailability.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/swimming_pool_availability/SwimmingPoolAvailability.kt new file mode 100644 index 00000000000..b7bc654f17a --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/swimming_pool_availability/SwimmingPoolAvailability.kt @@ -0,0 +1,8 @@ +package de.westnordost.streetcomplete.quests.swimming_pool_availability + +enum class SwimmingPoolAvailability(val osmValue: String) { + NO("no"), + ONLY_INDOOR("indoor"), + ONLY_OUTDOOR("outdoor"), + INDOOR_AND_OUTDOOR("indoor;outdoor"), +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/trail_visibility/AddTrailVisibility.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/trail_visibility/AddTrailVisibility.kt new file mode 100644 index 00000000000..729e02c67dc --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/trail_visibility/AddTrailVisibility.kt @@ -0,0 +1,37 @@ +package de.westnordost.streetcomplete.quests.trail_visibility + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags + +class AddTrailVisibility : OsmFilterQuestType() { + + override val elementFilter = """ + ways with + highway ~ path|footway|cycleway|bridleway + and !trail_visibility + and ( access !~ no|private or foot ~ yes|permissive|designated or bicycle ~ yes|permissive|designated) + and (sac_scale and sac_scale != hiking) + and (!lit or lit = no) + and surface ~ "ground|earth|dirt|soil|grass|sand|mud|ice|salt|snow|rock|stone" + """ + override val changesetComment = "Specify Trail Visibility" + override val wikiLink = "Key:trail_visibility" + override val icon = R.drawable.ic_quest_trail_visibility + override val defaultDisabledMessage = R.string.default_disabled_msg_trail_visibility + + override fun getTitle(tags: Map) = R.string.quest_trail_visibility_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("ways with highway and trail_visibility") + + override fun createForm() = AddTrailVisibilityForm() + + override fun applyAnswerTo(answer: TrailVisibility, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["trail_visibility"] = answer.osmValue + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/trail_visibility/AddTrailVisibilityForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/trail_visibility/AddTrailVisibilityForm.kt new file mode 100644 index 00000000000..76b9bc8e7c1 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/trail_visibility/AddTrailVisibilityForm.kt @@ -0,0 +1,30 @@ +package de.westnordost.streetcomplete.quests.trail_visibility + +import android.os.Bundle +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AImageListQuestForm +import de.westnordost.streetcomplete.view.image_select.DisplayItem + +class AddTrailVisibilityForm : AImageListQuestForm() { + + override val items: List> get() = listOf( + TrailVisibility.EXCELLENT, + TrailVisibility.GOOD, + TrailVisibility.INTERMEDIATE, + TrailVisibility.BAD, + TrailVisibility.HORRIBLE, + TrailVisibility.NO + ).toItems() + + override val itemsPerRow = 2 + override val moveFavoritesToFront = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imageSelector.cellLayoutId = R.layout.cell_labeled_icon_select_trail_visibility + } + + override fun onClickOk(selectedItems: List) { + applyAnswer(selectedItems.first()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/trail_visibility/TrailVisibility.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/trail_visibility/TrailVisibility.kt new file mode 100644 index 00000000000..43f6095b65c --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/trail_visibility/TrailVisibility.kt @@ -0,0 +1,39 @@ +package de.westnordost.streetcomplete.quests.trail_visibility + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.trail_visibility.TrailVisibility.* +import de.westnordost.streetcomplete.view.image_select.GroupableDisplayItem +import de.westnordost.streetcomplete.view.image_select.Item + +enum class TrailVisibility(val osmValue: String) { + EXCELLENT("excellent"), + GOOD("good"), + INTERMEDIATE("intermediate"), + BAD("bad"), + HORRIBLE("horrible"), + NO("no") +} +fun Collection.toItems() = map { it.asItem() } + +fun TrailVisibility.asItem(): GroupableDisplayItem { + return Item(this, titleId = titleResId, descriptionId = descriptionResId) +} + +private val TrailVisibility.titleResId: Int get() = when (this) { + EXCELLENT -> R.string.quest_trail_visibility_excellent + GOOD -> R.string.quest_trail_visibility_good + INTERMEDIATE -> R.string.quest_trail_visibility_intermediate + BAD -> R.string.quest_trail_visibility_bad + HORRIBLE -> R.string.quest_trail_visibility_horrible + NO -> R.string.quest_trail_visibility_no +} + +private val TrailVisibility.descriptionResId: Int? get() = when (this) { + EXCELLENT -> R.string.quest_trail_visibility_excellent_description + GOOD -> R.string.quest_trail_visibility_good_description + INTERMEDIATE -> R.string.quest_trail_visibility_intermediate_description + BAD -> R.string.quest_trail_visibility_bad_description + HORRIBLE -> R.string.quest_trail_visibility_horrible_description + NO -> R.string.quest_trail_visibility_no_description + else -> null +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/tree/AddTreeGenus.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/tree/AddTreeGenus.kt new file mode 100644 index 00000000000..8b07ee5b962 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/tree/AddTreeGenus.kt @@ -0,0 +1,112 @@ +package de.westnordost.streetcomplete.quests.tree + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.AlertDialog +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.quests.custom.readFromUriToExternalFile +import de.westnordost.streetcomplete.quests.custom.writeFromExternalFileToUri +import de.westnordost.streetcomplete.util.ktx.getActivity +import java.io.File + +class AddTreeGenus : OsmFilterQuestType() { + + override val elementFilter = """ + nodes with + natural = tree + and !genus and !species and !taxon + and !~"genus:.*" and !~"species:.*" and !~"taxon:.*" + """ + override val changesetComment = "Add tree genus/species" + override val defaultDisabledMessage = R.string.quest_tree_disabled_msg + override val wikiLink = "Key:genus" + override val icon = R.drawable.ic_quest_tree + + override fun getTitle(tags: Map) = R.string.quest_tree_genus_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("nodes with natural = tree") + + override fun createForm() = AddTreeGenusForm() + + override val isDeleteElementEnabled = true + + override fun applyAnswerTo(answer: TreeAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + when (answer) { + is NotTreeButStump -> tags["natural"] = "tree_stump" + is Tree -> { + if (answer.isSpecies) + tags["species"] = answer.name + else + tags["genus"] = answer.name + } + } + } + + @Composable + override fun QuestSettings(context: Context, onDismissRequest: () -> Unit) { + val file = File(context.getExternalFilesDir(null), FILENAME_TREES) + val activity = LocalContext.current.getActivity()!! + val importIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "text/comma-separated-values" + } + val exportIntent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_TITLE, FILENAME_TREES) + type = "text/comma-separated-values" + } + val importFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode != Activity.RESULT_OK || it.data == null) + return@rememberLauncherForActivityResult + val uri = it.data?.data ?: return@rememberLauncherForActivityResult + readFromUriToExternalFile(uri, file.name, activity) + onDismissRequest() + } + val exportFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode != Activity.RESULT_OK || it.data == null) + return@rememberLauncherForActivityResult + val uri = it.data?.data ?: return@rememberLauncherForActivityResult + writeFromExternalFileToUri(file.name, uri, activity) + onDismissRequest() + } + AlertDialog( + onDismissRequest = onDismissRequest, + buttons = { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + TextButton({ importFileLauncher.launch(importIntent) }) { + Text(stringResource(R.string.tree_custom_quest_import)) + } + if (file.exists()) + TextButton({ exportFileLauncher.launch(exportIntent) }) { + Text(stringResource(R.string.tree_custom_quest_export)) + } + TextButton(onDismissRequest) { Text(stringResource(android.R.string.cancel)) } + } + TextButton({ super.getQuestSettingsDialog(context)?.show(); onDismissRequest() }) { + Text(stringResource(R.string.element_selection_button)) + } + }, + title = { Text(stringResource(R.string.pref_trees_title)) }, + text = { Text(stringResource(R.string.tree_custom_quest_import_export_message)) } + ) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/tree/AddTreeGenusForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/tree/AddTreeGenusForm.kt new file mode 100644 index 00000000000..cbc272bbcc5 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/tree/AddTreeGenusForm.kt @@ -0,0 +1,161 @@ +package de.westnordost.streetcomplete.quests.tree + +import android.content.Context +import android.os.Bundle +import android.view.View +import androidx.core.view.doOnLayout +import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osm.mapdata.Node +import de.westnordost.streetcomplete.databinding.QuestNameSuggestionBinding +import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm +import de.westnordost.streetcomplete.quests.AnswerItem +import de.westnordost.streetcomplete.screens.main.map.getTreeGenus +import de.westnordost.streetcomplete.util.SearchAdapter +import de.westnordost.streetcomplete.util.ktx.dpToPx +import de.westnordost.streetcomplete.util.math.distanceTo +import de.westnordost.streetcomplete.util.math.enclosingBoundingBox +import de.westnordost.streetcomplete.util.takeFavourites +import org.koin.android.ext.android.inject +import java.io.File +import java.io.IOException +import java.text.Normalizer +import java.util.Locale +import java.util.regex.Pattern + +class AddTreeGenusForm : AbstractOsmQuestForm() { + + override val contentLayoutResId = R.layout.quest_name_suggestion + private val binding by contentViewBinding(QuestNameSuggestionBinding::bind) + private val name: String get() = binding.nameInput.text?.toString().orEmpty().trim() + private val mapDataSource: MapDataWithEditsSource by inject() + private val trees get() = loadTrees() + + override fun onClickOk() { + val tree = getSelectedTree() + if (tree == null) { + binding.nameInput.error = context?.resources?.getText(R.string.quest_tree_error) + } else { + prefs.addLastPicked(javaClass.simpleName, "${tree.isSpecies}§${tree.name}") + applyAnswer(tree) + } + } + + override fun isFormComplete(): Boolean { + return name.isNotEmpty() + } + + override val otherAnswers = listOf( + AnswerItem(R.string.quest_leafType_tree_is_just_a_stump) { + applyAnswer(NotTreeButStump, true) + }, + ) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val adapter = SearchAdapter(requireContext(), { getTrees(it) }, { it.toDisplayString() }) + binding.nameInput.setAdapter(adapter) + binding.nameInput.doAfterTextChanged { checkIsFormComplete() } + // set some blank input + // this is the only way I found how to display recent answers + binding.nameInput.setOnFocusChangeListener { _, focused -> + if (focused) binding.nameInput.setText(" ", true) + } + binding.nameInput.doOnLayout { binding.nameInput.dropDownWidth = binding.nameInput.width - requireContext().resources.dpToPx(60).toInt() } + binding.nameInput.requestFocus() + } + + override fun onClickMapAt(position: LatLon, clickAreaSizeInMeters: Double): Boolean { + val maxDist = clickAreaSizeInMeters + 5 + val bbox = position.enclosingBoundingBox(maxDist) + val mapData = mapDataSource.getMapDataWithGeometry(bbox) + var bestTree: Pair? = null + + mapData.forEach { element -> + if (element is Node && element.tags["natural"] == "tree") { + val name = getTreeGenus(element.tags) ?: return@forEach + val distance = element.position.distanceTo(position) + if (distance < (bestTree?.second ?: maxDist)) + bestTree = Pair(name, distance) + } + } + bestTree?.let { binding.nameInput.setText(getTrees(it.first).firstOrNull()?.toDisplayString() ?: "not found", false) } + + return true + } + + private fun getSelectedTree(): Tree? { + val input = binding.nameInput.text.toString() + return getTrees(input).firstOrNull { canonicalize(it.toDisplayString()) == canonicalize(input) } + } + + private fun getTrees(fullSearch: String): List { + val search = fullSearch.trim() + // not working, i need a tree with the same name and species, but local name? + if (search.isEmpty()) return lastPickedAnswers.mapNotNull { answer -> + val treeString = answer.split('§') + trees.firstOrNull { it.name == treeString[1] && it.isSpecies == (treeString[0] == "true") } + } + return trees.filter { tree -> + tree.toDisplayString() == search + || tree.toDisplayString().startsWith(search, true) + || tree.name == search + || tree.name.split(" ").any { it.startsWith(search, true) } + || tree.localName?.contains(search, true) == true + //sorting: genus-only first, then prefer trees with localName + }.sortedBy { it.localName == null }.sortedBy { it.isSpecies } + } + + private val lastPickedAnswers by lazy { + prefs.getLastPicked(javaClass.simpleName).takeFavourites(20, 50, 1) + } + + private fun loadTrees(): Set { + if (treeSet.isNotEmpty()) return treeSet + val c = context ?: return emptySet() + // load from file, assuming format: () + // assume species if it contains a space character + try { + c.getExternalFilesDir(null)?.let { dir -> + treeSet.addAll(File(dir, FILENAME_TREES).readLines().mapNotNull { it.toTree(it.substringBefore(" (").contains(" ")) }) + } + } catch (_: IOException) { } // file may not exist, so an exception is no surprise + + try { + c.assets.open("tree/otherDataGenus.txt").bufferedReader().lineSequence().mapNotNullTo(treeSet) { it.toTree(false) } + c.assets.open("tree/otherDataSpecies.txt").bufferedReader().lineSequence().mapNotNullTo(treeSet) { it.toTree(true) } + c.assets.open("tree/osmGenus.txt").bufferedReader().lineSequence().mapNotNullTo(treeSet) { it.toTree(false) } + c.assets.open("tree/osmSpecies.txt").bufferedReader().lineSequence().mapNotNullTo(treeSet) { it.toTree(true) } + } catch (_: IOException) { } + return treeSet + } + + companion object { + private val treeSet = mutableSetOf() + } + +} + +private fun String.toTree(isSpecies: Boolean): Tree? { + val line = trim() + if (line.isBlank()) return null + val localName = if (line.contains(" (") && line.contains(')')) + line.substringAfter("(").substringBeforeLast(")") + else null + return Tree(line.substringBefore(" (").intern(), isSpecies, localName) +} + +const val FILENAME_TREES = "trees.csv" + +private val FIND_DIACRITICS: Pattern = Pattern.compile("\\p{InCombiningDiacriticalMarks}+") + +private fun canonicalize(str: String): String? { + return stripDiacritics(str).lowercase(Locale.US) +} + +private fun stripDiacritics(str: String): String { + return FIND_DIACRITICS.matcher(Normalizer.normalize(str, Normalizer.Form.NFD)).replaceAll("") +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/tree/Tree.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/tree/Tree.kt new file mode 100644 index 00000000000..a466b818ec2 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/tree/Tree.kt @@ -0,0 +1,28 @@ +package de.westnordost.streetcomplete.quests.tree + +sealed interface TreeAnswer + +class Tree(val name: String, val isSpecies: Boolean, val localName: String?): TreeAnswer { + + // should be equal if name and isSpecies are the same, don't care about localName + override fun hashCode(): Int { + var hash = 7 + hash = 31 * hash + name.hashCode() + hash = 31 * hash + isSpecies.hashCode() + return hash + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Tree) return false + if (name != other.name) return false + if (isSpecies != other.isSpecies) return false + return true + } + + fun toDisplayString() = + if (localName != null) "$name ($localName)" + else name +} + +data object NotTreeButStump : TreeAnswer diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/valves/AddValves.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/valves/AddValves.kt new file mode 100644 index 00000000000..9a04683830c --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/valves/AddValves.kt @@ -0,0 +1,39 @@ +package de.westnordost.streetcomplete.quests.valves + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.BICYCLIST +import de.westnordost.streetcomplete.osm.Tags + +class AddValves : OsmFilterQuestType>() { + + override val elementFilter = """ + nodes, ways with + (compressed_air = yes + or service:bicycle:pump = yes + or amenity = compressed_air) + and access !~ private|no + and !valves + """ + override val changesetComment = "Specify valves types for air pumps or compressed air" + override val wikiLink = "Key:valves" + override val icon = R.drawable.ic_quest_valve + override val isDeleteElementEnabled = true + override val achievements = listOf(BICYCLIST) + override val defaultDisabledMessage = R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_valves_title + + override fun createForm() = AddValvesForm() + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("nodes, ways with amenity = compressed_air or service:bicycle:pump = yes or compressed_air = yes") + + override fun applyAnswerTo(answer: List, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["valves"] = answer.joinToString(";") { it.osmValue } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/valves/AddValvesForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/valves/AddValvesForm.kt new file mode 100644 index 00000000000..fc62dd4d008 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/valves/AddValvesForm.kt @@ -0,0 +1,23 @@ +package de.westnordost.streetcomplete.quests.valves + +import android.os.Bundle +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.osm.lane_narrowing_traffic_calming.asItem +import de.westnordost.streetcomplete.quests.AImageListQuestForm + +class AddValvesForm : AImageListQuestForm>() { + + override val items get() = Valves.entries.map { it.asItem() } + override val itemsPerRow = 2 + override val maxSelectableItems = -1 + override val moveFavoritesToFront = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imageSelector.cellLayoutId = R.layout.cell_icon_select_with_label_below + } + + override fun onClickOk(selectedItems: List) { + applyAnswer(selectedItems) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/valves/Valves.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/valves/Valves.kt new file mode 100644 index 00000000000..c181ab9a13b --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/valves/Valves.kt @@ -0,0 +1,8 @@ +package de.westnordost.streetcomplete.quests.valves + +enum class Valves(val osmValue: String) { + SCHRADER("schrader"), + SCLAVERAND("sclaverand"), + DUNLOP("dunlop"), + REGINA("regina"); +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/valves/ValvesItem.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/valves/ValvesItem.kt new file mode 100644 index 00000000000..d06f9f43d5b --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/valves/ValvesItem.kt @@ -0,0 +1,22 @@ +package de.westnordost.streetcomplete.quests.valves + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.valves.Valves.* +import de.westnordost.streetcomplete.view.image_select.DisplayItem +import de.westnordost.streetcomplete.view.image_select.Item + +fun Valves.asItem() = Item(this, iconResId, titleResId) + +private val Valves.titleResId: Int get() = when (this) { + SCHRADER -> R.string.quest_valves_schrader + SCLAVERAND -> R.string.quest_valves_sclaverand + DUNLOP -> R.string.quest_valves_dunlop + REGINA -> R.string.quest_valves_regina +} + +private val Valves.iconResId: Int get() = when (this) { + SCHRADER -> R.drawable.valves_schrader + SCLAVERAND -> R.drawable.valves_presta + DUNLOP -> R.drawable.valves_dunlop + REGINA -> R.drawable.valves_regina +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/via_ferrata_scale/AddViaFerrataScale.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/via_ferrata_scale/AddViaFerrataScale.kt new file mode 100644 index 00000000000..f444bacdafc --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/via_ferrata_scale/AddViaFerrataScale.kt @@ -0,0 +1,33 @@ +package de.westnordost.streetcomplete.quests.via_ferrata_scale + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.filter +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.osm.Tags + +class AddViaFerrataScale : OsmFilterQuestType() { + + override val elementFilter = """ + ways with + highway = via_ferrata + and !via_ferrata_scale + """ + override val changesetComment = "Specify Via Ferrata Grade Scale" + override val wikiLink = "Key:via_ferrata_scale" + override val icon = R.drawable.ic_quest_via_ferrata_scale + override val defaultDisabledMessage = R.string.default_disabled_msg_viaFerrataScale + + override fun getTitle(tags: Map) = R.string.quest_viaFerrataScale_title + + override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) = + getMapData().filter("ways with highway = via_ferrata") + + override fun createForm() = AddViaFerrataScaleForm() + + override fun applyAnswerTo(answer: ViaFerrataScale, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + tags["via_ferrata_scale"] = answer.osmValue + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/via_ferrata_scale/AddViaFerrataScaleForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/via_ferrata_scale/AddViaFerrataScaleForm.kt new file mode 100644 index 00000000000..ae9c323fd43 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/via_ferrata_scale/AddViaFerrataScaleForm.kt @@ -0,0 +1,33 @@ +package de.westnordost.streetcomplete.quests.via_ferrata_scale + +import android.os.Bundle +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.AImageListQuestForm +import de.westnordost.streetcomplete.view.image_select.DisplayItem + +class AddViaFerrataScaleForm : AImageListQuestForm() { + + override val items: List> get() = listOf( + ViaFerrataScale.ZERO, + ViaFerrataScale.ONE, + ViaFerrataScale.TWO, + ViaFerrataScale.THREE, + ViaFerrataScale.FOUR, + ViaFerrataScale.FIVE, + ViaFerrataScale.SIX + ).toItems() + + // optional: add quest_viaFerrataScale_hint text, but quest is already very long + + override val itemsPerRow = 1 + override val moveFavoritesToFront = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imageSelector.cellLayoutId = R.layout.cell_labeled_icon_select_via_ferrata_scale + } + + override fun onClickOk(selectedItems: List) { + applyAnswer(selectedItems.first()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/via_ferrata_scale/ViaFerrataScale.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/via_ferrata_scale/ViaFerrataScale.kt new file mode 100644 index 00000000000..5c00c6270c8 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/via_ferrata_scale/ViaFerrataScale.kt @@ -0,0 +1,52 @@ +package de.westnordost.streetcomplete.quests.via_ferrata_scale + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.quests.via_ferrata_scale.ViaFerrataScale.* +import de.westnordost.streetcomplete.view.image_select.GroupableDisplayItem +import de.westnordost.streetcomplete.view.image_select.Item + +enum class ViaFerrataScale(val osmValue: String) { + ZERO("0"), + ONE("1"), + TWO("2"), + THREE("3"), + FOUR("4"), + FIVE("5"), + SIX("6") +} +fun Collection.toItems() = map { it.asItem() } + +fun ViaFerrataScale.asItem(): GroupableDisplayItem { + return Item(this, imageResId, titleResId, descriptionResId) +} + +private val ViaFerrataScale.imageResId: Int get() = when (this) { + ZERO -> R.drawable.via_ferrata_scale_0 + ONE -> R.drawable.via_ferrata_scale_1 + TWO -> R.drawable.via_ferrata_scale_2 + THREE -> R.drawable.via_ferrata_scale_3 + FOUR -> R.drawable.via_ferrata_scale_4 + FIVE -> R.drawable.via_ferrata_scale_5 + SIX -> R.drawable.via_ferrata_scale_6 +} + +private val ViaFerrataScale.titleResId: Int get() = when (this) { + ZERO -> R.string.quest_viaFerrataScale_zero + ONE -> R.string.quest_viaFerrataScale_one + TWO -> R.string.quest_viaFerrataScale_two + THREE -> R.string.quest_viaFerrataScale_three + FOUR -> R.string.quest_viaFerrataScale_four + FIVE -> R.string.quest_viaFerrataScale_five + SIX -> R.string.quest_viaFerrataScale_six +} + +private val ViaFerrataScale.descriptionResId: Int? get() = when (this) { + ZERO -> R.string.quest_viaFerrataScale_zero_description + ONE -> R.string.quest_viaFerrataScale_one_description + TWO -> R.string.quest_viaFerrataScale_two_description + THREE -> R.string.quest_viaFerrataScale_three_description + FOUR -> R.string.quest_viaFerrataScale_four_description + FIVE -> R.string.quest_viaFerrataScale_five_description + SIX -> R.string.quest_viaFerrataScale_six_description + else -> null +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/way_lit/AddWayLit.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/way_lit/AddWayLit.kt index 62ea1f97112..7b0a4bb1d21 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/way_lit/AddWayLit.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/way_lit/AddWayLit.kt @@ -2,9 +2,10 @@ package de.westnordost.streetcomplete.quests.way_lit import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry -import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.PEDESTRIAN import de.westnordost.streetcomplete.osm.MAXSPEED_TYPE_KEYS +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.quest.DayNightCycle.ONLY_NIGHT import de.westnordost.streetcomplete.osm.Tags import de.westnordost.streetcomplete.osm.changeToSteps import de.westnordost.streetcomplete.osm.lit.applyTo @@ -35,13 +36,13 @@ class AddWayLit : OsmFilterQuestType() { or highway ~ ${LIT_WAYS.joinToString("|")} or highway = path and (foot = designated or bicycle = designated) ) + and (access !~ private|no or (foot and foot !~ private|no)) and ( !lit or lit = no and lit older today -8 years or lit older today -16 years ) - and (access !~ private|no or (foot and foot !~ private|no)) and indoor != yes and ~path|footway|cycleway !~ link """ @@ -51,6 +52,7 @@ class AddWayLit : OsmFilterQuestType() { override val icon = R.drawable.ic_quest_lantern override val achievements = listOf(PEDESTRIAN) override val defaultDisabledMessage = R.string.default_disabled_msg_overlay + override val dayNightCycle = ONLY_NIGHT override fun getTitle(tags: Map) = R.string.quest_lit_title diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessBusiness.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessBusiness.kt index 5e1c7eb973a..010a94be804 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessBusiness.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessBusiness.kt @@ -128,5 +128,12 @@ class AddWheelchairAccessBusiness : OsmFilterQuestType() { override fun applyAnswerTo(answer: WheelchairAccess, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { tags["wheelchair"] = answer.osmValue + answer.updatedDescriptions?.forEach { (language, description) -> + // language already contains the colon, or may be empty + if (description.isEmpty()) + tags.remove("wheelchair:description$language") + else + tags["wheelchair:description$language"] = description + } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessOutside.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessOutside.kt index b80bffce8b8..afa26f3c66a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessOutside.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessOutside.kt @@ -29,5 +29,12 @@ class AddWheelchairAccessOutside : OsmFilterQuestType() { override fun applyAnswerTo(answer: WheelchairAccess, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { tags.updateWithCheckDate("wheelchair", answer.osmValue) + answer.updatedDescriptions?.forEach { (language, description) -> + // language already contains the colon, or may be empty + if (description.isEmpty()) + tags.remove("wheelchair:description$language") + else + tags["wheelchair:description$language"] = description + } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessPublicTransport.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessPublicTransport.kt index 92e4b66de26..7c94c3df555 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessPublicTransport.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessPublicTransport.kt @@ -32,5 +32,12 @@ class AddWheelchairAccessPublicTransport : OsmFilterQuestType( override fun applyAnswerTo(answer: WheelchairAccess, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { tags.updateWithCheckDate("wheelchair", answer.osmValue) + answer.updatedDescriptions?.forEach { (language, description) -> + // language already contains the colon, or may be empty + if (description.isEmpty()) + tags.remove("wheelchair:description$language") + else + tags["wheelchair:description$language"] = description + } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessToilets.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessToilets.kt index 3321809c727..9a45237e609 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessToilets.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessToilets.kt @@ -33,5 +33,12 @@ class AddWheelchairAccessToilets : OsmFilterQuestType() { override fun applyAnswerTo(answer: WheelchairAccess, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { tags.updateWithCheckDate("wheelchair", answer.osmValue) + answer.updatedDescriptions?.forEach { (language, description) -> + // language already contains the colon, or may be empty + if (description.isEmpty()) + tags.remove("wheelchair:description$language") + else + tags["wheelchair:description$language"] = description + } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessToiletsPart.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessToiletsPart.kt index 4a2ca4cf4b1..f2166ff66ab 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessToiletsPart.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessToiletsPart.kt @@ -46,6 +46,13 @@ class AddWheelchairAccessToiletsPart : OsmFilterQuestType { tags.updateWithCheckDate("toilets:wheelchair", answer.access.osmValue) tags["toilets"] = "yes" + answer.access.updatedDescriptions?.forEach { (language, description) -> + // language already contains the colon, or may be empty + if (description.isEmpty()) + tags.remove("wheelchair:description$language") + else + tags["wheelchair:description$language"] = description + } } NoToilet -> { tags.updateWithCheckDate("toilets", "no") diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/WheelchairAccess.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/WheelchairAccess.kt index 9a6d76339ef..7754575422c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/WheelchairAccess.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/WheelchairAccess.kt @@ -1,6 +1,6 @@ package de.westnordost.streetcomplete.quests.wheelchair_access -enum class WheelchairAccess(val osmValue: String) { +enum class WheelchairAccess(val osmValue: String, var updatedDescriptions: Map? = null) { YES("yes"), LIMITED("limited"), NO("no"), diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/WheelchairAccessForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/WheelchairAccessForm.kt index cb8915ffa35..9fcd7446b69 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/WheelchairAccessForm.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/wheelchair_access/WheelchairAccessForm.kt @@ -1,17 +1,59 @@ package de.westnordost.streetcomplete.quests.wheelchair_access +import android.content.Context +import android.text.InputFilter +import android.widget.EditText +import android.widget.LinearLayout +import androidx.appcompat.app.AlertDialog import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.meta.CountryInfo +import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm import de.westnordost.streetcomplete.quests.AnswerItem import de.westnordost.streetcomplete.quests.wheelchair_access.WheelchairAccess.LIMITED import de.westnordost.streetcomplete.quests.wheelchair_access.WheelchairAccess.NO import de.westnordost.streetcomplete.quests.wheelchair_access.WheelchairAccess.YES +import de.westnordost.streetcomplete.util.dialogs.setViewWithDefaultPadding open class WheelchairAccessForm : AbstractOsmQuestForm() { override val buttonPanelAnswers = listOf( - AnswerItem(R.string.quest_generic_hasFeature_no) { applyAnswer(NO) }, - AnswerItem(R.string.quest_wheelchairAccess_limited) { applyAnswer(LIMITED) }, - AnswerItem(R.string.quest_generic_hasFeature_yes) { applyAnswer(YES) }, + AnswerItem(R.string.quest_generic_hasFeature_no) { applyAnswer(NO.apply { updatedDescriptions = descriptions }) }, + AnswerItem(R.string.quest_wheelchairAccess_limited) { applyAnswer(LIMITED.apply { updatedDescriptions = descriptions }) }, + AnswerItem(R.string.quest_generic_hasFeature_yes) { applyAnswer(YES.apply { updatedDescriptions = descriptions }) }, + ) + + private val descriptions = mutableMapOf() + + override fun isRejectingClose(): Boolean = descriptions.isNotEmpty() + + override val otherAnswers: List get() = listOf( + createAddDescriptionAnswer(element, descriptions, requireContext(), countryInfo) ) } + +fun createAddDescriptionAnswer(element: Element, descriptions: MutableMap, context: Context, countryInfo: CountryInfo) = + AnswerItem(R.string.quest_wheelchair_description_answer) { + val languages = (countryInfo.officialLanguages.map { ":$it" } + ":en" + "").toMutableSet() + val layout = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + val fields = languages.associateWith { + val e = EditText(context).apply { + hint = it.substringAfter(':').ifEmpty { context.getString(R.string.quest_wheelchair_description_no_language) } + element.tags["wheelchair:description$it"]?.let { setText(it) } + filters = arrayOf(InputFilter.LengthFilter(255)) + } + layout.addView(e) + e + } + AlertDialog.Builder(context) + .setTitle(R.string.quest_wheelchair_description_title) + .setViewWithDefaultPadding(layout) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _,_ -> + fields.forEach { (s, editText) -> + if (editText.text.toString().trim() != (element.tags["wheelchair:description$s"] ?: "")) + descriptions[s] = editText.text.toString() + } + } + .show() + } diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/width/AddFootwayWidth.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/width/AddFootwayWidth.kt new file mode 100644 index 00000000000..96ff53293c2 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/width/AddFootwayWidth.kt @@ -0,0 +1,61 @@ +package de.westnordost.streetcomplete.quests.width + +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmFilterQuestType +import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.PEDESTRIAN +import de.westnordost.streetcomplete.osm.Tags +import de.westnordost.streetcomplete.screens.measure.ArSupportChecker + +class AddFootwayWidth( + private val checkArSupport: ArSupportChecker +) : OsmFilterQuestType() { + + /* All either exclusive footways or ways that are cycleway + footway (or bridleway) but + * segregated */ + override val elementFilter = """ + ways with ( + ( + highway = footway + and footway !~ link|crossing + and bicycle !~ yes|designated + and (!width or source:width ~ ".*estimat.*") + ) or ( + segregated = yes + and ( + highway = cycleway and foot ~ yes|designated + or highway ~ path|footway and bicycle != no + or highway = bridleway and bicycle ~ designated|yes + ) + and (!footway:width or source:footway:width ~ ".*estimat.*") + ) + ) + and area != yes + and access !~ private|no + and placement != transition + and ~path|footway|cycleway|bridleway !~ link + """ + override val changesetComment = "Specify footway width" + override val wikiLink = "Key:width" + override val icon = R.drawable.ic_quest_footway_width + override val achievements = listOf(PEDESTRIAN) + override val defaultDisabledMessage: Int + get() = if (!checkArSupport()) R.string.default_disabled_msg_no_ar else R.string.default_disabled_msg_ee + + override fun getTitle(tags: Map) = R.string.quest_footway_width_title + + override fun createForm() = AddWidthForm() + + override fun applyAnswerTo(answer: WidthAnswer, tags: Tags, geometry: ElementGeometry, timestampEdited: Long) { + val isExclusive = tags["highway"] == "footway" && tags["bicycle"] != "yes" && tags["bicycle"] != "designated" + + val key = if (isExclusive) "width" else "footway:width" + + tags[key] = answer.width.toOsmValue() + if (answer.isARMeasurement) { + tags["source:$key"] = "ARCore" + } else { + tags.remove("source:$key") + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/width/AddRoadWidth.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/width/AddRoadWidth.kt index 3b3e1d456fd..ab5d26ea28f 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/quests/width/AddRoadWidth.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/quests/width/AddRoadWidth.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.quests.width +import android.content.Context import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry @@ -12,6 +13,8 @@ import de.westnordost.streetcomplete.osm.MAXSPEED_TYPE_KEYS import de.westnordost.streetcomplete.osm.ROADS_ASSUMED_TO_BE_PAVED import de.westnordost.streetcomplete.osm.Tags import de.westnordost.streetcomplete.osm.surface.PAVED_SURFACES +import de.westnordost.streetcomplete.quests.fullElementSelectionDialog +import de.westnordost.streetcomplete.quests.questPrefix import de.westnordost.streetcomplete.screens.measure.ArSupportChecker class AddRoadWidth( @@ -27,20 +30,7 @@ class AddRoadWidth( private val wayFilter by lazy { """ ways with ( - ( - highway ~ trunk|primary|secondary|tertiary|unclassified|residential|busway - and (lane_markings = no or lanes < 2) - ) or ( - highway = residential - and ( - maxspeed < 33 - or maxspeed = walk - or ~"${(MAXSPEED_TYPE_KEYS + "maxspeed").joinToString("|")}" ~ ".*:(zone)?:?([1-9]|[1-2][0-9]|30)" - ) - and lane_markings != yes and (!lanes or lanes < 2) - ) - or highway = living_street - or highway = service and service = alley + ${prefs.getString(questPrefix(prefs) + PREF_ROAD_WIDTH_ELEMENTS, ROAD_SELECTION)} ) and area != yes and (!width or source:width ~ ".*estimat.*") @@ -87,6 +77,30 @@ class AddRoadWidth( tags["width:carriageway"] = answer.width.toOsmValue() } } + + override val hasQuestSettings = true + + override fun getQuestSettingsDialog(context: Context) = + fullElementSelectionDialog(context, prefs, questPrefix(prefs) + PREF_ROAD_WIDTH_ELEMENTS, R.string.quest_settings_element_selection, ROAD_SELECTION.trimIndent()) } private val ROAD_NARROWERS = setOf("choker", "chicane", "choked_table") + +private val ROAD_SELECTION = """ + ( + highway ~ trunk|primary|secondary|tertiary|unclassified|residential|busway + and (lane_markings = no or lanes < 2) + ) or ( + highway = residential + and ( + maxspeed < 33 + or maxspeed = walk + or ~"${(MAXSPEED_TYPE_KEYS + "maxspeed").joinToString("|")}" ~ ".*:(zone)?:?([1-9]|[1-2][0-9]|30)" + ) + and lane_markings != yes and (!lanes or lanes < 2) + ) + or highway = living_street + or highway = service and service = alley +""" + +private const val PREF_ROAD_WIDTH_ELEMENTS = "qs_AddRoadWidth_element_selection" diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/about/AboutScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/about/AboutScreen.kt index fa4f74d119f..a7434ac8dc4 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/about/AboutScreen.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/about/AboutScreen.kt @@ -52,7 +52,7 @@ fun AboutScreen( Column(Modifier.fillMaxSize()) { TopAppBar( - title = { Text(stringResource(R.string.action_about2)) }, + title = { Text(stringResource(R.string.action_about2) + " SCEE") }, windowInsets = AppBarDefaults.topAppBarWindowInsets, navigationIcon = { IconButton(onClick = onClickBack) { BackIcon() } }, ) @@ -103,9 +103,14 @@ fun AboutScreen( onClick = { uriHandler.openUri("https://wiki.openstreetmap.org/wiki/StreetComplete/FAQ") }, ) { OpenInBrowserIcon() } + Preference( + name = "SCEE: " + stringResource(R.string.about_title_faq), + onClick = { uriHandler.openUri("https://wiki.openstreetmap.org/wiki/SCEE/FAQ") }, + ) { OpenInBrowserIcon() } + Preference( name = stringResource(R.string.about_title_report_error), - onClick = { uriHandler.openUri("https://github.com/streetcomplete/StreetComplete/issues") }, + onClick = { uriHandler.openUri("https://github.com/helium314/SCEE/issues") }, ) { OpenInBrowserIcon() } Preference( @@ -133,10 +138,20 @@ fun AboutScreen( ) ) { OpenInBrowserIcon() } + Preference( + name = "SCEE: " + stringResource(R.string.about_title_translate), + onClick = { uriHandler.openUri("https://translate.codeberg.org/projects/scee/") }, + ) { OpenInBrowserIcon() } + Preference( name = stringResource(R.string.about_title_repository), onClick = { uriHandler.openUri("https://github.com/streetcomplete/StreetComplete") }, ) { OpenInBrowserIcon() } + + Preference( + name = "SCEE " + stringResource(R.string.about_title_repository), + onClick = { uriHandler.openUri("https://github.com/Helium314/SCEE") }, + ) { OpenInBrowserIcon() } } PreferenceCategory(stringResource(R.string.about_category_feedback)) { @@ -150,7 +165,7 @@ fun AboutScreen( Preference( name = stringResource(R.string.about_title_feedback), - onClick = { uriHandler.openUri("https://github.com/streetcomplete/StreetComplete/discussions") }, + onClick = { uriHandler.openUri("https://github.com/Helium314/SCEE/discussions/") }, ) { OpenInBrowserIcon() } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/about/DonationsDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/about/DonationsDialog.kt index 2ec152fef85..dcedfbf76a4 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/about/DonationsDialog.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/about/DonationsDialog.kt @@ -54,19 +54,25 @@ fun DonationPlatformItems( ) { Column(modifier = modifier) { DonationPlatformItem( - title = "GitHub Sponsors", + title = "Liberapay (Helium314 / SCEE)", + icon = R.drawable.ic_liberapay, + url = "https://liberapay.com/helium314", + onClickLink + ) + DonationPlatformItem( + title = "GitHub Sponsors (westnordost)", icon = R.drawable.ic_github, url = "https://github.com/sponsors/westnordost", onClickLink ) DonationPlatformItem( - title = "Liberapay", + title = "Liberapay (westnordost)", icon = R.drawable.ic_liberapay, url = "https://liberapay.com/westnordost", onClickLink ) DonationPlatformItem( - title = "Patreon", + title = "Patreon (westnordost)", icon = R.drawable.ic_patreon, url = "https://patreon.com/westnordost", onClickLink diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/about/PrivacyStatementScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/about/PrivacyStatementScreen.kt index 76af1740855..c1573cf96e5 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/about/PrivacyStatementScreen.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/about/PrivacyStatementScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.upload.BANNED_VERSION_URL import de.westnordost.streetcomplete.ui.common.BackIcon import de.westnordost.streetcomplete.ui.common.HtmlText import de.westnordost.streetcomplete.util.html.tryParseHtml @@ -40,7 +41,7 @@ fun PrivacyStatementScreen( SelectionContainer { HtmlText( html = - tryParseHtml(stringResource(R.string.privacy_html)) + + tryParseHtml(stringResource(R.string.privacy_html).replace("https://www.westnordost.de/streetcomplete/banned_versions.txt", BANNED_VERSION_URL)) + tryParseHtml(stringResource(R.string.privacy_html_tileserver2, "JawgMaps", "https://www.jawg.io/en/confidentiality/")) + tryParseHtml(stringResource(R.string.privacy_html_statistics)) + tryParseHtml(stringResource(R.string.privacy_html_image_upload2)), diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainActivity.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainActivity.kt index 7bedd0292e9..41f8f231e48 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainActivity.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainActivity.kt @@ -2,19 +2,30 @@ package de.westnordost.streetcomplete.screens.main import android.annotation.SuppressLint import android.content.BroadcastReceiver +import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.SharedPreferences import android.content.IntentFilter +import android.content.ServiceConnection import android.content.pm.PackageManager import android.content.res.Configuration +import android.graphics.Color import android.graphics.PointF +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.drawable.LayerDrawable import android.location.Location import android.os.Bundle +import android.os.IBinder +import android.view.KeyEvent +import android.view.Menu import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.view.animation.AccelerateInterpolator import android.view.animation.OvershootInterpolator +import android.widget.ImageView import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.enableEdgeToEdge @@ -23,57 +34,90 @@ import androidx.annotation.DrawableRes import androidx.annotation.UiThread import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils import androidx.compose.ui.geometry.Offset import androidx.core.graphics.Insets import androidx.core.net.toUri +import androidx.core.os.ConfigurationCompat import androidx.core.os.bundleOf +import androidx.core.view.isGone +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.commit +import de.westnordost.countryboundaries.CountryBoundaries +import de.westnordost.osmfeatures.Feature import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager import de.westnordost.osmfeatures.FeatureDictionary +import de.westnordost.osmfeatures.GeometryType import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.StreetCompleteApplication import de.westnordost.streetcomplete.data.download.tiles.asBoundingBoxOfEnclosingTiles import de.westnordost.streetcomplete.data.edithistory.EditKey +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.osm.edits.ElementEditType import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChanges import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.geometry.ElementPolylinesGeometry import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.MutableMapDataWithGeometry import de.westnordost.streetcomplete.data.osm.mapdata.Node import de.westnordost.streetcomplete.data.osm.mapdata.Way +import de.westnordost.streetcomplete.data.osm.mapdata.isWayComplete +import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuest import de.westnordost.streetcomplete.data.osmnotes.edits.NotesWithEditsSource import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuest import de.westnordost.streetcomplete.data.osmtracks.Trackpoint +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuest +import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestController +import de.westnordost.streetcomplete.data.overlays.OverlayRegistry +import de.westnordost.streetcomplete.data.overlays.SelectedOverlayController import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.data.quest.OsmNoteQuestKey import de.westnordost.streetcomplete.data.quest.Quest import de.westnordost.streetcomplete.data.quest.QuestAutoSyncer import de.westnordost.streetcomplete.data.quest.QuestKey import de.westnordost.streetcomplete.data.quest.QuestType +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry import de.westnordost.streetcomplete.data.quest.VisibleQuestsSource import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenSource import de.westnordost.streetcomplete.databinding.ActivityMainBinding +import de.westnordost.streetcomplete.data.visiblequests.LevelFilter import de.westnordost.streetcomplete.databinding.EffectQuestPlopBinding +import de.westnordost.streetcomplete.osm.POPULAR_PLACE_FEATURE_IDS +import de.westnordost.streetcomplete.osm.isPlace import de.westnordost.streetcomplete.osm.level.levelsIntersect import de.westnordost.streetcomplete.osm.level.parseLevelsOrNull import de.westnordost.streetcomplete.overlays.AbstractOverlayForm import de.westnordost.streetcomplete.overlays.IsShowingElement +import de.westnordost.streetcomplete.overlays.custom.CustomOverlay import de.westnordost.streetcomplete.overlays.Overlay import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm import de.westnordost.streetcomplete.quests.AbstractQuestForm import de.westnordost.streetcomplete.quests.IsShowingQuestDetails import de.westnordost.streetcomplete.quests.LeaveNoteInsteadFragment +import de.westnordost.streetcomplete.quests.TagEditor +import de.westnordost.streetcomplete.quests.custom.CustomQuestList +import de.westnordost.streetcomplete.quests.custom.FILENAME_CUSTOM_QUEST +import de.westnordost.streetcomplete.quests.custom.readFromUriToExternalFile import de.westnordost.streetcomplete.quests.note_discussion.NoteDiscussionForm +import de.westnordost.streetcomplete.quests.tree.FILENAME_TREES import de.westnordost.streetcomplete.screens.BaseActivity import de.westnordost.streetcomplete.screens.main.bottom_sheet.CreateNoteFragment +import de.westnordost.streetcomplete.screens.main.bottom_sheet.CreatePoiFragment +import de.westnordost.streetcomplete.screens.main.bottom_sheet.InsertNodeFragment 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 @@ -91,8 +135,12 @@ import de.westnordost.streetcomplete.screens.main.map.getTitle import de.westnordost.streetcomplete.screens.main.map.maplibre.CameraPosition import de.westnordost.streetcomplete.screens.main.map.maplibre.toPadding import de.westnordost.streetcomplete.ui.util.content +import de.westnordost.streetcomplete.screens.settings.custom_geometry_changed +import de.westnordost.streetcomplete.screens.settings.gpx_track_changed import de.westnordost.streetcomplete.util.SoundFx import de.westnordost.streetcomplete.util.buildGeoUri +import de.westnordost.streetcomplete.util.getFakeCustomOverlays +import de.westnordost.streetcomplete.util.getSystemLocales import de.westnordost.streetcomplete.util.ktx.dpToPx import de.westnordost.streetcomplete.util.ktx.getLocationInWindow import de.westnordost.streetcomplete.util.ktx.hasLocationPermission @@ -100,21 +148,30 @@ import de.westnordost.streetcomplete.util.ktx.hideKeyboard import de.westnordost.streetcomplete.util.ktx.isLocationAvailable import de.westnordost.streetcomplete.util.ktx.observe import de.westnordost.streetcomplete.util.ktx.toLatLon +import de.westnordost.streetcomplete.util.ktx.toList import de.westnordost.streetcomplete.util.ktx.toast import de.westnordost.streetcomplete.util.ktx.truncateTo6Decimals import de.westnordost.streetcomplete.util.location.FineLocationManager import de.westnordost.streetcomplete.util.location.LocationAvailabilityReceiver import de.westnordost.streetcomplete.util.location.LocationRequestFragment +import de.westnordost.streetcomplete.util.logs.Log import de.westnordost.streetcomplete.util.math.area import de.westnordost.streetcomplete.util.math.enclosingBoundingBox import de.westnordost.streetcomplete.util.math.enlargedBy +import de.westnordost.streetcomplete.util.showOverlayCustomizer +import de.westnordost.streetcomplete.view.dialogs.SearchFeaturesDialog import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.qualifier.named import kotlin.math.PI +import kotlin.math.abs import kotlin.math.sqrt import kotlin.random.Random @@ -152,7 +209,9 @@ class MainActivity : VisibleQuestsSource.Listener, MapDataWithEditsSource.Listener, // rest - ShowsGeometryMarkers { + ShowsGeometryMarkers, + // we need the android preferences listener, because the new one can't to what is needed + SharedPreferences.OnSharedPreferenceChangeListener { private val questAutoSyncer: QuestAutoSyncer by inject() private val locationAvailabilityReceiver: LocationAvailabilityReceiver by inject() @@ -163,6 +222,13 @@ class MainActivity : private val questsHiddenSource: QuestsHiddenSource by inject() private val featureDictionary: Lazy by inject(named("FeatureDictionaryLazy")) private val soundFx: SoundFx by inject() + private val levelFilter: LevelFilter by inject() + private val countryBoundaries: Lazy by inject(named("CountryBoundariesLazy")) + private val questTypeRegistry: QuestTypeRegistry by inject() + private val overlayRegistry: OverlayRegistry by inject() + private val osmQuestController: OsmQuestController by inject() + private val selectedOverlaySource: SelectedOverlayController by inject() + private val customQuestList: CustomQuestList by inject() private lateinit var locationManager: FineLocationManager @@ -181,11 +247,19 @@ class MainActivity : private val bottomSheetFragment: Fragment? get() = supportFragmentManager.findFragmentByTag(BOTTOM_SHEET) + private var questMonitorJob: Job? = null + /* +++++++++++++++++++++++++++++++++++++++ CALLBACKS ++++++++++++++++++++++++++++++++++++++++ */ private val sheetBackPressedCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { - (bottomSheetFragment as IsCloseableBottomSheet).onClickClose { closeBottomSheet() } + val f = supportFragmentManager.fragments.lastOrNull() + if (f is IsCloseableBottomSheet) { + if (f != bottomSheetFragment && f is AbstractQuestForm) + f.onClickClose { TagEditor.changes = StringMapChanges(emptySet()) } + else if (f == bottomSheetFragment) + f.onClickClose { closeBottomSheet() } + } } } @@ -199,9 +273,28 @@ class MainActivity : //region Lifecycle - Android Lifecycle Callbacks + override fun onPause() { + super.onPause() + Log.i(TAG, "onPause") + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + Log.i(TAG, "onSaveInstanceState") + } + + override fun onDestroy() { + super.onDestroy() + Log.i(TAG, "onDestroy") + questMonitorJob?.cancel() + try { applicationContext.unbindService(questMonitorConnection) } + catch (_: IllegalArgumentException) {} + } + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + Log.i(TAG, "onCreate") if (savedInstanceState == null) { handleIntent(intent) @@ -226,6 +319,7 @@ class MainActivity : editHistoryViewModel = editHistoryViewModel, onClickZoomIn = ::onClickZoomIn, onClickZoomOut = ::onClickZoomOut, + onZoom = ::onZoom, onClickCompass = ::onClickCompassButton, onClickLocation = ::onClickLocationButton, onClickLocationPointer = ::onClickLocationPointer, @@ -247,7 +341,7 @@ class MainActivity : mapFragment?.highlightPins(edit.icon, listOf(edit.position)) mapFragment?.hideOverlay() } else if (editHistoryViewModel.isShowingSidebar.value) { - mapFragment?.clearFocus() + mapFragment?.endFocus() mapFragment?.clearHighlighting() } } @@ -270,6 +364,25 @@ class MainActivity : viewModel.isNavigationMode.value = mapFragment?.isNavigationMode ?: false } } + observe(viewModel.reverseQuestOrder) { + mapFragment?.setQuestOrder(it) + } + observe(viewModel.selectedOverlay) { + reloadOverlaySelector() + } + } + + override fun onResume() { + super.onResume() + Log.i(TAG, "onResume") + if (gpx_track_changed) { + mapFragment?.loadGpxTrack() + gpx_track_changed = false + } + if (custom_geometry_changed) { + mapFragment?.loadCustomGeometry() + custom_geometry_changed = false + } } override fun onStart() { @@ -277,11 +390,15 @@ class MainActivity : updateScreenOn() + Log.i(TAG, "onStart (add listeners)") + wasFollowingPosition = mapFragment?.isFollowingPosition // use value from mapFragment if already loaded visibleQuestsSource.addListener(this) mapDataWithEditsSource.addListener(this) locationAvailabilityReceiver.addListener(::updateLocationAvailability) - updateLocationAvailability(isLocationAvailable) + StreetCompleteApplication.preferences.registerOnSharedPreferenceChangeListener(this) + reloadOverlaySelector() + stopQuestMonitor() } override fun onNewIntent(intent: Intent) { @@ -291,8 +408,21 @@ class MainActivity : private fun handleIntent(intent: Intent) { if (intent.action != Intent.ACTION_VIEW) return - val data = intent.data?.toString() ?: return - viewModel.setUri(data) + val uri = intent.data ?: return + if (intent.type == "text/comma-separated-values") { + AlertDialog.Builder(this) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.pref_custom_title) { _, _ -> + readFromUriToExternalFile(uri, FILENAME_CUSTOM_QUEST, this) + customQuestList.reload() + visibleQuestsSource.clearCache() + } + .setNeutralButton(R.string.pref_trees_title) { _, _ -> + readFromUriToExternalFile(uri, FILENAME_TREES, this) + } + .show() + } + viewModel.setUri(uri.toString()) } override fun onConfigurationChanged(newConfig: Configuration) { @@ -302,18 +432,93 @@ class MainActivity : override fun onStop() { super.onStop() + Log.i(TAG, "onStop (remove listeners)") visibleQuestsSource.removeListener(this) mapDataWithEditsSource.removeListener(this) locationAvailabilityReceiver.removeListener(::updateLocationAvailability) locationManager.removeUpdates() + StreetCompleteApplication.preferences.unregisterOnSharedPreferenceChangeListener(this) + clearOverlaySelector() + startQuestMonitor() } //endregion + private fun clearOverlaySelector() = binding.overlayLayout.removeAllViews() + + private fun reloadOverlaySelector() { + if (!prefs.getBoolean(Prefs.OVERLAY_QUICK_SELECTOR, false)) { + binding.overlayScrollView.isGone = true + return + } + runOnUiThread { clearOverlaySelector() } + if (bottomSheetFragment == null) // always fill, but only show if no quest, overlay, etc... is showing + binding.overlayScrollView.isVisible = true + + val overlays = overlayRegistry.filter { + val eeAllowed = if (prefs.getBoolean(Prefs.EXPERT_MODE, false)) true + else overlayRegistry.getOrdinalOf(it)!! < ApplicationConstants.EE_QUEST_OFFSET + eeAllowed && it !is CustomOverlay + } + getFakeCustomOverlays(prefs, this.resources) + val params = ViewGroup.LayoutParams(resources.dpToPx(52).toInt(), resources.dpToPx(52).toInt()) + overlays.forEach { overlay -> + val view = ImageView(this) + val index = overlay.wikiLink?.toIntOrNull() + val isActive = selectedOverlaySource.selectedOverlay == overlay + || (selectedOverlaySource.selectedOverlay is CustomOverlay && index == prefs.getInt(Prefs.CUSTOM_OVERLAY_SELECTED_INDEX, 0)) + if (isActive) { + val ring = ContextCompat.getDrawable(this, R.drawable.pin_selection_ring)!! + val icon = ContextCompat.getDrawable(this, overlay.icon)!! + view.setImageDrawable(LayerDrawable(arrayOf(icon, ring))) + } else { + view.setImageResource(overlay.icon) + view.colorFilter = PorterDuffColorFilter(Color.LTGRAY, PorterDuff.Mode.MULTIPLY) + } + view.scaleX = 0.95f + view.scaleY = 0.95f + if (overlay.title == 0 && index != null) + view.setOnLongClickListener { + showOverlayCustomizer(index, this, prefs, questTypeRegistry, + { isCurrentCustomOverlay -> + lifecycleScope.launch(Dispatchers.IO) { + if (isCurrentCustomOverlay && selectedOverlaySource.selectedOverlay is CustomOverlay) { + selectedOverlaySource.selectedOverlay = null + delay(100) // need a rather long delay for this to work... + selectedOverlaySource.selectedOverlay = overlayRegistry.getByName(CustomOverlay::class.simpleName!!) + } + } + }, + { wasCurrentOverlay -> + if (wasCurrentOverlay && selectedOverlaySource.selectedOverlay is CustomOverlay) + selectedOverlaySource.selectedOverlay = null + }, + ) + true + } + view.setOnClickListener { + val oldOverlay = selectedOverlaySource.selectedOverlay + + // if active overlay was tapped, disable it + if (oldOverlay == overlay || (oldOverlay is CustomOverlay && index == prefs.getInt(Prefs.CUSTOM_OVERLAY_SELECTED_INDEX, 0))) + selectedOverlaySource.selectedOverlay = null + else + selectedOverlaySource.selectedOverlay = overlay + reloadOverlaySelector() + } + view.layoutParams = params + runOnUiThread { binding.overlayLayout.addView(view) } + } + } + /* ------------------------------- Preferences listeners ------------------------------------ */ + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) { + if (key != null && key.startsWith("custom_overlay") && key != Prefs.CUSTOM_OVERLAY_SELECTED_INDEX) + reloadOverlaySelector() + } + private fun updateScreenOn() { if (prefs.keepScreenOn) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) @@ -385,11 +590,12 @@ class MainActivity : } override fun onClickedMapAt(position: LatLon, clickAreaSizeInMeters: Double) { - val f = bottomSheetFragment + val f = supportFragmentManager.fragments.lastOrNull() if (f is IsCloseableBottomSheet) { - if (!f.onClickMapAt(position, clickAreaSizeInMeters)) { + if (f != bottomSheetFragment && f is AbstractQuestForm && !f.onClickMapAt(position, clickAreaSizeInMeters)) + f.onClickClose { TagEditor.changes = StringMapChanges(emptySet()) } + else if (f == bottomSheetFragment && !f.onClickMapAt(position, clickAreaSizeInMeters)) f.onClickClose { closeBottomSheet() } - } } else if (editHistoryViewModel.isShowingSidebar.value) { editHistoryViewModel.hideSidebar() } @@ -430,7 +636,15 @@ class MainActivity : override val metersPerPixel: Double? get() = mapFragment?.getMetersPerPixel() override fun onEdited(editType: ElementEditType, geometry: ElementGeometry) { + Log.i(TAG, "edited: ${editType.name}") showQuestSolvedAnimation(editType.icon, geometry.center) + if (editType is OsmElementQuestType<*> && prefs.getBoolean(Prefs.SHOW_NEXT_QUEST_IMMEDIATELY, false)) { + visibleQuestsSource.getAll(geometry.center.enclosingBoundingBox(1.0)) + .filterIsInstance() + .firstOrNull { it.geometry == geometry && it.type.dotColor == null } // this is not great, but we don't have key on the edited element any more + ?.let { runBlocking { lifecycleScope.launch { showQuestDetails(it) } } } + ?: closeBottomSheet() + } closeBottomSheet() } @@ -456,6 +670,18 @@ class MainActivity : override fun getPointOf(pos: LatLon): PointF? = mapFragment?.getPointOf(pos) + override fun onEditTags(element: Element, geometry: ElementGeometry, questKey: QuestKey?, editTypeName: String?) { + val f = TagEditor() + if (f.arguments == null) f.arguments = bundleOf() + val args = TagEditor.createArguments(element, geometry, mapFragment?.cameraPosition?.rotation, mapFragment?.cameraPosition?.tilt, questKey, editTypeName) + f.requireArguments().putAll(args) + binding.otherQuestsScrollView.visibility = View.GONE + supportFragmentManager.commit(true) { + replace(R.id.map_bottom_sheet_container, f, BOTTOM_SHEET) + addToBackStack(BOTTOM_SHEET) + } + } + /* ------------------------------- SplitWayFragment.Listener -------------------------------- */ override fun onSplittedWay(editType: ElementEditType, way: Way, geometry: ElementPolylinesGeometry) { @@ -466,6 +692,26 @@ class MainActivity : /* ------------------------------- MoveNodeFragment.Listener -------------------------------- */ override fun onMoveNode(editType: ElementEditType, node: Node) { + val ways = mapDataWithEditsSource.getWaysForNode(node.id) + val relations = mapDataWithEditsSource.getRelationsForNode(node.id) + if (ways.isNotEmpty() || relations.isNotEmpty()) { + val multipolygons = relations.filter { it.tags["type"] == "multipolygon" } + val message = if (ways.isNotEmpty() || multipolygons.isNotEmpty()) + getString(R.string.move_node_with_geometry, (ways + multipolygons).map { featureDictionary.value.byTags(it.tags).find().firstOrNull()?.name ?: it.tags }.toString()) + else + getString(R.string.move_node_of_other_relation, relations.map { featureDictionary.value.byTags(it.tags).find().firstOrNull()?.name ?: it.tags }.toString()) + AlertDialog.Builder(this) + .setTitle(R.string.general_warning) + .setMessage(message) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.dialog_button_understood) { _,_ -> moveNode(editType, node) } + .show() + } else { + moveNode(editType, node) + } + } + + private fun moveNode(editType: ElementEditType, node: Node) { val mapFragment = mapFragment ?: return showInBottomSheet(MoveNodeFragment.create(editType, node), clearPreviousHighlighting = false) mapFragment.clearSelectedPins() @@ -517,6 +763,7 @@ class MainActivity : /* ------------------------------- CreateNoteFragment.Listener ------------------------------ */ override fun onCreatedNote(position: LatLon) { + Log.i(TAG, "created note at $position") showQuestSolvedAnimation(R.drawable.ic_quest_create_note, position) closeBottomSheet() } @@ -545,7 +792,7 @@ class MainActivity : lifecycleScope.launch { val f = bottomSheetFragment // open quest has been deleted - if (f is IsShowingQuestDetails && f.questKey in removed) { + if (f is IsShowingQuestDetails && f.view != null && f.questKey in removed) { closeBottomSheet() } } @@ -653,6 +900,9 @@ class MainActivity : .setPositiveButton(R.string.confirmation_cancel_prev_download_confirmed) { _, _ -> viewModel.download(downloadBbox) } + .setNeutralButton(R.string.enqueue_download) { _, _ -> + viewModel.download(downloadBbox, true) + } .setNegativeButton(R.string.confirmation_cancel_prev_download_cancel, null) .show() } else { @@ -663,14 +913,18 @@ class MainActivity : } } - private fun onClickZoomOut() { + fun onClickZoomOut() { mapFragment?.updateCameraPosition(300) { zoomBy = -1.0 } } - private fun onClickZoomIn() { + fun onClickZoomIn() { mapFragment?.updateCameraPosition(300) { zoomBy = +1.0 } } + fun onZoom(zoomDelta: Float) { + mapFragment?.updateCameraPosition(300) { zoomBy = zoomDelta.toDouble() } + } + private fun onClickTracksStop() { // hide the track information viewModel.isRecordingTracks.value = false @@ -708,7 +962,8 @@ class MainActivity : setIsFollowingPosition(true) } else -> { - setIsNavigationMode(!mapFragment.isNavigationMode) + if (!prefs.getBoolean(Prefs.DISABLE_NAVIGATION_MODE, false) || mapFragment.isNavigationMode) + setIsNavigationMode(!mapFragment.isNavigationMode) } } } @@ -767,11 +1022,20 @@ class MainActivity : private fun showMapContextMenu(position: LatLon) { val popupMenu = PopupMenu(this, binding.contextMenuView) popupMenu.inflate(R.menu.menu_map_context) + if (prefs.getBoolean(Prefs.EXPERT_MODE, false)) { + popupMenu.menu.add(Menu.NONE, 4, 4, R.string.create_poi) + popupMenu.menu.add(Menu.NONE, 5, 5, R.string.insert_node) + } popupMenu.setOnMenuItemClickListener { item -> when (item.itemId) { R.id.action_create_note -> onClickCreateNote(position) R.id.action_create_track -> onClickCreateTrack() R.id.action_open_location -> onClickOpenLocationInOtherApp(position) + 4 -> onClickAddPoi(position) + 5 -> { + mapFragment?.hideOverlay() + showInBottomSheet(InsertNodeFragment.create(position)) + } } true } @@ -814,6 +1078,73 @@ class MainActivity : } } + private fun onClickAddPoi(pos: LatLon) { + if ((mapFragment?.cameraPosition?.zoom ?: 0.0) < ApplicationConstants.NOTE_MIN_ZOOM) { + toast(R.string.create_new_note_unprecise) + return + } + + val f = bottomSheetFragment + if (f is IsCloseableBottomSheet) f.onClickClose { selectPoiType(pos) } + else selectPoiType(pos) + } + + private fun selectPoiType(pos: LatLon) { + val country = countryBoundaries.value.getIds(pos.longitude, pos.latitude).firstOrNull() + val defaultFeatureIds: List = prefs.getString(Prefs.CREATE_POI_RECENT_FEATURE_IDS, "") + .split("§").filter { it.isNotBlank() && it != "shop" } + .ifEmpty { POPULAR_PLACE_FEATURE_IDS } + + SearchFeaturesDialog( + this, + featureDictionary.value, + GeometryType.POINT, + country, + null, // pre-filled search text + { it.addTags.isNotEmpty() }, // require non-empty tags, avoids the crash reported in https://github.com/Helium314/SCEE/issues/757 + { addPoi(pos, it) }, + defaultFeatureIds.reversed(), + false, + pos, + ).show() + } + + private fun addPoi(pos: LatLon, feature: Feature) { + showInBottomSheet(CreatePoiFragment.createFromFeature(feature, pos)) + + // actually this could run again if tags are changed + lifecycleScope.launch { + val bbox = pos.enclosingBoundingBox(50.0) + val data = withContext(Dispatchers.IO) { mapDataWithEditsSource.getMapDataWithGeometry(bbox) } + val elements = if (Node(0L, pos, feature.addTags).isPlace()) { + data.filter { it.isPlace() } + } else { + val filter = "nodes, ways, relations with ${feature.tags + .map { if (it.value == "*") it.key else it.key + "=" + it.value } + .joinToString(" and ")}".toElementFilterExpression() + data.filter { filter.matches(it) } + } + + putMarkersForCurrentHighlighting(elements.mapNotNull { e -> + // include only elements that fit with the currently active level filter + if (!levelFilter.levelAllowed(e)) return@mapNotNull null + + val geometry = data.getGeometry(e.type, e.id) ?: return@mapNotNull null + val icon = getIcon(featureDictionary.value, e) + val title = getTitle(e.tags) + Marker(geometry, icon, title) + }) + } + offsetPos(pos) + } + + fun offsetPos(pos: LatLon) { + mapFragment?.updateCameraPosition(300) { + position = pos + padding = getQuestFormInsets().toPadding() + } + } + private fun onClickCreateTrack() { mapFragment?.startPositionTrackRecording() viewModel.isRecordingTracks.value = true @@ -827,10 +1158,16 @@ class MainActivity : * view (e.g. if it was zoomed in before to focus on an element) */ @UiThread private fun closeBottomSheet() { + val showing = (bottomSheetFragment as? IsShowingElement)?.elementKey ?: (bottomSheetFragment as? IsShowingQuestDetails)?.questKey + Log.i(TAG, "closeBottomSheet while showing $showing") currentFocus?.hideKeyboard() if (bottomSheetFragment != null) { supportFragmentManager.popBackStack(BOTTOM_SHEET, FragmentManager.POP_BACK_STACK_INCLUSIVE) + binding.otherQuestsLayout.removeAllViews() + binding.otherQuestsScrollView.visibility = View.GONE } + if (prefs.getBoolean(Prefs.OVERLAY_QUICK_SELECTOR, false)) + binding.overlayScrollView.isVisible = true clearHighlighting() unfreezeMap() mapFragment?.endFocus() @@ -841,6 +1178,7 @@ class MainActivity : * played and the highlighting of the previous bottom sheet is cleared. */ private fun showInBottomSheet(f: Fragment, clearPreviousHighlighting: Boolean = true) { currentFocus?.hideKeyboard() + binding.overlayScrollView.isGone = true freezeMap() if (bottomSheetFragment != null) { if (clearPreviousHighlighting) clearHighlighting() @@ -885,10 +1223,15 @@ class MainActivity : private fun showOverlayFormForNewElement() { val overlay = viewModel.selectedOverlay.value ?: return val mapFragment = mapFragment ?: return + val camera = mapFragment.cameraPosition + if (overlay is CustomOverlay) { + val pos = camera?.position ?: return + showInBottomSheet(CreatePoiFragment.createWithPrefill(prefs.getString(Prefs.CUSTOM_OVERLAY_IDX_FILTER, "")!!.substringAfter("with "), pos)) + return + } val f = overlay.createForm(null) ?: return if (f.arguments == null) f.arguments = bundleOf() - val camera = mapFragment.cameraPosition val rotation = camera?.rotation ?: 0.0 val tilt = camera?.tilt ?: 0.0 val args = AbstractOverlayForm.createArguments(overlay, null, null, rotation, tilt) @@ -905,6 +1248,7 @@ class MainActivity : @UiThread private suspend fun showElementDetails(elementKey: ElementKey) { + Log.i(TAG, "showElementDetails for $elementKey") if (isElementCurrentlyDisplayed(elementKey)) return val overlay = viewModel.selectedOverlay.value ?: return val geometry = mapDataWithEditsSource.getGeometry(elementKey.type, elementKey.id) ?: return @@ -914,7 +1258,7 @@ class MainActivity : val center = geometry.center val note = withContext(Dispatchers.IO) { notesSource - .getAll(BoundingBox(center, center).enlargedBy(0.2)) + .getAll(BoundingBox(center, center).enlargedBy(0.2)).filterNot { it.isClosed } .firstOrNull { it.position.truncateTo6Decimals() == center.truncateTo6Decimals() } ?.takeIf { questsHiddenSource.get(OsmNoteQuestKey(it.id)) == null } } @@ -955,6 +1299,7 @@ class MainActivity : @UiThread private suspend fun showQuestDetails(quest: Quest) { + Log.i(TAG, "showQuestDetails for ${quest.key}") val mapFragment = mapFragment ?: return if (isQuestDetailsCurrentlyDisplayedFor(quest.key)) return @@ -967,11 +1312,18 @@ class MainActivity : val args = AbstractQuestForm.createArguments(quest.key, quest.type, quest.geometry, rotation, tilt) f.requireArguments().putAll(args) + val element = if (quest is OsmQuest) withContext(Dispatchers.IO) { + val e = mapDataWithEditsSource.get(quest.elementType, quest.elementId) + if (e == null) // this sometimes occurred in tests... until reason is found, just remove the quest + osmQuestController.delete(quest.key) + e + } ?: return + else null + val highlightedElementMarkers = lifecycleScope.async(Dispatchers.IO) { getHighlightedElements(quest, element) } + val otherQuestMarkers = lifecycleScope.async(Dispatchers.IO) { showOtherQuests(quest) } if (quest is OsmQuest) { - val element = withContext(Dispatchers.IO) { mapDataWithEditsSource.get(quest.elementType, quest.elementId) } ?: return - val osmArgs = AbstractOsmQuestForm.createArguments(element) + val osmArgs = AbstractOsmQuestForm.createArguments(element!!) f.requireArguments().putAll(osmArgs) - showHighlightedElements(quest, element) } showInBottomSheet(f) @@ -981,42 +1333,131 @@ class MainActivity : mapFragment.highlightPins(quest.type.icon, quest.markerLocations) mapFragment.hideNonHighlightedPins(quest.key) mapFragment.hideOverlay() + + lifecycleScope.launch(Dispatchers.IO) { + val markers = mergeMarkersAtSamePosition(highlightedElementMarkers.await(), otherQuestMarkers.await()) + mapFragment.putMarkersForCurrentHighlighting(markers) + } } - private fun showHighlightedElements(quest: OsmQuest, element: Element) { - val bbox = quest.geometry.getBounds().enlargedBy(quest.type.highlightedElementsRadius) + // if quest and highlight marker at same position, set color of highlight marker to quest color + private fun mergeMarkersAtSamePosition(highlightMarkers: List, questMarkers: List): List { + // creating a map of possibly many markers may not be the fastest thing... but still ok i guess + val m = hashMapOf() + highlightMarkers.associateByTo(m) { it.geometry.center } + questMarkers.forEach { questMarker -> + val highlightMarker = m[questMarker.geometry.center] + if (highlightMarker == null) { + m[questMarker.geometry.center] = questMarker + return@forEach + } + m[questMarker.geometry.center] = highlightMarker.copy(color = questMarker.color) + } + return m.values.toList() + } + + private fun getHighlightedElements(quest: Quest, element: Element? = null): List { + val bbox = when (quest) { + is OsmQuest -> quest.geometry.getBounds().enlargedBy(quest.type.highlightedElementsRadius) + is ExternalSourceQuest -> quest.geometry.getBounds().enlargedBy(quest.type.highlightedElementsRadius) + else -> return emptyList() + } var mapData: MapDataWithGeometry? = null fun getMapData(): MapDataWithGeometry { val data = mapDataWithEditsSource.getMapDataWithGeometry(bbox) + if (data is MutableMapDataWithGeometry && element is Way && !data.isWayComplete(element.id)) { + // complete way to show stuff along it + mapDataWithEditsSource.getWayComplete(element.id)?.nodes?.forEach { + data.put(it, ElementPointGeometry(it.position)) + } + } mapData = data return data } - val levels = parseLevelsOrNull(element.tags) - - lifecycleScope.launch(Dispatchers.Default) { - val elements = withContext(Dispatchers.IO) { - quest.type.getHighlightedElements(element, ::getMapData) + val elements = + when (quest) { + is OsmQuest -> element?.let { quest.type.getHighlightedElements(it, ::getMapData) } ?: emptySequence() + is ExternalSourceQuest -> quest.type.getHighlightedElements(::getMapData) + else -> emptySequence() } + if (elements == emptySequence()) return emptyList() + val levels = element?.let { parseLevelsOrNull(it.tags) } + val localLanguages = getSystemLocales().toList().map { it.language } + return elements.mapNotNull { e -> + // don't highlight "this" element + if (element == e) return@mapNotNull null + // include only elements with the same (=intersecting) level, if any + val eLevels = parseLevelsOrNull(e.tags) + if (!levels.levelsIntersect(eLevels)) return@mapNotNull null + // include only elements with the same layer, if any (except for bridges) + if (element?.tags?.get("layer") != e.tags["layer"] && e.tags["bridge"] == null) return@mapNotNull null + + val geometry = mapData?.getGeometry(e.type, e.id) ?: return@mapNotNull null + val icon = getIcon(featureDictionary.value, e) + val title = getTitle(e.tags, localLanguages) + Marker(geometry, icon, title) + }.toList() + } + + private fun showOtherQuests(quest: Quest): List { + if (prefs.getInt(Prefs.SHOW_NEARBY_QUESTS, 0) == 0) return emptyList() + + // Quests should be grouped by element key, so non-OsmQuests need some kind of fake key + fun Quest.thatKey() = if (this is OsmQuest) ElementKey(elementType, elementId) + else ElementKey(ElementType.entries[abs(key.hashCode() % 3)], -abs(7 * key.hashCode()).toLong()) + + val markers = mutableListOf() + + val quests = visibleQuestsSource.getNearbyQuests(quest, prefs.getFloat(Prefs.SHOW_NEARBY_QUESTS_DISTANCE, 0.0f).toDouble() + 0.01) + .filterNot { it == quest || it.type.dotColor != null } // ignore current quest and poi dots + .sortedBy { it.thatKey() != quest.thatKey() } + if (quests.isEmpty()) return emptyList() + + val questsAndColorByElement = mutableMapOf>>() + val colors = arrayOf(Color.GREEN, Color.YELLOW, Color.CYAN, Color.MAGENTA, Color.BLUE, ColorUtils.blendARGB(Color.RED, Color.YELLOW, 0.5f)) + var colorIterator = colors.iterator() + quests.forEach { + questsAndColorByElement.getOrPut(it.thatKey()) { + val color = if (it.thatKey() == quest.thatKey()) Color.WHITE // no color for other quests of the selected element + else colorIterator.next() + if (!colorIterator.hasNext()) colorIterator = colors.iterator() // cycle through color list if there are many elements + if (color != Color.WHITE) + markers.add(Marker(it.geometry, color = color)) + Pair(color, mutableListOf()) + }.second.add(it) + } - val markers = elements.mapNotNull { e -> - // don't highlight "this" element - if (element == e) return@mapNotNull null - // include only elements with the same (=intersecting) level, if any - val eLevels = parseLevelsOrNull(e.tags) - if (!levels.levelsIntersect(eLevels)) return@mapNotNull null - // include only elements with the same layer, if any - if (element.tags["layer"] != e.tags["layer"]) return@mapNotNull null - - val geometry = mapData?.getGeometry(e.type, e.id) ?: return@mapNotNull null - val icon = getIcon(featureDictionary.value, e) - val title = getTitle(e.tags) - Marker(geometry, icon, title) - }.toList() - - withContext(Dispatchers.Main) { putMarkersForCurrentHighlighting(markers) } + val params = ViewGroup.LayoutParams(resources.dpToPx(54).toInt(), resources.dpToPx(54).toInt()) + runOnUiThread { + questsAndColorByElement.values.forEach { + val color = it.first + it.second.forEach { q -> + val questView = ImageView(this).apply { + layoutParams = params + scaleX = 0.95f + scaleY = 0.95f + setOnClickListener { + binding.otherQuestsLayout.removeAllViews() + lifecycleScope.launch { showQuestDetails(q) } + } + + // create layerDrawable from quest icon and ring + val ring = ContextCompat.getDrawable(context, R.drawable.pin_selection_ring)!! // thanks google for not providing documentation WHEN this can be null... is it instead of resourceNotFoundException? + ring.colorFilter = if (color == Color.WHITE) null + else PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + val icon = ContextCompat.getDrawable(context, q.type.icon)!! + icon.colorFilter = PorterDuffColorFilter(ColorUtils.blendARGB(color, Color.WHITE, 0.8f), PorterDuff.Mode.MULTIPLY) + setImageDrawable(LayerDrawable(arrayOf(icon, ring))) + } + binding.otherQuestsLayout.addView(questView) + } + } + binding.otherQuestsScrollView.fullScroll(View.FOCUS_UP) // scroll up when the quest changes + binding.otherQuestsScrollView.visibility = View.VISIBLE } + return markers } private fun isQuestDetailsCurrentlyDisplayedFor(questKey: QuestKey): Boolean { @@ -1040,6 +1481,7 @@ class MainActivity : //region Animation - Animation(s) for when a quest is solved private fun showQuestSolvedAnimation(iconResId: Int, position: LatLon) { + if (!prefs.getBoolean(Prefs.SHOW_SOLVED_ANIMATION, true)) return val offset = binding.root.getLocationInWindow() val startPos = mapFragment?.getPointOf(position) ?: return @@ -1082,10 +1524,66 @@ class MainActivity : } //endregion + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_MENU) { + if (event.action == KeyEvent.ACTION_UP) { + viewModel.showMainMenuDialog.value = true + } + return true + } + if (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP && prefs.getBoolean(Prefs.VOLUME_ZOOM, false)) { + if (event.action == KeyEvent.ACTION_UP) { + onClickZoomIn() + } + return true + } + if (event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN && prefs.getBoolean(Prefs.VOLUME_ZOOM, false)) { + if (event.action == KeyEvent.ACTION_UP) { + onClickZoomOut() + } + return true + } + return super.dispatchKeyEvent(event) + } + + private fun startQuestMonitor() { + if (prefs.getBoolean(Prefs.QUEST_MONITOR, false) && !NearbyQuestMonitor.running) { + questMonitorJob?.cancel() + questMonitorJob = lifecycleScope.launch { + delay(1000) // wait, as we don't want do start the monitor if onDestroy follows + applicationContext.bindService(Intent(this@MainActivity, NearbyQuestMonitor::class.java), questMonitorConnection, BIND_AUTO_CREATE) + } + } + } + + private fun stopQuestMonitor() { + // try to stop quest monitor more often than it seems necessary, because sometime android + // is slow to react, e.g. when quickly switching between SC and other app + if (prefs.getBoolean(Prefs.QUEST_MONITOR, false) || NearbyQuestMonitor.running) { + try { applicationContext.unbindService(questMonitorConnection) } + catch (_: IllegalArgumentException) { } // happens on first start, and maybe if there is some issue + questMonitorJob?.cancel() + questMonitorJob = lifecycleScope.launch { + delay(5000) + // sometimes it just doesn't stop, or is started with considerable delay for some reason + // try to catch this here + try { applicationContext.unbindService(questMonitorConnection) } + catch (_: IllegalArgumentException) { } + } + } + } companion object { private const val BOTTOM_SHEET = "bottom_sheet" private const val TAG_LOCATION_REQUEST = "LocationRequestFragment" + + // quest monitor connection needs to work with multiple main activities + private val questMonitorConnection: ServiceConnection by lazy { object : ServiceConnection { + override fun onServiceConnected(p0: ComponentName?, p1: IBinder?) {} + override fun onServiceDisconnected(p0: ComponentName?) {} + } } } } + +private const val TAG = "MainActivity" diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainMenuDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainMenuDialog.kt index 1989e8ae864..3544e42f603 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainMenuDialog.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainMenuDialog.kt @@ -24,20 +24,25 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.presets.EditTypePresetsController import de.westnordost.streetcomplete.screens.main.controls.NotificationBox import de.westnordost.streetcomplete.screens.main.teammode.TeamModeColorCircle +import de.westnordost.streetcomplete.util.dialogs.showProfileSelectionDialog +import org.koin.compose.koinInject import de.westnordost.streetcomplete.ui.common.DownloadIcon import de.westnordost.streetcomplete.ui.common.TeamModeIcon import de.westnordost.streetcomplete.ui.common.UploadIcon -@OptIn(ExperimentalLayoutApi::class) @Composable fun MainMenuDialog( onDismissRequest: () -> Unit, @@ -57,6 +62,9 @@ fun MainMenuDialog( backgroundColor: Color = MaterialTheme.colors.surface, contentColor: Color = contentColorFor(backgroundColor), ) { + val prefs: Preferences = koinInject() + val editTypePresetsController: EditTypePresetsController = koinInject() + val ctx = LocalContext.current Dialog(onDismissRequest = onDismissRequest) { Surface( modifier = modifier, @@ -65,66 +73,140 @@ fun MainMenuDialog( contentColor = contentColor ) { Column { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - BigMenuButton( - onClick = { onDismissRequest(); onClickProfile() }, - icon = { Icon(painterResource(R.drawable.ic_profile_48dp), null) }, - text = stringResource( - if (isLoggedIn) R.string.user_profile else R.string.user_login - ), - ) - BigMenuButton( - onClick = { onDismissRequest(); onClickSettings() }, - icon = { Icon(painterResource(R.drawable.ic_settings_48dp), null) }, - text = stringResource(R.string.action_settings), - ) - BigMenuButton( - onClick = { onDismissRequest(); onClickAbout() }, - icon = { Icon(painterResource(R.drawable.ic_info_outline_48dp), null) }, - text = stringResource(R.string.action_about2), - ) - } - Divider() - if (unsyncedEditsCount != null) { - CompactMenuButton( - onClick = { onDismissRequest(); onClickUpload() }, - icon = { - UploadIcon() - if (unsyncedEditsCount > 0) { - NotificationBox { - Text(unsyncedEditsCount.toString(), textAlign = TextAlign.Center) + if (!prefs.getBoolean(Prefs.MAIN_MENU_FULL_GRID, false)) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + BigMenuButton( + onClick = { onDismissRequest(); onClickProfile() }, + icon = { Icon(painterResource(R.drawable.ic_profile_48dp), null) }, + text = stringResource( + if (isLoggedIn) R.string.user_profile else R.string.user_login + ), + ) + BigMenuButton( + onClick = { onDismissRequest(); onClickSettings() }, + icon = { Icon(painterResource(R.drawable.ic_settings_48dp), null) }, + text = stringResource(R.string.action_settings), + ) + BigMenuButton( + onClick = { onDismissRequest(); onClickAbout() }, + icon = { Icon(painterResource(R.drawable.ic_info_outline_48dp), null) }, + text = LocalContext.current.getString(R.string.action_about2) + " SCEE", + ) + } + Divider() + if (unsyncedEditsCount != null) { + CompactMenuButton( + onClick = { onDismissRequest(); onClickUpload() }, + icon = { + UploadIcon() + if (unsyncedEditsCount > 0) { + NotificationBox { + Text(unsyncedEditsCount.toString(), textAlign = TextAlign.Center) + } } - } - }, - text = stringResource(R.string.action_upload), - enabled = !isUploadingOrDownloading, - ) - } - CompactMenuButton( - onClick = { onDismissRequest(); onClickDownload() }, - icon = { DownloadIcon() }, - text = stringResource(R.string.action_download), - ) - if (indexInTeam == null) { + }, + text = stringResource(R.string.action_upload), + enabled = !isUploadingOrDownloading, + ) + } CompactMenuButton( - onClick = { onDismissRequest(); onClickEnterTeamMode() }, - icon = { TeamModeIcon() }, - text = stringResource(R.string.team_mode) + onClick = { onDismissRequest(); onClickDownload() }, + icon = { DownloadIcon() }, + text = stringResource(R.string.action_download), ) + if (indexInTeam == null) { + CompactMenuButton( + onClick = { onDismissRequest(); onClickEnterTeamMode() }, + icon = { TeamModeIcon() }, + text = stringResource(R.string.team_mode) + ) + } else { + CompactMenuButton( + onClick = { onDismissRequest(); onClickExitTeamMode() }, + icon = { + TeamModeColorCircle( + index = indexInTeam, + modifier = Modifier.size(24.dp) + ) + }, + text = stringResource(R.string.team_mode_exit) + ) + } + if (prefs.getBoolean(Prefs.MAIN_MENU_SWITCH_PRESETS, false)) + CompactMenuButton( + onClick = { onDismissRequest(); showProfileSelectionDialog(ctx, editTypePresetsController, prefs) }, + icon = { }, + text = stringResource(R.string.quick_switch_preset) + ) } else { - CompactMenuButton( - onClick = { onDismissRequest(); onClickExitTeamMode() }, - icon = { - TeamModeColorCircle( - index = indexInTeam, - modifier = Modifier.size(24.dp) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + BigMenuButton( + onClick = { onDismissRequest(); onClickProfile() }, + icon = { Icon(painterResource(R.drawable.ic_profile_48dp), null) }, + text = stringResource( + if (isLoggedIn) R.string.user_profile else R.string.user_login + ), + ) + BigMenuButton( + onClick = { onDismissRequest(); onClickSettings() }, + icon = { Icon(painterResource(R.drawable.ic_settings_48dp), null) }, + text = stringResource(R.string.action_settings), + ) + BigMenuButton( + onClick = { onDismissRequest(); onClickAbout() }, + icon = { Icon(painterResource(R.drawable.ic_info_outline_48dp), null) }, + text = LocalContext.current.getString(R.string.action_about2) + " SCEE", + ) + if (unsyncedEditsCount != null && !isUploadingOrDownloading) { + BigMenuButton( + onClick = { onDismissRequest(); onClickUpload() }, + icon = { + UploadIcon() + if (unsyncedEditsCount > 0) { + NotificationBox { + Text(unsyncedEditsCount.toString(), textAlign = TextAlign.Center) + } + } + }, + text = stringResource(R.string.action_upload), ) - }, - text = stringResource(R.string.team_mode_exit) - ) + } + BigMenuButton( + onClick = { onDismissRequest(); onClickDownload() }, + icon = { DownloadIcon() }, + text = stringResource(R.string.action_download), + ) + if (indexInTeam == null) { + BigMenuButton( + onClick = { onDismissRequest(); onClickEnterTeamMode() }, + icon = { TeamModeIcon() }, + text = stringResource(R.string.team_mode) + ) + } else { + BigMenuButton( + onClick = { onDismissRequest(); onClickExitTeamMode() }, + icon = { + TeamModeColorCircle( + index = indexInTeam, + modifier = Modifier.size(24.dp) + ) + }, + text = stringResource(R.string.team_mode_exit) + ) + } + if (prefs.getBoolean(Prefs.MAIN_MENU_SWITCH_PRESETS, false)) + BigMenuButton( + onClick = { onDismissRequest(); showProfileSelectionDialog(ctx, editTypePresetsController, prefs) }, + icon = { }, + text = stringResource(R.string.quick_switch_preset) + ) + } } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainModule.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainModule.kt index e40029fd8fb..2cee86e379d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainModule.kt @@ -14,8 +14,8 @@ val mainModule = module { viewModel { MainViewModelImpl( get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), - get(), get(), get(), get(), get(), get(), get() + get(), get(), get(), get(), get(), get(), get(), get() ) } - viewModel { EditHistoryViewModelImpl(get(), get(), get(named("FeatureDictionaryLazy"))) } + viewModel { EditHistoryViewModelImpl(get(), get(), get(named("FeatureDictionaryLazy")), get()) } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainScreen.kt index 78293a0303d..f1e3f01f230 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainScreen.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainScreen.kt @@ -1,6 +1,10 @@ package de.westnordost.streetcomplete.screens.main +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.content.Intent +import android.view.KeyEvent import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -9,6 +13,7 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -20,6 +25,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.ButtonDefaults import androidx.compose.material.MaterialTheme @@ -35,8 +42,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext @@ -57,6 +67,7 @@ import de.westnordost.streetcomplete.screens.main.controls.MapButton import de.westnordost.streetcomplete.screens.main.controls.MessagesButton import de.westnordost.streetcomplete.screens.main.controls.OverlaySelectionButton import de.westnordost.streetcomplete.screens.main.controls.PointerPinButton +import de.westnordost.streetcomplete.screens.main.controls.QuickSettingsDropdown import de.westnordost.streetcomplete.screens.main.controls.ScaleBar import de.westnordost.streetcomplete.screens.main.controls.StarsCounter import de.westnordost.streetcomplete.screens.main.controls.ZoomButtons @@ -76,6 +87,7 @@ import de.westnordost.streetcomplete.screens.tutorial.OverlaysTutorialScreen import de.westnordost.streetcomplete.screens.user.UserActivity import de.westnordost.streetcomplete.ui.common.AnimatedScreenVisibility import de.westnordost.streetcomplete.ui.common.LargeCreateIcon +import de.westnordost.streetcomplete.ui.common.QuickSettingsIcon import de.westnordost.streetcomplete.ui.common.StopRecordingIcon import de.westnordost.streetcomplete.ui.common.UndoIcon import de.westnordost.streetcomplete.ui.ktx.dir @@ -93,6 +105,7 @@ fun MainScreen( editHistoryViewModel: EditHistoryViewModel, onClickZoomIn: () -> Unit, onClickZoomOut: () -> Unit, + onZoom: (Float) -> Unit, onClickCompass: () -> Unit, onClickLocation: () -> Unit, onClickLocationPointer: () -> Unit, @@ -148,11 +161,14 @@ fun MainScreen( val isRequestingLogin by viewModel.isRequestingLogin.collectAsState() + val showQuickSettings by viewModel.showQuickSettings.collectAsState() + var showQuickSettingsMenu by remember { mutableStateOf(false) } + var showOverlaysDropdown by remember { mutableStateOf(false) } var showOverlaysTutorial by remember { mutableStateOf(false) } var showIntroTutorial by remember { mutableStateOf(false) } var showTeamModeWizard by remember { mutableStateOf(false) } - var showMainMenuDialog by remember { mutableStateOf(false) } + var showMainMenuDialog by viewModel.showMainMenuDialog var shownMessage by remember { mutableStateOf(null) } val showEditHistorySidebar by editHistoryViewModel.isShowingSidebar.collectAsState() @@ -244,7 +260,8 @@ fun MainScreen( .defaultMinSize(minWidth = 96.dp) .clickable(null, null) { viewModel.toggleShowingCurrentWeek() }, isCurrentWeek = isShowingStarsCurrentWeek, - showProgress = isUploadingOrDownloading + showProgress = isUploadingOrDownloading, + hasUnsyncedChanges = unsyncedEditsCount != 0 ) } @@ -314,7 +331,8 @@ fun MainScreen( if (showZoomButtons) { ZoomButtons( onZoomIn = onClickZoomIn, - onZoomOut = onClickZoomOut + onZoomOut = onClickZoomOut, + zoom = onZoom ) } LocationStateButton( @@ -352,6 +370,21 @@ fun MainScreen( .padding(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + if (showQuickSettings) { + Box{ + MapButton( + onClick = { showQuickSettingsMenu = !showQuickSettingsMenu }, + modifier = Modifier.size(48.dp) + ) { + QuickSettingsIcon() + } + QuickSettingsDropdown( + expanded = showQuickSettingsMenu, + onDismissRequest = { showQuickSettingsMenu = false }, + viewModel = viewModel + ) + } + } if (isRecordingTracks) { MapButton( onClick = onClickStopTrackRecording, @@ -420,6 +453,7 @@ fun MainScreen( } if (showMainMenuDialog) { + val requester = remember { FocusRequester() } // necessary for receiving key event MainMenuDialog( onDismissRequest = { showMainMenuDialog = false }, onClickProfile = { context.startActivity(Intent(context, UserActivity::class.java)) }, @@ -433,7 +467,18 @@ fun MainScreen( indexInTeam = if (isTeamMode) indexInTeam else null, unsyncedEditsCount = if (!isAutoSync) unsyncedEditsCount else null, isUploadingOrDownloading = isUploadingOrDownloading, + modifier = Modifier + .focusRequester(requester) + .focusable() + .onKeyEvent { + if (it.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_MENU && it.nativeKeyEvent.action == KeyEvent.ACTION_UP){ + context.startActivity(Intent(context, SettingsActivity::class.java)) + showMainMenuDialog = false + } + false + } ) + LaunchedEffect(Unit) { requester.requestFocus() } } urlConfig?.let { config -> @@ -450,6 +495,9 @@ fun MainScreen( LastUploadErrorEffect(lastError = error, onReportError = ::sendErrorReport) } lastCrashReport?.let { report -> + val clip = ClipData.newPlainText("SCEE error message", report) + (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(clip) + context.toast("crash report copied to clipboard") LastCrashEffect(lastReport = report, onReport = { context.sendErrorReportEmail(it) }) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModel.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModel.kt index 3eed407fd4a..fe1abe2e714 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModel.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModel.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.screens.main +import androidx.compose.runtime.MutableState import androidx.compose.ui.geometry.Offset import androidx.lifecycle.ViewModel import de.westnordost.streetcomplete.data.messages.Message @@ -69,7 +70,7 @@ abstract class MainViewModel : ViewModel() { abstract fun finishRequestingLogin() abstract fun upload() - abstract fun download(bbox: BoundingBox) + abstract fun download(bbox: BoundingBox, enqueue: Boolean = false) /* stars */ abstract val starsCount: StateFlow @@ -89,6 +90,10 @@ abstract class MainViewModel : ViewModel() { abstract val isRecordingTracks: MutableStateFlow abstract val userHasMovedCamera: MutableStateFlow + + abstract val showQuickSettings: StateFlow + abstract val reverseQuestOrder: MutableStateFlow + abstract val showMainMenuDialog: MutableState } data class ShownUrlConfig(val urlConfig: UrlConfig, val alreadyExists: Boolean) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt index 7a00ba04808..1c28a709322 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt @@ -1,7 +1,12 @@ package de.westnordost.streetcomplete.screens.main +import android.content.res.Resources +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.geometry.Offset import androidx.lifecycle.viewModelScope +import de.westnordost.streetcomplete.BuildConfig +import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.UnsyncedChangesCountSource import de.westnordost.streetcomplete.data.download.DownloadController import de.westnordost.streetcomplete.data.download.DownloadProgressSource @@ -32,13 +37,16 @@ import de.westnordost.streetcomplete.data.presets.EditTypePresetsSource import de.westnordost.streetcomplete.data.visiblequests.TeamModeQuestFilter import de.westnordost.streetcomplete.data.visiblequests.VisibleEditTypeSource import de.westnordost.streetcomplete.overlays.Overlay +import de.westnordost.streetcomplete.overlays.custom.CustomOverlay import de.westnordost.streetcomplete.screens.main.controls.LocationState import de.westnordost.streetcomplete.screens.main.map.maplibre.CameraPosition import de.westnordost.streetcomplete.util.CrashReportExceptionHandler +import de.westnordost.streetcomplete.util.getFakeCustomOverlays import de.westnordost.streetcomplete.util.ktx.launch import de.westnordost.streetcomplete.util.parseGeoUri import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -47,6 +55,7 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.withContext @@ -71,6 +80,7 @@ class MainViewModelImpl( private val elementEditsSource: ElementEditsSource, private val noteEditsSource: NoteEditsSource, private val prefs: Preferences, + private val resources: Resources ) : MainViewModel() { /* error handling */ @@ -178,13 +188,22 @@ class MainViewModelImpl( }.stateIn(viewModelScope + IO, SharingStarted.Eagerly, getVisibleOverlays()) private fun getVisibleOverlays(): List = - overlayRegistry.filter { visibleEditTypeSource.isVisible(it) } + overlayRegistry.filter { + val eeAllowed = if (prefs.getBoolean(Prefs.EXPERT_MODE, false)) true + else overlayRegistry.getOrdinalOf(it)!! < ApplicationConstants.EE_QUEST_OFFSET + visibleEditTypeSource.isVisible(it) + && eeAllowed // expert mode on, or SC overlay + && it !is CustomOverlay // custom overlay added separately + } + getFakeCustomOverlays(prefs, resources) override val selectedOverlay: StateFlow = callbackFlow { send(selectedOverlayController.selectedOverlay) val listener = object : SelectedOverlaySource.Listener { override fun onSelectedOverlayChanged() { - trySend(selectedOverlayController.selectedOverlay) + if (selectedOverlayController.selectedOverlay is CustomOverlay) { + trySend(null) // necessary for button reload when switching between custom overlays + viewModelScope.launch { delay(50); trySend(selectedOverlayController.selectedOverlay) } + } else trySend(selectedOverlayController.selectedOverlay) } } selectedOverlayController.addListener(listener) @@ -215,8 +234,8 @@ class MainViewModelImpl( launch(IO) { teamModeQuestFilter.disableTeamMode() } } - override fun download(bbox: BoundingBox) { - downloadController.download(bbox, true) + override fun download(bbox: BoundingBox, enqueue: Boolean) { + downloadController.download(bbox, true, enqueue) } private val teamModeListener = object : TeamModeQuestFilter.TeamModeChangeListener { @@ -281,10 +300,10 @@ class MainViewModelImpl( awaitClose { userLoginSource.removeListener(listener) } }.stateIn(viewModelScope, SharingStarted.Eagerly, false) - override val isConnected: Boolean get() = internetConnectionState.isConnected + override val isConnected: Boolean get() = internetConnectionState.isConnected || BuildConfig.DEBUG override fun upload() { - if (isLoggedIn.value) { + if (isLoggedIn.value || BuildConfig.DEBUG) { uploadController.upload(isUserInitiated = true) } else { isRequestingLogin.value = true @@ -405,6 +424,14 @@ class MainViewModelImpl( override val userHasMovedCamera = MutableStateFlow(false) + override val showQuickSettings = callbackFlow { + send(prefs.showQuickSettings) + val listener = prefs.onShowQuickSettingsChanged { trySend(it) } + awaitClose { listener.deactivate() } + }.stateIn(viewModelScope, SharingStarted.Eagerly, prefs.showQuickSettings) + override val reverseQuestOrder = MutableStateFlow(false) + override val showMainMenuDialog = mutableStateOf(false) + // --------------------------------------------------------------------------------------- init { diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/NearbyQuestMonitor.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/NearbyQuestMonitor.kt new file mode 100644 index 00000000000..c375a1b1383 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/NearbyQuestMonitor.kt @@ -0,0 +1,188 @@ +package de.westnordost.streetcomplete.screens.main + +import android.Manifest +import android.app.Notification +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.net.ConnectivityManager +import android.os.Binder +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.widget.Toast +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import com.russhwolf.settings.ObservableSettings +import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.download.DownloadController +import de.westnordost.streetcomplete.data.download.Downloader +import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesSource +import de.westnordost.streetcomplete.data.download.tiles.enclosingTilePos +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.quest.Quest +import de.westnordost.streetcomplete.data.quest.VisibleQuestsSource +import de.westnordost.streetcomplete.util.buildGeoUri +import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds +import de.westnordost.streetcomplete.util.ktx.toLatLon +import de.westnordost.streetcomplete.util.ktx.toast +import de.westnordost.streetcomplete.util.logs.Log +import de.westnordost.streetcomplete.util.math.distanceTo +import de.westnordost.streetcomplete.util.math.enclosingBoundingBox +import org.koin.android.ext.android.inject +import org.koin.core.component.KoinComponent +import kotlin.math.max + +class NearbyQuestMonitor : Service(), LocationListener, KoinComponent { + + private val locationManager: LocationManager by lazy { applicationContext.getSystemService(LOCATION_SERVICE) as LocationManager } + private val prefs: ObservableSettings by inject() + private val visibleQuestsSource: VisibleQuestsSource by inject() + private val downloadController: DownloadController by inject() + private val downloader: Downloader by inject() + private val downloadedTilesSource: DownloadedTilesSource by inject() + private var lastScanCenter = LatLon(0.0, 0.0) + private val searchRadius = prefs.getFloat(Prefs.QUEST_MONITOR_RADIUS, 50f).toDouble() + private val download = prefs.getBoolean(Prefs.QUEST_MONITOR_DOWNLOAD, false) + private val dataRetainTime = prefs.getInt(Prefs.DATA_RETAIN_TIME, ApplicationConstants.DELETE_OLD_DATA_AFTER_DAYS) * 24L * 60 * 60 * 1000 + + private fun getQuestFoundNotification(size: Int, closest: Quest): Notification = + NotificationCompat.Builder(this, FOUND_CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_notification) + .setContentTitle(getString(R.string.quest_monitor_found, size)) + .setContentText(resources.getString(closest.type.title)) + .setContentIntent(intent(closest.position)) + .setCategory(NotificationCompat.CATEGORY_ALARM) + .build() + + private fun intent(position: LatLon): PendingIntent? { + val intent = Intent(this, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + intent.action = Intent.ACTION_VIEW + intent.data = buildGeoUri(position.latitude, position.longitude).toUri() + return PendingIntentCompat.getActivity(applicationContext, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT, true) + } + + override fun onBind(intent: Intent): IBinder { + running = true + // create notification channels and service notification + val manager = NotificationManagerCompat.from(this) + if (manager.getNotificationChannelCompat(MONITOR_CHANNEL_ID) == null) + manager.createNotificationChannel( + NotificationChannelCompat.Builder(MONITOR_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) + .setName(getString(R.string.quest_monitor_channel_name)) + .build() + ) + if (manager.getNotificationChannelCompat(FOUND_CHANNEL_ID) == null) + manager.createNotificationChannel( + NotificationChannelCompat.Builder(FOUND_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH) + .setName(getString(R.string.quest_monitor_channel_name_found)) + .setVibrationEnabled(true) + .build() + ) + try { + val int = Intent(this, MainActivity::class.java) + int.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + val pi = PendingIntentCompat.getActivity(applicationContext, 0, int, 0, true) + val notification = NotificationCompat.Builder(this, MONITOR_CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_notification) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.quest_monitor_running)) + .setContentIntent(pi) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + startForeground(MONITOR_NOTIFICATION_ID, notification, FOREGROUND_SERVICE_TYPE_LOCATION) + else + startForeground(MONITOR_NOTIFICATION_ID, notification) + lastScanCenter = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)?.toLatLon() + ?: locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)?.toLatLon() + ?: LatLon(0.0, 0.0) + if (prefs.getBoolean(Prefs.QUEST_MONITOR_GPS, false)) + locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, prefs.getInt(Prefs.GPS_INTERVAL, 0) * 1000L, 0.0f, this) + if (prefs.getBoolean(Prefs.QUEST_MONITOR_NET, false)) + locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, prefs.getInt(Prefs.NETWORK_INTERVAL, 5) * 1000L, 0.0f, this) + locationManager.requestLocationUpdates(LocationManager.PASSIVE_PROVIDER, 0L, 0.0f, this) + } catch (e: SecurityException) { + // there is some foreground issue, and of course location permissions + this.toast(R.string.quest_monitor_error, Toast.LENGTH_LONG) + } catch (e: Exception) { + // there is also ForegroundServiceNotAllowedException that can occur, didn't bother investigating details... + // catch other exceptions because ForegroundServiceNotAllowedException is only available on API 31 and up + this.toast(R.string.quest_monitor_error, Toast.LENGTH_LONG) + } + return Binder() + } + + override fun onLocationChanged(location: Location) { + // check whether we have nearby quests + if (location.accuracy > searchRadius) return + val loc = location.toLatLon() + if (loc.distanceTo(lastScanCenter) < searchRadius * 0.7) return // don't scan if we're still close to previous location + lastScanCenter = loc + val quests = visibleQuestsSource.getAll(loc.enclosingBoundingBox(searchRadius)).filter { it.type.dotColor == null } + if (quests.isEmpty()) { + NotificationManagerCompat.from(this).cancel(FOUND_NOTIFICATION_ID) // no quest, no notification + if (download) { + // check whether surrounding area should be downloaded + if (downloader.isDownloadInProgress) return // download already running + val activeNetworkInfo = getSystemService()?.activeNetworkInfo ?: return + if (!activeNetworkInfo.isConnected) return // we are not connected + val ignoreOlderThan = nowAsEpochMilliseconds() - dataRetainTime + val tile = loc.enclosingTilePos(ApplicationConstants.DOWNLOAD_TILE_ZOOM).toTilesRect() + if (downloadedTilesSource.contains(tile, ignoreOlderThan)) return // we already have the area + downloadController.download(loc.enclosingBoundingBox(max(150.0, searchRadius))) // download quests in at least 150 m radius (will likely be a single z16 tile) + } + return + } + val closest = quests.minBy { + // square distance in lat/lon is enough + val lonDiff = loc.latitude - it.position.latitude + val latDiff = loc.longitude - it.position.longitude + latDiff * latDiff + lonDiff * lonDiff + } + val notification = getQuestFoundNotification(quests.size, closest) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) + NotificationManagerCompat.from(this).notify(FOUND_NOTIFICATION_ID, notification) + else + Log.i("NearbyQuestMonitor", "Quests found, but no notification permission") + } + + // not overriding those causes crashes on Android 10 (only?) + override fun onProviderDisabled(provider: String) {} + override fun onProviderEnabled(provider: String) {} + @Deprecated("Deprecated in Java") // so it crashes without this, but complains about deprecation if it's there? WTF? + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} + + override fun onDestroy() { + super.onDestroy() + locationManager.removeUpdates(this) + NotificationManagerCompat.from(this).cancel(FOUND_NOTIFICATION_ID) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + stopForeground(STOP_FOREGROUND_REMOVE) + else + stopForeground(true) // why the fuck can't it detect that the non-deprecated alternative is not available below N? + running = false + } + companion object { + var running = false + private set + } +} + +private const val MONITOR_NOTIFICATION_ID = 759743090 +private const val FOUND_NOTIFICATION_ID = 16540685 +private const val MONITOR_CHANNEL_ID = "quest_monitor" +private const val FOUND_CHANNEL_ID = "quest_found" diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/AbstractBottomSheetFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/AbstractBottomSheetFragment.kt index 375d569e557..090fd3fc2eb 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/AbstractBottomSheetFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/AbstractBottomSheetFragment.kt @@ -13,12 +13,17 @@ import androidx.fragment.app.Fragment import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED +import com.russhwolf.settings.ObservableSettings import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.quests.TagEditor +import de.westnordost.streetcomplete.util.ktx.dpToPx import de.westnordost.streetcomplete.util.ktx.updateMargins import de.westnordost.streetcomplete.view.RoundRectOutlineProvider import de.westnordost.streetcomplete.view.SlidingRelativeLayout import de.westnordost.streetcomplete.view.insets_animation.respectSystemInsets +import org.koin.android.ext.android.inject import kotlin.math.min /** Abstract base class for expandable and closeable bottom sheets. In detail, it does manage the @@ -33,6 +38,7 @@ abstract class AbstractBottomSheetFragment : Fragment(), IsCloseableBottomSheet protected abstract val bottomSheetContainer: SlidingRelativeLayout protected abstract val bottomSheet: ViewGroup protected abstract val scrollViewChild: View + protected val prefs: Preferences by inject() /** Title view of the bottom sheet. Tapping on it expands / retracts the bottom sheet */ protected abstract val bottomSheetTitle: View? @@ -41,6 +47,8 @@ abstract class AbstractBottomSheetFragment : Fragment(), IsCloseableBottomSheet /** View that floats at the bottom on top of any retracted/expanded bottom sheet */ protected abstract val floatingBottomView: View? + protected abstract val floatingBottomView2: View? + open val hideButtonBottomMarginDp = 8 private lateinit var bottomSheetBehavior: BottomSheetBehavior<*> @@ -57,6 +65,7 @@ abstract class AbstractBottomSheetFragment : Fragment(), IsCloseableBottomSheet scrollViewChild.updatePadding(bottom = it.bottom) bottomSheetContainer.updateMargins(top = it.top, left = it.left, right = it.right) floatingBottomView?.updateMargins(bottom = it.bottom) + floatingBottomView2?.updateMargins(bottom = it.bottom + context.resources.dpToPx(hideButtonBottomMarginDp).toInt()) // expanding bottom sheet when keyboard is opened if (minBottomInset < it.bottom) expand() @@ -122,7 +131,8 @@ abstract class AbstractBottomSheetFragment : Fragment(), IsCloseableBottomSheet * requires user confirmation if any changes have been made */ @UiThread override fun onClickClose(onConfirmed: () -> Unit) { - if (!isRejectingClose()) { + // changes != null means we just answered a quest inside tag editor + if (TagEditor.changes != null || !isRejectingClose()) { onDiscard() onConfirmed() } else { diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/AbstractCreateNoteFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/AbstractCreateNoteFragment.kt index d930ff68a21..20ef8837b63 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/AbstractCreateNoteFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/AbstractCreateNoteFragment.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.view.View import android.widget.EditText import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.quests.note_discussion.AttachPhotoFragment import de.westnordost.streetcomplete.util.ktx.nonBlankTextOrNull @@ -16,6 +17,7 @@ abstract class AbstractCreateNoteFragment : AbstractBottomSheetFragment() { protected abstract val noteInput: EditText protected abstract val okButtonContainer: View protected abstract val okButton: View + protected abstract val gpxButton: View private val attachPhotoFragment: AttachPhotoFragment? get() = childFragmentManager.findFragmentById(R.id.attachPhotoFragment) as AttachPhotoFragment? @@ -26,13 +28,14 @@ abstract class AbstractCreateNoteFragment : AbstractBottomSheetFragment() { super.onViewCreated(view, savedInstanceState) noteInput.doAfterTextChanged { updateOkButtonEnablement() } - okButton.setOnClickListener { onClickOk() } + okButton.setOnClickListener { onClickOk(false) } + gpxButton.setOnClickListener { onClickOk(true) } updateOkButtonEnablement() } - private fun onClickOk() { - onComposedNote(noteText!!, attachPhotoFragment?.imagePaths.orEmpty()) + private fun onClickOk(isGpxNote: Boolean) { + onComposedNote(noteText!!, attachPhotoFragment?.imagePaths.orEmpty(), isGpxNote) } override fun onDiscard() { @@ -45,10 +48,14 @@ abstract class AbstractCreateNoteFragment : AbstractBottomSheetFragment() { private fun updateOkButtonEnablement() { if (noteText != null) { okButtonContainer.popIn() + if (prefs.getBoolean(Prefs.GPX_BUTTON, false)) + floatingBottomView2?.popIn() } else { okButtonContainer.popOut() + if (prefs.getBoolean(Prefs.GPX_BUTTON, false)) + floatingBottomView2?.popOut() } } - protected abstract fun onComposedNote(text: String, imagePaths: List) + protected abstract fun onComposedNote(text: String, imagePaths: List, isGpxNote: Boolean) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/CreateNoteFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/CreateNoteFragment.kt index 87f10dfcf29..cb80383799e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/CreateNoteFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/CreateNoteFragment.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.screens.main.bottom_sheet +import android.annotation.SuppressLint import android.content.res.Configuration import android.graphics.PointF import android.os.Bundle @@ -16,7 +17,9 @@ import androidx.core.graphics.toPointF import androidx.core.os.bundleOf import androidx.core.view.isGone import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesSource import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsController @@ -29,6 +32,7 @@ import de.westnordost.streetcomplete.util.ktx.getLocationInWindow import de.westnordost.streetcomplete.util.ktx.hideKeyboard import de.westnordost.streetcomplete.util.ktx.isKeyboardOpen import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope +import de.westnordost.streetcomplete.util.dialogs.showOutsideDownloadedAreaDialog import de.westnordost.streetcomplete.util.viewBinding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -39,6 +43,7 @@ import org.koin.android.ext.android.inject class CreateNoteFragment : AbstractCreateNoteFragment() { private val noteEditsController: NoteEditsController by inject() + private val downloadedTilesSource: DownloadedTilesSource by inject() private var _binding: FragmentCreateNoteBinding? = null private val binding: FragmentCreateNoteBinding get() = _binding!! @@ -51,8 +56,16 @@ class CreateNoteFragment : AbstractCreateNoteFragment() { override val bottomSheetTitle get() = bottomSheetBinding.speechBubbleTitleContainer override val bottomSheetContent get() = bottomSheetBinding.speechbubbleContentContainer override val floatingBottomView get() = bottomSheetBinding.okButton - override val okButton get() = bottomSheetBinding.okButton + override val floatingBottomView2 get() = bottomSheetBinding.hideButton override val okButtonContainer get() = bottomSheetBinding.okButtonContainer + override val gpxButton get() = if (prefs.getBoolean(Prefs.SWAP_GPX_NOTE_BUTTONS, false) && prefs.getBoolean(Prefs.GPX_BUTTON, false)) + bottomSheetBinding.okButton + else + bottomSheetBinding.hideButton + override val okButton get() = if (prefs.getBoolean(Prefs.SWAP_GPX_NOTE_BUTTONS, false) && prefs.getBoolean(Prefs.GPX_BUTTON, false)) + bottomSheetBinding.hideButton + else + bottomSheetBinding.okButton private val contentBinding by viewBinding(FormLeaveNoteBinding::bind, R.id.content) @@ -85,6 +98,7 @@ class CreateNoteFragment : AbstractCreateNoteFragment() { return binding.root } + @SuppressLint("SetTextI18n") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -96,6 +110,11 @@ class CreateNoteFragment : AbstractCreateNoteFragment() { bottomSheetBinding.titleLabel.text = getString(R.string.map_btn_create_note) contentBinding.descriptionLabel.text = getString(R.string.create_new_note_description) + if (prefs.getBoolean(Prefs.GPX_BUTTON, false)) { + bottomSheetBinding.okButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0,0,0,0) // removes check drawable + gpxButton.text = "GPX" + okButton.text = "OSM" + } } override fun onDestroyView() { @@ -137,10 +156,10 @@ class CreateNoteFragment : AbstractCreateNoteFragment() { binding.markerCreateLayout.markerLayoutContainer.visibility = View.INVISIBLE } - override fun onComposedNote(text: String, imagePaths: List) { + override fun onComposedNote(text: String, imagePaths: List, isGpxNote: Boolean) { /* pressing once on "OK" should first only close the keyboard, so that the user can review - the position of the note he placed */ - if (contentBinding.noteInput.isKeyboardOpen) { + the position of the note he placed (this is now optional) */ + if (prefs.getBoolean(Prefs.HIDE_KEYBOARD_FOR_NOTE, true) && contentBinding.noteInput.isKeyboardOpen) { contentBinding.noteInput.hideKeyboard() return } @@ -149,15 +168,18 @@ class CreateNoteFragment : AbstractCreateNoteFragment() { val screenPos = createNoteMarker.getLocationInWindow() screenPos.offset(createNoteMarker.width / 2, createNoteMarker.height / 2) val position = listener?.getMapPositionAt(screenPos.toPointF()) ?: return + showOutsideDownloadedAreaDialog(requireContext(), position, downloadedTilesSource) { reallyCreateNote(text, imagePaths, isGpxNote, position) } + } + private fun reallyCreateNote(text: String, imagePaths: List, isGpxNote: Boolean, position: LatLon) { binding.markerCreateLayout.markerLayoutContainer.visibility = View.INVISIBLE - val fullText = "$text\n\nvia ${ApplicationConstants.USER_AGENT}" + val fullText = if (isGpxNote) text else "$text\n\nvia ${ApplicationConstants.USER_AGENT}" viewLifecycleScope.launch { withContext(Dispatchers.IO) { val recordedTrack = if (hasGpxAttached) listener?.getRecordedTrack().orEmpty() else emptyList() - noteEditsController.add(0, NoteEditAction.CREATE, position, fullText, imagePaths, recordedTrack) + noteEditsController.add(0, NoteEditAction.CREATE, position, fullText, imagePaths, recordedTrack, isGpxNote, context) } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/CreatePoiFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/CreatePoiFragment.kt new file mode 100644 index 00000000000..032e6df0261 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/CreatePoiFragment.kt @@ -0,0 +1,138 @@ +package de.westnordost.streetcomplete.screens.main.bottom_sheet + +import android.os.Bundle +import android.view.View +import android.widget.RelativeLayout +import androidx.core.graphics.toPointF +import androidx.core.os.bundleOf +import androidx.lifecycle.lifecycleScope +import de.westnordost.osmfeatures.Feature +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesSource +import de.westnordost.streetcomplete.data.osm.edits.ElementEditType +import de.westnordost.streetcomplete.data.osm.edits.create.CreateNodeAction +import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osm.mapdata.Node +import de.westnordost.streetcomplete.data.quest.QuestKey +import de.westnordost.streetcomplete.data.visiblequests.LevelFilter +import de.westnordost.streetcomplete.osm.isPlace +import de.westnordost.streetcomplete.quests.TagEditor +import de.westnordost.streetcomplete.util.ktx.getLocationInWindow +import de.westnordost.streetcomplete.util.dialogs.showOutsideDownloadedAreaDialog +import de.westnordost.streetcomplete.view.checkIsSurvey +import de.westnordost.streetcomplete.view.confirmIsSurvey +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.koin.android.ext.android.inject + +/** Abstract base class for a bottom sheet that lets the user create a note */ +class CreatePoiFragment : TagEditor() { + + // keep the listener from note fragment, there is nothing note-specific happening anyway + private val listener: CreateNoteFragment.Listener? get() = parentFragment as? CreateNoteFragment.Listener ?: activity as? CreateNoteFragment.Listener + private val levelFilter: LevelFilter by inject() + private val downloadedTilesSource: DownloadedTilesSource by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val prefillTags: Map = arguments?.getString(ARG_PREFILLED_TAGS)?.let { Json.decodeFromString(it) } ?: emptyMap() + newTags.putAll(prefillTags) + val allowedLevel = levelFilter.allowedLevel + if (levelFilter.isEnabled && allowedLevel != null && !newTags.contains("level") && !newTags.contains("level:ref") && !newTags.contains("addr:floor")) { + val levelTag = if (levelFilter.allowedLevelTags.size == 1) levelFilter.allowedLevelTags.single() + else if (levelFilter.allowedLevelTags.contains("level:ref") && "[a-zA-Z]".toRegex().containsMatchIn(allowedLevel)) "level:ref" + else "level" + newTags[levelTag] = if (levelTag == "level:ref") allowedLevel + else allowedLevel.toIntOrNull()?.toString() ?: "" + } + tagList.clear() + tagList.addAll(newTags.toList()) + tagList.sortBy { it.first } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.elementInfo.text = arguments?.getString(ARG_NAME) ?: "" + // set editorContainer top margin so the marker is always visible + val p = binding.editorContainer.layoutParams as RelativeLayout.LayoutParams + p.topMargin = (resources.displayMetrics.heightPixels - resources.getDimensionPixelOffset(R.dimen.quest_form_bottomOffset)) * 2 / 3 + + arguments?.getString(ARG_ID)?.let { + val recentFeatureIds = prefs.getString(Prefs.CREATE_POI_RECENT_FEATURE_IDS, "").split("§").toMutableList() + if (recentFeatureIds.lastOrNull() == it) return@let + recentFeatureIds.remove(it) + recentFeatureIds.add(it) + prefs.putString(Prefs.CREATE_POI_RECENT_FEATURE_IDS, recentFeatureIds.takeLast(25).joinToString("§")) + } + + binding.markerCreateLayout.pin.pinIconView.setImageResource(R.drawable.ic_add_poi) + binding.markerCreateLayout.root.visibility = View.VISIBLE + } + + override suspend fun applyEdit() { + val createNoteMarker = binding.markerCreateLayout.pin.root + val screenPos = createNoteMarker.getLocationInWindow() + screenPos.offset(createNoteMarker.width / 2, createNoteMarker.height / 2) + val position = listener?.getMapPositionAt(screenPos.toPointF()) ?: return + showOutsideDownloadedAreaDialog(requireContext(), position, downloadedTilesSource) { + lifecycleScope.launch { reallyApplyEdit(position) } + } + } + + private suspend fun reallyApplyEdit(position: LatLon) { + val isSurvey = checkIsSurvey(ElementPointGeometry(position), recentLocationStore.get()) + if (!isSurvey && !confirmIsSurvey(requireContext())) + return + elementEditsController.add(addNodeEdit, ElementPointGeometry(position), "survey", CreateNodeAction(position, element.tags), isSurvey, questKey) + listener?.onCreatedNote(position) + arguments?.getString(ARG_ID)?.let { + val prefillTags: Map = arguments?.getString(ARG_PREFILLED_TAGS)?.let { Json.decodeFromString(it) } ?: emptyMap() + if (!element.isPlace() && prefillTags != element.tags) + prefs.putString(Prefs.CREATE_NODE_LAST_TAGS_FOR_FEATURE + it, Json.encodeToString(element.tags)) + } + } + + companion object { + private const val ARG_PREFILLED_TAGS = "prefilled_tags" + private const val ARG_NAME = "feature_name" + private const val ARG_ID = "feature_id" + + fun createFromFeature(feature: Feature?, pos: LatLon) = CreatePoiFragment().also { + it.arguments = bundleOf(ARG_PREFILLED_TAGS to feature?.addTags?.let { Json.encodeToString(it) }, ARG_NAME to feature?.name, ARG_ID to feature?.id) + // tag editor arguments are actually unnecessary here, but we still need an original element + it.requireArguments().putAll(createArguments(Node(0L, pos), ElementPointGeometry(pos), null, null)) + } + fun createWithPrefill(prefill: String, pos: LatLon, questKey: QuestKey? = null) = CreatePoiFragment().also { + // this will only prefill if there is one equals sign in the line + it.arguments = bundleOf(ARG_PREFILLED_TAGS to Json.encodeToString(prefill.toTags())) + it.requireArguments().putAll(createArguments(Node(0L, pos), ElementPointGeometry(pos), null, null, questKey)) + } + } +} + +val addNodeEdit = object : ElementEditType { + override val icon: Int = R.drawable.ic_add_poi + override val title: Int = R.string.create_poi + override val wikiLink: String? = null + override val changesetComment: String = "Add node" + override val name: String = "AddNode" +} + +// convert simple key = value pairs into tags, and understand simple filter expressions +fun String.toTags(): Map { + val tags = mutableMapOf() + if (!contains('(')) + split("\n", " and ").forEach { line -> + if (line.isBlank() || line.contains(" or ")) return@forEach + val kv = line.split("=", "!~", "~") + if (kv.size != 1 && kv.size != 2) return@forEach + if ('|' in kv[0] || '!' in kv[0] || '*' in kv[0]) return@forEach + if (kv.size == 1 || "!=" in line || '~' in line) tags[kv[0].trim()] = "" + else tags[kv[0].trim()] = kv[1].trim() + } + return tags +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/InsertNodeFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/InsertNodeFragment.kt new file mode 100644 index 00000000000..96e2acb59b1 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/InsertNodeFragment.kt @@ -0,0 +1,441 @@ +package de.westnordost.streetcomplete.screens.main.bottom_sheet + +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.PointF +import android.os.Bundle +import android.text.method.ScrollingMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.AnimationUtils +import androidx.annotation.UiThread +import androidx.core.graphics.Insets +import androidx.core.os.bundleOf +import androidx.core.view.doOnLayout +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import com.russhwolf.settings.ObservableSettings +import de.westnordost.countryboundaries.CountryBoundaries +import de.westnordost.osmfeatures.Feature +import de.westnordost.osmfeatures.FeatureDictionary +import de.westnordost.osmfeatures.GeometryType +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource +import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Way +import de.westnordost.streetcomplete.data.visiblequests.LevelFilter +import de.westnordost.streetcomplete.databinding.FragmentInsertNodeBinding +import de.westnordost.streetcomplete.databinding.RowInsertNodeElementBinding +import de.westnordost.streetcomplete.overlays.AbstractOverlayForm +import de.westnordost.streetcomplete.screens.main.MainActivity +import de.westnordost.streetcomplete.screens.main.map.MainMapFragment +import de.westnordost.streetcomplete.screens.main.map.Marker +import de.westnordost.streetcomplete.screens.main.map.ShowsGeometryMarkers +import de.westnordost.streetcomplete.screens.main.map.getIcon +import de.westnordost.streetcomplete.screens.main.map.getTitle +import de.westnordost.streetcomplete.screens.main.map.maplibre.toPadding +import de.westnordost.streetcomplete.util.getNameAndLocationSpanned +import de.westnordost.streetcomplete.util.ktx.dpToPx +import de.westnordost.streetcomplete.util.ktx.popIn +import de.westnordost.streetcomplete.util.ktx.popOut +import de.westnordost.streetcomplete.util.ktx.setMargins +import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope +import de.westnordost.streetcomplete.util.math.PositionOnCrossingWaySegments +import de.westnordost.streetcomplete.util.math.PositionOnWay +import de.westnordost.streetcomplete.util.math.PositionOnWaySegment +import de.westnordost.streetcomplete.util.math.PositionOnWaysSegment +import de.westnordost.streetcomplete.util.math.VertexOfWay +import de.westnordost.streetcomplete.util.math.contains +import de.westnordost.streetcomplete.util.math.enclosingBoundingBox +import de.westnordost.streetcomplete.util.math.getPositionOnWaysForInsertNodeFragment +import de.westnordost.streetcomplete.view.RoundRectOutlineProvider +import de.westnordost.streetcomplete.view.dialogs.SearchFeaturesDialog +import de.westnordost.streetcomplete.view.insets_animation.respectSystemInsets +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.koin.android.ext.android.inject +import org.koin.core.qualifier.named + +/** Fragment that lets the user split an OSM way */ +class InsertNodeFragment : + Fragment(R.layout.fragment_split_way), IsCloseableBottomSheet, IsMapPositionAware { + + private var _binding: FragmentInsertNodeBinding? = null + private val binding get() = _binding!! + + private val mapDataSource: MapDataWithEditsSource by inject() + private val featureDictionary: Lazy by inject(named("FeatureDictionaryLazy")) + private val countryBoundaries: Lazy by inject(named("CountryBoundariesLazy")) + private val prefs: ObservableSettings by inject() + private val levelFilter: LevelFilter by inject() + + private val isFormComplete get() = positionOnWay != null + + private val showsGeometryMarkersListener: ShowsGeometryMarkers? get() = + parentFragment as? ShowsGeometryMarkers ?: activity as? ShowsGeometryMarkers + private val overlayFormListener: AbstractOverlayForm.Listener? get() = parentFragment as? AbstractOverlayForm.Listener ?: activity as? AbstractOverlayForm.Listener + private val initialMap = prefs.getString(Prefs.THEME_BACKGROUND, "MAP") + private val tagsText by lazy { binding.tagsText.apply { + maxLines = 8 + isClickable = true + scrollBarFadeDuration = 0 + movementMethod = ScrollingMovementMethod() + } } + private val mapFragment by lazy { + (activity as? MainActivity)?.supportFragmentManager?.fragments?.filterIsInstance()?.singleOrNull() + } + private lateinit var mapData: MapDataWithGeometry + private lateinit var ways: List>> + private var positionOnWay: PositionOnWay? = null + set(value) { + val sameExceptForPosition = when { + field is PositionOnWaySegment && value is PositionOnWaySegment -> (field as PositionOnWaySegment).let { it.wayId == value.wayId && it.segment == value.segment } + field is PositionOnWaysSegment && value is PositionOnWaysSegment -> (field as PositionOnWaysSegment).insertIntoWaysAt == value.insertIntoWaysAt + field is VertexOfWay && value is VertexOfWay -> (field as VertexOfWay).let { it.wayIds == value.wayIds && it.nodeId == value.nodeId } + else -> field == value + } + field = value + setMarkerPosition(value?.position) + if (!sameExceptForPosition) // no need to set texts to same values and highlight the same elements again + onSelectedWays() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentInsertNodeBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.createMarker.doOnLayout { setMarkerPosition(null) } + binding.bottomSheetContainer.respectSystemInsets(View::setMargins) + + binding.okButton.setOnClickListener { onClickOk() } + binding.cancelButton.setOnClickListener { activity?.onBackPressed() } + + binding.undoButton.isInvisible = true + binding.okButton.isInvisible = !isFormComplete + binding.mapButton.setOnClickListener { toggleBackground() } + updateMapButtonText() + + val cornerRadius = resources.getDimension(R.dimen.speech_bubble_rounded_corner_radius) + val margin = resources.getDimensionPixelSize(R.dimen.horizontal_speech_bubble_margin) + binding.speechbubbleContentContainer.outlineProvider = RoundRectOutlineProvider( + cornerRadius, margin, margin, margin, margin + ) + + val args = requireArguments() + val pos: LatLon = Json.decodeFromString(args.getString(ARG_POS)!!) + getMapData(pos) + + if (savedInstanceState == null) { + binding.speechbubbleContentContainer.startAnimation( + AnimationUtils.loadAnimation(context, R.anim.inflate_answer_bubble) + ) + } + val insets = Insets.of( // slightly lower position of marker than usual + resources.getDimensionPixelSize(R.dimen.quest_form_leftOffset), + resources.getDimensionPixelSize(R.dimen.quest_form_topOffset), + resources.getDimensionPixelSize(R.dimen.quest_form_rightOffset), + resources.getDimensionPixelSize(R.dimen.quest_form_bottomOffset) / 2 + ) + mapFragment?.updateCameraPosition(300) { + position = pos + padding = insets.toPadding() + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + // see rant comment in AbstractBottomSheetFragment + resources.updateConfiguration(newConfig, resources.displayMetrics) + + binding.bottomSheetContainer.updateLayoutParams { width = resources.getDimensionPixelSize(R.dimen.quest_form_width) } + } + + private fun onClickOk() { + val pow = positionOnWay ?: return + val fd = featureDictionary.value + val country = countryBoundaries.value.getIds(pow.position.longitude, pow.position.latitude).firstOrNull() + val defaultFeatureIds = prefs.getString(Prefs.INSERT_NODE_RECENT_FEATURE_IDS, "") + .split("§").filter { it.isNotBlank() } + .ifEmpty { listOf("amenity/post_box", "barrier/gate", "highway/crossing/unmarked", "highway/crossing/uncontrolled", "highway/traffic_signals", "barrier/bollard", "traffic_calming/table") } + + // also allow empty somehow? + SearchFeaturesDialog( + requireContext(), + fd, + GeometryType.VERTEX, + country, + null, // pre-filled search text + { true }, // filter, but we want everything + { onSelectedFeature(it, pow) }, + defaultFeatureIds.reversed(), + false, + pow.position, + ).show() + restoreBackground() + } + + private fun onSelectedFeature(feature: Feature, positionOnWay: PositionOnWay) { + viewLifecycleScope.launch { + val recentFeatureIds = prefs.getString(Prefs.INSERT_NODE_RECENT_FEATURE_IDS, "").split("§").toMutableList() + if (recentFeatureIds.lastOrNull() != feature.id) { + recentFeatureIds.remove(feature.id) + recentFeatureIds.add(feature.id) + prefs.putString(Prefs.INSERT_NODE_RECENT_FEATURE_IDS, recentFeatureIds.takeLast(25).joinToString("§")) + } + showsGeometryMarkersListener?.putMarkersForCurrentHighlighting( // currently not done, but still need it + listOf(Marker( + ElementPointGeometry(positionOnWay.position), + R.drawable.crosshair_marker, + null + )) + ) + val mapData = mapDataSource.getMapDataWithGeometry(positionOnWay.position.enclosingBoundingBox(30.0)) + val nearbySimilarElements = mapData.filter { e -> feature.tags.all { e.tags[it.key] == it.value } } + showsGeometryMarkersListener?.putMarkersForCurrentHighlighting(nearbySimilarElements.mapNotNull { + val geo = mapData.getGeometry(it.type, it.id) ?: return@mapNotNull null + Marker( + geo, + getIcon(featureDictionary.value, it), + getTitle(it.tags) + ) + }) + } + val startTags = (positionOnWay as? VertexOfWay)?.let { mapData.getNode(it.nodeId) }?.tags ?: emptyMap() + val f = InsertNodeTagEditor.create(positionOnWay, feature, startTags) + parentFragmentManager.commit { + replace(id, f, "bottom_sheet") + addToBackStack("bottom_sheet") + } + } + + private fun toggleBackground() { + prefs.putString(Prefs.THEME_BACKGROUND, if (prefs.getString(Prefs.THEME_BACKGROUND, "MAP") == "MAP") "AERIAL" else "MAP") + updateMapButtonText() + } + + private fun updateMapButtonText() { + val isMap = prefs.getString(Prefs.THEME_BACKGROUND, "MAP") == "MAP" + val textId = if (isMap) R.string.background_type_aerial_esri else R.string.background_type_map + binding.mapButton.setText(textId) + } + + private fun restoreBackground() { + if (prefs.getString(Prefs.THEME_BACKGROUND, "MAP") != initialMap) + prefs.putString(Prefs.THEME_BACKGROUND, initialMap) + } + + override fun onMapMoved(position: LatLon) { + newPosition(position) + } + + private fun newPosition(position: LatLon, forceMoveMarker: Boolean = false) { + if (position !in mapData.boundingBox!!) + getMapData(position) + val metersPerPixel = overlayFormListener?.metersPerPixel ?: return + val maxDistance = metersPerPixel * requireContext().resources.dpToPx(20) + val snapToVertexDistance = maxDistance / 2 + + positionOnWay = + if (forceMoveMarker) position.getPositionOnWaysForInsertNodeFragment(ways, maxDistance, snapToVertexDistance) + else getDefaultMarkerPosition()?.getPositionOnWaysForInsertNodeFragment(ways, maxDistance, snapToVertexDistance) + } + + private fun onSelectedWays() { + val pow = positionOnWay + if (pow == null) { + noWaySelected() + return + } + val ways = pow.getWays() + + if (ways.isEmpty()) { // actually this should never happen + noWaySelected() + return + } + binding.elements.removeAllViews() + + val vertex = if (pow is VertexOfWay) mapData.getNode(pow.nodeId) else null + // show tags of node, or of a way if node has no tags + if (vertex?.tags?.isNotEmpty() == true) { + addViewForElement(vertex) + setTagsText(vertex) + } else { + setTagsText(ways.first()) + } + ways.forEach { addViewForElement(it) } + + // highlight ways and position + viewLifecycleScope.launch { + showsGeometryMarkersListener?.clearMarkersForCurrentHighlighting() + if (vertex?.tags?.isNotEmpty() != true && ways.size > 1) + // highlight the way we show tags of + mapData.getWayGeometry(ways.first().id)?.let { + showsGeometryMarkersListener?.putMarkersForCurrentHighlighting(listOf(Marker(it, null, null, Color.GREEN))) + } + mapFragment?.highlightGeometries(ways.mapNotNull { mapData.getWayGeometry(it.id) }) + showsGeometryMarkersListener?.putMarkersForCurrentHighlighting(ways.flatMap { way -> + way.nodeIds.mapNotNull { + val node = mapData.getNode(it) ?: return@mapNotNull null + if (node.tags.isEmpty()) return@mapNotNull null + Marker( + ElementPointGeometry(node.position), + getIcon(featureDictionary.value, node), + getTitle(node.tags) + ) + } + }) + } + animateButtonVisibilities() + } + // todo: had an issue where nothing was highlighted, but can't reproduce + // how is this possible? don't remember whether text was shown correctly, but this would help massively + // if text shown: how can highlighting be cancelled without clearing the text? + // if not: maybe some issue in positionOnWay setter? + + private fun addViewForElement(element: Element) { + val view = RowInsertNodeElementBinding.inflate(layoutInflater) + view.elementText.text = getElementText(element) + view.elementText.setOnClickListener { + setTagsText(element) + // highlight the selected way, but not in orange (because that should stay) + positionOnWay?.getWays()?.forEach { + if (it == element) return@forEach + mapData.getGeometry(it.type, it.id)?.let { mapFragment?.deleteMarkerForCurrentHighlighting(it) } + } + if (element is Way) // highlighting nodes may remove other markers, e.g. for a crossing. so just don't + mapData.getGeometry(element.type, element.id)?.let { mapFragment?.putMarkersForCurrentHighlighting(listOf(Marker(it, null, null, Color.GREEN))) } + } + val pow = positionOnWay + if (pow is PositionOnWaysSegment && element is Way && pow.insertIntoWaysAt.size > 1) { + view.deleteButton.isVisible = true + view.deleteButton.setOnClickListener { + val powNow = positionOnWay + if (powNow !is PositionOnWaysSegment) return@setOnClickListener + val inserts = powNow.insertIntoWaysAt.filterNot { it.wayId == element.id } + positionOnWay = PositionOnWaysSegment(powNow.position, inserts) + } + } + binding.elements.addView(view.root) + } + + private fun setTagsText(element: Element) { + val text = if (element.tags.isEmpty()) { + // check whether empty line is part of multipolygon + val relation = mapDataSource.getRelationsForElement(element.type, element.id).firstOrNull { + it.tags["type"] == "multipolygon" + } + if (relation == null || relation.tags.isEmpty()) "" + else { + "no tags, but part of multipolygon:\n" + + relation.tags.map { "${it.key} = ${it.value}" }.sorted().joinToString("\n") + } + } else + element.tags.map { "${it.key} = ${it.value}" }.sorted().joinToString("\n") + if (tagsText.text != text) { + tagsText.text = text + tagsText.scrollY = 0 + } + } + + private fun getElementText(element: Element): CharSequence { + val title = getNameAndLocationSpanned(element, resources, featureDictionary.value, false) + return title ?: "${element.type} ${element.id}" + } + + private fun noWaySelected() { + binding.elements.removeAllViews() + tagsText.setText(R.string.insert_node_select_way) + showsGeometryMarkersListener?.clearMarkersForCurrentHighlighting() + mapFragment?.clearHighlighting() + animateButtonVisibilities() + } + + private fun setMarkerPosition(position: LatLon?) { + val point = if (position == null) { + getDefaultMarkerScreenPosition() + } else { + overlayFormListener?.getPointOf(position) + } ?: return + binding.createMarker.x = point.x - binding.createMarker.width / 2 + binding.createMarker.y = point.y - binding.createMarker.height / 2 + } + + private fun getDefaultMarkerPosition(): LatLon? { + val point = getDefaultMarkerScreenPosition() ?: return null + return overlayFormListener?.getMapPositionAt(point) + } + + private fun getDefaultMarkerScreenPosition(): PointF? { + val view = view ?: return null + val left = resources.getDimensionPixelSize(R.dimen.quest_form_leftOffset) + val right = resources.getDimensionPixelSize(R.dimen.quest_form_rightOffset) + val top = resources.getDimensionPixelSize(R.dimen.quest_form_topOffset) + val bottom = resources.getDimensionPixelSize(R.dimen.quest_form_bottomOffset) / 2 + val x = (view.width + left - right) / 2f + val y = (view.height + top - bottom) / 2f + return PointF(x, y) + } + + override fun onClickMapAt(position: LatLon, clickAreaSizeInMeters: Double): Boolean { + newPosition(position, forceMoveMarker = true) + return true + } + + @UiThread override fun onClickClose(onConfirmed: () -> Unit) { + restoreBackground() + onConfirmed() + } + + private fun getMapData(position: LatLon) { + mapData = mapDataSource.getMapDataWithGeometry(position.enclosingBoundingBox(100.0)) + ways = mapData.ways.mapNotNull { way -> + if (!levelFilter.levelAllowed(way)) return@mapNotNull null + val positions = way.nodeIds.map { + val node = mapData.getNode(it) ?: throw IllegalStateException("node $it of way ${way.id} not in map data") // todo: remove later, but keep for testing + node.position + } + way to positions + } + } + + private fun animateButtonVisibilities() { + if (isFormComplete) binding.okButton.popIn() else binding.okButton.popOut() + } + + private fun PositionOnWay.getWays() = when (this) { + is PositionOnWaySegment -> listOf(mapData.getWay(wayId)!!) + is VertexOfWay -> wayIds.map { mapData.getWay(it)!! } + is PositionOnWaysSegment -> insertIntoWaysAt.map { mapData.getWay(it.wayId)!! } + is PositionOnCrossingWaySegments -> insertIntoWaysAt.map { mapData.getWay(it.wayId)!! } + } + + companion object { + private const val ARG_POS = "pos" + + fun create(position: LatLon): InsertNodeFragment { + val f = InsertNodeFragment() + f.arguments = bundleOf( + ARG_POS to Json.encodeToString(position), + ) + return f + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/InsertNodeTagEditor.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/InsertNodeTagEditor.kt new file mode 100644 index 00000000000..42a1dbdcc87 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/InsertNodeTagEditor.kt @@ -0,0 +1,82 @@ +package de.westnordost.streetcomplete.screens.main.bottom_sheet + +import android.os.Bundle +import android.view.View +import androidx.core.os.bundleOf +import de.westnordost.osmfeatures.Feature +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.data.osm.edits.create.createNodeAction +import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.Node +import de.westnordost.streetcomplete.osm.isPlace +import de.westnordost.streetcomplete.quests.TagEditor +import de.westnordost.streetcomplete.util.math.PositionOnWay +import de.westnordost.streetcomplete.view.checkIsSurvey +import de.westnordost.streetcomplete.view.confirmIsSurvey +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** Fragment that lets the user split an OSM way */ +class InsertNodeTagEditor : TagEditor() { + + private val listener: CreateNoteFragment.Listener? get() = parentFragment as? CreateNoteFragment.Listener ?: activity as? CreateNoteFragment.Listener + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + newTags.putAll(arguments?.getString(ARG_TAGS)?.let { Json.decodeFromString(it) } ?: emptyMap()) + tagList.clear() + tagList.addAll(newTags.toList()) + tagList.sortBy { it.first } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.elementInfo.text = arguments?.getString(ARG_FEATURE_NAME) ?: "" + } + + override suspend fun applyEdit() { + val args = requireArguments() + val positionOnWay: PositionOnWay = Json.decodeFromString(args.getString(ARG_POSITION_ON_WAY)!!) + val isSurvey = checkIsSurvey(ElementPointGeometry(positionOnWay.position), recentLocationStore.get()) + if (!isSurvey && !confirmIsSurvey(requireContext())) + return + val action = createNodeAction(positionOnWay, mapDataSource) { changeBuilder -> + changeBuilder.keys.forEach { if (it !in element.tags) changeBuilder.remove(it) } // remove tags, only relevant if there are startTags + element.tags.forEach { changeBuilder[it.key] = it.value } // and add changes + } ?: return + + elementEditsController.add(addNodeEdit, ElementPointGeometry(positionOnWay.position), "survey", action, isSurvey) + listener?.onCreatedNote(positionOnWay.position) + arguments?.getString(ARG_FEATURE_ID)?.let { + val initialTags: Map = arguments?.getString(ARG_TAGS)?.let { Json.decodeFromString(it) } ?: emptyMap() + if (!element.isPlace() && initialTags != element.tags) + prefs.putString(Prefs.CREATE_NODE_LAST_TAGS_FOR_FEATURE + it, Json.encodeToString(element.tags)) + } + } + + companion object { + private const val ARG_POSITION_ON_WAY = "position_on_way" + private const val ARG_FEATURE_NAME = "feature_name" + private const val ARG_FEATURE_ID = "feature_id" + private const val ARG_TAGS = "tags" + + fun create(positionOnWay: PositionOnWay, feature: Feature?, startTags: Map = emptyMap()): InsertNodeTagEditor { + val f = InsertNodeTagEditor() + val args = createArguments(Node(0L, positionOnWay.position, startTags), ElementPointGeometry(positionOnWay.position), null, null) + val tags = HashMap() + startTags.forEach { tags[it.key] = it.value } // only relevant if a an existing node with non-empty tags is re-used + feature?.addTags?.forEach { tags[it.key] = it.value } + args.putAll(bundleOf( + ARG_POSITION_ON_WAY to Json.encodeToString(positionOnWay), + ARG_TAGS to tags, + )) + feature?.let { + args.putString(ARG_FEATURE_NAME, it.name) + args.putString(ARG_FEATURE_ID, it.id) + args.putString(ARG_TAGS, Json.encodeToString(it.addTags)) + } + f.arguments = args + return f + } + } +} 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 index b4188855894..be4de8c4048 100644 --- 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 @@ -9,9 +9,12 @@ import androidx.annotation.UiThread import androidx.appcompat.app.AlertDialog import androidx.core.graphics.toPointF import androidx.core.os.bundleOf +import androidx.core.view.isGone import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import com.russhwolf.settings.ObservableSettings import de.westnordost.countryboundaries.CountryBoundaries +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.AllEditTypes import de.westnordost.streetcomplete.data.location.RecentLocationStore @@ -25,7 +28,6 @@ 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.mapdata.key import de.westnordost.streetcomplete.databinding.FragmentMoveNodeBinding import de.westnordost.streetcomplete.overlays.IsShowingElement import de.westnordost.streetcomplete.screens.measure.MeasureDisplayUnit @@ -60,6 +62,7 @@ class MoveNodeFragment : private val countryBoundaries: Lazy by inject(named("CountryBoundariesLazy")) private val countryInfos: CountryInfos by inject() private val recentLocationStore: RecentLocationStore by inject() + private val prefs: ObservableSettings by inject() override val elementKey: ElementKey by lazy { node.key } @@ -68,6 +71,7 @@ class MoveNodeFragment : private lateinit var displayUnit: MeasureDisplayUnit private lateinit var arrowDrawable: ArrowDrawable + private val initialMap = prefs.getString(Prefs.THEME_BACKGROUND, "MAP") private val hasChanges get() = getMarkerPosition() != node.position @@ -105,6 +109,8 @@ class MoveNodeFragment : binding.okButton.setOnClickListener { onClickOk() } binding.cancelButton.setOnClickListener { activity?.onBackPressed() } binding.pin.pinIconView.setImageResource(editType.icon) + binding.mapButton.setOnClickListener { toggleBackground() } + updateMapButtonText() val cornerRadius = resources.getDimension(R.dimen.speech_bubble_rounded_corner_radius) val margin = resources.getDimensionPixelSize(R.dimen.horizontal_speech_bubble_margin) @@ -135,6 +141,22 @@ class MoveNodeFragment : ) } + private fun toggleBackground() { + prefs.putString(Prefs.THEME_BACKGROUND, if (prefs.getString(Prefs.THEME_BACKGROUND, "MAP") == "MAP") "AERIAL" else "MAP") + updateMapButtonText() + } + + private fun updateMapButtonText() { + val isMap = prefs.getString(Prefs.THEME_BACKGROUND, "MAP") == "MAP" + val textId = if (isMap) R.string.background_type_aerial_esri else R.string.background_type_map + binding.mapButton.setText(textId) + } + + private fun restoreBackground() { + if (prefs.getString(Prefs.THEME_BACKGROUND, "MAP") != initialMap) + prefs.putString(Prefs.THEME_BACKGROUND, initialMap) + } + private fun getMarkerScreenPosition(): PointF { val moveNodeMarker = binding.pin.root val screenPos = moveNodeMarker.getLocationInWindow() @@ -148,6 +170,7 @@ class MoveNodeFragment : private fun onClickOk() { val position = getMarkerPosition() ?: return if (!checkIsDistanceOkAndUpdateText(position)) return + restoreBackground() viewLifecycleScope.launch { moveNodeTo(position) } @@ -157,7 +180,7 @@ class MoveNodeFragment : val isSurvey = checkIsSurvey(ElementPointGeometry(position), recentLocationStore.get()) if (isSurvey || confirmIsSurvey(requireContext())) { val action = MoveNodeAction(node, position) - elementEditsController.add(editType, ElementPointGeometry(node.position), "survey", action, isSurvey) + elementEditsController.add(editType, ElementPointGeometry(node.position), "survey,extra", action, isSurvey) listener?.onMovedNode(editType, position) } } @@ -199,12 +222,14 @@ class MoveNodeFragment : @UiThread override fun onClickClose(onConfirmed: () -> Unit) { if (!hasChanges) { + restoreBackground() onConfirmed() } else { activity?.let { AlertDialog.Builder(it) .setMessage(R.string.confirmation_discard_title) .setPositiveButton(R.string.confirmation_discard_positive) { _, _ -> + restoreBackground() onConfirmed() } .setNegativeButton(R.string.short_no_answer_on_button, null) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/SplitWayFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/SplitWayFragment.kt index 8e17ab6b2aa..fe207142d3c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/SplitWayFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/SplitWayFragment.kt @@ -17,6 +17,8 @@ import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment +import com.russhwolf.settings.ObservableSettings +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.AllEditTypes import de.westnordost.streetcomplete.data.location.RecentLocationStore @@ -31,7 +33,6 @@ import de.westnordost.streetcomplete.data.osm.geometry.ElementPolylinesGeometry import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osm.mapdata.Way -import de.westnordost.streetcomplete.data.osm.mapdata.key import de.westnordost.streetcomplete.databinding.FragmentSplitWayBinding import de.westnordost.streetcomplete.overlays.IsShowingElement import de.westnordost.streetcomplete.screens.main.map.Marker @@ -74,6 +75,7 @@ class SplitWayFragment : private val allEditTypes: AllEditTypes by inject() private val soundFx: SoundFx by inject() private val recentLocationStore: RecentLocationStore by inject() + private val prefs: ObservableSettings by inject() override val elementKey: ElementKey by lazy { way.key } @@ -96,6 +98,7 @@ class SplitWayFragment : private val showsGeometryMarkersListener: ShowsGeometryMarkers? get() = parentFragment as? ShowsGeometryMarkers ?: activity as? ShowsGeometryMarkers + private val initialMap = prefs.getString(Prefs.THEME_BACKGROUND, "MAP") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -122,6 +125,8 @@ class SplitWayFragment : binding.undoButton.isInvisible = !hasChanges binding.okButton.isInvisible = !isFormComplete + binding.mapButton.setOnClickListener { toggleBackground() } + updateMapButtonText() val cornerRadius = resources.getDimension(R.dimen.speech_bubble_rounded_corner_radius) val margin = resources.getDimensionPixelSize(R.dimen.horizontal_speech_bubble_margin) @@ -149,13 +154,14 @@ class SplitWayFragment : } private suspend fun splitWay() { + restoreBackground() binding.glassPane.isGone = false if (splits.size <= 2 || confirmManySplits()) { val isSurvey = checkIsSurvey(geometry, recentLocationStore.get()) if (isSurvey || confirmIsSurvey(requireContext())) { val action = SplitWayAction(way, ArrayList(splits.map { it.first })) withContext(Dispatchers.IO) { - elementEditsController.add(editType, geometry, "survey", action, isSurvey) + elementEditsController.add(editType, geometry, "survey,extra", action, isSurvey) } listener?.onSplittedWay(editType, way, geometry) return @@ -187,6 +193,22 @@ class SplitWayFragment : } } + private fun toggleBackground() { + prefs.putString(Prefs.THEME_BACKGROUND, if (prefs.getString(Prefs.THEME_BACKGROUND, "MAP") == "MAP") "AERIAL" else "MAP") + updateMapButtonText() + } + + private fun updateMapButtonText() { + val isMap = prefs.getString(Prefs.THEME_BACKGROUND, "MAP") == "MAP" + val textId = if (isMap) R.string.background_type_aerial_esri else R.string.background_type_map + binding.mapButton.setText(textId) + } + + private fun restoreBackground() { + if (prefs.getString(Prefs.THEME_BACKGROUND, "MAP") != initialMap) + prefs.putString(Prefs.THEME_BACKGROUND, initialMap) + } + @UiThread override fun onClickMapAt(position: LatLon, clickAreaSizeInMeters: Double): Boolean { val splitWayCandidates = createSplits(position, clickAreaSizeInMeters) @@ -274,12 +296,14 @@ class SplitWayFragment : @UiThread override fun onClickClose(onConfirmed: () -> Unit) { if (!hasChanges) { + restoreBackground() onConfirmed() } else { activity?.let { AlertDialog.Builder(it) .setMessage(R.string.confirmation_discard_title) .setPositiveButton(R.string.confirmation_discard_positive) { _, _ -> + restoreBackground() onConfirmed() } .setNegativeButton(R.string.short_no_answer_on_button, null) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/OverlaySelectionButton.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/OverlaySelectionButton.kt index b40a901070c..fd8942a344f 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/OverlaySelectionButton.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/OverlaySelectionButton.kt @@ -4,10 +4,16 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.overlays.Overlay +import de.westnordost.streetcomplete.overlays.custom.CustomOverlay +import de.westnordost.streetcomplete.overlays.custom.getIndexedCustomOverlayPref import de.westnordost.streetcomplete.ui.common.OverlaysIcon +import org.koin.compose.koinInject /** Overlay selection button that shows the icon of the currently selected overlay */ @Composable @@ -16,7 +22,14 @@ fun OverlaySelectionButton( overlay: Overlay?, modifier: Modifier = Modifier ) { - val icon = overlay?.icon + val prefs: Preferences = koinInject() + val icon = if (overlay is CustomOverlay){ + val index = prefs.getInt(Prefs.CUSTOM_OVERLAY_SELECTED_INDEX, 0) + LocalContext.current.resources.getIdentifier( + prefs.getString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_ICON, index), "ic_custom_overlay"), + "drawable", LocalContext.current.packageName + ) + } else overlay?.icon MapButton( onClick = onClick, modifier = modifier, @@ -25,7 +38,7 @@ fun OverlaySelectionButton( if (icon != null) { Image( painter = painterResource(icon), - contentDescription = overlay.name, + contentDescription = overlay!!.name, modifier = Modifier.size(36.dp) ) } else { diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/QuickSettingsDropdown.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/QuickSettingsDropdown.kt new file mode 100644 index 00000000000..c697533bd1c --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/QuickSettingsDropdown.kt @@ -0,0 +1,70 @@ +package de.westnordost.streetcomplete.screens.main.controls + +import androidx.compose.material.DropdownMenu +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.visiblequests.LevelFilter +import de.westnordost.streetcomplete.data.presets.EditTypePresetsController +import de.westnordost.streetcomplete.screens.main.MainViewModel +import de.westnordost.streetcomplete.ui.common.DropdownMenuItem +import de.westnordost.streetcomplete.util.dialogs.showProfileSelectionDialog +import org.koin.compose.koinInject + +@Composable +fun QuickSettingsDropdown( + expanded: Boolean, + onDismissRequest: () -> Unit, + viewModel: MainViewModel, + modifier: Modifier = Modifier, +) { + + val editTypePresetsController: EditTypePresetsController = koinInject() + val levelFilter: LevelFilter = koinInject() + val prefs: Preferences = koinInject() + val ctx = LocalContext.current + + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = modifier + ) { + DropdownMenuItem(onClick = { + onDismissRequest() + showProfileSelectionDialog(ctx, editTypePresetsController, prefs) + }) + { + Text(text = stringResource(R.string.quick_switch_preset)) + } + DropdownMenuItem( + onClick = { + onDismissRequest() + levelFilter.showLevelFilterDialog(ctx, viewModel.mapCamera.value) + }) + { + Text(text = stringResource(R.string.level_filter)) + } + DropdownMenuItem(onClick = { + onDismissRequest() + prefs.prefs.putString(Prefs.THEME_BACKGROUND, if (prefs.getString(Prefs.THEME_BACKGROUND, "MAP") == "MAP") "AERIAL" else "MAP") + }) + { + Text(text = stringResource(R.string.quick_switch_map_background)) + } + DropdownMenuItem(onClick = { + onDismissRequest() + viewModel.reverseQuestOrder.value = !viewModel.reverseQuestOrder.value + }) { + val textResId = if (viewModel.reverseQuestOrder.collectAsState().value) + R.string.quest_order_normal + else R.string.quest_order_reverse + Text(text = stringResource(textResId)) + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/StarsCounter.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/StarsCounter.kt index d4413607859..06ed5830e62 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/StarsCounter.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/StarsCounter.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.screens.main.controls +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,12 +11,14 @@ import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.LocalElevationOverlay import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.westnordost.streetcomplete.R @@ -31,6 +34,7 @@ fun StarsCounter( modifier: Modifier = Modifier, isCurrentWeek: Boolean = false, showProgress: Boolean = false, + hasUnsyncedChanges: Boolean ) { val surfaceColor = MaterialTheme.colors.surface val haloColor = LocalElevationOverlay.current?.apply(surfaceColor, 4.dp) ?: surfaceColor @@ -60,8 +64,12 @@ fun StarsCounter( contentDescription = null, tint = contentColorFor(surfaceColor) ) +// if (hasUnsyncedChanges) +// NotificationBox { +// Text("+", textAlign = TextAlign.Center) +// } } - +/* if (isCurrentWeek) { Column { TextWithHalo( @@ -85,7 +93,7 @@ fun StarsCounter( elevation = 4.dp, style = MaterialTheme.typography.titleLarge, ) - } + }*/ } } @@ -95,6 +103,7 @@ private fun PreviewStarsCounter() { StarsCounter( count = 123, isCurrentWeek = true, - showProgress = true + showProgress = true, + hasUnsyncedChanges = true ) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/ZoomButtons.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/ZoomButtons.kt index baf3c3a0911..71d059eb389 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/ZoomButtons.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/ZoomButtons.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.screens.main.controls +import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.width @@ -12,16 +13,19 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.westnordost.streetcomplete.ui.common.ZoomInIcon import de.westnordost.streetcomplete.ui.common.ZoomOutIcon +import de.westnordost.streetcomplete.util.logs.Log /** Combined control for zooming in and out */ @Composable fun ZoomButtons( onZoomIn: () -> Unit, onZoomOut: () -> Unit, + zoom: (Float) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, colors: ButtonColors = ButtonDefaults.buttonColors( @@ -35,7 +39,16 @@ fun ZoomButtons( contentColor = colors.contentColor(enabled).value, elevation = 4.dp ) { - Column(Modifier.width(IntrinsicSize.Min)) { + Column( + Modifier + .width(IntrinsicSize.Min) + .pointerInput(Unit) { + detectVerticalDragGestures { change, dragAmount -> + change.consume() + zoom(-dragAmount / 30) + } + } + ) { IconButton(onClick = onZoomIn, enabled = enabled) { ZoomInIcon() } Divider() IconButton(onClick = onZoomOut, enabled = enabled) { ZoomOutIcon() } @@ -46,5 +59,5 @@ fun ZoomButtons( @Preview @Composable private fun PreviewZoomButtons() { - ZoomButtons(onZoomIn = {}, onZoomOut = {}) + ZoomButtons(onZoomIn = {}, onZoomOut = {}, zoom = {}) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/Edit.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/Edit.kt index b70faa6964a..a654781f241 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/Edit.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/Edit.kt @@ -11,9 +11,11 @@ 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 +import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.CLOSE import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.COMMENT import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.CREATE import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuestHidden +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestHidden import de.westnordost.streetcomplete.data.quest.QuestType import de.westnordost.streetcomplete.quests.getTitle @@ -23,10 +25,12 @@ val Edit.icon: Int get() = when (this) { when (action) { CREATE -> R.drawable.ic_quest_create_note COMMENT -> R.drawable.ic_quest_notes + CLOSE -> R.drawable.ic_quest_close_note } } is OsmNoteQuestHidden -> R.drawable.ic_quest_notes is OsmQuestHidden -> questType.icon + is ExternalSourceQuestHidden -> questType.icon else -> 0 } @@ -41,6 +45,7 @@ val Edit.overlayIcon: Int get() = when (this) { } is OsmNoteQuestHidden -> R.drawable.ic_undo_visibility is OsmQuestHidden -> R.drawable.ic_undo_visibility + is ExternalSourceQuestHidden -> R.drawable.ic_undo_visibility else -> 0 } @@ -58,6 +63,7 @@ fun Edit.getTitle(elementTags: Map?): String = when (this) { stringResource(when (action) { CREATE -> R.string.created_note_action_title COMMENT -> R.string.commented_note_action_title + CLOSE -> R.string.closed_note_action_title }) } is OsmQuestHidden -> { diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditDescription.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditDescription.kt index ca41578cb2a..423816ff13a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditDescription.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditDescription.kt @@ -8,10 +8,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.edithistory.Edit +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestHidden import de.westnordost.streetcomplete.data.osm.edits.ElementEdit import de.westnordost.streetcomplete.data.osm.edits.create.CreateNodeAction import de.westnordost.streetcomplete.data.osm.edits.create.CreateNodeFromVertexAction +import de.westnordost.streetcomplete.data.osm.edits.create.CreateRelationAction import de.westnordost.streetcomplete.data.osm.edits.delete.DeletePoiNodeAction +import de.westnordost.streetcomplete.data.osm.edits.delete.DeleteRelationAction 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 @@ -36,7 +39,7 @@ fun EditDescription( when (edit.action) { is UpdateElementTagsAction -> TagUpdatesList(edit.action.changes.changes, modifier) - is DeletePoiNodeAction -> + is DeletePoiNodeAction, is DeleteRelationAction -> Text(stringResource(R.string.deleted_poi_action_description), modifier) is SplitWayAction -> Text(stringResource(R.string.split_way_action_description), modifier) @@ -49,11 +52,16 @@ fun EditDescription( TagUpdatesList(edit.action.changes.changes, modifier) is MoveNodeAction -> Text(stringResource(R.string.move_node_action_description), modifier) + is CreateRelationAction -> + Column(modifier) { + Text(stringResource(R.string.create_relation_action_description)) + TagList(edit.action.tags) + } } } is NoteEdit -> Text(edit.text.orEmpty(), modifier) - is OsmQuestHidden -> + is OsmQuestHidden, is ExternalSourceQuestHidden -> Text(stringResource(R.string.hid_action_description), modifier) is OsmNoteQuestHidden -> Text(stringResource(R.string.hid_action_description), modifier) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryViewModel.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryViewModel.kt index 49062a8183a..3d3f4afba69 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryViewModel.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.westnordost.osmfeatures.FeatureDictionary +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.edithistory.Edit import de.westnordost.streetcomplete.data.edithistory.EditHistoryController import de.westnordost.streetcomplete.data.edithistory.EditHistorySource @@ -15,8 +16,10 @@ import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestHidden +import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.util.ktx.launch import de.westnordost.streetcomplete.util.ktx.toLocalDateTime +import de.westnordost.streetcomplete.util.logs.Log import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -39,6 +42,7 @@ abstract class EditHistoryViewModel : ViewModel() { abstract fun select(editKey: EditKey?) abstract fun undo(editKey: EditKey) + abstract fun updateEdits() abstract val featureDictionaryLazy: Lazy @@ -61,6 +65,7 @@ class EditHistoryViewModelImpl( private val mapDataSource: MapDataWithEditsSource, private val editHistoryController: EditHistoryController, override val featureDictionaryLazy: Lazy, + private val prefs: Preferences, ) : EditHistoryViewModel() { private val edits = MutableStateFlow>(emptyList()) @@ -99,7 +104,7 @@ class EditHistoryViewModelImpl( } override fun showSidebar() { - selectedEdit.value = edits.value.lastOrNull() + selectedEdit.value = if (prefs.getBoolean(Prefs.SELECT_FIRST_EDIT, true)) edits.value.lastOrNull() else null isShowingSidebar.value = true } @@ -158,7 +163,7 @@ class EditHistoryViewModelImpl( editHistoryController.removeListener(editHistoryListener) } - private fun updateEdits() { + override fun updateEdits() { launch(IO) { edits.value = editHistoryController.getAll().sortedBy { it.createdTimestamp } if (edits.value.isEmpty()) hideSidebar() diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/DownloadedAreaManager.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/DownloadedAreaManager.kt index 677af787efd..c3ddfa440d8 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/DownloadedAreaManager.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/DownloadedAreaManager.kt @@ -2,7 +2,9 @@ package de.westnordost.streetcomplete.screens.main.map import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner +import com.russhwolf.settings.ObservableSettings import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesSource import de.westnordost.streetcomplete.screens.main.map.components.DownloadedAreaMapComponent import kotlinx.coroutines.CoroutineScope @@ -15,7 +17,8 @@ import kotlinx.coroutines.withContext class DownloadedAreaManager( private val mapComponent: DownloadedAreaMapComponent, - private val downloadedTilesSource: DownloadedTilesSource + private val downloadedTilesSource: DownloadedTilesSource, + private val prefs: ObservableSettings, ) : DefaultLifecycleObserver { private val viewLifecycleScope: CoroutineScope = CoroutineScope(SupervisorJob()) @@ -44,7 +47,8 @@ class DownloadedAreaManager( private fun update() { viewLifecycleScope.launch { - val tiles = withContext(Dispatchers.IO) { downloadedTilesSource.getAll(ApplicationConstants.DELETE_OLD_DATA_AFTER) } + val deleteOldDataAfter = prefs.getInt(Prefs.DATA_RETAIN_TIME, ApplicationConstants.DELETE_OLD_DATA_AFTER_DAYS) * 24L * 60 * 60 * 1000 + val tiles = withContext(Dispatchers.IO) { downloadedTilesSource.getAll(deleteOldDataAfter) } mapComponent.set(tiles) } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/EditHistoryPinsManager.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/EditHistoryPinsManager.kt index eff5a0a6eeb..2a0ef5492cc 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/EditHistoryPinsManager.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/EditHistoryPinsManager.kt @@ -13,8 +13,10 @@ import de.westnordost.streetcomplete.data.osm.mapdata.ElementType import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestHidden import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEdit import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuestHidden +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestHidden import de.westnordost.streetcomplete.data.quest.OsmNoteQuestKey import de.westnordost.streetcomplete.data.quest.OsmQuestKey +import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey import de.westnordost.streetcomplete.screens.main.edithistory.icon import de.westnordost.streetcomplete.screens.main.map.components.Pin import de.westnordost.streetcomplete.screens.main.map.components.PinsMapComponent @@ -43,6 +45,7 @@ class EditHistoryPinsManager( private var isStarted: Boolean = false + private val editHistoryListener = object : EditHistorySource.Listener { override fun onAdded(added: Edit) { updatePins() } override fun onSynced(synced: Edit) {} @@ -107,12 +110,15 @@ private const val MARKER_ELEMENT_TYPE = "element_type" private const val MARKER_ELEMENT_ID = "element_id" private const val MARKER_QUEST_TYPE = "quest_type" private const val MARKER_NOTE_ID = "note_id" +private const val MARKER_OTHER_SOURCE = "other_source" +private const val MARKER_OTHER_SOURCE_ID = "other_source_id" private const val MARKER_ID = "id" private const val EDIT_TYPE_ELEMENT = "element" private const val EDIT_TYPE_NOTE = "note" private const val EDIT_TYPE_HIDE_OSM_NOTE_QUEST = "hide_osm_note_quest" private const val EDIT_TYPE_HIDE_OSM_QUEST = "hide_osm_quest" +private const val EDIT_TYPE_HIDE_OTHER_SOURCE_QUEST = "hide_other_source_quest" private fun Edit.toProperties(): List> = when (this) { is ElementEdit -> listOf( @@ -133,6 +139,12 @@ private fun Edit.toProperties(): List> = when (this) { MARKER_ELEMENT_ID to elementId.toString(), MARKER_QUEST_TYPE to questType.name ) + is ExternalSourceQuestHidden -> listOf( + MARKER_EDIT_TYPE to EDIT_TYPE_HIDE_OTHER_SOURCE_QUEST, + MARKER_OTHER_SOURCE to questType.source, + MARKER_OTHER_SOURCE_ID to id, + MARKER_QUEST_TYPE to questType.name + ) else -> throw IllegalArgumentException() } @@ -149,5 +161,7 @@ private fun Map.toEditKey(): EditKey? = when (get(MARKER_EDIT_TY )) EDIT_TYPE_HIDE_OSM_NOTE_QUEST -> QuestHiddenKey(OsmNoteQuestKey(getValue(MARKER_NOTE_ID).toLong())) + EDIT_TYPE_HIDE_OTHER_SOURCE_QUEST -> + QuestHiddenKey(ExternalSourceQuestKey(getValue(MARKER_OTHER_SOURCE), getValue(MARKER_OTHER_SOURCE_ID))) else -> null } 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 58a0eebfe80..5e63535cc8a 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 @@ -10,6 +10,7 @@ import androidx.annotation.DrawableRes import androidx.annotation.UiThread import androidx.core.content.getSystemService import androidx.core.graphics.Insets +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesSource import de.westnordost.streetcomplete.data.edithistory.EditHistorySource import de.westnordost.streetcomplete.data.edithistory.EditKey @@ -24,8 +25,10 @@ import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.data.quest.QuestKey import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry import de.westnordost.streetcomplete.data.quest.VisibleQuestsSource +import de.westnordost.streetcomplete.data.visiblequests.LevelFilter import de.westnordost.streetcomplete.data.visiblequests.QuestTypeOrderSource import de.westnordost.streetcomplete.screens.main.map.components.CurrentLocationMapComponent +import de.westnordost.streetcomplete.screens.main.map.components.CustomGeometryMapComponent import de.westnordost.streetcomplete.screens.main.map.components.DownloadedAreaMapComponent import de.westnordost.streetcomplete.screens.main.map.components.FocusGeometryMapComponent import de.westnordost.streetcomplete.screens.main.map.components.GeometryMarkersMapComponent @@ -37,6 +40,8 @@ import de.westnordost.streetcomplete.screens.main.map.maplibre.CameraPosition import de.westnordost.streetcomplete.screens.main.map.maplibre.MapImages import de.westnordost.streetcomplete.screens.main.map.maplibre.camera import de.westnordost.streetcomplete.screens.main.map.maplibre.toLatLon +import de.westnordost.streetcomplete.screens.settings.loadCustomGeometryText +import de.westnordost.streetcomplete.screens.settings.loadGpxTrackPoints import de.westnordost.streetcomplete.util.ktx.currentDisplay import de.westnordost.streetcomplete.util.ktx.dpToPx import de.westnordost.streetcomplete.util.ktx.isLocationAvailable @@ -67,6 +72,7 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers { private val mapDataSource: MapDataWithEditsSource by inject() private val selectedOverlaySource: SelectedOverlaySource by inject() private val downloadedTilesSource: DownloadedTilesSource by inject() + private val levelFilter: LevelFilter by inject() private val locationAvailabilityReceiver: LocationAvailabilityReceiver by inject() private val recentLocationStore: RecentLocationStore by inject() private val prefs: Preferences by inject() @@ -87,6 +93,7 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers { private var downloadedAreaManager: DownloadedAreaManager? = null private var locationMapComponent: CurrentLocationMapComponent? = null private var tracksMapComponent: TracksMapComponent? = null + private var customGeometryMapComponent: CustomGeometryMapComponent? = null interface Listener { fun onClickedQuest(questKey: QuestKey) @@ -195,7 +202,7 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers { private fun setupComponents(context: Context, map: MapLibreMap, style: Style) { val fingerRadius = context.resources.dpToPx(CLICK_AREA_SIZE_IN_DP / 2) - mapImages = MapImages(context.resources, style) + mapImages = MapImages(context.resources, map) geometryMarkersMapComponent = GeometryMarkersMapComponent(context, map, mapImages!!) @@ -205,8 +212,8 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers { tracksMapComponent = TracksMapComponent(context, style, map) viewLifecycleOwner.lifecycle.addObserver(tracksMapComponent!!) - pinsMapComponent = PinsMapComponent(context, context.contentResolver, map, mapImages!!, ::onClickPin) - geometryMapComponent = FocusGeometryMapComponent(context.contentResolver, map) + pinsMapComponent = PinsMapComponent(context, context.contentResolver, map, mapImages!!, prefs, ::onClickPin) + geometryMapComponent = FocusGeometryMapComponent(context.contentResolver, map, prefs.prefs) viewLifecycleOwner.lifecycle.addObserver(geometryMapComponent!!) styleableOverlayMapComponent = StyleableOverlayMapComponent(context, map, mapImages!!, fingerRadius, ::onClickElement) @@ -215,6 +222,8 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers { selectedPinsMapComponent = SelectedPinsMapComponent(context, map, mapImages!!) viewLifecycleOwner.lifecycle.addObserver(selectedPinsMapComponent!!) + + customGeometryMapComponent = CustomGeometryMapComponent(context, map) } private fun setupLayers(style: Style) { @@ -247,7 +256,8 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers { geometryMapComponent?.layers, locationMapComponent?.layers, pinsMapComponent?.layers, - selectedPinsMapComponent?.layers + selectedPinsMapComponent?.layers, + customGeometryMapComponent?.layers ).flatten()) { style.addLayer(layer) } @@ -257,7 +267,7 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers { restoreMapState() centerCurrentPositionIfFollowing() - questPinsManager = QuestPinsManager(map, pinsMapComponent!!, questTypeOrderSource, questTypeRegistry, visibleQuestsSource) + questPinsManager = QuestPinsManager(map, pinsMapComponent!!, questTypeOrderSource, questTypeRegistry, visibleQuestsSource, prefs.prefs, mapDataSource, selectedOverlaySource) questPinsManager!!.isVisible = pinMode == PinMode.QUESTS viewLifecycleOwner.lifecycle.addObserver(questPinsManager!!) @@ -265,14 +275,16 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers { editHistoryPinsManager!!.isVisible = pinMode == PinMode.EDITS viewLifecycleOwner.lifecycle.addObserver(editHistoryPinsManager!!) - styleableOverlayManager = StyleableOverlayManager(map, styleableOverlayMapComponent!!, mapDataSource, selectedOverlaySource) + styleableOverlayManager = StyleableOverlayManager(map, styleableOverlayMapComponent!!, mapDataSource, selectedOverlaySource, levelFilter) viewLifecycleOwner.lifecycle.addObserver(styleableOverlayManager!!) - downloadedAreaManager = DownloadedAreaManager(downloadedAreaMapComponent!!, downloadedTilesSource) + downloadedAreaManager = DownloadedAreaManager(downloadedAreaMapComponent!!, downloadedTilesSource, prefs.prefs) viewLifecycleOwner.lifecycle.addObserver(downloadedAreaManager!!) onSelectedOverlayChanged() selectedOverlaySource.addListener(overlayListener) + loadGpxTrack() + loadCustomGeometry() locationMapComponent?.targetLocation = displayedLocation @@ -301,6 +313,18 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers { } //endregion + fun loadGpxTrack() { + val gpxPoints = if (prefs.getBoolean(Prefs.SHOW_GPX_TRACK, false)) + loadGpxTrackPoints(requireContext()) ?: emptyList() + else emptyList() + tracksMapComponent?.setGpxTrack(gpxPoints) + } + + fun loadCustomGeometry() { + val text = context?.let { loadCustomGeometryText(it) } + if (text == null || !prefs.getBoolean(Prefs.SHOW_CUSTOM_GEOMETRY, false)) customGeometryMapComponent?.clear() + else customGeometryMapComponent?.set(text) + } //region Tracking GPS, Rotation, location availability, pin mode, click ... @@ -457,6 +481,10 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers { geometryMapComponent?.showGeometry(geometry) } + fun highlightGeometries(geometries: Collection) { + geometryMapComponent?.showGeometries(geometries) + } + /** Clear all highlighting */ fun clearHighlighting() { pinsMapComponent?.setVisible(true) @@ -476,6 +504,10 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers { } } + fun setQuestOrder(reverse: Boolean) { + questPinsManager?.setQuestOrder(reverse) + } + @UiThread override fun deleteMarkerForCurrentHighlighting(geometry: ElementGeometry) { geometryMarkersMapComponent?.delete(geometry) } @@ -491,7 +523,7 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers { @SuppressLint("MissingPermission") fun startPositionTracking() { locationMapComponent?.isVisible = true - locationManager.requestUpdates(0, 5000, 1f) + locationManager.requestUpdates(prefs.prefs.getInt(Prefs.GPS_INTERVAL, 0) * 1000L, prefs.prefs.getInt(Prefs.NETWORK_INTERVAL, 5) * 1000L, 1f) } fun stopPositionTracking() { diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MapFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MapFragment.kt index 4e99c588f9b..895b0a070ff 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MapFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MapFragment.kt @@ -3,9 +3,12 @@ package de.westnordost.streetcomplete.screens.main.map import android.graphics.PointF import android.os.Bundle import android.view.View +import androidx.annotation.UiThread import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import com.russhwolf.settings.SettingsListener import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osm.mapdata.LatLon @@ -45,6 +48,23 @@ open class MapFragment : Fragment(R.layout.fragment_map) { private val prefs: Preferences by inject() + private var started = true + private var styleNeedsReload = false + + private val themeChangeListener: SettingsListener = prefs.prefs.addStringListener(Prefs.THEME_BACKGROUND, "MAP") { + if (started) + viewLifecycleScope.launch { + if (it == "AERIAL") + // crappy workaround for a bug: when switching to raster background, the raster tile in current view is invisible + // so we zoom out and in again and hope the tile is loaded + updateCameraPosition { zoomBy = -1.0} + reloadStyle() + if (it == "AERIAL") + updateCameraPosition { zoomBy = 1.0} + } + else styleNeedsReload = true + } + interface Listener { /** Called when the map has been completely initialized */ fun onMapInitialized() @@ -93,15 +113,19 @@ open class MapFragment : Fragment(R.layout.fragment_map) { // the offline manager is only available together with the map, i.e. not from the CleanerWorker lifecycleScope.launch { delay(30000) // cleaning is low priority, do it once startup is done - val oldDataTimestamp = nowAsEpochMilliseconds() - ApplicationConstants.DELETE_OLD_DATA_AFTER + val retainTime = prefs.getInt(Prefs.DATA_RETAIN_TIME, ApplicationConstants.DELETE_OLD_DATA_AFTER_DAYS) + val oldDataTimestamp = nowAsEpochMilliseconds() - retainTime OfflineManager.getInstance(requireContext()).deleteRegionsOlderThan(oldDataTimestamp) } } override fun onStart() { super.onStart() + started = true // sceneMapComponent might actually be null if map style not initialized yet - sceneMapComponent?.updateStyle() + if (styleNeedsReload) viewLifecycleScope.launch { reloadStyle() } + else sceneMapComponent?.updateStyle() + styleNeedsReload = false } override fun onResume() { @@ -116,6 +140,7 @@ open class MapFragment : Fragment(R.layout.fragment_map) { override fun onStop() { super.onStop() + started = false saveMapState() } @@ -138,10 +163,11 @@ open class MapFragment : Fragment(R.layout.fragment_map) { map.uiSettings.isLogoEnabled = false map.uiSettings.flingThreshold = 250 map.uiSettings.flingAnimationBaseTime = 500 - map.uiSettings.isDisableRotateWhenScaling = true + map.uiSettings.isDisableRotateWhenScaling = !prefs.getBoolean(Prefs.ROTATE_WHILE_ZOOMING, false) + // workaround for https://github.com/maplibre/maplibre-native/issues/2792 map.gesturesManager.moveGestureDetector.moveThreshold = resources.dpToPx(5f) - map.gesturesManager.rotateGestureDetector.angleThreshold = 1.5f + map.gesturesManager.rotateGestureDetector.angleThreshold = prefs.getFloat(Prefs.ROTATE_ANGLE_THRESHOLD, 1.5f) map.gesturesManager.shoveGestureDetector.pixelDeltaThreshold = resources.dpToPx(8f) map.addOnMoveListener(object : MapLibreMap.OnMoveListener { @@ -164,7 +190,7 @@ open class MapFragment : Fragment(R.layout.fragment_map) { true } - val sceneMapComponent = SceneMapComponent(requireContext(), map) + val sceneMapComponent = SceneMapComponent(requireContext(), map, prefs) val style = sceneMapComponent.loadStyle() this.sceneMapComponent = sceneMapComponent @@ -176,6 +202,13 @@ open class MapFragment : Fragment(R.layout.fragment_map) { listener?.onMapInitialized() } + @UiThread + private suspend fun reloadStyle() { + val map = map ?: return + val sceneMapComponent = sceneMapComponent ?: return + onMapStyleLoaded(map, sceneMapComponent.loadStyle()) + } + /* ----------------------------- Overridable map callbacks --------------------------------- */ protected open suspend fun onMapStyleLoaded(map: MapLibreMap, style: Style) {} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MapStyleCreator.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MapStyleCreator.kt new file mode 100644 index 00000000000..6efe1d1cd32 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MapStyleCreator.kt @@ -0,0 +1,780 @@ +package de.westnordost.streetcomplete.screens.main.map + +private const val isPoint = """["==", ["geometry-type"], "Point"]""" +private const val isLines = """["==", ["geometry-type"], "LineString"]""" +private const val isPolygon = """["==", ["geometry-type"], "Polygon"]""" + +private fun byZoom(vararg n: Pair): String = byZoom(n.toList()) + +private fun byZoom(n: Iterable>): String { + val values = n.flatMap { (z, v) -> listOf(z, v) }.joinToString() + return """["interpolate", ["exponential", 2], ["zoom"], $values]""" +} + +private fun tagIs(key: String, value: Any) = """["==", ["get", "$key"], ${ if (value is String) "\"$value\"" else value }]""" +private fun tagIsNot(key: String, value: Any) = """["!=", ["get", "$key"], ${ if (value is String) "\"$value\"" else value }]""" + +private fun tagIn(key: String, vararg values: String) = + """["in", ["get", "$key"], ["literal", [${ values.joinToString { "\"$it\"" } }]]]""" + +private fun tagNotIn(key: String, vararg values: String) = + """["!", ${tagIn(key, *values)}]""" + +private data class Layer( + val id: String, + val src: String, + val filter: List = emptyList(), + val minZoom: Double? = null, + val maxZoom: Double? = null, + val paint: Paint +) { + fun toJson() = "{ " + + listOfNotNull( + "\"id\": \"$id\"", + "\"source\": \"jawg-streets\"", + "\"source-layer\": \"$src\"", + minZoom?.let { "\"minzoom\": $it" }, + maxZoom?.let { "\"maxzoom\": $it" }, + when (filter.size) { + 0 -> null + 1 -> "\"filter\": " + filter.single() + else -> "\"filter\": [\"all\", " + filter.joinToString() + "]" + }, + paint.toJson(), + ).joinToString() + + " }" +} + +private interface Paint { + fun toJson(): String +} + +private data class Fill( + val color: String, + val opacity: String? = null, + val antialias: Boolean? = null +): Paint { + override fun toJson() = listOf( + "\"type\": \"fill\"", + "\"paint\": { " + + listOfNotNull( + "\"fill-color\": \"$color\"", + opacity?.let { "\"fill-opacity\": $it" }, + antialias?.let { "\"fill-antialias\": $it" }, + ).joinToString() + + "}" + ).joinToString() +} + +private data class FillExtrusion( + val color: String, + val base: String, + val height: String, + val opacity: String? = null, +) : Paint { + override fun toJson() = listOf( + "\"type\": \"fill-extrusion\"", + "\"paint\": { " + + listOfNotNull( + "\"fill-extrusion-color\": \"$color\"", + "\"fill-extrusion-height\": $height", + "\"fill-extrusion-base\": $base", + opacity?.let { "\"fill-extrusion-opacity\": $it" }, + ).joinToString() + + "}" + ).joinToString() +} + +private data class Line( + val color: String, + val width: String, + val gapWidth: String? = null, + val offset: String? = null, + val blur: String? = null, + val opacity: String? = null, + val miterLimit: Number? = null, + val dashes: String? = null, + val cap: String? = null, + val join: String? = null +): Paint { + override fun toJson() = listOfNotNull( + "\"type\": \"line\"", + "\"paint\": {" + + listOfNotNull( + "\"line-color\": \"$color\"", + "\"line-width\": $width", + gapWidth?.let { "\"line-gap-width\": $it" }, + offset?.let { "\"line-offset\": $it" }, + blur?.let { "\"line-blur\": $it" }, + dashes?.let { "\"line-dasharray\": $it" }, + opacity?.let { "\"line-opacity\": $it" }, + ).joinToString() + + "}", + if (cap != null || join != null || miterLimit != null) { + "\"layout\": {" + + listOfNotNull( + cap?.let { "\"line-cap\": \"$it\"" }, + join?.let { "\"line-join\": \"$it\"" }, + miterLimit?.let { "\"line-miter-limit\": $it" }, + ).joinToString() + + "}" + } else null + ).joinToString(",") +} + +private data class Circle( + val color: String, + val radius: String, + val opacity: String? = null, +): Paint { + override fun toJson() = listOfNotNull( + "\"type\": \"circle\"", + "\"paint\": {" + + listOfNotNull( + "\"circle-color\": \"$color\"", + "\"circle-radius\": $radius", + opacity?.let { "\"circle-opacity\": $it" }, + ).joinToString() + "}", + ).joinToString() +} + +private data class Symbol( + val image: String, + val color: String? = null, + val padding: Number? = null, + val placement: String? = null, + val spacing: String? = null, + val opacity: String? = null, + val size: String? = null, + val rotate: Number? = null, + val rotationAlignment: String? = null, +) : Paint { + override fun toJson() = listOfNotNull( + "\"type\": \"symbol\"", + "\"paint\": {" + + listOfNotNull( + color?.let { "\"icon-color\": \"$it\"" }, + opacity?.let { "\"icon-opacity\": $it" }, + ).joinToString() + + "}", + "\"layout\": {" + + listOfNotNull( + "\"icon-image\": \"$image\"", + size?.let { "\"icon-size\": $it" }, + spacing?.let { "\"symbol-spacing\": $it" }, + placement?.let { "\"symbol-placement\": \"$it\"" }, + padding?.let { "\"icon-padding\": $it" }, + rotate?.let { "\"icon-rotate\": $it" }, + rotationAlignment?.let { "\"icon-rotation-alignment\": \"$it\"" }, + ).joinToString() + + "}", + ).joinToString() +} + +private data class Text( + val text: String, + val size: String, + val color: String, + val fonts: List, + val wrap: Number? = null, + val padding: Number? = null, + val outlineColor: String? = null, + val outlineWidth: Number? = null, + val placement: String? = null, + val opacity: String? = null, + val sortKey: String? = null +) : Paint { + override fun toJson() = listOfNotNull( + "\"type\": \"symbol\"", + "\"paint\": {" + + listOfNotNull( + "\"text-color\": \"$color\"", + outlineColor?.let { "\"text-halo-color\": \"$it\"" }, + outlineWidth?.let { "\"text-halo-width\": $it" }, + opacity?.let { "\"text-opacity\": $it" }, + ).joinToString() + + "}", + "\"layout\": {" + + listOfNotNull( + "\"text-field\": $text", + "\"text-size\": $size", + "\"text-font\": [${fonts.joinToString { "\"$it\""}}]", + placement?.let { "\"symbol-placement\": \"$it\"" }, + padding?.let { "\"text-padding\": $it" }, + wrap?.let { "\"text-max-width\": $it" }, + sortKey?.let { "\"symbol-sort-key\": $it" }, + ).joinToString() + + "}", + ).joinToString() +} + +fun createMapStyle(name: String, accessToken: String, languages: List, colors: MapColors, rasterSource: String? = null, rasterMaxZoom: Int = 25): String { + + val pathWidth = listOf(14.0 to 0.5, 16.0 to 1.0, 24.0 to 256.0) // ~1m + + fun coalesceName() = + "[" + + listOf( + "\"coalesce\"", + *languages.map { "[\"get\", \"name_$it\"]" }.toTypedArray(), + "[\"get\", \"name\"]" + ).joinToString() + + "]" + + val defaultTextStyle = Text( + text = coalesceName(), + size = byZoom(1.0 to 13.0, 24.0 to 64.0), + fonts = listOf("Roboto Regular"), + color = colors.text, + outlineColor = colors.textOutline, + outlineWidth = 2.5, + padding = 12, + sortKey = "[\"get\", \"scalerank\"]" + ) + + val waterTextStyle = defaultTextStyle.copy( + color = colors.textWater, + outlineColor = colors.textWaterOutline + ) + + val rivers = Waterway("rivers", + filters = listOf(tagIn("class", "river", "canal")), + color = colors.water, + width = listOf(10.0 to 1.0, 16.0 to 3.0, 24.0 to 768.0), + minZoom = 10.0 + ) + + val streams = Waterway("streams", + filters = listOf(tagIn("class", "stream", "ditch", "drain")), + color = colors.water, + width = listOf(16.0 to 1.0, 24.0 to 256.0), + minZoom = 10.0 + ) + + val paths = Road("paths", + filters = listOf(tagIs("class", "path")), + color = colors.path, + colorOutline = colors.path, + width = pathWidth, + minZoom = 15.0 + ) + val pedestrian = Road("pedestrian", + filters = listOf(tagIs("class", "street_limited"), tagIs("type", "pedestrian")), + color = colors.pedestrian, + colorOutline = colors.roadOutline, + width = listOf(13.0 to 1.5, 16.0 to 4.0, 24.0 to 1024.0), // ~4m + minZoom = 14.0 + ) + val serviceRoads = Road("roads-service", + filters = listOf(tagIn("class", "service", "driveway")), + color = colors.road, + colorOutline = colors.roadOutline, + width = listOf(13.0 to 0.5, 16.0 to 3.0, 24.0 to 768.0), // ~3m + minZoom = 14.0 + ) + val minorRoads = Road("roads-minor", + filters = listOf(tagIn("class", "street", "street_limited"), tagIsNot("type", "pedestrian")), + color = colors.road, + colorOutline = colors.roadOutline, + width = listOf(11.0 to 0.5, 16.0 to 4.0, 24.0 to 1024.0), // ~4m + minZoom = 12.0 + ) + val majorRoads = Road("roads-major", + filters = listOf(tagIs("class", "main")), + color = colors.road, + colorOutline = colors.roadOutline, + width = listOf(9.0 to 1.0, 16.0 to 6.0, 24.0 to 1536.0), // ~6m + minZoom = 5.0, + ) + val motorways = Road("motorways", + filters = listOf(tagIs("class", "motorway")), + color = colors.motorway, + colorOutline = colors.motorwayOutline, + width = listOf(8.0 to 1.0, 16.0 to 8.0, 24.0 to 2048.0), // ~8m + minZoom = 5.0, + ) + val motorwayLinks = Road("motorway-links", + filters = listOf(tagIs("class", "motorway_link")), + color = colors.motorway, + colorOutline = colors.motorwayOutline, + width = listOf(11.0 to 1.0, 16.0 to 4.0, 24.0 to 1024.0), // ~4m + ) + + val roads = listOf(pedestrian, serviceRoads, minorRoads, majorRoads, motorways, motorwayLinks) + + fun stepsOverlayLayer(structure: Structure) = Layer( + id = listOfNotNull("steps", structure.id).joinToString("-"), + src = "road", + filter = listOf(tagIn("class", "path"), tagIn("type", "steps"), isLines, structure.filter), + paint = Line( + color = colors.pedestrian, + width = byZoom(pathWidth.map { (z, w) -> z to w * 0.7 }), + opacity = if (structure == Structure.Tunnel) "0.25" else null, + dashes = "[0.6, 0.4]" + ) + ) + + fun railwayLayer(structure: Structure) = Layer( + id = listOfNotNull("railways", structure.id).joinToString("-"), + src = "road", + filter = listOf(tagIn("class", "major_rail", "minor_rail"), isLines, structure.filter), + paint = Line( + color = colors.railway, + // at zoom 17, the line spits up into two lines, to mimic the two tracks of a railway + width = byZoom(12.0 to 0.75, 13.0 to 2.0, 16.999 to 4.0, 17.0 to 2.0, 24.0 to 128.0), + gapWidth = byZoom(12.0 to 0.0, 17.0 to 0.0, 24.0 to 256.0), + join = "round", + opacity = byZoom(12.0 to 0.0, 13.0 to 1.0) + ) + ) + + fun pedestrianAreaLayer(structure: Structure) = Layer( + id = listOfNotNull("pedestrian-areas", structure.id).joinToString("-"), + src = "road", + filter = listOf(tagIn("class", "path", "street_limited"), isPolygon, structure.filter), + minZoom = 15.0, + paint = Fill( + color = colors.pedestrian, + opacity = byZoom(15.0 to 0.0, 16.0 to 1.0), + ) + ) + + fun pedestrianAreaCasingLayer(structure: Structure) = Layer( + id = listOfNotNull("pedestrian-areas-casing", structure.id).joinToString("-"), + src = "road", + filter = listOf(tagIn("class", "path", "street_limited"), isPolygon, structure.filter), + minZoom = 16.0, + paint = Line( + color = colors.path, + width = byZoom(16.0 to 1.0, 24.0 to 128.0), + offset = byZoom(16.0 to -0.5, 24.0 to -64.0), + opacity = byZoom(16.0 to 0.0, 17.0 to 1.0), + dashes = null, + ) + ) + + + + fun allRoadLayers(structure: Structure) = listOfNotNull( + // for roads, first draw the casing (= outline) of all roads + + *roads.map { it.toCasingLayer(structure) }.toTypedArray(), + + // , then draw the road color... + + // roads and pedestrian areas should be drawn on top of paths, as paths on + // these are kind of "virtual", do only exist for connectivity + paths.toLayer(structure), // paths do not have a casing + stepsOverlayLayer(structure), + *roads.map { it.toLayer(structure) }.toTypedArray(), + // pedestrian area tunnels are not drawn + + paths.toLayerPrivateOverlay(structure, colors.privateOverlay), + serviceRoads.toLayerPrivateOverlay(structure, colors.privateOverlay), + + // railway tunnels are not drawn + // railways are drawn last because e.g. trams should appear on top of roads + if (structure != Structure.Tunnel) railwayLayer(structure) else null, + ) + + val layers = listOf( + + Layer("landuse-town", + src = "landuse", + filter = listOf( + tagNotIn("class", "pitch", "park", "grass", "cemetery", "wood", "scrub", "national_park") + ), + minZoom = 11.0, + paint = Fill(color = colors.town, opacity = byZoom(11.0 to 0.0, 12.0 to 1.0)) + ), + Layer("landuse-green", + src = "landuse", + filter = listOf(tagIn("class", "pitch", "park", "grass", "cemetery")), + minZoom = 5.0, + paint = Fill(color = colors.green, opacity = byZoom(5.0 to 0.0, 6.0 to 1.0)) + ), + Layer("landuse-pitch-park-outline", + src = "landuse", + filter = listOf(tagIn("class", "pitch", "park")), + minZoom = 16.0, + paint = Line( + color = colors.earth, + width = byZoom(16.0 to 1.0, 24.0 to 128.0), + offset = byZoom(16.0 to 0.5, 24.0 to 64.0) + ) + ), + Layer("landuse-forest", + src = "landuse", + filter = listOf(tagIn("class", "wood", "scrub")), + minZoom = 5.0, + paint = Fill(color = colors.forest, opacity = byZoom(5.0 to 0.0, 6.0 to 1.0)) + ), + + *(1..2).map { i -> + Layer("hillshade-highlight-$i", + src = "hillshade", + filter = listOf(tagIs("highlight", i)), + maxZoom = 16.0, + paint = Fill( + color = colors.hillshadeLight, + antialias = false, + opacity = byZoom(12.0 to 0.12, 16.0 to 0.0) + ) + ) + }.toTypedArray(), + + *(1..4).map { i -> + Layer("hillshade-shadow-$i", + src = "hillshade", + filter = listOf(tagIs("shadow", i)), + maxZoom = 16.0, + paint = Fill( + color = colors.hillshadeShadow, + antialias = false, + opacity = byZoom(12.0 to 0.05, 16.0 to 0.0) + ) + ) + }.toTypedArray(), + + Layer("water-areas", + src = "water", + filter = listOf(Structure.None.filter), + paint = Fill(colors.water) + ), + Layer("water-shore-lines", + src = "water", + filter = listOf(Structure.None.filter), + minZoom = 15.0, + paint = Line( + color = colors.waterShore, + width = byZoom(15.0 to 1.0, 18.0 to 4.0, 24.0 to 256.0), + offset = byZoom(15.0 to 1.0, 18.0 to 4.0, 24.0 to 256.0), + opacity = byZoom(15.0 to 0.0, 18.0 to 1.0), + miterLimit = 6, + ) + ), + rivers.toLayer(Structure.None), + streams.toLayer(Structure.None), + + Layer("aeroways", + src = "aeroway", + filter = listOf(isLines), + paint = Line( + color = colors.aeroway, + width = byZoom(10.0 to 1.0, 24.0 to 8192.0), + join = "round" + ) + ), + + Layer("buildings", + src = "building", + minZoom = 15.0, + paint = Fill(color = colors.building, opacity = byZoom(15.0 to 0.0, 16.0 to 1.0)) + ), + + Layer("buildings-outline", + src = "building", + minZoom = 15.5, + paint = Line( + color = colors.buildingOutline, + width = byZoom(16.0 to 1.0, 24.0 to 128.0), + opacity = byZoom(15.5 to 0.0, 16.0 to 1.0) + ) + ), + + pedestrianAreaCasingLayer(Structure.None), + pedestrianAreaLayer(Structure.None), + + *allRoadLayers(Structure.Tunnel).toTypedArray(), + + *allRoadLayers(Structure.None).toTypedArray(), + + Layer("barriers-large", + src = "structure", + filter = listOf(tagIn("type", "city_wall", "dam", "cliff")), + minZoom = 16.0, + paint = Line(width = byZoom(16.0 to 4.0, 24.0 to 768.0), color = colors.buildingOutline) + ), + Layer("barriers-wall", + src = "structure", + filter = listOf(tagIs("class", "fence"), tagIsNot("type", "city_wall")), + minZoom = 16.0, + paint = Line(width = byZoom(16.0 to 1.0, 24.0 to 256.0), color = colors.buildingOutline) + ), + Layer("barriers-hedges", + src = "structure", + filter = listOf(tagIs("class", "hedge")), + minZoom = 16.0, + paint = Line(width = byZoom(16.0 to 1.0, 24.0 to 512.0), color = colors.forest) + ), + + Layer("point-barriers", + src = "structure", + filter = listOf(isPoint), + minZoom = 17.0, + paint = Circle(color = colors.pointBarrier, radius = byZoom(17.0 to 2.0, 24.0 to 256.0)) + ), + + Layer("bridge-areas", + src = "structure", + filter = listOf(isPolygon, tagIs("class", "bridge")), + paint = Fill(color = colors.building, opacity = "0.8") + ), + Layer("bridge-lines", + src = "structure", + filter = listOf(isLines, tagIs("class", "bridge")), + paint = Line(color = colors.building, width = byZoom(16.0 to 4.0, 24.0 to 512.0), opacity = "0.8") + ), + + Layer("water-areas-bridge", + src = "water", + filter = listOf(Structure.Bridge.filter), + paint = Fill(colors.water) + ), + rivers.toLayer(Structure.Bridge), + streams.toLayer(Structure.Bridge), + + pedestrianAreaCasingLayer(Structure.Bridge), + pedestrianAreaLayer(Structure.Bridge), + + *allRoadLayers(Structure.Bridge).toTypedArray(), + + Layer("oneway-arrows", + src = "road", + filter = listOf(isLines, tagIs("oneway", true)), + minZoom = 17.0, + paint = Symbol( + image = "oneway-arrow", + size = byZoom(17.0 to 0.25, 24.0 to 16.0), + color = colors.onewayArrow, + padding = 5, + placement = "line", + spacing = byZoom(17.0 to 200.0, 24.0 to 25600.0), + rotate = 90, + rotationAlignment = "map" + ) + ), + + Layer("boundaries", + src = "admin", + filter = listOf(tagIs("admin_level", 2), tagIsNot("maritime", true)), + paint = Line(color = colors.adminBoundary, width = "1", dashes = "[1, 2]") + ), + + Layer("labels-country", + src = "place_label", + filter = listOf(tagIs("class", "country")), + paint = defaultTextStyle.copy(fonts = listOf("Roboto Bold")) + ), + + Layer("labels-localities", + src = "place_label", + filter = listOf(tagIs("class", "locality")), + paint = defaultTextStyle + ), + + Layer("labels-housenumbers", + src = "housenum_label", + minZoom = 17.0, + paint = defaultTextStyle.copy(text = "[\"get\", \"house_num\"]") + ), + + Layer("labels-road", + src = "road", + minZoom = 14.0, + filter = listOf(isLines), + paint = defaultTextStyle.copy(wrap = 25, placement = "line-center") + ), + + Layer("labels-rivers", + src = "waterway", + minZoom = 14.0, + filter = listOf( + tagIsNot("structure", "tunnel"), + tagIn("class", "river", "canal") + ), + paint = waterTextStyle.copy(placement = "line-center",) + ), + + Layer("labels-streams", + src = "waterway", + minZoom = 16.0, + filter = listOf( + tagIsNot("structure", "tunnel"), + tagIn("class", "stream", "ditch", "drain") + ), + paint = waterTextStyle.copy(placement = "line-center") + ), + + /* + // I don't know, kind of does not look good. Maybe it would look better if roofs were rendered? + + Layer("buildings-extrude", + src = "building", + filter = listOf(tagIs("extrude", true)), + minZoom = 15.0, + maxZoom = 19.0, + paint = FillExtrusion( + color = colors.building, + base = """["get", "min_height"]""", + height = """["get", "height"]""", + opacity = byZoom(15, 0, 16, 0.8, 18, 0.8, 19, 0), + ) + ), + + */ + ) + + return """${partBeforeLayers(name, accessToken, rasterSource, rasterMaxZoom)} + { "id": "background", "type": "background", "paint": {"background-color": "${colors.earth}"}}, + ${layers.joinToString(",\n ") { it.toJson() }} + ] +} +""" +} + +private fun partBeforeLayers(name: String, accessToken: String, rasterSource: String?, rasterMaxZoom: Int) = """{ + "version": 8, + "name": "$name", + "sources": { + "jawg-streets": { + "type": "vector", + "tiles": ["https://tile.jawg.io/streets-v2+hillshade-v1/{z}/{x}/{y}.pbf?access-token=$accessToken"], + "attribution": "© OSM contributors | © JawgMaps", + "maxzoom": 16 + ${if (rasterSource == null) "}" else """ }, + "raster-source": { + "type": "raster", + "tiles": ["$rasterSource"], + "maxzoom": $rasterMaxZoom + }"""} + }, + "transition": { "duration": 300, "delay": 0 }, + "light": { "intensity": 0.2 }, + "glyphs": "asset://map_theme/glyphs/{fontstack}/{range}.pbf", + "sprite": "asset://map_theme/sprites", + "layers": [${if (rasterSource == null) "" else "\n"+"""{ "id": "raster-layer", "source": "raster-source", "type": "raster" },"""}""" + +private data class Waterway( + val id: String, + val filters: List, + val color: String, + val width: List>, + val minZoom: Double? = null, +) + +private fun Waterway.toLayer(structure: Structure) = Layer( + id = listOfNotNull(id, structure.id).joinToString("-"), + src = "waterway", + filter = filters + listOf(isLines, structure.filter), + minZoom = minZoom, + paint = Line( + color = color, + width = byZoom(width.map { (z, w) -> z to w }), + join = "round", + cap = "round", + ) +) + +private data class Road( + val id: String, + val filters: List, + val color: String, + val colorOutline: String, + val width: List>, + val minZoom: Double? = null, +) + +private fun Road.toLayer(structure: Structure) = Layer( + id = listOfNotNull(id, structure.id).joinToString("-"), + src = "road", + filter = filters + listOf(isLines, structure.filter), + paint = Line( + color = color, + width = byZoom(width.map { (z, w) -> z to w }), + join = "round", + cap = "round", + opacity = when { + structure == Structure.Tunnel -> "0.25" + minZoom != null -> byZoom(minZoom to 0.0, minZoom + 1.0 to 1.0) + else -> null + } + ) +) + +private fun Road.toCasingLayer(structure: Structure) = Layer( + id = listOfNotNull(id, structure.id, "casing").joinToString("-"), + src = "road", + filter = filters + listOf(isLines, structure.filter), + minZoom = 15.5, + paint = Line( + color = colorOutline, + width = byZoom(16.0 to 1.0, 24.0 to 128.0), + join = "round", + opacity = byZoom(15.0 to 0.0, 16.0 to 1.0), + // cap must not be round for bridges so that the casing is not drawn on top of normal roads + cap = if (structure == Structure.None) "round" else "butt", + dashes = if (structure == Structure.Tunnel) "[4, 4]" else null, + gapWidth = byZoom(width.map { (z, w) -> z to w }) + ) +) + +private fun Road.toLayerPrivateOverlay(structure: Structure, privateColor: String) = Layer( + id = listOfNotNull(id, structure.id, "private").joinToString("-"), + src = "road", + filter = filters + listOf( + isLines, + tagIn("access", "no", "private", "destination", "customers", "delivery", "agricultural", "forestry", "emergency"), + structure.filter + ), + paint = Line( + color = privateColor, + width = byZoom(width.map { (z, w) -> z to w * 0.5 }), + join = "round", + cap = "round", + dashes = "[1, 2]", + ) +) + +private enum class Structure { Bridge, Tunnel, None } + +private val Structure.filter get() = when (this) { + Structure.Bridge -> tagIs("structure", "bridge") + Structure.Tunnel -> tagIs("structure", "tunnel") + Structure.None -> tagNotIn("structure", "bridge", "tunnel") +} + +private val Structure.id get() = when (this) { + Structure.Bridge -> "bridge" + Structure.Tunnel -> "tunnel" + Structure.None -> null +} + +data class MapColors( + val earth: String, + val water: String, + val waterShore: String, + val green: String, + val forest: String, + val town: String, + val building: String, + val buildingOutline: String, + val pointBarrier: String, + val adminBoundary: String, + val railway: String, + val aeroway: String, + val path: String, + val road: String, + val roadOutline: String, + val pedestrian: String, + val motorway: String, + val motorwayOutline: String, + val text: String, + val textOutline: String, + val textWater: String, + val textWaterOutline: String, + val privateOverlay: String, + val hillshadeLight: String, + val hillshadeShadow: String, + val onewayArrow: String, +) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MapStyles.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MapStyles.kt new file mode 100644 index 00000000000..eb2d3bea284 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MapStyles.kt @@ -0,0 +1,147 @@ +package de.westnordost.streetcomplete.screens.main.map + +val themeLight = MapColors( + earth = "#f3eacc", + water = "#68d", + waterShore = "#abe", + green = "#c6ddaa", + forest = "#a8c884", + town = "#f3dacd", + building = "rgb(204,214,238)", + buildingOutline = "rgb(185,195,217)", + pointBarrier = "#888", + adminBoundary = "#e39", + railway = "#99a", + aeroway = "#fff", + path = "#ca9", + road = "#fff", + roadOutline = "#ca9", + pedestrian = "#f6eee6", + motorway = "#fa8", + motorwayOutline = "#a88", + text = "#124", + textOutline = "#fff", + textWater = "#fff", + textWaterOutline = "#349", + privateOverlay = "#f3dacd", + hillshadeLight = "hsl(220, 100%, 95%)", + hillshadeShadow = "hsl(18, 100%, 40%)", + onewayArrow = "#888", +) + +val themeNight = MapColors( + earth = "#2e2e48", + water = "#002", + waterShore = "#228", + green = "#363054", + forest = "#403962", + town = "#3d364e", + building = "rgba(41,92,92,0.8)", + buildingOutline = "rgba(31,82,82,0.8)", + pointBarrier = "#99f", + adminBoundary = "#e72", + railway = "#96c", + aeroway = "#559", + path = "#547", + road = "#559", + roadOutline = "#547", + pedestrian = "#554e7e", + motorway = "#669", + motorwayOutline = "#99f", + text = "#ccf", + textOutline = "#2e2e48", + textWater = "#2e2e48", + textWaterOutline = "#ccf", + privateOverlay = "#3d364e", + hillshadeLight = "hsl(240, 30%, 50%)", + hillshadeShadow = "hsl(240, 80%, 0%)", + onewayArrow = "#ccf" +) + +val themeDarkContrast = MapColors( + earth = "#151525", + water = "#000060", + waterShore = "#228", + green = "#375935", + forest = "#293f28", + town = "#000000", + building = "rgba(33,77,77,0.7)", + buildingOutline = "rgba(59,140,140,0.5)", + pointBarrier = "#99f", + adminBoundary = "#e72", + railway = "#727", + aeroway = "#559", + path = "#668", + road = "#559", + roadOutline = "#99f", + pedestrian = "#554e7e", + motorway = "#669", + motorwayOutline = "#99f", + text = "#ccf", + textOutline = "#2e2e48", + textWater = "#2e2e48", + textWaterOutline = "#aac", + privateOverlay = "#3d364e", + hillshadeLight = "hsl(240, 30%, 50%)", + hillshadeShadow = "hsl(240, 80%, 0%)", + onewayArrow = "#ccf" +) + +fun rasterBackground(hideLabels: Boolean) = if (hideLabels) + MapColors( // hide everything, not just labels + earth = "rgba(0,0,0,0)", + water = "rgba(0,0,0,0)", + waterShore = "rgba(0,0,0,0)", + green = "rgba(0,0,0,0)", + forest = "rgba(0,0,0,0)", + town = "rgba(0,0,0,0)", + building = "rgba(0,0,0,0)", + buildingOutline = "rgba(0,0,0,0)", + pointBarrier = "rgba(0,0,0,0)", + adminBoundary = "rgba(0,0,0,0)", + railway = "rgba(0,0,0,0)", + aeroway = "rgba(0,0,0,0)", + path = "rgba(0,0,0,0)", + road = "rgba(0,0,0,0)", + roadOutline = "rgba(0,0,0,0)", + pedestrian = "rgba(0,0,0,0)", + motorway = "rgba(0,0,0,0)", + motorwayOutline = "rgba(0,0,0,0)", + text = "rgba(0,0,0,0)", + textOutline = "rgba(0,0,0,0)", + textWater = "rgba(0,0,0,0)", + textWaterOutline = "rgba(0,0,0,0)", + privateOverlay = "rgba(0,0,0,0)", + hillshadeLight = "rgba(0,0,0,0)", + hillshadeShadow = "rgba(0,0,0,0)", + onewayArrow = "rgba(0,0,0,0)", + ) +else + MapColors( + earth = "rgba(0,0,0,0)", + water = "rgba(0,0,0,0)", + waterShore = "rgba(170,187,238,0.4)", + green = "rgba(0,0,0,0)", + forest = "rgba(0,0,0,0)", + town = "rgba(0,0,0,0)", + building = "rgba(204,214,238,0.2)", + buildingOutline = "rgba(185,195,217,0.4)", + pointBarrier = "#888", + adminBoundary = "#e39", + railway = "rgba(153,153,170,0.5)", + aeroway = "#fff", + path = "rgba(255,170,136,0.2)", + road = "rgba(255,255,255,0.2)", + roadOutline = "rgba(204,170,153,0.5)", + pedestrian = "rgba(255,170,136,0.2)", + motorway = "rgba(255,255,255,0.2)", + motorwayOutline = "rgba(204,170,153,0.5)", + text = "#124", + textOutline = "#fff", + textWater = "#fff", + textWaterOutline = "#349", + privateOverlay = "rgba(243,218,205,0.5)", + hillshadeLight = "hsl(220, 100%, 95%)", + hillshadeShadow = "hsl(18, 100%, 40%)", + onewayArrow = "#888", + ) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/PresetIcons.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/PresetIcons.kt index 93a024be248..6d16be3301e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/PresetIcons.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/PresetIcons.kt @@ -20,5 +20,22 @@ import de.westnordost.streetcomplete.view.presetIconIndex return null } -fun getTitle(tags: Map): String? = - getNameLabel(tags) ?: getShortHouseNumber(tags) +fun getTitle(tags: Map, languages: Collection = emptyList()): String? { + return getNameLabel(tags) ?: getShortHouseNumber(tags) ?: getTreeGenus(tags, languages) +} + +// prefer tree species in provided languages, then osm tag, then other languages +fun getTreeGenus(tags: Map, languages: Collection = emptyList()): String? { + if (tags["natural"] != "tree") return null + languages.forEach { lc -> + tags["species:$lc"]?.let { return it } + tags["genus:$lc"]?.let { return it } + } + tags["species"]?.let { return it } + tags["genus"]?.let { return it } + tags.forEach { (key, value) -> + if (key.startsWith("genus:") || key.startsWith("species:")) + return value + } + return null +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/QuestPinsManager.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/QuestPinsManager.kt index a18d80abf76..ef1cc43eb39 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/QuestPinsManager.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/QuestPinsManager.kt @@ -2,21 +2,34 @@ package de.westnordost.streetcomplete.screens.main.map import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner +import com.russhwolf.settings.ObservableSettings +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.download.tiles.TilesRect import de.westnordost.streetcomplete.data.download.tiles.enclosingTilesRect import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource +import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry import de.westnordost.streetcomplete.data.osm.mapdata.ElementType +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuest +import de.westnordost.streetcomplete.data.overlays.SelectedOverlaySource +import de.westnordost.streetcomplete.data.quest.DayNightCycle import de.westnordost.streetcomplete.data.quest.OsmNoteQuestKey import de.westnordost.streetcomplete.data.quest.OsmQuestKey +import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey import de.westnordost.streetcomplete.data.quest.Quest import de.westnordost.streetcomplete.data.quest.QuestKey import de.westnordost.streetcomplete.data.quest.QuestType import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry import de.westnordost.streetcomplete.data.quest.VisibleQuestsSource import de.westnordost.streetcomplete.data.visiblequests.QuestTypeOrderSource +import de.westnordost.streetcomplete.overlays.places.PlacesOverlay +import de.westnordost.streetcomplete.quests.show_poi.ShowBusiness import de.westnordost.streetcomplete.screens.main.map.components.Pin import de.westnordost.streetcomplete.screens.main.map.components.PinsMapComponent import de.westnordost.streetcomplete.screens.main.map.maplibre.screenAreaToBoundingBox +import de.westnordost.streetcomplete.screens.main.map.maplibre.toLatLon +import de.westnordost.streetcomplete.util.getNameLabel +import de.westnordost.streetcomplete.util.isDay import de.westnordost.streetcomplete.util.math.contains import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -38,22 +51,28 @@ class QuestPinsManager( private val pinsMapComponent: PinsMapComponent, private val questTypeOrderSource: QuestTypeOrderSource, private val questTypeRegistry: QuestTypeRegistry, - private val visibleQuestsSource: VisibleQuestsSource + private val visibleQuestsSource: VisibleQuestsSource, + private val prefs: ObservableSettings, + private val mapDataSource: MapDataWithEditsSource, + private val selectedOverlaySource: SelectedOverlaySource, ) : DefaultLifecycleObserver { // draw order in which the quest types should be rendered on the map - private val questTypeOrders: MutableMap = mutableMapOf() + private val questTypeOrders: MutableMap = hashMapOf() // last displayed rect of (zoom 16) tiles private var lastDisplayedRect: TilesRect? = null // quests in current view: key -> [pin, ...] - private val questsInView: MutableMap> = mutableMapOf() + private val questsInView: MutableMap> = hashMapOf() + var reversedOrder = false + private set private val questsInViewMutex = Mutex() private val visibleQuestsSourceMutex = Mutex() - private val viewLifecycleScope: CoroutineScope = CoroutineScope(SupervisorJob()) + private val viewLifecycleScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) // todo: remove? private var updateJob: Job? = null + private val m = Mutex() // todo: remove? /** Switch visibility of quest pins layer */ var isVisible: Boolean = false @@ -121,6 +140,7 @@ class QuestPinsManager( } private fun invalidate() { + viewLifecycleScope.launch { questsInViewMutex.withLock { questsInView.clear() } } lastDisplayedRect = null onNewScreenPosition() } @@ -219,10 +239,28 @@ class QuestPinsManager( pinsMapComponent.set(pins) } + fun setQuestOrder(reverse: Boolean) { + reversedOrder = reverse + reinitializeQuestTypeOrders() + } + private fun initializeQuestTypeOrders() { // this needs to be reinitialized when the quest order changes val sortedQuestTypes = questTypeRegistry.toMutableList() questTypeOrderSource.sort(sortedQuestTypes) + // move specific quest types to front if set by preference + val moveToFront = if (Prefs.DayNightBehavior.valueOf(prefs.getString(Prefs.DAY_NIGHT_BEHAVIOR, "IGNORE")) == Prefs.DayNightBehavior.PRIORITY) + if (map.cameraPosition.target?.toLatLon()?.let { isDay(it) } != false) + sortedQuestTypes.filter { it.dayNightCycle == DayNightCycle.ONLY_DAY } + else + sortedQuestTypes.filter { it.dayNightCycle == DayNightCycle.ONLY_NIGHT } + else + emptyList() + moveToFront.reversed().forEach { // reversed to keep order within moveToFront + sortedQuestTypes.remove(it) + sortedQuestTypes.add(0, it) + } + if (reversedOrder) sortedQuestTypes.reverse() // invert only after doing the sorting changes synchronized(questTypeOrders) { questTypeOrders.clear() sortedQuestTypes.forEachIndexed { index, questType -> @@ -232,12 +270,34 @@ class QuestPinsManager( } private fun createQuestPins(quest: Quest): List { - val props = quest.key.toProperties() + val color = quest.type.dotColor + val label = if (color != null && quest is OsmQuest) getLabel(quest) else null + val geometry = if (quest.geometry !is ElementPointGeometry && prefs.getBoolean(Prefs.QUEST_GEOMETRIES, false) && color == null) + quest.geometry + else null + + val props = if (label == null) quest.key.toProperties() else (quest.key.toProperties() + ("label" to label)) val order = synchronized(questTypeOrders) { questTypeOrders[quest.type] ?: 0 } - return quest.markerLocations.map { Pin(it, quest.type.icon, props, order) } + + val pins = quest.markerLocations.map { Pin(it, quest.type.icon, props, order, geometry, color) } + // storing importance in the quest requires the VisibleQuestsSource.cache to be invalidated on order change! + // or what we do: clear quest.pins if the order changed + quest.pins = pins + return pins + } + + private fun getLabel(quest: OsmQuest): String? { + if (quest.type is ShowBusiness && selectedOverlaySource.selectedOverlay is PlacesOverlay) + return null // avoid duplicate business labels if shops overlay is active + val labelSources = quest.type.dotLabelSources.ifEmpty { return null } + val tags = mapDataSource.get(quest.elementType, quest.elementId)?.tags ?: return null + return labelSources.firstNotNullOfOrNull { + if (it == "label") getNameLabel(tags) else tags[it] + } } private fun reinitializeQuestTypeOrders() { + visibleQuestsSource.clearCachedQuestPins() // pin.importance contains quest order, so we need to reset it initializeQuestTypeOrders() invalidate() } @@ -253,9 +313,12 @@ private const val MARKER_ELEMENT_TYPE = "element_type" private const val MARKER_ELEMENT_ID = "element_id" private const val MARKER_QUEST_TYPE = "quest_type" private const val MARKER_NOTE_ID = "note_id" +private const val MARKER_OTHER_ID = "other_id" +private const val MARKER_OTHER_SOURCE = "other_source" private const val QUEST_GROUP_OSM = "osm" private const val QUEST_GROUP_OSM_NOTE = "osm_note" +private const val QUEST_GROUP_OTHER = "other" private fun QuestKey.toProperties(): List> = when (this) { is OsmNoteQuestKey -> listOf( @@ -268,6 +331,11 @@ private fun QuestKey.toProperties(): List> = when (this) { MARKER_ELEMENT_ID to elementId.toString(), MARKER_QUEST_TYPE to questTypeName ) + is ExternalSourceQuestKey -> listOf( + MARKER_QUEST_GROUP to QUEST_GROUP_OTHER, + MARKER_OTHER_ID to id, + MARKER_OTHER_SOURCE to source, + ) } private fun Map.toQuestKey(): QuestKey? = when (get(MARKER_QUEST_GROUP)) { @@ -279,5 +347,7 @@ private fun Map.toQuestKey(): QuestKey? = when (get(MARKER_QUEST getValue(MARKER_ELEMENT_ID).toLong(), getValue(MARKER_QUEST_TYPE) ) + QUEST_GROUP_OTHER -> + ExternalSourceQuestKey(getValue(MARKER_OTHER_ID), getValue(MARKER_OTHER_SOURCE)) else -> null } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/ShowsGeometryMarkers.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/ShowsGeometryMarkers.kt index 83790dd74b1..7f4e45cd4d2 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/ShowsGeometryMarkers.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/ShowsGeometryMarkers.kt @@ -13,5 +13,7 @@ interface ShowsGeometryMarkers { data class Marker( val geometry: ElementGeometry, @DrawableRes val icon: Int? = null, - val title: String? = null + val title: String? = null, + val color: Int? = null, + val rotation: Double? = null, ) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/StyleableOverlayManager.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/StyleableOverlayManager.kt index d9d3aa7ceaa..b59524b2187 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/StyleableOverlayManager.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/StyleableOverlayManager.kt @@ -7,10 +7,15 @@ import de.westnordost.streetcomplete.data.download.tiles.enclosingTilesRect import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry -import de.westnordost.streetcomplete.data.osm.mapdata.key +import de.westnordost.streetcomplete.data.osm.mapdata.Relation import de.westnordost.streetcomplete.data.overlays.SelectedOverlaySource +import de.westnordost.streetcomplete.data.visiblequests.LevelFilter +import de.westnordost.streetcomplete.osm.ALL_ROADS import de.westnordost.streetcomplete.overlays.Overlay +import de.westnordost.streetcomplete.overlays.custom.CustomOverlay +import de.westnordost.streetcomplete.overlays.restriction.RestrictionOverlay import de.westnordost.streetcomplete.screens.main.map.components.StyleableOverlayMapComponent import de.westnordost.streetcomplete.screens.main.map.components.StyledElement import de.westnordost.streetcomplete.screens.main.map.maplibre.screenAreaToBoundingBox @@ -34,7 +39,8 @@ class StyleableOverlayManager( private val map: MapLibreMap, private val mapComponent: StyleableOverlayMapComponent, private val mapDataSource: MapDataWithEditsSource, - private val selectedOverlaySource: SelectedOverlaySource + private val selectedOverlaySource: SelectedOverlaySource, + private val levelFilter: LevelFilter, ) : DefaultLifecycleObserver { // last displayed rect of (zoom 16) tiles @@ -49,9 +55,78 @@ class StyleableOverlayManager( private var updateJob: Job? = null + /* todo: either re-introduce this cache (if clear performance benefit), or kick it (if noticeable improvement, maybe do sth like that for SC?) + private val m = Mutex() + + // cache recent queries in some sort of crappy chaotic spatial cache + // don't do it by tile, because this can be much slower to load in some cases + private val cache = LinkedHashMap>(16, 0.9f, true) + + // return styledElements, and cache the ones from rects we don't have + private fun getFromCache(tilesRect: TilesRect): Collection { + // get smallest cached rect that completely contains tileRect + var smallestTile: TilesRect? = null + cache.keys.forEach { + if (it.contains(tilesRect) && it.size < (smallestTile?.size ?: 1000)) + smallestTile = it + } + if (smallestTile != null) return cache[smallestTile]!!.values + // maybe we have it cached in multiple tiles + val tiles = tilesRect.asTilePosSequence().toList() + // info which cached tilesRect contains which tiles + val a = cache.keys.mapNotNull { rect -> + rect to tiles.filter { rect.contains(it.toTilesRect()) }.ifEmpty { return@mapNotNull null } + }.sortedBy { it.first.size } // sort by rect size so we prefer small ones + if (a.isEmpty()){ + return fetchAndCache(tilesRect) + } + + val cachedTiles = hashSetOf() + val fetchRects = mutableListOf() + for ((rect, list) in a) { + if (list.any { it !in cachedTiles }) { + cachedTiles.addAll(list) + fetchRects.add(rect) + } + } + // allow returning a larger area than wanted, this is slower when setting but uses cached data + if (cachedTiles.containsAll(tiles) && fetchRects.flatMap { it.asTilePosSequence().toList() }.toHashSet().size <= tiles.size * 1.5) { + val data = hashSetOf() + fetchRects.forEach { data.addAll(cache[it]!!.values) } + return data + } + + // still here -> just use the tiles that are fully contained in cache, and request the rest + val fullyContainedTileRects = a.unzip().first.filter { tilesRect.contains(it) } + val t = tiles.filterNot { tile -> fullyContainedTileRects.any { it.contains(tile.toTilesRect()) } } + val minRect = t.minTileRect() + return if (minRect == tilesRect || minRect == null) { + fetchAndCache(tilesRect) + } else { + val data = hashSetOf() + fullyContainedTileRects.forEach { data.addAll(cache[it]!!.values) } + data.addAll(fetchAndCache(minRect)) + data + } + } + + private fun fetchAndCache(tilesRect: TilesRect): Collection { + val mapData = mapDataSource.getMapDataWithGeometry(tilesRect.asBoundingBox(TILES_ZOOM)) + val overlay = overlay ?: return emptyList() + val data = HashMap(mapData.size / 20, 0.9f) + createStyledElementsByKey(overlay, mapData).forEach { (key, styledElement) -> + if (!levelFilter.levelAllowed(styledElement.element)) return@forEach + data[key] = styledElement + } + + cache[tilesRect] = data + if (cache.size > 16) cache.keys.remove(cache.keys.first()) + return data.values + } +*/ private var overlay: Overlay? = null set(value) { - if (field == value) return + if (field == value && field !is CustomOverlay) return val wasNull = field == null val isNullNow = value == null field = value @@ -77,6 +152,15 @@ class StyleableOverlayManager( updateJob = viewLifecycleScope.launch { oldUpdateJob?.join() // don't cancel, as updateStyledElements only updates existing data updateStyledElements(updated, deleted) + if (overlay is RestrictionOverlay + // reload all if relation is updated, because normal update doesn't change ways + // and reload if ways are updated, because without knowing the relation it will not be highlighted + && (updated.any { it is Relation || it.tags["highway"] in ALL_ROADS } || deleted.any { it.type == ElementType.RELATION })) { + lastDisplayedRect?.let { + //cache.clear() todo: remove when removing cache + setStyledElements(it.asBoundingBox(TILES_ZOOM)) + } + } } } @@ -148,6 +232,7 @@ class StyleableOverlayManager( } private fun clear() { + //runBlocking { m.withLock { cache.clear() } } lastDisplayedRect = null viewLifecycleScope.launch { mapDataInViewMutex.withLock { mapDataInView.clear() } @@ -163,6 +248,7 @@ class StyleableOverlayManager( val overlay = overlay ?: return mapDataInView.clear() createStyledElementsByKey(overlay, mapData).forEach { (key, styledElement) -> + if (!levelFilter.levelAllowed(styledElement.element)) return@forEach mapDataInView[key] = styledElement } mapDataInView.values.toList() @@ -170,6 +256,7 @@ class StyleableOverlayManager( mapComponent.set(styledElements) } + // todo: when using cache, this here was not called iirc private suspend fun updateStyledElements(updated: MapDataWithGeometry, deleted: Collection) { val styledElements = mapDataInViewMutex.withLock { val displayedBBox = lastDisplayedRect?.asBoundingBox(TILES_ZOOM) ?: return @@ -188,7 +275,7 @@ class StyleableOverlayManager( } // elements that are either newly displayed or which were updated styledElementsByKey.forEach { (key, styledElement) -> - if (displayedBBox.intersect(styledElement.geometry.getBounds())) { + if (displayedBBox.intersect(styledElement.geometry.getBounds()) && levelFilter.levelAllowed(styledElement.element)) { mapDataInView[key] = styledElement hasChanges = true } else { diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/CustomGeometryMapComponent.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/CustomGeometryMapComponent.kt new file mode 100644 index 00000000000..f47ae4a2188 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/CustomGeometryMapComponent.kt @@ -0,0 +1,86 @@ +package de.westnordost.streetcomplete.screens.main.map.components + +import android.content.Context +import androidx.annotation.UiThread +import de.westnordost.streetcomplete.screens.main.map.components.FocusGeometryMapComponent.Companion +import de.westnordost.streetcomplete.screens.main.map.maplibre.clear +import de.westnordost.streetcomplete.screens.main.map.maplibre.isArea +import de.westnordost.streetcomplete.screens.main.map.maplibre.isPoint +import de.westnordost.streetcomplete.util.logs.Log +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.style.expressions.Expression.* +import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.Layer +import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.layers.Property +import org.maplibre.android.style.layers.PropertyFactory.* +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.android.style.sources.GeoJsonSource + +/** Allows setting any (User-Provided) Geo-Json. Reads text from "name" property */ +class CustomGeometryMapComponent( + private val context: Context, + private val map: MapLibreMap, +) { + private val geometrySource = GeoJsonSource(SOURCE) + + val layers: List = listOf( + FillLayer("custom-geo-fill", SOURCE) + .withFilter(isArea()) + .withProperties( + fillColor(COLOR), + fillOpacity(0.3f) + ), + LineLayer("custom-geo-lines", SOURCE) + // both polygon and line + .withProperties( + lineWidth(8f), + lineColor(COLOR), + lineOpacity(0.5f), + lineCap(Property.LINE_CAP_ROUND) + ), + CircleLayer("custom-geo-circle", SOURCE) + .withFilter(isPoint()) + .withProperties( + circleColor(COLOR), + circleRadius(8f), + circleOpacity(0.6f) + ), + SymbolLayer("custom-geo-text", SOURCE) + .withFilter(all(has("name"), gte(zoom(), 14))) + .withProperties( + textColor(COLOR), + textFont(arrayOf("Roboto Regular")), + textField(get("name")), + textAllowOverlap(true), + textIgnorePlacement(true), + textAnchor(Property.TEXT_ANCHOR_TOP), + textOffset(arrayOf(0f, 1f)), + textSize(16 * context.resources.configuration.fontScale), + ), + ) + + init { + geometrySource.isVolatile = true + map.style?.addSource(geometrySource) + } + + @UiThread fun set(geoJson: String) { + try { + geometrySource.setGeoJson(geoJson) + } catch (e: Exception) { + Log.e("CustomGeometrySource", "error setting geoJson: $e") + clear() + } + } + + @UiThread fun clear() { + geometrySource.clear() + } + + companion object { + private const val SOURCE = "custom-geo-source" + private const val COLOR = "#9e319e" + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/FocusGeometryMapComponent.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/FocusGeometryMapComponent.kt index 00896ae0d68..d489d45f3f7 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/FocusGeometryMapComponent.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/FocusGeometryMapComponent.kt @@ -6,8 +6,12 @@ import android.provider.Settings import androidx.annotation.UiThread import androidx.core.graphics.Insets import androidx.lifecycle.DefaultLifecycleObserver +import com.russhwolf.settings.ObservableSettings +import de.westnordost.streetcomplete.Prefs import androidx.lifecycle.LifecycleOwner +import com.google.gson.JsonObject import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.geometry.ElementPolylinesGeometry import de.westnordost.streetcomplete.screens.main.map.maplibre.CameraPosition import de.westnordost.streetcomplete.screens.main.map.maplibre.Padding import de.westnordost.streetcomplete.screens.main.map.maplibre.camera @@ -17,6 +21,10 @@ import de.westnordost.streetcomplete.screens.main.map.maplibre.isArea import de.westnordost.streetcomplete.screens.main.map.maplibre.isPoint import de.westnordost.streetcomplete.screens.main.map.maplibre.toMapLibreGeometry import de.westnordost.streetcomplete.screens.main.map.maplibre.updateCamera +import org.maplibre.android.style.expressions.Expression.has +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.style.layers.CircleLayer import org.maplibre.android.style.layers.FillLayer @@ -34,7 +42,7 @@ import kotlin.math.sin /** Display element geometry and enables focussing on given geometry. I.e. to highlight the geometry * of the element a selected quest refers to. Also zooms to the element in question so that it is * contained in the screen area */ -class FocusGeometryMapComponent(private val contentResolver: ContentResolver, private val map: MapLibreMap) : +class FocusGeometryMapComponent(private val contentResolver: ContentResolver, private val map: MapLibreMap, private val prefs: ObservableSettings) : DefaultLifecycleObserver { private val focusedGeometrySource = GeoJsonSource(SOURCE) @@ -59,6 +67,17 @@ class FocusGeometryMapComponent(private val contentResolver: ContentResolver, pr lineOpacity(0.7f), lineCap(Property.LINE_CAP_ROUND) ), + SymbolLayer("focus-geo-arrows", SOURCE) + .withFilter(has("arrows")) + .withProperties( + iconColor("#D14000"), + iconOpacity(0.7f), + symbolPlacement(Property.SYMBOL_PLACEMENT_LINE), + iconAllowOverlap(true), + iconIgnorePlacement(true), + iconImage("oneway-arrow"), + iconRotate(90f) + ), CircleLayer("focus-geo-circle", SOURCE) .withFilter(isPoint()) .withProperties( @@ -94,7 +113,22 @@ class FocusGeometryMapComponent(private val contentResolver: ContentResolver, pr /** Show the given geometry. Previously shown geometry is replaced. */ @UiThread fun showGeometry(geometry: ElementGeometry) { - focusedGeometrySource.setGeoJson(geometry.toMapLibreGeometry()) + if (geometry is ElementPolylinesGeometry && prefs.getBoolean(Prefs.SHOW_WAY_DIRECTION, false)) { + val feature = Feature.fromGeometry(geometry.toMapLibreGeometry(), JsonObject().apply { addProperty("arrows", "yes") }) + focusedGeometrySource.setGeoJson(feature) + } else focusedGeometrySource.setGeoJson(geometry.toMapLibreGeometry()) + val animatorDurationScale = Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + if (animatorDurationScale > 0f) animation.start() + } + + // as above, but shows more than 1 geometry + fun showGeometries(geometries: Collection) { + val geoFeatures = geometries.map { + if (it is ElementPolylinesGeometry && prefs.getBoolean(Prefs.SHOW_WAY_DIRECTION, false)) + Feature.fromGeometry(it.toMapLibreGeometry(), JsonObject().apply { addProperty("arrows", "yes") }) + else Feature.fromGeometry(it.toMapLibreGeometry()) + } + focusedGeometrySource.setGeoJson(FeatureCollection.fromFeatures(geoFeatures)) val animatorDurationScale = Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) if (animatorDurationScale > 0f) animation.start() } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/GeometryMarkersMapComponent.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/GeometryMarkersMapComponent.kt index 273b6c20f7e..d326ebc323c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/GeometryMarkersMapComponent.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/GeometryMarkersMapComponent.kt @@ -17,6 +17,7 @@ import de.westnordost.streetcomplete.screens.main.map.maplibre.isArea import de.westnordost.streetcomplete.screens.main.map.maplibre.isPoint import de.westnordost.streetcomplete.screens.main.map.maplibre.toMapLibreGeometry import de.westnordost.streetcomplete.screens.main.map.maplibre.toPoint +import de.westnordost.streetcomplete.util.ktx.toHexColor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.maplibre.android.maps.MapLibreMap @@ -47,29 +48,30 @@ class GeometryMarkersMapComponent( FillLayer("geo-fill", SOURCE) .withFilter(isArea()) .withProperties( - fillColor("#D140D0"), + fillColor(get("color")), fillOpacity(0.3f) ), LineLayer("geo-lines", SOURCE) // both polygon and line .withProperties( lineWidth(10f), - lineColor("#D140D0"), + lineColor(get("color")), lineOpacity(0.5f), lineCap(Property.LINE_CAP_ROUND) ), SymbolLayer("geo-symbols", SOURCE) .withFilter(isPoint()) .withProperties( - iconColor("#D140D0"), + iconColor(get("color")), iconImage(get("icon")), iconSize(interpolate(linear(), zoom(), stop(17, 0.5f), stop(19, 1f))), iconAllowOverlap(true), + iconRotate(get("rotation")), textField(get("label")), textAnchor(Property.TEXT_ANCHOR_TOP), textOffset(arrayOf(0f, 1f)), textSize(16 * context.resources.configuration.fontScale), - textColor("#D140D0"), + textColor(get("color")), textFont(arrayOf("Roboto Bold")), textOptional(true) ) @@ -88,7 +90,7 @@ class GeometryMarkersMapComponent( createIconBitmap(context, it, sdf) to sdf } for (marker in markers) { - featuresByGeometry[marker.geometry] = marker.toFeatures(context.resources) + synchronized(this) {featuresByGeometry[marker.geometry] = marker.toFeatures(context.resources) } } withContext(Dispatchers.Main) { update() } } @@ -103,7 +105,7 @@ class GeometryMarkersMapComponent( geometrySource.clear() } - private fun update() { + private fun update() = synchronized(this) { geometrySource.setGeoJson(FeatureCollection.fromFeatures(featuresByGeometry.values.flatten())) } @@ -114,6 +116,7 @@ class GeometryMarkersMapComponent( private fun Marker.toFeatures(resources: Resources): List { val features = ArrayList(3) + val color = color?.toHexColor() ?: "#D140D0" // point marker or any marker with title or icon if (icon != null || title != null || geometry is ElementPointGeometry) { val p = JsonObject() @@ -122,12 +125,14 @@ private fun Marker.toFeatures(resources: Resources): List { if (title != null) { p.addProperty("label", title) } + p.addProperty("color", color) + p.addProperty("rotation", rotation?.toFloat() ?: 0.0f) features.add(Feature.fromGeometry(geometry.center.toPoint(), p)) } // polygon / polylines marker(s) if (geometry is ElementPolygonsGeometry || geometry is ElementPolylinesGeometry) { - features.add(Feature.fromGeometry(geometry.toMapLibreGeometry())) + features.add(Feature.fromGeometry(geometry.toMapLibreGeometry(), JsonObject().apply { addProperty("color", color) })) } return features } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/PinsMapComponent.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/PinsMapComponent.kt index c4b8d0bb2f4..e634dcf9d4c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/PinsMapComponent.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/PinsMapComponent.kt @@ -2,25 +2,37 @@ package de.westnordost.streetcomplete.screens.main.map.components import android.content.ContentResolver import android.content.Context +import android.content.res.Configuration import androidx.annotation.UiThread import androidx.core.graphics.Insets import com.google.gson.JsonObject +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.preferences.Theme import de.westnordost.streetcomplete.screens.main.map.createPinBitmap import de.westnordost.streetcomplete.screens.main.map.maplibre.MapImages import de.westnordost.streetcomplete.screens.main.map.maplibre.clear import de.westnordost.streetcomplete.screens.main.map.maplibre.getEnclosingCamera +import de.westnordost.streetcomplete.screens.main.map.maplibre.isArea +import de.westnordost.streetcomplete.screens.main.map.maplibre.isLine +import de.westnordost.streetcomplete.screens.main.map.maplibre.queryRenderedFeatures import de.westnordost.streetcomplete.screens.main.map.maplibre.toLatLon +import de.westnordost.streetcomplete.screens.main.map.maplibre.toMapLibreGeometry import de.westnordost.streetcomplete.screens.main.map.maplibre.toPoint import de.westnordost.streetcomplete.screens.main.map.maplibre.updateCamera +import de.westnordost.streetcomplete.util.ktx.dpToPx import de.westnordost.streetcomplete.util.math.enclosingBoundingBox import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.maplibre.android.geometry.LatLng import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.style.expressions.Expression import org.maplibre.android.style.expressions.Expression.all import org.maplibre.android.style.expressions.Expression.any +import org.maplibre.android.style.expressions.Expression.has import org.maplibre.android.style.expressions.Expression.division import org.maplibre.android.style.expressions.Expression.get import org.maplibre.android.style.expressions.Expression.gt @@ -29,6 +41,8 @@ import org.maplibre.android.style.expressions.Expression.literal import org.maplibre.android.style.expressions.Expression.log2 import org.maplibre.android.style.expressions.Expression.lte import org.maplibre.android.style.expressions.Expression.sum +import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.LineLayer import org.maplibre.android.style.expressions.Expression.toNumber import org.maplibre.android.style.expressions.Expression.zoom import org.maplibre.android.style.layers.CircleLayer @@ -52,6 +66,7 @@ class PinsMapComponent( private val contentResolver: ContentResolver, private val map: MapLibreMap, private val mapImages: MapImages, + private val prefs: Preferences, private val onClickPin: (properties: Map) -> Unit ) { private val pinsSource = GeoJsonSource(SOURCE, @@ -61,7 +76,49 @@ class PinsMapComponent( .withClusterRadius(55) ) + private val radius = context.resources.dpToPx(4) + + // separate sources because this should not count towards clustering + private val pinsGeometrySource = GeoJsonSource(GEOMETRY_SOURCE) + private val pinDotsSource = GeoJsonSource(DOT_SOURCE) + private val isNightMode: Boolean get() { + val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return currentNightMode == Configuration.UI_MODE_NIGHT_YES + } + val layers: List = listOf( + LineLayer("pins-geometry-lines-layer", GEOMETRY_SOURCE) + .withFilter(all(isLine(), gte(zoom(), 16f))) + .withProperties( + lineColor("#0092D1"), + lineOpacity(0.4f), + lineWidth(10f), + lineCap(Property.LINE_CAP_ROUND) + ), + FillLayer("pins-geometry-fill-layer", GEOMETRY_SOURCE) + .withFilter(all(isArea(), gte(zoom(), 17f))) + .withProperties( + fillColor("#0092D1"), + fillOpacity(0.2f) + ), + SymbolLayer("pin-dot-label-layer", DOT_SOURCE) + .withFilter(all( + gt(zoom(), CLUSTER_MAX_ZOOM), + has("label") + )) + .withProperties( + textField(get("label")), + textFont(arrayOf("Roboto Regular")), + textSize(16 * context.resources.configuration.fontScale), + textColor(if (isNightMode) "#ccf" else "#124"), + textHaloColor(if (isNightMode) "#2e2e48" else "#fff"), + textAnchor(Property.TEXT_ANCHOR_TOP), + textOffset(arrayOf(0f, 0.5f)), + textHaloWidth(2.5f), + textOptional(true), + textAllowOverlap(Expression.step(zoom(), literal(false), Expression.stop(21, true))), + symbolSortKey(get("dot-order")), + ), SymbolLayer("pin-cluster-layer", SOURCE) .withFilter(all( gte(zoom(), 13f), @@ -91,12 +148,21 @@ class PinsMapComponent( circleStrokeColor("#aaaaaa"), circleRadius(5f), circleStrokeWidth(1f), - circleTranslate(arrayOf(0f, -8f)), // so that it hides behind the pin + circleTranslate(arrayOf(0f, if (prefs.prefs.getBoolean(Prefs.OFFSET_FIX, false)) 0f else -8f)), // so that it hides behind the pin circleTranslateAnchor(Property.CIRCLE_TRANSLATE_ANCHOR_VIEWPORT), symbolSortKey(40f), iconAllowOverlap(true), iconIgnorePlacement(true), ), + CircleLayer("pin-quest-dot-layer", DOT_SOURCE) + .withFilter(all(gt(zoom(), CLUSTER_MAX_ZOOM))) + .withProperties( + circleColor(get("dot-color")), + circleStrokeColor(if (prefs.theme == Theme.LIGHT) "#666666" else "#333333"), + circleRadius(8f), + circleStrokeWidth(1f), + circleSortKey(get("dot-order")) + ), SymbolLayer("pins-layer", SOURCE) .withFilter(gt(zoom(), CLUSTER_MAX_ZOOM)) .withProperties( @@ -122,8 +188,12 @@ class PinsMapComponent( init { pinsSource.isVolatile = true + pinsGeometrySource.isVolatile = true + pinDotsSource.isVolatile = true map.style?.addImageAsync("cluster-circle", context.getDrawable(R.drawable.pin_circle)!!) map.style?.addSource(pinsSource) + map.style?.addSource(pinsGeometrySource) + map.style?.addSource(pinDotsSource) map.addOnMapClickListener(::onClick) } @@ -131,9 +201,16 @@ class PinsMapComponent( suspend fun set(pins: Collection) { val icons = pins.map { it.icon } mapImages.addOnce(icons) { createPinBitmap(context, it) to false } - val features = pins.map { it.toFeature() } + val features = pins.mapNotNull { it.toFeature() } + val dots = pins.mapNotNull { it.toDot() } val mapLibreFeatures = FeatureCollection.fromFeatures(features) - withContext(Dispatchers.Main) { pinsSource.setGeoJson(mapLibreFeatures) } + val geoFeatures = createGeometryFeatures(pins) + val dotFeatures = FeatureCollection.fromFeatures(dots) + withContext(Dispatchers.Main) { + pinsSource.setGeoJson(mapLibreFeatures) + pinsGeometrySource.setGeoJson(geoFeatures) + pinDotsSource.setGeoJson(dotFeatures) + } } /** Clear pins */ @@ -144,7 +221,8 @@ class PinsMapComponent( private fun onClick(position: LatLng): Boolean { val feature = map.queryRenderedFeatures( map.projection.toScreenLocation(position), - *arrayOf("pins-layer", "pin-cluster-layer") + radius, // makes using SCEE quest dots much easier + *arrayOf("pins-layer", "pin-cluster-layer", "pin-quest-dot-layer") ).firstOrNull() ?: return false val properties = feature.properties() @@ -178,7 +256,8 @@ class PinsMapComponent( } } - private fun Pin.toFeature(): Feature { + private fun Pin.toFeature(): Feature? { + if (color != null) return null val p = JsonObject() p.addProperty("icon-image", context.resources.getResourceEntryName(icon)) p.addProperty("icon-order", order + 50) @@ -186,8 +265,25 @@ class PinsMapComponent( return Feature.fromGeometry(position.toPoint(), p) } + private fun Pin.toDot(): Feature? { + if (color == null) return null + val p = JsonObject() + p.addProperty("dot-order", order) + p.addProperty("dot-color", color) + properties.forEach { p.addProperty(it.first, it.second) } + return Feature.fromGeometry(position.toPoint(), p) + } + + private fun createGeometryFeatures(pins: Collection): FeatureCollection? { + val geometries = pins.mapNotNull { if (it.color == null) it.geometry else null } + .takeIf { it.isNotEmpty() }?.toHashSet() ?: return null + return FeatureCollection.fromFeatures(geometries.map { Feature.fromGeometry(it.toMapLibreGeometry()) }) + } + companion object { private const val SOURCE = "pins-source" + private const val GEOMETRY_SOURCE = "pins-geometry-source" + private const val DOT_SOURCE = "pins-dot-source" private const val CLUSTER_MAX_ZOOM = 14 } } @@ -196,7 +292,9 @@ data class Pin( val position: LatLon, val icon: Int, val properties: Collection> = emptyList(), - val order: Int = 0 + val order: Int = 0, + val geometry: ElementGeometry? = null, + val color: String? = null, ) private fun JsonObject.toMap(): Map = diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/SceneMapComponent.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/SceneMapComponent.kt index e31c57262f8..c55f61bac6a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/SceneMapComponent.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/SceneMapComponent.kt @@ -4,7 +4,17 @@ import android.content.Context import android.content.res.Configuration import android.provider.Settings import androidx.annotation.UiThread +import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.BuildConfig +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.preferences.Theme +import de.westnordost.streetcomplete.screens.main.map.createMapStyle import de.westnordost.streetcomplete.screens.main.map.maplibre.awaitSetStyle +import de.westnordost.streetcomplete.screens.main.map.rasterBackground +import de.westnordost.streetcomplete.screens.main.map.themeDarkContrast +import de.westnordost.streetcomplete.screens.main.map.themeLight +import de.westnordost.streetcomplete.util.logs.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.maplibre.android.maps.MapLibreMap @@ -21,18 +31,34 @@ import java.util.Locale class SceneMapComponent( private val context: Context, private val map: MapLibreMap, + private val prefs: Preferences, ) { /** Load the scene */ suspend fun loadStyle(): Style { val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK val isNightMode = currentNightMode == Configuration.UI_MODE_NIGHT_YES - val mapFile = - if (isNightMode) "map_theme/streetcomplete-night.json" - else "map_theme/streetcomplete.json" - - val styleJsonString = context.resources.assets.open(mapFile) - .bufferedReader() - .use { it.readText() } + val token = context.resources.assets.open("map_theme/streetcomplete.json").bufferedReader() + .use { it.readText() }.substringAfter("?access-token=").substringBefore("\"]") + if (BuildConfig.DEBUG) { + // make sure created file for light theme is same as map_theme/streetcomplete.json + // this is to avoid overlooking style updates + val lightTheme = context.resources.assets.open("map_theme/streetcomplete.json").bufferedReader().use { it.readText() }.lines() + val createdLightTheme = createMapStyle("StreetComplete", token, emptyList(), themeLight).lines() + for (i in lightTheme.indices) { + if (lightTheme[i] != createdLightTheme[i]) { + Log.i("SceneMapComponent", "different-o: ${lightTheme[i]}") + Log.i("SceneMapComponent", "different-n: ${createdLightTheme[i]}") + } + } + require(lightTheme == createdLightTheme) { "Created light theme is not the same as the file in assets. Please update MapStyles or MapStyleCreator." } + } + val styleJsonString = when { + prefs.prefs.getString(Prefs.THEME_BACKGROUND, "MAP") != "MAP" -> + createMapStyle("StreetComplete-Raster", token, emptyList(), rasterBackground(prefs.prefs.getBoolean(Prefs.NO_SATELLITE_LABEL, false)), prefs.prefs.getString(Prefs.RASTER_TILE_URL, ApplicationConstants.RASTER_DEFAULT_URL), prefs.prefs.getInt(Prefs.RASTER_TILE_MAXZOOM, ApplicationConstants.RASTER_DEFAULT_MAXZOOM)) + prefs.theme == Theme.DARK_CONTRAST -> createMapStyle("StreetComplete-Dark_Contrast", token, emptyList(), themeDarkContrast) + isNightMode -> context.resources.assets.open("map_theme/streetcomplete-night.json").bufferedReader().use { it.readText() } + else -> context.resources.assets.open("map_theme/streetcomplete.json").bufferedReader().use { it.readText() } + } val styleBuilder = Style.Builder().fromJson(styleJsonString) val style = map.awaitSetStyle(styleBuilder) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/StyleableOverlayMapComponent.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/StyleableOverlayMapComponent.kt index a722a55d99e..2f8e331e467 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/StyleableOverlayMapComponent.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/StyleableOverlayMapComponent.kt @@ -8,7 +8,6 @@ import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey import de.westnordost.streetcomplete.data.osm.mapdata.ElementType -import de.westnordost.streetcomplete.data.osm.mapdata.key import de.westnordost.streetcomplete.overlays.Color.INVISIBLE import de.westnordost.streetcomplete.overlays.PointStyle import de.westnordost.streetcomplete.overlays.PolygonStyle @@ -192,8 +191,8 @@ class StyleableOverlayMapComponent( textColor(if (isNightMode) "#ccf" else "#124"), textHaloColor(if (isNightMode) "#2e2e48" else "#fff"), textHaloWidth(2.5f), - iconColor(if (isNightMode) "#ccf" else "#124"), - iconHaloColor(if (isNightMode) "#2e2e48" else "#fff"), + iconColor(get("icon-color")), + iconHaloColor(get("icon-halo-color")), iconHaloWidth(2.5f), textOptional(true), iconAllowOverlap(true), @@ -253,6 +252,10 @@ class StyleableOverlayMapComponent( is PointStyle -> { if (style.icon != null) { p.addProperty("icon", context.resources.getResourceEntryName(style.icon)) + val color = style.color ?: if (isNightMode) "#ccf" else "#124" + p.addProperty("icon-color", color) + val haloColor = style.color?.let { getDarkenedColor(it) } ?: if (isNightMode) "#2e2e48" else "#fff" + p.addProperty("icon-halo-color", haloColor) } if (style.label != null) p.addProperty("label", style.label) @@ -279,6 +282,10 @@ class StyleableOverlayMapComponent( val pp = getElementKeyProperties(element.key) if (style.icon != null) { pp.addProperty("icon", context.resources.getResourceEntryName(style.icon)) + val color = if (isNightMode) "#ccf" else "#124" + pp.addProperty("icon-color", color) + val haloColor = if (isNightMode) "#2e2e48" else "#fff" + pp.addProperty("icon-halo-color", haloColor) } if (style.label != null) pp.addProperty("label", style.label) Feature.fromGeometry(geometry.center.toPoint(), pp) @@ -332,6 +339,19 @@ class StyleableOverlayMapComponent( Feature.fromGeometry(line, p2) } + // looks similar to "normal" private dashes, but doesn't have round line cap + val private = style.stroke.let { + if (it != null && it.color != INVISIBLE && !it.dashed && element.tags["highway"] != null && element.tags["access"] in privateWays) { + val p2 = p.deepCopy() + p2.addProperty("width", width * 0.5f) + p2.addProperty("color", getDarkenedColor(it.color)) + p2.addProperty("dashed", true) + Feature.fromGeometry(line, p2) + } else { + null + } + } + val label = if (style.label != null) { Feature.fromGeometry( geometry.center.toPoint(), @@ -341,7 +361,7 @@ class StyleableOverlayMapComponent( null } - listOfNotNull(left, right, center, label) + listOfNotNull(left, right, center, label, private) } } } @@ -408,3 +428,6 @@ private fun Style.getIcon(): Int? = when (this) { is PolygonStyle -> icon is PolylineStyle -> null } + +// same as in streetcomplete.json +private val privateWays = hashSetOf("no", "private", "destination", "customers", "delivery", "agricultural", "forestry", "emergency") diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/TracksMapComponent.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/TracksMapComponent.kt index 78f5c70d603..ea0804ee690 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/TracksMapComponent.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/TracksMapComponent.kt @@ -43,6 +43,8 @@ class TracksMapComponent(context: Context, mapStyle: Style, private val map: Map private val trackSource = GeoJsonSource("track-source") private val oldTrackSource = GeoJsonSource("old-track-source") + private val gpxSource = GeoJsonSource("gpx-source") + private data class Track(val trackpoints: MutableList, val isRecording: Boolean) private var track: Track = Track(ArrayList(), false) private var oldTracks: MutableList> = arrayListOf() @@ -67,7 +69,14 @@ class TracksMapComponent(context: Context, mapStyle: Style, private val map: Map LineLayer("track", "track-source") .withProperties(*commonTrackProperties, lineOpacity(0.6f), lineCap(Property.LINE_CAP_ROUND), lineDasharray(arrayOf(0f, 2f))), LineLayer("old-track", "old-track-source") - .withProperties(*commonTrackProperties, lineOpacity(0.2f), lineCap(Property.LINE_CAP_ROUND), lineDasharray(arrayOf(0f, 2f))) + .withProperties(*commonTrackProperties, lineOpacity(0.2f), lineCap(Property.LINE_CAP_ROUND), lineDasharray(arrayOf(0f, 2f))), + LineLayer("gpx-layer", "gpx-source") + .withProperties( + lineColor("#53fe70"), + lineOpacity(0.25f), + lineWidth(5f), + lineCap(Property.LINE_CAP_ROUND) + ) ) init { @@ -87,6 +96,7 @@ class TracksMapComponent(context: Context, mapStyle: Style, private val map: Map map.style?.addSource(trackAnimationSource) map.style?.addSource(trackSource) map.style?.addSource(oldTrackSource) + map.style?.addSource(gpxSource) } override fun onPause(owner: LifecycleOwner) { @@ -142,6 +152,11 @@ class TracksMapComponent(context: Context, mapStyle: Style, private val map: Map trackAnimationSource.clear() } + @UiThread fun setGpxTrack(gpxPoints: List) { + val line = LineString.fromLngLats(gpxPoints.map { Point.fromLngLat(it.longitude, it.latitude) }) + gpxSource.setGeoJson(line) + } + private fun updateAnimatedTrack(progress: Float) { val size = track.trackpoints.size if (size < 2) return diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/maplibre/MapImages.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/maplibre/MapImages.kt index a68eb12cc0c..6ce13d54c9b 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/maplibre/MapImages.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/maplibre/MapImages.kt @@ -7,9 +7,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.Style -class MapImages(private val resources: Resources, private val style: Style) { +class MapImages(private val resources: Resources, private val map: MapLibreMap) { private val images = HashSet() private val mutex = Mutex() @@ -17,7 +18,7 @@ class MapImages(private val resources: Resources, private val style: Style) { if (id !in images) { val name = resources.getResourceEntryName(id) val (bitmap, sdf) = createBitmap(id) - withContext(Dispatchers.Main) { style.addImage(name, bitmap, sdf) } + withContext(Dispatchers.Main) { map.style?.addImage(name, bitmap, sdf) } images.add(id) Log.v("MapImages", "Loaded 1 image") } @@ -39,8 +40,8 @@ class MapImages(private val resources: Resources, private val style: Style) { val nonSdfImages = data.filterNot { it.sdf }.associateTo(HashMap()) { it.name to it.bitmap } withContext(Dispatchers.Main) { - if (nonSdfImages.isNotEmpty()) style.addImages(nonSdfImages, false) - if (sdfImages.isNotEmpty()) style.addImages(sdfImages, true) + if (nonSdfImages.isNotEmpty()) map.style?.addImages(nonSdfImages, false) + if (sdfImages.isNotEmpty()) map.style?.addImages(sdfImages, true) } images.addAll(loadIds) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/overlays/OverlaySelectionDropdownMenu.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/overlays/OverlaySelectionDropdownMenu.kt index 4cbfbf33de5..bed3e8dd984 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/overlays/OverlaySelectionDropdownMenu.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/overlays/OverlaySelectionDropdownMenu.kt @@ -1,6 +1,7 @@ package de.westnordost.streetcomplete.screens.main.overlays import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -10,12 +11,19 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry import de.westnordost.streetcomplete.overlays.Overlay +import de.westnordost.streetcomplete.overlays.custom.CustomOverlay +import de.westnordost.streetcomplete.overlays.custom.getCustomOverlayIndices import de.westnordost.streetcomplete.ui.common.DropdownMenuItem +import de.westnordost.streetcomplete.util.showOverlayCustomizer +import org.koin.compose.koinInject /** Dropdown menu for selecting an overlay */ @Composable @@ -26,6 +34,10 @@ fun OverlaySelectionDropdownMenu( onSelect: (Overlay?) -> Unit, modifier: Modifier = Modifier ) { + val ctx = LocalContext.current + val questTypeRegistry: QuestTypeRegistry = koinInject() + val prefs: Preferences = koinInject() + DropdownMenu( expanded = expanded, onDismissRequest = onDismissRequest, @@ -48,7 +60,49 @@ fun OverlaySelectionDropdownMenu( contentDescription = null, modifier = Modifier.size(36.dp) ) - Text(stringResource(overlay.title)) + Text( + text = if (overlay.title != 0) stringResource(overlay.title) else overlay.changesetComment, + modifier = Modifier.weight(1f) + ) + if (overlay.title == 0) { + Image( + painter = painterResource(R.drawable.ic_settings_48dp), + contentDescription = null, + modifier = Modifier + .size(36.dp) + .clickable { + onDismissRequest() + showOverlayCustomizer(overlay.wikiLink!!.toInt(), ctx, prefs, questTypeRegistry, + { onSelect(overlay) }, + { if (it) onSelect(null) } + ) + } + ) + } + } + } + } + if (prefs.expertMode) { + DropdownMenuItem(onClick = { + onDismissRequest() + showOverlayCustomizer((getCustomOverlayIndices(prefs).maxOrNull() ?: 0) + 1, ctx, prefs, questTypeRegistry, + { prefs.selectedOverlayName = CustomOverlay::class.simpleName }, // not great, as it relies on onSelected not changing + { onSelect(null) } + ) + }) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_add_24dp), + contentDescription = null, + modifier = Modifier.size(36.dp) + ) + Text( + text = stringResource(R.string.custom_overlay_add_button), + modifier = Modifier.weight(1f) + ) } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/DataManagementScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/DataManagementScreen.kt new file mode 100644 index 00000000000..131749a6a98 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/DataManagementScreen.kt @@ -0,0 +1,812 @@ +package de.westnordost.streetcomplete.screens.settings + +import android.app.Activity +import android.app.Dialog +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.text.InputType +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.SwitchCompat +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AppBarDefaults +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.core.content.edit +import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.StreetCompleteApplication +import de.westnordost.streetcomplete.data.Cleaner +import de.westnordost.streetcomplete.data.ConflictAlgorithm +import de.westnordost.streetcomplete.data.Database +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController +import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestsHiddenTable +import de.westnordost.streetcomplete.data.osmnotes.notequests.NoteQuestsHiddenTable +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.presets.EditTypePreset +import de.westnordost.streetcomplete.data.presets.EditTypePresetsController +import de.westnordost.streetcomplete.data.presets.EditTypePresetsTable +import de.westnordost.streetcomplete.data.urlconfig.UrlConfigController +import de.westnordost.streetcomplete.data.visiblequests.QuestTypeOrderTable +import de.westnordost.streetcomplete.data.visiblequests.VisibleEditTypeController +import de.westnordost.streetcomplete.data.visiblequests.VisibleEditTypeTable +import de.westnordost.streetcomplete.overlays.custom.getCustomOverlayIndices +import de.westnordost.streetcomplete.overlays.custom.getIndexedCustomOverlayPref +import de.westnordost.streetcomplete.quests.amenity_cover.AddAmenityCover +import de.westnordost.streetcomplete.quests.custom.CustomQuest +import de.westnordost.streetcomplete.quests.osmose.OsmoseDao +import de.westnordost.streetcomplete.ui.common.BackIcon +import de.westnordost.streetcomplete.ui.common.dialogs.SimpleListPickerDialog +import de.westnordost.streetcomplete.ui.common.dialogs.TextInputDialog +import de.westnordost.streetcomplete.ui.common.settings.Preference +import de.westnordost.streetcomplete.ui.common.settings.SwitchPreference +import de.westnordost.streetcomplete.util.dialogs.setViewWithDefaultPadding +import de.westnordost.streetcomplete.util.getFakeCustomOverlays +import de.westnordost.streetcomplete.util.ktx.getActivity +import de.westnordost.streetcomplete.util.ktx.toast +import de.westnordost.streetcomplete.util.logs.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.koin.compose.koinInject +import java.io.BufferedWriter + +// todo: there is still a lot of non-compose in here, but that's for later... +@Composable +fun DataManagementScreen( + onClickBack: () -> Unit, +) { + val prefs: Preferences = koinInject() + val cleaner: Cleaner = koinInject() + val db: Database = koinInject() + val editTypePresetsController: EditTypePresetsController = koinInject() + val urlConfigController: UrlConfigController = koinInject() + val visibleEditTypeController: VisibleEditTypeController = koinInject() + val osmoseDao: OsmoseDao = koinInject() + val externalSourceQuestController: ExternalSourceQuestController = koinInject() + val ctx = LocalContext.current + val scope = rememberCoroutineScope() + var showDeleteAfterDialog by remember { mutableStateOf(false) } + var showGpsIntervalDialog by remember { mutableStateOf(false) } + var showNetIntervalDialog by remember { mutableStateOf(false) } + var showExportDialog by remember { mutableStateOf(false) } + var showImportDialog by remember { mutableStateOf(false) } + var currentSetting by rememberSaveable { mutableStateOf("") } + val exportPicker = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode != Activity.RESULT_OK || it.data == null) + return@rememberLauncherForActivityResult + val uri = it.data?.data ?: return@rememberLauncherForActivityResult + val activity = ctx.getActivity() ?: return@rememberLauncherForActivityResult + when (currentSetting) { + "settings" -> exportSettings(uri, activity) + "hidden_quests" -> exportHidden(uri, activity, db) + "presets" -> exportPresets(uri, activity, db, editTypePresetsController, urlConfigController) + "overlays" -> exportOverlays(uri, activity, prefs) + } + currentSetting = "" + } + val importPicker = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode != Activity.RESULT_OK || it.data == null) + return@rememberLauncherForActivityResult + val uri = it.data?.data ?: return@rememberLauncherForActivityResult + val activity = ctx.getActivity() ?: return@rememberLauncherForActivityResult + when (currentSetting) { + "settings" -> if (!importSettings(uri, activity, osmoseDao, externalSourceQuestController)) + ctx.toast(ctx.getString(R.string.import_error), Toast.LENGTH_LONG) + "hidden_quests" -> importHidden(uri, activity, db, visibleEditTypeController) + "presets" -> importPresets(uri, activity, db, visibleEditTypeController) + "overlays" -> importOverlays(uri, activity) + } + currentSetting = "" + } + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text(stringResource(R.string.pref_screen_data_management)) }, + windowInsets = AppBarDefaults.topAppBarWindowInsets, + navigationIcon = { IconButton(onClick = onClickBack) { BackIcon() } }, + ) + Column( + Modifier + .verticalScroll(rememberScrollState()) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom + ) + ) + ) { + SwitchPreference( + name = stringResource(R.string.pref_auto_download_title), + description = stringResource(R.string.pref_auto_download_summary), + pref = Prefs.AUTO_DOWNLOAD, + default = true, + ) + SwitchPreference( + name = stringResource(R.string.pref_manual_download_cache_title), + description = stringResource(R.string.pref_manual_download_cache_summary), + pref = Prefs.MANUAL_DOWNLOAD_OVERRIDE_CACHE, + default = true, + ) + Preference( + name = stringResource(R.string.pref_tile_source_title), + onClick = { showRasterUrlDialog(ctx, StreetCompleteApplication.preferences) }, + ) + Preference( + name = stringResource(R.string.pref_delete_old_data_after), + onClick = { showDeleteAfterDialog = true }, + description = stringResource(R.string.pref_delete_old_data_after_summary, prefs.getInt(Prefs.DATA_RETAIN_TIME, 14)) + ) + SwitchPreference( + name = stringResource(R.string.pref_update_local_statistics), + description = stringResource(R.string.pref_update_local_statistics_summary), + pref = Prefs.UPDATE_LOCAL_STATISTICS, + default = true, + ) + Preference( + name = stringResource(R.string.pref_gps_interval_title), + onClick = { showGpsIntervalDialog = true }, + description = stringResource(R.string.pref_interval_summary, prefs.getInt(Prefs.DATA_RETAIN_TIME, 0)) + ) + Preference( + name = stringResource(R.string.pref_network_interval_title), + onClick = { showNetIntervalDialog = true }, + description = stringResource(R.string.pref_interval_summary, prefs.getInt(Prefs.DATA_RETAIN_TIME, 5)) + ) + Preference( + name = stringResource(R.string.pref_export), + onClick = { showExportDialog = true }, + ) + Preference( + name = stringResource(R.string.pref_import), + onClick = { showImportDialog = true }, + ) + } + if (showDeleteAfterDialog) + TextInputDialog( + onDismissRequest = { showDeleteAfterDialog = false }, + onConfirmed = { + prefs.putInt(Prefs.DATA_RETAIN_TIME, it.toIntOrNull() ?: 14) + scope.launch(Dispatchers.IO) { cleaner.cleanOld() } + }, + text = prefs.getInt(Prefs.DATA_RETAIN_TIME, 14).toString(), + title = { Text(stringResource(R.string.pref_delete_old_data_after_message)) }, + keyboardType = KeyboardType.Number, + checkTextValid = { + val value = it.toIntOrNull() + value != null && value >= 3 + } + ) + if (showGpsIntervalDialog) + TextInputDialog( + onDismissRequest = { showGpsIntervalDialog = false }, + onConfirmed = { prefs.putInt(Prefs.GPS_INTERVAL, it.toIntOrNull() ?: 0) }, + text = prefs.getInt(Prefs.GPS_INTERVAL, 0).toString(), + title = { Text(stringResource(R.string.pref_interval_message)) }, + keyboardType = KeyboardType.Number, + checkTextValid = { + val value = it.toIntOrNull() + value != null && value >= 0 + } + ) + if (showNetIntervalDialog) + TextInputDialog( + onDismissRequest = { showNetIntervalDialog = false }, + onConfirmed = { prefs.putInt(Prefs.NETWORK_INTERVAL, it.toIntOrNull() ?: 5) }, + text = prefs.getInt(Prefs.NETWORK_INTERVAL, 5).toString(), + title = { Text(stringResource(R.string.pref_interval_message)) }, + keyboardType = KeyboardType.Number, + checkTextValid = { + val value = it.toIntOrNull() + value != null && value >= 0 + } + ) + if (showExportDialog) + SimpleListPickerDialog( + onDismissRequest = { showExportDialog = false }, + showButtons = false, + items = listOf("hidden_quests", "presets","overlays","settings"), + getItemName = { + val id = when (it) { + "settings" -> R.string.import_export_settings + "hidden_quests" -> R.string.import_export_hidden_quests + "presets" -> R.string.import_export_presets + "overlays" -> R.string.import_export_custom_overlays + else -> 0 + } + stringResource(id) + }, + onItemSelected = { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_TITLE, "$it.txt") + type = "application/text" + } + currentSetting = it + exportPicker.launch(intent) + }, + title = { Text(stringResource(R.string.pref_export)) } + ) + if (showImportDialog) + SimpleListPickerDialog( + onDismissRequest = { showImportDialog = false }, + showButtons = false, + items = listOf("hidden_quests", "presets","overlays","settings"), + getItemName = { + val id = when (it) { + "settings" -> R.string.import_export_settings + "hidden_quests" -> R.string.import_export_hidden_quests + "presets" -> R.string.import_export_presets + "overlays" -> R.string.import_export_custom_overlays + else -> 0 + } + stringResource(id) + }, + onItemSelected = { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" // can't select text file if setting to application/text + } + currentSetting = it + importPicker.launch(intent) + }, + title = { Text(stringResource(R.string.pref_import)) } + ) + } +} + +private const val BACKUP_HIDDEN_OSM_QUESTS = "quests" +private const val BACKUP_HIDDEN_NOTES = "notes" +private const val BACKUP_HIDDEN_OTHER_QUESTS = "other_source_quests" +private const val BACKUP_PRESETS = "presets" +private const val BACKUP_PRESETS_ORDERS = "orders" +private const val BACKUP_PRESETS_VISIBILITIES = "visibilities" +private const val BACKUP_PRESETS_QUEST_SETTINGS = "quest_settings" + +private const val TAG = "DataManagementSettings" + +const val LAST_KNOWN_DB_VERSION = 19L + +val renamedQuests = mapOf( + "ExternalQuest" to CustomQuest::class.simpleName!!, + "AddPicnicTableCover" to AddAmenityCover::class.simpleName!!, +) +fun String.renameUpdatedQuests() = + renamedQuests.entries.fold(this) { acc, (old, new) -> acc.replace(old, new) } + +private fun showRasterUrlDialog(context: Context, prefs: SharedPreferences) { + var d: AlertDialog? = null + val currentUrl = prefs.getString(Prefs.RASTER_TILE_URL, ApplicationConstants.RASTER_DEFAULT_URL)!! + val urlText = EditText(context).apply { + setText(currentUrl) + doAfterTextChanged { + val t = it.toString() + d?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = t.contains("{x}") && t.contains("{y}") && t.contains("{z}") + } + } + val hideLabelsSwitch = SwitchCompat(context).apply { + setText(R.string.pref_tile_source_hide_labels) + isChecked = prefs.getBoolean(Prefs.NO_SATELLITE_LABEL, false) + } + val maxZoom = EditText(context).apply { + inputType = InputType.TYPE_CLASS_NUMBER + setText(prefs.getInt(Prefs.RASTER_TILE_MAXZOOM, ApplicationConstants.RASTER_DEFAULT_MAXZOOM).toString()) + } + val layout = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(TextView(context).apply { setText(R.string.pref_tile_source_message) }) + addView(urlText) + addView(TextView(context).apply { setText(R.string.pref_tile_maxzoom) }) + addView(maxZoom) + addView(hideLabelsSwitch) + } + d = AlertDialog.Builder(context) + .setTitle(R.string.pref_tile_source_title) + .setViewWithDefaultPadding(layout) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.action_reset) { _, _ -> + prefs.edit { + remove(Prefs.RASTER_TILE_URL) + remove(Prefs.RASTER_TILE_MAXZOOM) + remove(Prefs.NO_SATELLITE_LABEL) + } + } + .setPositiveButton(android.R.string.ok) { _, _ -> + prefs.edit { + putString(Prefs.RASTER_TILE_URL, urlText.text.toString()) + putInt(Prefs.RASTER_TILE_MAXZOOM, maxZoom.text.toString().toInt()) + putBoolean(Prefs.NO_SATELLITE_LABEL, hideLabelsSwitch.isChecked) + } + + // trigger the listener in MapFragment (if it exists) + val map = prefs.getString(Prefs.THEME_BACKGROUND, "MAP") + prefs.edit().putString(Prefs.THEME_BACKGROUND, if (map == "MAP") "AERIAL" else "MAP").apply() + prefs.edit().putString(Prefs.THEME_BACKGROUND, map).apply() + } + .create() + d.show() +} + +private fun exportHidden(uri: Uri, activity: Activity, db: Database) { + activity.contentResolver?.openOutputStream(uri)?.use { os -> + val version = db.rawQuery("PRAGMA user_version;") { c -> c.getLong("user_version") }.single() + if (version > LAST_KNOWN_DB_VERSION) + activity.toast(activity.getString(R.string.export_warning_db_version), Toast.LENGTH_LONG) + + val hiddenOsmQuests = db.query(OsmQuestsHiddenTable.NAME) { c -> + c.getLong(OsmQuestsHiddenTable.Columns.ELEMENT_ID).toString() + "," + + c.getString(OsmQuestsHiddenTable.Columns.ELEMENT_TYPE) + "," + + c.getString(OsmQuestsHiddenTable.Columns.QUEST_TYPE) + "," + + c.getLong(OsmQuestsHiddenTable.Columns.TIMESTAMP) + } + val hiddenNotes = db.query(NoteQuestsHiddenTable.NAME) { c-> + c.getLong(NoteQuestsHiddenTable.Columns.NOTE_ID).toString() + "," + + c.getLong(NoteQuestsHiddenTable.Columns.TIMESTAMP) + } + val hiddenExternalSourceQuests = db.query(ExternalSourceQuestTables.NAME_HIDDEN) { c -> + c.getString(ExternalSourceQuestTables.Columns.SOURCE) + "," + + c.getString(ExternalSourceQuestTables.Columns.ID) + "," + + c.getLong(ExternalSourceQuestTables.Columns.TIMESTAMP) + } + + os.bufferedWriter().use { + it.write(version.toString()) + it.write("\n\n$BACKUP_HIDDEN_OSM_QUESTS\n") + it.write(hiddenOsmQuests.joinToString("\n")) + it.write("\n\n$BACKUP_HIDDEN_NOTES\n") + it.write(hiddenNotes.joinToString("\n")) + it.write("\n\n$BACKUP_HIDDEN_OTHER_QUESTS\n") + it.write(hiddenExternalSourceQuests.joinToString("\n") + "\n") + } + } +} + +private fun exportPresets(uri: Uri, activity: Activity, db: Database, editTypePresetsController: EditTypePresetsController, urlConfigController: UrlConfigController) { + val allPresets = mutableListOf() + allPresets.add(EditTypePreset(0, activity.getString(R.string.quest_presets_default_name))) + allPresets.addAll(editTypePresetsController.getAll()) + val array = allPresets.map { it.name }.toTypedArray() + val selectedPresets = mutableSetOf() + val d = AlertDialog.Builder(activity) + .setTitle(R.string.import_export_presets_select) + .setMultiChoiceItems(array, null) { di, which, isChecked -> + if (isChecked) selectedPresets.add(allPresets[which].id) + else selectedPresets.remove(allPresets[which].id) + (di as AlertDialog).getButton(Dialog.BUTTON_POSITIVE)?.isEnabled = selectedPresets.isNotEmpty() + } + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + exportPresets(selectedPresets, uri, activity, db, urlConfigController) + } + .show() + d.getButton(Dialog.BUTTON_POSITIVE)?.isEnabled = false +} + +private fun exportPresets(ids: Collection, uri: Uri, activity: Activity, db: Database, urlConfigController: UrlConfigController) { + activity.contentResolver?.openOutputStream(uri)?.use { os -> + val version = db.rawQuery("PRAGMA user_version;") { c -> c.getLong("user_version") }.single() + if (version > LAST_KNOWN_DB_VERSION) + activity.toast(activity.getString(R.string.export_warning_db_version), Toast.LENGTH_LONG) + + val presetString = ids.joinToString(",") + val presets = db.query(EditTypePresetsTable.NAME, where = "${EditTypePresetsTable.Columns.EDIT_TYPE_PRESET_ID} IN ($presetString)") { c -> + c.getLong(EditTypePresetsTable.Columns.EDIT_TYPE_PRESET_ID).toString() + "," + + c.getString(EditTypePresetsTable.Columns.EDIT_TYPE_PRESET_NAME) + }.map { "$it,${urlConfigController.create(it.substringBefore(',').toLong())}" } + val orders = db.query(QuestTypeOrderTable.NAME, where = "${QuestTypeOrderTable.Columns.EDIT_TYPE_PRESET_ID} IN ($presetString)") { c-> + c.getLong(QuestTypeOrderTable.Columns.EDIT_TYPE_PRESET_ID).toString() + "," + + c.getString(QuestTypeOrderTable.Columns.BEFORE) + "," + + c.getString(QuestTypeOrderTable.Columns.AFTER) + } + val visibilities = db.query(VisibleEditTypeTable.NAME, where = "${VisibleEditTypeTable.Columns.EDIT_TYPE_PRESET_ID} IN ($presetString)") { c -> + c.getLong(VisibleEditTypeTable.Columns.EDIT_TYPE_PRESET_ID).toString() + "," + + c.getString(VisibleEditTypeTable.Columns.EDIT_TYPE) + "," + + c.getLong(VisibleEditTypeTable.Columns.VISIBILITY).toString() + } + val perPresetQuestSetting = "\\d+_qs_.+".toRegex() + val questSettings = StreetCompleteApplication.preferences.all.filterKeys { it.matches(perPresetQuestSetting) && it.substringBefore('_').toLongOrNull() in ids } + + os.bufferedWriter().use { + it.appendLine(version.toString()) + it.appendLine("\n$BACKUP_PRESETS") + it.appendLine(presets.joinToString("\n")) + it.appendLine("\n$BACKUP_PRESETS_ORDERS") + it.appendLine(orders.joinToString("\n")) + it.appendLine("\n$BACKUP_PRESETS_VISIBILITIES") + it.appendLine(visibilities.joinToString("\n")) + it.appendLine("\n$BACKUP_PRESETS_QUEST_SETTINGS") + settingsToJsonStream(questSettings, it) + } + } +} + +// this will ignore settings with value null +@Suppress("UNCHECKED_CAST") // it is checked... but whatever (except string set, because not allowed to check for that) +private fun settingsToJsonStream(settings: Map, out: BufferedWriter) { + val booleans = settings.filterValues { it is Boolean } as Map + val ints = settings.filterValues { it is Int } as Map + val longs = settings.filterValues { it is Long } as Map + val floats = settings.filterValues { it is Float } as Map + val strings = settings.filterValues { it is String } as Map + val stringSets = settings.filterValues { it is Set<*> } as Map> + // now write + out.appendLine("boolean settings") + out.appendLine( Json.encodeToString(booleans)) + out.appendLine() + out.appendLine("int settings") + out.appendLine( Json.encodeToString(ints)) + out.appendLine() + out.appendLine("long settings") + out.appendLine( Json.encodeToString(longs)) + out.appendLine() + out.appendLine("float settings") + out.appendLine( Json.encodeToString(floats)) + out.appendLine() + out.appendLine("string settings") + out.appendLine( Json.encodeToString(strings)) + out.appendLine() + out.appendLine("string set settings") + out.appendLine( Json.encodeToString(stringSets)) +} + +private fun exportOverlays(uri: Uri, activity: Activity, scPrefs: Preferences) { + val allOverlays = getFakeCustomOverlays(scPrefs, activity.resources, false) + val array = allOverlays.map { it.changesetComment }.toTypedArray() + val selectedOverlays = mutableSetOf() + val d = AlertDialog.Builder(activity) + .setTitle(R.string.import_export_custom_overlays_select) + .setMultiChoiceItems(array, null) { di, which, isChecked -> + if (isChecked) selectedOverlays.add(allOverlays[which].wikiLink!!) + else selectedOverlays.remove(allOverlays[which].wikiLink!!) + (di as AlertDialog).getButton(Dialog.BUTTON_POSITIVE)?.isEnabled = selectedOverlays.isNotEmpty() + } + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + exportCustomOverlays(selectedOverlays, uri, activity) + } + .show() + d.getButton(Dialog.BUTTON_POSITIVE)?.isEnabled = false +} + +private fun exportCustomOverlays(indices: Collection, uri: Uri, activity: Activity) { + val prefs = StreetCompleteApplication.preferences + val filterRegex = "custom_overlay_(?:${indices.joinToString("|")})_.*".toRegex() + val settings = prefs.all.filterKeys { filterRegex.matches(it) }.toMutableMap() + settings[Prefs.CUSTOM_OVERLAY_INDICES] = indices.joinToString(",") + if (prefs.getInt(Prefs.CUSTOM_OVERLAY_SELECTED_INDEX, 0).toString() in indices) + settings[Prefs.CUSTOM_OVERLAY_SELECTED_INDEX] = prefs.getInt(Prefs.CUSTOM_OVERLAY_SELECTED_INDEX, 0) + activity.contentResolver?.openOutputStream(uri)?.use { it.bufferedWriter().use { + it.appendLine("overlays") + settingsToJsonStream(settings, it) + } } +} + +private fun exportSettings(uri: Uri, activity: Activity) { + val perPresetQuestSetting = "\\d+_qs_.+".toRegex() + val settings = StreetCompleteApplication.preferences.all.filterKeys { + !it.contains("TangramPinsSpriteSheet") // this is huge and gets generated if missing anyway + && !it.contains("TangramIconsSpriteSheet") // this is huge and gets generated if missing anyway + && it != Preferences.OAUTH2_ACCESS_TOKEN // login + && !it.contains("osm.") // login data + && !it.matches(perPresetQuestSetting) // per-preset quest settings should be stored with presets, because preset id is never guaranteed to match + && !it.startsWith("custom_overlay") // custom overlays are exported separately + } + activity.contentResolver?.openOutputStream(uri)?.use { it.bufferedWriter().use { settingsToJsonStream(settings, it) } } +} + +private fun importOverlays(uri: Uri, activity: Activity) { + AlertDialog.Builder(activity) + .setTitle(R.string.pref_import) + .setMessage(R.string.import_presets_overlays_message) + .setPositiveButton(R.string.import_presets_overlays_replace) { _, _ -> if (!importCustomOverlays(uri, true, activity)) activity.toast(activity.getString(R.string.import_error), Toast.LENGTH_LONG) } + .setNeutralButton(R.string.import_presets_overlays_add) { _, _ -> if (!importCustomOverlays(uri, false, activity)) activity.toast(activity.getString(R.string.import_error), Toast.LENGTH_LONG) } + .show() +} + +private fun importCustomOverlays(uri: Uri, replaceExisting: Boolean, activity: Activity): Boolean { + val lines = activity.contentResolver?.openInputStream(uri)?.use { it.reader().readLines() } ?: return false + if (lines.first() != "overlays") return false + val prefs = StreetCompleteApplication.preferences + return if (replaceExisting) { + // first remove old overlays + // this is necessary because otherwise overlay may remain, but hidden due to not in indices pref + prefs.edit { prefs.all.keys.forEach { if (it.startsWith("custom_overlay")) remove(it) } } + + val result = readToSettings(lines.subList(1, lines.size)) + // update in case of old data + if (prefs.contains("custom_overlay_filter") || prefs.contains("custom_overlay_color_key")) { + val indices = if (prefs.contains(Prefs.CUSTOM_OVERLAY_INDICES)) getCustomOverlayIndices(prefs) else emptyList() + val newIndex = indices.maxOrNull() ?: 0 + prefs.edit { + if (prefs.contains("custom_overlay_filter")) + putString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_FILTER, newIndex), prefs.getString("custom_overlay_filter", "")!!) + if (prefs.contains("custom_overlay_color_key")) + putString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_COLOR_KEY, newIndex), prefs.getString("custom_overlay_color_key", "")!!) + remove("custom_overlay_filter") + remove("custom_overlay_color_key") + putString(Prefs.CUSTOM_OVERLAY_INDICES, (indices + newIndex).sorted().joinToString(",")) + } + } + result + } + else { + val customOverlayRegex = "custom_overlay_(\\d+)_".toRegex() + val indices = getCustomOverlayIndices(prefs).toMutableSet() + val offset = indices.maxOrNull()?.let { it + 1 } ?: 0 + val newLines = lines.mapNotNull { line -> + if (line == "overlays") return@mapNotNull null + line.replace(customOverlayRegex) { result -> + if (result.groupValues.size <= 1) throw (IllegalStateException()) + val oldIndex = result.groupValues[1].toInt() + val newIndex = oldIndex + offset + indices.add(newIndex) + "custom_overlay_${newIndex}_" + } + } + val result = readToSettings(newLines) + prefs.edit { + // update in case of old data + if (prefs.contains("custom_overlay_filter") || prefs.contains("custom_overlay_color_key")) { + val oldOverlayIndex = if (indices.contains(offset)) indices.max() + 1 else offset + indices.add(oldOverlayIndex) + if (prefs.contains("custom_overlay_filter")) + putString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_FILTER, oldOverlayIndex), prefs.getString("custom_overlay_filter", "")!!) + if (prefs.contains("custom_overlay_color_key")) + putString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_COLOR_KEY, oldOverlayIndex), prefs.getString("custom_overlay_color_key", "")!!) + remove("custom_overlay_filter") + remove("custom_overlay_color_key") + } + // set updated indices + putString(Prefs.CUSTOM_OVERLAY_INDICES, indices.sorted().joinToString(",")) + } + result + } +} + +private fun readToSettings(list: List): Boolean { + val i = list.iterator() + val e = StreetCompleteApplication.preferences.edit() + try { + while (i.hasNext()) { + val next = i.next() + if (next.isBlank()) continue + when (next) { + "boolean settings" -> Json.decodeFromString>(i.next()).forEach { e.putBoolean(it.key, it.value) } + "int settings" -> Json.decodeFromString>(i.next()).forEach { e.putInt(it.key, it.value) } + "long settings" -> Json.decodeFromString>(i.next()).forEach { e.putLong(it.key, it.value) } + "float settings" -> Json.decodeFromString>(i.next()).forEach { e.putFloat(it.key, it.value) } + "string settings" -> Json.decodeFromString>(i.next()).forEach { e.putString(it.key, it.value) } + "string set settings" -> Json.decodeFromString>>(i.next()).forEach { e.putStringSet(it.key, it.value) } + } + } + e.apply() + return true + } catch (e: Exception) { + return false + } +} + +private fun importHidden(uri: Uri, activity: Activity, db: Database, visibleEditTypeController: VisibleEditTypeController) { + // do not delete existing hidden quests; this can be done manually anyway + val lines = importLinesAndCheck(uri, BACKUP_HIDDEN_OSM_QUESTS, activity, db) + + val quests = mutableListOf>() + val notes = mutableListOf>() + val externalSourceQuests = mutableListOf>() + val added = hashSetOf() // avoid duplicates + var currentThing = BACKUP_HIDDEN_OSM_QUESTS + for (line in lines) { + if (line.isEmpty()) continue + if (line == BACKUP_HIDDEN_NOTES || line == BACKUP_HIDDEN_OTHER_QUESTS) { + currentThing = line + continue + } + val split = line.split(",") + if (split.size < 2) break + when (currentThing) { + BACKUP_HIDDEN_OSM_QUESTS -> if (added.add(line)) quests.add(arrayOf(split[0].toLong(), split[1], split[2], split[3].toLong())) + BACKUP_HIDDEN_NOTES -> if (added.add(line)) notes.add(arrayOf(split[0].toLong(), split[1].toLong())) + BACKUP_HIDDEN_OTHER_QUESTS -> if (added.add(line)) externalSourceQuests.add(arrayOf(split[0], split[1], split[2].toLong())) + } + } + + db.insertMany(OsmQuestsHiddenTable.NAME, + arrayOf(OsmQuestsHiddenTable.Columns.ELEMENT_ID, + OsmQuestsHiddenTable.Columns.ELEMENT_TYPE, + OsmQuestsHiddenTable.Columns.QUEST_TYPE, + OsmQuestsHiddenTable.Columns.TIMESTAMP), + quests, + conflictAlgorithm = ConflictAlgorithm.REPLACE + ) + db.insertMany(NoteQuestsHiddenTable.NAME, + arrayOf(NoteQuestsHiddenTable.Columns.NOTE_ID, + NoteQuestsHiddenTable.Columns.TIMESTAMP), + notes, + conflictAlgorithm = ConflictAlgorithm.REPLACE + ) + db.insertMany(ExternalSourceQuestTables.NAME_HIDDEN, + arrayOf(ExternalSourceQuestTables.Columns.SOURCE, + ExternalSourceQuestTables.Columns.ID, + ExternalSourceQuestTables.Columns.TIMESTAMP), + externalSourceQuests, + conflictAlgorithm = ConflictAlgorithm.REPLACE + ) + + // definitely need to reset visible quests + visibleEditTypeController.onVisibilitiesChanged() + // imported hidden osmquests are applied, but don't show up in edit history + // imported other quests are not even applied +} + +/** @returns the lines after [checkLine], which is expected to be the second or third line */ +private fun importLinesAndCheck(uri: Uri, checkLine: String, activity: Activity, db: Database): List = + activity.contentResolver?.openInputStream(uri)?.use { it.bufferedReader().use { input -> + val fileVersion = input.readLine().toLongOrNull() + if (fileVersion == null || (input.readLine() != checkLine && input.readLine() != checkLine)) { + Log.w(TAG, "import error, file version $fileVersion, checkLine $checkLine") + activity.toast(activity.getString(R.string.import_error), Toast.LENGTH_LONG) + return emptyList() + } + val dbVersion = db.rawQuery("PRAGMA user_version;") { c -> c.getLong("user_version") }.single() + if (fileVersion != dbVersion && (fileVersion > LAST_KNOWN_DB_VERSION || dbVersion > LAST_KNOWN_DB_VERSION)) { + Log.w(TAG, "import error, file version $fileVersion, dbVersion $dbVersion, last known db version $LAST_KNOWN_DB_VERSION") + activity.toast(activity.getString(R.string.import_error_db_version), Toast.LENGTH_LONG) + return emptyList() + } + input.readLines().renameUpdatedQuests() + } } ?: emptyList() + +// when importing, names should be updated! +private fun List.renameUpdatedQuests() = map { it.renameUpdatedQuests() } + +private fun importPresets(uri: Uri, activity: Activity, db: Database, visibleEditTypeController: VisibleEditTypeController) { + val lines = importLinesAndCheck(uri, BACKUP_PRESETS, activity, db) + if (lines.isEmpty()) { + return + } + AlertDialog.Builder(activity) + .setTitle(R.string.pref_import) + .setMessage(R.string.import_presets_overlays_message) + .setPositiveButton(R.string.import_presets_overlays_replace) { _, _ -> importPresets(lines, true, db, visibleEditTypeController) } + .setNeutralButton(R.string.import_presets_overlays_add) { _, _ -> importPresets(lines, false, db, visibleEditTypeController) } + .show() +} + +private fun importPresets(lines: List, replaceExistingPresets: Boolean, db: Database, visibleEditTypeController: VisibleEditTypeController) { + val lines = lines.renameUpdatedQuests() + val presets = mutableListOf>() + val orders = mutableListOf>() + val visibilities = mutableListOf>() + // set of lines to avoid duplicates that might arise when user has quests of old and new name in the backup + val presetsSet = hashSetOf() + val ordersSet = hashSetOf() + val visibilitiesSet = hashSetOf() + var currentThing = BACKUP_PRESETS + val profileIdMap = mutableMapOf(0L to 0L) // "default" is not in the presets section + val qsRegex = "(\\d+)_qs_".toRegex() + for (line in lines) { // go through list of presets + val split = line.split(",") + if (split.size < 2) break // happens if we come to the next category + val id = split[0].toLong() + profileIdMap[id] = id + } + + if (!replaceExistingPresets) { + // map profile ids to ids greater than existing maximum + val max = db.query(EditTypePresetsTable.NAME) { it.getLong(EditTypePresetsTable.Columns.EDIT_TYPE_PRESET_ID) }.maxOrNull() ?: 0L + val keys = profileIdMap.keys.toList() + keys.forEachIndexed { i, id -> + profileIdMap[id] = max + i + 1L + } + // consider that profile 0 has no name, as it's the "default" + presets.add(arrayOf(profileIdMap[0L]!!, "Default")) + } + + val questSettingsLines = mutableListOf() + for (line in lines) { + if (line.isEmpty()) continue // happens if a section is completely empty + if (line == BACKUP_PRESETS_ORDERS || line == BACKUP_PRESETS_VISIBILITIES) { + currentThing = line + continue + } + if (line == BACKUP_PRESETS_QUEST_SETTINGS) { + try { + // get remaining lines (they must be written if BACKUP_PRESETS_QUEST_SETTINGS is written) + val l = lines.subList(lines.indexOf(line) + 1, lines.size) + // replace per-preset quest settings preset ids + val adjustedLines = l.map { it.replace(qsRegex) { result -> + if (result.groupValues.size > 1) + "${result.groupValues[1].toLongOrNull()?.let { profileIdMap[it] }}_qs_" + else throw (IllegalStateException()) + } } + questSettingsLines.addAll(adjustedLines) + } catch (_: Exception){ + // do nothing if lines are broken somehow + } + break + } + val split = line.split(",") + if (split.size < 2) break + val id = profileIdMap[split[0].toLong()]!! + when (currentThing) { + BACKUP_PRESETS -> if (presetsSet.add(line)) presets.add(arrayOf(id, split[1])) + BACKUP_PRESETS_ORDERS -> if (ordersSet.add(line)) orders.add(arrayOf(id, split[1], split[2])) + BACKUP_PRESETS_VISIBILITIES -> if (visibilitiesSet.add(line)) visibilities.add(arrayOf(id, split[1], split[2].toLong())) + } + } + + db.transaction { + if (replaceExistingPresets) { + // delete existing data in all tables + db.delete(EditTypePresetsTable.NAME) + db.delete(QuestTypeOrderTable.NAME) + db.delete(VisibleEditTypeTable.NAME) + } + db.insertMany(EditTypePresetsTable.NAME, + arrayOf(EditTypePresetsTable.Columns.EDIT_TYPE_PRESET_ID, EditTypePresetsTable.Columns.EDIT_TYPE_PRESET_NAME), + presets + ) + db.insertMany(QuestTypeOrderTable.NAME, + arrayOf(QuestTypeOrderTable.Columns.EDIT_TYPE_PRESET_ID, + QuestTypeOrderTable.Columns.BEFORE, + QuestTypeOrderTable.Columns.AFTER), + orders + ) + db.insertMany(VisibleEditTypeTable.NAME, + arrayOf(VisibleEditTypeTable.Columns.EDIT_TYPE_PRESET_ID, + VisibleEditTypeTable.Columns.EDIT_TYPE, + VisibleEditTypeTable.Columns.VISIBILITY), + visibilities + ) + } + + // database stuff successful, update preferences + if (replaceExistingPresets) { + val prefs = StreetCompleteApplication.preferences + prefs.edit { + // remove all per-preset quest settings for proper replace + prefs.all.keys.filter { qsRegex.containsMatchIn(it) }.forEach { remove(it) } + // set selected preset to default, because previously selected may not exist any more + putLong(Preferences.SELECTED_EDIT_TYPE_PRESET, 0) + } + } + readToSettings(questSettingsLines) + + visibleEditTypeController.setVisibilities(emptyMap()) // reload stuff +} + +private fun importSettings(uri: Uri, activity: Activity, osmoseDao: OsmoseDao, externalSourceQuestController: ExternalSourceQuestController): Boolean { + val lines = activity.contentResolver?.openInputStream(uri)?.use { it.reader().readLines().renameUpdatedQuests() } ?: return false + val r = readToSettings(lines) + osmoseDao.reloadIgnoredItems() + externalSourceQuestController.invalidate() + return r +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/DisplaySettingsScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/DisplaySettingsScreen.kt new file mode 100644 index 00000000000..2f28543255e --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/DisplaySettingsScreen.kt @@ -0,0 +1,312 @@ +package de.westnordost.streetcomplete.screens.settings + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.provider.OpenableColumns +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AlertDialog +import androidx.compose.material.AppBarDefaults +import androidx.compose.material.Button +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.download.DownloadController +import de.westnordost.streetcomplete.data.download.DownloadWorker +import de.westnordost.streetcomplete.data.importGpx +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.visiblequests.VisibleEditTypeController +import de.westnordost.streetcomplete.ui.common.BackIcon +import de.westnordost.streetcomplete.ui.common.dialogs.SimpleListPickerDialog +import de.westnordost.streetcomplete.ui.common.settings.Preference +import de.westnordost.streetcomplete.ui.common.settings.SwitchPreference +import de.westnordost.streetcomplete.util.ktx.getActivity +import de.westnordost.streetcomplete.util.ktx.toast +import io.ticofab.androidgpxparser.parser.GPXParser +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import java.io.File +import java.io.IOException + +@Composable +fun DisplaySettingsScreen( + onClickBack: () -> Unit, +) { + val visibleEditTypeController: VisibleEditTypeController = koinInject() + val prefs: Preferences = koinInject() + val scope = rememberCoroutineScope() + var showBackgroundDialog by remember { mutableStateOf(false) } + var showGpxDialog by remember { mutableStateOf(false) } + var showGeometryDialog by remember { mutableStateOf(false) } + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text(stringResource(R.string.pref_screen_display)) }, + windowInsets = AppBarDefaults.topAppBarWindowInsets, + navigationIcon = { IconButton(onClick = onClickBack) { BackIcon() } }, + ) + Column( + Modifier + .verticalScroll(rememberScrollState()) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom + ) + ) + ) { + SwitchPreference( + name = stringResource(R.string.pref_way_direction), + description = stringResource(R.string.pref_way_direction_summary), + default = false, + pref = Prefs.SHOW_WAY_DIRECTION + ) + SwitchPreference( + name = stringResource(R.string.pref_quest_geometries_title), + description = stringResource(R.string.pref_quest_geometries_summary), + default = false, + pref = Prefs.QUEST_GEOMETRIES, + onCheckedChange = { visibleEditTypeController.onVisibilitiesChanged() } + ) + SwitchPreference( + name = stringResource(R.string.pref_offset_fix_title2), + description = stringResource(R.string.pref_offset_fix_summary), + default = false, + pref = Prefs.OFFSET_FIX, + onCheckedChange = { + // trigger map update by switching background twice + val old = prefs.getString(Prefs.THEME_BACKGROUND, "MAP") + val new = if (old == "MAP") "AERIAL" else "MAP" + prefs.putString(Prefs.THEME_BACKGROUND, new) + scope.launch { + delay(100) + prefs.putString(Prefs.THEME_BACKGROUND, old) + } + } + ) + SwitchPreference( + name = stringResource(R.string.pref_show_solved_animation), + description = stringResource(R.string.pref_show_solved_animation_summary), + default = true, + pref = Prefs.SHOW_SOLVED_ANIMATION + ) + Preference( + name = stringResource(R.string.pref_background_type_select), + description = if (prefs.getString(Prefs.THEME_BACKGROUND, "MAP") == "MAP") + stringResource(R.string.background_type_map) + else stringResource(R.string.background_type_aerial_esri), + onClick = { showBackgroundDialog = true } + ) + Preference( + name = stringResource(R.string.pref_gpx_track_title), + onClick = { showGpxDialog = true }, + ) + Preference( + name = stringResource(R.string.pref_custom_geometry_title), + onClick = { showGeometryDialog = true }, + ) + if (showBackgroundDialog) + SimpleListPickerDialog( + onDismissRequest = { showBackgroundDialog = false }, + items = listOf("MAP", "AERIAL"), + onItemSelected = { prefs.putString(Prefs.THEME_BACKGROUND, it) }, + getItemName = { + if (it == "MAP") stringResource(R.string.background_type_map) + else stringResource(R.string.background_type_aerial_esri) + }, + selectedItem = prefs.getString(Prefs.THEME_BACKGROUND, "MAP") + ) + if (showGpxDialog) { + val ctx = LocalContext.current + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + val uri = it.data?.data + if (it.resultCode != Activity.RESULT_OK || uri == null) { + ctx.toast(R.string.pref_gpx_track_loading_error, Toast.LENGTH_LONG) + return@rememberLauncherForActivityResult + } + ctx.getActivity()?.contentResolver?.query(uri, null, null, null, null).use { + if (it != null && it.moveToFirst()) { + val idx = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (idx >= 0 && !it.getString(idx).endsWith(".gpx")) { + ctx.toast(R.string.pref_gpx_track_loading_error, Toast.LENGTH_LONG) + return@rememberLauncherForActivityResult + } + } + } + try { + ctx.getActivity()?.contentResolver?.openInputStream(uri)?.use { it.bufferedReader().use { reader -> + File(ctx.getExternalFilesDir(null), GPX_TRACK_FILE).writeText(reader.readText()) + } } + gpx_track_changed = true + showGpxDialog = false + showGpxDialog = true + } catch (e: IOException) { + ctx.toast(R.string.pref_gpx_track_loading_error, Toast.LENGTH_LONG) + } + } + val gpxFileExists = ctx.getExternalFilesDir(null)?.let { File(it, GPX_TRACK_FILE) }?.exists() == true + val downloadController: DownloadController = koinInject() + AlertDialog( + onDismissRequest = { showGpxDialog = false }, + confirmButton = { + TextButton(onClick = { showGpxDialog = false }) { Text(stringResource(R.string.close)) } + }, + title = { Text(stringResource(R.string.pref_gpx_track_title))}, + text = { + Column { + Button( + onClick = { + val points = loadGpxTrackPoints(ctx, true) ?: return@Button + GlobalScope.launch { + val import = importGpx(points, true, 10.0).getOrNull() + import?.downloadBBoxes?.let { + if (it.isEmpty()) return@launch + DownloadWorker.enqueuedDownloads.addAll(it.drop(1)) + downloadController.download(it.first(), false, true) + } + } + }, + enabled = gpxFileExists + ) { Text(stringResource(R.string.pref_gpx_track_download), Modifier.fillMaxWidth(), textAlign = TextAlign.Center) } + Button( + onClick = { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + // actually the type should be application/gpx+xml, but often doesn't work + // for some phones only application/octet-stream works, for others it doesn't, so just allow everything + type = "*/*" + } + launcher.launch(intent) + } + ) { Text(stringResource(R.string.pref_gpx_track_provide), Modifier.fillMaxWidth(), textAlign = TextAlign.Center) } + if (gpxFileExists) + SwitchPreference( + name = stringResource(R.string.pref_gpx_track_enable), + default = false, + pref = Prefs.SHOW_GPX_TRACK, + ) + } + }, + ) + } + if (showGeometryDialog) { + val ctx = LocalContext.current + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + val uri = it.data?.data + if (it.resultCode != Activity.RESULT_OK || uri == null) { + ctx.toast(R.string.file_loading_error, Toast.LENGTH_LONG) + return@rememberLauncherForActivityResult + } + try { + ctx.getActivity()?.contentResolver?.openInputStream(uri)?.use { it.bufferedReader().use { reader -> + File(ctx.getExternalFilesDir(null), CUSTOM_GEOMETRY_FILE).writeText(reader.readText()) + } } + custom_geometry_changed = true + showGeometryDialog = false + showGeometryDialog = true + } catch (e: IOException) { + ctx.toast(R.string.file_loading_error, Toast.LENGTH_LONG) + } + } + val fileExists = ctx.getExternalFilesDir(null)?.let { File(it, CUSTOM_GEOMETRY_FILE) }?.exists() == true + AlertDialog( + onDismissRequest = { showGeometryDialog = false }, + confirmButton = { + TextButton(onClick = { showGeometryDialog = false }) { Text(stringResource(R.string.close)) } + }, + title = { Text(stringResource(R.string.pref_custom_geometry_title))}, + text = { + Column { + Text(stringResource(R.string.pref_custom_geometry_info)) + Button( + onClick = { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + launcher.launch(intent) + } + ) { Text(stringResource(R.string.file_provide), Modifier.fillMaxWidth(), textAlign = TextAlign.Center) } + if (fileExists) + SwitchPreference( + name = stringResource(R.string.quest_enabled), + default = false, + pref = Prefs.SHOW_CUSTOM_GEOMETRY, + ) + } + }, + ) + } + } + } +} + +fun loadGpxTrackPoints(context: Context, complain: Boolean = false): List? { + // load gpx file as one long track, no matter how it's stored internally (for now) + // ... + // + val gpxFile = context.getExternalFilesDir(null)?.let { File(it, GPX_TRACK_FILE) } + if (gpxFile?.exists() != true) { + if (complain) + context.toast(R.string.pref_gpx_track_loading_error, Toast.LENGTH_LONG) + return null + } + + val gpxPoints = runCatching { + GPXParser().parse(gpxFile.inputStream()).tracks.map { track -> + track.trackSegments.map { segment -> + segment.trackPoints + } + }.flatten().flatten() + .map { trackPoint -> + LatLon( + latitude = trackPoint.latitude, + longitude = trackPoint.longitude + ) + } + }.getOrNull() + + if ((gpxPoints?.size ?: 0) < 2) { + context.toast(R.string.pref_gpx_track_loading_error, Toast.LENGTH_LONG) + return null + } + return gpxPoints +} + +fun loadCustomGeometryText(context: Context): String? { + val file = context.getExternalFilesDir(null)?.let { File(it, CUSTOM_GEOMETRY_FILE) } + if (file?.exists() != true) return null + return file.readText() +} + +private const val GPX_TRACK_FILE = "display_track.gpx" +private const val CUSTOM_GEOMETRY_FILE = "customGeometry.geojson" + +var gpx_track_changed = false +var custom_geometry_changed = false diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/NoteSettingsScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/NoteSettingsScreen.kt new file mode 100644 index 00000000000..b0767021b1f --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/NoteSettingsScreen.kt @@ -0,0 +1,237 @@ +package de.westnordost.streetcomplete.screens.settings + +import android.app.Activity +import android.content.Intent +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AppBarDefaults +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestController +import de.westnordost.streetcomplete.data.osmnotes.notequests.getRawBlockList +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.ui.common.BackIcon +import de.westnordost.streetcomplete.ui.common.dialogs.TextInputDialog +import de.westnordost.streetcomplete.ui.common.settings.Preference +import de.westnordost.streetcomplete.ui.common.settings.SwitchPreference +import de.westnordost.streetcomplete.util.ktx.getActivity +import de.westnordost.streetcomplete.util.ktx.toast +import kotlinx.serialization.json.Json +import org.koin.compose.koinInject +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@Composable +fun NoteSettingsScreen( + onClickBack: () -> Unit, +) { + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text(stringResource(R.string.pref_screen_notes)) }, + windowInsets = AppBarDefaults.topAppBarWindowInsets, + navigationIcon = { IconButton(onClick = onClickBack) { BackIcon() } }, + ) + Column( + Modifier + .verticalScroll(rememberScrollState()) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom + ) + ) + ) { + var showHideNotesDialog by remember { mutableStateOf(false) } + val ctx = LocalContext.current + SwitchPreference( + name = stringResource(R.string.pref_show_gpx_button_title), + description = stringResource(R.string.pref_show_gpx_button_summary), + pref = Prefs.GPX_BUTTON, + default = false, + ) + SwitchPreference( + name = stringResource(R.string.pref_swap_gpx_note_button), + pref = Prefs.SWAP_GPX_NOTE_BUTTONS, + default = false, + ) + SwitchPreference( + name = stringResource(R.string.pref_hide_keyboard_title), + description = stringResource(R.string.pref_hide_keyboard_summary), + pref = Prefs.HIDE_KEYBOARD_FOR_NOTE, + default = true, + ) + SwitchPreference( + name = stringResource(R.string.pref_really_all_notes_title), + description = stringResource(R.string.pref_really_all_notes_summary), + pref = Prefs.REALLY_ALL_NOTES, + default = false, + ) + SwitchPreference( + name = stringResource(R.string.pref_create_custom_quest_title), + description = stringResource(R.string.pref_create_custom_quest_summary), + pref = Prefs.CREATE_EXTERNAL_QUESTS, + default = false, + ) + SwitchPreference( + name = stringResource(R.string.pref_save_photos_title), + description = stringResource(R.string.pref_save_photos_summary), + pref = Prefs.QUEST_SETTINGS_PER_PRESET, + default = false, + ) + Preference( + name = stringResource(R.string.pref_hide_notes_title), + onClick = { showHideNotesDialog = true }, + ) + val gpxLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode != Activity.RESULT_OK || it.data == null) + return@rememberLauncherForActivityResult + val uri = it.data?.data ?: return@rememberLauncherForActivityResult + val output = ctx.getActivity()?.contentResolver?.openOutputStream(uri) ?: return@rememberLauncherForActivityResult + val os = output.buffered() + try { + // read gpx and extract images + val filesDir = ctx.getExternalFilesDir(null) + val gpxFile = File(filesDir, "notes.gpx") + val files = mutableListOf(gpxFile) + val gpxText = gpxFile.readText(Charsets.UTF_8) + val picturesDir = File(filesDir, "Pictures") + // get all files in pictures dir and check whether they occur in gpxText + if (picturesDir.isDirectory) { + picturesDir.walk().forEach { + if (!it.isDirectory && gpxText.contains(it.name)) + files.add(it) + } + } + filesDir?.walk()?.forEach { + if (it.name.startsWith("track_") && it.name.endsWith(".gpx") && gpxText.contains(it.name)) + files.add(it) + } + + // write files to zip + val zipStream = ZipOutputStream(os) + files.forEach { + val fileStream = FileInputStream(it).buffered() + zipStream.putNextEntry(ZipEntry(it.name)) + fileStream.copyTo(zipStream, 1024) + fileStream.close() + zipStream.closeEntry() + } + zipStream.close() + files.forEach { it.delete() } + } catch (e: IOException) { + ctx.toast(ctx.getString(R.string.pref_save_file_error), Toast.LENGTH_LONG) + } + os.close() + output.close() + } + Preference( + name = stringResource(R.string.pref_save_gpx), + onClick = { + if (File(ctx.getExternalFilesDir(null), "notes.gpx").exists()) { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_TITLE, "notes.zip") + type = "application/zip" + } + gpxLauncher.launch(intent) + } else { + ctx.toast(ctx.getString(R.string.pref_files_not_found), Toast.LENGTH_LONG) + } + }, + ) + val photoLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode != Activity.RESULT_OK || it.data == null) + return@rememberLauncherForActivityResult + val uri = it.data?.data ?: return@rememberLauncherForActivityResult + val output = ctx.getActivity()?.contentResolver?.openOutputStream(uri) ?: return@rememberLauncherForActivityResult + val os = output.buffered() + try { + val filesDir = ctx.getExternalFilesDir(null) + val files = mutableListOf() + val picturesDir = File(filesDir, "full_photos") + // get all files in pictures dir + if (picturesDir.isDirectory) { + picturesDir.walk().forEach { + if (!it.isDirectory) files.add(it) + } + } + else { // we checked for this, but better be sure + ctx.toast(ctx.getString(R.string.pref_files_not_found), Toast.LENGTH_LONG) + return@rememberLauncherForActivityResult + } + + // write files to zip + val zipStream = ZipOutputStream(os) + files.forEach { + val fileStream = FileInputStream(it).buffered() + zipStream.putNextEntry(ZipEntry(it.name)) + fileStream.copyTo(zipStream, 1024) + fileStream.close() + zipStream.closeEntry() + } + zipStream.close() + files.forEach { it.delete() } + } catch (e: IOException) { + ctx.toast(ctx.getString(R.string.pref_save_file_error), Toast.LENGTH_LONG) + } + os.close() + output.close() + } + Preference( + name = stringResource(R.string.pref_get_photos_title), + onClick = { + val dir = File(ctx.getExternalFilesDir(null), "full_photos") + if (dir.exists() && dir.isDirectory && dir.list()?.isNotEmpty() == true) { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_TITLE, "full_photos.zip") + type = "application/zip" + } + photoLauncher.launch(intent) + } else { + ctx.toast(ctx.getString(R.string.pref_files_not_found), Toast.LENGTH_LONG) + } + }, + ) + if (showHideNotesDialog) { + val prefs: Preferences = koinInject() + val blockList = getRawBlockList(prefs) + TextInputDialog( + onDismissRequest = { showHideNotesDialog = false }, + onConfirmed = { + val content = it.split(",").map { it.trim().lowercase() } + prefs.putString(Prefs.HIDE_NOTES_BY_USERS, Json.encodeToString(content)) + OsmQuestController.reloadQuestTypes() + }, + singleLine = false, + title = { Text(stringResource(R.string.pref_hide_notes_message)) }, + textInputLabel = { Text(stringResource(R.string.pref_hide_notes_hint)) }, + text = blockList.joinToString(", ") + ) + } + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/QuestSettingsScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/QuestSettingsScreen.kt new file mode 100644 index 00000000000..af729935c71 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/QuestSettingsScreen.kt @@ -0,0 +1,249 @@ +package de.westnordost.streetcomplete.screens.settings + +import android.Manifest.permission.ACCESS_BACKGROUND_LOCATION +import android.Manifest.permission.ACCESS_FINE_LOCATION +import android.Manifest.permission.POST_NOTIFICATIONS +import android.content.Context +import android.os.Build +import android.text.InputType +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.SwitchCompat +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AppBarDefaults +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.app.ActivityCompat +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestController +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.preferences.ResurveyIntervalsUpdater +import de.westnordost.streetcomplete.data.visiblequests.DayNightQuestFilter +import de.westnordost.streetcomplete.data.visiblequests.QuestTypeOrderController +import de.westnordost.streetcomplete.data.visiblequests.VisibleEditTypeController +import de.westnordost.streetcomplete.ui.common.BackIcon +import de.westnordost.streetcomplete.ui.common.dialogs.SimpleListPickerDialog +import de.westnordost.streetcomplete.ui.common.settings.Preference +import de.westnordost.streetcomplete.ui.common.settings.SwitchPreference +import de.westnordost.streetcomplete.util.dialogs.setViewWithDefaultPadding +import de.westnordost.streetcomplete.util.ktx.dpToPx +import de.westnordost.streetcomplete.util.ktx.getActivity +import de.westnordost.streetcomplete.util.ktx.hasPermission +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +@Composable +fun QuestSettingsScreen( + onClickBack: () -> Unit, +) { + val ctx = LocalContext.current + val prefs: Preferences = koinInject() + val scope = rememberCoroutineScope() + val visibleEditTypeController: VisibleEditTypeController = koinInject() + val dayNightQuestFilter: DayNightQuestFilter = koinInject() + val questTypeOrderController: QuestTypeOrderController = koinInject() + val resurveyIntervalsUpdater: ResurveyIntervalsUpdater = koinInject() + var showDayNightDialog by remember { mutableStateOf(false) } + + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text(stringResource(R.string.pref_screen_quests)) }, + windowInsets = AppBarDefaults.topAppBarWindowInsets, + navigationIcon = { IconButton(onClick = onClickBack) { BackIcon() } }, + ) + Column( + Modifier + .verticalScroll(rememberScrollState()) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom + ) + ) + ) { + Preference( + name = stringResource(R.string.pref_day_night_title), + onClick = { showDayNightDialog = true }, + ) { + Text(stringResource(Prefs.DayNightBehavior.valueOf(prefs.getString(Prefs.DAY_NIGHT_BEHAVIOR, "IGNORE")).titleResId)) + } + if (prefs.expertMode) + Preference( + name = stringResource(R.string.advanced_resurvey_title), + onClick = { advancedResurveyDialog(prefs, ctx, resurveyIntervalsUpdater) }, + description = stringResource(R.string.pref_advanced_resurvey_summary) + ) + if (prefs.expertMode) + SwitchPreference( + name = stringResource(R.string.pref_quest_settings_preset_title), + description = stringResource(R.string.pref_quest_settings_preset_summary), + pref = Prefs.QUEST_SETTINGS_PER_PRESET, + default = false, + onCheckedChange = { OsmQuestController.reloadQuestTypes() }, + ) + if (prefs.expertMode) + SwitchPreference( + name = stringResource(R.string.pref_dynamic_quest_creation_title), + description = stringResource(R.string.pref_dynamic_quest_creation_summary), + pref = Prefs.DYNAMIC_QUEST_CREATION, + default = false, + onCheckedChange = { scope.launch(Dispatchers.IO) { visibleEditTypeController.onVisibilitiesChanged() } } + ) + if (prefs.expertMode) + SwitchPreference( + name = stringResource(R.string.pref_override_country_restrictions_title), + description = stringResource(R.string.pref_override_country_restrictions_summary), + pref = Prefs.OVERRIDE_COUNTRY_RESTRICTIONS, + default = false, + onCheckedChange = { scope.launch(Dispatchers.IO) { visibleEditTypeController.onVisibilitiesChanged() } } + ) + Preference( + name = stringResource(R.string.pref_quest_monitor_title), + onClick = { questMonitorDialog(prefs, ctx) }, + description = stringResource(R.string.pref_quest_monitor_summary) + ) + SwitchPreference( + name = stringResource(R.string.pref_hide_overlay_quests), + pref = Prefs.HIDE_OVERLAY_QUESTS, + default = true, + onCheckedChange = { scope.launch(Dispatchers.IO) { visibleEditTypeController.onVisibilitiesChanged() } } + ) + } + } + if (showDayNightDialog) + SimpleListPickerDialog( + onDismissRequest = { showDayNightDialog = false }, + items = Prefs.DayNightBehavior.entries, + onItemSelected = { + prefs.putString(Prefs.DAY_NIGHT_BEHAVIOR, it.name) + scope.launch(Dispatchers.IO) { + dayNightQuestFilter.reload() + visibleEditTypeController.onVisibilitiesChanged() + questTypeOrderController.onQuestTypeOrderChanged() + } + }, + title = { Text(stringResource(R.string.pref_day_night_title)) }, + selectedItem = Prefs.DayNightBehavior.valueOf(prefs.getString(Prefs.DAY_NIGHT_BEHAVIOR, "IGNORE")), + getItemName = { stringResource(it.titleResId) } + ) +} + +// todo: composable +private fun advancedResurveyDialog(prefs: Preferences, context: Context, resurveyIntervalsUpdater: ResurveyIntervalsUpdater) { + val layout = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + val keyText = TextView(context) + keyText.setText(R.string.advanced_resurvey_message_keys) + val keyEditText = EditText(context) + keyEditText.inputType = InputType.TYPE_CLASS_TEXT + keyEditText.setHint(R.string.advanced_resurvey_hint_keys) + keyEditText.setText(prefs.getString(Prefs.RESURVEY_KEYS, "")) + + val dateText = TextView(context) + dateText.setText(R.string.advanced_resurvey_message_date) + val dateEditText = EditText(context) + dateEditText.inputType = InputType.TYPE_CLASS_TEXT + dateEditText.setHint(R.string.advanced_resurvey_hint_date) + dateEditText.setText(prefs.getString(Prefs.RESURVEY_DATE, "")) + + layout.addView(keyText) + layout.addView(keyEditText) + layout.addView(dateText) + layout.addView(dateEditText) + + AlertDialog.Builder(context) + .setTitle(R.string.advanced_resurvey_title) + .setViewWithDefaultPadding(layout) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + prefs.putString(Prefs.RESURVEY_DATE, dateEditText.text.toString()) + prefs.putString(Prefs.RESURVEY_KEYS, keyEditText.text.toString()) + resurveyIntervalsUpdater.update() + } + .show() +} + +// todo: composable +private fun questMonitorDialog(prefs: Preferences, context: Context) { + val layout = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + val enable = SwitchCompat(context).apply { + isChecked = prefs.getBoolean(Prefs.QUEST_MONITOR, false) + setText(R.string.pref_quest_monitor_title) + setOnCheckedChangeListener { _, b -> + val activity = context.getActivity() ?: return@setOnCheckedChangeListener + if (!b) return@setOnCheckedChangeListener + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !activity.hasPermission(ACCESS_FINE_LOCATION)) { + isChecked = false + ActivityCompat.requestPermissions(activity, arrayOf(ACCESS_FINE_LOCATION), 0) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !activity.hasPermission(ACCESS_BACKGROUND_LOCATION)) { + isChecked = false + ActivityCompat.requestPermissions(activity, arrayOf(ACCESS_BACKGROUND_LOCATION), 0) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !activity.hasPermission(POST_NOTIFICATIONS)) { + isChecked = false + ActivityCompat.requestPermissions(activity, arrayOf(POST_NOTIFICATIONS), 0) + } + } + } + val downloadSwitch = SwitchCompat(context).apply { + isChecked = prefs.getBoolean(Prefs.QUEST_MONITOR_DOWNLOAD, false) + setText(R.string.pref_quest_monitor_download) + setPadding(0, 0, 0, context.resources.dpToPx(8).toInt()) + } + val activeText = TextView(context).apply { setText(R.string.quest_monitor_active_request) } + val gpsSwitch = SwitchCompat(context).apply { + isChecked = prefs.getBoolean(Prefs.QUEST_MONITOR_GPS, false) + setText(R.string.quest_monitor_gps) + } + val netSwitch = SwitchCompat(context).apply { + isChecked = prefs.getBoolean(Prefs.QUEST_MONITOR_NET, false) + setText(R.string.quest_monitor_net) + } + val accuracyText = TextView(context).apply { setText(R.string.quest_monitor_search_radius_text) } + val accuracyEditText = EditText(context) + accuracyEditText.inputType = InputType.TYPE_CLASS_NUMBER + accuracyEditText.setText(prefs.getFloat(Prefs.QUEST_MONITOR_RADIUS, 50f).toString()) + + layout.addView(enable) + layout.addView(downloadSwitch) + layout.addView(activeText) + layout.addView(gpsSwitch) + layout.addView(netSwitch) + layout.addView(accuracyText) + layout.addView(accuracyEditText) + + AlertDialog.Builder(context) + .setTitle(R.string.pref_quest_monitor_title) + .setViewWithDefaultPadding(ScrollView(context).apply { addView(layout) }) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + prefs.putBoolean(Prefs.QUEST_MONITOR, enable.isChecked) + prefs.putBoolean(Prefs.QUEST_MONITOR_GPS, gpsSwitch.isChecked) + prefs.putBoolean(Prefs.QUEST_MONITOR_NET, netSwitch.isChecked) + prefs.putBoolean(Prefs.QUEST_MONITOR_DOWNLOAD, downloadSwitch.isChecked) + prefs.prefs.putFloat(Prefs.QUEST_MONITOR_RADIUS, accuracyEditText.text.toString().toFloatOrNull() ?: 50f) + } + .show() +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsActivity.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsActivity.kt index f757209c7d8..648ac36724e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsActivity.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsActivity.kt @@ -65,7 +65,7 @@ class SettingsActivity : BaseActivity(), AbstractOsmQuestForm.Listener { SettingsNavHost( onClickBack = { finish() }, onClickShowQuestTypeForDebug = ::onClickQuestType, - startDestination = if (launchQuestSelection) SettingsDestination.QuestSelection else null + startDestination = if (launchQuestSelection) SettingsDestination.QuestSelection else null, ) } } @@ -119,6 +119,10 @@ class SettingsActivity : BaseActivity(), AbstractOsmQuestForm.Listener { popQuestForm() } + override fun onEditTags(element: Element, geometry: ElementGeometry, questKey: QuestKey?, editTypeName: String?) { + popQuestForm() + } + private fun popQuestForm() { binding.questFormContainer.visibility = View.GONE supportFragmentManager.popBackStack() @@ -144,6 +148,7 @@ class SettingsActivity : BaseActivity(), AbstractOsmQuestForm.Listener { f.requireArguments().putAll(AbstractOsmQuestForm.createArguments(element)) f.hideQuestController = object : HideQuestController { override fun hide(key: QuestKey) {} + override fun tempHide(key: QuestKey) {} } f.addElementEditsController = object : AddElementEditsController { override fun add( @@ -151,7 +156,8 @@ class SettingsActivity : BaseActivity(), AbstractOsmQuestForm.Listener { geometry: ElementGeometry, source: String, action: ElementEditAction, - isNearUserLocation: Boolean + isNearUserLocation: Boolean, + key: QuestKey? ) { when (action) { is DeletePoiNodeAction -> { @@ -174,6 +180,8 @@ class SettingsActivity : BaseActivity(), AbstractOsmQuestForm.Listener { private fun updateContainerVisibility() { binding.questFormContainer.isGone = supportFragmentManager.findFragmentById(R.id.questForm) == null + binding.sceeSettingsFragmentContainer.isGone = supportFragmentManager.findFragmentById(R.id.sceeSettingsFragment) == null + binding.toolbar.toolbar.isGone = supportFragmentManager.findFragmentById(R.id.sceeSettingsFragment) == null } private fun createMockElementWithGeometry(questType: OsmElementQuestType<*>): Pair { diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsNavHost.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsNavHost.kt index a4bf02ffe98..5e721309e3a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsNavHost.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsNavHost.kt @@ -18,7 +18,7 @@ import org.koin.androidx.compose.koinViewModel @Composable fun SettingsNavHost( onClickBack: () -> Unit, onClickShowQuestTypeForDebug: (QuestType) -> Unit, - startDestination: String? = null + startDestination: String? = null, ) { val navController = rememberNavController() val dir = LocalLayoutDirection.current.dir @@ -42,7 +42,12 @@ import org.koin.androidx.compose.koinViewModel onClickPresetSelection = { navController.navigate(SettingsDestination.EditTypePresets) }, onClickQuestSelection = { navController.navigate(SettingsDestination.QuestSelection) }, onClickOverlaySelection = { navController.navigate(SettingsDestination.OverlaySelection) }, - onClickBack = ::goBack + onClickBack = ::goBack, + onClickQuestSettings = { navController.navigate(SettingsDestination.QuestSettings) }, + onClickUiSettings = { navController.navigate(SettingsDestination.UiSettings) }, + onClickDisplaySettings = { navController.navigate(SettingsDestination.DisplaySettings) }, + onClickNoteSettings = { navController.navigate(SettingsDestination.NoteSettings) }, + onClickDataSettings = { navController.navigate(SettingsDestination.DataManagementSettings) }, ) } composable(SettingsDestination.EditTypePresets) { @@ -70,6 +75,31 @@ import org.koin.androidx.compose.koinViewModel onClickBack = ::goBack, ) } + composable(SettingsDestination.QuestSettings) { + QuestSettingsScreen( + onClickBack = ::goBack + ) + } + composable(SettingsDestination.UiSettings) { + UiSettingsScreen( + onClickBack = ::goBack + ) + } + composable(SettingsDestination.DisplaySettings) { + DisplaySettingsScreen( + onClickBack = ::goBack + ) + } + composable(SettingsDestination.NoteSettings) { + NoteSettingsScreen( + onClickBack = ::goBack + ) + } + composable(SettingsDestination.DataManagementSettings) { + DataManagementScreen( + onClickBack = ::goBack + ) + } } } @@ -79,4 +109,9 @@ object SettingsDestination { const val QuestSelection = "quest_selection" const val OverlaySelection = "overlay_selection" const val ShowQuestForms = "show_quest_forms" + const val QuestSettings = "scee_quest_settings" + const val UiSettings = "scee_ui_settings" + const val DisplaySettings = "scee_display_settings" + const val NoteSettings = "scee_note_settings" + const val DataManagementSettings = "scee_data_settings" } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsScreen.kt index 2a48b82872d..d6e832d4351 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsScreen.kt @@ -1,5 +1,14 @@ package de.westnordost.streetcomplete.screens.settings +import android.content.Context +import android.os.Build +import android.view.View +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.appcompat.app.AlertDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -24,14 +33,18 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import de.westnordost.streetcomplete.ApplicationConstants.DELETE_OLD_DATA_AFTER +import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.ApplicationConstants import de.westnordost.streetcomplete.ApplicationConstants.REFRESH_DATA_AFTER import de.westnordost.streetcomplete.BuildConfig +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.preferences.Autosync +import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.data.preferences.ResurveyIntervals import de.westnordost.streetcomplete.data.preferences.Theme import de.westnordost.streetcomplete.ui.common.BackIcon @@ -41,7 +54,12 @@ import de.westnordost.streetcomplete.ui.common.dialogs.InfoDialog import de.westnordost.streetcomplete.ui.common.dialogs.SimpleListPickerDialog import de.westnordost.streetcomplete.ui.common.settings.Preference import de.westnordost.streetcomplete.ui.common.settings.PreferenceCategory +import de.westnordost.streetcomplete.util.TempLogger +import de.westnordost.streetcomplete.util.dialogs.setDefaultDialogPadding import de.westnordost.streetcomplete.util.ktx.format +import de.westnordost.streetcomplete.util.logs.DatabaseLogger +import de.westnordost.streetcomplete.util.logs.Log +import org.koin.compose.koinInject import java.util.Locale /** Shows the settings lists */ @@ -53,6 +71,11 @@ fun SettingsScreen( onClickQuestSelection: () -> Unit, onClickOverlaySelection: () -> Unit, onClickBack: () -> Unit, + onClickQuestSettings: () -> Unit, + onClickUiSettings: () -> Unit, + onClickDisplaySettings: () -> Unit, + onClickNoteSettings: () -> Unit, + onClickDataSettings: () -> Unit, ) { val hiddenQuestCount by viewModel.hiddenQuestCount.collectAsState() val questTypeCount by viewModel.questTypeCount.collectAsState() @@ -67,6 +90,7 @@ fun SettingsScreen( val keepScreenOn by viewModel.keepScreenOn.collectAsState() val showZoomButtons by viewModel.showZoomButtons.collectAsState() val selectedLanguage by viewModel.selectedLanguage.collectAsState() + val expertMode by viewModel.expertMode.collectAsState() var showDeleteCacheConfirmation by remember { mutableStateOf(false) } var showRestoreHiddenQuestsConfirmation by remember { mutableStateOf(false) } @@ -76,9 +100,27 @@ fun SettingsScreen( var showLanguageSelect by remember { mutableStateOf(false) } var showAutosyncSelect by remember { mutableStateOf(false) } var showResurveyIntervalsSelect by remember { mutableStateOf(false) } + var showExpertModeConfirmation by remember { mutableStateOf(false) } val presetNameOrDefault = selectedPresetName ?: stringResource(R.string.quest_presets_default_name) + val c = LocalContext.current + val databaseLogger: DatabaseLogger = koinInject() + val prefs: Preferences = koinInject() + var useDebugLogger by remember { mutableStateOf(prefs.getBoolean(Prefs.TEMP_LOGGER, false)) } + + fun useDebugLogger(use: Boolean, prefs: Preferences, databaseLogger: DatabaseLogger) { + prefs.putBoolean(Prefs.TEMP_LOGGER, use) + useDebugLogger = use + if (use) { + Log.instances.removeAll { it is DatabaseLogger } + Log.instances.add(TempLogger) + } else { + Log.instances.remove(TempLogger) + Log.instances.add(databaseLogger) + } + } + Column(Modifier.fillMaxSize()) { TopAppBar( title = { Text(stringResource(R.string.action_settings)) }, @@ -205,6 +247,62 @@ fun SettingsScreen( ) } + PreferenceCategory(stringResource(R.string.pref_category_mods)) { + + Preference( + name = stringResource(R.string.pref_expert_mode_title), + onClick = { + if (expertMode) viewModel.setExpertMode(false) + else showExpertModeConfirmation = true + }, + description = stringResource(R.string.pref_expert_mode_summary) + ) { + Switch( + checked = expertMode, + onCheckedChange = { + if (!it) viewModel.setExpertMode(it) + else showExpertModeConfirmation = true + } + ) + } + + Preference( + name = stringResource(R.string.pref_screen_ui), + onClick = onClickUiSettings, + ) + Preference( + name = stringResource(R.string.pref_screen_display), + onClick = onClickDisplaySettings, + ) + Preference( + name = stringResource(R.string.pref_screen_quests), + onClick = onClickQuestSettings, + ) + Preference( + name = stringResource(R.string.pref_screen_notes), + onClick = onClickNoteSettings, + ) + Preference( + name = stringResource(R.string.pref_screen_data_management), + onClick = onClickDataSettings, + ) + if (BuildConfig.DEBUG) { + Preference( + name = "Debug log reader", + onClick = { showOldLogReader(c) } + ) + + Preference( + name = "Use temp debug logger", + onClick = { useDebugLogger(!useDebugLogger, prefs, databaseLogger) }, + ) { + Switch( + checked = useDebugLogger, + onCheckedChange = { useDebugLogger(it, prefs, databaseLogger) } + ) + } + } + } if (BuildConfig.DEBUG) { PreferenceCategory("Debug") { Preference( @@ -216,6 +314,14 @@ fun SettingsScreen( } } + if (showExpertModeConfirmation) { + ConfirmationDialog( + onDismissRequest = { showExpertModeConfirmation = false }, + onConfirmed = { viewModel.setExpertMode(true) }, + text = { Text(stringResource(R.string.pref_expert_mode_message)) }, + confirmButtonText = stringResource(R.string.dialog_button_understood) + ) + } if (showDeleteCacheConfirmation) { ConfirmationDialog( onDismissRequest = { showDeleteCacheConfirmation = false }, @@ -225,7 +331,7 @@ fun SettingsScreen( Text(stringResource( R.string.delete_cache_dialog_message, (1.0 * REFRESH_DATA_AFTER / (24 * 60 * 60 * 1000)).format(locale, 1), - (1.0 * DELETE_OLD_DATA_AFTER / (24 * 60 * 60 * 1000)).format(locale, 1) + (1.0 * viewModel.prefs.getInt(Prefs.DATA_RETAIN_TIME, ApplicationConstants.DELETE_OLD_DATA_AFTER_DAYS)).format(locale, 1) )) }, confirmButtonText = stringResource(R.string.delete_confirmation) @@ -236,6 +342,7 @@ fun SettingsScreen( onDismissRequest = { showRestoreHiddenQuestsConfirmation = false }, onConfirmed = { viewModel.unhideQuests() }, title = { Text(stringResource(R.string.restore_dialog_message)) }, + text = { Text(stringResource(R.string.restore_dialog_hint)) }, confirmButtonText = stringResource(R.string.restore_confirmation) ) } @@ -310,6 +417,7 @@ private val Autosync.titleResId: Int get() = when (this) { } private val ResurveyIntervals.titleResId: Int get() = when (this) { + ResurveyIntervals.EVEN_LESS_OFTEN -> R.string.resurvey_intervals_even_less_often ResurveyIntervals.LESS_OFTEN -> R.string.resurvey_intervals_less_often ResurveyIntervals.DEFAULT -> R.string.resurvey_intervals_default ResurveyIntervals.MORE_OFTEN -> R.string.resurvey_intervals_more_often @@ -319,6 +427,7 @@ private val Theme.titleResId: Int get() = when (this) { Theme.LIGHT -> R.string.theme_light Theme.DARK -> R.string.theme_dark Theme.SYSTEM -> R.string.theme_system_default + Theme.DARK_CONTRAST -> R.string.theme_dark_contrast } private fun getLanguageDisplayName(languageTag: String): String? { @@ -326,3 +435,67 @@ private fun getLanguageDisplayName(languageTag: String): String? { val locale = Locale.forLanguageTag(languageTag) return locale.getDisplayName(locale) } + +private fun showOldLogReader(context: Context) { // todo: repeats lines... is it the logger, or the dialog? + var reversed = false + var filter = "" + var maxLines = 200 + val log = TextView(context) + var lines = TempLogger.getLog().take(maxLines) + log.setTextIsSelectable(true) + log.text = lines.joinToString("\n") + fun reloadText() { + val l = TempLogger.getLog() + lines = when { + filter.isNotBlank() && reversed -> l.asReversed().filter { line -> line.toString().contains(filter, true) } + filter.isNotBlank() -> l.filter { line -> line.toString().contains(filter, true) } + reversed -> l.asReversed() + else -> l + }.take(maxLines) + log.text = lines.joinToString("\n") + } + val scrollLog = ScrollView(context).apply { + addView(log) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + setOnScrollChangeListener { _, _, _, _, _ -> + if (log.bottom <= height + scrollY && lines.size >= maxLines) { + maxLines *= 2 + reloadText() + } + } + } + } + val reverseButton = Button(context) + reverseButton.setText(R.string.pref_read_reverse_button) + reverseButton.setOnClickListener { + reversed = !reversed + reloadText() + scrollLog.scrollY = 0 + } + val filterView = EditText(context).apply { + setHint(R.string.pref_read_filter_hint) + doAfterTextChanged { + filter = it.toString() + val previousCursorPosition = selectionStart + reloadText() + scrollLog.fullScroll(View.FOCUS_UP) + requestFocus() // focus is lost when scrolling it seems + setSelection(previousCursorPosition) + } + setDefaultDialogPadding() // not a dialog, but still suitable + } + val layout = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + layout.addView(LinearLayout(context).apply { + addView(reverseButton) + addView(filterView) + }) // put this on top, or layout will need more work to keep this visible + layout.addView(scrollLog) + val d = AlertDialog.Builder(context) + .setTitle(R.string.pref_read_log_title) + .setView(layout) // not using default padding to allow longer log lines (looks ugly, but is very convenient) + .setPositiveButton(R.string.close, null) + .create() + d.show() + // maximize dialog size, because log lines are long + d.window?.setLayout(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsViewModel.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsViewModel.kt index 64e1d6e3ad1..2839497307d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsViewModel.kt @@ -4,10 +4,12 @@ import android.content.res.Resources import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import com.russhwolf.settings.SettingsListener +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.Cleaner import de.westnordost.streetcomplete.data.osm.edits.EditType import de.westnordost.streetcomplete.data.overlays.OverlayRegistry +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestController import de.westnordost.streetcomplete.data.preferences.Autosync import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.data.preferences.ResurveyIntervals @@ -42,6 +44,8 @@ abstract class SettingsViewModel : ViewModel() { abstract val keepScreenOn: StateFlow abstract val showZoomButtons: StateFlow abstract val selectedLanguage: StateFlow + abstract val expertMode: StateFlow + abstract val prefs: Preferences abstract fun unhideQuests() @@ -49,6 +53,7 @@ abstract class SettingsViewModel : ViewModel() { abstract fun setResurveyIntervals(value: ResurveyIntervals) abstract fun setShowAllNotes(value: Boolean) + abstract fun setExpertMode(value: Boolean) abstract fun setAutosync(value: Autosync) abstract fun setTheme(value: Theme) abstract fun setKeepScreenOn(value: Boolean) @@ -60,7 +65,7 @@ data class QuestTypeCount(val total: Int, val enabled: Int) @Stable class SettingsViewModelImpl( - private val prefs: Preferences, + override val prefs: Preferences, private val resources: Resources, private val cleaner: Cleaner, private val hiddenQuestsController: QuestsHiddenController, @@ -107,6 +112,7 @@ class SettingsViewModelImpl( override val keepScreenOn = MutableStateFlow(prefs.keepScreenOn) override val showZoomButtons = MutableStateFlow(prefs.showZoomButtons) override val selectedLanguage = MutableStateFlow(prefs.language) + override val expertMode = MutableStateFlow(prefs.expertMode) private val listeners = mutableListOf() @@ -115,6 +121,8 @@ class SettingsViewModelImpl( editTypePresetsSource.addListener(editTypePresetsListener) hiddenQuestsController.addListener(hiddenQuestsListener) + if (prefs.getBoolean(Prefs.DYNAMIC_QUEST_CREATION, false)) + OsmQuestController.reloadQuestTypes() listeners += prefs.onResurveyIntervalsChanged { resurveyIntervals.value = it } listeners += prefs.onAutosyncChanged { autosync.value = it } listeners += prefs.onThemeChanged { theme.value = it } @@ -122,6 +130,7 @@ class SettingsViewModelImpl( listeners += prefs.onKeepScreenOnChanged { keepScreenOn.value = it } listeners += prefs.onShowZoomButtonsChanged { showZoomButtons.value = it } listeners += prefs.onLanguageChanged { selectedLanguage.value = it } + listeners += prefs.onExpertModeChanged { expertMode.value = it } updateQuestTypeCount() updateOverlayCount() @@ -151,6 +160,8 @@ class SettingsViewModelImpl( override fun setShowZoomButtons(value: Boolean) { prefs.showZoomButtons = value } override fun setSelectedLanguage(value: String?) { prefs.language = value } + override fun setExpertMode(value: Boolean) { prefs.expertMode = value } + override fun unhideQuests() { launch(IO) { hiddenQuestsController.unhideAll() diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/UiSettingsScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/UiSettingsScreen.kt new file mode 100644 index 00000000000..48a1355b276 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/UiSettingsScreen.kt @@ -0,0 +1,234 @@ +package de.westnordost.streetcomplete.screens.settings + +import android.annotation.SuppressLint +import android.content.Context +import android.text.InputType +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AppBarDefaults +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.ui.common.BackIcon +import de.westnordost.streetcomplete.ui.common.dialogs.TextInputDialog +import de.westnordost.streetcomplete.ui.common.settings.Preference +import de.westnordost.streetcomplete.ui.common.settings.SwitchPreference +import de.westnordost.streetcomplete.util.dialogs.setViewWithDefaultPadding +import org.koin.compose.koinInject + +@Composable +fun UiSettingsScreen( + onClickBack: () -> Unit, +) { + val ctx = LocalContext.current + val prefs: Preferences = koinInject() + var showMinLinesDialog by remember { mutableStateOf(false) } + var showRotateAngleDialog by remember { mutableStateOf(false) } + + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text(stringResource(R.string.pref_screen_ui)) }, + windowInsets = AppBarDefaults.topAppBarWindowInsets, + navigationIcon = { IconButton(onClick = onClickBack) { BackIcon() } }, + ) + Column( + Modifier + .verticalScroll(rememberScrollState()) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom + ) + ) + ) { + SwitchPreference( + name = stringResource(R.string.pref_show_quick_settings_title), + pref = Prefs.QUICK_SETTINGS, + default = false, + ) + SwitchPreference( + name = stringResource(R.string.pref_overlay_quick_selector_title), + pref = Prefs.OVERLAY_QUICK_SELECTOR, + default = false, + ) + SwitchPreference( + name = stringResource(R.string.pref_show_next_quest_title), + description = stringResource(R.string.pref_show_next_quest_summary), + pref = Prefs.SHOW_NEXT_QUEST_IMMEDIATELY, + default = false, + ) + Preference( + name = stringResource(R.string.pref_show_nearby_quests_title), + onClick = { nearbyQuestDialog(prefs, ctx) }, + description = stringResource(R.string.pref_show_nearby_quests_summary) + ) + SwitchPreference( + name = stringResource(R.string.pref_hide_button_title), + description = stringResource(R.string.pref_hide_button_summary), + pref = Prefs.SHOW_HIDE_BUTTON, + default = false, + ) + SwitchPreference( + name = stringResource(R.string.pref_create_node_show_keyboard_title), + pref = Prefs.CREATE_NODE_SHOW_KEYBOARD, + default = true, + ) + SwitchPreference( + name = stringResource(R.string.pref_select_first_edit_title), + description = stringResource(R.string.pref_select_first_edit_summary), + pref = Prefs.SELECT_FIRST_EDIT, + default = true, + ) + SwitchPreference( + name = stringResource(R.string.pref_search_more_languages_title), + description = stringResource(R.string.pref_search_more_languages_summary), + pref = Prefs.SEARCH_MORE_LANGUAGES, + default = false, + ) + Preference( + name = stringResource(R.string.pref_recent_answers_first_min_lines), + onClick = { showMinLinesDialog = true }, + description = stringResource(R.string.pref_recent_answers_first_min_lines_summary, prefs.getInt(Prefs.FAVS_FIRST_MIN_LINES, 1)) + ) + SwitchPreference( + name = stringResource(R.string.pref_disable_navigation_mode_title), + description = stringResource(R.string.pref_disable_navigation_mode_summary), + pref = Prefs.DISABLE_NAVIGATION_MODE, + default = false, + ) + SwitchPreference( + name = stringResource(R.string.pref_main_menu_grid), + pref = Prefs.MAIN_MENU_FULL_GRID, + default = false, + ) + SwitchPreference( + name = stringResource(R.string.pref_main_menu_switch_presets_title), + pref = Prefs.MAIN_MENU_SWITCH_PRESETS, + default = false, + ) + SwitchPreference( + name = stringResource(R.string.pref_caps_word_name_input), + pref = Prefs.CAPS_WORD_NAME_INPUT, + default = false, + ) + SwitchPreference( + name = stringResource(R.string.pref_volume_zoom_title), + description = stringResource(R.string.pref_volume_zoom_summary), + pref = Prefs.VOLUME_ZOOM, + default = false, + ) + SwitchPreference( + name = stringResource(R.string.pref_rotate_while_zooming_title), + pref = Prefs.ROTATE_WHILE_ZOOMING, + default = false, + ) + Preference( + name = stringResource(R.string.pref_rotate_angle_threshold_title), + onClick = { showRotateAngleDialog = true }, + ) + } + } + if (showMinLinesDialog) + TextInputDialog( + onDismissRequest = { showMinLinesDialog = false }, + onConfirmed = { prefs.putInt(Prefs.FAVS_FIRST_MIN_LINES, it.toIntOrNull() ?: 1) }, + text = prefs.getInt(Prefs.FAVS_FIRST_MIN_LINES, 1).toString(), + //title = { Text(stringResource(R.string.pref_recent_answers_first_min_lines)) }, + title = { Text(stringResource(R.string.pref_recent_answers_first_min_lines_message)) }, + keyboardType = KeyboardType.Number, + checkTextValid = { + val value = it.toIntOrNull() + value != null && value >= 0 + } + ) + if (showRotateAngleDialog) + TextInputDialog( + onDismissRequest = { showRotateAngleDialog = false }, + onConfirmed = { prefs.prefs.putFloat(Prefs.ROTATE_ANGLE_THRESHOLD, it.toFloatOrNull() ?: 1.5f) }, + text = prefs.getFloat(Prefs.ROTATE_ANGLE_THRESHOLD, 1.5f).toString(), + title = { Text(stringResource(R.string.pref_rotate_angle_threshold_title)) }, + //textInputLabel = { Text(stringResource(R.string.pref_search_more_languages_summary)) }, + keyboardType = KeyboardType.Decimal, + checkTextValid = { + val value = it.toFloatOrNull() + value != null && value >= 0 + } + ) +} + +// todo: composable +@SuppressLint("ResourceType") // for nearby quests... though it could probably be done in a nicer way +private fun nearbyQuestDialog(prefs: Preferences, context: Context) { + val builder = AlertDialog.Builder(context) + builder.setTitle(R.string.pref_show_nearby_quests_title) + val linearLayout = LinearLayout(context) + linearLayout.orientation = LinearLayout.VERTICAL + + val buttons = RadioGroup(context) + buttons.orientation = RadioGroup.VERTICAL + buttons.addView(RadioButton(context).apply { + setText(R.string.show_nearby_quests_disable) + id = 0 + }) + buttons.addView(RadioButton(context).apply { + setText(R.string.show_nearby_quests_visible) + id = 1 + }) + buttons.addView(RadioButton(context).apply { + setText(R.string.show_nearby_quests_all_types) + id = 2 + if (!prefs.getBoolean(Prefs.EXPERT_MODE, false)) isEnabled = false + }) + buttons.addView(RadioButton(context).apply { + setText(R.string.show_nearby_quests_even_hidden) + id = 3 + if (!prefs.getBoolean(Prefs.EXPERT_MODE, false)) isEnabled = false + }) + buttons.check(prefs.getInt(Prefs.SHOW_NEARBY_QUESTS, 0)) + buttons.setOnCheckedChangeListener { _, _ -> + if (buttons.checkedRadioButtonId in 0..3) + prefs.putInt(Prefs.SHOW_NEARBY_QUESTS, buttons.checkedRadioButtonId) + } + + val distanceText = TextView(context).apply { setText(R.string.show_nearby_quests_distance) } + + val distance = EditText(context).apply { + inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL + setText(prefs.getFloat(Prefs.SHOW_NEARBY_QUESTS_DISTANCE, 0.0f).toString()) + } + linearLayout.addView(buttons) + linearLayout.addView(distanceText) + linearLayout.addView(distance) + + builder.setViewWithDefaultPadding(linearLayout) + builder.setPositiveButton(android.R.string.ok) { _, _ -> + distance.text.toString().toFloatOrNull()?.let { + prefs.prefs.putFloat(Prefs.SHOW_NEARBY_QUESTS_DISTANCE, it.coerceAtLeast(0.0f).coerceAtMost(10.0f)) + } + } + builder.show() +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelection.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelection.kt index 6634a89fc82..8fdf437acc7 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelection.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelection.kt @@ -1,14 +1,22 @@ package de.westnordost.streetcomplete.screens.settings.quest_selection import androidx.compose.runtime.Immutable +import de.westnordost.streetcomplete.ApplicationConstants.EE_QUEST_OFFSET +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuestType +import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.data.quest.QuestType +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry @Immutable data class QuestSelection( val questType: QuestType, val selected: Boolean, val enabledInCurrentCountry: Boolean, + val prefs: Preferences, ) { - val isInteractionEnabled get() = questType !is OsmNoteQuestType + fun isInteractionEnabled(questTypeRegistry: QuestTypeRegistry) = prefs.getBoolean(Prefs.EXPERT_MODE, false) + // not sure how questTypeRegistry can be empty / not contain a quest initially coming from that repository + // but it can happen, so just don't crash, see https://github.com/Helium314/SCEE/issues/639 + || (questType !is OsmNoteQuestType && (questTypeRegistry.getOrdinalOf(questType) ?: EE_QUEST_OFFSET) < EE_QUEST_OFFSET) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelectionList.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelectionList.kt index 4b5d2166000..86b89b5e70a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelectionList.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelectionList.kt @@ -34,6 +34,7 @@ import de.westnordost.streetcomplete.quests.seating.AddSeating import de.westnordost.streetcomplete.quests.tactile_paving.AddTactilePavingBusStop import de.westnordost.streetcomplete.ui.common.dialogs.ConfirmationDialog import de.westnordost.streetcomplete.ui.theme.titleMedium +import org.koin.compose.koinInject import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -96,7 +97,7 @@ fun QuestSelectionList( ReorderableItem( state = dragDropState, key = item.questType.name, - enabled = item.isInteractionEnabled + enabled = item.isInteractionEnabled(koinInject()) ) { isDragging -> val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) val haptic = LocalHapticFeedback.current @@ -105,7 +106,7 @@ fun QuestSelectionList( elevation = elevation, modifier = Modifier .longPressDraggableHandle( - enabled = item.isInteractionEnabled, + enabled = item.isInteractionEnabled(koinInject()), onDragStarted = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) }, onDragStopped = ::onDragStopped, ) @@ -169,9 +170,9 @@ private fun QuestSelectionHeader(modifier: Modifier = Modifier) { private fun PreviewQuestSelectionList() { QuestSelectionList( items = listOf( - QuestSelection(OsmNoteQuestType, true, true), - QuestSelection(AddSeating(), false, true), - QuestSelection(AddTactilePavingBusStop(), true, false), + QuestSelection(OsmNoteQuestType, true, true, koinInject()), + QuestSelection(AddSeating(), false, true, koinInject()), + QuestSelection(AddTactilePavingBusStop(), true, false, koinInject()), ), displayCountry = "Atlantis", onSelect = { _, _ -> }, diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelectionRow.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelectionRow.kt index aeb71f16c4f..1b2e7143bbb 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelectionRow.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelectionRow.kt @@ -1,6 +1,8 @@ package de.westnordost.streetcomplete.screens.settings.quest_selection import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Checkbox import androidx.compose.material.ContentAlpha import androidx.compose.material.Icon @@ -26,13 +29,19 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry +import de.westnordost.streetcomplete.quests.questPrefix import de.westnordost.streetcomplete.quests.surface.AddRoadSurface +import org.koin.compose.koinInject /** Single item in the quest selection list. Shows icon + title, whether it is enabled and whether * it is disabled by default / disabled in the country one is in */ @@ -44,12 +53,14 @@ fun QuestSelectionRow( modifier: Modifier = Modifier ) { val alpha = if (!item.selected) ContentAlpha.disabled else ContentAlpha.high + val questTypeRegistry: QuestTypeRegistry = koinInject() + val c = LocalContext.current // wtf is the point of this? Row( modifier = modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically, ) { - if (item.isInteractionEnabled) { + if (item.isInteractionEnabled(questTypeRegistry)) { Icon(painterResource(R.drawable.ic_drag_vertical), "Reorder") } else { Spacer(Modifier.size(24.dp)) @@ -75,19 +86,47 @@ fun QuestSelectionRow( DisabledHint(stringResource(R.string.questList_disabled_by_default)) } } + if (item.questType.hasQuestSettings) { + var showQuestSettings by remember { mutableStateOf(false) } + Image( + painter = painterResource(R.drawable.ic_settings_48dp), + contentDescription = null, + modifier = Modifier + .padding(start = 16.dp) + .size(48.dp) + .clickable { showQuestSettings = true } + .background( // looks ugly and does not appear right after dismissing dialog, but whatever... I don't care any more. + color = nowItHasToBeSomewhereElse(item), + shape = CircleShape + ) + .alpha(alpha), + ) + if (showQuestSettings) + item.questType.QuestSettings(c) { showQuestSettings = false } + } Box( - modifier = Modifier.width(64.dp).fillMaxHeight(), + modifier = Modifier + .width(64.dp) + .fillMaxHeight(), contentAlignment = Alignment.Center ) { Checkbox( checked = item.selected, onCheckedChange = onToggleSelection, - enabled = item.isInteractionEnabled + enabled = item.isInteractionEnabled(questTypeRegistry) ) } } } +@Composable +private fun nowItHasToBeSomewhereElse(item: QuestSelection): Color { + val start = questPrefix(item.prefs) + "qs_" + item.questType.name + "_" + return if (item.prefs.prefs.keys.any { it.startsWith(start) }) + colorResource(id = R.color.accent) + else Color.Transparent +} + @Composable private fun DisabledHint(text: String) { Text( @@ -104,7 +143,7 @@ private fun QuestSelectionRowPreview() { var selected by remember { mutableStateOf(true) } QuestSelectionRow( - item = QuestSelection(AddRoadSurface(), selected, false), + item = QuestSelection(AddRoadSurface(), selected, false, koinInject()), onToggleSelection = { selected = !selected }, displayCountry = "Atlantis", ) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelectionViewModel.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelectionViewModel.kt index d71d7a9bf4f..6317ee44d3a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelectionViewModel.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/quest_selection/QuestSelectionViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.westnordost.countryboundaries.CountryBoundaries +import de.westnordost.streetcomplete.ApplicationConstants import de.westnordost.streetcomplete.data.osm.edits.EditType import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType import de.westnordost.streetcomplete.data.preferences.Preferences @@ -38,6 +39,7 @@ abstract class QuestSelectionViewModel : ViewModel() { abstract val filteredQuests: StateFlow> abstract val currentCountry: String? abstract val selectedEditTypePresetName: StateFlow + abstract var onlySceeQuests: Boolean abstract fun select(questType: QuestType, selected: Boolean) abstract fun order(questType: QuestType, toAfter: QuestType) @@ -54,7 +56,7 @@ class QuestSelectionViewModelImpl( private val visibleEditTypeController: VisibleEditTypeController, private val questTypeOrderController: QuestTypeOrderController, countryBoundaries: Lazy, - prefs: Preferences, + private val prefs: Preferences, ) : QuestSelectionViewModel() { override val searchText = MutableStateFlow("") @@ -77,6 +79,13 @@ class QuestSelectionViewModelImpl( override fun onVisibilitiesChanged() { initQuests() } } + override var onlySceeQuests: Boolean = false + set(value) { + if (field == value) return + field = value + initQuests() + } + private val questTypeOrderListener = object : QuestTypeOrderSource.Listener { override fun onQuestTypeOrderAdded(item: QuestType, toAfter: QuestType) { quests.update { quests -> @@ -179,13 +188,10 @@ class QuestSelectionViewModelImpl( launch(IO) { val sortedQuestTypes = questTypeRegistry.toMutableList() questTypeOrderController.sort(sortedQuestTypes) - quests.value = sortedQuestTypes - .map { QuestSelection( - questType = it, - selected = visibleEditTypeController.isVisible(it), - enabledInCurrentCountry = isQuestEnabledInCurrentCountry(it) - ) } - .toMutableList() + quests.value = sortedQuestTypes.mapNotNull { + if (onlySceeQuests && questTypeRegistry.getOrdinalOf(it)!! < ApplicationConstants.EE_QUEST_OFFSET) null + else QuestSelection(it, visibleEditTypeController.isVisible(it), enabledInCurrentCountry = isQuestEnabledInCurrentCountry(it), prefs) + }.toMutableList() } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/questselection/QuestPresetsAdapter.kt.old b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/questselection/QuestPresetsAdapter.kt.old new file mode 100644 index 00000000000..43d6108eebd --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/questselection/QuestPresetsAdapter.kt.old @@ -0,0 +1,163 @@ +package de.westnordost.streetcomplete.screens.settings.questselection + +import android.content.Context +import android.text.InputFilter +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.PopupMenu +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.russhwolf.settings.ObservableSettings +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.StreetCompleteApplication +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestController +import de.westnordost.streetcomplete.databinding.RowQuestPresetBinding +import de.westnordost.streetcomplete.view.dialogs.EditTextDialog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** Adapter for the list in which the user can select which preset of quest selections he wants to + * use. */ +class QuestPresetsAdapter( + private val context: Context, + private val viewModel: QuestPresetsViewModel, + private val prefs: ObservableSettings, +) : RecyclerView.Adapter(), DefaultLifecycleObserver { + + var presets: List = listOf() + set(value) { + val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize() = field.size + override fun getNewListSize() = value.size + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + field[oldItemPosition].id == value[newItemPosition].id + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + field[oldItemPosition] == value[newItemPosition] + }) + field = value.toList() + diff.dispatchUpdatesTo(this) + } + + private val viewLifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + override fun onDestroy(owner: LifecycleOwner) { + viewLifecycleScope.cancel() + } + + override fun getItemCount(): Int = presets.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QuestPresetViewHolder { + val inflater = LayoutInflater.from(parent.context) + return QuestPresetViewHolder(RowQuestPresetBinding.inflate(inflater, parent, false)) + } + + override fun onBindViewHolder(holder: QuestPresetViewHolder, position: Int) { + holder.onBind(presets[position]) + } + + inner class QuestPresetViewHolder(private val binding: RowQuestPresetBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun onBind(with: QuestPresetSelection) { + binding.presetTitleText.text = with.nonEmptyName + binding.selectionRadioButton.setOnCheckedChangeListener(null) + binding.selectionRadioButton.isChecked = with.selected + binding.selectionRadioButton.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) viewModel.select(with.id) + } + binding.menuButton.isEnabled = true + binding.menuButton.setOnClickListener { onClickMenuButton(with) } + } + + private fun onClickMenuButton(preset: QuestPresetSelection) { + if (prefs.getBoolean(Prefs.QUEST_SETTINGS_PER_PRESET, false)) + OsmQuestController.reloadQuestTypes() + val popup = PopupMenu(itemView.context, binding.menuButton) + popup.setForceShowIcon(true) + if (preset.id != 0L) { + val renameItem = popup.menu.add(R.string.quest_presets_rename) + renameItem.setIcon(R.drawable.ic_edit_24dp) + renameItem.setOnMenuItemClickListener { onClickRenamePreset(preset); true } + } + + val duplicateItem = popup.menu.add(R.string.quest_presets_duplicate) + duplicateItem.setIcon(R.drawable.ic_content_copy_24dp) + duplicateItem.setOnMenuItemClickListener { onClickDuplicatePreset(preset); true } + + val shareItem = popup.menu.add(R.string.quest_presets_share) + shareItem.setIcon(R.drawable.ic_share_24dp) + shareItem.setOnMenuItemClickListener { onClickSharePreset(preset); true } + + if (preset.id != 0L) { + val deleteItem = popup.menu.add(R.string.quest_presets_delete) + deleteItem.setIcon(R.drawable.ic_delete_24dp) + deleteItem.setOnMenuItemClickListener { onClickDeleteQuestPreset(preset); true } + } + + popup.show() + } + + private fun onClickRenamePreset(preset: QuestPresetSelection) { + val ctx = itemView.context + val dialog = EditTextDialog(ctx, + title = ctx.getString(R.string.quest_presets_rename), + text = preset.nonEmptyName, + hint = ctx.getString(R.string.quest_presets_preset_name), + callback = { name -> viewModel.rename(preset.id, name) } + ) + dialog.editText.filters = arrayOf(InputFilter.LengthFilter(60)) + dialog.show() + } + + private fun onClickDuplicatePreset(preset: QuestPresetSelection) { + val ctx = itemView.context + val dialog = EditTextDialog(ctx, + title = ctx.getString(R.string.quest_presets_duplicate), + text = preset.nonEmptyName, + hint = ctx.getString(R.string.quest_presets_preset_name), + callback = { name -> viewModel.duplicate(preset.id, name) } + ) + dialog.editText.filters = arrayOf(InputFilter.LengthFilter(60)) + dialog.show() + } + + private fun onClickSharePreset(preset: QuestPresetSelection) { + viewLifecycleScope.launch { + val url = viewModel.createUrlConfig(preset.id) + + val copyFromQuestSettings = StreetCompleteApplication.preferences.all.filterKeys { it.startsWith("${preset.id}_qs_") } + prefs.apply { + copyFromQuestSettings.forEach { (key, value) -> + val newKey = key.replace("${preset.id}_qs_", "${preset.id}_qs_") + when (value) { + is Boolean -> putBoolean(newKey, value) + is Int -> putInt(newKey, value) + is String -> putString(newKey, value) + is Long -> putLong(newKey, value) + is Float -> putFloat(newKey, value) + } + } + } + UrlConfigQRCodeDialog(context, url).show() + } + } + + private fun onClickDeleteQuestPreset(preset: QuestPresetSelection) { + AlertDialog.Builder(itemView.context) + .setMessage(itemView.context.getString(R.string.quest_presets_delete_message, preset.nonEmptyName)) + .setPositiveButton(R.string.delete_confirmation) { _, _ -> viewModel.delete(preset.id) } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + } + + private val QuestPresetSelection.nonEmptyName: String get() = + if (name.isNotEmpty()) name else context.getString(R.string.quest_presets_default_name) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/questselection/QuestPresetsFragment.kt.old b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/questselection/QuestPresetsFragment.kt.old new file mode 100644 index 00000000000..6ee101ea031 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/questselection/QuestPresetsFragment.kt.old @@ -0,0 +1,49 @@ +package de.westnordost.streetcomplete.screens.settings.questselection + +import android.os.Bundle +import android.text.InputFilter +import android.view.View +import com.russhwolf.settings.ObservableSettings +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.FragmentQuestPresetsBinding +import de.westnordost.streetcomplete.screens.HasTitle +import de.westnordost.streetcomplete.screens.TwoPaneDetailFragment +import de.westnordost.streetcomplete.util.ktx.observe +import de.westnordost.streetcomplete.util.viewBinding +import de.westnordost.streetcomplete.view.dialogs.EditTextDialog +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel + +/** Shows a screen in which the user can select which preset of quest selections he wants to + * use. */ +class QuestPresetsFragment : TwoPaneDetailFragment(R.layout.fragment_quest_presets), HasTitle { + + private val prefs: ObservableSettings by inject() + private val binding by viewBinding(FragmentQuestPresetsBinding::bind) + private val viewModel by viewModel() + + override val title: String get() = getString(R.string.action_manage_presets) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val adapter = QuestPresetsAdapter(requireContext(), viewModel, prefs) + lifecycle.addObserver(adapter) + binding.questPresetsList.adapter = adapter + binding.addPresetButton.setOnClickListener { onClickAddPreset() } + + observe(viewModel.presets) { presets -> + adapter.presets = presets + } + } + + private fun onClickAddPreset() { + val ctx = context ?: return + val dialog = EditTextDialog(ctx, + title = getString(R.string.quest_presets_preset_add), + hint = getString(R.string.quest_presets_preset_name), + callback = { name -> viewModel.add(name) } + ) + dialog.editText.filters = arrayOf(InputFilter.LengthFilter(60)) + dialog.show() + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/questselection/QuestSelectionAdapter.kt.old b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/questselection/QuestSelectionAdapter.kt.old new file mode 100644 index 00000000000..d80fe8e1fd5 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/questselection/QuestSelectionAdapter.kt.old @@ -0,0 +1,243 @@ +package de.westnordost.streetcomplete.screens.settings.questselection + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.drawable.GradientDrawable +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG +import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_IDLE +import androidx.recyclerview.widget.ItemTouchHelper.DOWN +import androidx.recyclerview.widget.ItemTouchHelper.UP +import androidx.recyclerview.widget.RecyclerView +import com.russhwolf.settings.ObservableSettings +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry +import de.westnordost.streetcomplete.databinding.RowQuestSelectionBinding +import de.westnordost.streetcomplete.quests.questPrefix +import de.westnordost.streetcomplete.screens.settings.genericQuestTitle +import de.westnordost.streetcomplete.util.ktx.toast +import java.util.Collections +import java.util.Locale + +/** Adapter for the list in which the user can enable and disable quests as well as re-order them */ +class QuestSelectionAdapter( + private val context: Context, + private val viewModel: QuestSelectionViewModel, + private val questTypeRegistry: QuestTypeRegistry, + private val prefs: ObservableSettings, +) : RecyclerView.Adapter() { + + private val itemTouchHelper by lazy { ItemTouchHelper(TouchHelperCallback()) } + + var quests: List = listOf() + set(value) { + val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize() = field.size + override fun getNewListSize() = value.size + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + field[oldItemPosition].questType == value[newItemPosition].questType + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + field[oldItemPosition].selected == value[newItemPosition].selected + }) + field = value.toList() + diff.dispatchUpdatesTo(this) + } + + var onlySceeQuests: Boolean = false + set(value) { + if (field == value) return + field = value + viewModel.onlySceeQuests = value + } + + override fun getItemCount(): Int = quests.size + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + itemTouchHelper.attachToRecyclerView(recyclerView) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QuestSelectionViewHolder { + val inflater = LayoutInflater.from(parent.context) + return QuestSelectionViewHolder(RowQuestSelectionBinding.inflate(inflater, parent, false)) + } + + override fun onBindViewHolder(holder: QuestSelectionViewHolder, position: Int) { + holder.onBind(quests[position]) + } + + /** Contains the logic for drag and drop (for reordering) */ + private inner class TouchHelperCallback : ItemTouchHelper.Callback() { + private var draggedFrom = -1 + private var draggedTo = -1 + + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + val qv = (viewHolder as QuestSelectionViewHolder).item ?: return 0 + if (!qv.isInteractionEnabled(questTypeRegistry)) return 0 + + return makeFlag(ACTION_STATE_IDLE, UP or DOWN) or + makeFlag(ACTION_STATE_DRAG, UP or DOWN) + } + + override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { + val from = viewHolder.bindingAdapterPosition + val to = target.bindingAdapterPosition + Collections.swap(quests, from, to) + notifyItemMoved(from, to) + return true + } + + override fun canDropOver(recyclerView: RecyclerView, current: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { + val qv = (target as QuestSelectionViewHolder).item ?: return false + return qv.isInteractionEnabled(questTypeRegistry) + } + + override fun onMoved(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, fromPos: Int, target: RecyclerView.ViewHolder, toPos: Int, x: Int, y: Int) { + super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y) + if (draggedFrom == -1) draggedFrom = fromPos + draggedTo = toPos + } + + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + super.onSelectedChanged(viewHolder, actionState) + if (actionState == ACTION_STATE_IDLE) { + onDropped() + } + } + + private fun onDropped() { + /* since we modify the quest list during move (in onMove) for the animation, the quest + * type we dragged is now already at the position we want it to be. */ + if (draggedTo != draggedFrom && draggedTo > 0) { + val item = quests[draggedTo].questType + val toAfter = quests[draggedTo - 1].questType + + viewModel.orderQuest(item, toAfter) + } + draggedFrom = -1 + draggedTo = -1 + } + + override fun isItemViewSwipeEnabled() = false + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} + } + + /** View Holder for a single quest type */ + inner class QuestSelectionViewHolder(private val binding: RowQuestSelectionBinding) : + RecyclerView.ViewHolder(binding.root) { + + var item: QuestSelection? = null + + private val questSettingsInUseBackground by lazy { GradientDrawable().apply { + gradientType = GradientDrawable.RADIAL_GRADIENT + gradientRadius = 25f + val bgColor = ContextCompat.getColor(context, R.color.background) + val accentColor = ContextCompat.getColor(context, R.color.accent) + colors = arrayOf(accentColor, bgColor).toIntArray() + } } + + private val questPrefix by lazy { questPrefix(prefs) + "qs_" } + private val questTypesWithUsedSettings by lazy { + val set = hashSetOf() + prefs.keys.forEach { + if (it.startsWith(questPrefix)) + set.add(it.substringAfter(questPrefix).substringBefore("_")) + } + set + } + + @SuppressLint("ClickableViewAccessibility") + fun onBind(item: QuestSelection) { + this.item = item + binding.questIcon.setImageResource(item.questType.icon) + binding.questTitle.text = genericQuestTitle(binding.questTitle.resources, item.questType) + + binding.visibilityCheckBox.isEnabled = item.isInteractionEnabled(questTypeRegistry) + binding.dragHandle.isInvisible = !item.isInteractionEnabled(questTypeRegistry) + itemView.setBackgroundResource(if (item.isInteractionEnabled(questTypeRegistry)) R.color.background else R.color.greyed_out) + + if (!item.selected) { + binding.questIcon.setColorFilter(ContextCompat.getColor(itemView.context, R.color.greyed_out)) + } else { + binding.questIcon.clearColorFilter() + } + binding.visibilityCheckBox.isChecked = item.selected + binding.questTitle.isEnabled = item.selected + + val isEnabledInCurrentCountry = viewModel.isQuestEnabledInCurrentCountry(item.questType) + binding.disabledText.isGone = isEnabledInCurrentCountry + if (!isEnabledInCurrentCountry) { + val country = viewModel.currentCountry?.let { Locale("", it).displayCountry } ?: "Atlantis" + binding.disabledText.text = binding.disabledText.resources.getString( + R.string.questList_disabled_in_country, country + ) + } + + binding.visibilityCheckBox.setOnClickListener { + if (!item.selected && item.questType.defaultDisabledMessage != 0) { + AlertDialog.Builder(context) + .setTitle(R.string.enable_quest_confirmation_title) + .setMessage(item.questType.defaultDisabledMessage) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.selectQuest(item.questType, true) + setBackground(item) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> binding.visibilityCheckBox.isChecked = false } + .setOnCancelListener { binding.visibilityCheckBox.isChecked = false } + .show() + } else { + viewModel.selectQuest(item.questType, !item.selected) + setBackground(item) + } + } + + binding.dragHandle.setOnTouchListener { v, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> itemTouchHelper.startDrag(this) + MotionEvent.ACTION_UP -> v.performClick() + } + true + } + + if (prefs.getBoolean(Prefs.EXPERT_MODE, false) && item.questType.hasQuestSettings) { + binding.questSettings.isVisible = true + binding.questSettings.setOnClickListener { + val settings = item.questType.getQuestSettingsDialog(it.context) + if (!prefs.getBoolean(Prefs.DYNAMIC_QUEST_CREATION, false)) + settings?.setOnDismissListener { + context.toast(R.string.quest_settings_per_preset_rescan, Toast.LENGTH_LONG) + synchronized(questSettingsInUseBackground) { + if (prefs.keys.any { it.startsWith(questPrefix + item.questType.name) }) + questTypesWithUsedSettings.add(item.questType.name) + else questTypesWithUsedSettings.remove(item.questType.name) + setBackground(item) + } + } + settings?.show() + } + setBackground(item) + } else + binding.questSettings.isGone = true + } + + private fun setBackground(item: QuestSelection) { + synchronized(questSettingsInUseBackground) { + if (item.questType.name in questTypesWithUsedSettings) + binding.questSettings.background = questSettingsInUseBackground + else binding.questSettings.setBackgroundColor(ContextCompat.getColor(context, R.color.background)) + } + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/questselection/QuestSelectionFragment.kt.old b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/questselection/QuestSelectionFragment.kt.old new file mode 100644 index 00000000000..436047a0e55 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/questselection/QuestSelectionFragment.kt.old @@ -0,0 +1,147 @@ +package de.westnordost.streetcomplete.screens.settings.questselection + +import android.content.Context +import android.content.res.Configuration +import android.os.Bundle +import android.view.Menu +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.SearchView +import androidx.appcompat.widget.Toolbar +import androidx.core.view.isInvisible +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.russhwolf.settings.ObservableSettings +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.quest.QuestType +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry +import de.westnordost.streetcomplete.databinding.FragmentQuestSelectionBinding +import de.westnordost.streetcomplete.screens.HasTitle +import de.westnordost.streetcomplete.screens.TwoPaneDetailFragment +import de.westnordost.streetcomplete.screens.settings.genericQuestTitle +import de.westnordost.streetcomplete.util.ktx.containsAll +import de.westnordost.streetcomplete.util.ktx.observe +import de.westnordost.streetcomplete.util.viewBinding +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import java.util.Locale + +/** Shows a screen in which the user can enable and disable quests as well as re-order them */ +class QuestSelectionFragment : TwoPaneDetailFragment(R.layout.fragment_quest_selection), HasTitle { + + private val binding by viewBinding(FragmentQuestSelectionBinding::bind) + private val viewModel by viewModel() + private val questTypeRegistry: QuestTypeRegistry by inject() + private val prefs: ObservableSettings by inject() + + private lateinit var questSelectionAdapter: QuestSelectionAdapter + + override val title: String get() = getString(R.string.pref_title_quests2) + + override val subtitle: String get() = + getString( + R.string.pref_subtitle_quests_preset_name, + viewModel.selectedQuestPresetName ?: getString(R.string.quest_presets_default_name) + ) + + private val filter: String get() = + (binding.toolbar.root.menu.findItem(R.id.action_search).actionView as SearchView) + .query.trim().toString() + + private val englishResources by lazy { + val conf = Configuration(resources.configuration) + conf.setLocale(Locale.ENGLISH) + val localizedContext = requireContext().createConfigurationContext(conf) + localizedContext.resources + } + + override fun onAttach(context: Context) { + super.onAttach(context) + questSelectionAdapter = QuestSelectionAdapter(requireContext(), viewModel, questTypeRegistry, prefs) + questSelectionAdapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + createOptionsMenu(binding.toolbar.root) + + binding.questSelectionList.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + binding.questSelectionList.layoutManager = LinearLayoutManager(context) + binding.questSelectionList.adapter = questSelectionAdapter + + observe(viewModel.quests) { quests -> + questSelectionAdapter.quests = filterQuests(quests, filter) + updateDisplayedQuestCount() + } + } + + private fun createOptionsMenu(toolbar: Toolbar) { + toolbar.inflateMenu(R.menu.menu_quest_selection) + if (prefs.getBoolean(Prefs.EXPERT_MODE, false)) + toolbar.menu.add(Menu.NONE, 3, 3, R.string.action_scee_quests) + + val searchView = toolbar.menu.findItem(R.id.action_search).actionView as SearchView + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean = false + override fun onQueryTextChange(newText: String?): Boolean { + questSelectionAdapter.quests = filterQuests(viewModel.quests.value, newText) + updateDisplayedQuestCount() + return false + } + }) + + toolbar.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_reset -> { + AlertDialog.Builder(requireContext()) + .setMessage(R.string.pref_quests_reset) + .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.resetQuestSelectionsAndOrder() } + .setNegativeButton(android.R.string.cancel, null) + .show() + true + } + R.id.action_deselect_all -> { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.pref_quests_deselect_all) + .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.unselectAllQuests() } + .setNegativeButton(android.R.string.cancel, null) + .show() + true + } + 3 -> { + questSelectionAdapter.onlySceeQuests = !questSelectionAdapter.onlySceeQuests + if (questSelectionAdapter.onlySceeQuests){ + toolbar.menu.findItem(3).setTitle(R.string.action_all_quests) + } else { + toolbar.menu.findItem(3).setTitle(R.string.action_scee_quests) + } + + true + } + else -> false + } + } + } + + private fun updateDisplayedQuestCount() { + val isEmpty = questSelectionAdapter.itemCount == 0 + binding.tableHeader.isInvisible = isEmpty + binding.emptyText.isInvisible = !isEmpty + } + + private fun filterQuests(quests: List, filter: String?): List { + val words = filter.orEmpty().trim().lowercase() + return if (words.isEmpty()) { + quests + } else { + quests.filter { questTypeMatchesSearchWords(it.questType, words.split(' ')) } + } + } + + private fun questTypeMatchesSearchWords(questType: QuestType, words: List) = + genericQuestTitle(resources, questType).lowercase().containsAll(words) || + genericQuestTitle(englishResources, questType).lowercase().containsAll(words) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/IntroTutorialScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/IntroTutorialScreen.kt index 11c3e59050f..26195a0a2d3 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/IntroTutorialScreen.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/IntroTutorialScreen.kt @@ -47,7 +47,7 @@ fun IntroTutorialScreen( dismissOnBackPress: Boolean = false, ) { TutorialScreen( - pageCount = 4, + pageCount = 5, onDismissRequest = onDismissRequest, onFinished = onFinished, onPageChanged = { page -> @@ -69,6 +69,7 @@ fun IntroTutorialScreen( 1 -> IntroTutorialStep1Text() 2 -> IntroTutorialStep2Text() 3 -> IntroTutorialStep3Text() + 4 -> IntroTutorialStep4Text() } } } @@ -248,6 +249,14 @@ private fun IntroTutorialStep3Text() { ) } +@Composable +private fun IntroTutorialStep4Text() { + Text( + text = stringResource(R.string.tutorial_info_fork_message), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + ) +} @Preview(device = Devices.NEXUS_5) // darn small device @PreviewScreenSizes @Composable diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/common/ActionIcons.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/common/ActionIcons.kt index 780bec37835..d961f438966 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/ui/common/ActionIcons.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/common/ActionIcons.kt @@ -1,7 +1,10 @@ package de.westnordost.streetcomplete.ui.common +import androidx.compose.foundation.magnifier import androidx.compose.material.Icon import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import de.westnordost.streetcomplete.R @@ -91,6 +94,11 @@ fun StopRecordingIcon() { Icon(painterResource(R.drawable.ic_stop_recording_24dp), stringResource(R.string.map_btn_stop_track)) } +@Composable +fun QuickSettingsIcon() { + Icon(painterResource(R.drawable.ic_settings_48dp), stringResource(R.string.action_settings)) +} + @Composable fun LargeCreateIcon() { Icon(painterResource(R.drawable.ic_crosshair_32dp), stringResource(R.string.action_create_new_poi)) diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/SimpleListPickerDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/SimpleListPickerDialog.kt index 79f7ece6dd4..83ffe384a4f 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/SimpleListPickerDialog.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/SimpleListPickerDialog.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -50,6 +51,7 @@ fun SimpleListPickerDialog( title: (@Composable () -> Unit)? = null, selectedItem: T? = null, getItemName: (@Composable (T) -> String) = { it.toString() }, + showButtons: Boolean = true, width: Dp? = null, shape: Shape = MaterialTheme.shapes.medium, backgroundColor: Color = MaterialTheme.colors.surface, @@ -103,16 +105,18 @@ fun SimpleListPickerDialog( modifier = Modifier .clickable { select(item) } .padding(horizontal = 24.dp) + .heightIn(min = 40.dp) ) { Text( text = getItemName(item), style = MaterialTheme.typography.body1, modifier = Modifier.weight(1f), ) - RadioButton( - selected = selected == item, - onClick = { select(item) } - ) + if (showButtons) + RadioButton( + selected = selected == item, + onClick = { select(item) } + ) } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/TextInputDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/TextInputDialog.kt index c16e2aea367..f3b99851584 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/TextInputDialog.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/TextInputDialog.kt @@ -1,6 +1,7 @@ package de.westnordost.streetcomplete.ui.common.dialogs import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.AlertDialog import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField @@ -20,6 +21,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.window.DialogProperties @@ -36,6 +38,9 @@ fun TextInputDialog( backgroundColor: Color = MaterialTheme.colors.surface, contentColor: Color = contentColorFor(backgroundColor), properties: DialogProperties = DialogProperties(), + keyboardType: KeyboardType = KeyboardType.Unspecified, + singleLine: Boolean = true, + checkTextValid: (text: String) -> Boolean = { it.isNotBlank() } ) { val focusRequester = remember { FocusRequester() } @@ -49,7 +54,7 @@ fun TextInputDialog( onDismissRequest = onDismissRequest, confirmButton = { TextButton( - enabled = value.text.isNotBlank(), + enabled = checkTextValid(value.text), onClick = { onDismissRequest(); onConfirmed(value.text) } ) { Text(stringResource(android.R.string.ok)) @@ -66,7 +71,8 @@ fun TextInputDialog( onValueChange = { value = it }, modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), label = textInputLabel, - singleLine = true + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + singleLine = singleLine ) }, shape = shape, diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/common/settings/SwitchPreference.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/common/settings/SwitchPreference.kt new file mode 100644 index 00000000000..b31d19aba10 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/common/settings/SwitchPreference.kt @@ -0,0 +1,40 @@ +package de.westnordost.streetcomplete.ui.common.settings + +import androidx.compose.material.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import de.westnordost.streetcomplete.data.preferences.Preferences +import org.koin.compose.koinInject + +@Composable +fun SwitchPreference( + name: String, + modifier: Modifier = Modifier, + pref: String, + default: Boolean, + description: String? = null, + onCheckedChange: (Boolean) -> Unit = { }, +) { + val prefs: Preferences = koinInject() + var value by remember { mutableStateOf(prefs.getBoolean(pref, default)) } + fun switched(newValue: Boolean) { + value = newValue + prefs.putBoolean(pref, newValue) + onCheckedChange(newValue) + } + Preference( + name = name, + onClick = { switched(!value) }, + modifier = modifier, + description = description + ) { + Switch( + checked = value, + onCheckedChange = { switched(it) } + ) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/AccessManagerDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/util/AccessManagerDialog.kt new file mode 100644 index 00000000000..1657688e63f --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/util/AccessManagerDialog.kt @@ -0,0 +1,159 @@ +package de.westnordost.streetcomplete.util + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.appcompat.app.AlertDialog +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChangesBuilder +import de.westnordost.streetcomplete.databinding.DialogAccessManagerBinding +import de.westnordost.streetcomplete.databinding.RowAccessBinding +import de.westnordost.streetcomplete.util.dialogs.showAddConditionalDialog +import de.westnordost.streetcomplete.util.ktx.dpToPx + +class AccessManagerDialog(context: Context, tags: Map, onClickOk: (StringMapChangesBuilder) -> Unit) : AlertDialog(context) { + private val binding = DialogAccessManagerBinding.inflate(LayoutInflater.from(context)) + private val originalAccessTags = tags.filterKeys { key -> accessKeys.any { it == key || key.startsWith("$it:") } } + private val newAccessTags = LinkedHashMap(originalAccessTags) + + init { + binding.addConditionalButton.setOnClickListener { + showAddConditionalDialog(context, accessKeys.toList(), listOf("yes", "no", "delivery", "destination"), null) { k, v -> + newAccessTags[k] = v + createAccessTagViews() + } + } + binding.addButton.setOnClickListener { showAddAccessDialog(context) } + createAccessTagViews() + setMessage(context.getString(R.string.access_manager_message)) + setView(binding.root) + setButton(BUTTON_NEGATIVE, context.getString(android.R.string.cancel)) { _, _ -> } + setButton(BUTTON_POSITIVE, context.getString(android.R.string.ok)) { _, _ -> + val builder = StringMapChangesBuilder(tags) + newAccessTags.forEach { + if (originalAccessTags[it.key] != it.value) + builder[it.key] = it.value + } + originalAccessTags.keys.forEach { + if (it !in newAccessTags) + builder.remove(it) + } + onClickOk(builder) + } + } + + private fun updateOkButton() { + getButton(BUTTON_POSITIVE).isEnabled = originalAccessTags != newAccessTags + } + + private fun createAccessTagViews() { + binding.accessTags.removeAllViews() + newAccessTags.forEach { binding.accessTags.addView(accessView(it.key, it.value)) } + } + + private fun accessView(key: String, value: String): View { + val view = RowAccessBinding.inflate(LayoutInflater.from(context)) + view.keyText.text = key + val values = if (value in accessValues) accessValues else (arrayOf(value) + accessValues) + view.valueSpinner.adapter = ArrayAdapter(binding.root.context, android.R.layout.simple_dropdown_item_1line, values) + view.valueSpinner.setSelection(accessValues.indexOf(value)) + view.valueSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + val selected = values[position] + newAccessTags[key] = selected + updateOkButton() + } + override fun onNothingSelected(p0: AdapterView<*>?) { } // just do nothing? or remove tag? + } + view.deleteButton.setOnClickListener { + newAccessTags.remove(key) + createAccessTagViews() + } + view.root.setPadding(0, context.resources.dpToPx(4).toInt(), 0, context.resources.dpToPx(4).toInt()) + return view.root + } + + // maybe reduce height, but need a simple solution... + private fun showAddAccessDialog(context: Context) { + Builder(context) + .setTitle("key") + .setSingleChoiceItems(accessKeys, -1) { di, i -> + Builder(context) + .setTitle("value") + .setSingleChoiceItems(accessValues, -1) { di2, j -> + newAccessTags[accessKeys[i]] = accessValues[j] + createAccessTagViews() + di2.dismiss() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + di.dismiss() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } +} + +val accessKeys = arrayOf( // sorted by number of uses + "access", // 18m + "foot", // 7m + "bicycle", // 7m + "bus", // 3.5m + "motor_vehicle", // 2m + "horse", // 1.6m + "hgv", // 790k + "motorcar", // 590k + "motorcycle", // 580k + "vehicle", // 350k + "moped", // 235k + "mofa", // 200k + "golf_cart", // 158k + "psv", // 115k + "hazmat", // 87k + "dog", // 80k + "bdouble", // 60k + "ski", // 60k + "goods", // 41k + "taxi", // 23k + "carriage", // 20k + "hov", // 20k + "disabled", // 13.5k + "tourist_bus", // 13k + "atv", // 12k + "hand_cart", // 6.8k + "inline_skates", // 5k + "speed_pedelec", // 3.7k + "motorhome", // 3.5k + "trailer", // 2.7k + "ohv", // 2.4k + "caravan", // 2k + "coach", // 1.7k + "carpool", // 1.5k + "hgv_articulated", // 1k + "small_electric_vehicle", // 800 + "auto_rickshaw", // 625 + "electric_bicycle", // 335 + "cycle_rickshaw", // 78 + "nev", // 62 + "kick_scooter", // 60 +) + +val accessValues = arrayOf( + "yes", + "no", + "private", + "permissive", + "permit", + "destination", + "delivery", + "customers", + "designated", // not for access + "use_sidepath", // usually for foot / bicycle + "dismount", // bicycle + "agricultural", + "forestry", + "discouraged", // really required explicit sign + //"variable", doesn't make sense without supporting access:lanes +) diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/CheckIfDay.kt b/app/src/main/java/de/westnordost/streetcomplete/util/CheckIfDay.kt new file mode 100644 index 00000000000..bd3cfe79281 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/util/CheckIfDay.kt @@ -0,0 +1,41 @@ +package de.westnordost.streetcomplete.util + +import com.luckycatlabs.sunrisesunset.Zenith +import com.luckycatlabs.sunrisesunset.calculator.SolarEventCalculator +import com.luckycatlabs.sunrisesunset.dto.Location +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZonedDateTime +import java.util.* + +fun localDateToCalendar(localDate: LocalDate): Calendar { + val calendar = Calendar.getInstance() + calendar.set(localDate.year, localDate.month.value, localDate.dayOfMonth) + return calendar +} + +fun isDay(pos: LatLon): Boolean { + /* This functions job is to check if it's currently light out. + It will use the location of the node and check the civil sunrise/sunset time. + + Sometimes sunset is after midnight. This would actually cause sunset to be before sunrise (as it's checking 00:00 to 23:59, it'll catch the day before!), so to gnt around this, we check the next day if that's the case. + */ + + val timezone = TimeZone.getDefault().id + val location = Location(pos.latitude, pos.longitude) + val calculator = SolarEventCalculator(location, timezone) + val now = ZonedDateTime.now(ZoneId.of(timezone)) + val today = now.toLocalDate() + + val sunrise = ZonedDateTime.of(today, LocalTime.parse(calculator.computeSunriseTime(Zenith.CIVIL, localDateToCalendar(today))), ZoneId.of(timezone)) + val sunset = ZonedDateTime.of(today, LocalTime.parse(calculator.computeSunsetTime(Zenith.CIVIL, localDateToCalendar(today))), ZoneId.of(timezone)) + return if (sunset < sunrise) { + + val sunset = ZonedDateTime.of(today.plusDays(1), LocalTime.parse(calculator.computeSunsetTime(Zenith.CIVIL, localDateToCalendar(today.plusDays(1)))), ZoneId.of(timezone)) + now in sunrise..sunset + } else { + now in sunrise..sunset + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/CrashReportExceptionHandler.kt b/app/src/main/java/de/westnordost/streetcomplete/util/CrashReportExceptionHandler.kt index 629fc02e1a3..99ec4a99d04 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/CrashReportExceptionHandler.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/CrashReportExceptionHandler.kt @@ -2,11 +2,17 @@ package de.westnordost.streetcomplete.util import android.content.Context import android.os.Build +import com.russhwolf.settings.ObservableSettings import de.westnordost.streetcomplete.ApplicationConstants import de.westnordost.streetcomplete.BuildConfig +import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.data.logs.LogsController import de.westnordost.streetcomplete.data.logs.format +import de.westnordost.streetcomplete.util.ktx.minusInSystemTimeZone import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds +import de.westnordost.streetcomplete.util.ktx.systemTimeNow +import de.westnordost.streetcomplete.util.ktx.toLocalDateTime +import kotlinx.datetime.DateTimeUnit import kotlinx.io.IOException import java.util.Locale @@ -16,6 +22,7 @@ import java.util.Locale class CrashReportExceptionHandler( private val context: Context, private val logsController: LogsController, + private val prefs: ObservableSettings, private val crashReportFile: String ) : Thread.UncaughtExceptionHandler { @@ -98,6 +105,11 @@ class CrashReportExceptionHandler( } private fun readLogFromDatabase(): String { + if (prefs.getBoolean(Prefs.TEMP_LOGGER, false)) { + val tooOld = systemTimeNow().toLocalDateTime() + .minusInSystemTimeZone(ApplicationConstants.DO_NOT_ATTACH_LOG_TO_CRASH_REPORT_AFTER, DateTimeUnit.MILLISECOND) + return TempLogger.getLog().filter { it.time > tooOld }.joinToString("\n") { it.toString() } + } val newLogTimestamp = nowAsEpochMilliseconds() - ApplicationConstants.DO_NOT_ATTACH_LOG_TO_CRASH_REPORT_AFTER diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/EditTagsAdapter.kt b/app/src/main/java/de/westnordost/streetcomplete/util/EditTagsAdapter.kt new file mode 100644 index 00000000000..c56c3d2855c --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/util/EditTagsAdapter.kt @@ -0,0 +1,276 @@ +package de.westnordost.streetcomplete.util + +import android.content.Context +import android.os.Build +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.AutoCompleteTextView +import android.widget.ImageView +import android.widget.Toast +import androidx.core.view.WindowInsetsCompat +import androidx.core.widget.doAfterTextChanged +import androidx.recyclerview.widget.RecyclerView +import com.russhwolf.settings.ObservableSettings +import de.westnordost.osmfeatures.Feature +import de.westnordost.osmfeatures.FeatureDictionary +import de.westnordost.osmfeatures.GeometryType +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.databinding.RowEditTagBinding +import de.westnordost.streetcomplete.util.ktx.dpToPx +import de.westnordost.streetcomplete.util.ktx.spToPx +import de.westnordost.streetcomplete.util.ktx.toast +import de.westnordost.streetcomplete.util.logs.Log +import kotlinx.serialization.json.Json + +// use displaySet and dataSet: displaySet is the sorted map.toList +// editing the map directly is not so simple, because the order may change if the key is changed +class EditTagsAdapter( + private val displaySet: MutableList>, + private val dataSet: MutableMap, + private val geometryType: GeometryType, // todo: currently unused, but should be used (later) for getting correct suggestions + private val featureDictionary: FeatureDictionary, + context: Context, + private val prefs: ObservableSettings, + private val onDataChanged: () -> Unit +) : + RecyclerView.Adapter() { + val suggestionHeight = TypedValue().apply { context.theme.resolveAttribute(android.R.attr.listPreferredItemHeight, this, false) } + .getDimension(context.resources.displayMetrics).toInt() + val suggestionMaxHeight = (context.resources.displayMetrics.heightPixels * 0.8).toInt() + // used to avoid covering keyboard button by tag dropdown + // autocomplete view height is sth like 18sp text size, 16sp edit date text size + some padding + val keyViewOffset = (context.resources.spToPx(34) + context.resources.dpToPx(32)).toInt() + val topMargin = context.resources.dpToPx(60).toInt() // for id/editorContainer + + init { + if (keySuggestionsForFeatureId.isEmpty() && valueSuggestionsByKey.isEmpty()) { + try { + val keySuggestions = context.resources.assets.open("tag_editor/keySuggestionsForFeature.json").reader().readText() + val valueSuggestions = context.resources.assets.open("tag_editor/valueSuggestionsByKey.json").reader().readText() + // filling maps twice is a bit inefficient, but there are so many duplicate strings that interning is worth it + Json.decodeFromString?, List?>>>(keySuggestions).forEach { + keySuggestionsForFeatureId[it.key.intern()] = it.value.first?.map { it.intern() } to it.value.second?.map { it.intern() } + } + Json.decodeFromString>>(valueSuggestions).forEach { + valueSuggestionsByKey[it.key.intern()] = it.value.map { it.intern() } + } + } catch (e: Exception) { + Log.w("EditTagsAdapter", "failed to read and parse suggestions: ${e.message}") + } + } + } + + inner class ViewHolder(binding: RowEditTagBinding) : RecyclerView.ViewHolder(binding.root) { + private fun storeRecentlyUsed(text: String, name: String, isKey: Boolean) { // will be value if not key + val keys = linkedSetOf(text) + val pref = "EditTagsAdapter_${name}_" + if (isKey) "keys" else "values" + keys.addAll(prefs.getString(pref, "").split("§§")) + prefs.putString(pref, keys.filter { it.isNotEmpty() }.take(15).joinToString("§§")) + } + + val keyView: AutoCompleteTextView = binding.keyText.apply { + var lastFeature: Feature? = null + val lastSuggestions = linkedSetOf() + setOnFocusChangeListener { _, focused -> + val text = text.toString() + if (focused) setText(text) // to get fresh suggestions and show dropdown; showDropDown() not helping here + else if (text !in lastSuggestions && text.isNotBlank()) { + // store most recently used keys on focus loss + // this may be because user typed answer instead of tapping suggestion + // unfortunately this also happens in other cases where storing may not be wanted, but leave it for now + storeRecentlyUsed(text, lastFeature?.id.toString(), true) + } + } + onItemClickListener = AdapterView.OnItemClickListener { _, _, _, _ -> + storeRecentlyUsed(text.toString(), lastFeature?.id.toString(), true) + // move to value view if key is selected from suggestions + valueView.requestFocus() + } + setAdapter(SearchAdapter(context, { search -> + if (!isFocused) return@SearchAdapter emptyList() // don't search if the field is not focused + lastFeature = featureDictionary.byTags(dataSet).isSuggestion(false).find().firstOrNull() + lastSuggestions.clear() + lastSuggestions.addAll(getKeySuggestions(lastFeature?.id, dataSet).filter { it.startsWith(search) }) + // limit the height of suggestions, because when all are shown the ones on top are hard to reach + // top should be suggestionMaxHeight from screen bottom + // additionally, if the keyboard is not shown, dropdown should not block show keyboard button + var minus = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val showingKeyboard = WindowInsetsCompat.toWindowInsetsCompat(rootWindowInsets).isVisible(WindowInsetsCompat.Type.ime()) + dropDownVerticalOffset = if (showingKeyboard) -9 // reading offset that has not been set gives -9 (why?) + else keyViewOffset + minus = if (showingKeyboard) rootWindowInsets.systemWindowInsetBottom + // insets minus offset plus distance of bottom of recycler view from screen bottom + // last one needs to consider that view.bottom is relative to parent, which has a 60 dp margin from top + else rootWindowInsets.systemWindowInsetBottom - keyViewOffset + ((parent.parent as? RecyclerView)?.let { resources.displayMetrics.heightPixels - topMargin - it.bottom } ?: 0) + } + dropDownHeight = (suggestionHeight * lastSuggestions.size).coerceAtMost(suggestionMaxHeight - minus) + + lastSuggestions.toList() + }, { it })) + doAfterTextChanged { + val position = absoluteAdapterPosition + val newKey = it.toString() + // do nothing if key is unchanged, happens when views are filled by EditTagsAdapter + if (displaySet[position].first == newKey) return@doAfterTextChanged + if (dataSet.containsKey(newKey)) { + // don't store duplicate keys, user should rename or delete them + context.toast(resources.getString(R.string.tag_editor_duplicate_key, newKey), Toast.LENGTH_LONG) + return@doAfterTextChanged + } + val oldEntry = displaySet[position] + dataSet.remove(oldEntry.first) + val newEntry = newKey to oldEntry.second + dataSet[newEntry.first] = newEntry.second + displaySet[position] = newEntry + onDataChanged() + } + } + + val valueView: AutoCompleteTextView = binding.valueText.apply { + val lastSuggestions = linkedSetOf() + setOnFocusChangeListener { _, focused -> + val text = text.toString() + if (focused) setText(text) // to get fresh suggestions and show dropdown; showDropDown() not helping here + else if (text !in lastSuggestions && text.isNotBlank() && keyView.text.toString().isNotBlank()) { + // store most recently used values on focus loss (user typed answer instead of tapping suggestion) + storeRecentlyUsed(text, keyView.text.toString(), false) + } + } + onItemClickListener = AdapterView.OnItemClickListener { _, _, _, _ -> + storeRecentlyUsed(text.toString(), keyView.text.toString(), false) + } + setAdapter(SearchAdapter(context, { search -> + if (!isFocused) return@SearchAdapter emptyList() + val key = displaySet[absoluteAdapterPosition].first + lastSuggestions.clear() + prefs.getString("EditTagsAdapter_${keyView.text}_values", "") + .split("§§").forEach { + if (it.startsWith(search) && it.isNotEmpty()) + lastSuggestions.add(it) + } + lastSuggestions.addAll(valueSuggestionsByKey[key].orEmpty().filter { it.startsWith(search) }) + val minus = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) rootWindowInsets.systemWindowInsetBottom + else 0 + dropDownHeight = (suggestionHeight * lastSuggestions.size).coerceAtMost(suggestionMaxHeight - minus) + lastSuggestions.toList() + }, { it })) + doAfterTextChanged { + val position = absoluteAdapterPosition + if (displaySet[position].second == it.toString()) return@doAfterTextChanged + val oldEntry = displaySet[position] + val newEntry = oldEntry.first to it.toString() + dataSet[newEntry.first] = newEntry.second + displaySet[position] = newEntry + onDataChanged() + } + } + + val delete: ImageView = binding.deleteButton.apply { + setOnClickListener { + val position = absoluteAdapterPosition + val oldEntry = displaySet[position] + if (oldEntry.second.isEmpty()) { + // delete row if value is empty + displaySet.removeAt(position) + dataSet.remove(oldEntry.first) + } else { + // otherwise clear value only + displaySet[position] = displaySet[position].copy(second = "") + dataSet[oldEntry.first] = "" + // show suggestions if not entering a name + if (!oldEntry.second.startsWith("name")) + valueView.postDelayed({ valueView.requestFocus() }, 10) + } + notifyDataSetChanged() // slightly weird behavior if only notifying about the actual changes + onDataChanged() + } + setOnLongClickListener { + val position = absoluteAdapterPosition + val oldEntry = displaySet[position] + displaySet.removeAt(position) + dataSet.remove(oldEntry.first) + onDataChanged() + notifyDataSetChanged() + true + } + } + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder(RowEditTagBinding.inflate(LayoutInflater.from(viewGroup.context), viewGroup, false)) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + viewHolder.keyView.setText(displaySet[position].first) + viewHolder.valueView.setText(displaySet[position].second) + } + + override fun getItemCount() = displaySet.size + + override fun getItemId(position: Int) = position.toLong() + + // todo: use geometry type for suggestions + // means that generateTagSuggestions needs to be adjusted to generate something containing allowed geometry types + // and maybe suggestions should only be for a single geometry type? or for all geometry types for which this key is allowed? + // basic test: no building suggestion when adding a shop node (though currently this is manually excluded) + // ideally FeatureDictionary at some point implements fields / moreFields... + private fun getKeySuggestions(featureId: String?, tags: Map): Collection { + val suggestions = prefs.getString("EditTagsAdapter_${featureId}_keys", "").split("§§").filter { it.isNotEmpty() }.toMutableSet() + if (featureId == null) return suggestions.filterNot { it in tags.keys } + val fields = getMainSuggestions(featureId) + val moreFields = getSecondarySuggestions(featureId) + val fieldSuggestions = mutableListOf() + val moreFieldSuggestions = mutableListOf() + fields.forEach { + if (it == "building" || it.startsWith("gnis:feature_id") ) return@forEach + if (it.startsWith('{')) // does this actually trigger? or is it unnecessary? + fieldSuggestions.addAll(getMainSuggestions(it.substringAfter('{').substringBefore('}'))) + else fieldSuggestions.add(it) + } + moreFields.forEach { + // ignore some moreFields that often are inappropriate (but keep if in fields!) + if (it.startsWith("ref:") || it.startsWith("building") || it == "gnis:feature_id" || it == "ele" || it == "height" ) return@forEach + if (it.startsWith('{')) + moreFieldSuggestions.addAll(getSecondarySuggestions(it.substringAfter('{').substringBefore('}'))) + else moreFieldSuggestions.add(it) + } + + // suggestions should not be cluttered with all those address tags, but we don't want to ignore them completely + // but we want to ignore some refs, and building which shows up for shops, but is usually not a good idea because we ignore geometry + val fieldsMoveToEnd = fieldSuggestions.filter { it.startsWith("addr:") || it.startsWith("ref:") || it.startsWith("tiger:") } + fieldSuggestions.removeAll(fieldsMoveToEnd) + fieldSuggestions.removeAll { it.startsWith("{") } // appeared in the latest presets update, maybe we should actually go deeper for this '{'? + val moreFieldsMoveToEnd = moreFieldSuggestions.filter { it.startsWith("addr:") || it.startsWith("ref:") || it.startsWith("tiger:") } + moreFieldSuggestions.removeAll(moreFieldsMoveToEnd) + moreFieldSuggestions.removeAll { it.startsWith("{") } + + // order: previously entered values, fields, moreFields, addr fields, addr moreFields + // do it in this complicated way because we don't want to (re)move keys the user has entered + suggestions.addAll(fieldSuggestions) + suggestions.addAll(moreFieldSuggestions) + suggestions.addAll(fieldsMoveToEnd) + suggestions.addAll(moreFieldsMoveToEnd) + + suggestions.removeAll(tags.keys) // don't suggest what we already have + return suggestions + // can be optimized, but likely not worth the work + } + + private fun getMainSuggestions(featureId: String): List { + val suggestions = keySuggestionsForFeatureId[featureId]?.first + return suggestions ?: if (featureId.contains('/')) getMainSuggestions(featureId.substringBeforeLast('/')) else emptyList() + } + + private fun getSecondarySuggestions(featureId: String): List { + val suggestions = keySuggestionsForFeatureId[featureId]?.second + return suggestions ?: if (featureId.contains('/')) getSecondarySuggestions(featureId.substringBeforeLast('/')) else emptyList() + } + + companion object { + private val keySuggestionsForFeatureId = hashMapOf?, List?>>() + private val valueSuggestionsByKey = hashMapOf>() + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/OverlayHelpers.kt b/app/src/main/java/de/westnordost/streetcomplete/util/OverlayHelpers.kt new file mode 100644 index 00000000000..dbcce02e3bd --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/util/OverlayHelpers.kt @@ -0,0 +1,285 @@ +package de.westnordost.streetcomplete.util + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.util.TypedValue +import android.view.ViewGroup +import android.widget.CheckBox +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.Spinner +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.SwitchCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.core.widget.doAfterTextChanged +import de.westnordost.streetcomplete.BuildConfig +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry +import de.westnordost.streetcomplete.overlays.Overlay +import de.westnordost.streetcomplete.overlays.Style +import de.westnordost.streetcomplete.overlays.custom.getCustomOverlayIndices +import de.westnordost.streetcomplete.overlays.custom.getIndexedCustomOverlayPref +import de.westnordost.streetcomplete.util.dialogs.setViewWithDefaultPadding +import de.westnordost.streetcomplete.util.ktx.dpToPx +import de.westnordost.streetcomplete.view.ArrayImageAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@SuppressLint("SetTextI18n") // this is about element type, don't want translation here +@Suppress("KotlinConstantConditions") // because this is simply incorrect... +fun showOverlayCustomizer( + index: Int, + ctx: Context, + prefs: Preferences, + questTypeRegistry: QuestTypeRegistry, + onChanged: (Boolean) -> Unit, // true if overlay is currently set custom overlay + onDeleted: (Boolean) -> Unit, // true if overlay was currently set custom overlay +) { + var d: AlertDialog? = null + val padding = ctx.resources.dpToPx(4).toInt() + + var toastyJob: Job? = null + fun delayedToast(message: String?, context: Context) { + toastyJob?.cancel() + toastyJob = GlobalScope.launch(Dispatchers.IO) { + delay(3000) + withContext(Dispatchers.Main) { Toast.makeText(context, "Error: $message", Toast.LENGTH_LONG).show() } + } + } + + val title = EditText(ctx).apply { + setHint(R.string.name_label) + setText(prefs.getString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_NAME, index), "")) + } + val iconList = LinkedHashSet(questTypeRegistry.size).apply { + add(R.drawable.ic_custom_overlay) + questTypeRegistry.forEach { add(it.icon) } + }.toList() + val iconSpinner = Spinner(ctx).apply { + adapter = ArrayImageAdapter(ctx, iconList, 48) + val selectedIcon = ctx.resources.getIdentifier(prefs.getString( + getIndexedCustomOverlayPref( + Prefs.CUSTOM_OVERLAY_IDX_ICON, index), "ic_custom_overlay"), "drawable", ctx.packageName) + setSelection(iconList.indexOf(selectedIcon)) + dropDownWidth = ctx.resources.dpToPx(48).toInt() + layoutParams = ViewGroup.LayoutParams(ctx.resources.dpToPx(100).toInt(), ctx.resources.dpToPx(48).toInt()) + } + val filterText = TextView(ctx).apply { + text = "${ctx.getString(R.string.custom_overlay_filter_info)} ℹ️" + setPadding(padding, 2 * padding, padding, 0) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f) + setTextColor(ContextCompat.getColor(ctx, R.color.accent)) + setOnClickListener { + val dialog = AlertDialog.Builder(ctx) + .setMessage(R.string.custom_overlay_filter_message) + .setPositiveButton(R.string.close, null) + .setNeutralButton("link") { _, _ -> + val intent = Intent(Intent.ACTION_VIEW, "https://github.com/Helium314/SCEE/blob/modified/CONTRIBUTING_A_NEW_QUEST.md#element-selection".toUri()) + try { + ContextCompat.startActivity(ctx, intent, null) + } catch (_: Exception) { } + } + .create() + dialog.show() + } + } + val overlayFilter = prefs.getString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_FILTER, index), "").split(" with ").takeIf { it.size == 2 } + val tag = EditText(ctx).apply { + setHint(R.string.element_selection_button) + setText(overlayFilter?.get(1) ?: "") + } + val nodes = CheckBox(ctx).apply { + text = "nodes" + isChecked = overlayFilter?.get(0)?.contains("nodes") ?: true + } + val ways = CheckBox(ctx).apply { + text = "ways" + isChecked = overlayFilter?.get(0)?.contains("ways") ?: true + } + val relations = CheckBox(ctx).apply { + text = "relations" + isChecked = overlayFilter?.get(0)?.contains("relations") ?: true + } + fun filterString(): String { + val types = listOfNotNull( + if (nodes.isChecked) "nodes" else null, + if (ways.isChecked) "ways" else null, + if (relations.isChecked) "relations" else null, + ).joinToString(", ") + return "$types with ${tag.text}" + } + // need to add this after filterString, which needs to be added after tag + tag.doAfterTextChanged { text -> + if (text == null || text.count { it == '(' } != text.count { it == ')' }) { + d?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = false + return@doAfterTextChanged + } + try { + filterString().toElementFilterExpression() + d?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = true + toastyJob?.cancel() + } + catch (e: Exception) { // for some reason catching import de.westnordost.streetcomplete.data.elementfilter.ParseException is not enough (#386), though I cannot reproduce it + delayedToast(e.message, ctx) + d?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = tag.text.isEmpty() + } + } + val colorText = TextView(ctx).apply { + text = "${ctx.getString(R.string.custom_overlay_color_info)} ℹ️" + setPadding(padding, 2 * padding, padding, 0) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f) + setTextColor(ContextCompat.getColor(ctx, R.color.accent)) + setOnClickListener { + val dialog = AlertDialog.Builder(ctx) + .setMessage(R.string.custom_overlay_color_message) + .setPositiveButton(R.string.close, null) + .create() + dialog.show() + } + } + val color = EditText(ctx).apply { + setHint(R.string.custom_overlay_color_hint) + setText(prefs.getString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_COLOR_KEY, index), "")) + doAfterTextChanged {text -> + if (text == null || text.count { it == '(' } != text.count { it == ')' }) { + d?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = false + return@doAfterTextChanged + } + try { + text.toString().toRegex() + d?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = true + toastyJob?.cancel() + } + catch (e: Exception) { + delayedToast(e.message, ctx) + d?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = false + } + } + } + val highlightMissingSwitch = SwitchCompat(ctx).apply { + setText(R.string.custom_overlay_highlight_missing) + isChecked = prefs.getBoolean(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_HIGHLIGHT_MISSING_DATA, index), true) + } + val dashFilter = EditText(ctx).apply { + setHint(R.string.custom_overlay_dash_filter_hint) + setText(prefs.getString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_DASH_FILTER, index), "")) + doAfterTextChanged { text -> + if (text.isNullOrBlank()) { + d?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = true + return@doAfterTextChanged + } + + if (text.count { it == '(' } != text.count { it == ')' }) { + d?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = false + return@doAfterTextChanged + } + try { + "ways with $text".toElementFilterExpression() + d?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = true + toastyJob?.cancel() + } + catch (e: Exception) { + delayedToast(e.message, ctx) + d?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = tag.text.isEmpty() + } + } + } + val linearLayout = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + addView(LinearLayout(ctx).apply { + addView(iconSpinner) + addView(title) + }) + addView(filterText) + addView(tag) + addView(nodes) + addView(ways) + addView(relations) + addView(colorText) + addView(color) + addView(highlightMissingSwitch) + addView(dashFilter) + } + val indices = getCustomOverlayIndices(prefs).sorted() + val b = AlertDialog.Builder(ctx) + .setTitle(R.string.custom_overlay_title) + .setViewWithDefaultPadding(ScrollView(ctx).apply { addView(linearLayout) }) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + // update prefs and enable this overlay + prefs.putString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_FILTER, index), filterString()) + prefs.putString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_COLOR_KEY, index), color.text.toString()) + prefs.putString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_NAME, index), title.text.toString()) + prefs.putString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_ICON, index), ctx.resources.getResourceEntryName(iconList[iconSpinner.selectedItemPosition])) + prefs.putString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_DASH_FILTER, index), dashFilter.text.toString()) + prefs.putBoolean(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_HIGHLIGHT_MISSING_DATA, index), highlightMissingSwitch.isChecked) + if (index !in indices) { // add if it's new, and select it immediately + prefs.putString(Prefs.CUSTOM_OVERLAY_INDICES, (indices + index).joinToString(",")) + prefs.putInt(Prefs.CUSTOM_OVERLAY_SELECTED_INDEX, index) + } + onChanged(index == prefs.getInt(Prefs.CUSTOM_OVERLAY_SELECTED_INDEX, 0)) // todo: also if overlay is new? + } + if (index in indices) + b.setNeutralButton(R.string.delete_confirmation) { _, _ -> + val overlayName = prefs.getString( + getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_NAME, index), ctx.getString( + R.string.custom_overlay_title)) + AlertDialog.Builder(ctx) + .setMessage(ctx.getString(R.string.custom_overlay_delete, overlayName)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.delete_confirmation) { _, _ -> + val isActive = prefs.getInt(Prefs.CUSTOM_OVERLAY_SELECTED_INDEX, 0) == index + prefs.prefs.keys.forEach { if (it.startsWith("custom_overlay_${index}_")) prefs.prefs.remove(it) } + prefs.putString(Prefs.CUSTOM_OVERLAY_INDICES, indices.filterNot { it == index }.joinToString(",")) + if (isActive) + prefs.putInt(Prefs.CUSTOM_OVERLAY_SELECTED_INDEX, 0) + onDeleted(isActive) + } + .show() + } + d = b.create() + d.show() +} + +// creates dummy overlays for the custom overlay, so they can be displayed to the user +// title is invalid resId 0 +// name and wikiLink are the overlay index as stored in shared preferences +// changesetComment is the overlay title +fun getFakeCustomOverlays(prefs: Preferences, res: Resources, onlyIfExpertMode: Boolean = true): List { + if (onlyIfExpertMode && !prefs.getBoolean(Prefs.EXPERT_MODE, false)) return emptyList() + return prefs.getString(Prefs.CUSTOM_OVERLAY_INDICES, "0").split(",").mapNotNull { index -> + val i = index.toIntOrNull() ?: return@mapNotNull null + object : Overlay { + override fun getStyledElements(mapData: MapDataWithGeometry) = emptySequence>() + override fun createForm(element: Element?) = null + override val changesetComment = prefs.getString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_NAME, i), "") + .ifBlank { res.getString(R.string.custom_overlay_title) } // displayed overlay name + override val icon = res.getIdentifier( + prefs.getString(getIndexedCustomOverlayPref(Prefs.CUSTOM_OVERLAY_IDX_ICON, i), "ic_custom_overlay"), + "drawable", BuildConfig.APPLICATION_ID + ).takeIf { it != 0 } ?: R.drawable.ic_custom_overlay + override val title = 0 // use invalid resId placeholder, the adapter needs to be aware of this + override val name = index // allows to uniquely identify an overlay + override val wikiLink = index + override fun equals(other: Any?): Boolean { + return if (other !is Overlay) false + else wikiLink == other.wikiLink // we only care about index! + } + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/SearchAdapter.kt b/app/src/main/java/de/westnordost/streetcomplete/util/SearchAdapter.kt new file mode 100644 index 00000000000..f8df526393d --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/util/SearchAdapter.kt @@ -0,0 +1,56 @@ +package de.westnordost.streetcomplete.util + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.Filter +import android.widget.Filterable +import android.widget.TextView + +// search adapter was removed in favor of feature search, but we still want to use it for trees and tag editor +class SearchAdapter( + private val context: Context, + private val filterQuery: (term: String) -> List, + private val convertToString: (T) -> String +) : BaseAdapter(), Filterable { + + private val filter = SearchFilter() + + private var items: List = emptyList() + set(value) { + field = value + notifyDataSetChanged() + } + + override fun getCount(): Int = items.size + override fun getItem(position: Int): T = items[position] + override fun getItemId(position: Int): Long = position.toLong() + override fun getFilter() = filter + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_dropdown_item_1line, parent, false) + (view as TextView).text = filter.convertResultToString(getItem(position)) + return view + } + + inner class SearchFilter : Filter() { + override fun performFiltering(constraint: CharSequence?) = FilterResults().also { + val term = constraint?.toString() ?: "" + val results = filterQuery(term) + it.count = results.size + it.values = results + } + + override fun publishResults(constraint: CharSequence?, results: FilterResults) { + // results should always come from performFiltering, but still got a crash report with + // NPE here, which happens on click ok (and not actually anything where filtering happens) + (results?.values as? List)?.let { items = it } + } + + override fun convertResultToString(resultValue: Any?): CharSequence { + return (resultValue as? T?)?.let(convertToString) ?: super.convertResultToString(resultValue) + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/SpatialCache.kt b/app/src/main/java/de/westnordost/streetcomplete/util/SpatialCache.kt index 73d448439c5..3b60a3540b4 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/SpatialCache.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/SpatialCache.kt @@ -36,8 +36,11 @@ class SpatialCache( /** @return a new list of all keys in the cache */ fun getKeys(): List = synchronized(this) { byKey.keys.toList() } + /** @return a new list of all items in the cache */ + fun getItems(): List = synchronized(this) { byKey.values.toList() } + /** @return a new set of all tilePos in the cache */ - fun getTiles(): Set = synchronized(this) { byTile.keys.toSet() } + fun getTiles(): Set = synchronized(this) { byTile.keys.toHashSet() } /** @return the item with the given [key] if in cache */ fun get(key: K): T? = synchronized(this) { diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/TempLogger.kt b/app/src/main/java/de/westnordost/streetcomplete/util/TempLogger.kt new file mode 100644 index 00000000000..51c4dbc65dc --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/util/TempLogger.kt @@ -0,0 +1,54 @@ +package de.westnordost.streetcomplete.util + +import de.westnordost.streetcomplete.util.ktx.now +import de.westnordost.streetcomplete.util.logs.Logger +import kotlinx.datetime.LocalDateTime + +object TempLogger : Logger { + override fun e(tag: String, message: String, exception: Throwable?) { + if (exception == null) { + synchronized(logLines) { log(LogLine('E', tag, message)) } + } else { + synchronized(logLines) { log(LogLine('E', tag, "$message\n${exception.stackTraceToString()}")) } + } + } + + override fun w(tag: String, message: String, exception: Throwable?) { + if (exception == null) { + synchronized(logLines) { log(LogLine('W', tag, message)) } + } else { + synchronized(logLines) { log(LogLine('W', tag, "$message\n${exception.stackTraceToString()}")) } + } + } + + override fun i(tag: String, message: String) { + synchronized(logLines) { log(LogLine('I', tag, message)) } + } + + override fun d(tag: String, message: String) { + synchronized(logLines) { log(LogLine('D', tag, message)) } + } + + override fun v(tag: String, message: String) { + synchronized(logLines) { log(LogLine('V', tag, message)) } + } + + private fun log(line: LogLine) { + synchronized(logLines) { + if (logLines.size > 12000) // clear oldest entries if list gets too long + logLines.subList(0, 2000).clear() + logLines.add(line) + } + } + + private val logLines: MutableList = ArrayList(2000) + + /** returns a copy of [logLines] */ + fun getLog() = synchronized(logLines) { logLines.toList() } +} + +data class LogLine(val level: Char, val tag: String, val message: String,) { + val time = LocalDateTime.now() + override fun toString(): String = // should look like a normal android log line + "${time.toString().replace('T', ' ')} $level $tag: $message" +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/Winter.kt b/app/src/main/java/de/westnordost/streetcomplete/util/Winter.kt new file mode 100644 index 00000000000..73c88324325 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/util/Winter.kt @@ -0,0 +1,15 @@ +package de.westnordost.streetcomplete.util + +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.util.ktx.systemTimeNow +import de.westnordost.streetcomplete.util.ktx.toLocalDate +import java.time.Month.* + +fun isWinter(location: LatLon?): Boolean { + if (location == null) return false + val now = systemTimeNow().toLocalDate() + val winterSeason = if (location.latitude > 0) + listOf(NOVEMBER, DECEMBER, JANUARY, FEBRUARY, MARCH, APRIL) + else listOf(JUNE, JULY, AUGUST, SEPTEMBER, OCTOBER) + return now.month in winterSeason +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/dialogs/ConditionalDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/util/dialogs/ConditionalDialog.kt new file mode 100644 index 00000000000..54aa95b6841 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/util/dialogs/ConditionalDialog.kt @@ -0,0 +1,314 @@ +package de.westnordost.streetcomplete.util.dialogs + +import android.annotation.SuppressLint +import android.content.Context +import android.text.InputType +import android.text.format.DateFormat +import android.util.TypedValue +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.CheckBox +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.AppCompatSpinner +import androidx.appcompat.widget.SwitchCompat +import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.lifecycleScope +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.osm.opening_hours.model.TimeRange +import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHours +import de.westnordost.streetcomplete.quests.opening_hours.TimeRangePickerDialog +import de.westnordost.streetcomplete.quests.opening_hours.WeekdaysPickerDialog +import de.westnordost.streetcomplete.quests.opening_hours.adapter.OpeningWeekdaysRow +import de.westnordost.streetcomplete.util.ktx.showKeyboard +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Suppress("KotlinConstantConditions") // because this is simply incorrect... +@SuppressLint("SetTextI18n") // this is the value, and should absolutely not be translated +fun showAddConditionalDialog(context: Context, keys: List, values: List?, valueInputType: Int?, onClickOk: (String, String) -> Unit) { + var key = "" + var value = "" + val conditions = mutableMapOf() // key is time, weight, length,... and values are the limitation strings + var dialog: AlertDialog? = null + + fun isOk(text: String): Boolean = + key.isNotBlank() + && ((values != null && text.substringBefore(" @") in values) || text.substringBefore("@").isNotBlank()) + && text.contains('@') + && text.count { c -> c == '('} == 1 && text.count { c -> c == ')'} == 1 + && "()" !in text + + val valueEditText = EditText(context).apply { + doAfterTextChanged { + dialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = isOk(it.toString()) + } + } + + fun createFullValue() { + valueEditText.setText("$value @ (${conditions.values.joinToString(" AND ")})") + } + + val keySpinner = AppCompatSpinner(context).apply { + adapter = ArrayAdapter(context, android.R.layout.simple_dropdown_item_1line, keys) + onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long, ) { + key = keys[position] + } + override fun onNothingSelected(p0: AdapterView<*>?) { } + } + } + + val valueView = if (values == null) + EditText(context).apply { + hint = "value" + valueInputType?.let { inputType = it } + doAfterTextChanged { + value = it.toString() + createFullValue() + } + } + else + AppCompatSpinner(context).apply { + adapter = ArrayAdapter(context, android.R.layout.simple_dropdown_item_1line, values) + onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long, ) { + value = values[position] + createFullValue() + } + override fun onNothingSelected(p0: AdapterView<*>?) { } + } + } + + fun numericBox(type: String, textResId: Int): View { + val box = CheckBox(context) + var conditionText = "" + val switch = SwitchCompat(context).apply { + layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 0.4f) + isEnabled = false + text = "<" + setOnCheckedChangeListener { _, b -> + text = if (b) ">" else "<" + conditions[type] = "$type$text$conditionText" + createFullValue() + } + } + box.apply { + layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 0.6f) + setText(textResId) + setOnCheckedChangeListener { _, checked -> + if (checked) { + // allow selecting < and >? just let the user type it manually for now + var textDialog: AlertDialog? = null + val text = EditText(context).apply { + hint = type + inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + doAfterTextChanged { textDialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = it?.toString()?.toFloatOrNull() != null } + } + textDialog = AlertDialog.Builder(context) + .setView(text) + .setPositiveButton(android.R.string.ok) { _, _ -> + conditionText = text.text.toString() + conditions[type] = "$type${switch.text}${text.text}" + createFullValue() + } + .setOnCancelListener { isChecked = false } + .create() + textDialog.setOnShowListener { + dialog?.lifecycleScope?.launch { + delay(20) // without this, the keyboard sometimes isn't showing + text.requestFocus() + text.showKeyboard() + } + textDialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = false + } + textDialog.show() + } else { + conditions.remove(type) + createFullValue() + } + switch.isEnabled = checked + } + } + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + addView(box) + addView(switch) + } + } + + val timeBox = CheckBox(context).apply { + setText(de.westnordost.streetcomplete.R.string.access_time_limit) + setOnCheckedChangeListener { _, checked -> + if (checked && "time" !in conditions) { + // todo: use user preferred locale? + val dW = WeekdaysPickerDialog.show(context, null, /*countryInfo.userPreferredLocale*/ context.resources.configuration.locale) { weekdays -> + val dT = TimeRangePickerDialog( + context, + context.getString(de.westnordost.streetcomplete.R.string.time_limited_from), + context.getString(de.westnordost.streetcomplete.R.string.time_limited_to), + TimeRange(8 * 60, 18 * 60, false), + DateFormat.is24HourFormat(context) + ) { timeRange -> + val oh = listOf(OpeningWeekdaysRow(weekdays, timeRange)).toOpeningHours() + conditions["time"] = oh.toString() + createFullValue() + } + dT.setOnDismissListener { isChecked = !conditions["time"].isNullOrBlank() } + dT.show() + } + dW.setOnDismissListener { isChecked = !conditions["time"].isNullOrBlank() } + dW.show() + } else if (!checked) { + conditions.remove("time") + createFullValue() + } + } + } + val layout = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(keySpinner) + addView(valueView) + // todo: more numeric things? there is stay, but this is not really numeric... has hours / minutes + // though this could be translated and only take minutes? + addView(numericBox("weight", de.westnordost.streetcomplete.R.string.access_weight_limit)) + addView(numericBox("length", de.westnordost.streetcomplete.R.string.access_length_limit)) + addView(numericBox("width", de.westnordost.streetcomplete.R.string.access_width_limit)) + addView(timeBox) + addView(valueEditText) + } + dialog = AlertDialog.Builder(context) + .setViewWithDefaultPadding(layout) + .setPositiveButton(android.R.string.ok) { _, _ -> + val fullValue = valueEditText.text.toString() + if (isOk(fullValue)) + onClickOk("$key:conditional", fullValue) + } + .setNegativeButton(android.R.string.cancel, null) + .create() + dialog.show() +} + +// similar, but with some access tags instead of numeric restrictions +// todo: maybe no key list necessary, and maybe no value list too? +fun showOtherConditionalDialog(context: Context, keys: List, values: List?, valueInputType: Int?, onClickOk: (String, String) -> Unit) { + var key = "" + var value = "" + val conditions = mutableMapOf() // key is time, weight, length,... and values are the limitation strings + var dialog: AlertDialog? = null + + fun isOk(text: String): Boolean = + key.isNotBlank() + && ((values != null && text.substringBefore(" @") in values) || text.substringBefore("@").isNotBlank()) + && text.contains('@') + && text.count { c -> c == '('} == 1 && text.count { c -> c == ')'} == 1 + && "()" !in text + + val valueEditText = EditText(context).apply { + doAfterTextChanged { + dialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = isOk(it.toString()) + } + } + + fun createFullValue() { + valueEditText.setText("$value @ (${conditions.values.joinToString(" AND ")})") + } + + val keySpinner = AppCompatSpinner(context).apply { + adapter = ArrayAdapter(context, android.R.layout.simple_dropdown_item_1line, keys) + onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long, ) { + key = keys[position] + } + override fun onNothingSelected(p0: AdapterView<*>?) { } + } + } + + val valueView = if (values == null) + EditText(context).apply { + hint = "value, leave empty for none" // todo: string resource + valueInputType?.let { inputType = it } + doAfterTextChanged { + value = it.toString().ifBlank { "none" } + createFullValue() + } + value = "none" + } + else + AppCompatSpinner(context).apply { + adapter = ArrayAdapter(context, android.R.layout.simple_dropdown_item_1line, values) + onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long, ) { + value = values[position] + createFullValue() + } + override fun onNothingSelected(p0: AdapterView<*>?) { } + } + } + + val accessSpinner = AppCompatSpinner(context).apply { + val v = listOf(context.getString(R.string.quest_select_hint), "destination", "delivery", "agricultural", "forestry", "private") + adapter = ArrayAdapter(context, android.R.layout.simple_dropdown_item_1line, v) + onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long, ) { + if (position == 0) + conditions.remove("access") + else + conditions["access"] = v[position] + createFullValue() + } + override fun onNothingSelected(p0: AdapterView<*>?) { } + } + } + + val timeBox = CheckBox(context).apply { + setText(de.westnordost.streetcomplete.R.string.access_time_limit) + setOnCheckedChangeListener { _, checked -> + if (checked && "time" !in conditions) { + val dW = WeekdaysPickerDialog.show(context, null, /*countryInfo.userPreferredLocale*/ context.resources.configuration.locale) { weekdays -> + val dT = TimeRangePickerDialog( + context, + context.getString(de.westnordost.streetcomplete.R.string.time_limited_from), + context.getString(de.westnordost.streetcomplete.R.string.time_limited_to), + TimeRange(8 * 60, 18 * 60, false), + DateFormat.is24HourFormat(context) + ) { timeRange -> + val oh = listOf(OpeningWeekdaysRow(weekdays, timeRange)).toOpeningHours() + conditions["time"] = oh.toString() + createFullValue() + } + dT.setOnDismissListener { isChecked = !conditions["time"].isNullOrBlank() } + dT.show() + } + dW.setOnDismissListener { isChecked = !conditions["time"].isNullOrBlank() } + dW.show() + } else if (!checked) { + conditions.remove("time") + createFullValue() + } + } + } + val layout = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(keySpinner) + addView(valueView) + addView(timeBox) + addView(accessSpinner) + addView(valueEditText) + } + dialog = AlertDialog.Builder(context) + .setViewWithDefaultPadding(layout) + .setPositiveButton(android.R.string.ok) { _, _ -> + val fullValue = valueEditText.text.toString() + if (isOk(fullValue)) + onClickOk("$key:conditional", fullValue) + } + .setNegativeButton(android.R.string.cancel, null) + .create() + dialog.show() + createFullValue() +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/dialogs/DialogDefaultPadding.kt b/app/src/main/java/de/westnordost/streetcomplete/util/dialogs/DialogDefaultPadding.kt new file mode 100644 index 00000000000..050e5439a0b --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/util/dialogs/DialogDefaultPadding.kt @@ -0,0 +1,32 @@ +package de.westnordost.streetcomplete.util.dialogs + +import android.content.Context +import android.os.Build +import android.util.TypedValue +import android.view.View +import androidx.appcompat.app.AlertDialog +import de.westnordost.streetcomplete.util.ktx.dpToPx + +// using setView fills the entire AlertDialog, while setMessage or set*Items add some padding +// this adds same/similar padding to setView +fun AlertDialog.Builder.setViewWithDefaultPadding(v: View): AlertDialog.Builder { + v.setDefaultDialogPadding() + return setView(v) +} + +fun View.setDefaultDialogPadding() { + val padding = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + getDimensionFromAttribute(context, android.R.attr.dialogPreferredPadding) + } else { + context.resources.dpToPx(20).toInt() + } + // no source for /3, but it looks ok + setPadding(padding, padding / 3, padding, padding / 3) +} + +private fun getDimensionFromAttribute(context: Context, attr: Int): Int { + val typedValue = TypedValue() + return if (context.theme.resolveAttribute(attr, typedValue, true)) + TypedValue.complexToDimensionPixelSize(typedValue.data, context.resources.displayMetrics) + else 0 +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/dialogs/OutsideAreaDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/util/dialogs/OutsideAreaDialog.kt new file mode 100644 index 00000000000..87667075a74 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/util/dialogs/OutsideAreaDialog.kt @@ -0,0 +1,22 @@ +package de.westnordost.streetcomplete.util.dialogs + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesSource +import de.westnordost.streetcomplete.data.download.tiles.enclosingTilePos +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon + +fun showOutsideDownloadedAreaDialog(context: Context, position: LatLon, downloadedTilesSource: DownloadedTilesSource, onOk: () -> Unit) { + if (!downloadedTilesSource.contains(position.enclosingTilePos(ApplicationConstants.DOWNLOAD_TILE_ZOOM).toTilesRect(), 0L)) + AlertDialog.Builder(context) + .setTitle(R.string.general_warning) + .setMessage(R.string.outside_downloaded_area_warning) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> onOk() } + .setCancelable(false) + .show() + else + onOk() +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/dialogs/ProfileSelectionDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/util/dialogs/ProfileSelectionDialog.kt new file mode 100644 index 00000000000..894b912427c --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/util/dialogs/ProfileSelectionDialog.kt @@ -0,0 +1,43 @@ +package de.westnordost.streetcomplete.util.dialogs + +import android.content.Context +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestController +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.presets.EditTypePreset +import de.westnordost.streetcomplete.data.presets.EditTypePresetsController +import de.westnordost.streetcomplete.util.ktx.toast +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +fun showProfileSelectionDialog(context: Context, editTypePresetsController: EditTypePresetsController, prefs: Preferences) { + val presets = mutableListOf() + presets.add(EditTypePreset(0, context.getString(R.string.quest_presets_default_name))) + presets.addAll(editTypePresetsController.getAll()) + var selected = -1 + (presets).forEachIndexed { index, questPreset -> + if (questPreset.id == editTypePresetsController.selectedId) + selected = index + } + var dialog: AlertDialog? = null + val array = presets.map { it.name }.toTypedArray() + val builder = AlertDialog.Builder(context) + .setTitle(R.string.quest_presets_preset_name) + .setSingleChoiceItems(array, selected) { _, i -> + if (prefs.getBoolean(Prefs.QUEST_SETTINGS_PER_PRESET, false)) { + OsmQuestController.reloadQuestTypes() + if (!prefs.getBoolean(Prefs.DYNAMIC_QUEST_CREATION, false)) + context.toast(R.string.quest_settings_per_preset_rescan, Toast.LENGTH_LONG) + } + // launch in background, because this can block for quite a while if database is occupied + GlobalScope.launch(Dispatchers.IO) { editTypePresetsController.selectedId = presets[i].id } + dialog?.dismiss() + } + .setNegativeButton(android.R.string.cancel, null) + dialog = builder.create() + dialog.show() +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Color.kt b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Color.kt index c8ad13ad9ac..5c0633218f0 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Color.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Color.kt @@ -35,10 +35,14 @@ data class RGB(val red: UByte, val green: UByte, val blue: UByte) { "#" + red.toHexString() + green.toHexString() + blue.toHexString() } +@OptIn(ExperimentalStdlibApi::class) +fun Int.toHexColor(withAlpha: Boolean = false) = if (withAlpha) + "#${toHexString()}" else "#${toHexString().takeLast(6)}" + /** Creates RGB from string in the form "#rrggbb" */ @OptIn(ExperimentalStdlibApi::class, ExperimentalUnsignedTypes::class) fun String.toRGB(): RGB { - require(length == 7 || length == 9) + require(length == 7 || length == 9) { "bad color string: $this" } require(get(0) == '#') val rgb = substring(1).hexToUByteArray() diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Context.kt b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Context.kt index a3acac5e39e..5069675b039 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Context.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Context.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.util.ktx import android.Manifest.permission.ACCESS_FINE_LOCATION import android.content.ActivityNotFoundException import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.content.pm.PackageManager import android.location.LocationManager @@ -10,12 +11,14 @@ import android.os.Build import android.view.Display import android.view.WindowManager import android.widget.Toast +import androidx.activity.ComponentActivity import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.location.LocationManagerCompat import androidx.core.net.toUri import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.BuildConfig import de.westnordost.streetcomplete.R fun Context.toast(text: CharSequence, duration: Int = Toast.LENGTH_SHORT) { @@ -47,7 +50,7 @@ fun Context.sendEmail(to: String, subject: String, text: String? = null) { val intent = Intent(Intent.ACTION_SENDTO).apply { data = "mailto:".toUri() putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) - putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_SUBJECT, "SCEE " + BuildConfig.VERSION_NAME + " " + subject) if (text != null) { putExtra(Intent.EXTRA_TEXT, text) } @@ -73,3 +76,12 @@ fun Context.openUri(uri: String): Boolean = } catch (e: ActivityNotFoundException) { false } + +fun Context.getActivity(): ComponentActivity? { + val componentActivity = when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null + } + return componentActivity +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Fragment.kt b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Fragment.kt index cccf35c2b54..ac6613f741e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Fragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Fragment.kt @@ -1,10 +1,12 @@ package de.westnordost.streetcomplete.util.ktx +import androidx.appcompat.widget.Toolbar import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import de.westnordost.streetcomplete.screens.HasTitle import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch @@ -25,3 +27,22 @@ fun Fragment.observe(flow: SharedFlow, collector: FlowCollector) { } } } + +fun Fragment.setUpToolbarTitleAndIcon(toolbar: Toolbar) { + if (this is HasTitle) { + toolbar.title = title + toolbar.subtitle = subtitle + } + + val typedArray = + toolbar.context.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.homeAsUpIndicator)) + val attributeResourceId = typedArray.getResourceId(0, 0) + val backIcon = toolbar.context.getDrawable(attributeResourceId) + typedArray.recycle() + + toolbar.setNavigationOnClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + + toolbar.navigationIcon = backIcon +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Map.kt b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Map.kt index 98a6e9a99da..327346cead4 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Map.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Map.kt @@ -1,3 +1,13 @@ package de.westnordost.streetcomplete.util.ktx fun Map.containsAll(other: Map) = other.all { this[it.key] == it.value } + +/** Returns true if the map contains any of the specified [keys]. */ +fun Map.containsAnyKey(vararg keys: K): Boolean = keys.any { this.keys.contains(it) } + +// saves memory by interning the strings +// could save (noticeably!) more by using ArrayMap, but this slows down quest creation by ca. 15% +fun Map.toInternedMap() = if (isEmpty()) emptyMap() + else HashMap(size, 0.9f).also { + forEach { (k, v) -> it[k.intern()] = v.intern() } + } diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/location/FineLocationManager.kt b/app/src/main/java/de/westnordost/streetcomplete/util/location/FineLocationManager.kt index 1b4fbdf86de..4872e48f859 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/location/FineLocationManager.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/location/FineLocationManager.kt @@ -8,12 +8,16 @@ import android.location.LocationManager.GPS_PROVIDER import android.location.LocationManager.NETWORK_PROVIDER import android.os.CancellationSignal import android.os.Looper +import android.os.SystemClock import androidx.annotation.RequiresPermission import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.location.LocationListenerCompat import androidx.core.location.LocationManagerCompat import androidx.core.util.Consumer +import de.westnordost.streetcomplete.util.ktx.elapsedDuration +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.minutes /** Convenience wrapper around the location manager with easier API, making use of both the GPS * and Network provider */ @@ -54,6 +58,8 @@ class FineLocationManager(context: Context, locationUpdateCallback: (Location) - @RequiresPermission(ACCESS_FINE_LOCATION) fun requestUpdates(minGpsTime: Long, minNetworkTime: Long, minDistance: Float) { + // update immediately with last known location if possible + getLastLocation()?.let { locationListener.onLocationChanged(it) } if (deviceHasGPS) { locationManager.requestLocationUpdates(GPS_PROVIDER, minGpsTime, minDistance, locationListener, Looper.getMainLooper()) } @@ -62,6 +68,23 @@ class FineLocationManager(context: Context, locationUpdateCallback: (Location) - } } + @RequiresPermission(ACCESS_FINE_LOCATION) + fun getLastLocation() : Location? { + if (deviceHasGPS) { + locationManager.getLastKnownLocation(GPS_PROVIDER)?.let { + if ((SystemClock.elapsedRealtimeNanos().nanoseconds - it.elapsedDuration) < 2.minutes) + return it + } + } + if (deviceHasNetworkLocationProvider) { + locationManager.getLastKnownLocation(NETWORK_PROVIDER)?.let { + if ((SystemClock.elapsedRealtimeNanos().nanoseconds - it.elapsedDuration) < 2.minutes) + return it + } + } + return null + } + @RequiresPermission(ACCESS_FINE_LOCATION) @Synchronized fun getCurrentLocation() { refreshCancellationSignals() diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/location/LocationUtils.kt b/app/src/main/java/de/westnordost/streetcomplete/util/location/LocationUtils.kt index bb71d62debf..6d86f8bd147 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/location/LocationUtils.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/location/LocationUtils.kt @@ -1,6 +1,7 @@ package de.westnordost.streetcomplete.util.location import android.location.Location +import android.location.LocationManager.GPS_PROVIDER import de.westnordost.streetcomplete.util.ktx.elapsedDuration import kotlin.time.Duration.Companion.minutes @@ -40,6 +41,7 @@ fun Location.isBetterThan(previous: Location?): Boolean { isMoreAccurate -> true isNewer && !isLessAccurate -> true isNewer && !isMuchLessAccurate && isFromSameProvider -> true + isNewer && !isMuchLessAccurate && this.provider == GPS_PROVIDER -> true else -> false } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/math/SnapToWayUtils.kt b/app/src/main/java/de/westnordost/streetcomplete/util/math/SnapToWayUtils.kt index 294ab7b57af..11a50681693 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/math/SnapToWayUtils.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/math/SnapToWayUtils.kt @@ -1,18 +1,22 @@ package de.westnordost.streetcomplete.util.math +import de.westnordost.streetcomplete.data.osm.edits.create.InsertIntoWayAt import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.geometry.ElementPolygonsGeometry import de.westnordost.streetcomplete.data.osm.geometry.ElementPolylinesGeometry import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osm.mapdata.Way import de.westnordost.streetcomplete.util.ktx.asSequenceOfPairs +import kotlin.math.abs import kotlin.math.min +@kotlinx.serialization.Serializable sealed interface PositionOnWay { val position: LatLon } /** A vertex of one or several ways */ +@kotlinx.serialization.Serializable data class VertexOfWay( val wayIds: Set, override val position: LatLon, @@ -20,12 +24,35 @@ data class VertexOfWay( ) : PositionOnWay /** A point on a segment of a way */ +@kotlinx.serialization.Serializable data class PositionOnWaySegment( val wayId: Long, override val position: LatLon, val segment: Pair ) : PositionOnWay +/** A point on one or ways on top of each other (typically but not necessarily sharing segments) */ +@kotlinx.serialization.Serializable +data class PositionOnWaysSegment( + override val position: LatLon, + val insertIntoWaysAt: List +): PositionOnWay { + init { + require(insertIntoWaysAt.map { it.wayId }.toHashSet().size == insertIntoWaysAt.size) { "can't insert in the same way more than once" } + } +} + +/** A crossing point of two ways that are not connected at the crossing */ +@kotlinx.serialization.Serializable +data class PositionOnCrossingWaySegments( + override val position: LatLon, + val insertIntoWaysAt: List +): PositionOnWay { + init { + require(insertIntoWaysAt.size == 2 && insertIntoWaysAt.first().wayId != insertIntoWaysAt.last().wayId) { "must be 2 different ways" } + } +} + /** Returns the point on any of the given ways that is nearest to this point and at most * [maxDistance] meters away from this point or null if there is no such point. * @@ -54,7 +81,7 @@ private fun LatLon.getNearestVertexOfWays( var minDistance = Double.MAX_VALUE var nearestNodeId: Long? = null var nearestPoint: LatLon? = null - var nearestWayIds = mutableSetOf() + var nearestWayIds = mutableListOf() for ((way, positions) in ways) { for ((i, nodeId) in way.nodeIds.withIndex()) { if (nodeId == nearestNodeId) { @@ -68,13 +95,13 @@ private fun LatLon.getNearestVertexOfWays( minDistance = distance nearestNodeId = nodeId nearestPoint = point - nearestWayIds = mutableSetOf(way.id) + nearestWayIds = mutableListOf(way.id) } } } if (nearestNodeId != null && nearestPoint != null) { - return VertexOfWay(nearestWayIds, nearestPoint, nearestNodeId) + return VertexOfWay(nearestWayIds.toHashSet(), nearestPoint, nearestNodeId) } return null } @@ -105,6 +132,98 @@ private fun LatLon.getNearestPositionToWays( return null } +/** same as getPositionOnWays, but using PositionOnWaysSegment instead of PositionOnWaySegment + * (i.e. allowing multiple ways), and before that checks for crossing ways */ +fun LatLon.getPositionOnWaysForInsertNodeFragment( + ways: Collection>>, + maxDistance: Double, + snapToVertexDistance: Double = 0.0 +): PositionOnWay? { + if (snapToVertexDistance > 0.0) { + val nearestVertex = getNearestVertexOfWays(ways, min(maxDistance, snapToVertexDistance)) + if (nearestVertex != null) return nearestVertex + } + // distanceToArcs is called often, and if there is no nearby position found: called (at least) + // twice for every segment. This mini-cache mitigates calling it twice (also possibly more often + // for two ways having the same segments, but that's likely negligible) + val distanceToArcsCache = HashMap, Double>(ways.size * 3) + + return getNearestCrossingOfTwoWays(ways, maxDistance, distanceToArcsCache) + ?: getNearestPositionToWaysOnMultipleWays(ways, maxDistance, distanceToArcsCache) +} + +private fun LatLon.getNearestPositionToWaysOnMultipleWays( + ways: Collection>>, + maxDistance: Double, + distanceToArcsCache: MutableMap, Double>, +): PositionOnWaysSegment? { + var minDistance = Double.MAX_VALUE + var nearestWays = mutableListOf() + for ((way, positions) in ways) { + for (segment in positions.asSequenceOfPairs()) { + // todo: do level / layer checks (maybe) + val distance = distanceToArcsCache.getOrPut(segment) { distanceToArc(segment.first, segment.second) } + if (distance > maxDistance) continue + if (abs(distance - minDistance) < 5e-4) { + // we could check whether segments are actually parallel with small distance only, + // but since we check for crossing ways right before this, there are only very few + // ways we could filter out here (very near and almost parallel) -> maybe do something if an actual situation demands it + if (nearestWays.any { it.wayId == way.id }) + continue // don't add another segment of the same way, for now don't care if the second one would be closer + nearestWays.add(InsertIntoWayAt(way.id, segment.first, segment.second)) + } else if (distance < minDistance) { + minDistance = distance + nearestWays = mutableListOf(InsertIntoWayAt(way.id, segment.first, segment.second)) + } + } + } + if (nearestWays.isNotEmpty()) { + val nearestPoint = nearestPointOnArc(nearestWays.first().pos1, nearestWays.first().pos2) + return PositionOnWaysSegment(nearestPoint, nearestWays) + } + return null +} + +fun LatLon.getNearestCrossingOfTwoWays( + ways: Collection>>, + maxDistance: Double, + distanceToArcsCache: MutableMap, Double>, +): PositionOnCrossingWaySegments? { + val nearbyWaySegments = mutableListOf>>() + for ((way, positions) in ways) { + for (segment in positions.asSequenceOfPairs()) { + val distance = distanceToArcsCache.getOrPut(segment) { distanceToArc(segment.first, segment.second) } + if (distance <= maxDistance) { + nearbyWaySegments.add(way to segment) + } + } + } + // check whether any tow of those cross (hmm, this could be many checks and also slow -> log) + var minDistance = Double.MAX_VALUE + var bestPosition: PositionOnCrossingWaySegments? = null + nearbyWaySegments.forEachIndexed { i, (way, segment) -> + // check whether it crosses any way with higher index + if (i == nearbyWaySegments.lastIndex) return@forEachIndexed + // this double loop has the potential to explode, but actually it's rarely more than 5 ways, + // and thus much faster then finding distance to segments anyway + for (j in i+1 until nearbyWaySegments.size) { + val secondSegment = nearbyWaySegments[j].second + val secondWay = nearbyWaySegments[j].first + if (way.id == secondWay.id) continue // ignore ways crossing themselves + // todo: do level / layer checks (maybe) + val intersection = intersectionOf(segment.first, segment.second, secondSegment.first, secondSegment.second) ?: continue + val distance = distanceTo(intersection) + if (distance < minDistance && distance <= maxDistance) { + minDistance = distance + val insert1 = InsertIntoWayAt(way.id, segment.first, segment.second) + val insert2 = InsertIntoWayAt(secondWay.id, secondSegment.first, secondSegment.second) + bestPosition = PositionOnCrossingWaySegments(intersection, listOf(insert1, insert2)) + } + } + } + return bestPosition +} + private val ElementGeometry.wayLatLons: List get() = when (this) { // single is safe because a way cannot have multiple polygons / polylines is ElementPolygonsGeometry -> polygons.single() diff --git a/app/src/main/java/de/westnordost/streetcomplete/view/ArrayImageAdapter.kt b/app/src/main/java/de/westnordost/streetcomplete/view/ArrayImageAdapter.kt new file mode 100644 index 00000000000..a3b3b51a1ed --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/view/ArrayImageAdapter.kt @@ -0,0 +1,30 @@ +package de.westnordost.streetcomplete.view + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.TextView +import de.westnordost.streetcomplete.util.ktx.dpToPx + +class ArrayImageAdapter(context: Context, private val items: List, imageSizeDp: Int) : + ArrayAdapter(context, android.R.layout.select_dialog_item, items) +{ + private val params = ViewGroup.LayoutParams(context.resources.dpToPx(imageSizeDp).toInt(), context.resources.dpToPx(imageSizeDp).toInt()) + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { // for non-dropdown + val view = super.getView(position, convertView, parent) + val tv = view.findViewById(android.R.id.text1) + tv.text = "" + tv.background = context.getDrawable(items[position]) + tv.layoutParams = params + return view + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + val v = (convertView as? ImageView) ?: ImageView(context) + v.setImageResource(items[position]) + v.layoutParams = params + return v + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/view/CheckIsSurvey.kt b/app/src/main/java/de/westnordost/streetcomplete/view/CheckIsSurvey.kt index 4680e9dd9d0..08f0cb00db1 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/view/CheckIsSurvey.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/view/CheckIsSurvey.kt @@ -5,6 +5,7 @@ import android.location.Location import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone +import de.westnordost.streetcomplete.BuildConfig import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.location.RecentLocationStore.Companion.MAX_DISTANCE_TO_ELEMENT_FOR_SURVEY import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry @@ -63,5 +64,5 @@ suspend fun checkIsSurvey( } // "static" values, i.e. persisted per application start -private var dontShowAgain = false +private var dontShowAgain = BuildConfig.DEBUG private var timesShown = 0 diff --git a/app/src/main/java/de/westnordost/streetcomplete/view/dialogs/SearchFeaturesDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/view/dialogs/SearchFeaturesDialog.kt index 7f7b06a4946..91dedccaa3e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/view/dialogs/SearchFeaturesDialog.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/view/dialogs/SearchFeaturesDialog.kt @@ -4,21 +4,37 @@ import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup import android.view.WindowManager +import android.widget.ImageView import androidx.appcompat.app.AlertDialog +import androidx.core.view.isEmpty import androidx.core.view.isGone +import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.russhwolf.settings.ObservableSettings +import de.westnordost.countryboundaries.CountryBoundaries import de.westnordost.osmfeatures.Feature import de.westnordost.osmfeatures.FeatureDictionary import de.westnordost.osmfeatures.GeometryType +import de.westnordost.streetcomplete.Prefs +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.meta.CountryInfos +import de.westnordost.streetcomplete.data.meta.getByLocation +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.databinding.ViewFeatureBinding import de.westnordost.streetcomplete.databinding.ViewSelectPresetBinding import de.westnordost.streetcomplete.util.getLanguagesForFeatureDictionary +import de.westnordost.streetcomplete.util.ktx.allExceptFirstAndLast +import de.westnordost.streetcomplete.util.ktx.dpToPx import de.westnordost.streetcomplete.util.ktx.hideKeyboard import de.westnordost.streetcomplete.util.ktx.nonBlankTextOrNull import de.westnordost.streetcomplete.view.ListAdapter import de.westnordost.streetcomplete.view.controller.FeatureViewController +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koin.core.qualifier.named +import java.util.Locale /** Search and select a preset */ class SearchFeaturesDialog( @@ -31,20 +47,18 @@ class SearchFeaturesDialog( private val onSelectedFeatureFn: (Feature) -> Unit, private val codesOfDefaultFeatures: List, private val dismissKeyboardOnClose: Boolean = false, -) : AlertDialog(context) { + private val pos: LatLon? = null +) : AlertDialog(context), KoinComponent { private val binding = ViewSelectPresetBinding.inflate(LayoutInflater.from(context)) private val languages = getLanguagesForFeatureDictionary(context.resources.configuration) private val adapter = FeaturesAdapter() + private val countryInfos: CountryInfos by inject() + private val countryBoundaries: Lazy by inject(named("CountryBoundariesLazy")) + private val prefs: ObservableSettings by inject() private val searchText: String? get() = binding.searchEditText.nonBlankTextOrNull - private val defaultFeatures: List by lazy { - codesOfDefaultFeatures.mapNotNull { id -> - featureDictionary.getById(id, languages = languages, country = countryOrSubdivisionCode) - } - } - init { binding.searchEditText.setText(text) binding.searchEditText.selectAll() @@ -57,22 +71,77 @@ class SearchFeaturesDialog( setView(binding.root) - window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + if (prefs.getBoolean(Prefs.CREATE_NODE_SHOW_KEYBOARD, true) || text != null || codesOfDefaultFeatures.isEmpty()) + window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + + val params = ViewGroup.LayoutParams(context.resources.dpToPx(58).toInt(), context.resources.dpToPx(58).toInt()) + codesOfDefaultFeatures.forEach { + val resId = iconOnlyFeatures[it] ?: return@forEach + val feature = featureDictionary.byId(it).get() ?: return@forEach + binding.shortcuts.addView(ImageView(context).apply { + setImageResource(resId) + layoutParams = params + setOnClickListener { + onSelectedFeatureFn(feature) + dismiss() + } + }) + } + if (!binding.shortcuts.isEmpty()) + binding.shortcutScrollView.isVisible = true updateSearchResults() } - private fun getFeatures(startsWith: String): List = + // todo: this could use some update after SC changes + private fun getFeatures(startsWith: String): List { featureDictionary.getByTerm( search = startsWith, languages = languages, country = countryOrSubdivisionCode, geometry = geometryType, ).filter(filterFn).take(50).toList() + return if (prefs.getBoolean(Prefs.SEARCH_MORE_LANGUAGES, false)) { + // even if there are many languages, UI stuff will likely be slower than the multiple searches + val otherLocales = languages.toList().allExceptFirstAndLast() + // first is default, last is null + (pos?.let { p -> + val c = countryInfos.getByLocation(countryBoundaries.value, p.longitude, p.latitude) + c.officialLanguages.map { Locale(it, c.countryCode).toLanguageTag() } + } ?: emptyList()) + (featureDictionary.getByTerm( // get default results + search = startsWith, + languages = languages, + country = countryOrSubdivisionCode, + geometry = geometryType, + ).filter(filterFn).take(50).toList() + + otherLocales.toSet().flatMap { + if (it == null) return@flatMap emptyList() + featureDictionary.getByTerm( // plus results for each additional locale + search = startsWith, + languages = listOf(it), + country = countryOrSubdivisionCode, + geometry = geometryType, + ).filter(filterFn).take(50).toList() + }).distinctBy { it.id } + } else + featureDictionary.getByTerm( + search = startsWith, + languages = languages, + country = countryOrSubdivisionCode, + geometry = geometryType, + ).filter(filterFn).take(50).toList() + } private fun updateSearchResults() { val text = searchText - val list = if (text == null) defaultFeatures else getFeatures(text) + val list = if (text == null) codesOfDefaultFeatures.filterNot { it in iconOnlyFeatures }.mapNotNull { + featureDictionary.getById( + id = it, + languages = languages, + country = countryOrSubdivisionCode + ) + } else + getFeatures(text) adapter.list = list.toMutableList() binding.noResultsText.isGone = list.isNotEmpty() } @@ -109,3 +178,47 @@ class SearchFeaturesDialog( } } } + +// todo: weird mix of pin icons, quest icons, temaki icons +// ideally all would be same style, especially avoid monochrome temaki icons +// the colors really help a lot for finding the right icon very quickly +private val iconOnlyFeatures = mapOf( + "amenity/bench" to R.drawable.ic_preset_temaki_bench, + "amenity/lounger" to R.drawable.ic_preset_temaki_lounger, + "amenity/bicycle_parking" to R.drawable.ic_quest_bicycle_parking, + "amenity/motorcycle_parking" to R.drawable.ic_quest_motorcycle_parking, + "leisure/picnic_table" to R.drawable.ic_preset_maki_picnic_site, + "amenity/waste_basket" to R.drawable.ic_preset_maki_waste_basket, + "amenity/recycling_container" to R.drawable.ic_quest_recycling_container, + "amenity/bicycle_repair_station" to R.drawable.ic_quest_bicycle_repair, + "amenity/drinking_water" to R.drawable.ic_quest_drinking_water, + "emergency/fire_hydrant" to R.drawable.ic_quest_fire_hydrant, + "amenity/vending_machine" to R.drawable.ic_preset_temaki_vending_machine, + "amenity/vending_machine/cigarettes" to R.drawable.ic_preset_temaki_vending_cigarettes, + "amenity/vending_machine/excrement_bags" to R.drawable.ic_preset_temaki_vending_pet_waste, + "amenity/vending_machine/public_transport_tickets" to R.drawable.ic_preset_temaki_vending_tickets, + "amenity/vending_machine/drinks" to R.drawable.ic_preset_temaki_vending_cold_drink, + "amenity/atm" to R.drawable.ic_quest_money, + "natural/tree" to R.drawable.ic_quest_tree, + "tourism/information/guidepost" to R.drawable.ic_quest_destination, + "amenity/post_box" to R.drawable.ic_quest_mail, + "amenity/charging_station" to R.drawable.ic_quest_car_charger, + "highway/street_lamp" to R.drawable.ic_preset_temaki_street_lamp_arm, + "man_made/surveillance/camera" to R.drawable.ic_quest_surveillance_camera, + "highway/speed_camera" to R.drawable.ic_preset_temaki_security_camera, + "highway/crossing/unmarked" to R.drawable.ic_quest_pedestrian, + "highway/crossing/uncontrolled" to R.drawable.ic_quest_pedestrian_crossing, + "highway/crossing/traffic_signals" to R.drawable.ic_quest_blind_traffic_lights_sound, + "highway/traffic_signals" to R.drawable.ic_quest_traffic_lights, + "barrier/kerb" to R.drawable.ic_quest_kerb_tactile_paving, + "barrier/kerb/flush" to R.drawable.ic_preset_temaki_kerb_flush, + "barrier/kerb/rolled" to R.drawable.ic_preset_temaki_kerb_rolled, + "barrier/kerb/raised" to R.drawable.ic_preset_temaki_kerb_raised, + "barrier/kerb/lowered" to R.drawable.ic_preset_temaki_kerb_lowered, + "barrier/bollard" to R.drawable.ic_preset_temaki_bollard, + "traffic_calming/table" to R.drawable.ic_preset_temaki_speed_table, + "traffic_calming/bump" to R.drawable.ic_preset_temaki_speed_bump, + "entrance" to R.drawable.ic_quest_door, + "highway/stop" to R.drawable.ic_preset_temaki_stop, + "highway/give_way" to R.drawable.ic_preset_temaki_yield, +) diff --git a/app/src/main/java/de/westnordost/streetcomplete/view/image_select/FilteredDisplayItem.kt b/app/src/main/java/de/westnordost/streetcomplete/view/image_select/FilteredDisplayItem.kt new file mode 100644 index 00000000000..db00081ed50 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/view/image_select/FilteredDisplayItem.kt @@ -0,0 +1,55 @@ +package de.westnordost.streetcomplete.view.image_select + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import androidx.annotation.DrawableRes +import androidx.core.graphics.blue +import androidx.core.graphics.green +import androidx.core.graphics.red +import de.westnordost.streetcomplete.view.CharSequenceText +import de.westnordost.streetcomplete.view.DrawableImage +import de.westnordost.streetcomplete.view.Image +import de.westnordost.streetcomplete.view.Text + +abstract class FilteredDisplayItem(override val value: T, val context: Context) : + DisplayItem where T : OsmColour { + override val title: Text? get() = CharSequenceText(value.osmValue) + override val description: Text? = null + + @DrawableRes + var iconResId: Int = 0 + + override val image: Image + get() { + val color = Color.parseColor(value.androidValue ?: value.osmValue) + val contrastColor = getBestContrast(context) + val drawable = context.getDrawable(iconResId)!!.mutate() + val matrix = ColorMatrix( + floatArrayOf( + color.red / 255f, 0f, contrastColor.red / 255f, 0f, 0f, + color.green / 255f, 0f, contrastColor.green / 255f, 0f, 0f, + color.blue / 255f, 0f, contrastColor.blue / 255f, 0f, 0f, + 1f, 1f, 1f, 1f, 0f + ) + ) + drawable.colorFilter = ColorMatrixColorFilter(matrix) + return DrawableImage(drawable) + } + + override fun hashCode(): Int = (value.androidValue ?: value.osmValue).hashCode() + override fun equals(other: Any?): Boolean = + (other is FilteredDisplayItem<*>) && ((other.value.androidValue + ?: value.osmValue) == (value.androidValue ?: value.osmValue)) +} + +private fun isDarkMode(context: Context): Boolean { + val darkModeFlag = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return darkModeFlag == Configuration.UI_MODE_NIGHT_YES +} + +private fun getBestContrast(context: Context): Int { + return if (isDarkMode(context)) Color.LTGRAY else Color.DKGRAY +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/view/image_select/OsmColour.kt b/app/src/main/java/de/westnordost/streetcomplete/view/image_select/OsmColour.kt new file mode 100644 index 00000000000..52ed50c13e3 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/view/image_select/OsmColour.kt @@ -0,0 +1,6 @@ +package de.westnordost.streetcomplete.view.image_select + +interface OsmColour { + val androidValue: String? + val osmValue: String +} diff --git a/app/src/main/res/authors.txt b/app/src/main/res/authors.txt index 995c9995d98..093ddd4134e 100644 --- a/app/src/main/res/authors.txt +++ b/app/src/main/res/authors.txt @@ -34,6 +34,13 @@ barrier_bicycle_installation_f… CC0 LEGOFAHRRAD https://commons. barrier_bicycle_installation_o… CC-BY-SA 4.0 Ellievking1 https://commons.wikimedia.org/wiki/File:Barrier_on_Formartine_and_Buchan_cycle_route_at_Auchnagatt.jpg barrier_bicycle_installation_r… CC0 LEGOFAHRRAD https://commons.wikimedia.org/wiki/File:Cycle_barrier_removable_detail.jpg +bench_concrete.jpg CC-BY-SA 4.0 AgainErick https://commons.wikimedia.org/wiki/File:Street_bench_-_Diergaarde_Blijdorp_-_Rotterdam_-_Concrete.jpg +bench_metal.jpg CC-BY-SA 2.5 Finne Boonen https://commons.wikimedia.org/wiki/File:Botanique_bank.jpg +bench_plastic.jpg CC-BY-SA 3.0 Lionel Allorge https://commons.wikimedia.org/wiki/File:Aire_de_jeux_%C3%A0_Les_Moli%C3%A8res_le_9_ao%C3%BBt_2016_-_12.jpg +bench_wood.jpg CC-BY-SA 2.0 Ronald Saunders https://commons.wikimedia.org/wiki/File:Flickr_-_ronsaunders47_-_POND_VIEW_BENCH._BIRCHWOOD_.WARRINGTON..jpg +bench_brick.jpg CC-BY-SA 2.0 niiicedave https://wordpress.org/openverse/image/990a1a66-9332-456c-a0c5-0a7b6d7fff4f/ +bench_stone.jpg CC-BY-SA 3.0 Andrew Butko https://commons.wikimedia.org/wiki/File:%D0%9B%D0%B8%D0%B2%D0%B0%D0%B4%D0%B8%D0%B9%D1%81%D0%BA%D0%B8%D0%B9_%D0%B4%D0%B2%D0%BE%D1%80%D0%B5%D1%86_053.jpg + bicycle_parking_type_buildi... CC-BY-SA 3.0 Visitor7 https://commons.wikimedia.org/wiki/File:Bike_Storage_(YAUS).jpg bicycle_parking_type_locker... CC-BY-SA 3.0 Rept0n1x https://commons.wikimedia.org/wiki/File:Bicycle_lockers_at_Gathurst_railway_station.JPG bicycle_parking_type_safel... CC0 1.0 RogerWiki https://commons.wikimedia.org/wiki/File:Fahrradst%C3%A4nder_mit_Anlehnb%C3%BCgel_2.jpg @@ -41,6 +48,7 @@ bicycle_parking_type_shed.jpg CC-BY-SA 3.0 Tabl-trai https://commons.wikim bicycle_parking_type_stand.jpg CC0 Mateusz Konieczny https://commons.wikimedia.org/wiki/File:Bicycle_parking_stand_2.jpg bicycle_parking_type_wheelb... CC-BY-SA 4.0 Joxemai https://commons.wikimedia.org/wiki/File:Bizikletak_lotzekoa.JPG bicycle_parking_type_handle... CC-BY-SA 4.0 Hiddewie https://commons.wikimedia.org/wiki/File:Bicycle_parking_handlebar_holder.jpg +bicycle_parking_type_saddle... CC-BY-SA 4.0 mcliquid https://commons.wikimedia.org/wiki/File:Fahrradparkplatz_Sattelhalter_1.jpg bicycle_parking_type_two_ti... CC0 Bukk https://commons.wikimedia.org/wiki/File:Double_stack_bike_stand.jpg bicycle_parking_type_floor CC0 Nakaner https://wiki.openstreetmap.org/wiki/File:Fahrradparkplatz_mit_Schild_und_ohne_Buegel.jpg @@ -66,6 +74,21 @@ car_wash_automated.jpg CC-BY-SA 3.0 Michiel1972 https://commons.wik car_wash_self_service.jpg CC-BY-SA 4.0 Jacek Halicki https://commons.wikimedia.org/wiki/File:2015_K%C5%82odzko,_ul._Dusznicka,_myjnia_samochodowa_02.jpg car_wash_service.jpg Public Domain https://commons.wikimedia.org/wiki/File:US_Navy_110831-N-NT881-079_Sailors_assigned_to_the_guided-missile_submarine_USS_Ohio_(SSGN_726)_and_Navy_Operational_Support_Center_Cincinnati_part.jpg +crossing_markings_no.jpg CC0 https://wiki.openstreetmap.org/wiki/File:Crossing_without_markings.png (StenSoft) +crossing_markings_zebra.jpg CC0 https://wiki.openstreetmap.org/wiki/File:Markings_zebra.png (Popbal) +crossing_markings_lines.jpg CC0 https://wiki.openstreetmap.org/wiki/File:Markings_lines.png (Popball) +crossing_markings_ladder.jpg CC0 https://wiki.openstreetmap.org/wiki/File:Markings_ladder.png (Popball) +crossing_markings_dashes.jpg CC0 https://wiki.openstreetmap.org/wiki/File:Markings_dashes.png (Popball) +crossing_markings_dots.jpg CC0 https://wiki.openstreetmap.org/wiki/File:Markings_dots.png (Popball) +crossing_markings_surface.jpg CC0 https://wiki.openstreetmap.org/wiki/File:Surface_crossing_markings.png (Popball) +crossing_markings_ladder_skew… CC0 https://wiki.openstreetmap.org/wiki/File:Markings_adder_skewed.png (Popball) +crossing_markings_zebra_paire… CC0 https://wiki.openstreetmap.org/wiki/File:Markings_adder_skewed.png (Popball) +crossing_markings_zebra_bicol… CC0 https://wiki.openstreetmap.org/wiki/File:Markings_zebra_bicolour.png (Popball) +crossing_markings_zebra_doubl… CC0 https://wiki.openstreetmap.org/wiki/File:Crossing_markings_zebra_double.png (Popball) +crossing_markings_ladder_pair… CC0 https://wiki.openstreetmap.org/wiki/File:Paired_ladder.png (Popball) +crossing_markings_zebra_dots.… CC0 Alexis Lecanu +crossing_markings_pictograms.… CC0 https://wiki.openstreetmap.org/wiki/File:400px-Crossing_markings_pictogram.png (Kaligrafy) + drinking_water_type_jet_water… CC-BY-SA 3.0 crop of image by Sulfur https://commons.wikimedia.org/wiki/File:Bubbler.jpg drinking_water_type_generic... CC-BY-SA 4.0 crop of image by Basotxerri https://commons.wikimedia.org/wiki/File:Bielerh%C3%B6he_-_Barbarakapelle_-_Brunnen_01.jpg drinking_water_type_bottle_re… CC-BY-SA 4.0 crop of image by Fantaglobe11 https://commons.wikimedia.org/wiki/File:Station_Alkmaar_watertappunt.jpg @@ -135,6 +158,8 @@ ic_preset_maki-* CC0-1.0 from https://github.com/mapbox/ ic_preset_temaki-* CC0 1.0 from https://github.com/ideditor/temaki/tree/main/icons +ic_quest_sac_scale.svg CC-BY 4.0 Delesign Graphics https://iconscout.com/free-illustration/man-climbing-on-mountain-2061832 + kerb_height_raised.jpg CC0 Mateusz Konieczny (see res/graphics/kerbs/kerb_height_raised_original.jpg) kerb_height_lowered.jpg CC0 Mateusz Konieczny (see res/graphics/kerbs/kerb_height_lowered_original.jpg) kerb_height_flush.jpg CC0 Mateusz Konieczny (see res/graphics/kerbs/kerb_height_flush_original.jpg) @@ -234,6 +259,23 @@ recycling_centre.jpg Public Domain https://commons.wikimedia.org/w recycling_container.jpg CC0 https://commons.wikimedia.org/wiki/File:Vitoria_-_Contenedores_de_reciclaje_en_la_Avenida_de_Gasteiz.jpg recycling_container_undergr... CC0 https://commons.wikimedia.org/wiki/File:San_Fernando_de_Henares_-_reciclaje_de_residuos_urbanos_04.JPG +sac_scale_strolling.jpg CC-BY 4.0 https://wiki.openstreetmap.org/wiki/File:AF_1_C_-_Mendon_Ponds.jpg (Adam Franco) +sac_scale_t1.jpg CC-BY-SA 4.0 https://wiki.openstreetmap.org/wiki/File:SAC_SCALE_T1.jpg (Marco Volken) +sac_scale_t2.jpg CC-BY-SA 4.0 https://wiki.openstreetmap.org/wiki/File:SAC_SCALE_T2.jpg (Marco Volken) +sac_scale_t3.jpg CC-BY-SA 4.0 https://wiki.openstreetmap.org/wiki/File:SAC_SCALE_T3.jpg (Marco Volken) +sac_scale_t4.jpg CC-BY-SA 4.0 https://wiki.openstreetmap.org/wiki/File:SAC_SCALE_T4.jpg (Marco Volken) +sac_scale_t5.jpg CC-BY-SA 4.0 https://wiki.openstreetmap.org/wiki/File:SAC_SCALE_T5.jpg (Marco Volken) +sac_scale_t6.jpg CC-BY-SA 4.0 https://wiki.openstreetmap.org/wiki/File:SAC_SCALE_T6.jpg (Marco Volken) + +shelter_type_public_transpor… CC-BY 2.0 Janusz Jakubowski https://commons.wikimedia.org/wiki/File:Bus_shelter_in_Warsaw_(25843621920).jpg +shelter_type_picnic_shelter.… CC-BY-SA 3.0 B.navez https://wiki.openstreetmap.org/wiki/File:R%C3%A9union_Ma%C3%AFdo_kiosque_pique-nique.JPG +shelter_type_gazebo.jpg CC-BY 2.0 Tim Green https://commons.wikimedia.org/wiki/File:Shelter,_Roundhay_Park_(3511090304).jpg +shelter_type_lean_to.jpg CC-BY-SA 3.0 j doll https://commons.wikimedia.org/wiki/File:Keauhou_Shelter_(145211875).jpeg +shelter_type_basic_hut.jpg CC-BY-SA 4.0 Eginhard https://commons.wikimedia.org/wiki/File:Schutzh%C3%BCtte_Lauchb%C3%BChl_2021-06-19.jpg +shelter_type_sun_shelter.jpg CC-BY-SA 2.0 blmoregon https://commons.wikimedia.org/wiki/File:Oregon_Trail_-_Keeney_Historic_Site.jpg +shelter_type_field_shelter… CC-BY-SA 4.0 Kolforn https://commons.wikimedia.org/wiki/File:-2018-10-03_Free_range_pigs,_Southrepps_(1).JPG +shelter_type_rock_shelter.jpg CC-BY 2.0 Bureau of Land Management Oregon and Washington https://commons.wikimedia.org/wiki/File:Rock_Shelter_(11410676553).jpg + sliding_envelope.wav CC0 https://freesound.org/people/MTJohnson/sounds/444431/ snip.wav CC0 https://freesound.org/people/Godowan/sounds/240473/ @@ -327,8 +369,30 @@ tourism_information_map.jpg CC0 https://commons.wikimedia.org/w tourism_information_office.jpg CC-BY-SA 4.0 https://commons.wikimedia.org/wiki/File:Tourist_information_shop,_Delft_(2018).jpg (Donald Trung) tourism_information_termina... CC-BY 4.0 https://wiki.openstreetmap.org/wiki/File:Uh%C5%99%C3%ADn%C4%9Bves,_Nov%C3%A9_n%C3%A1m%C4%9Bst%C3%AD,_informa%C4%8Dn%C3%AD_stojan.jpg (ŠJů) +valves_schrader.jpg Free Art License BohwaZ https://commons.wikimedia.org/wiki/File:Valve_Schrader.jpg +valves_sclaverand.jpg Free Art License BohwaZ https://commons.wikimedia.org/wiki/File:Valve_Presta.JPG +valves_dunlop.jpg Free Art License BohwaZ https://commons.wikimedia.org/wiki/File:Valve_Dunlop.JPG +valves_regina.jpg Free Art License BohwaZ https://commons.wikimedia.org/wiki/File:Valve_Regina_avec_son_bouchon.jpg + + +via_ferrata_scale_0.jpg CC-BY-SA-2.0 https://wiki.openstreetmap.org/wiki/File:Super_easy_ferrata.jpg +via_ferrata_scale_1.jpg CC-BY-SA-3.0 https://wiki.openstreetmap.org/wiki/File:Alpspitz-ferrata-a.jpg +via_ferrata_scale_2.jpg CC-BY-SA-2.0 https://wiki.openstreetmap.org/wiki/File:Alpspitz-ferrata-b.jpg +via_ferrata_scale_3.jpg CC-BY-SA-2.0 https://wiki.openstreetmap.org/wiki/File:Absamer_klettersteig.jpg +via_ferrata_scale_4.jpg CC-BY-SA-2.0 https://wiki.openstreetmap.org/wiki/File:Mauerlaeufer_ueberhang.jpg +via_ferrata_scale_5.jpg CC-BY-SA-2.0 https://wiki.openstreetmap.org/wiki/File:Bergfuehrerquergang.jpg +via_ferrata_scale_6.jpg CC-BY-SA-2.5 https://commons.wikimedia.org/wiki/File:Eggst%C3%B6cke_-_H.jpg + vibrating_button_illustrati... CC-BY 4.0 Tobias Zwick vibrating_button_i... (MCC234) CC0 CJ Malone vibrating_button_i... (MCC505) CC0 https://commons.wikimedia.org/wiki/File:An_Australian_pedestrian_crossing_button.jpg (James Cridland) informal_crossing.jpg CC-SA 2.0 https://wiki.openstreetmap.org/wiki/File:West_Highland_Way_crossing_road_-_geograph.org.uk_-_3986498_(cropped).jpg +ic_quest_tree.xml Public Domain https://commons.wikimedia.org/wiki/File:Broccoli-tree.svg (Dvortygirl) +ic_quest_website.xml Public Domain https://commons.wikimedia.org/wiki/File:Web_(89510)_-_The_Noun_Project.svg + +crossing_type_signals_zebra... CC BY-SA 4.0 https://commons.wikimedia.org/wiki/File:Passage_pi%C3%A9ton_Cours_Lafayette_%C3%A0_Lyon_trottoir_oppos%C3%A9,_d%C3%A9but_du_Square_J%C3%A9r%C3%B4me_B%C3%A9rerd.jpg (Benoît Prieur) +crossing_type_marked.jpg CC BY-SA 4.0 https://commons.wikimedia.org/wiki/File:Pedestrian_crossing_Gympie_Rd_and_Murphy_Rd_pedestrian_crossing_DSCF5782.jpg (John Robert McPherson) + +surface_metal_grid.jpg CC-BY-SA 3.0 https://commons.wikimedia.org/wiki/File:Press_grating.jpg (Belt777) +surface_stepping_stones.jpg CC-BY-SA 2.0 https://wiki.openstreetmap.org/wiki/File:Stepping_stones_-_geograph.org.uk_-_832601.jpg (Keith Evans) diff --git a/app/src/main/res/drawable-hdpi/bench_brick.jpg b/app/src/main/res/drawable-hdpi/bench_brick.jpg new file mode 100644 index 00000000000..f00588cc8a1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/bench_brick.jpg differ diff --git a/app/src/main/res/drawable-hdpi/bench_concrete.jpg b/app/src/main/res/drawable-hdpi/bench_concrete.jpg new file mode 100644 index 00000000000..e141083c116 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/bench_concrete.jpg differ diff --git a/app/src/main/res/drawable-hdpi/bench_metal.jpg b/app/src/main/res/drawable-hdpi/bench_metal.jpg new file mode 100644 index 00000000000..60a7bf5db44 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/bench_metal.jpg differ diff --git a/app/src/main/res/drawable-hdpi/bench_plastic.jpg b/app/src/main/res/drawable-hdpi/bench_plastic.jpg new file mode 100644 index 00000000000..f4f79e8124c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/bench_plastic.jpg differ diff --git a/app/src/main/res/drawable-hdpi/bench_stone.jpg b/app/src/main/res/drawable-hdpi/bench_stone.jpg new file mode 100644 index 00000000000..026752c0922 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/bench_stone.jpg differ diff --git a/app/src/main/res/drawable-hdpi/bench_wood.jpg b/app/src/main/res/drawable-hdpi/bench_wood.jpg new file mode 100644 index 00000000000..0a3b67fb921 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/bench_wood.jpg differ diff --git a/app/src/main/res/drawable-hdpi/bicycle_parking_type_saddleholder.jpg b/app/src/main/res/drawable-hdpi/bicycle_parking_type_saddleholder.jpg new file mode 100644 index 00000000000..ea44ee939a4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/bicycle_parking_type_saddleholder.jpg differ diff --git a/app/src/main/res/drawable-hdpi/bicycle_parking_type_stand.jpg b/app/src/main/res/drawable-hdpi/bicycle_parking_type_stand.jpg index bdf3ebde95c..b693995b12c 100644 Binary files a/app/src/main/res/drawable-hdpi/bicycle_parking_type_stand.jpg and b/app/src/main/res/drawable-hdpi/bicycle_parking_type_stand.jpg differ diff --git a/app/src/main/res/drawable-hdpi/bicycle_rental_docking_station.jpg b/app/src/main/res/drawable-hdpi/bicycle_rental_docking_station.jpg index 4a33771247b..11947a72a59 100644 Binary files a/app/src/main/res/drawable-hdpi/bicycle_rental_docking_station.jpg and b/app/src/main/res/drawable-hdpi/bicycle_rental_docking_station.jpg differ diff --git a/app/src/main/res/drawable-hdpi/bicycle_rental_dropoff_point.jpg b/app/src/main/res/drawable-hdpi/bicycle_rental_dropoff_point.jpg index c3ef556ee5d..59ddc55696a 100644 Binary files a/app/src/main/res/drawable-hdpi/bicycle_rental_dropoff_point.jpg and b/app/src/main/res/drawable-hdpi/bicycle_rental_dropoff_point.jpg differ diff --git a/app/src/main/res/drawable-hdpi/bicycle_rental_human.jpg b/app/src/main/res/drawable-hdpi/bicycle_rental_human.jpg index 57874ef0f88..1950791b491 100644 Binary files a/app/src/main/res/drawable-hdpi/bicycle_rental_human.jpg and b/app/src/main/res/drawable-hdpi/bicycle_rental_human.jpg differ diff --git a/app/src/main/res/drawable-hdpi/bicycle_rental_shop_with_rental.jpg b/app/src/main/res/drawable-hdpi/bicycle_rental_shop_with_rental.jpg index 8c109d18ddb..8584a05c93b 100644 Binary files a/app/src/main/res/drawable-hdpi/bicycle_rental_shop_with_rental.jpg and b/app/src/main/res/drawable-hdpi/bicycle_rental_shop_with_rental.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_adobe.jpg b/app/src/main/res/drawable-hdpi/building_material_adobe.jpg new file mode 100644 index 00000000000..7644f42c34b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_adobe.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_bamboo.jpg b/app/src/main/res/drawable-hdpi/building_material_bamboo.jpg new file mode 100644 index 00000000000..bce83db9a94 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_bamboo.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_brick.jpg b/app/src/main/res/drawable-hdpi/building_material_brick.jpg new file mode 100644 index 00000000000..a24723abe04 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_brick.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_cement_block.jpg b/app/src/main/res/drawable-hdpi/building_material_cement_block.jpg new file mode 100644 index 00000000000..c91bbf726e7 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_cement_block.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_clay.jpg b/app/src/main/res/drawable-hdpi/building_material_clay.jpg new file mode 100644 index 00000000000..817991b6586 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_clay.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_concrete.jpg b/app/src/main/res/drawable-hdpi/building_material_concrete.jpg new file mode 100644 index 00000000000..6818ef09ef7 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_concrete.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_glass.jpg b/app/src/main/res/drawable-hdpi/building_material_glass.jpg new file mode 100644 index 00000000000..300e06260c2 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_glass.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_limestone.jpg b/app/src/main/res/drawable-hdpi/building_material_limestone.jpg new file mode 100644 index 00000000000..0f8463e42d3 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_limestone.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_loam.jpg b/app/src/main/res/drawable-hdpi/building_material_loam.jpg new file mode 100644 index 00000000000..842dbea57b1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_loam.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_marble.jpg b/app/src/main/res/drawable-hdpi/building_material_marble.jpg new file mode 100644 index 00000000000..08281213002 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_marble.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_metal.jpg b/app/src/main/res/drawable-hdpi/building_material_metal.jpg new file mode 100644 index 00000000000..4ccfd3f7540 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_metal.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_mirror.jpg b/app/src/main/res/drawable-hdpi/building_material_mirror.jpg new file mode 100644 index 00000000000..3e3ed4c58d8 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_mirror.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_mud.jpg b/app/src/main/res/drawable-hdpi/building_material_mud.jpg new file mode 100644 index 00000000000..8d0d6016d1e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_mud.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_plaster.jpg b/app/src/main/res/drawable-hdpi/building_material_plaster.jpg new file mode 100644 index 00000000000..1c8a7df580e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_plaster.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_plastic.jpg b/app/src/main/res/drawable-hdpi/building_material_plastic.jpg new file mode 100644 index 00000000000..08a8856c3b3 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_plastic.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_reed.jpg b/app/src/main/res/drawable-hdpi/building_material_reed.jpg new file mode 100644 index 00000000000..75ef760d21f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_reed.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_sandstone.jpg b/app/src/main/res/drawable-hdpi/building_material_sandstone.jpg new file mode 100644 index 00000000000..8c089950e07 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_sandstone.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_slate.jpg b/app/src/main/res/drawable-hdpi/building_material_slate.jpg new file mode 100644 index 00000000000..1bf78f1ca85 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_slate.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_stone.jpg b/app/src/main/res/drawable-hdpi/building_material_stone.jpg new file mode 100644 index 00000000000..6b5fabc704a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_stone.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_tiles.jpg b/app/src/main/res/drawable-hdpi/building_material_tiles.jpg new file mode 100644 index 00000000000..6e936b33d17 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_tiles.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_timber_framing.jpg b/app/src/main/res/drawable-hdpi/building_material_timber_framing.jpg new file mode 100644 index 00000000000..81ed66c77aa Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_timber_framing.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_vinyl.jpg b/app/src/main/res/drawable-hdpi/building_material_vinyl.jpg new file mode 100644 index 00000000000..ed85eb8e1c0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_vinyl.jpg differ diff --git a/app/src/main/res/drawable-hdpi/building_material_wood.jpg b/app/src/main/res/drawable-hdpi/building_material_wood.jpg new file mode 100644 index 00000000000..b4c2c7b06ab Binary files /dev/null and b/app/src/main/res/drawable-hdpi/building_material_wood.jpg differ diff --git a/app/src/main/res/drawable-hdpi/crossing_type_marked.jpg b/app/src/main/res/drawable-hdpi/crossing_type_marked.jpg new file mode 100644 index 00000000000..6a4859dc9c5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/crossing_type_marked.jpg differ diff --git a/app/src/main/res/drawable-hdpi/crossing_type_signals_zebra.jpg b/app/src/main/res/drawable-hdpi/crossing_type_signals_zebra.jpg new file mode 100644 index 00000000000..3dad7e49398 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/crossing_type_signals_zebra.jpg differ diff --git a/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_green.jpg b/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_green.jpg index 4d9d065b791..a244b7a2a62 100644 Binary files a/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_green.jpg and b/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_green.jpg differ diff --git a/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_lane.jpg b/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_lane.jpg index 719a263c06f..acff463511f 100644 Binary files a/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_lane.jpg and b/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_lane.jpg differ diff --git a/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_parking.jpg b/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_parking.jpg index dcc78c124f0..724f3b397a8 100644 Binary files a/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_parking.jpg and b/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_parking.jpg differ diff --git a/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_sidewalk.jpg b/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_sidewalk.jpg index b247702caeb..7203eb2ec68 100644 Binary files a/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_sidewalk.jpg and b/app/src/main/res/drawable-hdpi/fire_hydrant_position_pillar_sidewalk.jpg differ diff --git a/app/src/main/res/drawable-hdpi/ic_roof_crosspitched.png b/app/src/main/res/drawable-hdpi/ic_roof_crosspitched.png new file mode 100644 index 00000000000..020e49aae24 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_roof_crosspitched.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_roof_gabled_height_moved.png b/app/src/main/res/drawable-hdpi/ic_roof_gabled_height_moved.png new file mode 100644 index 00000000000..55f6eab7d37 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_roof_gabled_height_moved.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_roof_hip_and_gable.png b/app/src/main/res/drawable-hdpi/ic_roof_hip_and_gable.png new file mode 100644 index 00000000000..7e7590372b0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_roof_hip_and_gable.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_roof_sawtooth.png b/app/src/main/res/drawable-hdpi/ic_roof_sawtooth.png new file mode 100644 index 00000000000..4de37677957 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_roof_sawtooth.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_roof_side_half_hipped.png b/app/src/main/res/drawable-hdpi/ic_roof_side_half_hipped.png new file mode 100644 index 00000000000..ddfd5575592 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_roof_side_half_hipped.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_roof_side_hipped.png b/app/src/main/res/drawable-hdpi/ic_roof_side_hipped.png new file mode 100644 index 00000000000..9893036566b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_roof_side_hipped.png differ diff --git a/app/src/main/res/drawable-hdpi/map_type_scheme.jpg b/app/src/main/res/drawable-hdpi/map_type_scheme.jpg new file mode 100644 index 00000000000..5320e7baf3f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/map_type_scheme.jpg differ diff --git a/app/src/main/res/drawable-hdpi/map_type_street.jpg b/app/src/main/res/drawable-hdpi/map_type_street.jpg new file mode 100644 index 00000000000..6ef1196edd0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/map_type_street.jpg differ diff --git a/app/src/main/res/drawable-hdpi/map_type_topo.jpg b/app/src/main/res/drawable-hdpi/map_type_topo.jpg new file mode 100644 index 00000000000..f9c43d11775 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/map_type_topo.jpg differ diff --git a/app/src/main/res/drawable-hdpi/map_type_toposcope.jpg b/app/src/main/res/drawable-hdpi/map_type_toposcope.jpg new file mode 100644 index 00000000000..148187bc0ad Binary files /dev/null and b/app/src/main/res/drawable-hdpi/map_type_toposcope.jpg differ diff --git a/app/src/main/res/drawable-hdpi/recycling_centre.jpg b/app/src/main/res/drawable-hdpi/recycling_centre.jpg index 4a1a6ca1273..8fb4c6cda77 100644 Binary files a/app/src/main/res/drawable-hdpi/recycling_centre.jpg and b/app/src/main/res/drawable-hdpi/recycling_centre.jpg differ diff --git a/app/src/main/res/drawable-hdpi/recycling_container.jpg b/app/src/main/res/drawable-hdpi/recycling_container.jpg index 4a96711289f..8fd3ca65625 100644 Binary files a/app/src/main/res/drawable-hdpi/recycling_container.jpg and b/app/src/main/res/drawable-hdpi/recycling_container.jpg differ diff --git a/app/src/main/res/drawable-hdpi/recycling_container_underground.jpg b/app/src/main/res/drawable-hdpi/recycling_container_underground.jpg index 0419ad55e17..1755ea20a40 100644 Binary files a/app/src/main/res/drawable-hdpi/recycling_container_underground.jpg and b/app/src/main/res/drawable-hdpi/recycling_container_underground.jpg differ diff --git a/app/src/main/res/drawable-hdpi/sac_scale_strolling.jpg b/app/src/main/res/drawable-hdpi/sac_scale_strolling.jpg new file mode 100644 index 00000000000..ecdb0e58e84 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/sac_scale_strolling.jpg differ diff --git a/app/src/main/res/drawable-hdpi/sac_scale_t1.jpg b/app/src/main/res/drawable-hdpi/sac_scale_t1.jpg new file mode 100644 index 00000000000..813b59010de Binary files /dev/null and b/app/src/main/res/drawable-hdpi/sac_scale_t1.jpg differ diff --git a/app/src/main/res/drawable-hdpi/sac_scale_t2.jpg b/app/src/main/res/drawable-hdpi/sac_scale_t2.jpg new file mode 100644 index 00000000000..e0d86e9be4a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/sac_scale_t2.jpg differ diff --git a/app/src/main/res/drawable-hdpi/sac_scale_t3.jpg b/app/src/main/res/drawable-hdpi/sac_scale_t3.jpg new file mode 100644 index 00000000000..8141e44cb32 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/sac_scale_t3.jpg differ diff --git a/app/src/main/res/drawable-hdpi/sac_scale_t4.jpg b/app/src/main/res/drawable-hdpi/sac_scale_t4.jpg new file mode 100644 index 00000000000..f158a9bc918 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/sac_scale_t4.jpg differ diff --git a/app/src/main/res/drawable-hdpi/sac_scale_t5.jpg b/app/src/main/res/drawable-hdpi/sac_scale_t5.jpg new file mode 100644 index 00000000000..2cf291f77b1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/sac_scale_t5.jpg differ diff --git a/app/src/main/res/drawable-hdpi/sac_scale_t6.jpg b/app/src/main/res/drawable-hdpi/sac_scale_t6.jpg new file mode 100644 index 00000000000..ca7d2ecc498 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/sac_scale_t6.jpg differ diff --git a/app/src/main/res/drawable-hdpi/shelter_type_basic_hut.jpg b/app/src/main/res/drawable-hdpi/shelter_type_basic_hut.jpg new file mode 100644 index 00000000000..9f0eedf74f5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/shelter_type_basic_hut.jpg differ diff --git a/app/src/main/res/drawable-hdpi/shelter_type_field_shelter.jpg b/app/src/main/res/drawable-hdpi/shelter_type_field_shelter.jpg new file mode 100644 index 00000000000..41d79fa1457 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/shelter_type_field_shelter.jpg differ diff --git a/app/src/main/res/drawable-hdpi/shelter_type_gazebo.jpg b/app/src/main/res/drawable-hdpi/shelter_type_gazebo.jpg new file mode 100644 index 00000000000..79fbf82ba5b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/shelter_type_gazebo.jpg differ diff --git a/app/src/main/res/drawable-hdpi/shelter_type_lean_to.jpg b/app/src/main/res/drawable-hdpi/shelter_type_lean_to.jpg new file mode 100644 index 00000000000..cceea7ecb2e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/shelter_type_lean_to.jpg differ diff --git a/app/src/main/res/drawable-hdpi/shelter_type_picnic_shelter.jpg b/app/src/main/res/drawable-hdpi/shelter_type_picnic_shelter.jpg new file mode 100644 index 00000000000..37524d6c8fe Binary files /dev/null and b/app/src/main/res/drawable-hdpi/shelter_type_picnic_shelter.jpg differ diff --git a/app/src/main/res/drawable-hdpi/shelter_type_public_transport.jpg b/app/src/main/res/drawable-hdpi/shelter_type_public_transport.jpg new file mode 100644 index 00000000000..43abc951fb3 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/shelter_type_public_transport.jpg differ diff --git a/app/src/main/res/drawable-hdpi/shelter_type_rock_shelter.jpg b/app/src/main/res/drawable-hdpi/shelter_type_rock_shelter.jpg new file mode 100644 index 00000000000..8abd5f5058e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/shelter_type_rock_shelter.jpg differ diff --git a/app/src/main/res/drawable-hdpi/shelter_type_sun_shelter.jpg b/app/src/main/res/drawable-hdpi/shelter_type_sun_shelter.jpg new file mode 100644 index 00000000000..5c5325c20cf Binary files /dev/null and b/app/src/main/res/drawable-hdpi/shelter_type_sun_shelter.jpg differ diff --git a/app/src/main/res/drawable-hdpi/surface_chipseal.jpg b/app/src/main/res/drawable-hdpi/surface_chipseal.jpg new file mode 100644 index 00000000000..baacbc58536 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/surface_chipseal.jpg differ diff --git a/app/src/main/res/drawable-hdpi/surface_concrete_bad.jpg b/app/src/main/res/drawable-hdpi/surface_concrete_bad.jpg index dd76d9f66ed..7c2c00722fd 100644 Binary files a/app/src/main/res/drawable-hdpi/surface_concrete_bad.jpg and b/app/src/main/res/drawable-hdpi/surface_concrete_bad.jpg differ diff --git a/app/src/main/res/drawable-hdpi/surface_gravel_bad.jpg b/app/src/main/res/drawable-hdpi/surface_gravel_bad.jpg index 8788ae58c41..1f9bd2c2732 100644 Binary files a/app/src/main/res/drawable-hdpi/surface_gravel_bad.jpg and b/app/src/main/res/drawable-hdpi/surface_gravel_bad.jpg differ diff --git a/app/src/main/res/drawable-hdpi/surface_metal_grid.jpg b/app/src/main/res/drawable-hdpi/surface_metal_grid.jpg new file mode 100644 index 00000000000..967765613e9 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/surface_metal_grid.jpg differ diff --git a/app/src/main/res/drawable-hdpi/surface_stepping_stones.jpg b/app/src/main/res/drawable-hdpi/surface_stepping_stones.jpg new file mode 100644 index 00000000000..17df34eee94 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/surface_stepping_stones.jpg differ diff --git a/app/src/main/res/drawable-hdpi/surface_unpaved_horrible.jpg b/app/src/main/res/drawable-hdpi/surface_unpaved_horrible.jpg index 0d4cfb80784..4a213657e1d 100644 Binary files a/app/src/main/res/drawable-hdpi/surface_unpaved_horrible.jpg and b/app/src/main/res/drawable-hdpi/surface_unpaved_horrible.jpg differ diff --git a/app/src/main/res/drawable-hdpi/tourism_information_board.jpg b/app/src/main/res/drawable-hdpi/tourism_information_board.jpg index a30020845c8..f1f5d8469e8 100644 Binary files a/app/src/main/res/drawable-hdpi/tourism_information_board.jpg and b/app/src/main/res/drawable-hdpi/tourism_information_board.jpg differ diff --git a/app/src/main/res/drawable-hdpi/tourism_information_guidepost.jpg b/app/src/main/res/drawable-hdpi/tourism_information_guidepost.jpg index 258704f11da..33c70fb37cc 100644 Binary files a/app/src/main/res/drawable-hdpi/tourism_information_guidepost.jpg and b/app/src/main/res/drawable-hdpi/tourism_information_guidepost.jpg differ diff --git a/app/src/main/res/drawable-hdpi/tourism_information_map.jpg b/app/src/main/res/drawable-hdpi/tourism_information_map.jpg index 6a78a90d16c..34c466145a6 100644 Binary files a/app/src/main/res/drawable-hdpi/tourism_information_map.jpg and b/app/src/main/res/drawable-hdpi/tourism_information_map.jpg differ diff --git a/app/src/main/res/drawable-hdpi/tourism_information_office.jpg b/app/src/main/res/drawable-hdpi/tourism_information_office.jpg index 5ef94f74108..8a94ac97d1f 100644 Binary files a/app/src/main/res/drawable-hdpi/tourism_information_office.jpg and b/app/src/main/res/drawable-hdpi/tourism_information_office.jpg differ diff --git a/app/src/main/res/drawable-hdpi/valves_dunlop.jpg b/app/src/main/res/drawable-hdpi/valves_dunlop.jpg new file mode 100644 index 00000000000..6cd499fc43f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/valves_dunlop.jpg differ diff --git a/app/src/main/res/drawable-hdpi/valves_presta.jpg b/app/src/main/res/drawable-hdpi/valves_presta.jpg new file mode 100644 index 00000000000..ca789d48b39 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/valves_presta.jpg differ diff --git a/app/src/main/res/drawable-hdpi/valves_regina.jpg b/app/src/main/res/drawable-hdpi/valves_regina.jpg new file mode 100644 index 00000000000..662cb15e439 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/valves_regina.jpg differ diff --git a/app/src/main/res/drawable-hdpi/valves_schrader.jpg b/app/src/main/res/drawable-hdpi/valves_schrader.jpg new file mode 100644 index 00000000000..6652522033e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/valves_schrader.jpg differ diff --git a/app/src/main/res/drawable-hdpi/via_ferrata_scale_0.jpg b/app/src/main/res/drawable-hdpi/via_ferrata_scale_0.jpg new file mode 100644 index 00000000000..1fca8888d56 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/via_ferrata_scale_0.jpg differ diff --git a/app/src/main/res/drawable-hdpi/via_ferrata_scale_1.jpg b/app/src/main/res/drawable-hdpi/via_ferrata_scale_1.jpg new file mode 100644 index 00000000000..f9d8d37b577 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/via_ferrata_scale_1.jpg differ diff --git a/app/src/main/res/drawable-hdpi/via_ferrata_scale_2.jpg b/app/src/main/res/drawable-hdpi/via_ferrata_scale_2.jpg new file mode 100644 index 00000000000..b7f1c87e7d2 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/via_ferrata_scale_2.jpg differ diff --git a/app/src/main/res/drawable-hdpi/via_ferrata_scale_3.jpg b/app/src/main/res/drawable-hdpi/via_ferrata_scale_3.jpg new file mode 100644 index 00000000000..fc42f6658ab Binary files /dev/null and b/app/src/main/res/drawable-hdpi/via_ferrata_scale_3.jpg differ diff --git a/app/src/main/res/drawable-hdpi/via_ferrata_scale_4.jpg b/app/src/main/res/drawable-hdpi/via_ferrata_scale_4.jpg new file mode 100644 index 00000000000..3dbae61331b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/via_ferrata_scale_4.jpg differ diff --git a/app/src/main/res/drawable-hdpi/via_ferrata_scale_5.jpg b/app/src/main/res/drawable-hdpi/via_ferrata_scale_5.jpg new file mode 100644 index 00000000000..7296814a8f5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/via_ferrata_scale_5.jpg differ diff --git a/app/src/main/res/drawable-hdpi/via_ferrata_scale_6.jpg b/app/src/main/res/drawable-hdpi/via_ferrata_scale_6.jpg new file mode 100644 index 00000000000..f2a14b63264 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/via_ferrata_scale_6.jpg differ diff --git a/app/src/main/res/drawable-ldpi/map_type_scheme.jpg b/app/src/main/res/drawable-ldpi/map_type_scheme.jpg new file mode 100644 index 00000000000..4b7e0342510 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/map_type_scheme.jpg differ diff --git a/app/src/main/res/drawable-ldpi/map_type_street.jpg b/app/src/main/res/drawable-ldpi/map_type_street.jpg new file mode 100644 index 00000000000..56943bf662d Binary files /dev/null and b/app/src/main/res/drawable-ldpi/map_type_street.jpg differ diff --git a/app/src/main/res/drawable-ldpi/map_type_topo.jpg b/app/src/main/res/drawable-ldpi/map_type_topo.jpg new file mode 100644 index 00000000000..52380bef021 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/map_type_topo.jpg differ diff --git a/app/src/main/res/drawable-ldpi/map_type_toposcope.jpg b/app/src/main/res/drawable-ldpi/map_type_toposcope.jpg new file mode 100644 index 00000000000..c6e51ea8e11 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/map_type_toposcope.jpg differ diff --git a/app/src/main/res/drawable-mdpi/bench_brick.jpg b/app/src/main/res/drawable-mdpi/bench_brick.jpg new file mode 100644 index 00000000000..348351f4769 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/bench_brick.jpg differ diff --git a/app/src/main/res/drawable-mdpi/bench_concrete.jpg b/app/src/main/res/drawable-mdpi/bench_concrete.jpg new file mode 100644 index 00000000000..c2016c2a44f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/bench_concrete.jpg differ diff --git a/app/src/main/res/drawable-mdpi/bench_metal.jpg b/app/src/main/res/drawable-mdpi/bench_metal.jpg new file mode 100644 index 00000000000..d29797945f1 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/bench_metal.jpg differ diff --git a/app/src/main/res/drawable-mdpi/bench_plastic.jpg b/app/src/main/res/drawable-mdpi/bench_plastic.jpg new file mode 100644 index 00000000000..4dd35cc28a3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/bench_plastic.jpg differ diff --git a/app/src/main/res/drawable-mdpi/bench_stone.jpg b/app/src/main/res/drawable-mdpi/bench_stone.jpg new file mode 100644 index 00000000000..e275e896e46 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/bench_stone.jpg differ diff --git a/app/src/main/res/drawable-mdpi/bench_wood.jpg b/app/src/main/res/drawable-mdpi/bench_wood.jpg new file mode 100644 index 00000000000..6de749c2309 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/bench_wood.jpg differ diff --git a/app/src/main/res/drawable-mdpi/bicycle_parking_type_saddleholder.jpg b/app/src/main/res/drawable-mdpi/bicycle_parking_type_saddleholder.jpg new file mode 100644 index 00000000000..1837ba7db24 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/bicycle_parking_type_saddleholder.jpg differ diff --git a/app/src/main/res/drawable-mdpi/bicycle_parking_type_stand.jpg b/app/src/main/res/drawable-mdpi/bicycle_parking_type_stand.jpg index d8e96cab489..5c1b74ed636 100644 Binary files a/app/src/main/res/drawable-mdpi/bicycle_parking_type_stand.jpg and b/app/src/main/res/drawable-mdpi/bicycle_parking_type_stand.jpg differ diff --git a/app/src/main/res/drawable-mdpi/bicycle_rental_docking_station.jpg b/app/src/main/res/drawable-mdpi/bicycle_rental_docking_station.jpg index f54fdf48a13..1d62dc9ea19 100644 Binary files a/app/src/main/res/drawable-mdpi/bicycle_rental_docking_station.jpg and b/app/src/main/res/drawable-mdpi/bicycle_rental_docking_station.jpg differ diff --git a/app/src/main/res/drawable-mdpi/bicycle_rental_dropoff_point.jpg b/app/src/main/res/drawable-mdpi/bicycle_rental_dropoff_point.jpg index b598c139780..29fbc64908e 100644 Binary files a/app/src/main/res/drawable-mdpi/bicycle_rental_dropoff_point.jpg and b/app/src/main/res/drawable-mdpi/bicycle_rental_dropoff_point.jpg differ diff --git a/app/src/main/res/drawable-mdpi/bicycle_rental_human.jpg b/app/src/main/res/drawable-mdpi/bicycle_rental_human.jpg index 76fba91fdc4..8e0df446bdb 100644 Binary files a/app/src/main/res/drawable-mdpi/bicycle_rental_human.jpg and b/app/src/main/res/drawable-mdpi/bicycle_rental_human.jpg differ diff --git a/app/src/main/res/drawable-mdpi/bicycle_rental_shop_with_rental.jpg b/app/src/main/res/drawable-mdpi/bicycle_rental_shop_with_rental.jpg index ff2e1fb362b..1d95c6096ac 100644 Binary files a/app/src/main/res/drawable-mdpi/bicycle_rental_shop_with_rental.jpg and b/app/src/main/res/drawable-mdpi/bicycle_rental_shop_with_rental.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_adobe.jpg b/app/src/main/res/drawable-mdpi/building_material_adobe.jpg new file mode 100644 index 00000000000..d4ff56b79d5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_adobe.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_bamboo.jpg b/app/src/main/res/drawable-mdpi/building_material_bamboo.jpg new file mode 100644 index 00000000000..78ea1f7a21b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_bamboo.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_brick.jpg b/app/src/main/res/drawable-mdpi/building_material_brick.jpg new file mode 100644 index 00000000000..ee88cfd29b4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_brick.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_cement_block.jpg b/app/src/main/res/drawable-mdpi/building_material_cement_block.jpg new file mode 100644 index 00000000000..04ae26cdd43 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_cement_block.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_clay.jpg b/app/src/main/res/drawable-mdpi/building_material_clay.jpg new file mode 100644 index 00000000000..8306604c72c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_clay.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_concrete.jpg b/app/src/main/res/drawable-mdpi/building_material_concrete.jpg new file mode 100644 index 00000000000..c24a13f8770 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_concrete.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_glass.jpg b/app/src/main/res/drawable-mdpi/building_material_glass.jpg new file mode 100644 index 00000000000..a313e01ef22 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_glass.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_limestone.jpg b/app/src/main/res/drawable-mdpi/building_material_limestone.jpg new file mode 100644 index 00000000000..0783a450857 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_limestone.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_loam.jpg b/app/src/main/res/drawable-mdpi/building_material_loam.jpg new file mode 100644 index 00000000000..fec7b9cca7e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_loam.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_marble.jpg b/app/src/main/res/drawable-mdpi/building_material_marble.jpg new file mode 100644 index 00000000000..6c67014424c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_marble.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_metal.jpg b/app/src/main/res/drawable-mdpi/building_material_metal.jpg new file mode 100644 index 00000000000..0a29486e653 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_metal.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_mirror.jpg b/app/src/main/res/drawable-mdpi/building_material_mirror.jpg new file mode 100644 index 00000000000..3d5e9c0845e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_mirror.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_mud.jpg b/app/src/main/res/drawable-mdpi/building_material_mud.jpg new file mode 100644 index 00000000000..82ba240a041 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_mud.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_plaster.jpg b/app/src/main/res/drawable-mdpi/building_material_plaster.jpg new file mode 100644 index 00000000000..9e318d9cdee Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_plaster.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_plastic.jpg b/app/src/main/res/drawable-mdpi/building_material_plastic.jpg new file mode 100644 index 00000000000..f7e8962c23f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_plastic.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_reed.jpg b/app/src/main/res/drawable-mdpi/building_material_reed.jpg new file mode 100644 index 00000000000..e6bc18b19b1 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_reed.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_sandstone.jpg b/app/src/main/res/drawable-mdpi/building_material_sandstone.jpg new file mode 100644 index 00000000000..4e9403296e9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_sandstone.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_slate.jpg b/app/src/main/res/drawable-mdpi/building_material_slate.jpg new file mode 100644 index 00000000000..606205f6f6f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_slate.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_stone.jpg b/app/src/main/res/drawable-mdpi/building_material_stone.jpg new file mode 100644 index 00000000000..0309bf5571e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_stone.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_tiles.jpg b/app/src/main/res/drawable-mdpi/building_material_tiles.jpg new file mode 100644 index 00000000000..f71089c5314 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_tiles.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_timber_framing.jpg b/app/src/main/res/drawable-mdpi/building_material_timber_framing.jpg new file mode 100644 index 00000000000..ccd52a65816 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_timber_framing.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_vinyl.jpg b/app/src/main/res/drawable-mdpi/building_material_vinyl.jpg new file mode 100644 index 00000000000..5419cbedfe9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_vinyl.jpg differ diff --git a/app/src/main/res/drawable-mdpi/building_material_wood.jpg b/app/src/main/res/drawable-mdpi/building_material_wood.jpg new file mode 100644 index 00000000000..60ce7e140b9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/building_material_wood.jpg differ diff --git a/app/src/main/res/drawable-mdpi/crossing_type_marked.jpg b/app/src/main/res/drawable-mdpi/crossing_type_marked.jpg new file mode 100644 index 00000000000..5962008ce0c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/crossing_type_marked.jpg differ diff --git a/app/src/main/res/drawable-mdpi/crossing_type_signals_zebra.jpg b/app/src/main/res/drawable-mdpi/crossing_type_signals_zebra.jpg new file mode 100644 index 00000000000..84c9ca29f3b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/crossing_type_signals_zebra.jpg differ diff --git a/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_green.jpg b/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_green.jpg index c98e72665d3..a4848092a79 100644 Binary files a/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_green.jpg and b/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_green.jpg differ diff --git a/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_lane.jpg b/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_lane.jpg index 5e99456351e..1d1e761b561 100644 Binary files a/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_lane.jpg and b/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_lane.jpg differ diff --git a/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_parking.jpg b/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_parking.jpg index 254d1840cf1..64c7421801f 100644 Binary files a/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_parking.jpg and b/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_parking.jpg differ diff --git a/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_sidewalk.jpg b/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_sidewalk.jpg index ab4bd2f38b3..ef57cc1cb61 100644 Binary files a/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_sidewalk.jpg and b/app/src/main/res/drawable-mdpi/fire_hydrant_position_pillar_sidewalk.jpg differ diff --git a/app/src/main/res/drawable-mdpi/ic_roof_crosspitched.png b/app/src/main/res/drawable-mdpi/ic_roof_crosspitched.png new file mode 100644 index 00000000000..c830531b778 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_roof_crosspitched.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_roof_gabled_height_moved.png b/app/src/main/res/drawable-mdpi/ic_roof_gabled_height_moved.png new file mode 100644 index 00000000000..d03f32ddb01 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_roof_gabled_height_moved.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_roof_hip_and_gable.png b/app/src/main/res/drawable-mdpi/ic_roof_hip_and_gable.png new file mode 100644 index 00000000000..6efeef3759d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_roof_hip_and_gable.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_roof_sawtooth.png b/app/src/main/res/drawable-mdpi/ic_roof_sawtooth.png new file mode 100644 index 00000000000..c1bb72ed4ca Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_roof_sawtooth.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_roof_side_half_hipped.png b/app/src/main/res/drawable-mdpi/ic_roof_side_half_hipped.png new file mode 100644 index 00000000000..6ae2a23dd14 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_roof_side_half_hipped.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_roof_side_hipped.png b/app/src/main/res/drawable-mdpi/ic_roof_side_hipped.png new file mode 100644 index 00000000000..f255b083393 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_roof_side_hipped.png differ diff --git a/app/src/main/res/drawable-mdpi/map_type_scheme.jpg b/app/src/main/res/drawable-mdpi/map_type_scheme.jpg new file mode 100644 index 00000000000..a1d552990c3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/map_type_scheme.jpg differ diff --git a/app/src/main/res/drawable-mdpi/map_type_street.jpg b/app/src/main/res/drawable-mdpi/map_type_street.jpg new file mode 100644 index 00000000000..e63546e85fb Binary files /dev/null and b/app/src/main/res/drawable-mdpi/map_type_street.jpg differ diff --git a/app/src/main/res/drawable-mdpi/map_type_topo.jpg b/app/src/main/res/drawable-mdpi/map_type_topo.jpg new file mode 100644 index 00000000000..8372485115b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/map_type_topo.jpg differ diff --git a/app/src/main/res/drawable-mdpi/map_type_toposcope.jpg b/app/src/main/res/drawable-mdpi/map_type_toposcope.jpg new file mode 100644 index 00000000000..a28bd3897c6 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/map_type_toposcope.jpg differ diff --git a/app/src/main/res/drawable-mdpi/recycling_centre.jpg b/app/src/main/res/drawable-mdpi/recycling_centre.jpg index 96710f4329d..a4915ed2d22 100644 Binary files a/app/src/main/res/drawable-mdpi/recycling_centre.jpg and b/app/src/main/res/drawable-mdpi/recycling_centre.jpg differ diff --git a/app/src/main/res/drawable-mdpi/recycling_container.jpg b/app/src/main/res/drawable-mdpi/recycling_container.jpg index aa303527226..be1506ab6a7 100644 Binary files a/app/src/main/res/drawable-mdpi/recycling_container.jpg and b/app/src/main/res/drawable-mdpi/recycling_container.jpg differ diff --git a/app/src/main/res/drawable-mdpi/recycling_container_underground.jpg b/app/src/main/res/drawable-mdpi/recycling_container_underground.jpg index d0464efe3b2..c35460423b1 100644 Binary files a/app/src/main/res/drawable-mdpi/recycling_container_underground.jpg and b/app/src/main/res/drawable-mdpi/recycling_container_underground.jpg differ diff --git a/app/src/main/res/drawable-mdpi/sac_scale_strolling.jpg b/app/src/main/res/drawable-mdpi/sac_scale_strolling.jpg new file mode 100644 index 00000000000..737dd06ea98 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/sac_scale_strolling.jpg differ diff --git a/app/src/main/res/drawable-mdpi/sac_scale_t1.jpg b/app/src/main/res/drawable-mdpi/sac_scale_t1.jpg new file mode 100644 index 00000000000..2f784801650 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/sac_scale_t1.jpg differ diff --git a/app/src/main/res/drawable-mdpi/sac_scale_t2.jpg b/app/src/main/res/drawable-mdpi/sac_scale_t2.jpg new file mode 100644 index 00000000000..15b5cf3eb1d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/sac_scale_t2.jpg differ diff --git a/app/src/main/res/drawable-mdpi/sac_scale_t3.jpg b/app/src/main/res/drawable-mdpi/sac_scale_t3.jpg new file mode 100644 index 00000000000..6f6bfb2be4f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/sac_scale_t3.jpg differ diff --git a/app/src/main/res/drawable-mdpi/sac_scale_t4.jpg b/app/src/main/res/drawable-mdpi/sac_scale_t4.jpg new file mode 100644 index 00000000000..18280a729fe Binary files /dev/null and b/app/src/main/res/drawable-mdpi/sac_scale_t4.jpg differ diff --git a/app/src/main/res/drawable-mdpi/sac_scale_t5.jpg b/app/src/main/res/drawable-mdpi/sac_scale_t5.jpg new file mode 100644 index 00000000000..ac3862dd60b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/sac_scale_t5.jpg differ diff --git a/app/src/main/res/drawable-mdpi/sac_scale_t6.jpg b/app/src/main/res/drawable-mdpi/sac_scale_t6.jpg new file mode 100644 index 00000000000..28f0f0ed3b7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/sac_scale_t6.jpg differ diff --git a/app/src/main/res/drawable-mdpi/shelter_type_basic_hut.jpg b/app/src/main/res/drawable-mdpi/shelter_type_basic_hut.jpg new file mode 100644 index 00000000000..6d2faf26ce8 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/shelter_type_basic_hut.jpg differ diff --git a/app/src/main/res/drawable-mdpi/shelter_type_field_shelter.jpg b/app/src/main/res/drawable-mdpi/shelter_type_field_shelter.jpg new file mode 100644 index 00000000000..516b9704b7f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/shelter_type_field_shelter.jpg differ diff --git a/app/src/main/res/drawable-mdpi/shelter_type_gazebo.jpg b/app/src/main/res/drawable-mdpi/shelter_type_gazebo.jpg new file mode 100644 index 00000000000..ca475a81a57 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/shelter_type_gazebo.jpg differ diff --git a/app/src/main/res/drawable-mdpi/shelter_type_lean_to.jpg b/app/src/main/res/drawable-mdpi/shelter_type_lean_to.jpg new file mode 100644 index 00000000000..8ded0e9f576 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/shelter_type_lean_to.jpg differ diff --git a/app/src/main/res/drawable-mdpi/shelter_type_picnic_shelter.jpg b/app/src/main/res/drawable-mdpi/shelter_type_picnic_shelter.jpg new file mode 100644 index 00000000000..3dc8690f824 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/shelter_type_picnic_shelter.jpg differ diff --git a/app/src/main/res/drawable-mdpi/shelter_type_public_transport.jpg b/app/src/main/res/drawable-mdpi/shelter_type_public_transport.jpg new file mode 100644 index 00000000000..30a07996c9b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/shelter_type_public_transport.jpg differ diff --git a/app/src/main/res/drawable-mdpi/shelter_type_rock_shelter.jpg b/app/src/main/res/drawable-mdpi/shelter_type_rock_shelter.jpg new file mode 100644 index 00000000000..5b40a80ee8e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/shelter_type_rock_shelter.jpg differ diff --git a/app/src/main/res/drawable-mdpi/shelter_type_sun_shelter.jpg b/app/src/main/res/drawable-mdpi/shelter_type_sun_shelter.jpg new file mode 100644 index 00000000000..2832c25c56d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/shelter_type_sun_shelter.jpg differ diff --git a/app/src/main/res/drawable-mdpi/surface_chipseal.jpg b/app/src/main/res/drawable-mdpi/surface_chipseal.jpg new file mode 100644 index 00000000000..986705fd75b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/surface_chipseal.jpg differ diff --git a/app/src/main/res/drawable-mdpi/surface_gravel_very_bad.jpg b/app/src/main/res/drawable-mdpi/surface_gravel_very_bad.jpg index 5e94ab4791f..bcb71e8c246 100644 Binary files a/app/src/main/res/drawable-mdpi/surface_gravel_very_bad.jpg and b/app/src/main/res/drawable-mdpi/surface_gravel_very_bad.jpg differ diff --git a/app/src/main/res/drawable-mdpi/surface_metal_grid.jpg b/app/src/main/res/drawable-mdpi/surface_metal_grid.jpg new file mode 100644 index 00000000000..05e1aa65186 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/surface_metal_grid.jpg differ diff --git a/app/src/main/res/drawable-mdpi/surface_stepping_stones.jpg b/app/src/main/res/drawable-mdpi/surface_stepping_stones.jpg new file mode 100644 index 00000000000..01d1e6d5927 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/surface_stepping_stones.jpg differ diff --git a/app/src/main/res/drawable-mdpi/surface_unpaved_horrible.jpg b/app/src/main/res/drawable-mdpi/surface_unpaved_horrible.jpg index 907269382c1..b3a778101ab 100644 Binary files a/app/src/main/res/drawable-mdpi/surface_unpaved_horrible.jpg and b/app/src/main/res/drawable-mdpi/surface_unpaved_horrible.jpg differ diff --git a/app/src/main/res/drawable-mdpi/tourism_information_board.jpg b/app/src/main/res/drawable-mdpi/tourism_information_board.jpg index 7b32b50941b..21eeaa64b1d 100644 Binary files a/app/src/main/res/drawable-mdpi/tourism_information_board.jpg and b/app/src/main/res/drawable-mdpi/tourism_information_board.jpg differ diff --git a/app/src/main/res/drawable-mdpi/tourism_information_guidepost.jpg b/app/src/main/res/drawable-mdpi/tourism_information_guidepost.jpg index 5f60499b44a..1fe86367c84 100644 Binary files a/app/src/main/res/drawable-mdpi/tourism_information_guidepost.jpg and b/app/src/main/res/drawable-mdpi/tourism_information_guidepost.jpg differ diff --git a/app/src/main/res/drawable-mdpi/tourism_information_map.jpg b/app/src/main/res/drawable-mdpi/tourism_information_map.jpg index 5e3ff6e9755..a7fdb4dd56b 100644 Binary files a/app/src/main/res/drawable-mdpi/tourism_information_map.jpg and b/app/src/main/res/drawable-mdpi/tourism_information_map.jpg differ diff --git a/app/src/main/res/drawable-mdpi/tourism_information_office.jpg b/app/src/main/res/drawable-mdpi/tourism_information_office.jpg index 398adf810d2..c2f048d3b4b 100644 Binary files a/app/src/main/res/drawable-mdpi/tourism_information_office.jpg and b/app/src/main/res/drawable-mdpi/tourism_information_office.jpg differ diff --git a/app/src/main/res/drawable-mdpi/tourism_information_terminal.jpg b/app/src/main/res/drawable-mdpi/tourism_information_terminal.jpg index 0ed460fb64c..c0e23644dbc 100644 Binary files a/app/src/main/res/drawable-mdpi/tourism_information_terminal.jpg and b/app/src/main/res/drawable-mdpi/tourism_information_terminal.jpg differ diff --git a/app/src/main/res/drawable-mdpi/valves_dunlop.jpg b/app/src/main/res/drawable-mdpi/valves_dunlop.jpg new file mode 100644 index 00000000000..8892e4733e9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/valves_dunlop.jpg differ diff --git a/app/src/main/res/drawable-mdpi/valves_presta.jpg b/app/src/main/res/drawable-mdpi/valves_presta.jpg new file mode 100644 index 00000000000..0a3fddf39f7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/valves_presta.jpg differ diff --git a/app/src/main/res/drawable-mdpi/valves_regina.jpg b/app/src/main/res/drawable-mdpi/valves_regina.jpg new file mode 100644 index 00000000000..7a97a1d73eb Binary files /dev/null and b/app/src/main/res/drawable-mdpi/valves_regina.jpg differ diff --git a/app/src/main/res/drawable-mdpi/valves_schrader.jpg b/app/src/main/res/drawable-mdpi/valves_schrader.jpg new file mode 100644 index 00000000000..30e4bdc99ac Binary files /dev/null and b/app/src/main/res/drawable-mdpi/valves_schrader.jpg differ diff --git a/app/src/main/res/drawable-mdpi/via_ferrata_scale_0.jpg b/app/src/main/res/drawable-mdpi/via_ferrata_scale_0.jpg new file mode 100644 index 00000000000..7ac2c99c405 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/via_ferrata_scale_0.jpg differ diff --git a/app/src/main/res/drawable-mdpi/via_ferrata_scale_1.jpg b/app/src/main/res/drawable-mdpi/via_ferrata_scale_1.jpg new file mode 100644 index 00000000000..d171821b24b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/via_ferrata_scale_1.jpg differ diff --git a/app/src/main/res/drawable-mdpi/via_ferrata_scale_2.jpg b/app/src/main/res/drawable-mdpi/via_ferrata_scale_2.jpg new file mode 100644 index 00000000000..4ce1d302e53 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/via_ferrata_scale_2.jpg differ diff --git a/app/src/main/res/drawable-mdpi/via_ferrata_scale_3.jpg b/app/src/main/res/drawable-mdpi/via_ferrata_scale_3.jpg new file mode 100644 index 00000000000..f6ebe75a915 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/via_ferrata_scale_3.jpg differ diff --git a/app/src/main/res/drawable-mdpi/via_ferrata_scale_4.jpg b/app/src/main/res/drawable-mdpi/via_ferrata_scale_4.jpg new file mode 100644 index 00000000000..209b09a7fc9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/via_ferrata_scale_4.jpg differ diff --git a/app/src/main/res/drawable-mdpi/via_ferrata_scale_5.jpg b/app/src/main/res/drawable-mdpi/via_ferrata_scale_5.jpg new file mode 100644 index 00000000000..bb04041fcd0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/via_ferrata_scale_5.jpg differ diff --git a/app/src/main/res/drawable-mdpi/via_ferrata_scale_6.jpg b/app/src/main/res/drawable-mdpi/via_ferrata_scale_6.jpg new file mode 100644 index 00000000000..739bd6c3677 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/via_ferrata_scale_6.jpg differ diff --git a/app/src/main/res/drawable-v24/ic_launcher_background.xml b/app/src/main/res/drawable-v24/ic_launcher_background.xml new file mode 100644 index 00000000000..f665b9287f9 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_background.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000000..eb1810bee5e --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-xhdpi/bench_brick.jpg b/app/src/main/res/drawable-xhdpi/bench_brick.jpg new file mode 100644 index 00000000000..b91b9f38545 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/bench_brick.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/bench_concrete.jpg b/app/src/main/res/drawable-xhdpi/bench_concrete.jpg new file mode 100644 index 00000000000..699ca3387bb Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/bench_concrete.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/bench_metal.jpg b/app/src/main/res/drawable-xhdpi/bench_metal.jpg new file mode 100644 index 00000000000..8eba6e1d9c1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/bench_metal.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/bench_plastic.jpg b/app/src/main/res/drawable-xhdpi/bench_plastic.jpg new file mode 100644 index 00000000000..de7fb1b696a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/bench_plastic.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/bench_stone.jpg b/app/src/main/res/drawable-xhdpi/bench_stone.jpg new file mode 100644 index 00000000000..9ef298db69f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/bench_stone.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/bench_wood.jpg b/app/src/main/res/drawable-xhdpi/bench_wood.jpg new file mode 100644 index 00000000000..b17c45c0cae Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/bench_wood.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/bicycle_parking_type_saddleholder.jpg b/app/src/main/res/drawable-xhdpi/bicycle_parking_type_saddleholder.jpg new file mode 100644 index 00000000000..60ac5011735 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/bicycle_parking_type_saddleholder.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/bicycle_parking_type_stand.jpg b/app/src/main/res/drawable-xhdpi/bicycle_parking_type_stand.jpg index 5d798d77cd1..60c45df6623 100644 Binary files a/app/src/main/res/drawable-xhdpi/bicycle_parking_type_stand.jpg and b/app/src/main/res/drawable-xhdpi/bicycle_parking_type_stand.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/bicycle_rental_docking_station.jpg b/app/src/main/res/drawable-xhdpi/bicycle_rental_docking_station.jpg index 72b58349eb2..523cb6c8a16 100644 Binary files a/app/src/main/res/drawable-xhdpi/bicycle_rental_docking_station.jpg and b/app/src/main/res/drawable-xhdpi/bicycle_rental_docking_station.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/bicycle_rental_dropoff_point.jpg b/app/src/main/res/drawable-xhdpi/bicycle_rental_dropoff_point.jpg index b159b40e09a..1f8b9aa60e6 100644 Binary files a/app/src/main/res/drawable-xhdpi/bicycle_rental_dropoff_point.jpg and b/app/src/main/res/drawable-xhdpi/bicycle_rental_dropoff_point.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/bicycle_rental_human.jpg b/app/src/main/res/drawable-xhdpi/bicycle_rental_human.jpg index 86556cbf1d9..314b5838469 100644 Binary files a/app/src/main/res/drawable-xhdpi/bicycle_rental_human.jpg and b/app/src/main/res/drawable-xhdpi/bicycle_rental_human.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/bicycle_rental_shop_with_rental.jpg b/app/src/main/res/drawable-xhdpi/bicycle_rental_shop_with_rental.jpg index 79e53a6ef97..0190ba43ce9 100644 Binary files a/app/src/main/res/drawable-xhdpi/bicycle_rental_shop_with_rental.jpg and b/app/src/main/res/drawable-xhdpi/bicycle_rental_shop_with_rental.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_adobe.jpg b/app/src/main/res/drawable-xhdpi/building_material_adobe.jpg new file mode 100644 index 00000000000..95919914259 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_adobe.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_bamboo.jpg b/app/src/main/res/drawable-xhdpi/building_material_bamboo.jpg new file mode 100644 index 00000000000..b41dc6e9d52 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_bamboo.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_brick.jpg b/app/src/main/res/drawable-xhdpi/building_material_brick.jpg new file mode 100644 index 00000000000..63c9392b059 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_brick.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_cement_block.jpg b/app/src/main/res/drawable-xhdpi/building_material_cement_block.jpg new file mode 100644 index 00000000000..f89599c29f3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_cement_block.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_clay.jpg b/app/src/main/res/drawable-xhdpi/building_material_clay.jpg new file mode 100644 index 00000000000..8be7b14819e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_clay.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_concrete.jpg b/app/src/main/res/drawable-xhdpi/building_material_concrete.jpg new file mode 100644 index 00000000000..f7a4339c030 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_concrete.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_glass.jpg b/app/src/main/res/drawable-xhdpi/building_material_glass.jpg new file mode 100644 index 00000000000..313a6f026a2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_glass.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_limestone.jpg b/app/src/main/res/drawable-xhdpi/building_material_limestone.jpg new file mode 100644 index 00000000000..615507a20da Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_limestone.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_loam.jpg b/app/src/main/res/drawable-xhdpi/building_material_loam.jpg new file mode 100644 index 00000000000..3b17323b4bf Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_loam.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_marble.jpg b/app/src/main/res/drawable-xhdpi/building_material_marble.jpg new file mode 100644 index 00000000000..251576df889 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_marble.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_metal.jpg b/app/src/main/res/drawable-xhdpi/building_material_metal.jpg new file mode 100644 index 00000000000..7dd20071052 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_metal.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_mirror.jpg b/app/src/main/res/drawable-xhdpi/building_material_mirror.jpg new file mode 100644 index 00000000000..c0c464d7690 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_mirror.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_mud.jpg b/app/src/main/res/drawable-xhdpi/building_material_mud.jpg new file mode 100644 index 00000000000..cc80b5435fd Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_mud.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_plaster.jpg b/app/src/main/res/drawable-xhdpi/building_material_plaster.jpg new file mode 100644 index 00000000000..dee673f55e9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_plaster.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_plastic.jpg b/app/src/main/res/drawable-xhdpi/building_material_plastic.jpg new file mode 100644 index 00000000000..b6418c49657 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_plastic.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_reed.jpg b/app/src/main/res/drawable-xhdpi/building_material_reed.jpg new file mode 100644 index 00000000000..15cb6bb3f66 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_reed.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_sandstone.jpg b/app/src/main/res/drawable-xhdpi/building_material_sandstone.jpg new file mode 100644 index 00000000000..cc2aa4833a5 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_sandstone.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_slate.jpg b/app/src/main/res/drawable-xhdpi/building_material_slate.jpg new file mode 100644 index 00000000000..53a71297e56 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_slate.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_stone.jpg b/app/src/main/res/drawable-xhdpi/building_material_stone.jpg new file mode 100644 index 00000000000..933ba0691ea Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_stone.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_tiles.jpg b/app/src/main/res/drawable-xhdpi/building_material_tiles.jpg new file mode 100644 index 00000000000..2963bdb9869 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_tiles.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_timber_framing.jpg b/app/src/main/res/drawable-xhdpi/building_material_timber_framing.jpg new file mode 100644 index 00000000000..dfccec7fcb5 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_timber_framing.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_vinyl.jpg b/app/src/main/res/drawable-xhdpi/building_material_vinyl.jpg new file mode 100644 index 00000000000..61f3694d51b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_vinyl.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/building_material_wood.jpg b/app/src/main/res/drawable-xhdpi/building_material_wood.jpg new file mode 100644 index 00000000000..b6c575495b1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/building_material_wood.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/crossing_type_marked.jpg b/app/src/main/res/drawable-xhdpi/crossing_type_marked.jpg new file mode 100644 index 00000000000..4a4ad6ac938 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/crossing_type_marked.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/crossing_type_signals_zebra.jpg b/app/src/main/res/drawable-xhdpi/crossing_type_signals_zebra.jpg new file mode 100644 index 00000000000..49d964cd0fe Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/crossing_type_signals_zebra.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_green.jpg b/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_green.jpg index 72a513a859f..05d51d4ca91 100644 Binary files a/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_green.jpg and b/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_green.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_lane.jpg b/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_lane.jpg index 79a1b802cc7..556972de76f 100644 Binary files a/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_lane.jpg and b/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_lane.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_parking.jpg b/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_parking.jpg index deea4d51679..b65dd31bbfc 100644 Binary files a/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_parking.jpg and b/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_parking.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_sidewalk.jpg b/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_sidewalk.jpg index c20bb22935f..6f2c9f1b610 100644 Binary files a/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_sidewalk.jpg and b/app/src/main/res/drawable-xhdpi/fire_hydrant_position_pillar_sidewalk.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/map_type_scheme.jpg b/app/src/main/res/drawable-xhdpi/map_type_scheme.jpg new file mode 100644 index 00000000000..b7592e14055 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/map_type_scheme.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/map_type_street.jpg b/app/src/main/res/drawable-xhdpi/map_type_street.jpg new file mode 100644 index 00000000000..6e6d03ea374 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/map_type_street.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/map_type_topo.jpg b/app/src/main/res/drawable-xhdpi/map_type_topo.jpg new file mode 100644 index 00000000000..62e34a5a75d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/map_type_topo.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/map_type_toposcope.jpg b/app/src/main/res/drawable-xhdpi/map_type_toposcope.jpg new file mode 100644 index 00000000000..8847fe4c6f6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/map_type_toposcope.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/recycling_centre.jpg b/app/src/main/res/drawable-xhdpi/recycling_centre.jpg index b975397785d..62002613a34 100644 Binary files a/app/src/main/res/drawable-xhdpi/recycling_centre.jpg and b/app/src/main/res/drawable-xhdpi/recycling_centre.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/recycling_container.jpg b/app/src/main/res/drawable-xhdpi/recycling_container.jpg index d2df18a2153..5b5b1879c9e 100644 Binary files a/app/src/main/res/drawable-xhdpi/recycling_container.jpg and b/app/src/main/res/drawable-xhdpi/recycling_container.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/recycling_container_underground.jpg b/app/src/main/res/drawable-xhdpi/recycling_container_underground.jpg index 134f20d7c29..1d8b3e91782 100644 Binary files a/app/src/main/res/drawable-xhdpi/recycling_container_underground.jpg and b/app/src/main/res/drawable-xhdpi/recycling_container_underground.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/sac_scale_strolling.jpg b/app/src/main/res/drawable-xhdpi/sac_scale_strolling.jpg new file mode 100644 index 00000000000..f32e8f96625 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/sac_scale_strolling.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/sac_scale_t1.jpg b/app/src/main/res/drawable-xhdpi/sac_scale_t1.jpg new file mode 100644 index 00000000000..bb4e84bd7ef Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/sac_scale_t1.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/sac_scale_t2.jpg b/app/src/main/res/drawable-xhdpi/sac_scale_t2.jpg new file mode 100644 index 00000000000..73cbe5f1d84 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/sac_scale_t2.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/sac_scale_t3.jpg b/app/src/main/res/drawable-xhdpi/sac_scale_t3.jpg new file mode 100644 index 00000000000..78235a8da00 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/sac_scale_t3.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/sac_scale_t4.jpg b/app/src/main/res/drawable-xhdpi/sac_scale_t4.jpg new file mode 100644 index 00000000000..9bfe78de671 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/sac_scale_t4.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/sac_scale_t5.jpg b/app/src/main/res/drawable-xhdpi/sac_scale_t5.jpg new file mode 100644 index 00000000000..0c61ebe1fc6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/sac_scale_t5.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/sac_scale_t6.jpg b/app/src/main/res/drawable-xhdpi/sac_scale_t6.jpg new file mode 100644 index 00000000000..89c0f19b620 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/sac_scale_t6.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/shelter_type_basic_hut.jpg b/app/src/main/res/drawable-xhdpi/shelter_type_basic_hut.jpg new file mode 100644 index 00000000000..a35970d52e1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/shelter_type_basic_hut.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/shelter_type_field_shelter.jpg b/app/src/main/res/drawable-xhdpi/shelter_type_field_shelter.jpg new file mode 100644 index 00000000000..2faaae9f460 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/shelter_type_field_shelter.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/shelter_type_gazebo.jpg b/app/src/main/res/drawable-xhdpi/shelter_type_gazebo.jpg new file mode 100644 index 00000000000..9d46205ccd4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/shelter_type_gazebo.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/shelter_type_lean_to.jpg b/app/src/main/res/drawable-xhdpi/shelter_type_lean_to.jpg new file mode 100644 index 00000000000..2e58575cb5a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/shelter_type_lean_to.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/shelter_type_picnic_shelter.jpg b/app/src/main/res/drawable-xhdpi/shelter_type_picnic_shelter.jpg new file mode 100644 index 00000000000..ac08c53df1c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/shelter_type_picnic_shelter.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/shelter_type_public_transport.jpg b/app/src/main/res/drawable-xhdpi/shelter_type_public_transport.jpg new file mode 100644 index 00000000000..ec9fe9be6c9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/shelter_type_public_transport.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/shelter_type_rock_shelter.jpg b/app/src/main/res/drawable-xhdpi/shelter_type_rock_shelter.jpg new file mode 100644 index 00000000000..dcae478ff9f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/shelter_type_rock_shelter.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/shelter_type_sun_shelter.jpg b/app/src/main/res/drawable-xhdpi/shelter_type_sun_shelter.jpg new file mode 100644 index 00000000000..f6ad88916f6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/shelter_type_sun_shelter.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/surface_chipseal.jpg b/app/src/main/res/drawable-xhdpi/surface_chipseal.jpg new file mode 100644 index 00000000000..ad673a9c989 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/surface_chipseal.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/surface_concrete_bad.jpg b/app/src/main/res/drawable-xhdpi/surface_concrete_bad.jpg index 75d0bfe21be..a6476451d5b 100644 Binary files a/app/src/main/res/drawable-xhdpi/surface_concrete_bad.jpg and b/app/src/main/res/drawable-xhdpi/surface_concrete_bad.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/surface_gravel_very_bad.jpg b/app/src/main/res/drawable-xhdpi/surface_gravel_very_bad.jpg index c5c6aa32f5c..a4673332050 100644 Binary files a/app/src/main/res/drawable-xhdpi/surface_gravel_very_bad.jpg and b/app/src/main/res/drawable-xhdpi/surface_gravel_very_bad.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/surface_metal_grid.jpg b/app/src/main/res/drawable-xhdpi/surface_metal_grid.jpg new file mode 100644 index 00000000000..4308c28dea9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/surface_metal_grid.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/surface_stepping_stones.jpg b/app/src/main/res/drawable-xhdpi/surface_stepping_stones.jpg new file mode 100644 index 00000000000..6fbb2880501 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/surface_stepping_stones.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/surface_unpaved_horrible.jpg b/app/src/main/res/drawable-xhdpi/surface_unpaved_horrible.jpg index 032af17c814..00710ca08ce 100644 Binary files a/app/src/main/res/drawable-xhdpi/surface_unpaved_horrible.jpg and b/app/src/main/res/drawable-xhdpi/surface_unpaved_horrible.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/tourism_information_board.jpg b/app/src/main/res/drawable-xhdpi/tourism_information_board.jpg index 1850fa57674..720bfed46be 100644 Binary files a/app/src/main/res/drawable-xhdpi/tourism_information_board.jpg and b/app/src/main/res/drawable-xhdpi/tourism_information_board.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/tourism_information_guidepost.jpg b/app/src/main/res/drawable-xhdpi/tourism_information_guidepost.jpg index 2bc8417d939..bf622250d8d 100644 Binary files a/app/src/main/res/drawable-xhdpi/tourism_information_guidepost.jpg and b/app/src/main/res/drawable-xhdpi/tourism_information_guidepost.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/tourism_information_map.jpg b/app/src/main/res/drawable-xhdpi/tourism_information_map.jpg index 09f8c6b3af9..d9c6585cada 100644 Binary files a/app/src/main/res/drawable-xhdpi/tourism_information_map.jpg and b/app/src/main/res/drawable-xhdpi/tourism_information_map.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/tourism_information_office.jpg b/app/src/main/res/drawable-xhdpi/tourism_information_office.jpg index c565655adc9..8e180aa888e 100644 Binary files a/app/src/main/res/drawable-xhdpi/tourism_information_office.jpg and b/app/src/main/res/drawable-xhdpi/tourism_information_office.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/valves_dunlop.jpg b/app/src/main/res/drawable-xhdpi/valves_dunlop.jpg new file mode 100644 index 00000000000..18fcb07db7f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/valves_dunlop.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/valves_presta.jpg b/app/src/main/res/drawable-xhdpi/valves_presta.jpg new file mode 100644 index 00000000000..4b1dfde0f5b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/valves_presta.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/valves_regina.jpg b/app/src/main/res/drawable-xhdpi/valves_regina.jpg new file mode 100644 index 00000000000..9557310608d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/valves_regina.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/valves_schrader.jpg b/app/src/main/res/drawable-xhdpi/valves_schrader.jpg new file mode 100644 index 00000000000..bdea644a23c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/valves_schrader.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/via_ferrata_scale_0.jpg b/app/src/main/res/drawable-xhdpi/via_ferrata_scale_0.jpg new file mode 100644 index 00000000000..a50b11992a7 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/via_ferrata_scale_0.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/via_ferrata_scale_1.jpg b/app/src/main/res/drawable-xhdpi/via_ferrata_scale_1.jpg new file mode 100644 index 00000000000..5f409b07e49 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/via_ferrata_scale_1.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/via_ferrata_scale_2.jpg b/app/src/main/res/drawable-xhdpi/via_ferrata_scale_2.jpg new file mode 100644 index 00000000000..6acb8d18569 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/via_ferrata_scale_2.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/via_ferrata_scale_3.jpg b/app/src/main/res/drawable-xhdpi/via_ferrata_scale_3.jpg new file mode 100644 index 00000000000..0b9112f2121 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/via_ferrata_scale_3.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/via_ferrata_scale_4.jpg b/app/src/main/res/drawable-xhdpi/via_ferrata_scale_4.jpg new file mode 100644 index 00000000000..7b96c3d902d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/via_ferrata_scale_4.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/via_ferrata_scale_5.jpg b/app/src/main/res/drawable-xhdpi/via_ferrata_scale_5.jpg new file mode 100644 index 00000000000..1fe8d986485 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/via_ferrata_scale_5.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/via_ferrata_scale_6.jpg b/app/src/main/res/drawable-xhdpi/via_ferrata_scale_6.jpg new file mode 100644 index 00000000000..f5febe10c26 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/via_ferrata_scale_6.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/bench_brick.jpg b/app/src/main/res/drawable-xxhdpi/bench_brick.jpg new file mode 100644 index 00000000000..6e7cca8c64c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bench_brick.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/bench_concrete.jpg b/app/src/main/res/drawable-xxhdpi/bench_concrete.jpg new file mode 100644 index 00000000000..71b77a7f4a6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bench_concrete.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/bench_metal.jpg b/app/src/main/res/drawable-xxhdpi/bench_metal.jpg new file mode 100644 index 00000000000..a279ab6c600 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bench_metal.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/bench_plastic.jpg b/app/src/main/res/drawable-xxhdpi/bench_plastic.jpg new file mode 100644 index 00000000000..ab31edfb12c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bench_plastic.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/bench_stone.jpg b/app/src/main/res/drawable-xxhdpi/bench_stone.jpg new file mode 100644 index 00000000000..bcc2cf88406 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bench_stone.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/bench_wood.jpg b/app/src/main/res/drawable-xxhdpi/bench_wood.jpg new file mode 100644 index 00000000000..a7bd0c24bf1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bench_wood.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/bicycle_parking_type_saddleholder.jpg b/app/src/main/res/drawable-xxhdpi/bicycle_parking_type_saddleholder.jpg new file mode 100644 index 00000000000..bbe34e39aea Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bicycle_parking_type_saddleholder.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/bicycle_parking_type_stand.jpg b/app/src/main/res/drawable-xxhdpi/bicycle_parking_type_stand.jpg index 82e170f0398..ad4dbb58dfe 100644 Binary files a/app/src/main/res/drawable-xxhdpi/bicycle_parking_type_stand.jpg and b/app/src/main/res/drawable-xxhdpi/bicycle_parking_type_stand.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/bicycle_rental_dropoff_point.jpg b/app/src/main/res/drawable-xxhdpi/bicycle_rental_dropoff_point.jpg index d8dd6c60ac4..d475b020a83 100644 Binary files a/app/src/main/res/drawable-xxhdpi/bicycle_rental_dropoff_point.jpg and b/app/src/main/res/drawable-xxhdpi/bicycle_rental_dropoff_point.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/bicycle_rental_human.jpg b/app/src/main/res/drawable-xxhdpi/bicycle_rental_human.jpg index 0e3450d1be2..a1bb37a15e4 100644 Binary files a/app/src/main/res/drawable-xxhdpi/bicycle_rental_human.jpg and b/app/src/main/res/drawable-xxhdpi/bicycle_rental_human.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/bicycle_rental_shop_with_rental.jpg b/app/src/main/res/drawable-xxhdpi/bicycle_rental_shop_with_rental.jpg index 2a769e488a3..716e2109334 100644 Binary files a/app/src/main/res/drawable-xxhdpi/bicycle_rental_shop_with_rental.jpg and b/app/src/main/res/drawable-xxhdpi/bicycle_rental_shop_with_rental.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/bollard_fixed.jpg b/app/src/main/res/drawable-xxhdpi/bollard_fixed.jpg index 7ff32843b96..96eeb4bde96 100644 Binary files a/app/src/main/res/drawable-xxhdpi/bollard_fixed.jpg and b/app/src/main/res/drawable-xxhdpi/bollard_fixed.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_adobe.jpg b/app/src/main/res/drawable-xxhdpi/building_material_adobe.jpg new file mode 100644 index 00000000000..5187a905f37 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_adobe.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_bamboo.jpg b/app/src/main/res/drawable-xxhdpi/building_material_bamboo.jpg new file mode 100644 index 00000000000..de6f11e84df Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_bamboo.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_brick.jpg b/app/src/main/res/drawable-xxhdpi/building_material_brick.jpg new file mode 100644 index 00000000000..d57e70e61e4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_brick.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_cement_block.jpg b/app/src/main/res/drawable-xxhdpi/building_material_cement_block.jpg new file mode 100644 index 00000000000..77ad2e0590b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_cement_block.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_clay.jpg b/app/src/main/res/drawable-xxhdpi/building_material_clay.jpg new file mode 100644 index 00000000000..55e6afd2f12 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_clay.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_concrete.jpg b/app/src/main/res/drawable-xxhdpi/building_material_concrete.jpg new file mode 100644 index 00000000000..9b49ac839ae Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_concrete.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_glass.jpg b/app/src/main/res/drawable-xxhdpi/building_material_glass.jpg new file mode 100644 index 00000000000..8a82808e81b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_glass.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_limestone.jpg b/app/src/main/res/drawable-xxhdpi/building_material_limestone.jpg new file mode 100644 index 00000000000..859b302a6a0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_limestone.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_loam.jpg b/app/src/main/res/drawable-xxhdpi/building_material_loam.jpg new file mode 100644 index 00000000000..4d3d169a1c5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_loam.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_marble.jpg b/app/src/main/res/drawable-xxhdpi/building_material_marble.jpg new file mode 100644 index 00000000000..e8eff22d666 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_marble.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_metal.jpg b/app/src/main/res/drawable-xxhdpi/building_material_metal.jpg new file mode 100644 index 00000000000..e32fa1c68db Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_metal.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_mirror.jpg b/app/src/main/res/drawable-xxhdpi/building_material_mirror.jpg new file mode 100644 index 00000000000..715790e3b27 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_mirror.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_mud.jpg b/app/src/main/res/drawable-xxhdpi/building_material_mud.jpg new file mode 100644 index 00000000000..be291977ea0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_mud.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_plaster.jpg b/app/src/main/res/drawable-xxhdpi/building_material_plaster.jpg new file mode 100644 index 00000000000..b8a5b96c3c4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_plaster.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_plastic.jpg b/app/src/main/res/drawable-xxhdpi/building_material_plastic.jpg new file mode 100644 index 00000000000..c6b2f58630a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_plastic.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_reed.jpg b/app/src/main/res/drawable-xxhdpi/building_material_reed.jpg new file mode 100644 index 00000000000..0ea8806538d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_reed.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_sandstone.jpg b/app/src/main/res/drawable-xxhdpi/building_material_sandstone.jpg new file mode 100644 index 00000000000..c832ff473dd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_sandstone.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_slate.jpg b/app/src/main/res/drawable-xxhdpi/building_material_slate.jpg new file mode 100644 index 00000000000..3ab500e360d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_slate.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_stone.jpg b/app/src/main/res/drawable-xxhdpi/building_material_stone.jpg new file mode 100644 index 00000000000..d655828d5ed Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_stone.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_tiles.jpg b/app/src/main/res/drawable-xxhdpi/building_material_tiles.jpg new file mode 100644 index 00000000000..fdf121eaa31 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_tiles.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_timber_framing.jpg b/app/src/main/res/drawable-xxhdpi/building_material_timber_framing.jpg new file mode 100644 index 00000000000..3dc95c496a0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_timber_framing.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_vinyl.jpg b/app/src/main/res/drawable-xxhdpi/building_material_vinyl.jpg new file mode 100644 index 00000000000..71a5cf59a6f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_vinyl.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/building_material_wood.jpg b/app/src/main/res/drawable-xxhdpi/building_material_wood.jpg new file mode 100644 index 00000000000..15c56b2256d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/building_material_wood.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/crossing_type_marked.jpg b/app/src/main/res/drawable-xxhdpi/crossing_type_marked.jpg new file mode 100644 index 00000000000..970464330f3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/crossing_type_marked.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/crossing_type_signals_zebra.jpg b/app/src/main/res/drawable-xxhdpi/crossing_type_signals_zebra.jpg new file mode 100644 index 00000000000..43fc465ec4b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/crossing_type_signals_zebra.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_green.jpg b/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_green.jpg index c366582cbea..f97ad36abcb 100644 Binary files a/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_green.jpg and b/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_green.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_lane.jpg b/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_lane.jpg index 2a4e3e22fce..8a5847e722b 100644 Binary files a/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_lane.jpg and b/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_lane.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_parking.jpg b/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_parking.jpg index f8246e036fe..adc63f338d3 100644 Binary files a/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_parking.jpg and b/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_parking.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_sidewalk.jpg b/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_sidewalk.jpg index a7421a6af44..cc82a1dfa63 100644 Binary files a/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_sidewalk.jpg and b/app/src/main/res/drawable-xxhdpi/fire_hydrant_position_pillar_sidewalk.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_roof_crosspitched.png b/app/src/main/res/drawable-xxhdpi/ic_roof_crosspitched.png new file mode 100644 index 00000000000..e2187271079 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_roof_crosspitched.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_roof_gabled_height_moved.png b/app/src/main/res/drawable-xxhdpi/ic_roof_gabled_height_moved.png new file mode 100644 index 00000000000..9f286ccb6a7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_roof_gabled_height_moved.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_roof_hip_and_gable.png b/app/src/main/res/drawable-xxhdpi/ic_roof_hip_and_gable.png new file mode 100644 index 00000000000..aca21a48444 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_roof_hip_and_gable.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_roof_sawtooth.png b/app/src/main/res/drawable-xxhdpi/ic_roof_sawtooth.png new file mode 100644 index 00000000000..f87d5c065b8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_roof_sawtooth.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_roof_side_half_hipped.png b/app/src/main/res/drawable-xxhdpi/ic_roof_side_half_hipped.png new file mode 100644 index 00000000000..f26b94ae202 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_roof_side_half_hipped.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_roof_side_hipped.png b/app/src/main/res/drawable-xxhdpi/ic_roof_side_hipped.png new file mode 100644 index 00000000000..fdeeabbf17c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_roof_side_hipped.png differ diff --git a/app/src/main/res/drawable-xxhdpi/map_type_scheme.jpg b/app/src/main/res/drawable-xxhdpi/map_type_scheme.jpg new file mode 100644 index 00000000000..65560333fbf Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/map_type_scheme.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/map_type_street.jpg b/app/src/main/res/drawable-xxhdpi/map_type_street.jpg new file mode 100644 index 00000000000..39e73890b30 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/map_type_street.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/map_type_topo.jpg b/app/src/main/res/drawable-xxhdpi/map_type_topo.jpg new file mode 100644 index 00000000000..8f302fbd362 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/map_type_topo.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/map_type_toposcope.jpg b/app/src/main/res/drawable-xxhdpi/map_type_toposcope.jpg new file mode 100644 index 00000000000..99bb1064efe Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/map_type_toposcope.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/sac_scale_strolling.jpg b/app/src/main/res/drawable-xxhdpi/sac_scale_strolling.jpg new file mode 100644 index 00000000000..6758d936cf8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/sac_scale_strolling.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/sac_scale_t1.jpg b/app/src/main/res/drawable-xxhdpi/sac_scale_t1.jpg new file mode 100644 index 00000000000..3f3a7678a95 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/sac_scale_t1.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/sac_scale_t2.jpg b/app/src/main/res/drawable-xxhdpi/sac_scale_t2.jpg new file mode 100644 index 00000000000..5aa5085d4e4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/sac_scale_t2.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/sac_scale_t3.jpg b/app/src/main/res/drawable-xxhdpi/sac_scale_t3.jpg new file mode 100644 index 00000000000..89d6ecd45df Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/sac_scale_t3.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/sac_scale_t4.jpg b/app/src/main/res/drawable-xxhdpi/sac_scale_t4.jpg new file mode 100644 index 00000000000..3242378f082 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/sac_scale_t4.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/sac_scale_t5.jpg b/app/src/main/res/drawable-xxhdpi/sac_scale_t5.jpg new file mode 100644 index 00000000000..7c62f0e90bf Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/sac_scale_t5.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/sac_scale_t6.jpg b/app/src/main/res/drawable-xxhdpi/sac_scale_t6.jpg new file mode 100644 index 00000000000..b33883eca26 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/sac_scale_t6.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/shelter_type_basic_hut.jpg b/app/src/main/res/drawable-xxhdpi/shelter_type_basic_hut.jpg new file mode 100644 index 00000000000..7d0bcbc6a66 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/shelter_type_basic_hut.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/shelter_type_field_shelter.jpg b/app/src/main/res/drawable-xxhdpi/shelter_type_field_shelter.jpg new file mode 100644 index 00000000000..3a1ad38542a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/shelter_type_field_shelter.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/shelter_type_gazebo.jpg b/app/src/main/res/drawable-xxhdpi/shelter_type_gazebo.jpg new file mode 100644 index 00000000000..4eb78bd2f3d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/shelter_type_gazebo.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/shelter_type_lean_to.jpg b/app/src/main/res/drawable-xxhdpi/shelter_type_lean_to.jpg new file mode 100644 index 00000000000..f2f9212bf3f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/shelter_type_lean_to.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/shelter_type_picnic_shelter.jpg b/app/src/main/res/drawable-xxhdpi/shelter_type_picnic_shelter.jpg new file mode 100644 index 00000000000..3f4540d809b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/shelter_type_picnic_shelter.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/shelter_type_public_transport.jpg b/app/src/main/res/drawable-xxhdpi/shelter_type_public_transport.jpg new file mode 100644 index 00000000000..af684835b84 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/shelter_type_public_transport.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/shelter_type_rock_shelter.jpg b/app/src/main/res/drawable-xxhdpi/shelter_type_rock_shelter.jpg new file mode 100644 index 00000000000..c83f6c3c2f2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/shelter_type_rock_shelter.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/shelter_type_sun_shelter.jpg b/app/src/main/res/drawable-xxhdpi/shelter_type_sun_shelter.jpg new file mode 100644 index 00000000000..31d0a28fa40 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/shelter_type_sun_shelter.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/surface_chipseal.jpg b/app/src/main/res/drawable-xxhdpi/surface_chipseal.jpg new file mode 100644 index 00000000000..ee74fd5ab83 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/surface_chipseal.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/surface_concrete_bad.jpg b/app/src/main/res/drawable-xxhdpi/surface_concrete_bad.jpg index d3678ef629f..9cf9372188a 100644 Binary files a/app/src/main/res/drawable-xxhdpi/surface_concrete_bad.jpg and b/app/src/main/res/drawable-xxhdpi/surface_concrete_bad.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/surface_concrete_intermediate.jpg b/app/src/main/res/drawable-xxhdpi/surface_concrete_intermediate.jpg index 930fb2f86db..2bc3ed491df 100644 Binary files a/app/src/main/res/drawable-xxhdpi/surface_concrete_intermediate.jpg and b/app/src/main/res/drawable-xxhdpi/surface_concrete_intermediate.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/surface_gravel_very_bad.jpg b/app/src/main/res/drawable-xxhdpi/surface_gravel_very_bad.jpg index 79932dd8fcd..6b724459ca6 100644 Binary files a/app/src/main/res/drawable-xxhdpi/surface_gravel_very_bad.jpg and b/app/src/main/res/drawable-xxhdpi/surface_gravel_very_bad.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/surface_metal_grid.jpg b/app/src/main/res/drawable-xxhdpi/surface_metal_grid.jpg new file mode 100644 index 00000000000..69556a66f1f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/surface_metal_grid.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/surface_stepping_stones.jpg b/app/src/main/res/drawable-xxhdpi/surface_stepping_stones.jpg new file mode 100644 index 00000000000..18858da5e2e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/surface_stepping_stones.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/surface_unpaved_horrible.jpg b/app/src/main/res/drawable-xxhdpi/surface_unpaved_horrible.jpg index 06edae5a9fd..9e014b304e3 100644 Binary files a/app/src/main/res/drawable-xxhdpi/surface_unpaved_horrible.jpg and b/app/src/main/res/drawable-xxhdpi/surface_unpaved_horrible.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/tourism_information_board.jpg b/app/src/main/res/drawable-xxhdpi/tourism_information_board.jpg index 0ef16288466..fa487d64655 100644 Binary files a/app/src/main/res/drawable-xxhdpi/tourism_information_board.jpg and b/app/src/main/res/drawable-xxhdpi/tourism_information_board.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/tourism_information_guidepost.jpg b/app/src/main/res/drawable-xxhdpi/tourism_information_guidepost.jpg index 400bcf5abf5..e4b27e6acac 100644 Binary files a/app/src/main/res/drawable-xxhdpi/tourism_information_guidepost.jpg and b/app/src/main/res/drawable-xxhdpi/tourism_information_guidepost.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/tourism_information_map.jpg b/app/src/main/res/drawable-xxhdpi/tourism_information_map.jpg index 772c797382f..976f76bd123 100644 Binary files a/app/src/main/res/drawable-xxhdpi/tourism_information_map.jpg and b/app/src/main/res/drawable-xxhdpi/tourism_information_map.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/tourism_information_office.jpg b/app/src/main/res/drawable-xxhdpi/tourism_information_office.jpg index d381e24e26d..4f2aea46812 100644 Binary files a/app/src/main/res/drawable-xxhdpi/tourism_information_office.jpg and b/app/src/main/res/drawable-xxhdpi/tourism_information_office.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/tourism_information_terminal.jpg b/app/src/main/res/drawable-xxhdpi/tourism_information_terminal.jpg index 9fe787cb2e0..f40027c03d0 100644 Binary files a/app/src/main/res/drawable-xxhdpi/tourism_information_terminal.jpg and b/app/src/main/res/drawable-xxhdpi/tourism_information_terminal.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/valves_dunlop.jpg b/app/src/main/res/drawable-xxhdpi/valves_dunlop.jpg new file mode 100644 index 00000000000..dac0160d90a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/valves_dunlop.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/valves_presta.jpg b/app/src/main/res/drawable-xxhdpi/valves_presta.jpg new file mode 100644 index 00000000000..f390496dcb0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/valves_presta.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/valves_regina.jpg b/app/src/main/res/drawable-xxhdpi/valves_regina.jpg new file mode 100644 index 00000000000..4a6fdc29cb3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/valves_regina.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/valves_schrader.jpg b/app/src/main/res/drawable-xxhdpi/valves_schrader.jpg new file mode 100644 index 00000000000..029cc95dd98 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/valves_schrader.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_0.jpg b/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_0.jpg new file mode 100644 index 00000000000..3f3d9a352a5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_0.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_1.jpg b/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_1.jpg new file mode 100644 index 00000000000..ae0c8021b2f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_1.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_2.jpg b/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_2.jpg new file mode 100644 index 00000000000..4761dd96e18 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_2.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_3.jpg b/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_3.jpg new file mode 100644 index 00000000000..f95ed8d65e6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_3.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_4.jpg b/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_4.jpg new file mode 100644 index 00000000000..15c1d4d1ca2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_4.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_5.jpg b/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_5.jpg new file mode 100644 index 00000000000..4804668bfc0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_5.jpg differ diff --git a/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_6.jpg b/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_6.jpg new file mode 100644 index 00000000000..b3f931fe2a8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/via_ferrata_scale_6.jpg differ diff --git a/app/src/main/res/drawable-xxxhdpi/map_type_scheme.jpg b/app/src/main/res/drawable-xxxhdpi/map_type_scheme.jpg new file mode 100644 index 00000000000..992bd7b2f81 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/map_type_scheme.jpg differ diff --git a/app/src/main/res/drawable-xxxhdpi/map_type_street.jpg b/app/src/main/res/drawable-xxxhdpi/map_type_street.jpg new file mode 100644 index 00000000000..46b62e7e306 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/map_type_street.jpg differ diff --git a/app/src/main/res/drawable-xxxhdpi/map_type_topo.jpg b/app/src/main/res/drawable-xxxhdpi/map_type_topo.jpg new file mode 100644 index 00000000000..24125e2f8f4 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/map_type_topo.jpg differ diff --git a/app/src/main/res/drawable-xxxhdpi/map_type_toposcope.jpg b/app/src/main/res/drawable-xxxhdpi/map_type_toposcope.jpg new file mode 100644 index 00000000000..7a20258247b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/map_type_toposcope.jpg differ diff --git a/app/src/main/res/drawable/crossing_markings_dashes.xml b/app/src/main/res/drawable/crossing_markings_dashes.xml new file mode 100644 index 00000000000..a79e3956dc3 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_dashes.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_dots.xml b/app/src/main/res/drawable/crossing_markings_dots.xml new file mode 100644 index 00000000000..883ed06c78b --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_dots.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_ladder.xml b/app/src/main/res/drawable/crossing_markings_ladder.xml new file mode 100644 index 00000000000..2a9019119e7 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_ladder.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_ladder_paired.xml b/app/src/main/res/drawable/crossing_markings_ladder_paired.xml new file mode 100644 index 00000000000..275c7a59695 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_ladder_paired.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_ladder_skewed.xml b/app/src/main/res/drawable/crossing_markings_ladder_skewed.xml new file mode 100644 index 00000000000..6a5b8191108 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_ladder_skewed.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_lines.xml b/app/src/main/res/drawable/crossing_markings_lines.xml new file mode 100644 index 00000000000..96b091ccbaf --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_lines.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_no.xml b/app/src/main/res/drawable/crossing_markings_no.xml new file mode 100644 index 00000000000..ee602ad0722 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_no.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_part_dots.xml b/app/src/main/res/drawable/crossing_markings_part_dots.xml new file mode 100644 index 00000000000..e1eef779461 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_part_dots.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_part_lines.xml b/app/src/main/res/drawable/crossing_markings_part_lines.xml new file mode 100644 index 00000000000..d701253e14b --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_part_lines.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_part_pictograms.xml b/app/src/main/res/drawable/crossing_markings_part_pictograms.xml new file mode 100644 index 00000000000..a59581596f1 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_part_pictograms.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_part_road.xml b/app/src/main/res/drawable/crossing_markings_part_road.xml new file mode 100644 index 00000000000..52a94622fe7 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_part_road.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_part_sides.xml b/app/src/main/res/drawable/crossing_markings_part_sides.xml new file mode 100644 index 00000000000..a8e64d35915 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_part_sides.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_part_zebra.xml b/app/src/main/res/drawable/crossing_markings_part_zebra.xml new file mode 100644 index 00000000000..793c3bb90e6 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_part_zebra.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_part_zebra_paired.xml b/app/src/main/res/drawable/crossing_markings_part_zebra_paired.xml new file mode 100644 index 00000000000..70e91b5bc01 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_part_zebra_paired.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_pictograms.xml b/app/src/main/res/drawable/crossing_markings_pictograms.xml new file mode 100644 index 00000000000..66a653ad428 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_pictograms.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_surface.xml b/app/src/main/res/drawable/crossing_markings_surface.xml new file mode 100644 index 00000000000..89c985d0dc8 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_surface.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_zebra.xml b/app/src/main/res/drawable/crossing_markings_zebra.xml new file mode 100644 index 00000000000..28fcf6cb93b --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_zebra.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_zebra_bicolour.xml b/app/src/main/res/drawable/crossing_markings_zebra_bicolour.xml new file mode 100644 index 00000000000..27ba2d0d38c --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_zebra_bicolour.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_zebra_dots.xml b/app/src/main/res/drawable/crossing_markings_zebra_dots.xml new file mode 100644 index 00000000000..bf952fa5493 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_zebra_dots.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_zebra_double.xml b/app/src/main/res/drawable/crossing_markings_zebra_double.xml new file mode 100644 index 00000000000..ec493cfb426 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_zebra_double.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/crossing_markings_zebra_paired.xml b/app/src/main/res/drawable/crossing_markings_zebra_paired.xml new file mode 100644 index 00000000000..249431abcf7 --- /dev/null +++ b/app/src/main/res/drawable/crossing_markings_zebra_paired.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_add_poi.xml b/app/src/main/res/drawable/ic_add_poi.xml new file mode 100644 index 00000000000..76c4a6b5105 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_poi.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_up_down.xml b/app/src/main/res/drawable/ic_arrow_up_down.xml new file mode 100644 index 00000000000..b07584fc5ef --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_up_down.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_building_colour.xml b/app/src/main/res/drawable/ic_building_colour.xml new file mode 100644 index 00000000000..b8801970fdb --- /dev/null +++ b/app/src/main/res/drawable/ic_building_colour.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_car.xml b/app/src/main/res/drawable/ic_car.xml new file mode 100644 index 00000000000..7f076a85a6d --- /dev/null +++ b/app/src/main/res/drawable/ic_car.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_check_48dp.xml b/app/src/main/res/drawable/ic_check_48dp.xml index e65ff619137..cc37d17a586 100644 --- a/app/src/main/res/drawable/ic_check_48dp.xml +++ b/app/src/main/res/drawable/ic_check_48dp.xml @@ -2,8 +2,7 @@ android:width="48dp" android:height="48dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_checkmark_circle.xml b/app/src/main/res/drawable/ic_checkmark_circle.xml new file mode 100644 index 00000000000..7178b1e2ca4 --- /dev/null +++ b/app/src/main/res/drawable/ic_checkmark_circle.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_custom_overlay.xml b/app/src/main/res/drawable/ic_custom_overlay.xml new file mode 100644 index 00000000000..e65ac46cf25 --- /dev/null +++ b/app/src/main/res/drawable/ic_custom_overlay.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_custom_overlay_node.xml b/app/src/main/res/drawable/ic_custom_overlay_node.xml new file mode 100644 index 00000000000..10fead3255e --- /dev/null +++ b/app/src/main/res/drawable/ic_custom_overlay_node.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_edit_tags.xml b/app/src/main/res/drawable/ic_edit_tags.xml new file mode 100644 index 00000000000..6d78a1416e0 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_tags.xml @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_guidepost_climbing.xml b/app/src/main/res/drawable/ic_guidepost_climbing.xml new file mode 100644 index 00000000000..5ab44900ef9 --- /dev/null +++ b/app/src/main/res/drawable/ic_guidepost_climbing.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_guidepost_cycling.xml b/app/src/main/res/drawable/ic_guidepost_cycling.xml new file mode 100644 index 00000000000..188967b862d --- /dev/null +++ b/app/src/main/res/drawable/ic_guidepost_cycling.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_guidepost_hiking.xml b/app/src/main/res/drawable/ic_guidepost_hiking.xml new file mode 100644 index 00000000000..018998b93dc --- /dev/null +++ b/app/src/main/res/drawable/ic_guidepost_hiking.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_guidepost_horse_riding.xml b/app/src/main/res/drawable/ic_guidepost_horse_riding.xml new file mode 100644 index 00000000000..eae67c2ed33 --- /dev/null +++ b/app/src/main/res/drawable/ic_guidepost_horse_riding.xml @@ -0,0 +1,222 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_guidepost_inline_skating.xml b/app/src/main/res/drawable/ic_guidepost_inline_skating.xml new file mode 100644 index 00000000000..e0ef287fa04 --- /dev/null +++ b/app/src/main/res/drawable/ic_guidepost_inline_skating.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_guidepost_mtb.xml b/app/src/main/res/drawable/ic_guidepost_mtb.xml new file mode 100644 index 00000000000..c3867e48c52 --- /dev/null +++ b/app/src/main/res/drawable/ic_guidepost_mtb.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_guidepost_nordic_walking.xml b/app/src/main/res/drawable/ic_guidepost_nordic_walking.xml new file mode 100644 index 00000000000..2e85b801abf --- /dev/null +++ b/app/src/main/res/drawable/ic_guidepost_nordic_walking.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_guidepost_running.xml b/app/src/main/res/drawable/ic_guidepost_running.xml new file mode 100644 index 00000000000..4dbbc937316 --- /dev/null +++ b/app/src/main/res/drawable/ic_guidepost_running.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_guidepost_ski.xml b/app/src/main/res/drawable/ic_guidepost_ski.xml new file mode 100644 index 00000000000..963c1b617b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_guidepost_ski.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_guidepost_snow_shoe_hiking.xml b/app/src/main/res/drawable/ic_guidepost_snow_shoe_hiking.xml new file mode 100644 index 00000000000..2474cac8dd5 --- /dev/null +++ b/app/src/main/res/drawable/ic_guidepost_snow_shoe_hiking.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_lanes_no_center_line.xml b/app/src/main/res/drawable/ic_lanes_no_center_line.xml new file mode 100644 index 00000000000..49729b6c314 --- /dev/null +++ b/app/src/main/res/drawable/ic_lanes_no_center_line.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 207fc47cc63..00000000000 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml b/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml new file mode 100644 index 00000000000..f1d3a9b4bdb --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml deleted file mode 100644 index ab8d57b901a..00000000000 --- a/app/src/main/res/drawable/ic_launcher_monochrome.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_overlay_black_24dp.xml b/app/src/main/res/drawable/ic_overlay_black_24dp.xml index bede2a286a2..ee4d2a7d062 100644 --- a/app/src/main/res/drawable/ic_overlay_black_24dp.xml +++ b/app/src/main/res/drawable/ic_overlay_black_24dp.xml @@ -5,10 +5,10 @@ android:viewportHeight="20" android:viewportWidth="20"> diff --git a/app/src/main/res/drawable/ic_overlay_restriction.xml b/app/src/main/res/drawable/ic_overlay_restriction.xml new file mode 100644 index 00000000000..5088cc05644 --- /dev/null +++ b/app/src/main/res/drawable/ic_overlay_restriction.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_barrier_height.xml b/app/src/main/res/drawable/ic_quest_barrier_height.xml new file mode 100644 index 00000000000..9a962623941 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_barrier_height.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_barrier_locked.xml b/app/src/main/res/drawable/ic_quest_barrier_locked.xml new file mode 100644 index 00000000000..b52870a5fd1 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_barrier_locked.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_bench_material.xml b/app/src/main/res/drawable/ic_quest_bench_material.xml new file mode 100644 index 00000000000..bb45edc7969 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_bench_material.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_quest_brewery.xml b/app/src/main/res/drawable/ic_quest_brewery.xml new file mode 100644 index 00000000000..88605c283a9 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_brewery.xml @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_building_colour.xml b/app/src/main/res/drawable/ic_quest_building_colour.xml new file mode 100644 index 00000000000..b53bcf40753 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_building_colour.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_building_material.xml b/app/src/main/res/drawable/ic_quest_building_material.xml new file mode 100644 index 00000000000..c0d781c3d02 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_building_material.xml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_building_service_gas.xml b/app/src/main/res/drawable/ic_quest_building_service_gas.xml new file mode 100644 index 00000000000..d8fb732b3d9 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_building_service_gas.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_building_service_gas_pressure.xml b/app/src/main/res/drawable/ic_quest_building_service_gas_pressure.xml new file mode 100644 index 00000000000..0eccbbd4092 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_building_service_gas_pressure.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_building_service_gas_pump.xml b/app/src/main/res/drawable/ic_quest_building_service_gas_pump.xml new file mode 100644 index 00000000000..51ed09ca0c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_building_service_gas_pump.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_caravan_site.xml b/app/src/main/res/drawable/ic_quest_caravan_site.xml new file mode 100644 index 00000000000..b0d12bb6866 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_caravan_site.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_close_note.xml b/app/src/main/res/drawable/ic_quest_close_note.xml new file mode 100644 index 00000000000..a3ce2a58d10 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_close_note.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_custom.xml b/app/src/main/res/drawable/ic_quest_custom.xml new file mode 100644 index 00000000000..aa165cedea1 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_custom.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_destination.xml b/app/src/main/res/drawable/ic_quest_destination.xml new file mode 100644 index 00000000000..52dfee197c9 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_destination.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_footway_width.xml b/app/src/main/res/drawable/ic_quest_footway_width.xml new file mode 100644 index 00000000000..7de80b690e5 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_footway_width.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_general_ref.xml b/app/src/main/res/drawable/ic_quest_general_ref.xml new file mode 100644 index 00000000000..855f3d467f7 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_general_ref.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_quest_guidepost_ele.xml b/app/src/main/res/drawable/ic_quest_guidepost_ele.xml new file mode 100644 index 00000000000..9abe1cdf90b --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_guidepost_ele.xml @@ -0,0 +1,35 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_guidepost_name.xml b/app/src/main/res/drawable/ic_quest_guidepost_name.xml new file mode 100644 index 00000000000..21accc69b54 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_guidepost_name.xml @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_guidepost_sport.xml b/app/src/main/res/drawable/ic_quest_guidepost_sport.xml new file mode 100644 index 00000000000..4d1d4e9685c --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_guidepost_sport.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_healthcare_speciality.xml b/app/src/main/res/drawable/ic_quest_healthcare_speciality.xml new file mode 100644 index 00000000000..88f18668f01 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_healthcare_speciality.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_quest_lamp_mount.xml b/app/src/main/res/drawable/ic_quest_lamp_mount.xml new file mode 100644 index 00000000000..2a8850d51ae --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_lamp_mount.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_lamp_type.xml b/app/src/main/res/drawable/ic_quest_lamp_type.xml new file mode 100644 index 00000000000..0c9c25c0d0e --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_lamp_type.xml @@ -0,0 +1,40 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_map_size.xml b/app/src/main/res/drawable/ic_quest_map_size.xml new file mode 100644 index 00000000000..1ddada22435 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_map_size.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_map_type.xml b/app/src/main/res/drawable/ic_quest_map_type.xml new file mode 100644 index 00000000000..bb6a6c9a759 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_map_type.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_osmose.xml b/app/src/main/res/drawable/ic_quest_osmose.xml new file mode 100644 index 00000000000..565473fdea6 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_osmose.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_parking_capacity.xml b/app/src/main/res/drawable/ic_quest_parking_capacity.xml new file mode 100644 index 00000000000..2ea6ec2e3a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_parking_capacity.xml @@ -0,0 +1,29 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_quest_parking_capacity_disabled.xml b/app/src/main/res/drawable/ic_quest_parking_capacity_disabled.xml new file mode 100644 index 00000000000..739b50608cb --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_parking_capacity_disabled.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_parking_orientation.xml b/app/src/main/res/drawable/ic_quest_parking_orientation.xml new file mode 100644 index 00000000000..73fc3a68809 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_parking_orientation.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_pharmacy.xml b/app/src/main/res/drawable/ic_quest_pharmacy.xml new file mode 100644 index 00000000000..3cf509c4893 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_pharmacy.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_quest_piste_difficulty.xml b/app/src/main/res/drawable/ic_quest_piste_difficulty.xml new file mode 100644 index 00000000000..81552738d4f --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_piste_difficulty.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_piste_difficulty_advanced.xml b/app/src/main/res/drawable/ic_quest_piste_difficulty_advanced.xml new file mode 100644 index 00000000000..fb502e7baaf --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_piste_difficulty_advanced.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_piste_difficulty_black_diamond.xml b/app/src/main/res/drawable/ic_quest_piste_difficulty_black_diamond.xml new file mode 100644 index 00000000000..8a9091b20f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_piste_difficulty_black_diamond.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_piste_difficulty_blue_square.xml b/app/src/main/res/drawable/ic_quest_piste_difficulty_blue_square.xml new file mode 100644 index 00000000000..8dab6a78f6a --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_piste_difficulty_blue_square.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_piste_difficulty_double_black_diamond.xml b/app/src/main/res/drawable/ic_quest_piste_difficulty_double_black_diamond.xml new file mode 100644 index 00000000000..ddb194db152 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_piste_difficulty_double_black_diamond.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_quest_piste_difficulty_easy.xml b/app/src/main/res/drawable/ic_quest_piste_difficulty_easy.xml new file mode 100644 index 00000000000..608921af73c --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_piste_difficulty_easy.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_piste_difficulty_expert.xml b/app/src/main/res/drawable/ic_quest_piste_difficulty_expert.xml new file mode 100644 index 00000000000..fa87d0d218e --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_piste_difficulty_expert.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_piste_difficulty_extreme.xml b/app/src/main/res/drawable/ic_quest_piste_difficulty_extreme.xml new file mode 100644 index 00000000000..5be2343303e --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_piste_difficulty_extreme.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_piste_difficulty_freeride.xml b/app/src/main/res/drawable/ic_quest_piste_difficulty_freeride.xml new file mode 100644 index 00000000000..7a1af1ba6b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_piste_difficulty_freeride.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_piste_difficulty_intermediate.xml b/app/src/main/res/drawable/ic_quest_piste_difficulty_intermediate.xml new file mode 100644 index 00000000000..4659bc31d00 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_piste_difficulty_intermediate.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_piste_difficulty_novice.xml b/app/src/main/res/drawable/ic_quest_piste_difficulty_novice.xml new file mode 100644 index 00000000000..917fc0a9be5 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_piste_difficulty_novice.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_piste_difficulty_orange_oval.xml b/app/src/main/res/drawable/ic_quest_piste_difficulty_orange_oval.xml new file mode 100644 index 00000000000..ae05b42169a --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_piste_difficulty_orange_oval.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_piste_lit.xml b/app/src/main/res/drawable/ic_quest_piste_lit.xml new file mode 100644 index 00000000000..dde73540d0d --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_piste_lit.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_piste_ref.xml b/app/src/main/res/drawable/ic_quest_piste_ref.xml new file mode 100644 index 00000000000..4f21de7d09b --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_piste_ref.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_poi_bicycle.xml b/app/src/main/res/drawable/ic_quest_poi_bicycle.xml new file mode 100644 index 00000000000..7556cc7d23d --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_poi_bicycle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_poi_business.xml b/app/src/main/res/drawable/ic_quest_poi_business.xml new file mode 100644 index 00000000000..f6f8920daa8 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_poi_business.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_poi_camera.xml b/app/src/main/res/drawable/ic_quest_poi_camera.xml new file mode 100644 index 00000000000..bb66809b74a --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_poi_camera.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_poi_fixme.xml b/app/src/main/res/drawable/ic_quest_poi_fixme.xml new file mode 100644 index 00000000000..615a0f3f733 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_poi_fixme.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_poi_machine.xml b/app/src/main/res/drawable/ic_quest_poi_machine.xml new file mode 100644 index 00000000000..769fbf72a6c --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_poi_machine.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_poi_other.xml b/app/src/main/res/drawable/ic_quest_poi_other.xml new file mode 100644 index 00000000000..22255fe99fb --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_poi_other.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_poi_recycling.xml b/app/src/main/res/drawable/ic_quest_poi_recycling.xml new file mode 100644 index 00000000000..1df4e8eaac5 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_poi_recycling.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_poi_seating.xml b/app/src/main/res/drawable/ic_quest_poi_seating.xml new file mode 100644 index 00000000000..9fd229b9402 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_poi_seating.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_poi_traffic.xml b/app/src/main/res/drawable/ic_quest_poi_traffic.xml new file mode 100644 index 00000000000..3115871b064 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_poi_traffic.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_poi_vacant.xml b/app/src/main/res/drawable/ic_quest_poi_vacant.xml new file mode 100644 index 00000000000..ef51c143cde --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_poi_vacant.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_quest_post_office.xml b/app/src/main/res/drawable/ic_quest_post_office.xml new file mode 100644 index 00000000000..b4571c17e6c --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_post_office.xml @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_railway_platform_ref.xml b/app/src/main/res/drawable/ic_quest_railway_platform_ref.xml new file mode 100644 index 00000000000..cdc28316640 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_railway_platform_ref.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_roof_colour.xml b/app/src/main/res/drawable/ic_quest_roof_colour.xml new file mode 100644 index 00000000000..884f52b0b62 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_roof_colour.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_roof_orientation.xml b/app/src/main/res/drawable/ic_quest_roof_orientation.xml new file mode 100644 index 00000000000..546a3c8f78b --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_roof_orientation.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_sac_scale.xml b/app/src/main/res/drawable/ic_quest_sac_scale.xml new file mode 100644 index 00000000000..594987a210b --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_sac_scale.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_sauna.xml b/app/src/main/res/drawable/ic_quest_sauna.xml new file mode 100644 index 00000000000..5fa0ddd7a9f --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_sauna.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_seating_type.xml b/app/src/main/res/drawable/ic_quest_seating_type.xml new file mode 100644 index 00000000000..fe9361084c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_seating_type.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building.xml b/app/src/main/res/drawable/ic_quest_service_building.xml new file mode 100644 index 00000000000..be2f865f084 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_heating.xml b/app/src/main/res/drawable/ic_quest_service_building_heating.xml new file mode 100644 index 00000000000..d2e1563423f --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_heating.xml @@ -0,0 +1,33 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_industrial_substation.xml b/app/src/main/res/drawable/ic_quest_service_building_industrial_substation.xml new file mode 100644 index 00000000000..cffa3fb4d27 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_industrial_substation.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_internet_exchange.xml b/app/src/main/res/drawable/ic_quest_service_building_internet_exchange.xml new file mode 100644 index 00000000000..42f4fee46c0 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_internet_exchange.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_minor_substation.xml b/app/src/main/res/drawable/ic_quest_service_building_minor_substation.xml new file mode 100644 index 00000000000..267dd360b62 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_minor_substation.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_monitoring.xml b/app/src/main/res/drawable/ic_quest_service_building_monitoring.xml new file mode 100644 index 00000000000..bd49e3db5bf --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_monitoring.xml @@ -0,0 +1,17 @@ + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_oil_pump.xml b/app/src/main/res/drawable/ic_quest_service_building_oil_pump.xml new file mode 100644 index 00000000000..1bae70ca628 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_oil_pump.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_other.xml b/app/src/main/res/drawable/ic_quest_service_building_other.xml new file mode 100644 index 00000000000..386082f9455 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_other.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_power.xml b/app/src/main/res/drawable/ic_quest_service_building_power.xml new file mode 100644 index 00000000000..976b4d17904 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_power.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_power_plant.xml b/app/src/main/res/drawable/ic_quest_service_building_power_plant.xml new file mode 100644 index 00000000000..a3de46258da --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_power_plant.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_railway.xml b/app/src/main/res/drawable/ic_quest_service_building_railway.xml new file mode 100644 index 00000000000..310d26fb50e --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_railway.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_railway_engine_shed.xml b/app/src/main/res/drawable/ic_quest_service_building_railway_engine_shed.xml new file mode 100644 index 00000000000..c2911c33cb8 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_railway_engine_shed.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_railway_signal_box.xml b/app/src/main/res/drawable/ic_quest_service_building_railway_signal_box.xml new file mode 100644 index 00000000000..cdc439e241c --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_railway_signal_box.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_railway_ventilation.xml b/app/src/main/res/drawable/ic_quest_service_building_railway_ventilation.xml new file mode 100644 index 00000000000..709d432ccb9 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_railway_ventilation.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_railway_wash.xml b/app/src/main/res/drawable/ic_quest_service_building_railway_wash.xml new file mode 100644 index 00000000000..c7f4520a8d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_railway_wash.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_sewerage.xml b/app/src/main/res/drawable/ic_quest_service_building_sewerage.xml new file mode 100644 index 00000000000..bd898203a18 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_sewerage.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_substation.xml b/app/src/main/res/drawable/ic_quest_service_building_substation.xml new file mode 100644 index 00000000000..2fa5bb63cfe --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_substation.xml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_switchgear.xml b/app/src/main/res/drawable/ic_quest_service_building_switchgear.xml new file mode 100644 index 00000000000..f219db72e66 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_switchgear.xml @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_telecom.xml b/app/src/main/res/drawable/ic_quest_service_building_telecom.xml new file mode 100644 index 00000000000..8f85cd35c54 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_telecom.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_telecom_exchange.xml b/app/src/main/res/drawable/ic_quest_service_building_telecom_exchange.xml new file mode 100644 index 00000000000..55cef003d13 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_telecom_exchange.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_traction_substation.xml b/app/src/main/res/drawable/ic_quest_service_building_traction_substation.xml new file mode 100644 index 00000000000..3b75814db76 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_traction_substation.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_ventilation.xml b/app/src/main/res/drawable/ic_quest_service_building_ventilation.xml new file mode 100644 index 00000000000..75d0da6d7bb --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_ventilation.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_water.xml b/app/src/main/res/drawable/ic_quest_service_building_water.xml new file mode 100644 index 00000000000..18180e15820 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_water.xml @@ -0,0 +1,36 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_water_pump.xml b/app/src/main/res/drawable/ic_quest_service_building_water_pump.xml new file mode 100644 index 00000000000..d83240b67b2 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_water_pump.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_building_water_well.xml b/app/src/main/res/drawable/ic_quest_service_building_water_well.xml new file mode 100644 index 00000000000..2418d50c468 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_building_water_well.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_service_reservoir_covered.xml b/app/src/main/res/drawable/ic_quest_service_reservoir_covered.xml new file mode 100644 index 00000000000..f563f7cdf7f --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_service_reservoir_covered.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_shelter_type.xml b/app/src/main/res/drawable/ic_quest_shelter_type.xml new file mode 100644 index 00000000000..f1c89fdc5b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_shelter_type.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_street_cabinet.xml b/app/src/main/res/drawable/ic_quest_street_cabinet.xml new file mode 100644 index 00000000000..baf96af1d07 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_street_cabinet.xml @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_swimming_pool.xml b/app/src/main/res/drawable/ic_quest_swimming_pool.xml new file mode 100644 index 00000000000..4b2bf527769 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_swimming_pool.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_quest_trail_visibility.xml b/app/src/main/res/drawable/ic_quest_trail_visibility.xml new file mode 100644 index 00000000000..8dc4901b3bc --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_trail_visibility.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_tree.xml b/app/src/main/res/drawable/ic_quest_tree.xml new file mode 100644 index 00000000000..df6c901bec7 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_tree.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_valve.xml b/app/src/main/res/drawable/ic_quest_valve.xml new file mode 100644 index 00000000000..845c7a914b2 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_valve.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_via_ferrata_scale.xml b/app/src/main/res/drawable/ic_quest_via_ferrata_scale.xml new file mode 100644 index 00000000000..17ddc2638c0 --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_via_ferrata_scale.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quest_website.xml b/app/src/main/res/drawable/ic_quest_website.xml new file mode 100644 index 00000000000..a854261657c --- /dev/null +++ b/app/src/main/res/drawable/ic_quest_website.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_restriction_give_way.xml b/app/src/main/res/drawable/ic_restriction_give_way.xml new file mode 100644 index 00000000000..ecb0285a019 --- /dev/null +++ b/app/src/main/res/drawable/ic_restriction_give_way.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_restriction_no_left_turn.xml b/app/src/main/res/drawable/ic_restriction_no_left_turn.xml new file mode 100644 index 00000000000..c098d0faef6 --- /dev/null +++ b/app/src/main/res/drawable/ic_restriction_no_left_turn.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_restriction_no_right_turn.xml b/app/src/main/res/drawable/ic_restriction_no_right_turn.xml new file mode 100644 index 00000000000..6691018affd --- /dev/null +++ b/app/src/main/res/drawable/ic_restriction_no_right_turn.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_restriction_no_straight_on.xml b/app/src/main/res/drawable/ic_restriction_no_straight_on.xml new file mode 100644 index 00000000000..fa59beb2d68 --- /dev/null +++ b/app/src/main/res/drawable/ic_restriction_no_straight_on.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_restriction_no_u_turn.xml b/app/src/main/res/drawable/ic_restriction_no_u_turn.xml new file mode 100644 index 00000000000..9264a50ef9a --- /dev/null +++ b/app/src/main/res/drawable/ic_restriction_no_u_turn.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_restriction_only_left_turn.xml b/app/src/main/res/drawable/ic_restriction_only_left_turn.xml new file mode 100644 index 00000000000..bfe0adb215f --- /dev/null +++ b/app/src/main/res/drawable/ic_restriction_only_left_turn.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_restriction_only_right_turn.xml b/app/src/main/res/drawable/ic_restriction_only_right_turn.xml new file mode 100644 index 00000000000..38a0f754175 --- /dev/null +++ b/app/src/main/res/drawable/ic_restriction_only_right_turn.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_restriction_only_straight_on.xml b/app/src/main/res/drawable/ic_restriction_only_straight_on.xml new file mode 100644 index 00000000000..ee1cf305b4d --- /dev/null +++ b/app/src/main/res/drawable/ic_restriction_only_straight_on.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_restriction_stop.xml b/app/src/main/res/drawable/ic_restriction_stop.xml new file mode 100644 index 00000000000..ff2cdb20ff4 --- /dev/null +++ b/app/src/main/res/drawable/ic_restriction_stop.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_restriction_unknown.xml b/app/src/main/res/drawable/ic_restriction_unknown.xml new file mode 100644 index 00000000000..774fc4db062 --- /dev/null +++ b/app/src/main/res/drawable/ic_restriction_unknown.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_cone.xml b/app/src/main/res/drawable/ic_roof_colour_cone.xml new file mode 100644 index 00000000000..aaa9d4d9155 --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_cone.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_dome.xml b/app/src/main/res/drawable/ic_roof_colour_dome.xml new file mode 100644 index 00000000000..5f58fc1643f --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_dome.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_double_saltbox.xml b/app/src/main/res/drawable/ic_roof_colour_double_saltbox.xml new file mode 100644 index 00000000000..892fa09331e --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_double_saltbox.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_flat.xml b/app/src/main/res/drawable/ic_roof_colour_flat.xml new file mode 100644 index 00000000000..be3b3543a0e --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_flat.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_gabled.xml b/app/src/main/res/drawable/ic_roof_colour_gabled.xml new file mode 100644 index 00000000000..cfaa2b56fbe --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_gabled.xml @@ -0,0 +1,18 @@ + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_gambrel.xml b/app/src/main/res/drawable/ic_roof_colour_gambrel.xml new file mode 100644 index 00000000000..3caa4e8aef4 --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_gambrel.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_half_hipped.xml b/app/src/main/res/drawable/ic_roof_colour_half_hipped.xml new file mode 100644 index 00000000000..a8c25eb3ff1 --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_half_hipped.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_hipped.xml b/app/src/main/res/drawable/ic_roof_colour_hipped.xml new file mode 100644 index 00000000000..aa4bc714e15 --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_hipped.xml @@ -0,0 +1,30 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_mansard.xml b/app/src/main/res/drawable/ic_roof_colour_mansard.xml new file mode 100644 index 00000000000..bed5251affb --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_mansard.xml @@ -0,0 +1,45 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_onion.xml b/app/src/main/res/drawable/ic_roof_colour_onion.xml new file mode 100644 index 00000000000..102a9d0f4b2 --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_onion.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_pyramidal.xml b/app/src/main/res/drawable/ic_roof_colour_pyramidal.xml new file mode 100644 index 00000000000..73b511bc0c9 --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_pyramidal.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_quadruple_saltbox.xml b/app/src/main/res/drawable/ic_roof_colour_quadruple_saltbox.xml new file mode 100644 index 00000000000..e651109b3e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_quadruple_saltbox.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_round.xml b/app/src/main/res/drawable/ic_roof_colour_round.xml new file mode 100644 index 00000000000..e9369e7f877 --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_round.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_round_gabled.xml b/app/src/main/res/drawable/ic_roof_colour_round_gabled.xml new file mode 100644 index 00000000000..9aab4ca327c --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_round_gabled.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_saltbox.xml b/app/src/main/res/drawable/ic_roof_colour_saltbox.xml new file mode 100644 index 00000000000..7085c463686 --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_saltbox.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_roof_colour_skillion.xml b/app/src/main/res/drawable/ic_roof_colour_skillion.xml new file mode 100644 index 00000000000..fd161f3e371 --- /dev/null +++ b/app/src/main/res/drawable/ic_roof_colour_skillion.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_team_mode_48dp.xml b/app/src/main/res/drawable/ic_team_mode_48dp.xml new file mode 100644 index 00000000000..8dd9ad2f148 --- /dev/null +++ b/app/src/main/res/drawable/ic_team_mode_48dp.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/pin_selection_ring.xml b/app/src/main/res/drawable/pin_selection_ring.xml new file mode 100644 index 00000000000..feb2d13d261 --- /dev/null +++ b/app/src/main/res/drawable/pin_selection_ring.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/quest_street_cabinet_gas.xml b/app/src/main/res/drawable/quest_street_cabinet_gas.xml new file mode 100644 index 00000000000..29fb39efd5e --- /dev/null +++ b/app/src/main/res/drawable/quest_street_cabinet_gas.xml @@ -0,0 +1,33 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/quest_street_cabinet_postal_service.xml b/app/src/main/res/drawable/quest_street_cabinet_postal_service.xml new file mode 100644 index 00000000000..e1fe81f218d --- /dev/null +++ b/app/src/main/res/drawable/quest_street_cabinet_postal_service.xml @@ -0,0 +1,46 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/quest_street_cabinet_power.xml b/app/src/main/res/drawable/quest_street_cabinet_power.xml new file mode 100644 index 00000000000..778e6020010 --- /dev/null +++ b/app/src/main/res/drawable/quest_street_cabinet_power.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/app/src/main/res/drawable/quest_street_cabinet_sewerage.xml b/app/src/main/res/drawable/quest_street_cabinet_sewerage.xml new file mode 100644 index 00000000000..16e953b9460 --- /dev/null +++ b/app/src/main/res/drawable/quest_street_cabinet_sewerage.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/quest_street_cabinet_street_lighting.xml b/app/src/main/res/drawable/quest_street_cabinet_street_lighting.xml new file mode 100644 index 00000000000..ed5c94d26af --- /dev/null +++ b/app/src/main/res/drawable/quest_street_cabinet_street_lighting.xml @@ -0,0 +1,46 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/quest_street_cabinet_telecom.xml b/app/src/main/res/drawable/quest_street_cabinet_telecom.xml new file mode 100644 index 00000000000..6eba603d64f --- /dev/null +++ b/app/src/main/res/drawable/quest_street_cabinet_telecom.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/app/src/main/res/drawable/quest_street_cabinet_television.xml b/app/src/main/res/drawable/quest_street_cabinet_television.xml new file mode 100644 index 00000000000..30333476c5d --- /dev/null +++ b/app/src/main/res/drawable/quest_street_cabinet_television.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/quest_street_cabinet_traffic_control.xml b/app/src/main/res/drawable/quest_street_cabinet_traffic_control.xml new file mode 100644 index 00000000000..a8d09dfc93e --- /dev/null +++ b/app/src/main/res/drawable/quest_street_cabinet_traffic_control.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/quest_street_cabinet_traffic_monitoring.xml b/app/src/main/res/drawable/quest_street_cabinet_traffic_monitoring.xml new file mode 100644 index 00000000000..6623617f16f --- /dev/null +++ b/app/src/main/res/drawable/quest_street_cabinet_traffic_monitoring.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/quest_street_cabinet_transport_management.xml b/app/src/main/res/drawable/quest_street_cabinet_transport_management.xml new file mode 100644 index 00000000000..898da582d1f --- /dev/null +++ b/app/src/main/res/drawable/quest_street_cabinet_transport_management.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/quest_street_cabinet_waste.xml b/app/src/main/res/drawable/quest_street_cabinet_waste.xml new file mode 100644 index 00000000000..644ae542175 --- /dev/null +++ b/app/src/main/res/drawable/quest_street_cabinet_waste.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/quest_street_cabinet_water.xml b/app/src/main/res/drawable/quest_street_cabinet_water.xml new file mode 100644 index 00000000000..245b8eadb06 --- /dev/null +++ b/app/src/main/res/drawable/quest_street_cabinet_water.xml @@ -0,0 +1,42 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/round_white_button.xml b/app/src/main/res/drawable/round_white_button.xml index 021388e29d2..43e1950a164 100644 --- a/app/src/main/res/drawable/round_white_button.xml +++ b/app/src/main/res/drawable/round_white_button.xml @@ -33,7 +33,7 @@ - + - + - + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index c60d6cbedd8..ef03e93fef2 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -9,6 +9,27 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/cell_labeled_icon_select_trail_visibility.xml b/app/src/main/res/layout/cell_labeled_icon_select_trail_visibility.xml new file mode 100644 index 00000000000..8b5a3b28a84 --- /dev/null +++ b/app/src/main/res/layout/cell_labeled_icon_select_trail_visibility.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/cell_labeled_icon_select_via_ferrata_scale.xml b/app/src/main/res/layout/cell_labeled_icon_select_via_ferrata_scale.xml new file mode 100644 index 00000000000..e4de745fbe4 --- /dev/null +++ b/app/src/main/res/layout/cell_labeled_icon_select_via_ferrata_scale.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_access_manager.xml b/app/src/main/res/layout/dialog_access_manager.xml new file mode 100644 index 00000000000..9d6418eb749 --- /dev/null +++ b/app/src/main/res/layout/dialog_access_manager.xml @@ -0,0 +1,44 @@ + + + + + + + + + +