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:
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+76
-9
@@ -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()
|
||||
}
|
||||
|
||||
+158
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user