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>
This commit is contained in:
Christoph
2024-03-26 19:06:19 +01:00
committed by GitHub
co-authored by MM20
parent 4b30b13266
commit cba387c832
133 changed files with 4123 additions and 79 deletions
+6
View File
@@ -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"))
}
+2
View File
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application>
@@ -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<Float> = tween()
): State<BiasAlignment.Horizontal> {
val bias by animateFloatAsState(
targetValue = when (targetAlignment) {
Alignment.Start -> -1f
Alignment.End -> 1f
else -> 0f
},
animationSpec = animationSpec
)
return remember { derivedStateOf { BiasAlignment.Horizontal(bias) } }
}
@@ -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())
}
}
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)
}
@@ -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
}
@@ -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 <T> Deferred<T>?.asState(initialValue: T): State<T> {
return produceState(initialValue) {
if (this@asState != null) {
value = await()
}
}
}
@@ -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<Float, AnimationVector2D>({
val rad = it * Float.PI / 180f
AnimationVector2D(sin(rad), cos(rad))
}, {
(atan2(it.v1, it.v2) * 180f / Float.PI + 360f) % 360f
})
@@ -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
}
}
@@ -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") {
@@ -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<List<Location>>(emptyList())
val appResults = mutableStateOf<List<Application>>(emptyList())
val workAppResults = mutableStateOf<List<Application>>(emptyList())
val appShortcutResults = mutableStateOf<List<AppShortcut>>(emptyList())
@@ -94,7 +100,7 @@ class SearchVM : ViewModel(), KoinComponent {
val bestMatch = mutableStateOf<Searchable?>(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<UnitConverter>()
val calc = mutableListOf<Calculator>()
val articles = mutableListOf<Article>()
val locations = mutableListOf<Location>()
val website = mutableListOf<Website>()
val actions = mutableListOf<SearchAction>()
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
@@ -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<SavableSearchable?>(null)
private val iconSize = MutableStateFlow<Int>(0)
val isUpToDate = MutableStateFlow(true)
val devicePoseProvider: DevicePoseProvider by inject()
val searchable = MutableStateFlow<SavableSearchable?>(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, "")
}
@@ -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
}
)
}
}
}
}
@@ -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 }
)
@@ -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<DayOfWeek>()
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<ToolbarAction>()
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<Boolean>,
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()
}
@@ -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<Double, Double> {
// 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<OpeningHours>().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()
}
@@ -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()
}
@@ -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)
}
)
}
}
}
}
@@ -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)
}
}
@@ -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),
@@ -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)
}