From cba387c8329141a156f1379340f410f729132fd9 Mon Sep 17 00:00:00 2001 From: Christoph <47949835+Sir-Photch@users.noreply.github.com> Date: Tue, 26 Mar 2024 19:06:19 +0100 Subject: [PATCH] Add OSM search provider (#611) * add openstreetmaps module in data * fix injection * retrofit2 implementation * tokenization * partial rewrite of OpeningTime.fromOverpassElement * finish rewrite of OpeningTime.fromOverpassElement * fix merge x) * configurable search radius * add settings section and disable setting explicit timeout values for http-client to alleviate issues during debugging * settings screen localization * enable radius slider only when locations are enabled * fix dayRange parsing, add barebones UI * add files to git * add location listener in SearchableItemVM that gets activated by LocationItem * add heading listener * Calculations, UI additions * rename settings to LocationsSettings * use android location library for bearing calculations * location fix * rotation fix, demo UI * working buttons for launching map and website (if available) * finish botched UI * improve overpass query by utilizing regex for fuzzy search results * add link to documentation for further reference * localization comments * remove wikipedia minification setting * schema version, default radius 1.5km * move osm-specific opening-time parsing to OsmLocation * refactor with callbackFlow * remember flow and set minimum distance update to 1m * refactor for replacementIcon, add imperial unit option * 'open until' UI fix * implement serializers * catch errors in deserializer * hacky live sorting by distance * give max priority to bestmatch determined by SearchVM * add yards as additional step to metersToLocalizzedString * move http-client from serializers of osmlocation to companion object for cache updating * add setting for custom URL * round yards to int * add botched map preview * unbotch map tiles, draw user location in map (proof of concept) * - create MapTiles Composable - add border around map - add indicators for location and userlocation, when on map * fix default imperial units setting * fix tint color * add OSM attribution string * display loading animation when tiles can't be shown yet * create compose preview of maptiles * UI work * being glad that API's just return null instead of throwing information * tryStartActivity * aniimate card row placement * Text alignment, padding * Rotation -PI/PI wrap fix * fix direction arrow rotation when screen is upside down * more icons * icons, settings, localization - consider other tags than "amenity" when determining location category - add many more location categories with corresponding icons - add settings to disable map theming and hide search results with LocationCategory.OTHER - add default localizations for settings * catch errors when deserializing location category * move location and heading functions to Context.kt in extensions * fix hideUncategorized criterion * add pre-sorting by distance for location results in SearchVM by injecting Context into search() * specify receiver parameters in ktx.Context lambdas * move pose logic and context dependency to new module devicepose with DevicePoseProvider * git, add the frickin' module * search overpass for nodes and ways include category for parcel_locker already start searching for queries extending length 2 * make openingTimes immutable * OsmRepository changes - include telephone number - don't try to repeatedly update cache if there is no value to be updated to - deduplicate results with same label by category and distance (100m) - include fixmeurl to point to openstreetmaps.org/fixthemap * ask for center in overpass API to compute center coordinates of ways * search for brand * add chemist location category * restaurant / fastfood icon shenanigans * actually add the icons :| * add leisure tag for leisure:fitness_centre * return to 'open until'/'opens in'/'open next' * adding missing UI features - bug report dialog - call button if phone number exists - grid item popup * refactor to handle 24/7 locations more comfortably * hide hours in 'opens_in' when they are zero * show maptiles such that user is always in view * drawing adjustments * cache previous zoom level to speed up tile coordinate calculations * using remember * using MutableIntState * fix logic that determines whether tiles are loading * fix for numTiles == 9 * one plus one is two plus one makes three quick maths * animate user location indicator, remember calculations * fix off by one when determining next opening hour * second attempt to fix upside down arrow rotation (probably fine now) * logging * reconsider declination, inject samplingPeriod * undoing the merge undo * move localization string to i18n * revert reordering by distance * refactor .distanceTo * make Location abstract class to override compareTo with cached distance to correct sort order in search results * when it is if when you could use when * replace Pair with dataclass * condition check order * not creating objects with undefined locations, removing suspend from getCategory() * inject permissionsmanager as constructor parameter * Store OSM settings in decentralized datastore * Update searchable content in database on launch * Refactor, add mechanism to load updated searchable data lazily * Cache all OSM data in launcher database * Add pin to favorites button to location results * Add sealed class UpdateResult that is returned by awaiting updatedSelf of DeferredSearchable - update on success - set flag on temporarily unavailable (TODO add some UI indication) - delete and invalidate VM on permanently unavailable (Display some message window to user?) * Move sorting of Locations from OsmRepository to SearchVM using cached location in DevicePoseProvider, if available * make use of cached location in code * make use of DevicePoseProvider in WeatherRepository * inject via koin * increase getLocation().timeout() to 10 minutes since we are asking for locations only every hour, so 10 minutes seem reasonable (?) * poll new location every time * add icon for cached results where results are temporarily unavailable * Refactor DeferredSearchable to UpdatableSearchable that receives a closure to retrieve an updated self. - moved timestamp (formerly `updatedAt`) to UpdatableSearchable - moved logic whether to update searchable to `requestUpdatedSearchable` in SearchableItemVM, which gets triggered every time the details of the item are shown - keep track in SearchableVM whether we should retry updating, possibly bypassing a timestamp value that is not old enough - show toast upon permanently unavailable - animate "cached_searchable" icon - make "cached_searchable" icon clickable to show toast explaining the situation * logging on PermanentlyUnavailable * refactor OsmRepository.update() * MapTheming adjustments. There is now darkmode, hooray! * remove outdated comment * code tidying * remove unnecessary LaunchedEffect * make outdated badge only clickable when actually outdated * deserialize opening schedule * set deserialized props to null if strings are blank * also consider contact:* tagging scheme for website & phone * tweaks * git add Result.kt * don't search for locations if network is not allowed * merge fixes * Move location search settings to preferences module * Change wording and order of location search preferences * Limit location search results * Order location search results by distance * Use a sequence * Android Studio's suggestion wasn't as fleshed out as one would hope * Add proguard rules * Rename TileMapRepository to MapTileLoader --------- Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com> --- .idea/inspectionProfiles/Project_Default.xml | 9 + app/app/build.gradle.kts | 4 +- .../de/mm20/launcher2/LauncherApplication.kt | 4 + app/ui/build.gradle.kts | 6 + app/ui/src/main/AndroidManifest.xml | 2 + .../mm20/launcher2/ui/animation/Alignment.kt | 28 + .../java/de/mm20/launcher2/ui/ktx/Color.kt | 12 +- .../de/mm20/launcher2/ui/ktx/ColorMatrix.kt | 78 +++ .../java/de/mm20/launcher2/ui/ktx/Deferred.kt | 15 + .../java/de/mm20/launcher2/ui/ktx/Float.kt | 60 +- .../java/de/mm20/launcher2/ui/ktx/Modifier.kt | 3 +- .../ui/launcher/search/SearchColumn.kt | 33 +- .../launcher2/ui/launcher/search/SearchVM.kt | 33 +- .../search/common/SearchableItemVM.kt | 85 ++- .../launcher/search/common/grid/GridItem.kt | 23 +- .../launcher/search/common/list/ListItem.kt | 65 +- .../launcher/search/location/LocationItem.kt | 569 +++++++++++++++++ .../ui/launcher/search/location/MapTiles.kt | 582 ++++++++++++++++++ .../launcher2/ui/settings/SettingsActivity.kt | 4 + .../locations/LocationsSettingsScreen.kt | 158 +++++ .../locations/LocationsSettingsScreenVM.kt | 77 +++ .../settings/search/SearchSettingsScreen.kt | 14 + .../settings/search/SearchSettingsScreenVM.kt | 12 +- core/base/build.gradle.kts | 1 - .../de/mm20/launcher2/coroutines/Deferred.kt | 21 + .../java/de/mm20/launcher2/search/Location.kt | 250 ++++++++ .../de/mm20/launcher2/search/Searchable.kt | 2 + .../launcher2/search/UpdatableSearchable.kt | 18 + .../main/res/drawable/ic_location_alcohol.xml | 10 + .../src/main/res/drawable/ic_location_art.xml | 10 + .../src/main/res/drawable/ic_location_atm.xml | 10 + .../main/res/drawable/ic_location_bakery.xml | 10 + .../main/res/drawable/ic_location_bank.xml | 10 + .../src/main/res/drawable/ic_location_bar.xml | 10 + .../res/drawable/ic_location_basketball.xml | 10 + .../main/res/drawable/ic_location_bicycle.xml | 11 + .../res/drawable/ic_location_bus_station.xml | 10 + .../main/res/drawable/ic_location_cafe.xml | 10 + .../src/main/res/drawable/ic_location_car.xml | 10 + .../res/drawable/ic_location_car_repair.xml | 10 + .../main/res/drawable/ic_location_cinema.xml | 10 + .../main/res/drawable/ic_location_clothes.xml | 10 + .../main/res/drawable/ic_location_college.xml | 10 + .../res/drawable/ic_location_computer.xml | 10 + .../res/drawable/ic_location_convenience.xml | 10 + .../main/res/drawable/ic_location_dentist.xml | 10 + .../main/res/drawable/ic_location_doctors.xml | 10 + .../res/drawable/ic_location_electronics.xml | 10 + .../res/drawable/ic_location_fastfood.xml | 10 + .../main/res/drawable/ic_location_fitness.xml | 10 + .../main/res/drawable/ic_location_florist.xml | 10 + .../main/res/drawable/ic_location_fuel.xml | 10 + .../res/drawable/ic_location_furniture.xml | 10 + .../main/res/drawable/ic_location_gift.xml | 10 + .../res/drawable/ic_location_grave_yard.xml | 10 + .../res/drawable/ic_location_hairdresser.xml | 10 + .../res/drawable/ic_location_hardware.xml | 10 + .../res/drawable/ic_location_hospital.xml | 10 + .../main/res/drawable/ic_location_hotel.xml | 11 + .../res/drawable/ic_location_ice_cream.xml | 10 + .../main/res/drawable/ic_location_jewelry.xml | 10 + .../main/res/drawable/ic_location_kebab.xml | 10 + .../main/res/drawable/ic_location_kiosk.xml | 10 + .../main/res/drawable/ic_location_laundry.xml | 10 + .../main/res/drawable/ic_location_library.xml | 10 + .../main/res/drawable/ic_location_mall.xml | 10 + .../res/drawable/ic_location_mobile_phone.xml | 10 + .../main/res/drawable/ic_location_museum.xml | 10 + .../res/drawable/ic_location_nightclub.xml | 10 + .../res/drawable/ic_location_optician.xml | 10 + .../drawable/ic_location_parcel_locker.xml | 10 + .../main/res/drawable/ic_location_parking.xml | 10 + .../res/drawable/ic_location_pharmacy.xml | 10 + .../main/res/drawable/ic_location_photo.xml | 10 + .../main/res/drawable/ic_location_pizza.xml | 10 + .../main/res/drawable/ic_location_place.xml | 10 + .../main/res/drawable/ic_location_police.xml | 10 + .../res/drawable/ic_location_post_office.xml | 10 + .../src/main/res/drawable/ic_location_pub.xml | 10 + .../drawable/ic_location_public_building.xml | 10 + .../res/drawable/ic_location_railway_stop.xml | 10 + .../main/res/drawable/ic_location_ramen.xml | 10 + .../res/drawable/ic_location_restaurant.xml | 10 + .../main/res/drawable/ic_location_school.xml | 10 + .../main/res/drawable/ic_location_shoes.xml | 10 + .../main/res/drawable/ic_location_soccer.xml | 10 + .../res/drawable/ic_location_supermarket.xml | 10 + .../main/res/drawable/ic_location_tapas.xml | 10 + .../main/res/drawable/ic_location_tennis.xml | 10 + .../main/res/drawable/ic_location_theatre.xml | 10 + .../main/res/drawable/ic_location_tobacco.xml | 10 + .../main/res/drawable/ic_location_toilets.xml | 10 + .../res/drawable/ic_location_tram_stop.xml | 10 + .../drawable/ic_location_travel_agency.xml | 10 + .../main/res/drawable/ic_location_wine.xml | 10 + .../launcher2/crashreporter/CrashReporter.kt | 2 +- core/devicepose/.gitignore | 1 + core/devicepose/build.gradle.kts | 48 ++ core/devicepose/consumer-rules.pro | 0 core/devicepose/proguard-rules.pro | 21 + core/devicepose/src/main/AndroidManifest.xml | 5 + .../devicepose/DevicePoseProvider.kt | 160 +++++ .../de/mm20/launcher2/devicepose/Module.kt | 8 + core/i18n/src/main/res/values-de/strings.xml | 1 + core/i18n/src/main/res/values/strings.xml | 36 ++ .../main/java/de/mm20/launcher2/ktx/Float.kt | 4 + .../main/java/de/mm20/launcher2/ktx/Int.kt | 2 +- .../java/de/mm20/launcher2/ktx/Nullable.kt | 3 + .../main/java/de/mm20/launcher2/ktx/Result.kt | 6 + .../de/mm20/launcher2/preferences/Defaults.kt | 1 + .../preferences/LauncherSettingsData.kt | 13 + .../de/mm20/launcher2/preferences/Module.kt | 2 + .../search/LocationSearchSettings.kt | 123 ++++ .../mm20/launcher2/database/SearchableDao.kt | 17 +- .../entities/SavedSearchableEntity.kt | 6 + data/openstreetmaps/.gitignore | 1 + data/openstreetmaps/build.gradle.kts | 59 ++ data/openstreetmaps/consumer-rules.pro | 2 + data/openstreetmaps/proguard-rules.pro | 21 + .../src/main/AndroidManifest.xml | 5 + .../mm20/launcher2/openstreetmaps/Module.kt | 13 + .../launcher2/openstreetmaps/OsmLocation.kt | 297 +++++++++ .../launcher2/openstreetmaps/OsmRepository.kt | 195 ++++++ .../openstreetmaps/OsmSerialization.kt | 107 ++++ .../launcher2/openstreetmaps/OverpassApi.kt | 100 +++ data/weather/build.gradle.kts | 1 + .../launcher2/weather/WeatherRepository.kt | 70 ++- gradle.properties | 3 +- gradle/libs.versions.toml | 2 +- .../services/favorites/FavoritesService.kt | 13 +- .../java/de/mm20/launcher2/search/Module.kt | 1 + .../de/mm20/launcher2/search/SearchService.kt | 11 + settings.gradle.kts | 2 + 133 files changed, 4123 insertions(+), 79 deletions(-) create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/animation/Alignment.kt create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/ktx/ColorMatrix.kt create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Deferred.kt create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/MapTiles.kt create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreen.kt create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt create mode 100644 core/base/src/main/java/de/mm20/launcher2/coroutines/Deferred.kt create mode 100644 core/base/src/main/java/de/mm20/launcher2/search/Location.kt create mode 100644 core/base/src/main/java/de/mm20/launcher2/search/UpdatableSearchable.kt create mode 100644 core/base/src/main/res/drawable/ic_location_alcohol.xml create mode 100644 core/base/src/main/res/drawable/ic_location_art.xml create mode 100644 core/base/src/main/res/drawable/ic_location_atm.xml create mode 100644 core/base/src/main/res/drawable/ic_location_bakery.xml create mode 100644 core/base/src/main/res/drawable/ic_location_bank.xml create mode 100644 core/base/src/main/res/drawable/ic_location_bar.xml create mode 100644 core/base/src/main/res/drawable/ic_location_basketball.xml create mode 100644 core/base/src/main/res/drawable/ic_location_bicycle.xml create mode 100644 core/base/src/main/res/drawable/ic_location_bus_station.xml create mode 100644 core/base/src/main/res/drawable/ic_location_cafe.xml create mode 100644 core/base/src/main/res/drawable/ic_location_car.xml create mode 100644 core/base/src/main/res/drawable/ic_location_car_repair.xml create mode 100644 core/base/src/main/res/drawable/ic_location_cinema.xml create mode 100644 core/base/src/main/res/drawable/ic_location_clothes.xml create mode 100644 core/base/src/main/res/drawable/ic_location_college.xml create mode 100644 core/base/src/main/res/drawable/ic_location_computer.xml create mode 100644 core/base/src/main/res/drawable/ic_location_convenience.xml create mode 100644 core/base/src/main/res/drawable/ic_location_dentist.xml create mode 100644 core/base/src/main/res/drawable/ic_location_doctors.xml create mode 100644 core/base/src/main/res/drawable/ic_location_electronics.xml create mode 100644 core/base/src/main/res/drawable/ic_location_fastfood.xml create mode 100644 core/base/src/main/res/drawable/ic_location_fitness.xml create mode 100644 core/base/src/main/res/drawable/ic_location_florist.xml create mode 100644 core/base/src/main/res/drawable/ic_location_fuel.xml create mode 100644 core/base/src/main/res/drawable/ic_location_furniture.xml create mode 100644 core/base/src/main/res/drawable/ic_location_gift.xml create mode 100644 core/base/src/main/res/drawable/ic_location_grave_yard.xml create mode 100644 core/base/src/main/res/drawable/ic_location_hairdresser.xml create mode 100644 core/base/src/main/res/drawable/ic_location_hardware.xml create mode 100644 core/base/src/main/res/drawable/ic_location_hospital.xml create mode 100644 core/base/src/main/res/drawable/ic_location_hotel.xml create mode 100644 core/base/src/main/res/drawable/ic_location_ice_cream.xml create mode 100644 core/base/src/main/res/drawable/ic_location_jewelry.xml create mode 100644 core/base/src/main/res/drawable/ic_location_kebab.xml create mode 100644 core/base/src/main/res/drawable/ic_location_kiosk.xml create mode 100644 core/base/src/main/res/drawable/ic_location_laundry.xml create mode 100644 core/base/src/main/res/drawable/ic_location_library.xml create mode 100644 core/base/src/main/res/drawable/ic_location_mall.xml create mode 100644 core/base/src/main/res/drawable/ic_location_mobile_phone.xml create mode 100644 core/base/src/main/res/drawable/ic_location_museum.xml create mode 100644 core/base/src/main/res/drawable/ic_location_nightclub.xml create mode 100644 core/base/src/main/res/drawable/ic_location_optician.xml create mode 100644 core/base/src/main/res/drawable/ic_location_parcel_locker.xml create mode 100644 core/base/src/main/res/drawable/ic_location_parking.xml create mode 100644 core/base/src/main/res/drawable/ic_location_pharmacy.xml create mode 100644 core/base/src/main/res/drawable/ic_location_photo.xml create mode 100644 core/base/src/main/res/drawable/ic_location_pizza.xml create mode 100644 core/base/src/main/res/drawable/ic_location_place.xml create mode 100644 core/base/src/main/res/drawable/ic_location_police.xml create mode 100644 core/base/src/main/res/drawable/ic_location_post_office.xml create mode 100644 core/base/src/main/res/drawable/ic_location_pub.xml create mode 100644 core/base/src/main/res/drawable/ic_location_public_building.xml create mode 100644 core/base/src/main/res/drawable/ic_location_railway_stop.xml create mode 100644 core/base/src/main/res/drawable/ic_location_ramen.xml create mode 100644 core/base/src/main/res/drawable/ic_location_restaurant.xml create mode 100644 core/base/src/main/res/drawable/ic_location_school.xml create mode 100644 core/base/src/main/res/drawable/ic_location_shoes.xml create mode 100644 core/base/src/main/res/drawable/ic_location_soccer.xml create mode 100644 core/base/src/main/res/drawable/ic_location_supermarket.xml create mode 100644 core/base/src/main/res/drawable/ic_location_tapas.xml create mode 100644 core/base/src/main/res/drawable/ic_location_tennis.xml create mode 100644 core/base/src/main/res/drawable/ic_location_theatre.xml create mode 100644 core/base/src/main/res/drawable/ic_location_tobacco.xml create mode 100644 core/base/src/main/res/drawable/ic_location_toilets.xml create mode 100644 core/base/src/main/res/drawable/ic_location_tram_stop.xml create mode 100644 core/base/src/main/res/drawable/ic_location_travel_agency.xml create mode 100644 core/base/src/main/res/drawable/ic_location_wine.xml create mode 100644 core/devicepose/.gitignore create mode 100644 core/devicepose/build.gradle.kts create mode 100644 core/devicepose/consumer-rules.pro create mode 100644 core/devicepose/proguard-rules.pro create mode 100644 core/devicepose/src/main/AndroidManifest.xml create mode 100644 core/devicepose/src/main/java/de/mm20/launcher2/devicepose/DevicePoseProvider.kt create mode 100644 core/devicepose/src/main/java/de/mm20/launcher2/devicepose/Module.kt create mode 100644 core/ktx/src/main/java/de/mm20/launcher2/ktx/Nullable.kt create mode 100644 core/ktx/src/main/java/de/mm20/launcher2/ktx/Result.kt create mode 100644 core/preferences/src/main/java/de/mm20/launcher2/preferences/search/LocationSearchSettings.kt create mode 100644 data/openstreetmaps/.gitignore create mode 100644 data/openstreetmaps/build.gradle.kts create mode 100644 data/openstreetmaps/consumer-rules.pro create mode 100644 data/openstreetmaps/proguard-rules.pro create mode 100644 data/openstreetmaps/src/main/AndroidManifest.xml create mode 100644 data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/Module.kt create mode 100644 data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OsmLocation.kt create mode 100644 data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OsmRepository.kt create mode 100644 data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OsmSerialization.kt create mode 100644 data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OverpassApi.kt diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 0b42fcaa..7f097246 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -4,30 +4,39 @@ \ No newline at end of file diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 171c18b9..8b8fa88f 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -13,7 +13,7 @@ android { } packaging { - //resources.excludes.add("META-INF/DEPENDENCIES") + resources.excludes.add("META-INF/DEPENDENCIES") resources.excludes.add("META-INF/LICENSE") resources.excludes.add("META-INF/LICENSE.txt") resources.excludes.add("META-INF/license.txt") @@ -166,7 +166,9 @@ dependencies { implementation(project(":services:global-actions")) implementation(project(":services:widgets")) implementation(project(":services:favorites")) + implementation(project(":data:openstreetmaps")) implementation(project(":services:plugins")) + implementation(project(":core:devicepose")) // Uncomment this if you want annoying notifications in your debug builds yelling at you how terrible your code is //debugImplementation(libs.leakcanary) diff --git a/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt b/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt index 74047e0d..5874ba4e 100644 --- a/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt +++ b/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt @@ -26,8 +26,10 @@ import de.mm20.launcher2.database.databaseModule import de.mm20.launcher2.debug.initDebugMode import de.mm20.launcher2.globalactions.globalActionsModule import de.mm20.launcher2.notifications.notificationsModule +import de.mm20.launcher2.openstreetmaps.openStreetMapsModule import de.mm20.launcher2.permissions.permissionsModule import de.mm20.launcher2.data.plugins.dataPluginsModule +import de.mm20.launcher2.devicepose.devicePoseModule import de.mm20.launcher2.plugins.servicesPluginsModule import de.mm20.launcher2.preferences.preferencesModule import de.mm20.launcher2.searchactions.searchActionsModule @@ -86,11 +88,13 @@ class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory { websitesModule, widgetsModule, wikipediaModule, + openStreetMapsModule, servicesTagsModule, widgetsServiceModule, dataPluginsModule, servicesPluginsModule, backupModule, + devicePoseModule, ) ) } diff --git a/app/ui/build.gradle.kts b/app/ui/build.gradle.kts index 32086072..4b327490 100644 --- a/app/ui/build.gradle.kts +++ b/app/ui/build.gradle.kts @@ -6,6 +6,10 @@ plugins { android { compileSdk = libs.versions.compileSdk.get().toInt() + packaging { + resources.excludes.add("META-INF/DEPENDENCIES") + } + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() @@ -136,6 +140,7 @@ dependencies { implementation(project(":core:crashreporter")) implementation(project(":data:notifications")) implementation(project(":data:contacts")) + implementation(project(":data:openstreetmaps")) implementation(project(":core:permissions")) implementation(project(":data:websites")) implementation(project(":data:unitconverter")) @@ -149,4 +154,5 @@ dependencies { implementation(project(":services:global-actions")) implementation(project(":services:widgets")) implementation(project(":services:favorites")) + implementation(project(":core:devicepose")) } \ No newline at end of file diff --git a/app/ui/src/main/AndroidManifest.xml b/app/ui/src/main/AndroidManifest.xml index 46d055c0..611c1edf 100644 --- a/app/ui/src/main/AndroidManifest.xml +++ b/app/ui/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/animation/Alignment.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/animation/Alignment.kt new file mode 100644 index 00000000..4ece55eb --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/animation/Alignment.kt @@ -0,0 +1,28 @@ +package de.mm20.launcher2.ui.animation + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment + +@Composable +fun animateHorizontalAlignmentAsState( + targetAlignment: Alignment.Horizontal, + animationSpec: AnimationSpec = tween() +): State { + val bias by animateFloatAsState( + targetValue = when (targetAlignment) { + Alignment.Start -> -1f + Alignment.End -> 1f + else -> 0f + }, + animationSpec = animationSpec + ) + return remember { derivedStateOf { BiasAlignment.Horizontal(bias) } } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt index 42a4aa3d..35ba8416 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt @@ -2,6 +2,7 @@ package de.mm20.launcher2.ui.ktx import androidx.compose.ui.graphics.Color import hct.Hct +import kotlin.math.atan2 import kotlin.math.roundToInt fun Color.toHexString(): String { @@ -17,4 +18,13 @@ fun Color.toHexString(): String { fun Color.Companion.hct(hue: Float, chroma: Float, tone: Float): Color { val hct = Hct.from(hue.toDouble(), chroma.toDouble(), tone.toDouble()) return Color(hct.toInt()) -} \ No newline at end of file +} + +val Color.hue: Float + get() { + val r = this.red / 255f + val g = this.green / 255f + val b = this.blue / 255f + // sqrt(3) + return atan2(1.7320508f * (g - b), 2f * r - g - b) + } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/ColorMatrix.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/ColorMatrix.kt new file mode 100644 index 00000000..ab7a5fc4 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/ColorMatrix.kt @@ -0,0 +1,78 @@ +package de.mm20.launcher2.ui.ktx + +import androidx.compose.ui.graphics.ColorMatrix +import de.mm20.launcher2.ktx.PI +import kotlin.math.cos +import kotlin.math.sin + +fun ColorMatrix.invert(fraction: Float, withAlpha: Boolean = false): ColorMatrix { + assert(fraction in 0f..1f) + val scale = -2f * fraction + 1f + this *= ColorMatrix().apply { + setToScale(scale, scale, scale, if (withAlpha) scale else 1f) + this[0, 4] = 255f + this[1, 4] = 255f + this[2, 4] = 255f + } + return this +} + +// https://chromium.googlesource.com/chromium/blink/+/master/Source/platform/graphics/filters/FEColorMatrix.cpp#93 +fun ColorMatrix.hueRotate(deg: Float): ColorMatrix { + val cosHue = cos(deg * Float.PI / 180f) + val sinHue = sin(deg * Float.PI / 180f) + val mat = FloatArray(20) + mat[0] = 0.213f + cosHue * 0.787f - sinHue * 0.213f + mat[1] = 0.715f - cosHue * 0.715f - sinHue * 0.715f + mat[2] = 0.072f - cosHue * 0.072f + sinHue * 0.928f + mat[5] = 0.213f - cosHue * 0.213f + sinHue * 0.143f + mat[6] = 0.715f + cosHue * 0.285f + sinHue * 0.140f + mat[7] = 0.072f - cosHue * 0.072f - sinHue * 0.283f + mat[10] = 0.213f - cosHue * 0.213f - sinHue * 0.787f + mat[11] = 0.715f - cosHue * 0.715f + sinHue * 0.715f + mat[12] = 0.072f + cosHue * 0.928f + sinHue * 0.072f + mat[18] = 1f + this *= ColorMatrix(mat) + return this +} + +fun ColorMatrix.contrast(contrast: Float): ColorMatrix { + this *= ColorMatrix( + floatArrayOf( + contrast, 0f, 0f, 0f, 0f, + 0f, contrast, 0f, 0f, 0f, + 0f, 0f, contrast, 0f, 0f, + 0f, 0f, 0f, 1f, 0f + ) + ) + return this +} + +// https://github.com/darkreader/darkreader/blob/b1bbaa74b6bd460556ab0c2a8d8e20c562e05e9b/src/generators/utils/matrix.ts#L75 +fun ColorMatrix.sepia(amount: Float): ColorMatrix { + this *= ColorMatrix( + floatArrayOf( + 0.393f + 0.607f * (1f - amount), + 0.769f - 0.769f * (1f - amount), + 0.189f - 0.189f * (1f - amount), + 0f, + 0f, + 0.349f - 0.349f * (1f - amount), + 0.686f + 0.314f * (1 - amount), + 0.168f - 0.168f * (1f - amount), + 0f, + 0f, + 0.272f - 0.272f * (1f - amount), + 0.534f - 0.534f * (1 - amount), + 0.131f + 0.869f * (1f - amount), + 0f, + 0f, + 0f, + 0f, + 0f, + 1f, + 0f, + ) + ) + return this +} diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Deferred.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Deferred.kt new file mode 100644 index 00000000..17a3b1a2 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Deferred.kt @@ -0,0 +1,15 @@ +package de.mm20.launcher2.ui.ktx + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import kotlinx.coroutines.Deferred + +@Composable +fun Deferred?.asState(initialValue: T): State { + return produceState(initialValue) { + if (this@asState != null) { + value = await() + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Float.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Float.kt index afd8a649..e5208f77 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Float.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Float.kt @@ -1,10 +1,19 @@ package de.mm20.launcher2.ui.ktx +import android.content.Context +import androidx.compose.animation.core.AnimationVector2D +import androidx.compose.animation.core.TwoWayConverter import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import kotlin.math.PI +import de.mm20.launcher2.ktx.PI +import de.mm20.launcher2.ui.R +import java.text.DecimalFormat +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin /** * Converts the given pixel size to a Dp value based on the current density @@ -13,3 +22,52 @@ import kotlin.math.PI fun Float.toDp(): Dp { return (this / LocalDensity.current.density).dp } + +fun Float.roundToString(): String = this.roundToInt().toString() + +fun Float.metersToLocalizedString(context: Context, imperialUnits: Boolean): String { + val decimalFormat = + DecimalFormat().apply { maximumFractionDigits = 1; minimumFractionDigits = 0 } + + val (value, unit) = if (imperialUnits) { + // yee haw + val asFeet = this * 3.28084f + val isYards = asFeet >= 3f + val isMiles = asFeet >= 5280f + val value = + if (isMiles) decimalFormat.format(asFeet / 5280f) + else if (isYards) (asFeet / 3f).roundToString() + else asFeet.roundToString() + + val unit = context.getString( + if (isMiles) R.string.unit_mile_symbol + else if (isYards) R.string.unit_yard_symbol + else R.string.unit_foot_symbol + ) + + value to unit + } else { + val isKm = this >= 1000f + val value = + if (isKm) decimalFormat.format(this / 1000f) + else this.roundToString() + + val unit = context.getString( + if (isKm) R.string.unit_kilometer_symbol + else R.string.unit_meter_symbol + ) + + value to unit + } + + return "$value $unit" +} + +// https://stackoverflow.com/a/68651222 +val Float.Companion.DegreesConverter + get() = TwoWayConverter({ + val rad = it * Float.PI / 180f + AnimationVector2D(sin(rad), cos(rad)) + }, { + (atan2(it.v1, it.v2) * 180f / Float.PI + 360f) % 360f + }) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Modifier.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Modifier.kt index a574a20a..a7b173c5 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Modifier.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Modifier.kt @@ -2,10 +2,9 @@ package de.mm20.launcher2.ui.ktx import androidx.compose.ui.Modifier - fun Modifier.conditional(condition: Boolean, other: Modifier): Modifier { if (condition) { return this then other } return this -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt index 65c46e75..38be6367 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -38,6 +39,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.search.Location import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.common.FavoritesTagSelector @@ -60,6 +62,9 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlin.math.ceil +private const val PRIORITY_MIN = Int.MAX_VALUE +private const val PRIORITY_MAX = Int.MIN_VALUE + @Composable fun SearchColumn( modifier: Modifier = Modifier, @@ -90,6 +95,7 @@ fun SearchColumn( val unitConverter by viewModel.unitConverterResults val calculator by viewModel.calculatorResults val wikipedia by viewModel.articleResults + val locations by viewModel.locationResults val website by viewModel.websiteResults val hiddenResults by viewModel.hiddenResults @@ -100,6 +106,7 @@ fun SearchColumn( val missingCalendarPermission by viewModel.missingCalendarPermission.collectAsState(false) val missingShortcutsPermission by viewModel.missingAppShortcutPermission.collectAsState(false) val missingContactsPermission by viewModel.missingContactsPermission.collectAsState(false) + val missingLocationPermission by viewModel.missingLocationPermission.collectAsState(false) val missingFilesPermission by viewModel.missingFilesPermission.collectAsState(false) val pinnedTags by favoritesVM.pinnedTags.collectAsState(emptyList()) @@ -295,6 +302,30 @@ fun SearchColumn( key = "contacts", highlightedItem = bestMatch as? SavableSearchable ) + ListResults( + before = if (missingLocationPermission && !isSearchEmpty) { + { + MissingPermissionBanner( + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.missing_permission_location_search), + onClick = { viewModel.requestLocationPermission(context as AppCompatActivity) }, + secondaryAction = { + OutlinedButton(onClick = { + viewModel.disableLocationSearch() + }) { + Text( + stringResource(R.string.turn_off), + ) + } + } + ) + } + } else null, + items = locations.toImmutableList(), + reverse = reverse, + key = "locations", + highlightedItem = bestMatch as? SavableSearchable + ) for (wiki in wikipedia) { SingleResult(highlight = bestMatch == wiki) { ArticleItem(article = wiki) @@ -436,7 +467,7 @@ fun LazyListScope.ListResults( key: String, before: (@Composable () -> Unit)? = null, after: (@Composable () -> Unit)? = null, - highlightedItem: SavableSearchable? + highlightedItem: SavableSearchable?, ) { if (before != null) { item(key = "$key-before") { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt index f149a180..1f600bc9 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt @@ -5,14 +5,15 @@ import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import de.mm20.launcher2.devicepose.DevicePoseProvider import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager -import de.mm20.launcher2.preferences.LegacySettings.SearchResultOrderingSettings.Ordering import de.mm20.launcher2.preferences.SearchResultOrder import de.mm20.launcher2.preferences.search.CalendarSearchSettings import de.mm20.launcher2.preferences.search.ContactSearchSettings import de.mm20.launcher2.preferences.search.FavoritesSettings import de.mm20.launcher2.preferences.search.FileSearchSettings +import de.mm20.launcher2.preferences.search.LocationSearchSettings import de.mm20.launcher2.preferences.search.ShortcutSearchSettings import de.mm20.launcher2.preferences.ui.SearchUiSettings import de.mm20.launcher2.search.AppProfile @@ -25,6 +26,7 @@ import de.mm20.launcher2.search.File import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.search.SearchService import de.mm20.launcher2.search.Searchable +import de.mm20.launcher2.search.Location import de.mm20.launcher2.search.Website import de.mm20.launcher2.search.data.Calculator import de.mm20.launcher2.search.data.UnitConverter @@ -37,6 +39,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn @@ -57,6 +60,8 @@ class SearchVM : ViewModel(), KoinComponent { private val calendarSearchSettings: CalendarSearchSettings by inject() private val shortcutSearchSettings: ShortcutSearchSettings by inject() private val searchUiSettings: SearchUiSettings by inject() + private val locationSearchSettings: LocationSearchSettings by inject() + private val devicePoseProvider: DevicePoseProvider by inject() val launchOnEnter = searchUiSettings.launchOnEnter .stateIn(viewModelScope, SharingStarted.Eagerly, false) @@ -66,6 +71,7 @@ class SearchVM : ViewModel(), KoinComponent { val searchQuery = mutableStateOf("") val isSearchEmpty = mutableStateOf(true) + val locationResults = mutableStateOf>(emptyList()) val appResults = mutableStateOf>(emptyList()) val workAppResults = mutableStateOf>(emptyList()) val appShortcutResults = mutableStateOf>(emptyList()) @@ -94,7 +100,7 @@ class SearchVM : ViewModel(), KoinComponent { val bestMatch = mutableStateOf(null) init { - search("", true) + search("", forceRestart = true) } fun launchBestMatchOrAction(context: Context) { @@ -138,6 +144,7 @@ class SearchVM : ViewModel(), KoinComponent { results.files, results.contacts, results.calendars, + results.locations, results.wikipedia, results.websites, results.calculators, @@ -169,6 +176,11 @@ class SearchVM : ViewModel(), KoinComponent { resultsList = resultsList.sortedWith { a, b -> when { + a is Location && b is Location && devicePoseProvider.lastLocation != null -> { + a.distanceTo(devicePoseProvider.lastLocation!!) + .compareTo(b.distanceTo(devicePoseProvider.lastLocation!!)) + } + a is SavableSearchable && b !is SavableSearchable -> -1 a !is SavableSearchable && b is SavableSearchable -> 1 a is SavableSearchable && b is SavableSearchable -> { @@ -200,6 +212,7 @@ class SearchVM : ViewModel(), KoinComponent { val unitConv = mutableListOf() val calc = mutableListOf() val articles = mutableListOf
() + val locations = mutableListOf() val website = mutableListOf() val actions = mutableListOf() for (r in resultsList) { @@ -218,6 +231,7 @@ class SearchVM : ViewModel(), KoinComponent { r is Calculator -> calc.add(r) r is Website -> website.add(r) r is Article -> articles.add(r) + r is Location -> locations.add(r) r is SearchAction -> actions.add(r) } } @@ -230,6 +244,7 @@ class SearchVM : ViewModel(), KoinComponent { unitConv, calc, events, + locations, contacts, articles, website, @@ -245,6 +260,7 @@ class SearchVM : ViewModel(), KoinComponent { contactResults.value = contacts calendarResults.value = events articleResults.value = articles + locationResults.value = locations websiteResults.value = website calculatorResults.value = calc unitConverterResults.value = unitConv @@ -282,6 +298,19 @@ class SearchVM : ViewModel(), KoinComponent { contactSearchSettings.setEnabled(false) } + val missingLocationPermission = combine( + permissionsManager.hasPermission(PermissionGroup.Location), + locationSearchSettings.enabled.distinctUntilChanged() + ) { perm, enabled -> !perm && enabled } + + fun requestLocationPermission(context: AppCompatActivity) { + permissionsManager.requestPermission(context, PermissionGroup.Location) + } + + fun disableLocationSearch() { + locationSearchSettings.setEnabled(false) + } + val missingFilesPermission = combine( permissionsManager.hasPermission(PermissionGroup.ExternalStorage), fileSearchSettings.localFiles diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt index 4884b661..8ca044b4 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt @@ -1,28 +1,32 @@ package de.mm20.launcher2.ui.launcher.search.common import android.content.Context -import android.content.pm.LauncherApps -import android.content.pm.ShortcutInfo -import android.graphics.drawable.Drawable +import android.util.Log +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.compose.ui.geometry.Rect import androidx.core.app.ActivityOptionsCompat -import androidx.core.content.getSystemService import de.mm20.launcher2.appshortcuts.AppShortcutRepository import de.mm20.launcher2.badges.BadgeService +import de.mm20.launcher2.devicepose.DevicePoseProvider import de.mm20.launcher2.icons.IconService import de.mm20.launcher2.icons.LauncherIcon import de.mm20.launcher2.notifications.Notification import de.mm20.launcher2.notifications.NotificationRepository import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager -import de.mm20.launcher2.search.File -import de.mm20.launcher2.search.SavableSearchable +import de.mm20.launcher2.preferences.search.LocationSearchSettings import de.mm20.launcher2.search.AppShortcut import de.mm20.launcher2.search.Application +import de.mm20.launcher2.search.File +import de.mm20.launcher2.search.SavableSearchable +import de.mm20.launcher2.search.UpdatableSearchable +import de.mm20.launcher2.search.UpdateResult import de.mm20.launcher2.services.favorites.FavoritesService import de.mm20.launcher2.services.tags.TagsService +import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.launcher.search.ListItemViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -35,7 +39,9 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import kotlin.time.Duration.Companion.hours +@OptIn(ExperimentalCoroutinesApi::class) class SearchableItemVM : ListItemViewModel(), KoinComponent { private val favoritesService: FavoritesService by inject() private val badgeService: BadgeService by inject() @@ -44,9 +50,14 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent { private val notificationRepository: NotificationRepository by inject() private val appShortcutRepository: AppShortcutRepository by inject() private val permissionsManager: PermissionsManager by inject() + private val locationSearchSettings: LocationSearchSettings by inject() - private val searchable = MutableStateFlow(null) - private val iconSize = MutableStateFlow(0) + val isUpToDate = MutableStateFlow(true) + + val devicePoseProvider: DevicePoseProvider by inject() + + val searchable = MutableStateFlow(null) + private val iconSize = MutableStateFlow(0) fun init(searchable: SavableSearchable, iconSize: Int) { this.searchable.value = searchable this.iconSize.value = iconSize @@ -104,7 +115,7 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent { ).first() } - open fun launch(context: Context, bounds: Rect? = null): Boolean { + fun launch(context: Context, bounds: Rect? = null): Boolean { val searchable = searchable.value ?: return false val view = (context as? AppCompatActivity)?.window?.decorView val options = if (bounds != null && view != null) { @@ -167,7 +178,63 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent { favoritesService.reset(searchable) } + private var shouldRetryUpdate = false + + fun requestUpdatedSearchable(context: Context) { + val searchable = searchable.value ?: return + if (searchable is UpdatableSearchable<*>) { + val updatedSelf = searchable.updatedSelf ?: return + if (!shouldRetryUpdate && System.currentTimeMillis() < searchable.timestamp + 1.hours.inWholeMilliseconds) return + viewModelScope.launch { + this@SearchableItemVM.searchable.value = with(updatedSelf()) { + when (this) { + is UpdateResult.Success -> { + isUpToDate.value = true + shouldRetryUpdate = false + favoritesService.upsert(this.result) + this.result + } + + is UpdateResult.TemporarilyUnavailable -> { + isUpToDate.value = false + shouldRetryUpdate = true + return@launch + } + + is UpdateResult.PermanentlyUnavailable -> { + isUpToDate.value = false + shouldRetryUpdate = false + favoritesService.delete(searchable) + Toast.makeText( + context, + R.string.unavailable_searchable, + Toast.LENGTH_LONG + ).show() + Log.d("requestUpdatedSearchable", "PermanentlyUnavailable", this.cause) + null + } + } + } + } + } + } + fun requestShortcutPermission(activity: AppCompatActivity) { permissionsManager.requestPermission(activity, PermissionGroup.AppShortcuts) } + + val useInsaneUnits = locationSearchSettings.imperialUnits + .stateIn(viewModelScope, SharingStarted.Lazily, false) + + val showMap = locationSearchSettings.showMap + .stateIn(viewModelScope, SharingStarted.Lazily, false) + + val applyMapTheming = locationSearchSettings.themeMap + .stateIn(viewModelScope, SharingStarted.Lazily, false) + + val showPositionOnMap = locationSearchSettings.showPositionOnMap + .stateIn(viewModelScope, SharingStarted.Lazily, false) + + val mapTileServerUrl = locationSearchSettings.tileServer + .stateIn(viewModelScope, SharingStarted.Lazily, "") } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt index b1071733..f66afc2b 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,6 +51,7 @@ import de.mm20.launcher2.search.AppShortcut import de.mm20.launcher2.search.Application import de.mm20.launcher2.search.Article import de.mm20.launcher2.search.CalendarEvent +import de.mm20.launcher2.search.Location import de.mm20.launcher2.search.Website import de.mm20.launcher2.ui.component.LauncherCard import de.mm20.launcher2.ui.component.LocalIconShape @@ -61,6 +63,7 @@ import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM import de.mm20.launcher2.ui.launcher.search.contacts.ContactItemGridPopup import de.mm20.launcher2.ui.launcher.search.files.FileItemGridPopup import de.mm20.launcher2.ui.launcher.search.listItemViewModel +import de.mm20.launcher2.ui.launcher.search.location.LocationItemGridPopup import de.mm20.launcher2.ui.launcher.search.shortcut.ShortcutItemGridPopup import de.mm20.launcher2.ui.launcher.search.website.WebsiteItemGridPopup import de.mm20.launcher2.ui.launcher.search.wikipedia.ArticleItemGridPopup @@ -86,6 +89,8 @@ fun GridItem( viewModel.init(item, iconSize.toInt()) } + val item = viewModel.searchable.collectAsState().value ?: item + val context = LocalContext.current var showPopup by remember(item.key) { mutableStateOf(false) } @@ -94,6 +99,10 @@ fun GridItem( val launchOnPress = !item.preferDetailsOverLaunch val hapticFeedback = LocalHapticFeedback.current + LaunchedEffect(showPopup) { + if (showPopup) viewModel.requestUpdatedSearchable(context) + } + Column( modifier = modifier .combinedClickable( @@ -212,7 +221,7 @@ fun ItemPopup(origin: Rect, searchable: Searchable, onDismissRequest: () -> Unit .imePadding() .padding(horizontal = 16.dp) .then( - if (show.targetState ) { + if (show.targetState) { Modifier.pointerInput(Unit) { detectTapGestures(onPress = { show.targetState = false @@ -319,6 +328,18 @@ fun ItemPopup(origin: Rect, searchable: Searchable, onDismissRequest: () -> Unit } ) } + + is Location -> { + LocationItemGridPopup( + location = searchable, + show = show, + animationProgress = animationProgress.value, + origin = origin, + onDismiss = { + show.targetState = false + } + ) + } } } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt index d0b86656..dd7e3b43 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt @@ -13,6 +13,7 @@ import de.mm20.launcher2.search.AppShortcut import de.mm20.launcher2.search.CalendarEvent import de.mm20.launcher2.search.Contact import de.mm20.launcher2.search.File +import de.mm20.launcher2.search.Location import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.ui.component.InnerCard import de.mm20.launcher2.ui.ktx.toPixels @@ -21,6 +22,7 @@ import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM import de.mm20.launcher2.ui.launcher.search.contacts.ContactItem import de.mm20.launcher2.ui.launcher.search.files.FileItem import de.mm20.launcher2.ui.launcher.search.listItemViewModel +import de.mm20.launcher2.ui.launcher.search.location.LocationItem import de.mm20.launcher2.ui.launcher.search.shortcut.AppShortcutItem import de.mm20.launcher2.ui.locals.LocalGridSettings @@ -39,6 +41,12 @@ fun ListItem( LaunchedEffect(item, iconSize) { viewModel.init(item, iconSize.toInt()) } + + LaunchedEffect(showDetails) { + if (showDetails) viewModel.requestUpdatedSearchable(context) + } + + val item = viewModel.searchable.collectAsState().value ?: item var bounds by remember { mutableStateOf(Rect.Zero) } InnerCard( @@ -64,52 +72,69 @@ fun ListItem( onBack = { showDetails = false } ) } + is File -> { FileItem( modifier = Modifier .fillMaxWidth() .combinedClickable( - enabled = !showDetails, - onClick = { - if (!viewModel.launch(context, bounds)) { - showDetails = true - } - }, - onLongClick = { showDetails = true } - ), + enabled = !showDetails, + onClick = { + if (!viewModel.launch(context, bounds)) { + showDetails = true + } + }, + onLongClick = { showDetails = true } + ), file = item, showDetails = showDetails, onBack = { showDetails = false } ) } + is CalendarEvent -> { CalendarItem( modifier = Modifier .fillMaxWidth() .combinedClickable( - enabled = !showDetails, - onClick = { showDetails = true }, - onLongClick = { showDetails = true } - ), + enabled = !showDetails, + onClick = { showDetails = true }, + onLongClick = { showDetails = true } + ), calendar = item, showDetails = showDetails, onBack = { showDetails = false } ) } + + is Location -> { + LocationItem( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + enabled = !showDetails, + onClick = { showDetails = true }, + onLongClick = { showDetails = true }), + location = item, + showDetails = showDetails, + onBack = { showDetails = false } + ) + } + is AppShortcut -> { AppShortcutItem( shortcut = item, modifier = Modifier .fillMaxWidth() .combinedClickable( - enabled = !showDetails, - onClick = { - if (!viewModel.launch(context, bounds)) { - showDetails = true - } - }, - onLongClick = { showDetails = true } - ), + enabled = !showDetails, + onClick = { + if (!viewModel.launch(context, bounds)) { + showDetails = true + } + }, + onLongClick = { showDetails = true } + ), showDetails = showDetails, onBack = { showDetails = false } ) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt new file mode 100644 index 00000000..64f0a1b2 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt @@ -0,0 +1,569 @@ +package de.mm20.launcher2.ui.launcher.search.location + +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.EaseOutBack +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateValueAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandIn +import androidx.compose.animation.shrinkOut +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.ArrowUpward +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Map +import androidx.compose.material.icons.rounded.Phone +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.StarOutline +import androidx.compose.material.icons.rounded.TravelExplore +import androidx.compose.material.icons.twotone.CloudOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntRect +import androidx.compose.ui.unit.times +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.mm20.launcher2.i18n.R +import de.mm20.launcher2.ktx.tryStartActivity +import de.mm20.launcher2.search.Location +import de.mm20.launcher2.ui.animation.animateHorizontalAlignmentAsState +import de.mm20.launcher2.ui.animation.animateTextStyleAsState +import de.mm20.launcher2.ui.component.DefaultToolbarAction +import de.mm20.launcher2.ui.component.ShapedLauncherIcon +import de.mm20.launcher2.ui.component.Toolbar +import de.mm20.launcher2.ui.component.ToolbarAction +import de.mm20.launcher2.ui.ktx.DegreesConverter +import de.mm20.launcher2.ui.ktx.metersToLocalizedString +import de.mm20.launcher2.ui.ktx.toPixels +import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM +import de.mm20.launcher2.ui.launcher.search.listItemViewModel +import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled +import de.mm20.launcher2.ui.locals.LocalGridSettings +import de.mm20.launcher2.ui.modifier.scale +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import java.time.DayOfWeek +import java.time.Duration +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.format.TextStyle +import kotlin.math.pow + +@Composable +fun LocationItem( + modifier: Modifier = Modifier, + location: Location, + showDetails: Boolean, + onBack: () -> Unit, +) { + val context = LocalContext.current + val viewModel: SearchableItemVM = listItemViewModel(key = "search-${location.key}") + val iconSize = LocalGridSettings.current.iconSize.dp.toPixels() + + val userLocation by remember { + viewModel.devicePoseProvider.getLocation() + }.collectAsStateWithLifecycle(viewModel.devicePoseProvider.lastLocation) + val insaneUnits by viewModel.useInsaneUnits.collectAsState() + + val isUpToDate by viewModel.isUpToDate.collectAsState() + + val distance = userLocation?.distanceTo(location.toAndroidLocation()) + + var showBugreportDialog by remember { mutableStateOf(false) } + + Row(modifier = modifier) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.padding(start = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val icon by viewModel.icon.collectAsStateWithLifecycle() + val badge by viewModel.badge.collectAsState(null) + Box( + modifier = Modifier + .size(52.dp) + .aspectRatio(1f) + ) { + ShapedLauncherIcon( + size = 48.dp, + icon = { icon }, + badge = { badge }, + ) + val targetIconAnimationValue = if (isUpToDate) 0f else 1f + val animatedIconAlpha by animateFloatAsState( + targetValue = targetIconAnimationValue, + animationSpec = tween(delayMillis = 275) + ) + val animatedIconSize by animateDpAsState( + targetValue = targetIconAnimationValue * 20.dp, + animationSpec = tween(delayMillis = 275, easing = EaseOutBack) + ) + Box( + Modifier + .size(22.dp) + .align(Alignment.BottomEnd) + ) { + Icon( + modifier = Modifier + .size(animatedIconSize) + .alpha(animatedIconAlpha) + .align(Alignment.Center) + .clickable(!isUpToDate) { + Toast + .makeText( + context, + R.string.cached_searchable, + Toast.LENGTH_SHORT + ) + .show() + }, + imageVector = Icons.TwoTone.CloudOff, + contentDescription = null + ) + } + } + } + Column( + modifier = Modifier.fillMaxWidth(.75f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val textStyle by animateTextStyleAsState( + if (showDetails) MaterialTheme.typography.titleMedium + else MaterialTheme.typography.titleSmall + ) + val titleAlignment by animateHorizontalAlignmentAsState( + targetAlignment = if (showDetails) Alignment.CenterHorizontally else Alignment.Start + ) + Text( + text = location.labelOverride ?: location.label, + modifier = Modifier.align(titleAlignment), + style = textStyle, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + softWrap = true, + ) + if (!location.openingSchedule?.openingHours.isNullOrEmpty()) { + val isOpen = location.openingSchedule!!.isOpen + AnimatedVisibility(!showDetails) { + Text( + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth(), + text = context.getString(if (isOpen) R.string.location_open else R.string.location_closed), + style = MaterialTheme.typography.labelSmall, + color = if (isOpen) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.Start, + ) + } + } + } + Column( + modifier = Modifier.padding(end = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround, + ) { + val targetHeading by remember(userLocation, location) { + if (userLocation != null) + viewModel.devicePoseProvider.getHeadingToDegrees( + userLocation!!.bearingTo( + location.toAndroidLocation() + ) + ) + else + emptyFlow() + }.collectAsStateWithLifecycle(null) + + if (targetHeading != null) { + val directionArrowAngle by animateValueAsState( + targetValue = targetHeading!!, + typeConverter = Float.DegreesConverter + ) + Icon( + modifier = Modifier.rotate(directionArrowAngle), + imageVector = Icons.Rounded.ArrowUpward, + contentDescription = null + ) + } + if (distance != null) { + Text( + text = distance.metersToLocalizedString( + context, insaneUnits + ), style = MaterialTheme.typography.labelSmall + ) + } + } + } + AnimatedVisibility(showDetails) { + Column { + val isTwentyFourSeven = location.openingSchedule?.isTwentyFourSeven ?: false + val hasOpeningHours = !location.openingSchedule?.openingHours.isNullOrEmpty() + val daysOfWeek = enumValues() + + val javaLocale = java.util.Locale.forLanguageTag(Locale.current.toLanguageTag()) + val timeFormatter = DateTimeFormatter + .ofLocalizedTime(FormatStyle.SHORT) + .withLocale(javaLocale) + + if (isTwentyFourSeven) { + Text( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 8.dp), + text = stringResource(id = R.string.location_open_24_7), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + ) + } else if (hasOpeningHours) { + val oh = location.openingSchedule!!.openingHours + val openIndex = oh.indexOfFirst { it.isOpen } + if (openIndex != -1) { + val todaySchedule = oh[openIndex] + Text( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 8.dp), + text = stringResource( + R.string.location_open_until, + (todaySchedule.startTime + todaySchedule.duration).format( + timeFormatter + ) + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + + val showMap by viewModel.showMap.collectAsState() + if (showMap) { + val zoomLevel = 19 + val nTiles = 4 + + val tileServerUrl by viewModel.mapTileServerUrl.collectAsState() + val shape = MaterialTheme.shapes.small + + val applyTheming by viewModel.applyMapTheming.collectAsState() + val showPositionOnMap by viewModel.showPositionOnMap.collectAsState() + + HorizontalDivider() + + MapTiles( + modifier = Modifier + .padding(top = 16.dp, bottom = 8.dp) + .align(Alignment.CenterHorizontally) + .fillMaxWidth(.9125f) + .aspectRatio(1f) + .border(1.dp, MaterialTheme.colorScheme.outline, shape) + .clip(shape) + .clickable { + viewModel.launch(context) + }, + tileServerUrl = tileServerUrl, + location = location, + initialZoomLevel = zoomLevel, + numberOfTiles = nTiles, + applyTheming = applyTheming, + userLocation = if (showPositionOnMap) userLocation?.let { + UserLocation( + it.latitude, + it.longitude + ) + } else null, + ) + + val address = buildAddress(location.street, location.houseNumber) + if (address != null) { + Text( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 8.dp), + text = address, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } + + HorizontalDivider(Modifier.padding(top = 8.dp)) + + if (!isTwentyFourSeven && hasOpeningHours) { + val today = LocalDateTime.now().dayOfWeek + val oh = location.openingSchedule!!.openingHours + val nextOpeningTime = + (0..DayOfWeek.SUNDAY.ordinal) + .firstNotNullOfOrNull { + val dow = + daysOfWeek[(today.ordinal + it) % (DayOfWeek.SUNDAY.ordinal + 1)] + oh.filter { + it.dayOfWeek == dow + }.firstOrNull { + it.dayOfWeek != today || it.startTime.isAfter(LocalTime.now()) + } + } ?: oh.first() + + Text( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 8.dp), + text = stringResource( + if (nextOpeningTime.dayOfWeek == today) R.string.location_open_next + else R.string.location_open_next_day, + if (nextOpeningTime.dayOfWeek == today) { + val untilOpenToday = Duration.between( + LocalTime.now(), + nextOpeningTime.startTime, + ) + val hours = untilOpenToday.toHours() + val minutes = untilOpenToday.toMinutes() % 60L + if (hours > 0L) "${hours}h ${minutes}m" + else "${minutes}m" + } else "${ + nextOpeningTime.dayOfWeek.getDisplayName( + TextStyle.FULL_STANDALONE, + javaLocale + ) + } ${nextOpeningTime.startTime.format(timeFormatter)}" + ), + style = MaterialTheme.typography.labelMedium, + ) + } + + val toolbarActions = mutableListOf() + + if (LocalFavoritesEnabled.current) { + val isPinned by viewModel.isPinned.collectAsState(false) + val favAction = if (isPinned) { + DefaultToolbarAction( + label = stringResource(R.string.menu_favorites_unpin), + icon = Icons.Rounded.Star, + action = { + viewModel.unpin() + } + ) + } else { + DefaultToolbarAction( + label = stringResource(R.string.menu_favorites_pin), + icon = Icons.Rounded.StarOutline, + action = { + viewModel.pin() + }) + } + toolbarActions.add(favAction) + } + + if (!showMap) { + toolbarActions += DefaultToolbarAction( + label = stringResource(id = R.string.menu_map), + icon = Icons.Rounded.Map + ) { + viewModel.launch(context) + } + + } + + location.phoneNumber?.let { + toolbarActions += DefaultToolbarAction( + label = stringResource(id = R.string.menu_dial), + icon = Icons.Rounded.Phone + ) { + viewModel.viewModelScope.launch { + context.tryStartActivity( + Intent( + Intent.ACTION_DIAL, Uri.parse("tel:$it") + ) + ) + } + } + } + + location.websiteUrl?.let { + toolbarActions += DefaultToolbarAction( + label = stringResource(id = R.string.menu_website), + icon = Icons.Rounded.TravelExplore + ) { + viewModel.viewModelScope.launch { + context.tryStartActivity( + Intent( + Intent.ACTION_VIEW, Uri.parse(it) + ) + ) + } + } + } + + location.fixMeUrl?.let { + toolbarActions += DefaultToolbarAction( + label = stringResource(id = R.string.menu_bugreport), + icon = Icons.Rounded.BugReport, + ) { + showBugreportDialog = true + } + } + + Toolbar( + modifier = Modifier.fillMaxWidth(), + leftActions = listOf(DefaultToolbarAction( + label = stringResource(id = R.string.menu_back), + icon = Icons.AutoMirrored.Rounded.ArrowBack + ) { + onBack() + }), + rightActions = toolbarActions, + ) + } + } + } + } + + if (showBugreportDialog && location.fixMeUrl != null) { + AlertDialog( + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 35.dp, + confirmButton = { + TextButton( + onClick = { showBugreportDialog = false }, + shape = MaterialTheme.shapes.medium, + ) { + Text( + text = stringResource(id = android.R.string.ok), + style = MaterialTheme.typography.labelLarge, + ) + } + }, + onDismissRequest = { + showBugreportDialog = false + }, + text = { + Column( + Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Text( + text = stringResource(id = R.string.alert_bugreport), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + softWrap = true, + textAlign = TextAlign.Justify + ) + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + Text( + modifier = modifier.clickable { + showBugreportDialog = false + viewModel.viewModelScope.launch { + context.tryStartActivity( + Intent( + Intent.ACTION_VIEW, Uri.parse(location.fixMeUrl) + ) + ) + } + }, + text = location.fixMeUrl!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + ) + } +} + +@Composable +fun LocationItemGridPopup( + location: Location, + show: MutableTransitionState, + animationProgress: Float, + origin: Rect, + onDismiss: () -> Unit +) { + AnimatedVisibility( + show, + enter = expandIn( + animationSpec = tween(300), + expandFrom = Alignment.TopEnd, + ) { origin.roundToIntRect().size }, + exit = shrinkOut( + animationSpec = tween(300), + shrinkTowards = Alignment.TopEnd, + ) { origin.roundToIntRect().size }, + ) { + LocationItem( + modifier = Modifier + .fillMaxWidth() + .scale( + 1 - (1 - LocalGridSettings.current.iconSize / 84f) * (1 - animationProgress), + transformOrigin = TransformOrigin(1f, 0f) + ) + .offset( + x = 16.dp * (1 - animationProgress).pow(10), + y = (-16).dp * (1 - animationProgress), + ), + location = location, + showDetails = true, + onBack = onDismiss, + ) + } +} + +private fun buildAddress( + street: String?, + houseNumber: String?, +): String? { + val summary = StringBuilder() + if (street != null) { + summary.append(street, ' ') + if (houseNumber != null) { + summary.append(houseNumber, ' ') + } + } + return if (summary.isEmpty()) null else summary.toString() +} diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/MapTiles.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/MapTiles.kt new file mode 100644 index 00000000..d5abaadc --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/MapTiles.kt @@ -0,0 +1,582 @@ +package de.mm20.launcher2.ui.launcher.search.location + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import androidx.compose.animation.core.EaseInOutCirc +import androidx.compose.animation.core.EaseInOutSine +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateOffsetAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.minus +import coil.ImageLoader +import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter +import coil.disk.DiskCache +import coil.memory.MemoryCache +import coil.request.CachePolicy +import coil.request.ImageRequest +import de.mm20.launcher2.ktx.PI +import de.mm20.launcher2.ktx.tryStartActivity +import de.mm20.launcher2.search.Location +import de.mm20.launcher2.search.LocationCategory +import de.mm20.launcher2.search.OpeningHours +import de.mm20.launcher2.search.OpeningSchedule +import de.mm20.launcher2.search.SavableSearchable +import de.mm20.launcher2.search.SearchableSerializer +import de.mm20.launcher2.ui.ktx.contrast +import de.mm20.launcher2.ui.ktx.hue +import de.mm20.launcher2.ui.ktx.hueRotate +import de.mm20.launcher2.ui.ktx.invert +import de.mm20.launcher2.ui.locals.LocalDarkTheme +import kotlinx.collections.immutable.toImmutableList +import org.koin.android.ext.koin.androidContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koin.core.context.GlobalContext +import org.koin.core.context.startKoin +import kotlin.math.asinh +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sqrt +import kotlin.math.tan + +data class UserLocation(val lat: Double, val lon: Double) + +@Composable +fun MapTiles( + tileServerUrl: String, + location: Location, + initialZoomLevel: Int, + numberOfTiles: Int, + userLocation: UserLocation?, + applyTheming: Boolean, + modifier: Modifier = Modifier, + // https://wiki.openstreetmap.org/wiki/Attribution_guidelines/2021-06-04_draft#Attribution_text + osmAttribution: String? = "© OpenStreetMap", +) { + val context = LocalContext.current + val tintColor = MaterialTheme.colorScheme.surface + val darkMode = LocalDarkTheme.current + + val previousZoomLevel = remember { mutableIntStateOf(-1) } + val (start, stop, zoom) = remember(userLocation) { + userLocation + ?.runCatching { + getEnclosingTiles( + location, + numberOfTiles, + this, + previousZoomLevel + ) + } + ?.onFailure { + Log.e("MapTiles", "Enclosing calculation failed", it) + } + ?.getOrNull() + ?: getTilesAround(location, initialZoomLevel, numberOfTiles) + } + + val sideLength = stop.x - start.x + 1 + + val imageStates = remember { (0 until numberOfTiles).map { false }.toMutableStateList() } + + val colorMatrix = remember(applyTheming, darkMode, tintColor) { + // darkreader css for openstreetmap tiles + // invert(93.7%) hue-rotate(180deg) contrast(90.6%) + val tintHueDeg = tintColor.hue * 180f / Float.PI + + if (!darkMode && applyTheming) { + ColorMatrix() + .hueRotate(tintHueDeg) + } else if (darkMode) { + ColorMatrix() + .invert(0.937f) + .hueRotate(180f + if (applyTheming) tintHueDeg else 0f) + .contrast(0.906f) + } else null + } + + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Column(modifier = Modifier.matchParentSize()) { + for (y in start.y..stop.y) { + Row( + modifier = Modifier + .weight(1f / sideLength) + .fillMaxSize() + ) { + for (x in start.x..stop.x) { + AsyncImage( + modifier = Modifier + .weight(1f / sideLength) + .fillMaxSize(), + imageLoader = MapTileLoader.loader, + model = MapTileLoader.getTileRequest(tileServerUrl, x, y, zoom), + contentDescription = null, + colorFilter = colorMatrix?.let { ColorFilter.colorMatrix(it) }, + filterQuality = FilterQuality.High, + onState = { + val stateIndex = + (y - start.y) * (stop.y - start.y + 1) + (x - start.x) + when (it) { + is AsyncImagePainter.State.Loading -> imageStates[stateIndex] = + false + + is AsyncImagePainter.State.Success -> imageStates[stateIndex] = + true + + is AsyncImagePainter.State.Error -> { + imageStates[stateIndex] = false + Log.e( + "MapTiles", + "Error loading tile: $x, $y @$zoom", + it.result.throwable + ) + } + + else -> {} + } + } + ) + } + } + } + } + + if (imageStates.all { it }) { + val locationBorderColor = + if (applyTheming) MaterialTheme.colorScheme.error else Color(0xFFEFA521) // orange-ish + val userLocationColor = + if (applyTheming) MaterialTheme.colorScheme.onErrorContainer else Color(0xFF35A82C) // darkish green + val userLocationBorderColor = + if (applyTheming) { + MaterialTheme.colorScheme.errorContainer + } else if (darkMode) { + Color(0xFF777777) + } else { + Color(0xFFE5E5E5) + } + + val infiniteTransition = rememberInfiniteTransition("infiniteTransition") + val userLocAnimation by infiniteTransition.animateFloat( + initialValue = 0.8f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = EaseInOutSine), + repeatMode = RepeatMode.Reverse + ), + label = "userLocAnimation" + ) + val poiLocAnimation by infiniteTransition.animateFloat( + initialValue = 30f, + targetValue = 20f, + animationSpec = infiniteRepeatable( + animation = tween(750, easing = EaseInOutCirc), + repeatMode = RepeatMode.Reverse + ), + label = "poiLocAnimation" + ) + + val textMeasurer = rememberTextMeasurer() + val osmAttributionTextStyle = MaterialTheme.typography.labelSmall + val osmAttributionTextColor = MaterialTheme.colorScheme.onSurface + val osmAttributionSurface = + MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = .5f) + + val (yLocation, xLocation) = remember(location, zoom) { + getDoubleTileCoordinates( + latitude = location.latitude, + longitude = location.longitude, + zoom + ) + } + val (yUser, xUser) = remember(userLocation, zoom) { + if (userLocation != null) { + getDoubleTileCoordinates( + latitude = userLocation.lat, + longitude = userLocation.lon, + zoom + ) + } else { + -1.0 to -1.0 + } + } + val animatedUserIndicatorOffset by animateOffsetAsState( + targetValue = (Offset( + xUser.toFloat(), + yUser.toFloat() + ) - start) / sideLength.toFloat(), + animationSpec = tween( + 1000, + 250 + ) + ) + + + Canvas(modifier = Modifier.matchParentSize()) { + assert(size.width == size.height) + + if (userLocation != null) { + if (start.y < yUser && yUser < stop.y + 1 && + start.x < xUser && xUser < stop.x + 1 + ) { + val userIndicatorOffset = animatedUserIndicatorOffset * size.width + drawCircle( + color = userLocationBorderColor, + radius = 18.5f * userLocAnimation, + center = userIndicatorOffset, + alpha = (userLocAnimation - 0.8f) * 5f + ) + drawCircle( + color = userLocationColor, + radius = 13.5f * userLocAnimation, + center = userIndicatorOffset, + ) + } + } + + val locationIndicatorOffset = + (Offset( + xLocation.toFloat(), + yLocation.toFloat() + ) - start) / sideLength.toFloat() * size.width + drawCircle( + color = locationBorderColor, + radius = poiLocAnimation, + center = locationIndicatorOffset, + style = Stroke(width = 4f) + ) + if (osmAttribution != null) { + val measureResult = textMeasurer.measure( + osmAttribution, + maxLines = 1, + style = osmAttributionTextStyle + ) + val osmLabelPadding = 6f + val textOffset = Offset( + x = size.width - measureResult.size.width - osmLabelPadding, + y = size.height - measureResult.size.height - osmLabelPadding + ) + drawRoundRect( + color = osmAttributionSurface, + topLeft = textOffset - Offset(osmLabelPadding, 0f), + size = Size( + width = measureResult.size.width + 2 * osmLabelPadding, + height = measureResult.size.height + osmLabelPadding + ), + cornerRadius = CornerRadius(8f, 8f) + ) + drawText( + measureResult, + color = osmAttributionTextColor, + topLeft = textOffset + ) + } + } + } else { + val loadingColor = MaterialTheme.colorScheme.secondary + CircularProgressIndicator( + modifier = Modifier.fillMaxSize(.15f), + color = loadingColor, + strokeCap = StrokeCap.Round, + ) + } + } +} + +private fun getDoubleTileCoordinates( + latitude: Double, + longitude: Double, + zoomLevel: Int +): Pair { + // https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Mathematics + val latRadians = Math.toRadians(latitude) + val xCoordinate = (longitude + 180.0) / 360.0 * (1 shl zoomLevel) + val yCoordinate = (1.0 - asinh(tan(latRadians)) / Math.PI) * (1 shl (zoomLevel - 1)) + + return yCoordinate to xCoordinate +} + +data class TileCoordinateRange(val start: IntOffset, val stop: IntOffset, val zoomLevel: Int) + +private fun getTilesAround( + location: Location, + zoomLevel: Int, + nTiles: Int +): TileCoordinateRange { + if (sqrt(nTiles.toDouble()) % 1.0 != 0.0) + throw IllegalArgumentException("nTiles must be a square number") + + val sideLen = sqrt(nTiles.toDouble()).toInt() + val sideLenHalf = sideLen / 2 + + val (yCoordinate, xCoordinate) = getDoubleTileCoordinates( + location.latitude, + location.longitude, + zoomLevel + ) + val xTile = xCoordinate.toInt() + val yTile = yCoordinate.toInt() + + val yStart: Int + val yStop: Int + val xStart: Int + val xStop: Int + + if (sideLen % 2 == 1) { + // center tile is defined + yStart = yTile - sideLenHalf + yStop = yTile + sideLenHalf + xStart = xTile - sideLenHalf + xStop = xTile + sideLenHalf + } else { + // center tile is not defined; take adjacent tiles closest to coordinate of interest + val leftOfCenter = (xCoordinate % 1.0) < 0.5 + val topOfCenter = (yCoordinate % 1.0) < 0.5 + + yStart = if (topOfCenter) yTile - sideLenHalf else yTile - sideLenHalf + 1 + yStop = if (topOfCenter) yTile + sideLenHalf - 1 else yTile + sideLenHalf + xStart = if (leftOfCenter) xTile - sideLenHalf else xTile - sideLenHalf + 1 + xStop = if (leftOfCenter) xTile + sideLenHalf - 1 else xTile + sideLenHalf + } + + return TileCoordinateRange(IntOffset(xStart, yStart), IntOffset(xStop, yStop), zoomLevel) +} + +const val ZOOM_MAX = 19 +const val ZOOM_MIN = 0 + +private fun getEnclosingTiles( + location: Location, + nTiles: Int, + userLocation: UserLocation, + previousZoomLevel: MutableIntState, +): TileCoordinateRange { + if (sqrt(nTiles.toDouble()) % 1.0 != 0.0) + throw IllegalArgumentException("nTiles must be a square number") + + val sideLen = sqrt(nTiles.toDouble()).toInt() + val sideLenHalf = sideLen / 2 + + // start at previous zoom (+1) to do less calculations, because if + // - user comes closer to location: + // we might be able to increase the zoom level by one + // - user moves further away from location: + // we still iterate down to minimum zoom and there is no need to start with ZOOM_MAX for that + for (zoomLevel in previousZoomLevel + .intValue + .let { if (it == -1) ZOOM_MAX else min(it + 1, ZOOM_MAX) } downTo ZOOM_MIN + ) { + + val (locationY, locationX) = getDoubleTileCoordinates( + location.latitude, + location.longitude, + zoomLevel + ) + val (userY, userX) = getDoubleTileCoordinates( + userLocation.lat, + userLocation.lon, + zoomLevel + ) + + val (locationTileY, locationTileX) = locationY.toInt() to locationX.toInt() + val (userTileY, userTileX) = userY.toInt() to userX.toInt() + + if (locationTileY - sideLenHalf <= userTileY && userTileY <= locationTileY + sideLenHalf && + locationTileX - sideLenHalf <= userTileX && userTileX <= locationTileX + sideLenHalf + ) { + var xStart = min(locationTileX, userTileX) + var yStart = min(locationTileY, userTileY) + var xStop = max(locationTileX, userTileX) + var yStop = max(locationTileY, userTileY) + + val xRem = sideLen - xStop + xStart - 1 + if (0 < xRem) { + val leftOfCenter = (locationX % 1.0) < 0.5 + val ceil = ceil(xRem / 2.0).toInt() + val floor = floor(xRem / 2.0).toInt() + + if (leftOfCenter) { + xStart -= ceil + xStop += floor + } else { + xStart -= floor + xStop += ceil + } + } + val yRem = sideLen - yStop + yStart - 1 + if (0 < yRem) { + val topOfCenter = (locationY % 1.0) < 0.5 + val ceil = ceil(yRem / 2.0).toInt() + val floor = floor(yRem / 2.0).toInt() + + if (topOfCenter) { + yStart -= ceil + yStop += floor + } else { + yStart -= floor + yStop += ceil + } + } + + previousZoomLevel.intValue = zoomLevel + + return TileCoordinateRange( + IntOffset(xStart, yStart), + IntOffset(xStop, yStop), + zoomLevel + ) + } + } + + throw IllegalStateException("Unreachable (right?) | lat: ${location.latitude} | lon: ${location.longitude} | user: $userLocation | nTiles: $nTiles") +} + +private object MapTileLoader : KoinComponent { + private val context: Context by inject() + + private val userAgent = "${context.packageName}/${ + context.packageManager.getPackageInfo( + context.packageName, + 0 + )?.versionName ?: "dev" + }" + fun getTileRequest(tileServerUrl: String, x: Int, y: Int, zoom: Int): ImageRequest { + return ImageRequest.Builder(context) + .data("$tileServerUrl/$zoom/$x/$y.png") + .addHeader( + "User-Agent", + userAgent + ) + .build() + } + + val loader = ImageLoader + .Builder(context) + .memoryCache { + MemoryCache.Builder(context) + .maxSizePercent(0.05) + .build() + } + .memoryCachePolicy(CachePolicy.ENABLED) + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("osm_tiles")) + .maxSizePercent(0.01) + .build() + } + .diskCachePolicy(CachePolicy.ENABLED) + .respectCacheHeaders(true) + .networkCachePolicy(CachePolicy.ENABLED) + .build() +} + +@Preview +@Composable +private fun MapTilesPreview() { + val context = LocalContext.current + + if (GlobalContext.getKoinApplicationOrNull() == null) { + startKoin { + androidContext(context) + } + } + + val borderShape = MaterialTheme.shapes.medium + + MapTiles( + modifier = Modifier + .size(300.dp) + .border( + BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + borderShape + ) + .clip(borderShape), + tileServerUrl = "http://tile.openstreetmap.org", + location = MockLocation, + initialZoomLevel = 19, + numberOfTiles = 9, + applyTheming = false, + userLocation = UserLocation(52.51623, 13.4048) + ) +} + +internal object MockLocation : Location { + + override val domain: String = "MOCKLOCATION" + override val key: String = "MOCKLOCATION" + override val label: String = "Brandenburger Tor" + override val fixMeUrl: String = "https://www.openstreetmap.org/fixthemap" + + override val latitude = 52.5162700 + override val longitude = 13.3777021 + + override var category: LocationCategory? = LocationCategory.OTHER + + override val street: String = "Pariser Platz" + + override val houseNumber: String = "1" + + override val openingSchedule: OpeningSchedule = + OpeningSchedule(true, emptyList().toImmutableList()) + + override val websiteUrl: String = "https://en.wikipedia.org/wiki/Brandenburg_Gate" + + override val phoneNumber: String = "+49 1234567" + + override fun overrideLabel(label: String): SavableSearchable = TODO() + + override fun launch(context: Context, options: Bundle?): Boolean = + context.tryStartActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://en.wikipedia.org/wiki/Brandenburg_Gate") + ) + ) + + override fun getSerializer(): SearchableSerializer = TODO() +} diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt index 587f5654..bdbebaae 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt @@ -48,6 +48,7 @@ import de.mm20.launcher2.ui.settings.homescreen.HomescreenSettingsScreen import de.mm20.launcher2.ui.settings.icons.IconsSettingsScreen import de.mm20.launcher2.ui.settings.integrations.IntegrationsSettingsScreen import de.mm20.launcher2.ui.settings.license.LicenseScreen +import de.mm20.launcher2.ui.settings.locations.LocationsSettingsScreen import de.mm20.launcher2.ui.settings.log.LogScreen import de.mm20.launcher2.ui.settings.main.MainSettingsScreen import de.mm20.launcher2.ui.settings.media.MediaIntegrationSettingsScreen @@ -151,6 +152,9 @@ class SettingsActivity : BaseActivity() { composable("settings/search/wikipedia") { WikipediaSettingsScreen() } + composable("settings/search/locations") { + LocationsSettingsScreen() + } composable("settings/search/files") { FileSearchSettingsScreen() } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreen.kt new file mode 100644 index 00000000..45395abe --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreen.kt @@ -0,0 +1,158 @@ +package de.mm20.launcher2.ui.settings.locations + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.preferences.search.LocationSearchSettings +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.preferences.ListPreference +import de.mm20.launcher2.ui.component.preferences.PreferenceCategory +import de.mm20.launcher2.ui.component.preferences.PreferenceScreen +import de.mm20.launcher2.ui.component.preferences.SliderPreference +import de.mm20.launcher2.ui.component.preferences.SwitchPreference +import de.mm20.launcher2.ui.component.preferences.TextPreference +import de.mm20.launcher2.ui.ktx.metersToLocalizedString + +@Composable +fun LocationsSettingsScreen() { + val viewModel: LocationsSettingsScreenVM = viewModel() + + val locations by viewModel.locations.collectAsState() + val imperialUnits by viewModel.imperialUnits.collectAsState() + val hideUncategorized by viewModel.hideUncategorized.collectAsState() + val radius by viewModel.radius.collectAsState() + val customOverpassUrl by viewModel.customOverpassUrl.collectAsState() + val showMap by viewModel.showMap.collectAsState() + val themeMap by viewModel.themeMap.collectAsState() + val showPositionOnMap by viewModel.showPositionOnMap.collectAsState() + val customTileServerUrl by viewModel.customTileServerUrl.collectAsState() + + + PreferenceScreen(title = stringResource(R.string.preference_search_locations)) { + item { + PreferenceCategory { + SwitchPreference( + title = stringResource(R.string.preference_search_locations), + summary = stringResource(R.string.preference_search_locations_summary), + value = locations == true, + onValueChanged = { + viewModel.setLocations(it) + } + ) + } + } + item { + PreferenceCategory { + ListPreference( + title = stringResource(R.string.length_unit), + items = listOf( + stringResource(R.string.imperial) to true, + stringResource(R.string.metric) to false + ), + enabled = locations == true, + value = imperialUnits, + onValueChanged = { + viewModel.setImperialUnits(it) + } + ) + SliderPreference( + title = stringResource(R.string.preference_search_locations_radius), + value = radius, + min = 500, + max = 10000, + step = 500, + enabled = locations == true, + onValueChanged = { + viewModel.setRadius(it) + }, + label = { + Text( + modifier = Modifier + .width(64.dp) + .padding(start = 16.dp), + text = it.toFloat() + .metersToLocalizedString(LocalContext.current, imperialUnits), + style = MaterialTheme.typography.titleSmall + ) + it.toFloat() + .metersToLocalizedString(LocalContext.current, imperialUnits) + } + ) + SwitchPreference( + title = stringResource(R.string.preference_search_locations_hide_uncategorized), + summary = stringResource(R.string.preference_search_locations_hide_uncategorized_summary), + value = hideUncategorized == true, + enabled = locations == true, + onValueChanged = { + viewModel.setHideUncategorized(it) + } + ) + } + } + item { + PreferenceCategory { + SwitchPreference( + title = stringResource(R.string.preference_search_locations_show_map), + summary = stringResource(R.string.preference_search_locations_show_map_summary), + enabled = locations == true, + value = showMap == true, + onValueChanged = { + viewModel.setShowMap(it) + } + ) + SwitchPreference( + title = stringResource(R.string.preference_search_locations_theme_map), + summary = stringResource(R.string.preference_search_locations_theme_map_summary), + value = themeMap == true, + enabled = locations == true && showMap == true, + onValueChanged = { + viewModel.setThemeMap(it) + } + ) + SwitchPreference( + title = stringResource(R.string.preference_search_locations_show_position_on_map), + summary = stringResource(R.string.preference_search_locations_show_position_on_map_summary), + value = showPositionOnMap == true, + enabled = locations == true && showMap == true, + onValueChanged = { + viewModel.setShowPositionOnMap(it) + } + ) + } + + } + item { + PreferenceCategory(stringResource(R.string.preference_category_advanced)) { + TextPreference( + title = stringResource(R.string.preference_search_location_custom_overpass_url), + value = customOverpassUrl, + placeholder = stringResource(id = R.string.overpass_url), + summary = customOverpassUrl.takeIf { !it.isNullOrBlank() } + ?: stringResource(id = R.string.overpass_url), + onValueChanged = { + viewModel.setCustomOverpassUrl(it) + } + ) + TextPreference( + title = stringResource(R.string.preference_search_location_custom_tile_server_url), + value = customTileServerUrl ?: "", + placeholder = LocationSearchSettings.DefaultTileServerUrl, + summary = customTileServerUrl.takeIf { !it.isNullOrBlank() } + ?: LocationSearchSettings.DefaultTileServerUrl, + onValueChanged = { + viewModel.setCustomTileServerUrl(it) + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt new file mode 100644 index 00000000..49720a44 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt @@ -0,0 +1,77 @@ +package de.mm20.launcher2.ui.settings.locations + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.mm20.launcher2.preferences.search.LocationSearchSettings +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class LocationsSettingsScreenVM: ViewModel(), KoinComponent { + private val settings: LocationSearchSettings by inject() + + val locations = settings.enabled + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + fun setLocations(openStreetMaps: Boolean) { + settings.setEnabled(openStreetMaps) + } + + val imperialUnits = settings.imperialUnits + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) + fun setImperialUnits(imperialUnits: Boolean) { + settings.setImperialUnits(imperialUnits) + } + + val radius = settings.searchRadius + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 1500) + fun setRadius(radius: Int) { + settings.setSearchRadius(radius) + } + + val customOverpassUrl = settings.overpassUrl + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") + fun setCustomOverpassUrl(customUrl: String) { + var url = customUrl + if (url.endsWith('/')){ + url = url.substringBeforeLast('/') + } + if (url.endsWith("/api/interpreter")) { + url = url.substringBeforeLast("/api/interpreter") + } + + settings.setOverpassUrl(url) + } + + val showMap = settings.showMap + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + fun setShowMap(showMap: Boolean) { + settings.setShowMap(showMap) + } + + val showPositionOnMap = settings.showPositionOnMap + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + fun setShowPositionOnMap(showPositionOnMap: Boolean) { + settings.setShowPositionOnMap(showPositionOnMap) + } + + val customTileServerUrl = settings.tileServer + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + fun setCustomTileServerUrl(customTileServerUrl: String) { + settings.setTileServer(customTileServerUrl) + } + + val hideUncategorized = settings.hideUncategorized + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + fun setHideUncategorized(hideUncategorized: Boolean) { + settings.setHideUncategorized(hideUncategorized) + } + + val themeMap = settings.themeMap + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + fun setThemeMap(themeMap: Boolean) { + settings.setThemeMap(themeMap) + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt index faefb804..38beb495 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt @@ -174,6 +174,20 @@ fun SearchSettingsScreen() { } ) + val locations by viewModel.locations.collectAsStateWithLifecycle(null) + PreferenceWithSwitch( + title= stringResource(R.string.preference_search_locations), + summary = stringResource(R.string.preference_search_locations_summary), + icon = Icons.Rounded.Place, + switchValue = locations == true, + onSwitchChanged = { + viewModel.setLocations(it) + }, + onClick = { + navController?.navigate("settings/search/locations") + } + ) + Preference( title = stringResource(R.string.preference_screen_search_actions), summary = stringResource(R.string.preference_search_search_actions_summary), diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt index a0c377e3..d234262e 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt @@ -9,15 +9,14 @@ import de.mm20.launcher2.preferences.SearchResultOrder import de.mm20.launcher2.preferences.search.CalculatorSearchSettings import de.mm20.launcher2.preferences.search.CalendarSearchSettings import de.mm20.launcher2.preferences.search.ContactSearchSettings +import de.mm20.launcher2.preferences.search.LocationSearchSettings import de.mm20.launcher2.preferences.search.ShortcutSearchSettings import de.mm20.launcher2.preferences.search.UnitConverterSettings import de.mm20.launcher2.preferences.search.WebsiteSearchSettings import de.mm20.launcher2.preferences.search.WikipediaSearchSettings import de.mm20.launcher2.preferences.ui.SearchUiSettings import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -32,6 +31,7 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent { private val calculatorSearchSettings: CalculatorSearchSettings by inject() private val permissionsManager: PermissionsManager by inject() + private val locationSearchSettings: LocationSearchSettings by inject() val favorites = searchUiSettings.favorites .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) @@ -95,9 +95,15 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent { websiteSearchSettings.setEnabled(websites) } - val autoFocus = searchUiSettings.openKeyboard + val locations = locationSearchSettings.enabled .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + fun setLocations(locations: Boolean) { + locationSearchSettings.setEnabled(locations) + } + + val autoFocus = searchUiSettings.openKeyboard + fun setAutoFocus(autoFocus: Boolean) { searchUiSettings.setOpenKeyboard(autoFocus) } diff --git a/core/base/build.gradle.kts b/core/base/build.gradle.kts index 794b1a05..071a5883 100644 --- a/core/base/build.gradle.kts +++ b/core/base/build.gradle.kts @@ -46,7 +46,6 @@ dependencies { implementation(libs.materialcomponents.core) implementation(libs.koin.android) - implementation(libs.androidx.palette) implementation(project(":core:ktx")) diff --git a/core/base/src/main/java/de/mm20/launcher2/coroutines/Deferred.kt b/core/base/src/main/java/de/mm20/launcher2/coroutines/Deferred.kt new file mode 100644 index 00000000..3185f3f5 --- /dev/null +++ b/core/base/src/main/java/de/mm20/launcher2/coroutines/Deferred.kt @@ -0,0 +1,21 @@ +package de.mm20.launcher2.coroutines + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +fun deferred(block: suspend () -> T): Deferred { + val deferred = CompletableDeferred() + return object : Deferred by deferred { + private val mutex = Mutex() + override suspend fun await(): T { + mutex.withLock { + if (!deferred.isCompleted) { + block().also { deferred.complete(it) } + } + } + return deferred.await() + } + } +} diff --git a/core/base/src/main/java/de/mm20/launcher2/search/Location.kt b/core/base/src/main/java/de/mm20/launcher2/search/Location.kt new file mode 100644 index 00000000..72be196d --- /dev/null +++ b/core/base/src/main/java/de/mm20/launcher2/search/Location.kt @@ -0,0 +1,250 @@ +package de.mm20.launcher2.search + +import android.content.Context +import androidx.core.content.ContextCompat +import de.mm20.launcher2.base.R +import de.mm20.launcher2.icons.ColorLayer +import de.mm20.launcher2.icons.StaticLauncherIcon +import de.mm20.launcher2.icons.TintedIconLayer +import kotlinx.collections.immutable.ImmutableList +import java.time.DayOfWeek +import java.time.Duration +import java.time.LocalDate +import java.time.LocalTime +import android.location.Location as AndroidLocation + +interface Location : SavableSearchable { + + val latitude: Double + val longitude: Double + val fixMeUrl: String? + + val category: LocationCategory? + + val street: String? + val houseNumber: String? + val openingSchedule: OpeningSchedule? + val websiteUrl: String? + val phoneNumber: String? + + override val preferDetailsOverLaunch: Boolean + get() = true + + override fun getPlaceholderIcon(context: Context): StaticLauncherIcon { + val (resId, bgColor) = when (category) { + LocationCategory.FAST_FOOD, LocationCategory.RESTAURANT -> with( + labelOverride ?: label + ) { + when { + contains( + "pizza", + ignoreCase = true + ) -> R.drawable.ic_location_pizza to R.color.red + + contains( + "ramen", + ignoreCase = true + ) -> R.drawable.ic_location_ramen to R.color.orange + + contains( + "tapas", + ignoreCase = true + ) -> R.drawable.ic_location_tapas to R.color.orange + + contains( + "keba" /* b or p, depending on locale */, + ignoreCase = true + ) -> R.drawable.ic_location_kebab to R.color.orange + + category == LocationCategory.FAST_FOOD -> R.drawable.ic_location_fastfood to R.color.orange + else -> R.drawable.ic_location_restaurant to R.color.red + } + } + + LocationCategory.BAR -> R.drawable.ic_location_bar to R.color.amber + LocationCategory.CAFE, LocationCategory.COFFEE_SHOP -> R.drawable.ic_location_cafe to R.color.brown + LocationCategory.HOTEL -> R.drawable.ic_location_hotel to R.color.green + LocationCategory.SUPERMARKET -> R.drawable.ic_location_supermarket to R.color.lightblue + LocationCategory.SCHOOL -> R.drawable.ic_location_school to R.color.purple + LocationCategory.PARKING -> R.drawable.ic_location_parking to R.color.blue + LocationCategory.FUEL -> R.drawable.ic_location_fuel to R.color.teal + LocationCategory.TOILETS -> R.drawable.ic_location_toilets to R.color.blue + LocationCategory.PHARMACY -> R.drawable.ic_location_pharmacy to R.color.pink + LocationCategory.HOSPITAL, LocationCategory.CLINIC -> R.drawable.ic_location_hospital to R.color.red + LocationCategory.POST_OFFICE -> R.drawable.ic_location_post_office to R.color.yellow + LocationCategory.PUB, LocationCategory.BIERGARTEN -> R.drawable.ic_location_pub to R.color.amber + LocationCategory.GRAVE_YARD -> R.drawable.ic_location_grave_yard to R.color.grey + LocationCategory.DOCTORS -> R.drawable.ic_location_doctors to R.color.red + LocationCategory.POLICE -> R.drawable.ic_location_police to R.color.blue + LocationCategory.DENTIST -> R.drawable.ic_location_dentist to R.color.lightblue + LocationCategory.LIBRARY, LocationCategory.BOOKS -> R.drawable.ic_location_library to R.color.brown + LocationCategory.COLLEGE, LocationCategory.UNIVERSITY -> R.drawable.ic_location_college to R.color.purple + LocationCategory.ICE_CREAM -> R.drawable.ic_location_ice_cream to R.color.pink + LocationCategory.THEATRE -> R.drawable.ic_location_theatre to R.color.purple + LocationCategory.PUBLIC_BUILDING -> R.drawable.ic_location_public_building to R.color.bluegrey + LocationCategory.CINEMA -> R.drawable.ic_location_cinema to R.color.purple + LocationCategory.NIGHTCLUB -> R.drawable.ic_location_nightclub to R.color.purple + LocationCategory.CONVENIENCE -> R.drawable.ic_location_convenience to R.color.lightblue + LocationCategory.CLOTHES -> R.drawable.ic_location_clothes to R.color.pink + LocationCategory.HAIRDRESSER, LocationCategory.BEAUTY -> R.drawable.ic_location_hairdresser to R.color.pink + LocationCategory.CAR_REPAIR -> R.drawable.ic_location_car_repair to R.color.blue + LocationCategory.BAKERY -> R.drawable.ic_location_bakery to R.color.brown + LocationCategory.CAR -> R.drawable.ic_location_car to R.color.blue + LocationCategory.MOBILE_PHONE -> R.drawable.ic_location_mobile_phone to R.color.blue + LocationCategory.FURNITURE -> R.drawable.ic_location_furniture to R.color.brown + LocationCategory.ALCOHOL -> R.drawable.ic_location_alcohol to R.color.amber + LocationCategory.FLORIST -> R.drawable.ic_location_florist to R.color.green + LocationCategory.HARDWARE -> R.drawable.ic_location_hardware to R.color.brown + LocationCategory.ELECTRONICS -> R.drawable.ic_location_electronics to R.color.blue + LocationCategory.SHOES -> R.drawable.ic_location_shoes to R.color.pink + LocationCategory.MALL, LocationCategory.DEPARTMENT_STORE, LocationCategory.CHEMIST -> R.drawable.ic_location_mall to R.color.blue + LocationCategory.OPTICIAN -> R.drawable.ic_location_optician to R.color.blue + LocationCategory.JEWELRY -> R.drawable.ic_location_jewelry to R.color.pink + LocationCategory.GIFT -> R.drawable.ic_location_gift to R.color.pink + LocationCategory.BICYCLE -> R.drawable.ic_location_bicycle to R.color.blue + LocationCategory.LAUNDRY -> R.drawable.ic_location_laundry to R.color.blue + LocationCategory.COMPUTER -> R.drawable.ic_location_computer to R.color.blue + LocationCategory.TOBACCO -> R.drawable.ic_location_tobacco to R.color.amber + LocationCategory.WINE -> R.drawable.ic_location_wine to R.color.amber + LocationCategory.PHOTO -> R.drawable.ic_location_photo to R.color.blue + LocationCategory.BANK -> R.drawable.ic_location_bank to R.color.blue + LocationCategory.SOCCER -> R.drawable.ic_location_soccer to R.color.green + LocationCategory.BASKETBALL -> R.drawable.ic_location_basketball to R.color.orange + LocationCategory.TENNIS -> R.drawable.ic_location_tennis to R.color.orange + LocationCategory.FITNESS, LocationCategory.FITNESS_CENTRE -> R.drawable.ic_location_fitness to R.color.orange + LocationCategory.TRAM_STOP -> R.drawable.ic_location_tram_stop to R.color.blue + LocationCategory.RAILWAY_STOP -> R.drawable.ic_location_railway_stop to R.color.lightblue + LocationCategory.BUS_STATION, LocationCategory.BUS_STOP -> R.drawable.ic_location_bus_station to R.color.blue + LocationCategory.ATM -> R.drawable.ic_location_atm to R.color.green + LocationCategory.ART -> R.drawable.ic_location_art to R.color.deeporange + LocationCategory.KIOSK -> R.drawable.ic_location_kiosk to R.color.bluegrey + LocationCategory.MUSEUM -> R.drawable.ic_location_museum to R.color.deeporange + LocationCategory.PARCEL_LOCKER -> R.drawable.ic_location_parcel_locker to R.color.bluegrey + LocationCategory.TRAVEL_AGENCY -> R.drawable.ic_location_travel_agency to R.color.lightblue + else -> R.drawable.ic_location_place to R.color.bluegrey + } + return StaticLauncherIcon( + foregroundLayer = TintedIconLayer( + icon = ContextCompat.getDrawable(context, resId)!!, + scale = 0.5f, + color = ContextCompat.getColor(context, bgColor) + ), + backgroundLayer = ColorLayer(ContextCompat.getColor(context, bgColor)) + ) + } + + fun toAndroidLocation(): AndroidLocation { + val location = AndroidLocation("KvaesitsoLocationProvider") + + location.latitude = latitude + location.longitude = longitude + + return location + } + + fun distanceTo(androidLocation: AndroidLocation): Float { + return androidLocation.distanceTo(this.toAndroidLocation()) + } + + fun distanceTo(otherLocation: Location): Float = + this.distanceTo(otherLocation.toAndroidLocation()) +} + +// https://taginfo.openstreetmap.org/tags +// 'amenity', 'shop', 'sport' of which the most important +enum class LocationCategory { + RESTAURANT, + FAST_FOOD, + BAR, + CAFE, + HOTEL, + SUPERMARKET, + OTHER, + SCHOOL, + PARKING, + FUEL, + TOILETS, + PHARMACY, + HOSPITAL, + POST_OFFICE, + PUB, + GRAVE_YARD, + DOCTORS, + POLICE, + DENTIST, + LIBRARY, + COLLEGE, + ICE_CREAM, + THEATRE, + PUBLIC_BUILDING, + CINEMA, + NIGHTCLUB, + BIERGARTEN, + CLINIC, + UNIVERSITY, + DEPARTMENT_STORE, + CLOTHES, + CONVENIENCE, + HAIRDRESSER, + CAR_REPAIR, + BEAUTY, + BOOKS, + BAKERY, + CAR, + MOBILE_PHONE, + FURNITURE, + ALCOHOL, + FLORIST, + HARDWARE, + ELECTRONICS, + SHOES, + MALL, + OPTICIAN, + JEWELRY, + GIFT, + BICYCLE, + LAUNDRY, + COMPUTER, + TOBACCO, + WINE, + PHOTO, + COFFEE_SHOP, + BANK, + SOCCER, + BASKETBALL, + TENNIS, + FITNESS, + TRAM_STOP, + RAILWAY_STOP, + BUS_STATION, + ATM, + ART, + KIOSK, + BUS_STOP, + MUSEUM, + PARCEL_LOCKER, + CHEMIST, + TRAVEL_AGENCY, + FITNESS_CENTRE +} + +data class OpeningHours( + val dayOfWeek: DayOfWeek, + val startTime: LocalTime, + val duration: Duration +) { + val isOpen: Boolean + get() = LocalDate.now().dayOfWeek == dayOfWeek && + LocalTime.now().isAfter(startTime) && + LocalTime.now().isBefore(startTime.plus(duration)) + + override fun toString(): String = "$dayOfWeek $startTime-${startTime.plus(duration)}" +} + +data class OpeningSchedule( + val isTwentyFourSeven: Boolean, + val openingHours: ImmutableList +) { + val isOpen: Boolean + get() = isTwentyFourSeven || openingHours.any { it.isOpen } +} diff --git a/core/base/src/main/java/de/mm20/launcher2/search/Searchable.kt b/core/base/src/main/java/de/mm20/launcher2/search/Searchable.kt index 8f07941a..7c2040ed 100644 --- a/core/base/src/main/java/de/mm20/launcher2/search/Searchable.kt +++ b/core/base/src/main/java/de/mm20/launcher2/search/Searchable.kt @@ -1,3 +1,5 @@ package de.mm20.launcher2.search +import kotlinx.coroutines.Deferred + interface Searchable \ No newline at end of file diff --git a/core/base/src/main/java/de/mm20/launcher2/search/UpdatableSearchable.kt b/core/base/src/main/java/de/mm20/launcher2/search/UpdatableSearchable.kt new file mode 100644 index 00000000..fc6c4046 --- /dev/null +++ b/core/base/src/main/java/de/mm20/launcher2/search/UpdatableSearchable.kt @@ -0,0 +1,18 @@ +package de.mm20.launcher2.search + +/** + * Interface that can be implemented by [SavableSearchable]s to provide a way to update itself. + * Consumers of [SavableSearchable]s can check if the [SavableSearchable] implements this interface + * and decide to get an updated version of the [SavableSearchable] by calling [updatedSelf], which + * returns an [UpdateResult] that contains either an up-to-date value or specifies unavailability. + */ +interface UpdatableSearchable { + val timestamp: Long + val updatedSelf: (suspend () -> UpdateResult)? +} + +sealed class UpdateResult { + data class Success(val result: T) : UpdateResult() + data class TemporarilyUnavailable(val cause: Throwable? = null) : UpdateResult() + data class PermanentlyUnavailable(val cause: Throwable? = null) : UpdateResult() +} diff --git a/core/base/src/main/res/drawable/ic_location_alcohol.xml b/core/base/src/main/res/drawable/ic_location_alcohol.xml new file mode 100644 index 00000000..daa69902 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_alcohol.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_art.xml b/core/base/src/main/res/drawable/ic_location_art.xml new file mode 100644 index 00000000..58c504c2 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_art.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_atm.xml b/core/base/src/main/res/drawable/ic_location_atm.xml new file mode 100644 index 00000000..d30afef0 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_atm.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_bakery.xml b/core/base/src/main/res/drawable/ic_location_bakery.xml new file mode 100644 index 00000000..5d32b1b1 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_bakery.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_bank.xml b/core/base/src/main/res/drawable/ic_location_bank.xml new file mode 100644 index 00000000..0d11a26e --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_bank.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_bar.xml b/core/base/src/main/res/drawable/ic_location_bar.xml new file mode 100644 index 00000000..4177e3c9 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_bar.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_basketball.xml b/core/base/src/main/res/drawable/ic_location_basketball.xml new file mode 100644 index 00000000..188a7212 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_basketball.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_bicycle.xml b/core/base/src/main/res/drawable/ic_location_bicycle.xml new file mode 100644 index 00000000..0aa0787a --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_bicycle.xml @@ -0,0 +1,11 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_bus_station.xml b/core/base/src/main/res/drawable/ic_location_bus_station.xml new file mode 100644 index 00000000..252a94e9 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_bus_station.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_cafe.xml b/core/base/src/main/res/drawable/ic_location_cafe.xml new file mode 100644 index 00000000..c78c90c4 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_cafe.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_car.xml b/core/base/src/main/res/drawable/ic_location_car.xml new file mode 100644 index 00000000..415d5424 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_car.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_car_repair.xml b/core/base/src/main/res/drawable/ic_location_car_repair.xml new file mode 100644 index 00000000..4331b6c5 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_car_repair.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_cinema.xml b/core/base/src/main/res/drawable/ic_location_cinema.xml new file mode 100644 index 00000000..7ecda3c1 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_cinema.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_clothes.xml b/core/base/src/main/res/drawable/ic_location_clothes.xml new file mode 100644 index 00000000..c685ffbf --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_clothes.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_college.xml b/core/base/src/main/res/drawable/ic_location_college.xml new file mode 100644 index 00000000..74566fa6 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_college.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_computer.xml b/core/base/src/main/res/drawable/ic_location_computer.xml new file mode 100644 index 00000000..013db96c --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_computer.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_convenience.xml b/core/base/src/main/res/drawable/ic_location_convenience.xml new file mode 100644 index 00000000..7d31d5bf --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_convenience.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_dentist.xml b/core/base/src/main/res/drawable/ic_location_dentist.xml new file mode 100644 index 00000000..f06b6b06 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_dentist.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_doctors.xml b/core/base/src/main/res/drawable/ic_location_doctors.xml new file mode 100644 index 00000000..df3addaf --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_doctors.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_electronics.xml b/core/base/src/main/res/drawable/ic_location_electronics.xml new file mode 100644 index 00000000..b20db4af --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_electronics.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_fastfood.xml b/core/base/src/main/res/drawable/ic_location_fastfood.xml new file mode 100644 index 00000000..853b3906 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_fastfood.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_fitness.xml b/core/base/src/main/res/drawable/ic_location_fitness.xml new file mode 100644 index 00000000..ecaa2c28 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_fitness.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_florist.xml b/core/base/src/main/res/drawable/ic_location_florist.xml new file mode 100644 index 00000000..05707d01 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_florist.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_fuel.xml b/core/base/src/main/res/drawable/ic_location_fuel.xml new file mode 100644 index 00000000..22405b9c --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_fuel.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_furniture.xml b/core/base/src/main/res/drawable/ic_location_furniture.xml new file mode 100644 index 00000000..cc1cd227 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_furniture.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_gift.xml b/core/base/src/main/res/drawable/ic_location_gift.xml new file mode 100644 index 00000000..9a5201f1 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_gift.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_grave_yard.xml b/core/base/src/main/res/drawable/ic_location_grave_yard.xml new file mode 100644 index 00000000..f84a005d --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_grave_yard.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_hairdresser.xml b/core/base/src/main/res/drawable/ic_location_hairdresser.xml new file mode 100644 index 00000000..a30d24cf --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_hairdresser.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_hardware.xml b/core/base/src/main/res/drawable/ic_location_hardware.xml new file mode 100644 index 00000000..565fe4e4 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_hardware.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_hospital.xml b/core/base/src/main/res/drawable/ic_location_hospital.xml new file mode 100644 index 00000000..6a40ded6 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_hospital.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_hotel.xml b/core/base/src/main/res/drawable/ic_location_hotel.xml new file mode 100644 index 00000000..dec280aa --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_hotel.xml @@ -0,0 +1,11 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_ice_cream.xml b/core/base/src/main/res/drawable/ic_location_ice_cream.xml new file mode 100644 index 00000000..017315ee --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_ice_cream.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_jewelry.xml b/core/base/src/main/res/drawable/ic_location_jewelry.xml new file mode 100644 index 00000000..14459a2d --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_jewelry.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_kebab.xml b/core/base/src/main/res/drawable/ic_location_kebab.xml new file mode 100644 index 00000000..9b1dd862 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_kebab.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_kiosk.xml b/core/base/src/main/res/drawable/ic_location_kiosk.xml new file mode 100644 index 00000000..35ccab47 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_kiosk.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_laundry.xml b/core/base/src/main/res/drawable/ic_location_laundry.xml new file mode 100644 index 00000000..95cedb4e --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_laundry.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_library.xml b/core/base/src/main/res/drawable/ic_location_library.xml new file mode 100644 index 00000000..38d4811a --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_library.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_mall.xml b/core/base/src/main/res/drawable/ic_location_mall.xml new file mode 100644 index 00000000..07157c52 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_mall.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_mobile_phone.xml b/core/base/src/main/res/drawable/ic_location_mobile_phone.xml new file mode 100644 index 00000000..e9f64e67 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_mobile_phone.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_museum.xml b/core/base/src/main/res/drawable/ic_location_museum.xml new file mode 100644 index 00000000..f6a0272d --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_museum.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_nightclub.xml b/core/base/src/main/res/drawable/ic_location_nightclub.xml new file mode 100644 index 00000000..66602aff --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_nightclub.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_optician.xml b/core/base/src/main/res/drawable/ic_location_optician.xml new file mode 100644 index 00000000..3f02ad0f --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_optician.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_parcel_locker.xml b/core/base/src/main/res/drawable/ic_location_parcel_locker.xml new file mode 100644 index 00000000..bf8519d4 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_parcel_locker.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_parking.xml b/core/base/src/main/res/drawable/ic_location_parking.xml new file mode 100644 index 00000000..4e3c63ec --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_parking.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_pharmacy.xml b/core/base/src/main/res/drawable/ic_location_pharmacy.xml new file mode 100644 index 00000000..e05b7c18 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_pharmacy.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_photo.xml b/core/base/src/main/res/drawable/ic_location_photo.xml new file mode 100644 index 00000000..1620f8ca --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_photo.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_pizza.xml b/core/base/src/main/res/drawable/ic_location_pizza.xml new file mode 100644 index 00000000..93fef9c3 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_pizza.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_place.xml b/core/base/src/main/res/drawable/ic_location_place.xml new file mode 100644 index 00000000..89578be4 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_place.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_police.xml b/core/base/src/main/res/drawable/ic_location_police.xml new file mode 100644 index 00000000..a06c7655 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_police.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_post_office.xml b/core/base/src/main/res/drawable/ic_location_post_office.xml new file mode 100644 index 00000000..b17823e4 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_post_office.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_pub.xml b/core/base/src/main/res/drawable/ic_location_pub.xml new file mode 100644 index 00000000..1366a648 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_pub.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_public_building.xml b/core/base/src/main/res/drawable/ic_location_public_building.xml new file mode 100644 index 00000000..76671bd7 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_public_building.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_railway_stop.xml b/core/base/src/main/res/drawable/ic_location_railway_stop.xml new file mode 100644 index 00000000..9858788f --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_railway_stop.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_ramen.xml b/core/base/src/main/res/drawable/ic_location_ramen.xml new file mode 100644 index 00000000..41d42806 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_ramen.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_restaurant.xml b/core/base/src/main/res/drawable/ic_location_restaurant.xml new file mode 100644 index 00000000..975ae006 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_restaurant.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_school.xml b/core/base/src/main/res/drawable/ic_location_school.xml new file mode 100644 index 00000000..09bc06b6 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_school.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_shoes.xml b/core/base/src/main/res/drawable/ic_location_shoes.xml new file mode 100644 index 00000000..a1c55621 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_shoes.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_soccer.xml b/core/base/src/main/res/drawable/ic_location_soccer.xml new file mode 100644 index 00000000..73ec0423 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_soccer.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_supermarket.xml b/core/base/src/main/res/drawable/ic_location_supermarket.xml new file mode 100644 index 00000000..c2b53b8b --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_supermarket.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_tapas.xml b/core/base/src/main/res/drawable/ic_location_tapas.xml new file mode 100644 index 00000000..9224018a --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_tapas.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_tennis.xml b/core/base/src/main/res/drawable/ic_location_tennis.xml new file mode 100644 index 00000000..77fe00e7 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_tennis.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_theatre.xml b/core/base/src/main/res/drawable/ic_location_theatre.xml new file mode 100644 index 00000000..2b24794b --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_theatre.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_tobacco.xml b/core/base/src/main/res/drawable/ic_location_tobacco.xml new file mode 100644 index 00000000..455bd715 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_tobacco.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_toilets.xml b/core/base/src/main/res/drawable/ic_location_toilets.xml new file mode 100644 index 00000000..e9f200bd --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_toilets.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_tram_stop.xml b/core/base/src/main/res/drawable/ic_location_tram_stop.xml new file mode 100644 index 00000000..50db90f7 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_tram_stop.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_travel_agency.xml b/core/base/src/main/res/drawable/ic_location_travel_agency.xml new file mode 100644 index 00000000..42be767a --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_travel_agency.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/base/src/main/res/drawable/ic_location_wine.xml b/core/base/src/main/res/drawable/ic_location_wine.xml new file mode 100644 index 00000000..fb734ca0 --- /dev/null +++ b/core/base/src/main/res/drawable/ic_location_wine.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/crashreporter/src/main/java/de/mm20/launcher2/crashreporter/CrashReporter.kt b/core/crashreporter/src/main/java/de/mm20/launcher2/crashreporter/CrashReporter.kt index 2ae26257..72c0c115 100644 --- a/core/crashreporter/src/main/java/de/mm20/launcher2/crashreporter/CrashReporter.kt +++ b/core/crashreporter/src/main/java/de/mm20/launcher2/crashreporter/CrashReporter.kt @@ -14,7 +14,7 @@ import java.io.File object CrashReporter { fun logException(e: Exception) { if (e !is CancellationException) { - com.balsikandar.crashreporter.CrashReporter.logException(e) + CrashReporter.logException(e) } Log.e("MM20", Log.getStackTraceString(e)) } diff --git a/core/devicepose/.gitignore b/core/devicepose/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/devicepose/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/devicepose/build.gradle.kts b/core/devicepose/build.gradle.kts new file mode 100644 index 00000000..5d6c0e1d --- /dev/null +++ b/core/devicepose/build.gradle.kts @@ -0,0 +1,48 @@ +@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "de.mm20.launcher2.location" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation(libs.androidx.core) + implementation(libs.androidx.appcompat) + implementation(libs.materialcomponents.core) + + implementation(libs.koin.android) + + implementation(project(":core:ktx")) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/core/devicepose/consumer-rules.pro b/core/devicepose/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/core/devicepose/proguard-rules.pro b/core/devicepose/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/core/devicepose/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/devicepose/src/main/AndroidManifest.xml b/core/devicepose/src/main/AndroidManifest.xml new file mode 100644 index 00000000..48d92bb3 --- /dev/null +++ b/core/devicepose/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/devicepose/src/main/java/de/mm20/launcher2/devicepose/DevicePoseProvider.kt b/core/devicepose/src/main/java/de/mm20/launcher2/devicepose/DevicePoseProvider.kt new file mode 100644 index 00000000..5b7e45ba --- /dev/null +++ b/core/devicepose/src/main/java/de/mm20/launcher2/devicepose/DevicePoseProvider.kt @@ -0,0 +1,160 @@ +package de.mm20.launcher2.devicepose + +import android.Manifest +import android.content.Context +import android.hardware.GeomagneticField +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.util.Log +import androidx.core.content.getSystemService +import de.mm20.launcher2.ktx.PI +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.channelFlow +import de.mm20.launcher2.ktx.checkPermission +import kotlinx.coroutines.flow.combine + +class DevicePoseProvider internal constructor( + private val context: Context +) { + var lastLocation: Location? = null + private set + + private var declination: Float? = null + private fun updateDeclination(location: Location) { + declination = GeomagneticField( + location.latitude.toFloat(), + location.longitude.toFloat(), + location.altitude.toFloat(), + location.time + ).declination + } + + fun getLocation(minTimeMs: Long = 1000, minDistanceM: Float = 1f) = channelFlow { + val locationCallback = LocationListener { + lastLocation = it + updateDeclination(it) + trySend(it) + } + + context.getSystemService() + ?.runCatching { + val hasFineAccess = + context.checkPermission(Manifest.permission.ACCESS_FINE_LOCATION) + val hasCoarseAccess = + context.checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION) + + val location = + (if (hasFineAccess) this@runCatching.getLastKnownLocation(LocationManager.GPS_PROVIDER) else null) + ?: if (hasCoarseAccess) this@runCatching.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) else null + + if (location != null) { + lastLocation = location + updateDeclination(location) + trySend(location) + } + + if (hasFineAccess) { + this@runCatching.requestLocationUpdates( + LocationManager.GPS_PROVIDER, + minTimeMs, + minDistanceM, + locationCallback + ) + } + if (hasCoarseAccess) { + this@runCatching.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + minTimeMs, + minDistanceM, + locationCallback + ) + } + }?.onFailure { + Log.e("SearchableItemVM", "Failed to register location listener", it) + } + + awaitClose { + context.getSystemService()?.removeUpdates(locationCallback) + } + } + + fun getAzimuthDegrees(samplingPeriodUs: Int = SensorManager.SENSOR_DELAY_UI): Flow = callbackFlow { + val azimuthCallback = object : SensorEventListener { + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + override fun onSensorChanged(event: SensorEvent?) { + if (event?.sensor?.type != Sensor.TYPE_ROTATION_VECTOR) + return + + val rotationMatrix = FloatArray(9) + val orientationAngles = FloatArray(3) + + SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) + SensorManager.getOrientation(rotationMatrix, orientationAngles) + + trySend(orientationAngles[0] * 180f / Float.PI + (declination ?: 0f)) + } + } + + context + .getSystemService() + ?.runCatching { + this@runCatching.registerListener( + azimuthCallback, + this@runCatching.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) + ?: return@runCatching, + samplingPeriodUs + ) + }?.onFailure { + Log.e("DevicePoseProvider", "Failed to register ROTATION_VECTOR listener", it) + } + + awaitClose { + context.getSystemService()?.unregisterListener(azimuthCallback) + } + } + + fun getHeadingToDegrees(headingEastwardDegrees: Float, samplingPeriodUs: Int = SensorManager.SENSOR_DELAY_UI): Flow = combine( + getAzimuthDegrees(samplingPeriodUs), + callbackFlow { + val upsideDownCallback = object : SensorEventListener { + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + override fun onSensorChanged(event: SensorEvent?) { + if (event?.sensor?.type != Sensor.TYPE_GRAVITY) + return + + val (_, _, z) = event.values + trySend(z < 0f) + } + } + context + .getSystemService() + ?.runCatching { + this@runCatching.registerListener( + upsideDownCallback, + this@runCatching.getDefaultSensor(Sensor.TYPE_GRAVITY) + ?: return@runCatching, + samplingPeriodUs + ) + }?.onFailure { + Log.e("SearchableItemVM", "Failed to register GRAVITY listener", it) + } + + awaitClose { + context.getSystemService()?.unregisterListener(upsideDownCallback) + } + }) { azimuthDegrees, isUpsideDown -> + + if (isUpsideDown) { + azimuthDegrees - headingEastwardDegrees + } else { + headingEastwardDegrees - azimuthDegrees + } + } +} diff --git a/core/devicepose/src/main/java/de/mm20/launcher2/devicepose/Module.kt b/core/devicepose/src/main/java/de/mm20/launcher2/devicepose/Module.kt new file mode 100644 index 00000000..3d4c075f --- /dev/null +++ b/core/devicepose/src/main/java/de/mm20/launcher2/devicepose/Module.kt @@ -0,0 +1,8 @@ +package de.mm20.launcher2.devicepose + +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +val devicePoseModule = module { + single { DevicePoseProvider(androidContext()) } +} \ No newline at end of file diff --git a/core/i18n/src/main/res/values-de/strings.xml b/core/i18n/src/main/res/values-de/strings.xml index 72f4d56c..84819c6c 100644 --- a/core/i18n/src/main/res/values-de/strings.xml +++ b/core/i18n/src/main/res/values-de/strings.xml @@ -193,6 +193,7 @@ Tja, da sind Sie. Herzlichen Glückwunsch. War es das wert\? Kalender-Berechtigung gewähren, um Ihre nächsten Termine hier anzuzeigen. Kalender-Berechtigung gewähren, um Termine zu durchsuchen. + Standortzugriff gewähren, um Orte zu finden. Kontakt-Berechtigung gewähren, um Kontakte zu durchsuchen. Speicher-Berechtigung gewähren, um Fotos, Medien und Dokumente auf diesem Gerät zu durchsuchen. Schließen diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index 6ae96527..bc4e74ae 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -1,5 +1,13 @@ + Open + Closed + + Open until %1$s + + Opens in %1$s + + Opens next %1$s Share @@ -20,6 +28,8 @@ Open Hide + Map + Website Don\'t hide %1$s has been hidden. @@ -413,6 +423,8 @@ Grant calendar permission to display upcoming appointments and events here. Grant calendar permission to search your calendar. + + Grant location permission to search nearby locations. Grant storage permission to search photos, media and document on this device. @@ -640,7 +652,11 @@ Wikipedia Search the free encyclopedia Websites + Places + Search radius Show a preview of a website if the search query is a URL + Search the local area for shops and other places + Show your own location in the map Web search Show shortcuts to different search engines Local files @@ -857,6 +873,23 @@ This shortcut is unavailable because %1$s isn\'t the default launcher Enable plugin + Unit of length + Imperial + Metric + Overpass URL + https://overpass-api.de + Hide uncategorized locations + Show only results with well defined categories, like cafés or restaurants + Map + Show a map preview for places + Map theming + Apply the color scheme of the launcher to the map + Show location + Tileserver URL + Dial + Bug Report + You have reached the bug-report dialog. In case the location result contains incorrect data, please double check with the provider and tap the link below if the issue is theirs. If this is not the case, please raise an issue on GitHub with the location that you were looking up! + Open 24/7 This plugin isn\'t working correctly You need to setup this plugin first Set up @@ -865,4 +898,7 @@ Official Set as weather provider Currently set as weather provider + + This value is possibly outdated because of connectivity issues. + This item does not exist anymore. \ No newline at end of file diff --git a/core/ktx/src/main/java/de/mm20/launcher2/ktx/Float.kt b/core/ktx/src/main/java/de/mm20/launcher2/ktx/Float.kt index 68b569de..37584a22 100644 --- a/core/ktx/src/main/java/de/mm20/launcher2/ktx/Float.kt +++ b/core/ktx/src/main/java/de/mm20/launcher2/ktx/Float.kt @@ -10,3 +10,7 @@ fun Float.ceilToInt(): Int { private const val TWO_PI_F = (2.0 * PI).toFloat() val Float.Companion.TWO_PI: Float get() = TWO_PI_F + +private const val PI_F = PI.toFloat() +val Float.Companion.PI: Float + get() = PI_F diff --git a/core/ktx/src/main/java/de/mm20/launcher2/ktx/Int.kt b/core/ktx/src/main/java/de/mm20/launcher2/ktx/Int.kt index c985623b..ad056ced 100644 --- a/core/ktx/src/main/java/de/mm20/launcher2/ktx/Int.kt +++ b/core/ktx/src/main/java/de/mm20/launcher2/ktx/Int.kt @@ -16,4 +16,4 @@ val Int.sat : Float ColorUtils.RGBToHSL(red, green, blue, it) return it[1] } - } \ No newline at end of file + } diff --git a/core/ktx/src/main/java/de/mm20/launcher2/ktx/Nullable.kt b/core/ktx/src/main/java/de/mm20/launcher2/ktx/Nullable.kt new file mode 100644 index 00000000..3bca4f23 --- /dev/null +++ b/core/ktx/src/main/java/de/mm20/launcher2/ktx/Nullable.kt @@ -0,0 +1,3 @@ +package de.mm20.launcher2.ktx + +inline fun T?.or(block: () -> T?): T? = this ?: block() diff --git a/core/ktx/src/main/java/de/mm20/launcher2/ktx/Result.kt b/core/ktx/src/main/java/de/mm20/launcher2/ktx/Result.kt new file mode 100644 index 00000000..a19ab9f5 --- /dev/null +++ b/core/ktx/src/main/java/de/mm20/launcher2/ktx/Result.kt @@ -0,0 +1,6 @@ +package de.mm20.launcher2.ktx + +inline fun Result.orRunCatching(block: () -> T): Result = this.fold( + onSuccess = { this }, + onFailure = { runCatching { block() } } +) diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt index fdb9d706..73ff5192 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt @@ -1,6 +1,7 @@ package de.mm20.launcher2.preferences import android.content.Context +import androidx.core.graphics.blue import de.mm20.launcher2.ktx.isAtLeastApiLevel import de.mm20.launcher2.preferences.LegacySettings.SearchBarSettings.SearchBarColors import de.mm20.launcher2.preferences.ktx.toSettingsColorsScheme diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt index f0088906..ae9b8e38 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt @@ -1,6 +1,7 @@ package de.mm20.launcher2.preferences import android.content.Context +import de.mm20.launcher2.preferences.search.LocationSearchSettings import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.util.UUID @@ -124,11 +125,23 @@ data class LauncherSettingsData( val weatherProviderSettings: Map = emptyMap(), val weatherImperialUnits: Boolean = false, + val locationSearchEnabled: Boolean = false, + val locationSearchImperialUnits: Boolean = false, + val locationSearchRadius: Int = 1500, + val locationSearchHideUncategorized: Boolean = true, + val locationSearchOverpassUrl: String = LocationSearchSettings.DefaultOverpassUrl, + val locationSearchTileServer: String = LocationSearchSettings.DefaultTileServerUrl, + val locationSearchShowMap: Boolean = false, + val locationSearchShowPositionOnMap: Boolean = false, + val locationSearchThemeMap: Boolean = true, + + ) { constructor( context: Context, ) : this( weatherImperialUnits = context.resources.getBoolean(R.bool.default_imperialUnits), + locationSearchImperialUnits = context.resources.getBoolean(R.bool.default_imperialUnits), gridColumnCount = context.resources.getInteger(R.integer.config_columnCount), ) } diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/Module.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/Module.kt index 87c386f7..9536daa9 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/Module.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/Module.kt @@ -7,6 +7,7 @@ import de.mm20.launcher2.preferences.search.CalculatorSearchSettings import de.mm20.launcher2.preferences.search.CalendarSearchSettings import de.mm20.launcher2.preferences.search.FavoritesSettings import de.mm20.launcher2.preferences.search.FileSearchSettings +import de.mm20.launcher2.preferences.search.LocationSearchSettings import de.mm20.launcher2.preferences.search.RankingSettings import de.mm20.launcher2.preferences.search.ShortcutSearchSettings import de.mm20.launcher2.preferences.search.UnitConverterSettings @@ -47,4 +48,5 @@ val preferencesModule = module { factory { GestureSettings(get()) } factory { CalculatorSearchSettings(get()) } factory { ClockWidgetSettings(get()) } + factory { LocationSearchSettings(get()) } } \ No newline at end of file diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/LocationSearchSettings.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/LocationSearchSettings.kt new file mode 100644 index 00000000..9c7f8eae --- /dev/null +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/LocationSearchSettings.kt @@ -0,0 +1,123 @@ +package de.mm20.launcher2.preferences.search + +import de.mm20.launcher2.preferences.LauncherDataStore +import kotlinx.coroutines.flow.map + +class LocationSearchSettings internal constructor( + private val launcherDataStore: LauncherDataStore, +) { + + val data + get() = launcherDataStore.data.map { + LocationSearchSettingsData( + enabled = it.locationSearchEnabled, + searchRadius = it.locationSearchRadius, + hideUncategorized = it.locationSearchHideUncategorized, + overpassUrl = it.locationSearchOverpassUrl, + tileServer = it.locationSearchTileServer, + imperialUnits = it.locationSearchImperialUnits, + showMap = it.locationSearchShowMap, + showPositionOnMap = it.locationSearchShowPositionOnMap, + themeMap = it.locationSearchThemeMap, + ) + } + + val enabled + get() = launcherDataStore.data.map { it.locationSearchEnabled } + + fun setEnabled(enabled: Boolean) { + launcherDataStore.update { + it.copy(locationSearchEnabled = enabled) + } + } + + val searchRadius + get() = launcherDataStore.data.map { it.locationSearchRadius } + + fun setSearchRadius(searchRadius: Int) { + launcherDataStore.update { + it.copy(locationSearchRadius = searchRadius) + } + } + + val hideUncategorized + get() = launcherDataStore.data.map { it.locationSearchHideUncategorized } + + fun setHideUncategorized(hideUncategorized: Boolean) { + launcherDataStore.update { + it.copy(locationSearchHideUncategorized = hideUncategorized) + } + } + + val overpassUrl + get() = launcherDataStore.data.map { it.locationSearchOverpassUrl } + + fun setOverpassUrl(overpassUrl: String) { + launcherDataStore.update { + it.copy(locationSearchOverpassUrl = overpassUrl) + } + } + + val tileServer + get() = launcherDataStore.data.map { it.locationSearchTileServer } + + fun setTileServer(tileServer: String) { + launcherDataStore.update { + it.copy(locationSearchTileServer = tileServer) + } + } + + val showMap + get() = launcherDataStore.data.map { it.locationSearchShowMap } + + fun setShowMap(showMap: Boolean) { + launcherDataStore.update { + it.copy(locationSearchShowMap = showMap) + } + } + + val showPositionOnMap + get() = launcherDataStore.data.map { it.locationSearchShowPositionOnMap } + + fun setShowPositionOnMap(showPositionOnMap: Boolean) { + launcherDataStore.update { + it.copy(locationSearchShowPositionOnMap = showPositionOnMap) + } + } + + val themeMap + get() = launcherDataStore.data.map { it.locationSearchThemeMap } + + fun setThemeMap(themeMap: Boolean) { + launcherDataStore.update { + it.copy(locationSearchThemeMap = themeMap) + } + } + + val imperialUnits + get() = launcherDataStore.data.map { it.locationSearchImperialUnits } + + fun setImperialUnits(imperialUnits: Boolean) { + launcherDataStore.update { + it.copy(locationSearchImperialUnits = imperialUnits) + } + } + + companion object { + const val DefaultTileServerUrl = "https://tile.openstreetmap.org" + const val DefaultOverpassUrl = "https://overpass-api.de/" + } + +} + +data class LocationSearchSettingsData( + val enabled: Boolean = false, + val searchRadius: Int = 1500, + val hideUncategorized: Boolean = true, + val overpassUrl: String = LocationSearchSettings.DefaultOverpassUrl, + val tileServer: String = LocationSearchSettings.DefaultTileServerUrl, + val imperialUnits: Boolean = false, + val showMap: Boolean = false, + val showPositionOnMap: Boolean = false, + val themeMap: Boolean = true, +) \ No newline at end of file diff --git a/data/database/src/main/java/de/mm20/launcher2/database/SearchableDao.kt b/data/database/src/main/java/de/mm20/launcher2/database/SearchableDao.kt index 26a33010..f2c80bda 100644 --- a/data/database/src/main/java/de/mm20/launcher2/database/SearchableDao.kt +++ b/data/database/src/main/java/de/mm20/launcher2/database/SearchableDao.kt @@ -1,5 +1,6 @@ package de.mm20.launcher2.database +import android.util.Log import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -8,13 +9,14 @@ import androidx.room.Transaction import androidx.room.Update import androidx.room.Upsert import de.mm20.launcher2.database.entities.SavedSearchableEntity +import de.mm20.launcher2.database.entities.SavedSearchableUpdateContentEntity import de.mm20.launcher2.database.entities.SavedSearchableUpdatePinEntity import kotlinx.coroutines.flow.Flow @Dao interface SearchableDao { @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(searchable: SavedSearchableEntity) + suspend fun insert(searchable: SavedSearchableEntity): Long @Upsert(entity = SavedSearchableEntity::class) suspend fun upsert(searchable: SavedSearchableEntity) @@ -25,6 +27,9 @@ interface SearchableDao { @Update(entity = SavedSearchableEntity::class) suspend fun update(searchable: SavedSearchableUpdatePinEntity) + @Update(entity = SavedSearchableEntity::class) + suspend fun update(searchable: SavedSearchableUpdateContentEntity) + @Query( "SELECT * FROM Searchable " + "WHERE (" + @@ -146,7 +151,15 @@ interface SearchableDao { incrementLaunchCount(item.key) increaseWeightWhere(item.key, alpha) reduceWeightExcept(item.key, alpha) - insert(item) + if (insert(item) == -1L) { + update( + SavedSearchableUpdateContentEntity( + serializedSearchable = item.serializedSearchable, + type = item.type, + key = item.key, + ) + ) + } } @Query("UPDATE Searchable SET launchCount = launchCount + 1 WHERE `key` = :key") diff --git a/data/database/src/main/java/de/mm20/launcher2/database/entities/SavedSearchableEntity.kt b/data/database/src/main/java/de/mm20/launcher2/database/entities/SavedSearchableEntity.kt index afe95a69..bf6f3b4a 100644 --- a/data/database/src/main/java/de/mm20/launcher2/database/entities/SavedSearchableEntity.kt +++ b/data/database/src/main/java/de/mm20/launcher2/database/entities/SavedSearchableEntity.kt @@ -20,4 +20,10 @@ data class SavedSearchableUpdatePinEntity( val type: String, @ColumnInfo(name = "searchable") val serializedSearchable: String, val pinPosition: Int? = null, +) + +data class SavedSearchableUpdateContentEntity( + val key: String, + val type: String, + @ColumnInfo(name = "searchable") val serializedSearchable: String, ) \ No newline at end of file diff --git a/data/openstreetmaps/.gitignore b/data/openstreetmaps/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/data/openstreetmaps/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/openstreetmaps/build.gradle.kts b/data/openstreetmaps/build.gradle.kts new file mode 100644 index 00000000..ed53c3cc --- /dev/null +++ b/data/openstreetmaps/build.gradle.kts @@ -0,0 +1,59 @@ +@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.plugin.serialization) +} + +android { + namespace = "de.mm20.launcher2.openstreetmaps" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation(libs.bundles.kotlin) + implementation(libs.androidx.core) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.browser) + + implementation(libs.bundles.androidx.lifecycle) + + implementation(libs.okhttp) + implementation(libs.bundles.retrofit) + + implementation(libs.koin.android) + + implementation(libs.androidx.appcompat) + + implementation(project(":core:preferences")) + implementation(project(":core:base")) + implementation(project(":core:ktx")) + implementation(project(":core:permissions")) + implementation(project(":core:crashreporter")) + implementation(project(":core:devicepose")) +} \ No newline at end of file diff --git a/data/openstreetmaps/consumer-rules.pro b/data/openstreetmaps/consumer-rules.pro new file mode 100644 index 00000000..b2638ebb --- /dev/null +++ b/data/openstreetmaps/consumer-rules.pro @@ -0,0 +1,2 @@ +-keep class de.mm20.launcher2.openstreetmaps.** { *; } +-keep class kotlin.coroutines.Continuation \ No newline at end of file diff --git a/data/openstreetmaps/proguard-rules.pro b/data/openstreetmaps/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/data/openstreetmaps/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/data/openstreetmaps/src/main/AndroidManifest.xml b/data/openstreetmaps/src/main/AndroidManifest.xml new file mode 100644 index 00000000..48d92bb3 --- /dev/null +++ b/data/openstreetmaps/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/Module.kt b/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/Module.kt new file mode 100644 index 00000000..5e6456d9 --- /dev/null +++ b/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/Module.kt @@ -0,0 +1,13 @@ +package de.mm20.launcher2.openstreetmaps + +import de.mm20.launcher2.search.Location +import de.mm20.launcher2.search.SearchableDeserializer +import de.mm20.launcher2.search.SearchableRepository +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val openStreetMapsModule = module { + single { OsmRepository(get(), get(), get()) } + factory>(named()) { get() } + factory(named(OsmLocation.DOMAIN)) { OsmLocationDeserializer(get()) } +} \ No newline at end of file diff --git a/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OsmLocation.kt b/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OsmLocation.kt new file mode 100644 index 00000000..dccef91e --- /dev/null +++ b/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OsmLocation.kt @@ -0,0 +1,297 @@ +package de.mm20.launcher2.openstreetmaps + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.net.Uri +import android.util.Log +import de.mm20.launcher2.ktx.orRunCatching +import de.mm20.launcher2.ktx.tryStartActivity +import de.mm20.launcher2.search.Location +import de.mm20.launcher2.search.LocationCategory +import de.mm20.launcher2.search.OpeningHours +import de.mm20.launcher2.search.OpeningSchedule +import de.mm20.launcher2.search.SearchableSerializer +import de.mm20.launcher2.search.UpdateResult +import de.mm20.launcher2.search.UpdatableSearchable +import kotlinx.collections.immutable.toImmutableList +import java.time.DayOfWeek +import java.time.Duration +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import java.time.format.ResolverStyle +import java.util.Locale + +internal data class OsmLocation( + internal val id: Long, + override val label: String, + override val category: LocationCategory?, + override val latitude: Double, + override val longitude: Double, + override val street: String?, + override val houseNumber: String?, + override val openingSchedule: OpeningSchedule?, + override val websiteUrl: String?, + override val phoneNumber: String?, + override val labelOverride: String? = null, + override val timestamp: Long, + override var updatedSelf: (suspend () -> UpdateResult)? = null, +) : Location, UpdatableSearchable { + + override val domain: String + get() = DOMAIN + override val key: String = "$domain://$id" + override val fixMeUrl: String + get() = FIXMEURL + + override fun overrideLabel(label: String): OsmLocation { + return this.copy(labelOverride = label) + } + + override fun launch(context: Context, options: Bundle?): Boolean { + return context.tryStartActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("geo:$latitude,$longitude?q=${Uri.encode(label)}") + ), + options + ) + } + + override fun getSerializer(): SearchableSerializer { + return OsmLocationSerializer() + } + + companion object { + + internal const val DOMAIN = "osm" + internal const val FIXMEURL = "https://www.openstreetmap.org/fixthemap" + + private val categoryTags = setOf( + "amenity", + "shop", + "sport", // "sport:soccer" + "railway", // "railway:stop" + "highway", // "highway:bus_stop" + "tourism", // "tourism:museum" + "leisure", // "leisure:fitness_center" + ) + + fun fromOverpassResponse( + result: OverpassResponse + ): List = result.elements.mapNotNull { + OsmLocation( + id = it.id, + label = it.tags["name"] ?: it.tags["brand"] ?: return@mapNotNull null, + category = it.tags.firstNotNullOfOrNull { (tag, value) -> + if (tag.lowercase() in categoryTags) { + value + .split(' ', ',', '.', ';') // in case there are multiple + .firstNotNullOfOrNull { value -> + runCatching { + LocationCategory.valueOf(value.uppercase(Locale.ROOT)) + }.orRunCatching { + LocationCategory.valueOf( + // e.g. "railway:stop" -> "RAILWAY_STOP" + "${tag}_${value}".uppercase( + Locale.ROOT + ) + ) + }.getOrNull() + } + } else null + } ?: LocationCategory.OTHER, + latitude = it.lat ?: it.center?.lat ?: return@mapNotNull null, + longitude = it.lon ?: it.center?.lon ?: return@mapNotNull null, + street = it.tags["addr:street"], + houseNumber = it.tags["addr:housenumber"], + openingSchedule = it.tags["opening_hours"]?.let { ot -> parseOpeningSchedule(ot) }, + websiteUrl = it.tags["website"] ?: it.tags["contact:website"], + phoneNumber = it.tags["phone"] ?: it.tags["contact:phone"], + timestamp = System.currentTimeMillis(), + ) + } + } +} + +// allow for 24:00 to be part of the same day +// https://stackoverflow.com/a/31113244 +private val DATE_TIME_FORMATTER = + DateTimeFormatter.ISO_LOCAL_TIME.withResolverStyle(ResolverStyle.SMART) + +private val timeRegex by lazy { + Regex( + """^(?:\d{2}:\d{2}-?){2}$""", + RegexOption.IGNORE_CASE + ) +} +private val singleDayRegex by lazy { + Regex( + """^[mtwfsp][ouehra]$""", + RegexOption.IGNORE_CASE + ) +} +private val dayRangeRegex by lazy { + Regex( + """^[mtwfsp][ouehra]-[mtwfsp][ouehra]$""", + RegexOption.IGNORE_CASE + ) +} + +private val daysOfWeek = enumValues().toList().toImmutableList() + +private val twentyFourSeven = daysOfWeek.map { + OpeningHours( + dayOfWeek = it, + startTime = LocalTime.MIDNIGHT, + duration = Duration.ofDays(1) + ) +}.toImmutableList() + +// If this is not sufficient, resort to implementing https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification +// or port https://github.com/opening-hours/opening_hours.js +internal fun parseOpeningSchedule(it: String?): OpeningSchedule? { + if (it.isNullOrBlank()) return null + + val openingHours = mutableListOf() + + // e.g. + // "Mo-Sa 11:00-14:00, 17:00-23:00; Su 11:00-23:00" + // "Mo-Sa 11:00-21:00; PH,Su off" + // "Mo-Th 10:00-24:00, Fr,Sa 10:00-05:00, PH,Su 12:00-22:00" + var blocks = + it.split(',', ';', ' ').mapNotNull { if (it.isBlank()) null else it.trim() } + + if (blocks.first() == "24/7") + return OpeningSchedule( + isTwentyFourSeven = true, + openingHours = twentyFourSeven + ) + + fun dayOfWeekFromString(it: String): DayOfWeek? = when (it.lowercase()) { + "mo" -> DayOfWeek.MONDAY + "tu" -> DayOfWeek.TUESDAY + "we" -> DayOfWeek.WEDNESDAY + "th" -> DayOfWeek.THURSDAY + "fr" -> DayOfWeek.FRIDAY + "sa" -> DayOfWeek.SATURDAY + "su" -> DayOfWeek.SUNDAY + else -> null + } + + var allDay = false + var everyDay = false + + fun parseGroup(group: List) { + if (group.isEmpty()) + return + + var times = group + .filter { timeRegex.matches(it) } + .mapNotNull { + try { + val startTime = + LocalTime.parse(it.substringBefore('-'), DATE_TIME_FORMATTER) + val endTime = + LocalTime.parse(it.substringAfter('-'), DATE_TIME_FORMATTER) + + var duration = Duration.between(startTime, endTime) + + if (duration.isNegative || duration.isZero) + duration += Duration.ofDays(1) + + startTime to duration + } catch (dtpe: DateTimeParseException) { + Log.e( + "OpeningTimeFromOverpassElement", + "Failed to parse opening time $it", + dtpe + ) + null + } + } + + var days = group + .filter { dayRangeRegex.matches(it) } + .flatMap { + val dowStart = dayOfWeekFromString(it.substringBefore('-')) + ?: return@flatMap emptyList() + val dowEnd = dayOfWeekFromString(it.substringAfter('-')) + ?: return@flatMap emptyList() + + if (dowStart.ordinal <= dowEnd.ordinal) + daysOfWeek.subList(dowStart.ordinal, dowEnd.ordinal + 1) + else // "We-Mo" + daysOfWeek.subList(dowStart.ordinal, daysOfWeek.size) + .union(daysOfWeek.subList(0, dowEnd.ordinal + 1)) + }.union( + group.filter { singleDayRegex.matches(it) } + .mapNotNull { dayOfWeekFromString(it) } + ) + + // if no time specified, treat as "all day" + if (times.isEmpty()) { + allDay = true + times = listOf(LocalTime.MIDNIGHT to Duration.ofDays(1)) + } + + // if no day specified, treat as "every day" + if (days.isEmpty()) { + everyDay = true + days = daysOfWeek.toSet() + } + + openingHours.addAll(days.flatMap { day -> + times.map { (start, duration) -> + OpeningHours( + dayOfWeek = day, + startTime = start, + duration = duration + ) + } + }) + } + + while (true) { + if (blocks.isEmpty()) + break + + // assuming that there are blocks that only contain time + // treating them as "every day of the week" + if (blocks.size < 2) { + parseGroup(blocks) + break + } + + val nextTimeIndex = + blocks.indexOfFirst { timeRegex.matches(it) } + + // no time left, so probably no sensible information + // willingly skips "off" and "closed" as they are not useful + if (nextTimeIndex == -1) + break + + // assuming next block to start with the first date coming after a time block + var nextGroupIndex = + blocks.subList(nextTimeIndex, blocks.size) + .indexOfFirst { !timeRegex.matches(it) } + + // no day left, so we are done + if (nextGroupIndex == -1) { + parseGroup(blocks) + break + } + + // convert index from sublist context + nextGroupIndex += nextTimeIndex + + parseGroup(blocks.subList(0, nextGroupIndex)) + blocks = blocks.subList(nextGroupIndex, blocks.size) + } + + return OpeningSchedule( + isTwentyFourSeven = allDay && everyDay, + openingHours.toImmutableList() + ) +} diff --git a/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OsmRepository.kt b/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OsmRepository.kt new file mode 100644 index 00000000..367614c9 --- /dev/null +++ b/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OsmRepository.kt @@ -0,0 +1,195 @@ +package de.mm20.launcher2.openstreetmaps + +import android.util.Log +import de.mm20.launcher2.crashreporter.CrashReporter +import de.mm20.launcher2.devicepose.DevicePoseProvider +import de.mm20.launcher2.permissions.PermissionGroup +import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.preferences.search.LocationSearchSettings +import de.mm20.launcher2.search.Location +import de.mm20.launcher2.search.LocationCategory +import de.mm20.launcher2.search.SearchableRepository +import de.mm20.launcher2.search.UpdateResult +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.net.UnknownHostException + +internal class OsmRepository( + private val settings: LocationSearchSettings, + private val poseProvider: DevicePoseProvider, + permissionsManager: PermissionsManager, +) : SearchableRepository { + + + private val scope = CoroutineScope(Job() + Dispatchers.Default) + + private val httpClient = OkHttpClient() + private val overpassService = settings.overpassUrl.map { + try { + Retrofit.Builder() + .client(httpClient) + .baseUrl(it.takeIf { it.isNotBlank() } ?: LocationSearchSettings.DefaultOverpassUrl) + .addConverterFactory(OverpassQueryConverterFactory()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(OverpassApi::class.java) + } catch (e: Exception) { + CrashReporter.logException(e) + null + } + }.stateIn(scope, SharingStarted.Eagerly, null) + + private val hasLocationPermission = permissionsManager.hasPermission(PermissionGroup.Location) + + internal suspend fun update( + id: Long + ): UpdateResult = overpassService.first()?.runCatching { + this.search( + OverpassIdQuery( + id = id + ) + ).let { + OsmLocation.fromOverpassResponse(it) + }.first().apply { + updatedSelf = { update(id) } + } + }?.fold( + onSuccess = { UpdateResult.Success(it) }, + onFailure = { + when (it) { + is CancellationException, is UnknownHostException -> { + // network + UpdateResult.TemporarilyUnavailable(it) + } + + is HttpException -> when (it.code()) { + in 400..499 -> UpdateResult.PermanentlyUnavailable(it) + else -> UpdateResult.TemporarilyUnavailable(it) + } + + is NoSuchElementException -> { + // empty response + UpdateResult.PermanentlyUnavailable(it) + } + + else -> { + if (it is Exception) { + CrashReporter.logException(it) + } + UpdateResult.TemporarilyUnavailable(it) + } + } + } + ) ?: let { + Log.e("OsmLocationDeserializer", "overpassService was not initialized") + UpdateResult.TemporarilyUnavailable() + } + + override fun search(query: String, allowNetwork: Boolean): Flow> = channelFlow { + send(persistentListOf()) + + if (!allowNetwork) return@channelFlow + + // values higher than 2 might block searches for "dm" + // (Drogerie Markt, a problem specific to germany, but probably also relevant for other countries) + if (query.length < 2) return@channelFlow + + hasLocationPermission.collectLatest { locationPermission -> + if (!locationPermission) return@collectLatest + + settings.data.collectLatest dataStore@{ settings -> + if (!settings.enabled) return@dataStore + + val userLocation = + poseProvider.getLocation().firstOrNull() ?: poseProvider.lastLocation + ?: return@dataStore + + withContext(Dispatchers.IO) { + httpClient.dispatcher.cancelAll() + } + + suspend fun searchByTag(tag: String): OverpassResponse? = + overpassService.first()?.runCatching { + this.search( + OverpassFuzzyRadiusQuery( + tag = tag, + query = query, + radius = settings.searchRadius, + latitude = userLocation.latitude, + longitude = userLocation.longitude, + ) + ) + }?.onFailure { + if (it !is HttpException && it !is CancellationException) { + Log.e("OsmRepository", "Failed to search for $tag: $query", it) + } + }?.getOrNull() + + val result = awaitAll( + // optionally query by "amenity" or "shop" here + // if we want to make searching for locations fuzzier + // however, this would not account for localized queries like "Bäcker" (shop:bakery) + async(this.coroutineContext) { searchByTag("name") }, + async(this.coroutineContext) { searchByTag("brand") }, + ).flatMap { + it?.let { + OsmLocation.fromOverpassResponse(it) + } ?: emptyList() + } + + if (result.isNotEmpty()) { + send( + result + .asSequence() + .filter { + !settings.hideUncategorized || (it.category != null && it.category != LocationCategory.OTHER) + } + .groupBy { + it.label.lowercase() + } + .flatMap { (_, duplicates) -> + // deduplicate results with same labels, if + // - same category + // - distance is less than 100m + if (duplicates.size < 2) duplicates + else { + val luckyFirst = duplicates.first() + duplicates + .drop(1) + .filter { + it.category != luckyFirst.category || + it.distanceTo(luckyFirst) > 100.0 + } + luckyFirst + } + } + .sortedBy { + it.distanceTo(userLocation) + } + .take(7) + .toImmutableList() + ) + } + } + } + } +} \ No newline at end of file diff --git a/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OsmSerialization.kt b/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OsmSerialization.kt new file mode 100644 index 00000000..d44fec33 --- /dev/null +++ b/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OsmSerialization.kt @@ -0,0 +1,107 @@ +package de.mm20.launcher2.openstreetmaps + +import de.mm20.launcher2.ktx.jsonObjectOf +import de.mm20.launcher2.search.LocationCategory +import de.mm20.launcher2.search.OpeningHours +import de.mm20.launcher2.search.OpeningSchedule +import de.mm20.launcher2.search.SavableSearchable +import de.mm20.launcher2.search.SearchableDeserializer +import de.mm20.launcher2.search.SearchableSerializer +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import org.json.JSONArray +import org.json.JSONObject +import java.time.DayOfWeek +import java.time.Duration +import java.time.LocalTime + +class OsmLocationSerializer : SearchableSerializer { + override fun serialize(searchable: SavableSearchable): String { + searchable as OsmLocation + return jsonObjectOf( + "id" to searchable.id, + "lat" to searchable.latitude, + "lon" to searchable.longitude, + "category" to searchable.category?.name, + "label" to searchable.label, + "street" to searchable.street, + "houseNumber" to searchable.houseNumber, + "websiteUrl" to searchable.websiteUrl, + "phoneNumber" to searchable.phoneNumber, + "openingSchedule" to searchable.openingSchedule?.let { + jsonObjectOf( + "isTwentyFourSeven" to it.isTwentyFourSeven, + "openingHours" to JSONArray(it.openingHours.map { + jsonObjectOf( + "day" to it.dayOfWeek.value, + "openingTime" to it.startTime.toSecondOfDay() * 1000L, + "duration" to it.duration.toMillis(), + ) + }) + ) + }, + "timestamp" to searchable.timestamp, + ).toString() + } + + override val typePrefix: String + get() = "osmlocation" +} + +internal class OsmLocationDeserializer( + private val osmRepository: OsmRepository, +) : SearchableDeserializer { + override suspend fun deserialize(serialized: String): SavableSearchable { + val json = JSONObject(serialized) + val id = json.getLong("id") + + return OsmLocation( + id = id, + latitude = json.getDouble("lat"), + longitude = json.getDouble("lon"), + category = json.getString("category").runCatching { LocationCategory.valueOf(this) } + .getOrNull(), + label = json.getString("label"), + street = json.optString("street").takeIf { it.isNotBlank() }, + houseNumber = json.optString("houseNumber").takeIf { it.isNotBlank() }, + openingSchedule = json.optJSONObject("openingSchedule")?.let { getOpeningSchedule(it) }, + websiteUrl = json.optString("websiteUrl").takeIf { it.isNotBlank() }, + phoneNumber = json.optString("phoneNumber").takeIf { it.isNotBlank() }, + timestamp = json.optLong("timestamp"), + updatedSelf = { osmRepository.update(id) } + ) + } + + private fun getOpeningSchedule(json: JSONObject): OpeningSchedule { + return OpeningSchedule( + isTwentyFourSeven = json.optBoolean("isTwentyFourSeven"), + openingHours = json.optJSONArray("openingHours")?.let { + getOpeningHours(it) + } ?: persistentListOf() + ) + } + + private fun getOpeningHours(array: JSONArray): ImmutableList { + val hours = mutableListOf() + + for (i in 0 until array.length()) { + val json = array.getJSONObject(i) + val dayOfWeek = + DayOfWeek.of(json.optInt("day").takeIf { it in 1..7 } ?: continue) + val openingTimeMillis = + json.optLong("openingTime", -1).takeIf { it >= 0 } ?: continue + val durationMillis = json.optLong("duration", -1).takeIf { it >= 0 } ?: continue + + hours.add( + OpeningHours( + dayOfWeek = dayOfWeek, + startTime = LocalTime.ofSecondOfDay(openingTimeMillis / 1000L), + duration = Duration.ofMillis(durationMillis) + ) + ) + } + + return hours.toPersistentList() + } +} \ No newline at end of file diff --git a/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OverpassApi.kt b/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OverpassApi.kt new file mode 100644 index 00000000..7745c667 --- /dev/null +++ b/data/openstreetmaps/src/main/java/de/mm20/launcher2/openstreetmaps/OverpassApi.kt @@ -0,0 +1,100 @@ +package de.mm20.launcher2.openstreetmaps + +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import retrofit2.Converter +import retrofit2.Retrofit +import retrofit2.http.Body +import retrofit2.http.POST +import java.lang.reflect.Type + +data class OverpassFuzzyRadiusQuery( + val tag: String = "name", + val query: String, + val radius: Int, + val latitude: Double, + val longitude: Double, + val caseInvariant: Boolean = true, +) + +data class OverpassIdQuery( + val id: Long, +) + +data class OverpassResponse( + val elements: List, +) + +data class OverpassResponseElementCenter( + val lat: Double, + val lon: Double, +) + +data class OverpassResponseElement( + val type: String, + val id: Long, + val lat: Double?, + val lon: Double?, + val center: OverpassResponseElementCenter?, + val tags: Map, +) + +interface OverpassApi { + @POST("api/interpreter") + suspend fun search(@Body data: OverpassFuzzyRadiusQuery): OverpassResponse + + @POST("api/interpreter") + suspend fun search(@Body data: OverpassIdQuery): OverpassResponse +} + +class OverpassFuzzyRadiusQueryConverter : Converter { + override fun convert(value: OverpassFuzzyRadiusQuery): RequestBody { + + // allow other characters in between query words, if there are multiple + // https://dev.overpass-api.de/overpass-doc/en/criteria/per_tag.html#regex + val escapedQueryName = value + .query + .split(' ') + .joinToString( + separator = ".*", + prefix = "\"", + postfix = "\"" + ) { Regex.escapeReplacement(it) } + + val overpassQlBuilder = StringBuilder() + overpassQlBuilder.append("[out:json];") + // nw: node or way + overpassQlBuilder.append("nw(around:", value.radius, ',', value.latitude, ',', value.longitude, ')') + overpassQlBuilder.append('[', value.tag, '~', escapedQueryName, if (value.caseInvariant) ",i];" else "];") + // center to add the center coordinate of a way to the result, if applicable + overpassQlBuilder.append("out center;") + + return overpassQlBuilder.toString().toRequestBody() + } +} + +class OverpassIdQueryConverter : Converter { + override fun convert(value: OverpassIdQuery): RequestBody = """ + [out:json]; + nw(${value.id}); + out center; + """.trimIndent().toRequestBody() +} + +class OverpassQueryConverterFactory : Converter.Factory() { + override fun requestBodyConverter( + type: Type, + parameterAnnotations: Array, + methodAnnotations: Array, + retrofit: Retrofit + ): Converter<*, RequestBody>? { + if (type == OverpassFuzzyRadiusQuery::class.java) + return OverpassFuzzyRadiusQueryConverter() + + if (type == OverpassIdQuery::class.java) + return OverpassIdQueryConverter() + + return null + } +} + diff --git a/data/weather/build.gradle.kts b/data/weather/build.gradle.kts index 7d34d554..f18e8e2a 100644 --- a/data/weather/build.gradle.kts +++ b/data/weather/build.gradle.kts @@ -51,5 +51,6 @@ dependencies { implementation(project(":core:preferences")) implementation(project(":core:permissions")) implementation(project(":core:i18n")) + implementation(project(":core:devicepose")) } \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt index 72c56159..54cff8e2 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt @@ -1,14 +1,11 @@ package de.mm20.launcher2.weather -import android.Manifest import android.content.Context -import android.location.Location -import android.location.LocationManager import android.util.Log -import androidx.core.content.getSystemService import androidx.work.* import de.mm20.launcher2.database.AppDatabase -import de.mm20.launcher2.ktx.checkPermission +import de.mm20.launcher2.devicepose.DevicePoseProvider +import de.mm20.launcher2.ktx.or import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.plugin.PluginRepository @@ -24,8 +21,9 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import java.time.Duration import java.util.* -import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.minutes interface WeatherRepository { fun getProviders(): Flow> @@ -41,21 +39,20 @@ internal class WeatherRepositoryImpl( private val context: Context, private val database: AppDatabase, private val settings: WeatherSettings, - private val pluginRepository: PluginRepository, + private val pluginRepository: PluginRepository ) : WeatherRepository, KoinComponent { private val scope = CoroutineScope(Job() + Dispatchers.Default) - private val permissionsManager: PermissionsManager by inject() private val hasLocationPermission = permissionsManager.hasPermission(PermissionGroup.Location) - override fun getForecasts(limit: Int?): Flow> { return database.weatherDao().getForecasts(limit ?: 99999) .map { it.map { Forecast(it) } } } + override fun getDailyForecasts(): Flow> { return database.weatherDao().getForecasts() .map { it.map { Forecast(it) } } @@ -73,7 +70,7 @@ internal class WeatherRepositoryImpl( init { val weatherRequest = - PeriodicWorkRequest.Builder(WeatherUpdateWorker::class.java, 60, TimeUnit.MINUTES) + PeriodicWorkRequestBuilder(Duration.ofMinutes(60)) .build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( "weather", @@ -134,7 +131,7 @@ internal class WeatherRepositoryImpl( private fun requestUpdate() { - val weatherRequest = OneTimeWorkRequest.Builder(WeatherUpdateWorker::class.java) + val weatherRequest = OneTimeWorkRequestBuilder() .addTag("weather") .build() WorkManager.getInstance(context).enqueue(weatherRequest) @@ -151,15 +148,35 @@ internal class WeatherRepositoryImpl( override fun getProviders(): Flow> { val providers = mutableListOf() - providers.add(WeatherProviderInfo(BrightSkyProvider.Id, context.getString(R.string.provider_brightsky))) + providers.add( + WeatherProviderInfo( + BrightSkyProvider.Id, + context.getString(R.string.provider_brightsky) + ) + ) if (OpenWeatherMapProvider.isAvailable(context)) { - providers.add(WeatherProviderInfo(OpenWeatherMapProvider.Id, context.getString(R.string.provider_openweathermap))) + providers.add( + WeatherProviderInfo( + OpenWeatherMapProvider.Id, + context.getString(R.string.provider_openweathermap) + ) + ) } if (MetNoProvider.isAvailable(context)) { - providers.add(WeatherProviderInfo(MetNoProvider.Id, context.getString(R.string.provider_metno))) + providers.add( + WeatherProviderInfo( + MetNoProvider.Id, + context.getString(R.string.provider_metno) + ) + ) } if (HereProvider.isAvailable(context)) { - providers.add(WeatherProviderInfo(HereProvider.Id, context.getString(R.string.provider_here))) + providers.add( + WeatherProviderInfo( + HereProvider.Id, + context.getString(R.string.provider_here) + ) + ) } val pluginProviders = pluginRepository.findMany(type = PluginType.Weather, enabled = true) return pluginProviders.map { @@ -170,11 +187,14 @@ internal class WeatherRepositoryImpl( } } -class WeatherUpdateWorker(val context: Context, params: WorkerParameters) : - CoroutineWorker(context, params), KoinComponent { +class WeatherUpdateWorker( + val context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params), KoinComponent { private val appDatabase: AppDatabase by inject() private val settings: WeatherSettings by inject() + private val locationProvider: DevicePoseProvider by inject() override suspend fun doWork(): Result { Log.d("WeatherUpdateWorker", "Requesting weather data") @@ -218,15 +238,9 @@ class WeatherUpdateWorker(val context: Context, params: WorkerParameters) : } } - private fun getLastKnownLocation(): LatLon? { - val lm = context.getSystemService()!! - var location: Location? = null - if (context.checkPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { - location = lm.getLastKnownLocation(LocationManager.GPS_PROVIDER) - } - if (location == null && context.checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) { - location = lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) - } - return location?.let { LatLon(it.latitude, it.longitude) } - } + @OptIn(FlowPreview::class) + private suspend fun getLastKnownLocation(): LatLon? = + locationProvider.getLocation().timeout(10.minutes).firstOrNull().or { + locationProvider.lastLocation + }?.let { LatLon(it.latitude, it.longitude) } } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 3d58091d..b5c2dbfa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,4 +21,5 @@ org.gradle.caching=true android.enableR8.fullMode=false android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false -android.nonFinalResIds=false \ No newline at end of file +android.nonFinalResIds=false +org.gradle.configuration-cache=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7fa5dd4d..8cb64202 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -147,4 +147,4 @@ protobuf = { id = "com.google.protobuf", version.ref = "protobuf-gradle-plugin" ksp = { id = "com.google.devtools.ksp", version.ref = "ksp-gradle-plugin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -dokka = { id = "org.jetbrains.dokka", version = "1.9.10" } \ No newline at end of file +dokka = { id = "org.jetbrains.dokka", version = "1.9.10" } diff --git a/services/favorites/src/main/java/de/mm20/launcher2/services/favorites/FavoritesService.kt b/services/favorites/src/main/java/de/mm20/launcher2/services/favorites/FavoritesService.kt index 81f451a9..526bfd89 100644 --- a/services/favorites/src/main/java/de/mm20/launcher2/services/favorites/FavoritesService.kt +++ b/services/favorites/src/main/java/de/mm20/launcher2/services/favorites/FavoritesService.kt @@ -5,10 +5,8 @@ import de.mm20.launcher2.searchable.SavableSearchableRepository import kotlinx.coroutines.flow.Flow class FavoritesService( - val searchableRepository: SavableSearchableRepository, + private val searchableRepository: SavableSearchableRepository, ) { - - fun getFavorites( includeTypes: List? = null, excludeTypes: List? = null, @@ -27,6 +25,7 @@ class FavoritesService( limit = limit, ) } + fun isPinned(searchable: SavableSearchable): Flow { return searchableRepository.isPinned(searchable) } @@ -88,4 +87,12 @@ class FavoritesService( automaticallySorted = automaticallySorted ) } + + fun delete(searchable: SavableSearchable) { + searchableRepository.delete(searchable) + } + + fun upsert(searchable: SavableSearchable) { + searchableRepository.upsert(searchable) + } } \ No newline at end of file diff --git a/services/search/src/main/java/de/mm20/launcher2/search/Module.kt b/services/search/src/main/java/de/mm20/launcher2/search/Module.kt index 790133c5..a560c889 100644 --- a/services/search/src/main/java/de/mm20/launcher2/search/Module.kt +++ b/services/search/src/main/java/de/mm20/launcher2/search/Module.kt @@ -12,6 +12,7 @@ val searchModule = module { get(named()), get(named()), get(named
()), + get(named()), get(), get(), get(named()), diff --git a/services/search/src/main/java/de/mm20/launcher2/search/SearchService.kt b/services/search/src/main/java/de/mm20/launcher2/search/SearchService.kt index 7c3592f0..6c92b2d8 100644 --- a/services/search/src/main/java/de/mm20/launcher2/search/SearchService.kt +++ b/services/search/src/main/java/de/mm20/launcher2/search/SearchService.kt @@ -34,6 +34,7 @@ internal class SearchServiceImpl( private val contactRepository: SearchableRepository, private val fileRepository: SearchableRepository, private val articleRepository: SearchableRepository
, + private val locationRepository: SearchableRepository, private val unitConverterRepository: UnitConverterRepository, private val calculatorRepository: CalculatorRepository, private val websiteRepository: SearchableRepository, @@ -127,6 +128,15 @@ internal class SearchServiceImpl( } } } + launch { + locationRepository.search(query, allowNetwork) + .withCustomLabels(customAttributesRepository) + .collectLatest { r -> + results.update { + it.copy(locations = r.toImmutableList()) + } + } + } launch { fileRepository.search( query, @@ -166,6 +176,7 @@ data class SearchResults( val unitConverters: ImmutableList? = null, val websites: ImmutableList? = null, val wikipedia: ImmutableList
? = null, + val locations: ImmutableList? = null, val searchActions: ImmutableList? = null, val other: ImmutableList? = null, ) diff --git a/settings.gradle.kts b/settings.gradle.kts index 8d014d0d..f9a180c0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -64,4 +64,6 @@ include(":services:widgets") include(":services:favorites") include(":plugins:sdk") +include(":data:openstreetmaps") include(":services:plugins") +include(":core:devicepose")