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")