Add support for location plugins (#772)

* Cherry pick location refactor

* Refactor :data:openstreetmaps to :data:locations

* contract, plugin sdk

* Implement serialization, module tweaks

* Include check for out-of-date departures in SearchableItemVM.requestUpdatedSearchable()

* settings for location plugins

* Try not to be too lazy

* more fiddling with the plugin SDK

* add departures in MapView with mock data for debug builds

* change icons

* add boats

* animate departure lazycolumn

* Add MarqueeText for text overflow handling

* Define height for Departures in case there is no map to display

* Don't inclure railway / highway tags for OSM since there will be location plugins for that

* sort by time

* - Apply pre-merge changes to LocationSettings
- Add banner warning about slowed down location search for large search radii

* ditch `showLocationOnMap`

* LocationItem: make `showOpeningSchedule` toggleable

* LocationItem: make Navigation AssistChip work for people that don't have google maps installed

* LocationItem: resolve TODOs

* MapTiles: ditch unused code, animate userIndicator

* Reintroduce departure list

* Add LineColor

* Add osm tag `stars` as `userRating` https://taginfo.openstreetmap.org/keys/stars#overview

* typealias -> import

* Don't add Navigation Chip when there is no way to resolve navigation intents

* Add settings migration

* Set plugin SDK version to 1.2.0-SNAPSHOT

* Deduplicate shared plugin classes, use kotlinx.serialization

* Fix imports

* Use ZonedDateTime for depature times

* Add more line types

* Rewrite location serialization

* Replace street/houseNumber with address

* Add attribution field

* Add plugin config

* Reject location search requests without lat lon parameters

* Add default values to plugin location class

* Don't crash if column value is null

* Add docs comments to LocationCategory values

* Refactor OpeningSchedule as polymorphic

* remove dead corpse *ahem* code

* Split LocationCategory into category and icon

(Also update to Kotlin 2.0, please don't do this at home)

* Add more location icons

* Fix (?) location deserializer

* Add more location icons

* More icons

* Meh

* Add Pub

* Disable Github Maven repo if credentials are missing

* Add location search specific settings to plugin details screen

* Add language parameter

* Unbreak the build

* Refactor plugin SDK (with breaking changes)

* Set plugin SDK version to 2.0.0-SNAPSHOT

* Document SDK breaking changes

* Implement LocationProvider.getQuery

* Add a typesafe cursor API

* Oops I did it again

next time maybe check if the code is actually compiling before pushing

* Add missing return statement

* Fix list serialization

* Departure time UI adjustment

* Use typesafe cursor for weather plugins

* Add userRatingCount and emailAddress fields

* grrr

* Rename and extend LineTypes

* Add default lineType to Departure to fix serialization errors

* Fix refreshing stored plugin locations

* Adapt line name column width to available departures

* Fix plugin settings screen category overlap

* add LocationItem.GenericTransit

* Fix crash during deserialization of locations

* Update SDK docs

* Replace plugin "official" mark with "verified developer" mark

before anyone gets sued

* show 'now' when departure is in less than one minute

* Add typesafe Bundle API

* Implement plugin API changes

* Plugin SDK: Fix refresh result not being returned

* Update docs

* apply alpha to departures that have departured

* better (maybe): reduce saturation instead of alpha

* Add default values for Attribution

* Display attribution

* Rearrange location result layout

* Reduce searchable update interval to 1 minute

* Pass last update time to refresh function

* Change refresh path and ensure that timestamp is only update when the item was updated

* categorize osm location

* Update docs

* Optimize location search

- run providers in parallel
- flatten code

* add experimental address parsing for OSM

* add poi_category_townhall

* Fix popup closing when favorites items are updated

* Revert "Fix popup closing when favorites items are updated"

This reverts commit fc517fd066c7f8109b6d6df2d4f536af66398207.

* Fork AndroidAddressFormatter to `:libs:address-formatter`

* migrate `:libs:address-formatter` dependencies to version catalog and update them

* also consider addr:{suburb,hamlet} for `Address.city` if city tag is missing

* Move poi strings back to strings.xml

* Update Jetpack Compose

* Move address-formatter back to its original package, add license and readme

* Move address-formatter back to its original package

---------

Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com>
This commit is contained in:
Christoph 2024-06-14 11:57:03 +02:00 committed by GitHub
parent cfe80ff3e5
commit 65a9c8c1fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
145 changed files with 5804 additions and 2199 deletions

4
.gitignore vendored
View File

@ -305,4 +305,6 @@ fabric.properties
.idea/deploymentTargetDropDown.xml
.idea/deploymentTargetSelector.xml
.idea/copilot
.idea/other.xml
.idea/other.xml
.kotlin

View File

@ -2,6 +2,34 @@
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="AndroidLintNewerVersionAvailable" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />

2
.idea/kotlinc.xml generated
View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.24" />
<option name="version" value="2.0.0" />
</component>
</project>

View File

@ -57,7 +57,7 @@ android {
}
debug {
applicationIdSuffix = ".debug"
isDebuggable = false
isDebuggable = true
}
create("nightly") {
initWith(getByName("release"))
@ -166,7 +166,7 @@ dependencies {
implementation(project(":services:global-actions"))
implementation(project(":services:widgets"))
implementation(project(":services:favorites"))
implementation(project(":data:openstreetmaps"))
implementation(project(":data:locations"))
implementation(project(":services:plugins"))
implementation(project(":core:devicepose"))

View File

@ -26,7 +26,7 @@ import de.mm20.launcher2.database.databaseModule
import de.mm20.launcher2.debug.initDebugMode
import de.mm20.launcher2.globalactions.globalActionsModule
import de.mm20.launcher2.notifications.notificationsModule
import de.mm20.launcher2.openstreetmaps.openStreetMapsModule
import de.mm20.launcher2.locations.locationsModule
import de.mm20.launcher2.permissions.permissionsModule
import de.mm20.launcher2.data.plugins.dataPluginsModule
import de.mm20.launcher2.devicepose.devicePoseModule
@ -87,7 +87,7 @@ class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory {
websitesModule,
widgetsModule,
wikipediaModule,
openStreetMapsModule,
locationsModule,
servicesTagsModule,
widgetsServiceModule,
dataPluginsModule,

View File

@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.compose)
}
android {
@ -58,10 +59,6 @@ android {
viewBinding = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get()
}
lint {
abortOnError = false
}
@ -141,7 +138,7 @@ dependencies {
implementation(project(":core:crashreporter"))
implementation(project(":data:notifications"))
implementation(project(":data:contacts"))
implementation(project(":data:openstreetmaps"))
implementation(project(":data:locations"))
implementation(project(":core:permissions"))
implementation(project(":data:websites"))
implementation(project(":data:unitconverter"))

View File

@ -0,0 +1,101 @@
package de.mm20.launcher2.ui.component
import androidx.compose.foundation.MarqueeAnimationMode
import androidx.compose.foundation.MarqueeAnimationMode.Companion.Immediately
import androidx.compose.foundation.MarqueeDefaults
import androidx.compose.foundation.MarqueeSpacing
import androidx.compose.foundation.basicMarquee
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import de.mm20.launcher2.ui.ktx.conditional
import de.mm20.launcher2.ui.ktx.drawFadedEdge
@Composable
fun MarqueeText(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
style: TextStyle = LocalTextStyle.current,
iterations: Int = MarqueeDefaults.Iterations,
animationMode: MarqueeAnimationMode = Immediately,
repeatDelayMillis: Int = MarqueeDefaults.RepeatDelayMillis,
initialDelayMillis: Int = if (animationMode == Immediately) repeatDelayMillis else 0,
spacing: MarqueeSpacing = MarqueeDefaults.Spacing,
velocity: Dp = MarqueeDefaults.Velocity,
fadeLeft: Dp? = null,
fadeRight: Dp? = null,
) {
var textSize by remember { mutableIntStateOf(0) }
var textLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = text,
style = style,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
softWrap = false,
maxLines = 1,
onTextLayout = { textLayout = it },
modifier = modifier
.onGloballyPositioned { textSize = it.size.width }
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.conditional(
textLayout != null && textLayout!!.size.width > textSize,
Modifier
.drawWithContent {
drawContent()
if (fadeLeft != null) {
drawFadedEdge(leftEdge = true, fadeLeft)
}
if (fadeRight != null) {
drawFadedEdge(leftEdge = false, fadeRight)
}
}
)
.basicMarquee(
iterations = iterations,
initialDelayMillis = initialDelayMillis,
spacing = spacing,
repeatDelayMillis = repeatDelayMillis,
velocity = velocity
)
)
}

View File

@ -1,6 +1,8 @@
package de.mm20.launcher2.ui.component
import android.icu.text.DecimalFormat
import androidx.compose.foundation.layout.Row
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.StarHalf
@ -8,7 +10,9 @@ import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
@ -25,7 +29,8 @@ fun RatingBar(
rating: Float,
modifier: Modifier = Modifier,
tint: Color = MaterialTheme.colorScheme.primary,
starSize: Dp = 16.dp
starSize: Dp = 16.dp,
ratingCount: Int? = null,
) {
val starRating = round(rating * 10f).toInt()
val fullStars = starRating / 2
@ -34,6 +39,7 @@ fun RatingBar(
val iconModifier = Modifier.size(starSize)
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
for (i in 0 until fullStars) {
Icon(
@ -59,11 +65,18 @@ fun RatingBar(
modifier = iconModifier
)
}
Text(
modifier = Modifier.padding(start = 4.dp),
text = DecimalFormat("#.0").format(starRating / 2) +
if (ratingCount == null) "" else " ($ratingCount)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Preview
@Composable
fun RatingBarPreview() {
RatingBar(0.67f)
RatingBar(0.68f, ratingCount = 263)
}

View File

@ -8,14 +8,9 @@ import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.RectF
import android.graphics.drawable.AdaptiveIconDrawable
import android.util.Log
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -25,6 +20,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.GenericShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -67,17 +63,14 @@ import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.icons.ClockLayer
import de.mm20.launcher2.icons.ClockSublayer
import de.mm20.launcher2.icons.ClockSublayerRole
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.DynamicLauncherIcon
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.icons.LauncherIconLayer
import de.mm20.launcher2.icons.LauncherIconRenderSettings
import de.mm20.launcher2.icons.StaticIconLayer
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TextLayer
import de.mm20.launcher2.icons.TintedClockLayer
import de.mm20.launcher2.icons.TintedIconLayer
import de.mm20.launcher2.icons.TransparentLayer
import de.mm20.launcher2.icons.VectorLayer
import de.mm20.launcher2.ktx.drawWithColorFilter
import de.mm20.launcher2.preferences.IconShape
import de.mm20.launcher2.ui.base.LocalTime
@ -85,10 +78,8 @@ import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.locals.LocalDarkTheme
import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.modifier.scale
import kotlinx.coroutines.launch
import palettes.TonalPalette
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import kotlin.math.abs
import kotlin.math.pow
@ -220,6 +211,17 @@ fun ShapedLauncherIcon(
)
}
is VectorLayer -> {
Icon(
imageVector = fg.vector, contentDescription = null,
tint = if (fg.color == 0) {
Color(renderSettings.fgThemeColor)
} else {
Color(getTone(fg.color, renderSettings.fgTone))
},
)
}
else -> {}
}
} else {
@ -303,98 +305,6 @@ fun ShapedLauncherIcon(
}
}
@Composable
private fun IconLayer(
layer: LauncherIconLayer,
size: Dp,
colorTone: Int,
defaultTintColor: Color
) {
when (layer) {
is ClockLayer -> {
ClockLayer(
layer.sublayers,
scale = layer.scale,
defaultSecond = layer.defaultSecond,
defaultMinute = layer.defaultMinute,
defaultHour = layer.defaultHour,
tintColor = null
)
}
is TintedClockLayer -> {
ClockLayer(
layer.sublayers,
scale = layer.scale,
defaultSecond = layer.defaultSecond,
defaultMinute = layer.defaultMinute,
defaultHour = layer.defaultHour,
tintColor = if (layer.color == 0) defaultTintColor
else Color(getTone(layer.color, colorTone))
)
}
is ColorLayer -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(
if (layer.color == 0) {
defaultTintColor
} else {
Color(getTone(layer.color, colorTone))
}
)
)
}
is StaticIconLayer -> {
Canvas(modifier = Modifier.fillMaxSize()) {
withTransform({
this.scale(layer.scale)
}) {
drawIntoCanvas {
layer.icon.bounds = this.size.toRect().toAndroidRect()
layer.icon.draw(it.nativeCanvas)
}
}
}
}
is TextLayer -> {
Text(
text = layer.text,
style = MaterialTheme.typography.headlineSmall.copy(
fontSize = 20.sp * (size / 48.dp)
),
color = if (layer.color == 0) {
defaultTintColor
} else {
Color(getTone(layer.color, colorTone))
},
)
}
is TintedIconLayer -> {
val color =
if (layer.color == 0) defaultTintColor.toArgb()
else getTone(layer.color, colorTone)
Canvas(modifier = Modifier.fillMaxSize()) {
drawIntoCanvas {
layer.icon.bounds = this.size.toRect().toAndroidRect()
layer.icon.drawWithColorFilter(
it.nativeCanvas,
PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
)
}
}
}
is TransparentLayer -> {}
}
}
private fun getTone(argb: Int, tone: Int): Int {
return TonalPalette
.fromInt(argb)
@ -430,8 +340,13 @@ private fun ClockLayer(
sublayer.drawable.level = (((hour - defaultHour + 12) % 12) * 60
+ ((minute) % 60))
}
ClockSublayerRole.Minute -> sublayer.drawable.level = ((minute - defaultMinute + 60) % 60)
ClockSublayerRole.Second -> sublayer.drawable.level = (((second - defaultSecond + 60) % 60) * 10)
ClockSublayerRole.Minute -> sublayer.drawable.level =
((minute - defaultMinute + 60) % 60)
ClockSublayerRole.Second -> sublayer.drawable.level =
(((second - defaultSecond + 60) % 60) * 10)
else -> {}
}
drawIntoCanvas {

View File

@ -9,6 +9,7 @@ fun TextPreference(
title: String,
value: String,
summary: String? = value,
enabled: Boolean = true,
onValueChanged: (String) -> Unit,
placeholder: String? = null
) {
@ -16,6 +17,7 @@ fun TextPreference(
Preference(
title = title,
summary = summary,
enabled = enabled,
onClick = { showDialog = true }
)

View File

@ -23,8 +23,14 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.icons.WeatherCloud
import de.mm20.launcher2.icons.WeatherFog
import de.mm20.launcher2.icons.WeatherHailAnimatable
import de.mm20.launcher2.icons.WeatherLightRainAnimatable
import de.mm20.launcher2.icons.WeatherRainAnimatable
import de.mm20.launcher2.icons.WeatherSleetRainAnimatable
import de.mm20.launcher2.icons.WeatherSleetSnowAnimatable
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.icons.*
import kotlin.math.PI
import kotlin.math.sin

View File

@ -18,13 +18,13 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.ui.icons.WeatherCloud
import de.mm20.launcher2.ui.icons.WeatherFog
import de.mm20.launcher2.ui.icons.WeatherHail
import de.mm20.launcher2.ui.icons.WeatherLightRain
import de.mm20.launcher2.ui.icons.WeatherRain
import de.mm20.launcher2.ui.icons.WeatherSleetRain
import de.mm20.launcher2.ui.icons.WeatherSleetSnow
import de.mm20.launcher2.icons.WeatherCloud
import de.mm20.launcher2.icons.WeatherFog
import de.mm20.launcher2.icons.WeatherHail
import de.mm20.launcher2.icons.WeatherLightRain
import de.mm20.launcher2.icons.WeatherRain
import de.mm20.launcher2.icons.WeatherSleetRain
import de.mm20.launcher2.icons.WeatherSleetSnow
@Composable
fun WeatherIcon(

View File

@ -0,0 +1,24 @@
package de.mm20.launcher2.ui.ktx
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.unit.Dp
fun ContentDrawScope.drawFadedEdge(leftEdge: Boolean, edgeWidth: Dp) {
// https://stackoverflow.com/a/75112743
val edgeWidthPx = edgeWidth.toPx()
drawRect(
topLeft = Offset(if (leftEdge) 0f else size.width - edgeWidthPx, 0f),
size = Size(edgeWidthPx, size.height),
brush = Brush.horizontalGradient(
colors = listOf(Color.Transparent, Color.Black),
startX = if (leftEdge) 0f else size.width,
endX = if (leftEdge) edgeWidthPx else size.width - edgeWidthPx
),
blendMode = BlendMode.DstIn
)
}

View File

@ -17,7 +17,7 @@ suspend fun MutableState<Float>.animateTo(targetValue: Float) {
suspend inline fun <T, V: AnimationVector> MutableState<T>.animateTo(targetValue: T, converter: TwoWayConverter<T, V>) {
val animatable = Animatable(this.value, converter)
animatable.animateTo(targetValue) {
animatable.animateTo(targetValue) animatable@ {
this@animateTo.value = this.value
}
}

View File

@ -225,10 +225,11 @@ class SearchVM : ViewModel(), KoinComponent {
}
resultsList = resultsList.sortedWith { a, b ->
val lastLocation = devicePoseProvider.lastLocation
when {
a is Location && b is Location && devicePoseProvider.lastLocation != null -> {
a.distanceTo(devicePoseProvider.lastLocation!!)
.compareTo(b.distanceTo(devicePoseProvider.lastLocation!!))
a is Location && b is Location && lastLocation != null -> {
a.distanceTo(lastLocation)
.compareTo(b.distanceTo(lastLocation))
}
a is SavableSearchable && b !is SavableSearchable -> -1
@ -250,7 +251,6 @@ class SearchVM : ViewModel(), KoinComponent {
}
}
hiddenItemKeys.collectLatest { hiddenKeys ->
val hidden = mutableListOf<SavableSearchable>()
val apps = mutableListOf<Application>()
@ -349,7 +349,7 @@ class SearchVM : ViewModel(), KoinComponent {
val missingLocationPermission = combine(
permissionsManager.hasPermission(PermissionGroup.Location),
locationSearchSettings.enabled.distinctUntilChanged()
locationSearchSettings.osmLocations.distinctUntilChanged()
) { perm, enabled -> !perm && enabled }
fun requestLocationPermission(context: AppCompatActivity) {
@ -357,7 +357,7 @@ class SearchVM : ViewModel(), KoinComponent {
}
fun disableLocationSearch() {
locationSearchSettings.setEnabled(false)
locationSearchSettings.setOsmLocations(false)
}
val missingFilesPermission = combine(

View File

@ -6,6 +6,7 @@ import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.ui.geometry.Rect
import androidx.core.app.ActivityOptionsCompat
import androidx.customview.view.AbsSavedState
import de.mm20.launcher2.appshortcuts.AppShortcutRepository
import de.mm20.launcher2.badges.BadgeService
import de.mm20.launcher2.devicepose.DevicePoseProvider
@ -19,6 +20,7 @@ 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.Location
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.UpdatableSearchable
import de.mm20.launcher2.search.UpdateResult
@ -40,6 +42,7 @@ import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
@OptIn(ExperimentalCoroutinesApi::class)
class SearchableItemVM : ListItemViewModel(), KoinComponent {
@ -184,21 +187,24 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
val searchable = searchable.value ?: return
if (searchable is UpdatableSearchable<*>) {
val updatedSelf = searchable.updatedSelf ?: return
if (!shouldRetryUpdate && System.currentTimeMillis() < searchable.timestamp + 1.hours.inWholeMilliseconds) return
val sinceTimestamp = System.currentTimeMillis() - searchable.timestamp
val isOutOfDate = 1.minutes.inWholeMilliseconds < sinceTimestamp
if (!shouldRetryUpdate && !isOutOfDate) return
viewModelScope.launch {
this@SearchableItemVM.searchable.value = with(updatedSelf()) {
with(updatedSelf(searchable)) {
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 -> {
@ -211,7 +217,6 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
Toast.LENGTH_LONG
).show()
Log.d("requestUpdatedSearchable", "PermanentlyUnavailable", this.cause)
null
}
}
}
@ -232,9 +237,6 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
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, "")
}

View File

@ -23,7 +23,6 @@ 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
@ -90,8 +89,6 @@ 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) }

View File

@ -69,8 +69,6 @@ fun ListItem(
)
)
val item = viewModel.searchable.collectAsState().value ?: item
var bounds by remember { mutableStateOf(Rect.Zero) }
Box(
modifier = modifier

View File

@ -1,10 +1,7 @@
package de.mm20.launcher2.ui.launcher.search.contacts
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.expandIn
@ -25,7 +22,6 @@ import androidx.compose.material.icons.rounded.Email
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.MoreHoriz
import androidx.compose.material.icons.rounded.OpenInNew
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material.icons.rounded.Visibility
@ -63,9 +59,8 @@ 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.icons.Signal
import de.mm20.launcher2.ui.icons.Telegram
import de.mm20.launcher2.ui.icons.WhatsApp
import de.mm20.launcher2.icons.Signal
import de.mm20.launcher2.icons.Telegram
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import de.mm20.launcher2.ui.launcher.search.listItemViewModel

View File

@ -10,17 +10,6 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AppShortcut
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.Description
import androidx.compose.material.icons.rounded.Handyman
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
@ -32,12 +21,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.preferences.KeyboardFilterBarItem
import de.mm20.launcher2.search.SearchFilters
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.icons.Wikipedia
@Composable
fun KeyboardFilterBar(

View File

@ -15,7 +15,7 @@ import androidx.compose.material.icons.rounded.VisibilityOff
import de.mm20.launcher2.preferences.KeyboardFilterBarItem
import de.mm20.launcher2.search.SearchFilters
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.icons.Wikipedia
import de.mm20.launcher2.icons.Wikipedia
val KeyboardFilterBarItem.icon
get() = when (this) {

View File

@ -28,7 +28,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.search.SearchFilters
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.icons.Wikipedia
import de.mm20.launcher2.icons.Wikipedia
@Composable
fun SearchFilters(

View File

@ -14,29 +14,49 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkOut
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut
import androidx.compose.foundation.MarqueeSpacing
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.horizontalScroll
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.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.NavigateNext
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
import androidx.compose.material.icons.rounded.AirplanemodeActive
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.Commute
import androidx.compose.material.icons.rounded.DirectionsBoat
import androidx.compose.material.icons.rounded.DirectionsBus
import androidx.compose.material.icons.rounded.DirectionsRailway
import androidx.compose.material.icons.rounded.DirectionsTransit
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Navigation
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.Subway
import androidx.compose.material.icons.rounded.Train
import androidx.compose.material.icons.rounded.Tram
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.AssistChip
@ -58,31 +78,48 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.roundToIntRect
import androidx.compose.ui.unit.times
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import blend.Blend.harmonize
import coil.compose.AsyncImage
import de.mm20.launcher2.i18n.R
import de.mm20.launcher2.icons.CableCar
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.isOpen
import de.mm20.launcher2.search.location.Attribution
import de.mm20.launcher2.search.location.Departure
import de.mm20.launcher2.search.location.LineType
import de.mm20.launcher2.search.location.OpeningHours
import de.mm20.launcher2.search.location.OpeningSchedule
import de.mm20.launcher2.ui.base.LocalTime
import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.MarqueeText
import de.mm20.launcher2.ui.component.RatingBar
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.blendIntoViewScale
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.launcher.sheets.LocalBottomSheetManager
@ -92,7 +129,9 @@ import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
import de.mm20.launcher2.ui.modifier.scale
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle
@ -108,7 +147,6 @@ fun LocationItem(
) {
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()
@ -181,7 +219,8 @@ fun LocationItem(
if (category != null || formattedDistance != null) {
Text(
when {
category != null && formattedDistance != null -> "${stringResource(category.labelRes)}${formattedDistance}"
category != null && formattedDistance != null -> "$category$formattedDistance"
category != null -> category.toString()
formattedDistance != null -> formattedDistance
else -> ""
@ -258,7 +297,7 @@ fun LocationItem(
end = 12.dp,
bottom = 4.dp
),
verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.Top,
) {
Column(
modifier = Modifier.weight(1f),
@ -279,7 +318,8 @@ fun LocationItem(
if (category != null || formattedDistance != null) {
Text(
when {
category != null && formattedDistance != null -> "${stringResource(category.labelRes)}${formattedDistance}"
category != null && formattedDistance != null -> "$category$formattedDistance"
category != null -> category.toString()
formattedDistance != null -> formattedDistance
else -> ""
@ -294,31 +334,176 @@ fun LocationItem(
)
)
}
// TODO: add rating to location
if (!showMap && false) {
RatingBar(0.66f, modifier = Modifier.padding(top = 4.dp))
if (location.userRating != null) {
RatingBar(
location.userRating!!,
modifier = Modifier
.padding(top = 6.dp)
.offset(-2.dp)
)
}
if (!showMap) {
val attribution = location.attribution
if (attribution != null) {
Attribution(
attribution,
reverse = true,
modifier = Modifier
.padding(
top = 16.dp,
bottom = 0.dp,
)
.clickable(
enabled = attribution.url != null
) {
context.tryStartActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(attribution.url)
)
)
}
)
}
}
}
//TODO: add rating to location
if (showMap && false) {
RatingBar(0.66f)
}
if (!showMap) {
Compass(
targetHeading = targetHeading,
modifier = Modifier
.align(Alignment.Top)
.sharedBounds(
rememberSharedContentState("compass"),
this@AnimatedContent
),
size = 56.dp,
)
} else {
val attribution = location.attribution
if (attribution != null) {
Attribution(
attribution,
modifier = Modifier
.padding(
top = 4.dp,
bottom = 4.dp,
start = 12.dp)
.clickable(
enabled = attribution.url != null
) {
context.tryStartActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(attribution.url)
)
)
}
)
}
}
}
val openingSchedule = location.openingSchedule
if (openingSchedule != null && (openingSchedule.isTwentyFourSeven || openingSchedule.openingHours.isNotEmpty())) {
val departures = remember(location.departures) {
location.departures
?.sortedBy { it.time }
}
if (departures != null) {
val time = LocalTime.current
val nextDeparture = remember(time) {
departures.firstOrNull {
it.time.plus(it.delay ?: Duration.ZERO).isAfter(ZonedDateTime.now())
}
}
if (nextDeparture != null) {
var showDepartureList by remember(departures) {
mutableStateOf(false)
}
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 12.dp, top = 12.dp),
shape = MaterialTheme.shapes.small,
onClick = { showDepartureList = !showDepartureList }
) {
val listState = rememberLazyListState()
AnimatedContent(showDepartureList) { showList ->
if (!showList) {
Row(
Modifier
.padding(12.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
nextDeparture.LineIcon(Modifier.padding(end = 8.dp))
val lastStop = nextDeparture.lastStop
if (lastStop != null) {
MarqueeText(
modifier = Modifier.weight(1f),
text = lastStop,
style = MaterialTheme.typography.labelMedium,
iterations = Int.MAX_VALUE,
repeatDelayMillis = 0,
velocity = 20.dp,
fadeLeft = 5.dp,
fadeRight = 5.dp,
)
}
val formattedTime = remember(time) {
val timeLeft = Duration.between(
java.time.LocalTime.now(),
nextDeparture.time + (nextDeparture.delay
?: Duration.ZERO)
).toMinutes()
if (timeLeft < 1) "now" else "in $timeLeft min"
}
Text(
text = formattedTime,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(end = 12.dp)
)
Icon(Icons.AutoMirrored.Rounded.NavigateNext, null)
}
} else {
val longestLine = remember(departures) {
departures.maxOfOrNull { it.line.length }
}
LazyColumn(
state = listState,
modifier = modifier
.heightIn(max = 192.dp)
.padding(12.dp)
.fillMaxWidth()
.pointerInput(Unit) { detectDragGestures { _, _ -> } },
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
itemsIndexed(
departures,
key = { idx, _ -> idx }) { idx, it ->
it.LazyColumnPart(
lineWidth = longestLine,
Modifier
.fillMaxWidth()
.graphicsLayer {
alpha =
listState.layoutInfo.blendIntoViewScale(
idx
)
}
)
}
}
}
}
}
}
}
if (openingSchedule is OpeningSchedule.TwentyFourSeven || (openingSchedule is OpeningSchedule.Hours && openingSchedule.openingHours.isNotEmpty())) {
var showOpeningSchedule by remember(openingSchedule) {
mutableStateOf(false)
}
@ -328,8 +513,8 @@ fun LocationItem(
.padding(start = 12.dp, end = 12.dp, top = 12.dp),
shape = MaterialTheme.shapes.small,
onClick = {
if (!openingSchedule.isTwentyFourSeven) {
showOpeningSchedule = true
if (openingSchedule !is OpeningSchedule.TwentyFourSeven) {
showOpeningSchedule = !showOpeningSchedule
}
}
) {
@ -341,78 +526,84 @@ fun LocationItem(
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (openingSchedule.isTwentyFourSeven) {
Text(
text = stringResource(R.string.location_open_24_7),
style = MaterialTheme.typography.labelMedium,
)
} else {
val text = remember(openingSchedule) {
val currentOpeningTime =
openingSchedule.getCurrentOpeningHours()
val timeFormat =
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
return@remember if (currentOpeningTime != null) {
val isSameDay =
currentOpeningTime.dayOfWeek == LocalDateTime.now().dayOfWeek
val formattedTime =
timeFormat.format(currentOpeningTime.startTime + currentOpeningTime.duration)
val closingTime = if (isSameDay) {
context.getString(
R.string.location_closes,
formattedTime
)
} else {
val dow =
currentOpeningTime.dayOfWeek.getDisplayName(
TextStyle.SHORT,
Locale.getDefault()
)
context.getString(
R.string.location_closes_other_day,
dow,
formattedTime
)
}
"${context.getString(R.string.location_open)}$closingTime"
} else {
val nextOpeningTime =
openingSchedule.getNextOpeningHours()
val isSameDay =
nextOpeningTime.dayOfWeek == LocalDateTime.now().dayOfWeek
val formattedTime =
timeFormat.format(nextOpeningTime.startTime)
val openingTime = if (isSameDay) {
context.getString(
R.string.location_opens,
formattedTime
)
} else {
val dow =
nextOpeningTime.dayOfWeek.getDisplayName(
TextStyle.SHORT,
Locale.getDefault()
)
context.getString(
R.string.location_opens_other_day,
dow,
formattedTime
)
}
"${context.getString(R.string.location_closed)}$openingTime"
}
when (openingSchedule) {
is OpeningSchedule.TwentyFourSeven -> {
Text(
text = stringResource(R.string.location_open_24_7),
style = MaterialTheme.typography.labelMedium,
)
}
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.weight(1f)
)
is OpeningSchedule.Hours -> {
val text = remember(openingSchedule) {
val currentOpeningTime =
openingSchedule.getCurrentOpeningHours()
val timeFormat =
DateTimeFormatter.ofLocalizedTime(
FormatStyle.SHORT
)
return@remember if (currentOpeningTime != null) {
val isSameDay =
currentOpeningTime.dayOfWeek == LocalDateTime.now().dayOfWeek
val formattedTime =
timeFormat.format(currentOpeningTime.startTime + currentOpeningTime.duration)
val closingTime = if (isSameDay) {
context.getString(
R.string.location_closes,
formattedTime
)
} else {
val dow =
currentOpeningTime.dayOfWeek.getDisplayName(
TextStyle.SHORT,
Locale.getDefault()
)
context.getString(
R.string.location_closes_other_day,
dow,
formattedTime
)
}
"${context.getString(R.string.location_open)}$closingTime"
} else {
val nextOpeningTime =
openingSchedule.getNextOpeningHours()
val isSameDay =
nextOpeningTime.dayOfWeek == LocalDateTime.now().dayOfWeek
val formattedTime =
timeFormat.format(nextOpeningTime.startTime)
val openingTime = if (isSameDay) {
context.getString(
R.string.location_opens,
formattedTime
)
} else {
val dow =
nextOpeningTime.dayOfWeek.getDisplayName(
TextStyle.SHORT,
Locale.getDefault()
)
context.getString(
R.string.location_opens_other_day,
dow,
formattedTime
)
}
"${context.getString(R.string.location_closed)}$openingTime"
}
}
Icon(Icons.AutoMirrored.Rounded.NavigateNext, null)
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.weight(1f)
)
Icon(Icons.AutoMirrored.Rounded.NavigateNext, null)
}
}
}
} else {
} else if (openingSchedule is OpeningSchedule.Hours) {
Column(
modifier = Modifier.padding(vertical = 6.dp)
) {
@ -469,24 +660,26 @@ fun LocationItem(
.horizontalScroll(rememberScrollState())
.padding(start = 12.dp, top = 8.dp)
) {
AssistChip(
modifier = Modifier.padding(end = 12.dp),
onClick = {
context.tryStartActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse("google.navigation:q=${location.latitude},${location.longitude}")
),
)
},
label = { Text(stringResource(R.string.menu_navigation)) },
leadingIcon = {
Icon(
Icons.Rounded.Navigation, null,
modifier = Modifier.size(AssistChipDefaults.IconSize)
)
}
val navigationIntent = Intent(
Intent.ACTION_VIEW,
Uri.parse("google.navigation:q=${location.latitude},${location.longitude}")
)
val canResolveNavigationIntent = remember {
null != context.packageManager.resolveActivity(navigationIntent, 0)
}
if (canResolveNavigationIntent) {
AssistChip(
modifier = Modifier.padding(end = 12.dp),
onClick = { context.tryStartActivity(navigationIntent) },
label = { Text(stringResource(R.string.menu_navigation)) },
leadingIcon = {
Icon(
Icons.Rounded.Navigation, null,
modifier = Modifier.size(AssistChipDefaults.IconSize)
)
}
)
}
location.phoneNumber?.let {
AssistChip(
modifier = Modifier.padding(end = 12.dp),
@ -569,8 +762,6 @@ fun LocationItem(
action = { sheetManager.showCustomizeSearchableModal(location) }
))
location.fixMeUrl?.let {
toolbarActions += DefaultToolbarAction(
label = stringResource(id = R.string.menu_bugreport),
@ -715,11 +906,11 @@ private fun buildAddress(
return if (summary.isEmpty()) null else summary.toString()
}
private fun OpeningSchedule.getCurrentOpeningHours(): OpeningHours? {
private fun OpeningSchedule.Hours.getCurrentOpeningHours(): OpeningHours? {
return openingHours.find { it.isOpen() }
}
private fun OpeningSchedule.getNextOpeningHours(): OpeningHours {
private fun OpeningSchedule.Hours.getNextOpeningHours(): OpeningHours {
val now = LocalDateTime.now()
val sortedSchedule = this
.openingHours
@ -737,80 +928,178 @@ private fun OpeningSchedule.getNextOpeningHours(): OpeningHours {
} ?: sortedSchedule.first()
}
private val LocationCategory.labelRes
get() = when(this) {
LocationCategory.ART -> R.string.poi_category_art
LocationCategory.BANK -> R.string.poi_category_bank
LocationCategory.BAR -> R.string.poi_category_bar
LocationCategory.BEAUTY -> R.string.poi_category_beauty
LocationCategory.BICYCLE -> R.string.poi_category_bicycle
LocationCategory.RESTAURANT -> R.string.poi_category_restaurant
LocationCategory.FAST_FOOD -> R.string.poi_category_fast_food
LocationCategory.CAFE -> R.string.poi_category_coffee_shop
LocationCategory.HOTEL -> R.string.poi_category_hotel
LocationCategory.SUPERMARKET -> R.string.poi_category_supermarket
LocationCategory.OTHER -> R.string.poi_category_other
LocationCategory.SCHOOL -> R.string.poi_category_school
LocationCategory.PARKING -> R.string.poi_category_parking
LocationCategory.FUEL -> R.string.poi_category_fuel
LocationCategory.TOILETS -> R.string.poi_category_toilets
LocationCategory.PHARMACY -> R.string.poi_category_pharmacy
LocationCategory.HOSPITAL -> R.string.poi_category_hospital
LocationCategory.POST_OFFICE -> R.string.poi_category_post_office
LocationCategory.PUB -> R.string.poi_category_pub
LocationCategory.GRAVE_YARD -> R.string.poi_category_grave_yard
LocationCategory.DOCTORS -> R.string.poi_category_doctors
LocationCategory.POLICE -> R.string.poi_category_police
LocationCategory.DENTIST -> R.string.poi_category_dentist
LocationCategory.LIBRARY -> R.string.poi_category_library
LocationCategory.COLLEGE -> R.string.poi_category_college
LocationCategory.ICE_CREAM -> R.string.poi_category_ice_cream
LocationCategory.THEATRE -> R.string.poi_category_theater
LocationCategory.PUBLIC_BUILDING -> R.string.poi_category_public_building
LocationCategory.CINEMA -> R.string.poi_category_cinema
LocationCategory.NIGHTCLUB -> R.string.poi_category_nightclub
LocationCategory.BIERGARTEN -> R.string.poi_category_biergarten
LocationCategory.CLINIC -> R.string.poi_category_clinic
LocationCategory.UNIVERSITY -> R.string.poi_category_university
LocationCategory.DEPARTMENT_STORE -> R.string.poi_category_department_store
LocationCategory.CLOTHES -> R.string.poi_category_clothes
LocationCategory.CONVENIENCE -> R.string.poi_category_convenience
LocationCategory.HAIRDRESSER -> R.string.poi_category_hairdresser
LocationCategory.CAR_REPAIR -> R.string.poi_category_car_repair
LocationCategory.BOOKS -> R.string.poi_category_books
LocationCategory.BAKERY -> R.string.poi_category_bakery
LocationCategory.CAR -> R.string.poi_category_car
LocationCategory.MOBILE_PHONE -> R.string.poi_category_mobile_phone
LocationCategory.FURNITURE -> R.string.poi_category_furniture
LocationCategory.ALCOHOL -> R.string.poi_category_alcohol
LocationCategory.FLORIST -> R.string.poi_category_florist
LocationCategory.HARDWARE -> R.string.poi_category_hardware
LocationCategory.ELECTRONICS -> R.string.poi_category_electronics
LocationCategory.SHOES -> R.string.poi_category_shoes
LocationCategory.MALL -> R.string.poi_category_mall
LocationCategory.OPTICIAN -> R.string.poi_category_optician
LocationCategory.JEWELRY -> R.string.poi_category_jewelry
LocationCategory.GIFT -> R.string.poi_category_gift
LocationCategory.LAUNDRY -> R.string.poi_category_laundry
LocationCategory.COMPUTER -> R.string.poi_category_computer
LocationCategory.TOBACCO -> R.string.poi_category_tobacco
LocationCategory.WINE -> R.string.poi_category_wine
LocationCategory.PHOTO -> R.string.poi_category_photo
LocationCategory.COFFEE_SHOP -> R.string.poi_category_coffee_shop
LocationCategory.SOCCER -> R.string.poi_category_soccer
LocationCategory.BASKETBALL -> R.string.poi_category_basketball
LocationCategory.TENNIS -> R.string.poi_category_tennis
LocationCategory.FITNESS -> R.string.poi_category_fitness
LocationCategory.TRAM_STOP -> R.string.poi_category_tram_stop
LocationCategory.RAILWAY_STATION -> R.string.poi_category_railway_station
LocationCategory.RAILWAY_STOP -> R.string.poi_category_railway_stop
LocationCategory.BUS_STATION -> R.string.poi_category_bus_station
LocationCategory.ATM -> R.string.poi_category_atm
LocationCategory.KIOSK -> R.string.poi_category_kiosk
LocationCategory.BUS_STOP -> R.string.poi_category_bus_stop
LocationCategory.MUSEUM -> R.string.poi_category_museum
LocationCategory.PARCEL_LOCKER -> R.string.poi_category_parcel_locker
LocationCategory.CHEMIST -> R.string.poi_category_chemist
LocationCategory.TRAVEL_AGENCY -> R.string.poi_category_travel_agency
LocationCategory.FITNESS_CENTRE -> R.string.poi_category_fitness_center
}
@Composable
fun Departure.LineIcon(
modifier: Modifier
) {
val harmonizeArgb = MaterialTheme.colorScheme.primary.toArgb()
var (lineBg, lineFg) = if (lineColor != null) {
val bg = Color(
harmonize(lineColor!!.toArgb(), harmonizeArgb)
)
val fg = Color(
harmonize(
if (0.5f < bg.luminance()) Color.Black.toArgb() else Color.White.toArgb(),
harmonizeArgb
)
)
bg to fg
} else {
MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
}
val hasDeparted = ZonedDateTime.now().isAfter(time + (delay ?: Duration.ZERO))
if (hasDeparted) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(lineBg.toArgb(), hsv)
val (h, s, v) = hsv
lineBg = Color.hsv(h, s / 2f, v, lineBg.alpha)
}
Row(
modifier = modifier
.wrapContentWidth(Alignment.Start)
.background(
lineBg,
MaterialTheme.shapes.small
)
.padding(top = 4.dp, bottom = 4.dp, start = 4.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = when (type) {
LineType.Bus -> Icons.Rounded.DirectionsBus
LineType.Tram -> Icons.Rounded.Tram
LineType.Subway -> Icons.Rounded.Subway
LineType.Monorail -> Icons.Rounded.DirectionsTransit
LineType.CommuterTrain -> Icons.Rounded.DirectionsRailway
LineType.Train, LineType.RegionalTrain, LineType.HighSpeedTrain -> Icons.Rounded.Train
LineType.Boat -> Icons.Rounded.DirectionsBoat
LineType.CableCar -> Icons.Rounded.CableCar
LineType.Airplane -> Icons.Rounded.AirplanemodeActive
null -> Icons.Rounded.Commute
},
contentDescription = type?.name, // TODO localize (maybe) with ?.let{ stringResource("departure_line_type_$it") }
tint = lineFg,
modifier = Modifier
.padding(end = 2.dp)
.size(16.dp),
)
MarqueeText(
text = line,
style = MaterialTheme.typography.labelSmall,
color = lineFg,
textAlign = TextAlign.Center,
fadeLeft = 2.5.dp,
fadeRight = 2.5.dp,
iterations = Int.MAX_VALUE,
repeatDelayMillis = 0,
spacing = MarqueeSpacing(10.dp),
velocity = 20.dp,
modifier = Modifier
.wrapContentSize()
.widthIn(max = 34.dp)
)
}
}
@Composable
fun Departure.LazyColumnPart(
lineWidth: Int?,
modifier: Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(0.8f)
) {
LineIcon(
Modifier
.padding(end = 8.dp)
.widthIn(
min = if (lineWidth == null) 0.dp
else max(64.dp, lineWidth * 8.dp)
)
)
if (lastStop != null) {
MarqueeText(
text = lastStop!!,
style = MaterialTheme.typography.labelMedium,
iterations = Int.MAX_VALUE,
repeatDelayMillis = 0,
velocity = 20.dp,
fadeLeft = 5.dp,
fadeRight = 5.dp,
)
}
}
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = time.format(
DateTimeFormatter.ofPattern(
"HH:mm",
Locale.getDefault()
)
),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(end = 2.dp)
)
val delayMinutes = delay?.toMinutes()
if (null != delayMinutes && 0L < delayMinutes) {
Text(
text = "+$delayMinutes",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
fontSize = TextUnit(2f, TextUnitType.Em),
)
}
}
}
}
@Composable
private fun Attribution(
attribution: Attribution,
modifier: Modifier = Modifier,
reverse: Boolean = false,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
if (attribution.text != null && !reverse) {
Text(
text = attribution.text!!,
style = MaterialTheme.typography.labelSmall,
)
}
if (attribution.iconUrl != null) {
AsyncImage(
modifier = Modifier
.padding(
start = if (reverse) 0.dp else 8.dp,
end = if (reverse) 8.dp else 0.dp
)
.requiredHeight(16.dp),
model = attribution.iconUrl!!,
contentDescription = null,
)
}
if (attribution.text != null && reverse) {
Text(
text = attribution.text!!,
style = MaterialTheme.typography.labelSmall,
)
}
}
}

View File

@ -8,6 +8,7 @@ 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.animateValueAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
@ -47,7 +48,6 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
@ -62,23 +62,26 @@ 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.search.location.Address
import de.mm20.launcher2.search.location.Departure
import de.mm20.launcher2.search.location.LineType
import de.mm20.launcher2.search.location.LocationIcon
import de.mm20.launcher2.search.location.OpeningSchedule
import de.mm20.launcher2.ui.ktx.DegreesConverter
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 java.time.Duration
import java.time.ZonedDateTime
import kotlin.math.PI
import kotlin.math.ceil
import kotlin.math.cos
@ -216,7 +219,15 @@ fun MapTiles(
)
)
if (userLocation != null) {
val userTileCoordinates = getTileCoordinates(userLocation.lat, userLocation.lon, zoom)
val userIndicatorOffset by animateOffsetAsState(
targetValue = getTileCoordinates(userLocation.lat, userLocation.lon, zoom).let {
Offset(
(it.x - start.x) * tileSize.value - 8f,
(it.y - start.y) * tileSize.value - 8f
)
},
animationSpec = tween()
)
if (userLocation.heading != null) {
val headingAnim by animateValueAsState(
@ -232,8 +243,8 @@ fun MapTiles(
.align(Alignment.TopStart)
.size(16.dp)
.absoluteOffset(
x = (userTileCoordinates.x - start.x) * tileSize - 8.dp,
y = (userTileCoordinates.y - start.y) * tileSize - 8.dp,
userIndicatorOffset.x.dp,
userIndicatorOffset.y.dp
)
.rotate(headingAnim)
.absoluteOffset(y = -8.dp)
@ -245,8 +256,8 @@ fun MapTiles(
.align(Alignment.TopStart)
.size(16.dp)
.absoluteOffset(
x = (userTileCoordinates.x - start.x) * tileSize - 8.dp,
y = (userTileCoordinates.y - start.y) * tileSize - 8.dp,
userIndicatorOffset.x.dp,
userIndicatorOffset.y.dp
)
.background(MaterialTheme.colorScheme.tertiary, CircleShape)
.border(2.dp, MaterialTheme.colorScheme.onTertiary, CircleShape)
@ -438,7 +449,7 @@ private fun MapTilesPreview() {
)
}
internal object MockLocation : Location {
private object MockLocation : Location {
override val domain: String = "MOCKLOCATION"
override val key: String = "MOCKLOCATION"
@ -448,19 +459,25 @@ internal object MockLocation : Location {
override val latitude = 52.5162700
override val longitude = 13.3777021
override var category: LocationCategory? = LocationCategory.OTHER
override val icon: LocationIcon? = null
override var category: String? = "Landmark"
override val street: String = "Pariser Platz"
override val houseNumber: String = "1"
override val address: Address = Address(
address = "Pariser Platz 1",
city = "Berlin",
postalCode = "10117",
country = "Germany"
)
override val openingSchedule: OpeningSchedule =
OpeningSchedule(true, emptyList<OpeningHours>().toImmutableList())
OpeningSchedule.TwentyFourSeven
override val websiteUrl: String = "https://en.wikipedia.org/wiki/Brandenburg_Gate"
override val phoneNumber: String = "+49 1234567"
override val emailAddress: String = "abc@de.fg"
override fun overrideLabel(label: String): SavableSearchable = TODO()
override fun launch(context: Context, options: Bundle?): Boolean =
@ -472,4 +489,20 @@ internal object MockLocation : Location {
)
override fun getSerializer(): SearchableSerializer = TODO()
override val departures: List<Departure> = listOf(
Departure(
ZonedDateTime.now() + Duration.ofMinutes(3),
Duration.ofMinutes(1),
"B2",
"heaven",
LineType.Bus,
android.graphics.Color.valueOf(0xFAFAFAFA)
)
)
override val userRating: Float
get() = 0.9f
override val userRatingCount: Int = 553
}

View File

@ -26,8 +26,21 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import de.mm20.launcher2.icons.Battery0Bar
import de.mm20.launcher2.icons.Battery1Bar
import de.mm20.launcher2.icons.Battery2Bar
import de.mm20.launcher2.icons.Battery3Bar
import de.mm20.launcher2.icons.Battery4Bar
import de.mm20.launcher2.icons.Battery5Bar
import de.mm20.launcher2.icons.Battery6Bar
import de.mm20.launcher2.icons.BatteryCharging0Bar
import de.mm20.launcher2.icons.BatteryCharging1Bar
import de.mm20.launcher2.icons.BatteryCharging2Bar
import de.mm20.launcher2.icons.BatteryCharging3Bar
import de.mm20.launcher2.icons.BatteryCharging4Bar
import de.mm20.launcher2.icons.BatteryCharging5Bar
import de.mm20.launcher2.icons.BatteryCharging6Bar
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.icons.*
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.*

View File

@ -40,14 +40,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
@ -59,8 +55,8 @@ import de.mm20.launcher2.ui.common.WeatherLocationSearchDialog
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.weather.AnimatedWeatherIcon
import de.mm20.launcher2.ui.component.weather.WeatherIcon
import de.mm20.launcher2.ui.icons.HumidityPercentage
import de.mm20.launcher2.ui.icons.Rain
import de.mm20.launcher2.icons.HumidityPercentage
import de.mm20.launcher2.icons.Rain
import de.mm20.launcher2.ui.ktx.blendIntoViewScale
import de.mm20.launcher2.ui.locals.LocalCardStyle
import de.mm20.launcher2.ui.modifier.consumeAllScrolling

View File

@ -14,9 +14,9 @@ import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.icons.Fdroid
import de.mm20.launcher2.ui.icons.GitHub
import de.mm20.launcher2.ui.icons.Telegram
import de.mm20.launcher2.icons.Fdroid
import de.mm20.launcher2.icons.GitHub
import de.mm20.launcher2.icons.Telegram
import de.mm20.launcher2.ui.locals.LocalNavController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

View File

@ -191,7 +191,7 @@ fun FileSearchSettingsScreen() {
if (state is PluginState.SetupRequired) {
Banner(
modifier = Modifier.padding(16.dp),
text = state.message ?: "You need to setup this plugin first",
text = state.message ?: stringResource(id = R.string.plugin_state_setup_required),
icon = Icons.Rounded.ErrorOutline,
primaryAction = {
TextButton(onClick = {
@ -201,7 +201,7 @@ fun FileSearchSettingsScreen() {
CrashReporter.logException(e)
}
}) {
Text("Set up")
Text(stringResource(id = R.string.plugin_action_setup))
}
}
)

View File

@ -1,9 +1,14 @@
package de.mm20.launcher2.ui.settings.locations
import android.app.PendingIntent
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.WarningAmber
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -11,9 +16,14 @@ 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.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.preferences.search.LocationSearchSettings
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.Banner
import de.mm20.launcher2.ui.component.preferences.ListPreference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
@ -26,39 +36,86 @@ import de.mm20.launcher2.ui.ktx.metersToLocalizedString
fun LocationsSettingsScreen() {
val viewModel: LocationsSettingsScreenVM = viewModel()
val locations by viewModel.locations.collectAsState()
val osmLocations by viewModel.osmLocations.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()
val plugins by viewModel.availablePlugins.collectAsStateWithLifecycle(
initialValue = emptyList(),
minActiveState = Lifecycle.State.RESUMED
)
val enabledPlugins by viewModel.enabledPlugins.collectAsStateWithLifecycle(initialValue = null)
val anyLocationProviderEnabled = osmLocations ?: false || enabledPlugins.isNullOrEmpty().not()
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,
title = stringResource(R.string.preference_search_osm_locations),
summary = stringResource(R.string.preference_search_osm_locations_summary),
value = osmLocations == true,
onValueChanged = {
viewModel.setLocations(it)
viewModel.setOsmLocations(it)
}
)
for (plugin in plugins) {
val state = plugin.state
if (state is PluginState.SetupRequired) {
Banner(
modifier = Modifier.padding(16.dp),
text = state.message
?: stringResource(id = R.string.plugin_state_setup_required),
icon = Icons.Rounded.ErrorOutline,
primaryAction = {
TextButton(onClick = {
try {
state.setupActivity.send()
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
}
}) {
Text(stringResource(id = R.string.plugin_action_setup))
}
}
)
}
SwitchPreference(
title = plugin.plugin.label,
enabled = enabledPlugins != null && state is PluginState.Ready,
summary = (state as? PluginState.Ready)?.text
?: (state as? PluginState.SetupRequired)?.message
?: plugin.plugin.description,
value = enabledPlugins?.contains(plugin.plugin.authority) == true && state is PluginState.Ready,
onValueChanged = {
viewModel.setPluginEnabled(plugin.plugin.authority, it)
},
)
}
}
}
item {
PreferenceCategory {
if (5000 < radius) {
Banner(
modifier = Modifier.padding(16.dp),
icon = Icons.Rounded.WarningAmber,
text = stringResource(R.string.preference_search_locations_radius_large_radius_warning)
)
}
ListPreference(
title = stringResource(R.string.length_unit),
items = listOf(
stringResource(R.string.imperial) to true,
stringResource(R.string.metric) to false
),
enabled = locations == true,
enabled = anyLocationProviderEnabled,
value = imperialUnits,
onValueChanged = {
viewModel.setImperialUnits(it)
@ -70,7 +127,7 @@ fun LocationsSettingsScreen() {
min = 500,
max = 10000,
step = 500,
enabled = locations == true,
enabled = anyLocationProviderEnabled,
onValueChanged = {
viewModel.setRadius(it)
},
@ -91,7 +148,7 @@ fun LocationsSettingsScreen() {
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,
enabled = anyLocationProviderEnabled,
onValueChanged = {
viewModel.setHideUncategorized(it)
}
@ -103,7 +160,7 @@ fun LocationsSettingsScreen() {
SwitchPreference(
title = stringResource(R.string.preference_search_locations_show_map),
summary = stringResource(R.string.preference_search_locations_show_map_summary),
enabled = locations == true,
enabled = anyLocationProviderEnabled,
value = showMap == true,
onValueChanged = {
viewModel.setShowMap(it)
@ -113,7 +170,7 @@ fun LocationsSettingsScreen() {
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,
enabled = anyLocationProviderEnabled && showMap == true,
onValueChanged = {
viewModel.setThemeMap(it)
}
@ -127,11 +184,12 @@ fun LocationsSettingsScreen() {
title = stringResource(R.string.preference_search_location_custom_overpass_url),
value = customOverpassUrl,
placeholder = LocationSearchSettings.DefaultOverpassUrl,
summary = customOverpassUrl.takeIf { !it.isNullOrBlank() }
summary = customOverpassUrl.takeIf { it.isNotBlank() }
?: LocationSearchSettings.DefaultOverpassUrl,
onValueChanged = {
viewModel.setCustomOverpassUrl(it)
}
},
enabled = osmLocations == true,
)
TextPreference(
title = stringResource(R.string.preference_search_location_custom_tile_server_url),
@ -141,7 +199,8 @@ fun LocationsSettingsScreen() {
?: LocationSearchSettings.DefaultTileServerUrl,
onValueChanged = {
viewModel.setCustomTileServerUrl(it)
}
},
enabled = anyLocationProviderEnabled
)
}
}

View File

@ -2,40 +2,52 @@ package de.mm20.launcher2.ui.settings.locations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.plugins.PluginService
import de.mm20.launcher2.preferences.search.LocationSearchSettings
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
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 {
class LocationsSettingsScreenVM : ViewModel(), KoinComponent {
private val settings: LocationSearchSettings by inject()
private val pluginService: PluginService by inject()
val locations = settings.enabled
val availablePlugins = pluginService.getPluginsWithState(
type = PluginType.LocationSearch,
enabled = true,
)
val enabledPlugins = settings.enabledPlugins
val osmLocations = settings.osmLocations
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun setLocations(openStreetMaps: Boolean) {
settings.setEnabled(openStreetMaps)
fun setOsmLocations(osmLocations: Boolean) {
settings.setOsmLocations(osmLocations)
}
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('/')){
if (url.endsWith('/')) {
url = url.substringBeforeLast('/')
}
if (url.endsWith("/api/interpreter")) {
@ -47,31 +59,33 @@ class LocationsSettingsScreenVM: ViewModel(), KoinComponent {
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)
}
fun setPluginEnabled(authority: String, enabled: Boolean) {
settings.setPluginEnabled(authority, enabled)
}
}

View File

@ -25,6 +25,7 @@ import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Error
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.LightMode
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Verified
import androidx.compose.material3.Icon
@ -81,6 +82,11 @@ fun PluginSettingsScreen(pluginId: String) {
minActiveState = Lifecycle.State.RESUMED
)
val locationPlugins by viewModel.locationPlugins.collectAsStateWithLifecycle(
emptyList(),
minActiveState = Lifecycle.State.RESUMED
)
val weatherPlugins by viewModel.weatherPlugins.collectAsStateWithLifecycle(
emptyList(),
minActiveState = Lifecycle.State.RESUMED
@ -97,6 +103,10 @@ fun PluginSettingsScreen(pluginId: String) {
null
)
val enabledLocationSearchPlugins by viewModel.enabledLocationSearchPlugins.collectAsStateWithLifecycle(
null
)
val weatherProviderId by viewModel.weatherProvider.collectAsStateWithLifecycle(
null
)
@ -176,30 +186,7 @@ fun PluginSettingsScreen(pluginId: String) {
pluginPackage?.label ?: "",
style = MaterialTheme.typography.titleLarge
)
if (pluginPackage?.isOfficial == true) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(top = 4.dp)
.background(
MaterialTheme.colorScheme.secondary,
shape = MaterialTheme.shapes.medium,
)
.padding(4.dp)
) {
Text(
stringResource(R.string.plugin_badge_official),
modifier = Modifier.padding(horizontal = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondary,
)
Icon(
Icons.Rounded.Verified, null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondary,
)
}
} else if (pluginPackage?.author != null) {
if (pluginPackage?.author != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@ -210,6 +197,15 @@ fun PluginSettingsScreen(pluginId: String) {
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
)
if (pluginPackage?.isVerified == true) {
Icon(
Icons.Rounded.Verified, null,
modifier = Modifier
.padding(start = 4.dp)
.size(16.dp),
tint = MaterialTheme.colorScheme.secondary,
)
}
}
}
}
@ -246,6 +242,7 @@ fun PluginSettingsScreen(pluginId: String) {
when (type) {
PluginType.FileSearch -> Icons.AutoMirrored.Rounded.InsertDriveFile
PluginType.Weather -> Icons.Rounded.LightMode
PluginType.LocationSearch -> Icons.Rounded.Place
},
null,
modifier = Modifier.size(16.dp),
@ -255,6 +252,7 @@ fun PluginSettingsScreen(pluginId: String) {
when (type) {
PluginType.FileSearch -> stringResource(R.string.plugin_type_filesearch)
PluginType.Weather -> stringResource(R.string.plugin_type_weather)
PluginType.LocationSearch -> stringResource(R.string.plugin_type_locationsearch)
},
modifier = Modifier.padding(horizontal = 4.dp),
style = MaterialTheme.typography.labelMedium,
@ -298,109 +296,164 @@ fun PluginSettingsScreen(pluginId: String) {
)
}
AnimatedVisibility(pluginPackage?.enabled == true && hasPermission == true) {
if (filePlugins.isNotEmpty()) {
PreferenceCategory(
stringResource(R.string.plugin_type_filesearch),
iconPadding = false,
) {
for (plugin in filePlugins) {
val state = plugin.state
if (state is PluginState.SetupRequired) {
Banner(
modifier = Modifier.padding(16.dp),
text = state.message ?: stringResource(R.string.plugin_state_setup_required),
icon = Icons.Rounded.Info,
primaryAction = {
TextButton(onClick = {
try {
state.setupActivity.sendWithBackgroundPermission()
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
Column {
if (filePlugins.isNotEmpty()) {
PreferenceCategory(
stringResource(R.string.plugin_type_filesearch),
iconPadding = false,
) {
for (plugin in filePlugins) {
val state = plugin.state
if (state is PluginState.SetupRequired) {
Banner(
modifier = Modifier.padding(16.dp),
text = state.message
?: stringResource(R.string.plugin_state_setup_required),
icon = Icons.Rounded.Info,
primaryAction = {
TextButton(onClick = {
try {
state.setupActivity.sendWithBackgroundPermission(context)
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
}
}) {
Text(stringResource(R.string.plugin_action_setup))
}
}) {
Text(stringResource(R.string.plugin_action_setup))
}
}
)
} else if (state is PluginState.Error) {
Banner(
modifier = Modifier.padding(16.dp),
text = stringResource(R.string.plugin_state_error),
icon = Icons.Rounded.Error,
color = MaterialTheme.colorScheme.errorContainer,
)
} else if (state is PluginState.Error) {
Banner(
modifier = Modifier.padding(16.dp),
text = stringResource(R.string.plugin_state_error),
icon = Icons.Rounded.Error,
color = MaterialTheme.colorScheme.errorContainer,
)
}
SwitchPreference(
title = plugin.plugin.label,
enabled = enabledFileSearchPlugins != null && state is PluginState.Ready,
summary = (state as? PluginState.Ready)?.text
?: (state as? PluginState.SetupRequired)?.message
?: plugin.plugin.description,
value = enabledFileSearchPlugins?.contains(plugin.plugin.authority) == true && state is PluginState.Ready,
onValueChanged = {
viewModel.setFileSearchPluginEnabled(
plugin.plugin.authority,
it
)
},
iconPadding = false,
)
}
SwitchPreference(
title = plugin.plugin.label,
enabled = enabledFileSearchPlugins != null && state is PluginState.Ready,
summary = (state as? PluginState.Ready)?.text
?: (state as? PluginState.SetupRequired)?.message
?: plugin.plugin.description,
value = enabledFileSearchPlugins?.contains(plugin.plugin.authority) == true && state is PluginState.Ready,
onValueChanged = {
viewModel.setFileSearchPluginEnabled(
plugin.plugin.authority,
it
)
},
iconPadding = false,
)
}
}
}
if (weatherPlugins.isNotEmpty()) {
PreferenceCategory(
stringResource(R.string.plugin_type_weather),
iconPadding = false,
) {
for (plugin in weatherPlugins) {
val state = plugin.state
if (state is PluginState.SetupRequired) {
Banner(
modifier = Modifier.padding(16.dp),
text = state.message ?: stringResource(R.string.plugin_state_setup_required),
icon = Icons.Rounded.Info,
primaryAction = {
TextButton(onClick = {
try {
state.setupActivity.sendWithBackgroundPermission()
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
if (locationPlugins.isNotEmpty()) {
PreferenceCategory(
stringResource(R.string.plugin_type_locationsearch),
iconPadding = false,
) {
for (plugin in locationPlugins) {
val state = plugin.state
if (state is PluginState.SetupRequired) {
Banner(
modifier = Modifier.padding(16.dp),
text = state.message
?: stringResource(R.string.plugin_state_setup_required),
icon = Icons.Rounded.Info,
primaryAction = {
TextButton(onClick = {
try {
state.setupActivity.sendWithBackgroundPermission(context)
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
}
}) {
Text(stringResource(R.string.plugin_action_setup))
}
}) {
Text(stringResource(R.string.plugin_action_setup))
}
}
)
} else if (state is PluginState.Error) {
Banner(
modifier = Modifier.padding(16.dp),
text = stringResource(R.string.plugin_state_error),
icon = Icons.Rounded.Error,
color = MaterialTheme.colorScheme.errorContainer,
)
}
SwitchPreference(
title = plugin.plugin.label,
enabled = enabledLocationSearchPlugins != null && state is PluginState.Ready,
summary = (state as? PluginState.Ready)?.text
?: (state as? PluginState.SetupRequired)?.message
?: plugin.plugin.description,
value = enabledLocationSearchPlugins?.contains(plugin.plugin.authority) == true && state is PluginState.Ready,
onValueChanged = {
viewModel.setLocationSearchPluginEnabled(
plugin.plugin.authority,
it
)
},
iconPadding = false,
)
} else if (state is PluginState.Error) {
Banner(
modifier = Modifier.padding(16.dp),
text = stringResource(R.string.plugin_state_error),
icon = Icons.Rounded.Error,
color = MaterialTheme.colorScheme.errorContainer,
}
}
}
if (weatherPlugins.isNotEmpty()) {
PreferenceCategory(
stringResource(R.string.plugin_type_weather),
iconPadding = false,
) {
for (plugin in weatherPlugins) {
val state = plugin.state
if (state is PluginState.SetupRequired) {
Banner(
modifier = Modifier.padding(16.dp),
text = state.message
?: stringResource(R.string.plugin_state_setup_required),
icon = Icons.Rounded.Info,
primaryAction = {
TextButton(onClick = {
try {
state.setupActivity.sendWithBackgroundPermission(context)
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
}
}) {
Text(stringResource(R.string.plugin_action_setup))
}
}
)
} else if (state is PluginState.Error) {
Banner(
modifier = Modifier.padding(16.dp),
text = stringResource(R.string.plugin_state_error),
icon = Icons.Rounded.Error,
color = MaterialTheme.colorScheme.errorContainer,
)
}
Preference(
title = plugin.plugin.label,
enabled = state is PluginState.Ready && weatherProviderId != plugin.plugin.authority,
iconPadding = false,
summary = if (weatherProviderId != plugin.plugin.authority) {
stringResource(R.string.plugin_weather_provider_enable)
} else {
stringResource(R.string.plugin_weather_provider_enabled)
},
onClick = {
viewModel.setWeatherProvider(plugin.plugin.authority)
}
)
}
Preference(
title = plugin.plugin.label,
enabled = state is PluginState.Ready && weatherProviderId != plugin.plugin.authority,
iconPadding = false,
summary = if (weatherProviderId != plugin.plugin.authority) {
stringResource(R.string.plugin_weather_provider_enable)
} else {
stringResource(R.string.plugin_weather_provider_enabled)
},
title = stringResource(R.string.widget_config_weather_integration_settings),
icon = Icons.AutoMirrored.Rounded.OpenInNew,
onClick = {
viewModel.setWeatherProvider(plugin.plugin.authority)
navController?.navigate("settings/integrations/weather")
}
)
}
Preference(
title = stringResource(R.string.widget_config_weather_integration_settings),
icon = Icons.AutoMirrored.Rounded.OpenInNew,
onClick = {
navController?.navigate("settings/integrations/weather")
}
)
}
}
}

View File

@ -14,6 +14,7 @@ import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.plugins.PluginService
import de.mm20.launcher2.plugins.PluginWithState
import de.mm20.launcher2.preferences.search.FileSearchSettings
import de.mm20.launcher2.preferences.search.LocationSearchSettings
import de.mm20.launcher2.preferences.weather.WeatherSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -31,6 +32,7 @@ import org.koin.core.component.inject
class PluginSettingsScreenVM : ViewModel(), KoinComponent {
private val pluginService by inject<PluginService>()
private val fileSearchSettings: FileSearchSettings by inject()
private val locationSearchSettings: LocationSearchSettings by inject()
private val weatherSettings: WeatherSettings by inject()
private var pluginPackageName = MutableStateFlow<String?>(null)
@ -72,6 +74,11 @@ class PluginSettingsScreenVM : ViewModel(), KoinComponent {
it.filter { it.plugin.type == PluginType.FileSearch }
}
val locationPlugins = states
.map {
it.filter { it.plugin.type == PluginType.LocationSearch }
}
val weatherPlugins = states
.map {
it.filter { it.plugin.type == PluginType.Weather }
@ -109,6 +116,11 @@ class PluginSettingsScreenVM : ViewModel(), KoinComponent {
fileSearchSettings.setPluginEnabled(authority, enabled)
}
val enabledLocationSearchPlugins = locationSearchSettings.enabledPlugins
fun setLocationSearchPluginEnabled(authority: String, enabled: Boolean) {
locationSearchSettings.setPluginEnabled(authority, enabled)
}
val weatherProvider = weatherSettings.providerId
fun setWeatherProvider(providerId: String) {
weatherSettings.setProvider(providerId)

View File

@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.ExtensionOff
import androidx.compose.material.icons.rounded.Verified
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -19,7 +18,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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
@ -98,13 +96,6 @@ private fun PluginPreference(viewModel: PluginsSettingsScreenVM, plugin: PluginP
verticalAlignment = Alignment.CenterVertically
) {
Text(plugin.label)
if (plugin.isOfficial) {
Icon(
Icons.Rounded.Verified, null,
modifier = Modifier.padding(start = 4.dp).size(16.dp),
tint = MaterialTheme.colorScheme.secondary,
)
}
}
},
summary = plugin.description?.let { { Text(it) } },

View File

@ -22,8 +22,6 @@ import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material.icons.rounded.Work
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -50,7 +48,7 @@ import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.component.preferences.PreferenceWithSwitch
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
import de.mm20.launcher2.ui.icons.Wikipedia
import de.mm20.launcher2.icons.Wikipedia
import de.mm20.launcher2.ui.launcher.search.filters.SearchFilters
import de.mm20.launcher2.ui.locals.LocalNavController
@ -227,15 +225,10 @@ fun SearchSettingsScreen() {
}
)
val locations by viewModel.locations.collectAsStateWithLifecycle(null)
PreferenceWithSwitch(
Preference(
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")
}

View File

@ -22,7 +22,6 @@ import de.mm20.launcher2.preferences.search.WikipediaSearchSettings
import de.mm20.launcher2.preferences.ui.SearchUiSettings
import de.mm20.launcher2.search.SearchFilters
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@ -109,13 +108,6 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
websiteSearchSettings.setEnabled(websites)
}
val locations = locationSearchSettings.enabled
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun setLocations(locations: Boolean) {
locationSearchSettings.setEnabled(locations)
}
val autoFocus = searchUiSettings.openKeyboard
fun setAutoFocus(autoFocus: Boolean) {

View File

@ -48,6 +48,9 @@ dependencies {
implementation(libs.koin.android)
implementation(libs.androidx.palette)
runtimeOnly(libs.androidx.compose.ui)
runtimeOnly(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.materialicons)
implementation(project(":core:ktx"))
implementation(project(":core:i18n"))

View File

@ -1,4 +1,4 @@
package de.mm20.launcher2.ui.icons
package de.mm20.launcher2.icons
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.materialIcon

View File

@ -1,9 +1,10 @@
package de.mm20.launcher2.ui.icons
package de.mm20.launcher2.icons
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
val Icons.Rounded.Pdf: ImageVector
get() = materialIcon("Icons.Rounded.Pdf") {
@ -1099,4 +1100,331 @@ val Icons.Rounded.HumidityPercentage
moveToRelative(0f, -6.2f)
close()
}
}
}
private val _CableCar = materialIcon("Icons.Rounded.CableCar") {
materialPath {
moveTo(21f, 4.5f)
lineTo(3f, 7.5f)
verticalLineTo(9f)
lineTo(10.999512f, 7.6669922f)
verticalLineTo(10.000488f)
horizontalLineTo(6f)
curveToRelative(-1.1079945f, 0f, -1.9995117f, 0.891517f, -1.9995117f, 1.999512f)
verticalLineToRelative(6f)
curveToRelative(0f, 1.107995f, 0.8915172f, 1.999512f, 1.9995117f, 1.999512f)
horizontalLineToRelative(12f)
curveToRelative(1.107995f, 0f, 1.999512f, -0.891517f, 1.999512f, -1.999512f)
verticalLineToRelative(-6f)
curveToRelative(0f, -1.107995f, -0.891517f, -1.999512f, -1.999512f, -1.999512f)
horizontalLineTo(13.000488f)
verticalLineTo(7.3330078f)
lineTo(21f, 6f)
close()
moveTo(6f, 12f)
horizontalLineToRelative(4.999512f)
verticalLineToRelative(3f)
horizontalLineTo(6f)
close()
moveToRelative(7.000488f, 0f)
horizontalLineTo(18f)
verticalLineToRelative(3f)
horizontalLineToRelative(-4.999512f)
close()
}
}
val Icons.Rounded.CableCar
get() = _CableCar
private val _Stethoscope = materialIcon("Icons.Rounded.Stethoscope") {
materialPath {
moveTo(13.5f, 22f)
quadTo(10.8f, 22f, 8.9f, 20.1f)
quadTo(7f, 18.2f, 7f, 15.5f)
verticalLineTo(14.925f)
quadTo(4.85f, 14.575f, 3.425f, 12.9125f)
quadTo(2f, 11.25f, 2f, 9f)
verticalLineTo(4f)
quadTo(2f, 3.575f, 2.2875f, 3.2875f)
quadTo(2.575f, 3f, 3f, 3f)
horizontalLineTo(5f)
quadTo(5f, 2.575f, 5.2875f, 2.2875f)
quadTo(5.575f, 2f, 6f, 2f)
quadTo(6.425f, 2f, 6.7125f, 2.2875f)
quadTo(7f, 2.575f, 7f, 3f)
verticalLineTo(5f)
quadTo(7f, 5.425f, 6.7125f, 5.7125f)
quadTo(6.425f, 6f, 6f, 6f)
quadTo(5.575f, 6f, 5.2875f, 5.7125f)
quadTo(5f, 5.425f, 5f, 5f)
horizontalLineTo(4f)
verticalLineTo(9f)
quadTo(4f, 10.65f, 5.175f, 11.825f)
quadTo(6.35f, 13f, 8f, 13f)
quadTo(9.65f, 13f, 10.825f, 11.825f)
quadTo(12f, 10.65f, 12f, 9f)
verticalLineTo(5f)
horizontalLineTo(11f)
quadTo(11f, 5.425f, 10.7125f, 5.7125f)
quadTo(10.425f, 6f, 10f, 6f)
quadTo(9.575f, 6f, 9.2875f, 5.7125f)
quadTo(9f, 5.425f, 9f, 5f)
verticalLineTo(3f)
quadTo(9f, 2.575f, 9.2875f, 2.2875f)
quadTo(9.575f, 2f, 10f, 2f)
quadTo(10.425f, 2f, 10.7125f, 2.2875f)
quadTo(11f, 2.575f, 11f, 3f)
horizontalLineToRelative(2f)
quadTo(13.425f, 3f, 13.7125f, 3.2875f)
quadTo(14f, 3.575f, 14f, 4f)
verticalLineToRelative(5f)
quadToRelative(0f, 2.25f, -1.425f, 3.9125f)
quadTo(11.15f, 14.575f, 9f, 14.925f)
verticalLineTo(15.5f)
quadToRelative(0f, 1.875f, 1.3125f, 3.1875f)
quadTo(11.625f, 20f, 13.5f, 20f)
quadTo(15.375f, 20f, 16.6875f, 18.6875f)
quadTo(18f, 17.375f, 18f, 15.5f)
verticalLineTo(13.825f)
quadTo(17.125f, 13.5f, 16.5625f, 12.7375f)
quadTo(16f, 11.975f, 16f, 11f)
quadTo(16f, 9.75f, 16.875f, 8.875f)
quadTo(17.75f, 8f, 19f, 8f)
quadTo(20.25f, 8f, 21.125f, 8.875f)
quadTo(22f, 9.75f, 22f, 11f)
quadTo(22f, 11.975f, 21.4375f, 12.7375f)
quadTo(20.875f, 13.5f, 20f, 13.825f)
verticalLineTo(15.5f)
quadToRelative(0f, 2.7f, -1.9f, 4.6f)
quadToRelative(-1.9f, 1.9f, -4.6f, 1.9f)
close()
}
}
val Icons.Rounded.Stethoscope
get() = _Stethoscope
private val _Dentistry = materialIcon("Icons.Rounded.Dentistry") {
materialPath {
moveTo(17f, 2.9875f)
quadToRelative(1.65f, 0f, 2.825f, 1.175f)
quadTo(21f, 5.3375f, 21f, 6.9875f)
quadTo(21f, 7.2625f, 20.9625f, 7.725f)
quadTo(20.925f, 8.1875f, 20.85f, 8.7875f)
lineToRelative(-1.375f, 10.075f)
quadToRelative(-0.125f, 0.95f, -0.8625f, 1.55f)
quadToRelative(-0.7375f, 0.6f, -1.6875f, 0.6f)
quadToRelative(-0.575f, 0f, -1.0625f, -0.25f)
quadToRelative(-0.4875f, -0.25f, -0.8125f, -0.7f)
lineToRelative(-2.675f, -3.9f)
quadToRelative(-0.05f, -0.1f, -0.1625f, -0.1375f)
quadToRelative(-0.1125f, -0.0375f, -0.2375f, -0.0375f)
quadToRelative(-0.1f, 0f, -0.4f, 0.225f)
lineToRelative(-2.6f, 3.775f)
quadTo(8.625f, 20.4875f, 8.1125f, 20.75f)
quadTo(7.6f, 21.0125f, 7.025f, 21.0125f)
quadTo(6.075f, 21.0125f, 5.35f, 20.4f)
quadTo(4.625f, 19.7875f, 4.5f, 18.8375f)
lineTo(3.15f, 8.7875f)
quadTo(3.075f, 8.1875f, 3.0375f, 7.725f)
quadTo(3f, 7.2625f, 3f, 6.9875f)
quadTo(3f, 5.3375f, 4.175f, 4.1625f)
quadTo(5.35f, 2.9875f, 7f, 2.9875f)
quadTo(7.9f, 2.9875f, 8.4375f, 3.225f)
quadTo(8.975f, 3.4625f, 9.475f, 3.7375f)
quadTo(9.975f, 4.0125f, 10.5375f, 4.25f)
quadTo(11.1f, 4.4875f, 12f, 4.4875f)
quadToRelative(0.9f, 0f, 1.4625f, -0.2375f)
quadToRelative(0.5625f, -0.2375f, 1.0625f, -0.5125f)
quadToRelative(0.5f, -0.275f, 1.05f, -0.5125f)
quadTo(16.125f, 2.9875f, 17f, 2.9875f)
close()
}
}
val Icons.Rounded.Dentistry
get() = _Dentistry
private val _Eyeglasses = materialIcon("Icons.Rounded.Eyeglasses") {
materialPath {
moveTo(6.85f, 15f)
quadTo(7.625f, 15f, 8.2375f, 14.55f)
quadTo(8.85f, 14.1f, 9.1f, 13.375f)
lineToRelative(0.375f, -1.15f)
quadToRelative(0.4f, -1.2f, -0.2f, -2.2125f)
quadTo(8.675f, 9f, 7.55f, 9f)
horizontalLineTo(4.025f)
lineTo(4.5f, 12.925f)
quadTo(4.625f, 13.8f, 5.2875f, 14.4f)
quadTo(5.95f, 15f, 6.85f, 15f)
close()
moveToRelative(10.3f, 0f)
quadToRelative(0.9f, 0f, 1.5625f, -0.6f)
quadTo(19.375f, 13.8f, 19.5f, 12.925f)
lineTo(19.975f, 9f)
horizontalLineToRelative(-3.5f)
quadToRelative(-1.125f, 0f, -1.725f, 1.025f)
quadToRelative(-0.6f, 1.025f, -0.2f, 2.225f)
lineToRelative(0.35f, 1.125f)
quadTo(15.15f, 14.1f, 15.7625f, 14.55f)
quadTo(16.375f, 15f, 17.15f, 15f)
close()
moveTo(6.85f, 17f)
quadTo(5.2f, 17f, 3.9625f, 15.9125f)
quadTo(2.725f, 14.825f, 2.525f, 13.175f)
lineTo(2f, 9f)
quadTo(1.575f, 9f, 1.2875f, 8.7125f)
quadTo(1f, 8.425f, 1f, 8f)
quadTo(1f, 7.575f, 1.2875f, 7.2875f)
quadTo(1.575f, 7f, 2f, 7f)
horizontalLineTo(7.55f)
quadTo(8.65f, 7f, 9.5625f, 7.5375f)
quadTo(10.475f, 8.075f, 11f, 9f)
horizontalLineToRelative(2.025f)
quadTo(13.55f, 8.075f, 14.4625f, 7.5375f)
quadTo(15.375f, 7f, 16.475f, 7f)
horizontalLineTo(22f)
quadTo(22.425f, 7f, 22.7125f, 7.2875f)
quadTo(23f, 7.575f, 23f, 8f)
quadTo(23f, 8.425f, 22.7125f, 8.7125f)
quadTo(22.425f, 9f, 22f, 9f)
lineToRelative(-0.525f, 4.175f)
quadToRelative(-0.2f, 1.65f, -1.4375f, 2.7375f)
quadTo(18.8f, 17f, 17.15f, 17f)
quadTo(15.725f, 17f, 14.5875f, 16.1875f)
quadTo(13.45f, 15.375f, 13f, 14.025f)
lineTo(12.625f, 12.9f)
quadToRelative(-0.05f, -0.175f, -0.1f, -0.3625f)
quadTo(12.475f, 12.35f, 12.425f, 12f)
horizontalLineToRelative(-0.85f)
quadToRelative(-0.05f, 0.3f, -0.1f, 0.4875f)
quadToRelative(-0.05f, 0.1875f, -0.1f, 0.3625f)
lineTo(11f, 14f)
quadTo(10.55f, 15.35f, 9.4125f, 16.175f)
quadTo(8.275f, 17f, 6.85f, 17f)
close()
}
}
val Icons.Rounded.Eyeglasses
get() = _Eyeglasses
private val _Monument = materialIcon("Icons.Rounded.Monument") {
materialPath {
moveTo(12f, 1.9995117f)
lineTo(10.000488f, 4.0004883f)
lineTo(10.000488f, 16.000488f)
lineTo(9f, 16.000488f)
curveTo(8.4460012f, 16.000488f, 7.9995117f, 16.445512f, 7.9995117f, 16.999512f)
lineTo(7.9995117f, 19.999512f)
lineTo(7.0004883f, 19.999512f)
curveTo(6.4464888f, 19.999512f, 6f, 20.446001f, 6f, 21f)
curveTo(6f, 21.553999f, 6.4464888f, 22.000488f, 7.0004883f, 22.000488f)
lineTo(16.999512f, 22.000488f)
curveTo(17.553511f, 22.000488f, 18f, 21.553999f, 18f, 21f)
curveTo(18f, 20.446001f, 17.553511f, 19.999512f, 16.999512f, 19.999512f)
lineTo(16.000488f, 19.999512f)
lineTo(16.000488f, 16.999512f)
curveTo(16.000488f, 16.445512f, 15.553998f, 16.000488f, 15f, 16.000488f)
lineTo(13.999512f, 16.000488f)
lineTo(13.999512f, 4.0004883f)
lineTo(12f, 1.9995117f)
close()
}
}
val Icons.Rounded.Monument
get() = _Monument
private val _Candle = materialIcon("Icons.Rounded.Candle") {
materialPath {
moveTo(240f, -160f)
horizontalLineToRelative(480f)
quadToRelative(17f, 0f, 28.5f, -11.5f)
reflectiveQuadTo(760f, -200f)
horizontalLineTo(200f)
quadToRelative(0f, 17f, 11.5f, 28.5f)
reflectiveQuadTo(240f, -160f)
close()
moveToRelative(240f, -480f)
quadToRelative(-48f, 0f, -80f, -33.5f)
reflectiveQuadTo(370f, -755f)
quadToRelative(2f, -44f, 27f, -78.5f)
reflectiveQuadToRelative(56f, -62.5f)
quadToRelative(6f, -5f, 12.5f, -7.5f)
reflectiveQuadTo(480f, -906f)
quadToRelative(8f, 0f, 14.5f, 2.5f)
reflectiveQuadTo(507f, -896f)
quadToRelative(31f, 28f, 56f, 62.5f)
reflectiveQuadToRelative(27f, 78.5f)
quadToRelative(2f, 48f, -30f, 81.5f)
reflectiveQuadTo(480f, -640f)
close()
moveToRelative(-40f, 360f)
horizontalLineToRelative(80f)
verticalLineToRelative(-240f)
horizontalLineToRelative(-80f)
verticalLineToRelative(240f)
close()
moveToRelative(40f, -440f)
quadToRelative(13f, 0f, 21.5f, -9f)
reflectiveQuadToRelative(8.5f, -22f)
quadToRelative(0f, -17f, -9.5f, -31f)
reflectiveQuadTo(480f, -809f)
quadToRelative(-11f, 13f, -20.5f, 27f)
reflectiveQuadToRelative(-9.5f, 31f)
quadToRelative(0f, 13f, 8.5f, 22f)
reflectiveQuadToRelative(21.5f, 9f)
close()
moveToRelative(330f, 440f)
quadToRelative(13f, 0f, 21.5f, -8.5f)
reflectiveQuadTo(840f, -310f)
quadToRelative(0f, -13f, -8.5f, -21.5f)
reflectiveQuadTo(810f, -340f)
quadToRelative(-13f, 0f, -21.5f, 8.5f)
reflectiveQuadTo(780f, -310f)
quadToRelative(0f, 13f, 8.5f, 21.5f)
reflectiveQuadTo(810f, -280f)
close()
moveTo(720f, -80f)
horizontalLineTo(240f)
quadToRelative(-50f, 0f, -85f, -35f)
reflectiveQuadToRelative(-35f, -85f)
verticalLineToRelative(-40f)
quadToRelative(0f, -17f, 11.5f, -28.5f)
reflectiveQuadTo(160f, -280f)
horizontalLineToRelative(200f)
verticalLineToRelative(-240f)
quadToRelative(0f, -33f, 23.5f, -56.5f)
reflectiveQuadTo(440f, -600f)
horizontalLineToRelative(80f)
quadToRelative(33f, 0f, 56.5f, 23.5f)
reflectiveQuadTo(600f, -520f)
verticalLineToRelative(240f)
horizontalLineToRelative(104f)
quadToRelative(-2f, -8f, -3f, -15f)
reflectiveQuadToRelative(-1f, -15f)
quadToRelative(0f, -46f, 32f, -78f)
reflectiveQuadToRelative(78f, -32f)
quadToRelative(46f, 0f, 78f, 32f)
reflectiveQuadToRelative(32f, 78f)
quadToRelative(0f, 38f, -22.5f, 67f)
reflectiveQuadTo(840f, -204f)
verticalLineToRelative(4f)
quadToRelative(0f, 50f, -35f, 85f)
reflectiveQuadToRelative(-85f, 35f)
close()
moveToRelative(-240f, -80f)
close()
moveToRelative(-40f, -120f)
horizontalLineToRelative(80f)
horizontalLineToRelative(-80f)
close()
moveToRelative(40f, -484f)
close()
}
}
val Icons.Rounded.Candle
get() = _Candle

View File

@ -1,6 +1,7 @@
package de.mm20.launcher2.icons
import android.graphics.drawable.Drawable
import androidx.compose.ui.graphics.vector.ImageVector
sealed interface LauncherIconLayer
@ -53,4 +54,9 @@ data class TextLayer(
val color: Int = 0,
) : LauncherIconLayer
data class VectorLayer(
val vector: ImageVector,
val color: Int = 0,
) : LauncherIconLayer
object TransparentLayer: LauncherIconLayer

View File

@ -1,9 +1,11 @@
package de.mm20.launcher2.plugin
import android.content.ContentResolver
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.util.Log
import de.mm20.launcher2.plugin.config.SearchPluginConfig
import de.mm20.launcher2.plugin.config.QueryPluginConfig
import de.mm20.launcher2.plugin.config.WeatherPluginConfig
import de.mm20.launcher2.plugin.contracts.PluginContract
@ -11,7 +13,25 @@ class PluginApi(
private val pluginAuthority: String,
private val contentResolver: ContentResolver,
) {
fun getSearchPluginConfig(): SearchPluginConfig? {
fun getConfig(): Bundle? {
val configBundle = try {
contentResolver.call(
Uri.Builder()
.scheme("content")
.authority(pluginAuthority)
.build(),
PluginContract.Methods.GetConfig,
null,
null
) ?: return null
} catch (e: Exception) {
Log.e("MM20", "Plugin $pluginAuthority threw exception", e)
return null
}
return configBundle
}
fun getSearchPluginConfig(): QueryPluginConfig? {
val configBundle = try {
contentResolver.call(
Uri.Builder()
@ -27,7 +47,7 @@ class PluginApi(
return null
}
return SearchPluginConfig(configBundle)
return QueryPluginConfig(configBundle)
}
fun getWeatherPluginConfig(): WeatherPluginConfig? {

View File

@ -10,7 +10,7 @@ data class PluginPackage(
val author: String? = null,
val settings: Intent? = null,
val plugins: List<Plugin>,
val isOfficial: Boolean = false,
val isVerified: Boolean = false,
) {
val enabled: Boolean = plugins.all { it.enabled }
}

View File

@ -0,0 +1,190 @@
package de.mm20.launcher2.plugin
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.os.CancellationSignal
import android.util.Log
import de.mm20.launcher2.plugin.config.QueryPluginConfig
import de.mm20.launcher2.plugin.contracts.LocationPluginContract
import de.mm20.launcher2.plugin.contracts.PluginContract
import de.mm20.launcher2.plugin.contracts.SearchPluginContract
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
private class NotUpdated : Exception("Not updated")
abstract class QueryPluginApi<TQuery, TResult>(
private val context: Context,
private val pluginAuthority: String,
) {
private fun getLanguage() = context.resources.configuration.locales[0].language
fun getConfig(): QueryPluginConfig? {
val configBundle = try {
context.contentResolver.call(
Uri.Builder()
.scheme("content")
.authority(pluginAuthority)
.build(),
PluginContract.Methods.GetConfig,
null,
null
) ?: return null
} catch (e: Exception) {
Log.e("MM20", "Plugin $pluginAuthority threw exception", e)
return null
}
return QueryPluginConfig(configBundle)
}
suspend fun search(query: TQuery, allowNetwork: Boolean): List<TResult> = withContext(Dispatchers.IO) {
val lang = getLanguage()
val uri = Uri.Builder()
.scheme("content")
.authority(pluginAuthority)
.path(LocationPluginContract.Paths.Search)
.appendQueryParameters(query)
.appendQueryParameter(
SearchPluginContract.Params.AllowNetwork,
allowNetwork.toString()
)
.appendQueryParameter(
SearchPluginContract.Params.Lang,
lang
)
.build()
val cancellationSignal = CancellationSignal()
return@withContext suspendCancellableCoroutine {
it.invokeOnCancellation {
cancellationSignal.cancel()
}
val cursor = try {
context.contentResolver.query(
uri,
null,
null,
cancellationSignal
)
} catch (e: Exception) {
Log.e("MM20", "Plugin $pluginAuthority threw exception", e)
it.resume(emptyList())
return@suspendCancellableCoroutine
}
if (cursor == null) {
Log.e("MM20", "Plugin $pluginAuthority returned null cursor")
it.resume(emptyList())
return@suspendCancellableCoroutine
}
val results = cursor.getData() ?: emptyList()
it.resume(results)
}
}
suspend fun get(id: String): Result<TResult?> = withContext(Dispatchers.IO) {
val uri = Uri.Builder()
.scheme("content")
.authority(pluginAuthority)
.path(SearchPluginContract.Paths.Root)
.appendPath(id)
.appendQueryParameter(SearchPluginContract.Params.Lang, getLanguage())
.build()
val cancellationSignal = CancellationSignal()
return@withContext suspendCancellableCoroutine {
it.invokeOnCancellation {
cancellationSignal.cancel()
}
val cursor = try {
context.contentResolver.query(
uri,
null,
null,
cancellationSignal
)
} catch (e: Exception) {
Log.e("MM20", "Plugin $pluginAuthority threw exception", e)
it.resume(Result.failure(e))
return@suspendCancellableCoroutine
}
if (cursor == null) {
Log.e("MM20", "Plugin $pluginAuthority returned null cursor")
it.resume(Result.success(null))
return@suspendCancellableCoroutine
}
val result = cursor.getData()?.firstOrNull()
if (result == null) {
it.resume(Result.success(null))
return@suspendCancellableCoroutine
}
it.resume(Result.success(result))
}
}
suspend fun refresh(item: TResult, lastUpdate: Long): Result<TResult?> = withContext(Dispatchers.IO) {
val uri = Uri.Builder()
.scheme("content")
.authority(pluginAuthority)
.path(SearchPluginContract.Paths.Refresh)
.appendQueryParameter(SearchPluginContract.Params.Lang, getLanguage())
.appendQueryParameter(SearchPluginContract.Params.UpdatedAt, lastUpdate.toString())
.build()
val cancellationSignal = CancellationSignal()
return@withContext suspendCancellableCoroutine {
it.invokeOnCancellation {
cancellationSignal.cancel()
}
val cursor = try {
context.contentResolver.query(
uri,
null,
item.toBundle(),
cancellationSignal
)
} catch (e: Exception) {
Log.e("MM20", "Plugin $pluginAuthority threw exception", e)
it.resume(Result.failure(e))
return@suspendCancellableCoroutine
}
if (cursor == null) {
Log.e("MM20", "Plugin $pluginAuthority returned null cursor")
it.resume(Result.failure(IllegalArgumentException()))
return@suspendCancellableCoroutine
}
if (cursor.extras?.getBoolean(SearchPluginContract.Extras.NotUpdated) == true) {
it.resume(Result.failure(NotUpdated()))
return@suspendCancellableCoroutine
}
val result = cursor.getData()?.firstOrNull()
if (result == null) {
it.resume(Result.success(null))
return@suspendCancellableCoroutine
}
it.resume(Result.success(result))
}
}
protected abstract fun Uri.Builder.appendQueryParameters(query: TQuery): Uri.Builder
protected abstract fun Cursor.getData(): List<TResult>?
protected abstract fun TResult.toBundle(): Bundle
}

View File

@ -2,8 +2,8 @@ package de.mm20.launcher2.plugin.config
import android.os.Bundle
fun SearchPluginConfig(bundle: Bundle): SearchPluginConfig? {
return SearchPluginConfig(
fun QueryPluginConfig(bundle: Bundle): QueryPluginConfig {
return QueryPluginConfig(
storageStrategy = valueOfOrElse(
bundle.getString(
"storageStrategy",

View File

@ -1,16 +1,135 @@
package de.mm20.launcher2.search
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.DirectionsBike
import androidx.compose.material.icons.rounded.AccountBalance
import androidx.compose.material.icons.rounded.AttachMoney
import androidx.compose.material.icons.rounded.Attractions
import androidx.compose.material.icons.rounded.BakeryDining
import androidx.compose.material.icons.rounded.Bed
import androidx.compose.material.icons.rounded.Bento
import androidx.compose.material.icons.rounded.Book
import androidx.compose.material.icons.rounded.BreakfastDining
import androidx.compose.material.icons.rounded.BrunchDining
import androidx.compose.material.icons.rounded.CarRental
import androidx.compose.material.icons.rounded.CarRepair
import androidx.compose.material.icons.rounded.Casino
import androidx.compose.material.icons.rounded.Checkroom
import androidx.compose.material.icons.rounded.Church
import androidx.compose.material.icons.rounded.ContentCut
import androidx.compose.material.icons.rounded.Diamond
import androidx.compose.material.icons.rounded.DirectionsBoat
import androidx.compose.material.icons.rounded.DirectionsBus
import androidx.compose.material.icons.rounded.DirectionsCar
import androidx.compose.material.icons.rounded.DirectionsTransit
import androidx.compose.material.icons.rounded.Discount
import androidx.compose.material.icons.rounded.DownhillSkiing
import androidx.compose.material.icons.rounded.ElectricScooter
import androidx.compose.material.icons.rounded.EvStation
import androidx.compose.material.icons.rounded.Fastfood
import androidx.compose.material.icons.rounded.Festival
import androidx.compose.material.icons.rounded.FitnessCenter
import androidx.compose.material.icons.rounded.Flight
import androidx.compose.material.icons.rounded.Forest
import androidx.compose.material.icons.rounded.Gavel
import androidx.compose.material.icons.rounded.Hiking
import androidx.compose.material.icons.rounded.Hotel
import androidx.compose.material.icons.rounded.Icecream
import androidx.compose.material.icons.rounded.Kayaking
import androidx.compose.material.icons.rounded.KebabDining
import androidx.compose.material.icons.rounded.Liquor
import androidx.compose.material.icons.rounded.LocalAtm
import androidx.compose.material.icons.rounded.LocalBar
import androidx.compose.material.icons.rounded.LocalCafe
import androidx.compose.material.icons.rounded.LocalCarWash
import androidx.compose.material.icons.rounded.LocalConvenienceStore
import androidx.compose.material.icons.rounded.LocalFireDepartment
import androidx.compose.material.icons.rounded.LocalFlorist
import androidx.compose.material.icons.rounded.LocalGasStation
import androidx.compose.material.icons.rounded.LocalGroceryStore
import androidx.compose.material.icons.rounded.LocalHospital
import androidx.compose.material.icons.rounded.LocalLaundryService
import androidx.compose.material.icons.rounded.LocalMall
import androidx.compose.material.icons.rounded.LocalParking
import androidx.compose.material.icons.rounded.LocalPharmacy
import androidx.compose.material.icons.rounded.LocalPizza
import androidx.compose.material.icons.rounded.LocalPolice
import androidx.compose.material.icons.rounded.LocalPostOffice
import androidx.compose.material.icons.rounded.LocalTaxi
import androidx.compose.material.icons.rounded.LunchDining
import androidx.compose.material.icons.rounded.Moped
import androidx.compose.material.icons.rounded.Mosque
import androidx.compose.material.icons.rounded.Motorcycle
import androidx.compose.material.icons.rounded.Museum
import androidx.compose.material.icons.rounded.MusicNote
import androidx.compose.material.icons.rounded.Newspaper
import androidx.compose.material.icons.rounded.Nightlife
import androidx.compose.material.icons.rounded.NordicWalking
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.Paragliding
import androidx.compose.material.icons.rounded.Park
import androidx.compose.material.icons.rounded.Pets
import androidx.compose.material.icons.rounded.Phone
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.Pool
import androidx.compose.material.icons.rounded.RamenDining
import androidx.compose.material.icons.rounded.Restaurant
import androidx.compose.material.icons.rounded.School
import androidx.compose.material.icons.rounded.ShoppingBag
import androidx.compose.material.icons.rounded.Skateboarding
import androidx.compose.material.icons.rounded.Snowboarding
import androidx.compose.material.icons.rounded.SoupKitchen
import androidx.compose.material.icons.rounded.Sports
import androidx.compose.material.icons.rounded.SportsBar
import androidx.compose.material.icons.rounded.SportsBaseball
import androidx.compose.material.icons.rounded.SportsBasketball
import androidx.compose.material.icons.rounded.SportsCricket
import androidx.compose.material.icons.rounded.SportsFootball
import androidx.compose.material.icons.rounded.SportsGolf
import androidx.compose.material.icons.rounded.SportsGymnastics
import androidx.compose.material.icons.rounded.SportsHandball
import androidx.compose.material.icons.rounded.SportsHockey
import androidx.compose.material.icons.rounded.SportsMartialArts
import androidx.compose.material.icons.rounded.SportsMotorsports
import androidx.compose.material.icons.rounded.SportsRugby
import androidx.compose.material.icons.rounded.SportsSoccer
import androidx.compose.material.icons.rounded.SportsTennis
import androidx.compose.material.icons.rounded.SportsVolleyball
import androidx.compose.material.icons.rounded.Stadium
import androidx.compose.material.icons.rounded.Subway
import androidx.compose.material.icons.rounded.Surfing
import androidx.compose.material.icons.rounded.Synagogue
import androidx.compose.material.icons.rounded.TakeoutDining
import androidx.compose.material.icons.rounded.TempleBuddhist
import androidx.compose.material.icons.rounded.TempleHindu
import androidx.compose.material.icons.rounded.TheaterComedy
import androidx.compose.material.icons.rounded.Theaters
import androidx.compose.material.icons.rounded.Train
import androidx.compose.material.icons.rounded.Tram
import androidx.compose.material.icons.rounded.Wc
import androidx.core.content.ContextCompat
import de.mm20.launcher2.base.R
import de.mm20.launcher2.icons.CableCar
import de.mm20.launcher2.icons.Candle
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.Dentistry
import de.mm20.launcher2.icons.Eyeglasses
import de.mm20.launcher2.icons.Monument
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TintedIconLayer
import kotlinx.collections.immutable.ImmutableList
import java.time.DayOfWeek
import java.time.Duration
import de.mm20.launcher2.icons.Stethoscope
import de.mm20.launcher2.icons.VectorLayer
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.location.Address
import de.mm20.launcher2.search.location.Attribution
import de.mm20.launcher2.search.location.Departure
import de.mm20.launcher2.search.location.LocationIcon
import de.mm20.launcher2.search.location.OpeningHours
import de.mm20.launcher2.search.location.OpeningSchedule
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.temporal.TemporalAdjusters
import android.location.Location as AndroidLocation
@ -20,128 +139,182 @@ interface Location : SavableSearchable {
val longitude: Double
val fixMeUrl: String?
val category: LocationCategory?
val icon: LocationIcon?
val category: String?
val address: Address?
val street: String?
val houseNumber: String?
val openingSchedule: OpeningSchedule?
val websiteUrl: String?
val phoneNumber: String?
val emailAddress: String?
val userRating: Float?
val userRatingCount: Int?
val openingSchedule: OpeningSchedule?
val departures: List<Departure>?
val attribution: Attribution?
get() = null
override val preferDetailsOverLaunch: Boolean
get() = true
override fun launch(context: Context, options: Bundle?): Boolean {
return context.tryStartActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse("geo:$latitude,$longitude?q=${Uri.encode(label)}")
),
options
)
}
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
val (resId, bgColor) = when (category) {
LocationCategory.FAST_FOOD, LocationCategory.RESTAURANT -> with(
labelOverride ?: label
) {
when {
contains(
"pizza",
ignoreCase = true
) -> R.drawable.ic_location_pizza to R.color.red
val (vector, bgColor) = when (icon) {
// blue: transportation
LocationIcon.Car -> Icons.Rounded.DirectionsCar to R.color.blue
LocationIcon.CarRental -> Icons.Rounded.CarRental to R.color.blue
LocationIcon.CarRepair -> Icons.Rounded.CarRepair to R.color.blue
LocationIcon.CarWash -> Icons.Rounded.LocalCarWash to R.color.blue
LocationIcon.ChargingStation -> Icons.Rounded.EvStation to R.color.blue
LocationIcon.GasStation -> Icons.Rounded.LocalGasStation to R.color.blue
LocationIcon.Parking -> Icons.Rounded.LocalParking to R.color.blue
LocationIcon.Bus -> Icons.Rounded.DirectionsBus to R.color.blue
LocationIcon.Tram -> Icons.Rounded.Tram to R.color.blue
LocationIcon.Subway -> Icons.Rounded.Subway to R.color.blue
LocationIcon.Train -> Icons.Rounded.Train to R.color.blue
LocationIcon.CableCar -> Icons.Rounded.CableCar to R.color.blue
LocationIcon.Airport -> Icons.Rounded.Flight to R.color.blue
LocationIcon.Boat -> Icons.Rounded.DirectionsBoat to R.color.blue
LocationIcon.Moped -> Icons.Rounded.Moped to R.color.blue
LocationIcon.Bike -> Icons.AutoMirrored.Rounded.DirectionsBike to R.color.blue
LocationIcon.Motorcycle -> Icons.Rounded.Motorcycle to R.color.blue
LocationIcon.ElectricScooter -> Icons.Rounded.ElectricScooter to R.color.blue
LocationIcon.Taxi -> Icons.Rounded.LocalTaxi to R.color.blue
LocationIcon.GenericTransit -> Icons.Rounded.DirectionsTransit to R.color.blue
contains(
"ramen",
ignoreCase = true
) -> R.drawable.ic_location_ramen to R.color.orange
// cyan: art, culture, entertainment
LocationIcon.ArtGallery -> Icons.Rounded.Palette to R.color.cyan
LocationIcon.Museum -> Icons.Rounded.Museum to R.color.cyan
LocationIcon.Theater -> Icons.Rounded.TheaterComedy to R.color.cyan
LocationIcon.MovieTheater -> Icons.Rounded.Theaters to R.color.cyan
LocationIcon.AmusementPark -> Icons.Rounded.Attractions to R.color.cyan
LocationIcon.NightClub -> Icons.Rounded.Nightlife to R.color.cyan
LocationIcon.ConcertHall -> Icons.Rounded.MusicNote to R.color.cyan
LocationIcon.Stadium -> Icons.Rounded.Stadium to R.color.cyan
LocationIcon.Casino -> Icons.Rounded.Casino to R.color.cyan
LocationIcon.Circus -> Icons.Rounded.Festival to R.color.cyan
// pink: hotels
LocationIcon.Hotel -> Icons.Rounded.Hotel to R.color.pink
// orange: food and drink
LocationIcon.Restaurant -> Icons.Rounded.Restaurant to R.color.orange
LocationIcon.Cafe -> Icons.Rounded.LocalCafe to R.color.orange
LocationIcon.FastFood -> Icons.Rounded.Fastfood to R.color.orange
LocationIcon.Pizza -> Icons.Rounded.LocalPizza to R.color.orange
LocationIcon.Burger -> Icons.Rounded.LunchDining to R.color.orange
LocationIcon.Kebab -> Icons.Rounded.KebabDining to R.color.orange
LocationIcon.IceCream -> Icons.Rounded.Icecream to R.color.orange
LocationIcon.Ramen -> Icons.Rounded.RamenDining to R.color.orange
LocationIcon.Soup -> Icons.Rounded.SoupKitchen to R.color.orange
LocationIcon.Bar -> Icons.Rounded.LocalBar to R.color.orange
LocationIcon.Brunch -> Icons.Rounded.BrunchDining to R.color.orange
LocationIcon.Breakfast -> Icons.Rounded.BreakfastDining to R.color.orange
LocationIcon.Pub -> Icons.Rounded.SportsBar to R.color.orange
LocationIcon.JapaneseCuisine -> Icons.Rounded.Bento to R.color.orange
LocationIcon.AsianCuisine -> Icons.Rounded.TakeoutDining to R.color.orange
// indigo: business and shopping
LocationIcon.Shopping -> Icons.Rounded.ShoppingBag to R.color.indigo
LocationIcon.Supermarket -> Icons.Rounded.LocalGroceryStore to R.color.indigo
LocationIcon.Florist -> Icons.Rounded.LocalFlorist to R.color.indigo
LocationIcon.Kiosk -> Icons.Rounded.Newspaper to R.color.indigo
LocationIcon.FurnitureStore -> Icons.Rounded.Bed to R.color.indigo
LocationIcon.CellPhoneStore -> Icons.Rounded.Phone to R.color.indigo
LocationIcon.BookStore -> Icons.Rounded.Book to R.color.indigo
LocationIcon.ClothingStore -> Icons.Rounded.Checkroom to R.color.indigo
LocationIcon.ConvenienceStore -> Icons.Rounded.LocalConvenienceStore to R.color.indigo
LocationIcon.DiscountStore -> Icons.Rounded.Discount to R.color.indigo
LocationIcon.JewelryStore -> Icons.Rounded.Diamond to R.color.indigo
LocationIcon.ShoppingMall -> Icons.Rounded.LocalMall to R.color.indigo
LocationIcon.LiquorStore -> Icons.Rounded.Liquor to R.color.indigo
LocationIcon.PetStore -> Icons.Rounded.Pets to R.color.indigo
LocationIcon.Bakery -> Icons.Rounded.BakeryDining to R.color.indigo
LocationIcon.Optician -> Icons.Rounded.Eyeglasses to R.color.indigo
LocationIcon.Pharmacy -> Icons.Rounded.LocalPharmacy to R.color.indigo
LocationIcon.HairSalon -> Icons.Rounded.ContentCut to R.color.indigo
LocationIcon.Laundromat -> Icons.Rounded.LocalLaundryService to R.color.indigo
// purple: sports and recreation
LocationIcon.Sports -> Icons.Rounded.Sports to R.color.purple
LocationIcon.FitnessCenter -> Icons.Rounded.FitnessCenter to R.color.purple
LocationIcon.Soccer -> Icons.Rounded.SportsSoccer to R.color.purple
LocationIcon.Basketball -> Icons.Rounded.SportsBasketball to R.color.purple
LocationIcon.Golf -> Icons.Rounded.SportsGolf to R.color.purple
LocationIcon.Tennis -> Icons.Rounded.SportsTennis to R.color.purple
LocationIcon.Baseball -> Icons.Rounded.SportsBaseball to R.color.purple
LocationIcon.Rugby -> Icons.Rounded.SportsRugby to R.color.purple
LocationIcon.AmericanFootball -> Icons.Rounded.SportsFootball to R.color.purple
LocationIcon.Hiking -> Icons.Rounded.Hiking to R.color.purple
LocationIcon.Swimming -> Icons.Rounded.Pool to R.color.purple
LocationIcon.Surfing -> Icons.Rounded.Surfing to R.color.purple
LocationIcon.Motorsports -> Icons.Rounded.SportsMotorsports to R.color.purple
LocationIcon.Handball -> Icons.Rounded.SportsHandball to R.color.purple
LocationIcon.Volleyball -> Icons.Rounded.SportsVolleyball to R.color.purple
LocationIcon.Skiing -> Icons.Rounded.DownhillSkiing to R.color.purple
LocationIcon.Kayaking -> Icons.Rounded.Kayaking to R.color.purple
LocationIcon.Skateboarding -> Icons.Rounded.Skateboarding to R.color.purple
LocationIcon.Cricket -> Icons.Rounded.SportsCricket to R.color.purple
LocationIcon.MartialArts -> Icons.Rounded.SportsMartialArts to R.color.purple
LocationIcon.NordicWalking -> Icons.Rounded.NordicWalking to R.color.purple
LocationIcon.Paragliding -> Icons.Rounded.Paragliding to R.color.purple
LocationIcon.Gymnastics -> Icons.Rounded.SportsGymnastics to R.color.purple
LocationIcon.Snowboarding -> Icons.Rounded.Snowboarding to R.color.purple
LocationIcon.Hockey -> Icons.Rounded.SportsHockey to R.color.purple
// green: finances
LocationIcon.Bank -> Icons.Rounded.AttachMoney to R.color.green
LocationIcon.Atm -> Icons.Rounded.LocalAtm to R.color.green
// red: health
LocationIcon.Hospital -> Icons.Rounded.LocalHospital to R.color.red
LocationIcon.Clinic -> Icons.Rounded.LocalHospital to R.color.red
LocationIcon.Dentist -> Icons.Rounded.Dentistry to R.color.red
LocationIcon.Physician -> Icons.Rounded.Stethoscope to R.color.red
// light green: nature
LocationIcon.Park -> Icons.Rounded.Park to R.color.lightgreen
LocationIcon.Forest -> Icons.Rounded.Forest to R.color.lightgreen
// brown: places of worship and remembrance
LocationIcon.Monument -> Icons.Rounded.Monument to R.color.brown
LocationIcon.Church -> Icons.Rounded.Church to R.color.brown
LocationIcon.Mosque -> Icons.Rounded.Mosque to R.color.brown
LocationIcon.Synagogue -> Icons.Rounded.Synagogue to R.color.brown
LocationIcon.HinduTemple -> Icons.Rounded.TempleHindu to R.color.brown
LocationIcon.BuddhistTemple -> Icons.Rounded.TempleBuddhist to R.color.brown
LocationIcon.Candle -> Icons.Rounded.Candle to R.color.brown
// bluegrey: public services
LocationIcon.GovernmentBuilding -> Icons.Rounded.AccountBalance to R.color.bluegrey
LocationIcon.Police -> Icons.Rounded.LocalPolice to R.color.bluegrey
LocationIcon.FireDepartment -> Icons.Rounded.LocalFireDepartment to R.color.bluegrey
LocationIcon.Courthouse -> Icons.Rounded.Gavel to R.color.bluegrey
LocationIcon.PostOffice -> Icons.Rounded.LocalPostOffice to R.color.bluegrey
LocationIcon.Library -> Icons.Rounded.Book to R.color.bluegrey
LocationIcon.School -> Icons.Rounded.School to R.color.bluegrey
LocationIcon.University -> Icons.Rounded.School to R.color.bluegrey
LocationIcon.PublicBathroom -> Icons.Rounded.Wc to R.color.bluegrey
contains(
"tapas",
ignoreCase = true
) -> R.drawable.ic_location_tapas to R.color.orange
contains(
"keba" /* b or p, depending on locale */,
ignoreCase = true
) -> R.drawable.ic_location_kebab to R.color.orange
category == LocationCategory.FAST_FOOD -> R.drawable.ic_location_fastfood to R.color.orange
else -> R.drawable.ic_location_restaurant to R.color.red
}
}
LocationCategory.BAR -> R.drawable.ic_location_bar to R.color.amber
LocationCategory.CAFE, LocationCategory.COFFEE_SHOP -> R.drawable.ic_location_cafe to R.color.brown
LocationCategory.HOTEL -> R.drawable.ic_location_hotel to R.color.green
LocationCategory.SUPERMARKET -> R.drawable.ic_location_supermarket to R.color.lightblue
LocationCategory.SCHOOL -> R.drawable.ic_location_school to R.color.purple
LocationCategory.PARKING -> R.drawable.ic_location_parking to R.color.blue
LocationCategory.FUEL -> R.drawable.ic_location_fuel to R.color.teal
LocationCategory.TOILETS -> R.drawable.ic_location_toilets to R.color.blue
LocationCategory.PHARMACY -> R.drawable.ic_location_pharmacy to R.color.pink
LocationCategory.HOSPITAL, LocationCategory.CLINIC -> R.drawable.ic_location_hospital to R.color.red
LocationCategory.POST_OFFICE -> R.drawable.ic_location_post_office to R.color.yellow
LocationCategory.PUB, LocationCategory.BIERGARTEN -> R.drawable.ic_location_pub to R.color.amber
LocationCategory.GRAVE_YARD -> R.drawable.ic_location_grave_yard to R.color.grey
LocationCategory.DOCTORS -> R.drawable.ic_location_doctors to R.color.red
LocationCategory.POLICE -> R.drawable.ic_location_police to R.color.blue
LocationCategory.DENTIST -> R.drawable.ic_location_dentist to R.color.lightblue
LocationCategory.LIBRARY, LocationCategory.BOOKS -> R.drawable.ic_location_library to R.color.brown
LocationCategory.COLLEGE, LocationCategory.UNIVERSITY -> R.drawable.ic_location_college to R.color.purple
LocationCategory.ICE_CREAM -> R.drawable.ic_location_ice_cream to R.color.pink
LocationCategory.THEATRE -> R.drawable.ic_location_theatre to R.color.purple
LocationCategory.PUBLIC_BUILDING -> R.drawable.ic_location_public_building to R.color.bluegrey
LocationCategory.CINEMA -> R.drawable.ic_location_cinema to R.color.purple
LocationCategory.NIGHTCLUB -> R.drawable.ic_location_nightclub to R.color.purple
LocationCategory.CONVENIENCE -> R.drawable.ic_location_convenience to R.color.lightblue
LocationCategory.CLOTHES -> R.drawable.ic_location_clothes to R.color.pink
LocationCategory.HAIRDRESSER, LocationCategory.BEAUTY -> R.drawable.ic_location_hairdresser to R.color.pink
LocationCategory.CAR_REPAIR -> R.drawable.ic_location_car_repair to R.color.blue
LocationCategory.BAKERY -> R.drawable.ic_location_bakery to R.color.brown
LocationCategory.CAR -> R.drawable.ic_location_car to R.color.blue
LocationCategory.MOBILE_PHONE -> R.drawable.ic_location_mobile_phone to R.color.blue
LocationCategory.FURNITURE -> R.drawable.ic_location_furniture to R.color.brown
LocationCategory.ALCOHOL -> R.drawable.ic_location_alcohol to R.color.amber
LocationCategory.FLORIST -> R.drawable.ic_location_florist to R.color.green
LocationCategory.HARDWARE -> R.drawable.ic_location_hardware to R.color.brown
LocationCategory.ELECTRONICS -> R.drawable.ic_location_electronics to R.color.blue
LocationCategory.SHOES -> R.drawable.ic_location_shoes to R.color.pink
LocationCategory.MALL, LocationCategory.DEPARTMENT_STORE, LocationCategory.CHEMIST -> R.drawable.ic_location_mall to R.color.blue
LocationCategory.OPTICIAN -> R.drawable.ic_location_optician to R.color.blue
LocationCategory.JEWELRY -> R.drawable.ic_location_jewelry to R.color.pink
LocationCategory.GIFT -> R.drawable.ic_location_gift to R.color.pink
LocationCategory.BICYCLE -> R.drawable.ic_location_bicycle to R.color.blue
LocationCategory.LAUNDRY -> R.drawable.ic_location_laundry to R.color.blue
LocationCategory.COMPUTER -> R.drawable.ic_location_computer to R.color.blue
LocationCategory.TOBACCO -> R.drawable.ic_location_tobacco to R.color.amber
LocationCategory.WINE -> R.drawable.ic_location_wine to R.color.amber
LocationCategory.PHOTO -> R.drawable.ic_location_photo to R.color.blue
LocationCategory.BANK -> R.drawable.ic_location_bank to R.color.blue
LocationCategory.SOCCER -> R.drawable.ic_location_soccer to R.color.green
LocationCategory.BASKETBALL -> R.drawable.ic_location_basketball to R.color.orange
LocationCategory.TENNIS -> R.drawable.ic_location_tennis to R.color.orange
LocationCategory.FITNESS, LocationCategory.FITNESS_CENTRE -> R.drawable.ic_location_fitness to R.color.orange
LocationCategory.TRAM_STOP -> R.drawable.ic_location_tram_stop to R.color.blue
LocationCategory.RAILWAY_STATION -> R.drawable.ic_location_railway_stop to R.color.lightblue
LocationCategory.BUS_STATION, LocationCategory.BUS_STOP -> R.drawable.ic_location_bus_station to R.color.blue
LocationCategory.ATM -> R.drawable.ic_location_atm to R.color.green
LocationCategory.ART -> R.drawable.ic_location_art to R.color.deeporange
LocationCategory.KIOSK -> R.drawable.ic_location_kiosk to R.color.bluegrey
LocationCategory.MUSEUM -> R.drawable.ic_location_museum to R.color.deeporange
LocationCategory.PARCEL_LOCKER -> R.drawable.ic_location_parcel_locker to R.color.bluegrey
LocationCategory.TRAVEL_AGENCY -> R.drawable.ic_location_travel_agency to R.color.lightblue
else -> R.drawable.ic_location_place to R.color.bluegrey
null -> Icons.Rounded.Place to R.color.bluegrey
}
return StaticLauncherIcon(
foregroundLayer = TintedIconLayer(
icon = ContextCompat.getDrawable(context, resId)!!,
scale = 0.5f,
foregroundLayer = VectorLayer(
vector = vector,
color = ContextCompat.getColor(context, bgColor)
),
backgroundLayer = ColorLayer(ContextCompat.getColor(context, bgColor))
)
}
fun toAndroidLocation(): AndroidLocation {
val location = AndroidLocation("KvaesitsoLocationProvider")
location.latitude = latitude
location.longitude = longitude
return location
}
fun toAndroidLocation(): AndroidLocation =
AndroidLocation("KvaesitsoLocationProvider").apply {
this.latitude = this@Location.latitude
this.longitude = this@Location.longitude
}
fun distanceTo(androidLocation: AndroidLocation): Float {
return androidLocation.distanceTo(this.toAndroidLocation())
@ -149,107 +322,25 @@ interface Location : SavableSearchable {
fun distanceTo(otherLocation: Location): Float =
this.distanceTo(otherLocation.toAndroidLocation())
fun distanceTo(
latitude: Double,
longitude: Double,
locationProvider: String = "KvaesitsoLocationProvider"
): Float =
this.distanceTo(AndroidLocation(locationProvider).apply {
this.latitude = latitude
this.longitude = longitude
})
}
// https://taginfo.openstreetmap.org/tags
// 'amenity', 'shop', 'sport' of which the most important
enum class LocationCategory {
RESTAURANT,
FAST_FOOD,
BAR,
CAFE,
HOTEL,
SUPERMARKET,
OTHER,
SCHOOL,
PARKING,
FUEL,
TOILETS,
PHARMACY,
HOSPITAL,
POST_OFFICE,
PUB,
GRAVE_YARD,
DOCTORS,
POLICE,
DENTIST,
LIBRARY,
COLLEGE,
ICE_CREAM,
THEATRE,
PUBLIC_BUILDING,
CINEMA,
NIGHTCLUB,
BIERGARTEN,
CLINIC,
UNIVERSITY,
DEPARTMENT_STORE,
CLOTHES,
CONVENIENCE,
HAIRDRESSER,
CAR_REPAIR,
BEAUTY,
BOOKS,
BAKERY,
CAR,
MOBILE_PHONE,
FURNITURE,
ALCOHOL,
FLORIST,
HARDWARE,
ELECTRONICS,
SHOES,
MALL,
OPTICIAN,
JEWELRY,
GIFT,
BICYCLE,
LAUNDRY,
COMPUTER,
TOBACCO,
WINE,
PHOTO,
COFFEE_SHOP,
BANK,
SOCCER,
BASKETBALL,
TENNIS,
FITNESS,
TRAM_STOP,
RAILWAY_STATION,
RAILWAY_STOP,
BUS_STATION,
ATM,
ART,
KIOSK,
BUS_STOP,
MUSEUM,
PARCEL_LOCKER,
CHEMIST,
TRAVEL_AGENCY,
FITNESS_CENTRE
fun OpeningSchedule.isOpen(date: LocalDateTime = LocalDateTime.now()): Boolean = when (this) {
is OpeningSchedule.TwentyFourSeven -> true
is OpeningSchedule.Hours -> openingHours.any { it.isOpen(date) }
}
data class OpeningHours(
val dayOfWeek: DayOfWeek,
val startTime: LocalTime,
val duration: Duration
) {
fun isOpen(date: LocalDateTime = LocalDateTime.now()): Boolean {
val startTime = date.with(TemporalAdjusters.previousOrSame(dayOfWeek)).with(startTime)
val endTime = startTime.plus(duration)
return date in startTime..<endTime
}
override fun toString(): String = "$dayOfWeek $startTime-${startTime.plus(duration)}"
}
data class OpeningSchedule(
val isTwentyFourSeven: Boolean,
val openingHours: ImmutableList<OpeningHours>
) {
fun isOpen(date: LocalDateTime = LocalDateTime.now()): Boolean {
return isTwentyFourSeven || openingHours.any { it.isOpen(date) }
}
fun OpeningHours.isOpen(date: LocalDateTime = LocalDateTime.now()): Boolean {
val startTime = date.with(TemporalAdjusters.previousOrSame(dayOfWeek)).with(startTime)
val endTime = startTime.plus(duration)
return date in startTime..<endTime
}

View File

@ -8,7 +8,7 @@ package de.mm20.launcher2.search
*/
interface UpdatableSearchable<T : SavableSearchable> {
val timestamp: Long
val updatedSelf: (suspend () -> UpdateResult<T>)?
val updatedSelf: (suspend (SavableSearchable) -> UpdateResult<T>)?
}
sealed class UpdateResult<out T> {
@ -16,3 +16,16 @@ sealed class UpdateResult<out T> {
data class TemporarilyUnavailable<T>(val cause: Throwable? = null) : UpdateResult<T>()
data class PermanentlyUnavailable<T>(val cause: Throwable? = null) : UpdateResult<T>()
}
fun <T>Result<T?>.asUpdateResult(): UpdateResult<T> {
return if (isSuccess) {
val refreshed = getOrNull()
if (refreshed == null) {
UpdateResult.PermanentlyUnavailable()
} else {
UpdateResult.Success(refreshed)
}
} else {
UpdateResult.TemporarilyUnavailable(exceptionOrNull())
}
}

View File

@ -1,5 +1,7 @@
package de.mm20.launcher2.search
import de.mm20.launcher2.search.location.OpeningHours
import de.mm20.launcher2.search.location.OpeningSchedule
import kotlinx.collections.immutable.persistentListOf
import org.junit.Assert
import org.junit.Test

View File

@ -10,7 +10,9 @@ import android.hardware.SensorManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.content.getSystemService
import de.mm20.launcher2.ktx.PI
import kotlinx.coroutines.channels.awaitClose

View File

@ -757,7 +757,7 @@
<string name="preference_default_filter_summary">Dostosuj domyślny filtr wyszukiwania</string>
<string name="preference_filter_bar">Pokaż pasek filtrów</string>
<string name="preference_filter_bar_summary">Pokaż szybkie filtry nad klawiaturą</string>
<string name="location_closes">Zamyka się za</string>
<string name="location_closes">Zamyka się za %1$s</string>
<string name="location_closes_other_day">Zamknięcie %1$s, %2$s</string>
<string name="location_opens">Otwórz o %1$s</string>
<string name="location_opens_other_day">Otwórz %1$s, %2$s</string>

View File

@ -925,6 +925,7 @@
<string name="search_filter_tools">Tools</string>
<string name="search_filter_online">Online results</string>
<string name="search_filter_apps">Apps</string>
<string name="plugin_type_locationsearch">Location search</string>
<string name="preference_default_filter">Default filter</string>
<string name="preference_default_filter_summary">Customize the default filter for searches</string>
<string name="preference_filter_bar">Show filter bar</string>
@ -932,6 +933,8 @@
<string name="preference_customize_filter_bar">Customize filter bar</string>
<string name="preference_customize_filter_bar_summary">Customize which items are included in the filter bar</string>
<string name="filter_settings_network_warning">The current filter enables online results by default. Your search queries might unintentionally be sent to external web services. For privacy reasons, this is not recommended.</string>
<string name="preference_search_osm_locations">OpenStreetMap</string>
<string name="preference_search_osm_locations_summary">Search OpenStreetMap for shops and other places in the local area</string>
<string name="poi_category_restaurant">Restaurant</string>
<string name="poi_category_fast_food">Fast food</string>
<string name="poi_category_bar">Bar</string>
@ -1006,4 +1009,50 @@
<string name="poi_category_chemist">Drug store</string>
<string name="poi_category_travel_agency">Travel agency</string>
<string name="poi_category_fitness_center">Fitness center</string>
<string name="poi_category_church">Church</string>
<string name="poi_category_mosque">Mosque</string>
<string name="poi_category_buddhist_temple">Buddhist temple</string>
<string name="poi_category_hindu_temple">Hindu temple</string>
<string name="poi_category_synagogue">Synagogue</string>
<string name="poi_category_pizza_restaurant">Pizza restaurant</string>
<string name="poi_category_burger_restaurant">Burger restaurant</string>
<string name="poi_category_place_of_worship">Place of worship</string>
<string name="poi_category_chinese_restaurant">Chinese restaurant</string>
<string name="poi_category_japanese_restaurant">Japanese restaurant</string>
<string name="poi_category_kebab_restaurant">Kebab restaurant</string>
<string name="poi_category_asian_restaurant">Asian restaurant</string>
<string name="poi_category_ramen_restaurant">Ramen restaurant</string>
<string name="poi_category_soup_restaurant">Soup restaurant</string>
<string name="poi_category_brunch_restaurant">Brunch restaurant</string>
<string name="poi_category_breakfast_restaurant">Breakfast restaurant</string>
<string name="poi_category_car_wash">Car wash</string>
<string name="poi_category_charging_station">Charging station</string>
<string name="poi_category_motorcycle_rental">Motorcycle rental</string>
<string name="poi_category_gallery">Gallery</string>
<string name="poi_category_amusement_park">Amusement park</string>
<string name="poi_category_concert_hall">Concert hall</string>
<string name="poi_category_stadium">Stadium</string>
<string name="poi_category_casino">Casino</string>
<string name="poi_category_shopping_center">Shopping center</string>
<string name="poi_category_discount_store">Discount store</string>
<string name="poi_category_pet">Pet store</string>
<string name="poi_category_shopping">Shopping</string>
<string name="poi_category_swimming">Swimming pool</string>
<string name="poi_category_martial_arts">Martial arts</string>
<string name="poi_category_golf">Golf</string>
<string name="poi_category_gymnastics">Gymnastics</string>
<string name="poi_category_ice_hockey">Ice hockey</string>
<string name="poi_category_baseball">Baseball</string>
<string name="poi_category_american_football">American football</string>
<string name="poi_category_handball">Handball</string>
<string name="poi_category_volleyball">Volleyball</string>
<string name="poi_category_skiing">Skiing</string>
<string name="poi_category_cricket">Cricket</string>
<string name="poi_category_park">Park</string>
<string name="poi_category_monument">Monument</string>
<string name="poi_category_government_building">Government building</string>
<string name="poi_category_fire_station">Fire station</string>
<string name="poi_category_courthouse">Courthouse</string>
<string name="poi_category_townhall">Townhall</string>
<string name="preference_search_locations_radius_large_radius_warning">Large search radii can significantly slow down location search.</string>
</resources>

View File

@ -2,9 +2,8 @@ package de.mm20.launcher2.ktx
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.decodeFromString
inline fun <reified T>Json.decodeFromStringOrNull(json: String?): T? {
inline fun <reified T> Json.decodeFromStringOrNull(json: String?): T? {
if (json == null) return null
return try {
decodeFromString(json)

View File

@ -2,19 +2,17 @@ package de.mm20.launcher2.ktx
import android.app.ActivityOptions
import android.app.PendingIntent
import android.content.Context
fun PendingIntent.sendWithBackgroundPermission() {
fun PendingIntent.sendWithBackgroundPermission(context: Context) {
if (isAtLeastApiLevel(34)) {
val options = ActivityOptions.makeBasic()
.setPendingIntentCreatorBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
)
.setPendingIntentBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
)
.toBundle()
send(options)
send(context, 0, null, null, null, null, options)
} else {
send()
send(context, 0, null)
}
}

View File

@ -3,6 +3,7 @@ package de.mm20.launcher2.preferences
import android.content.Context
import de.mm20.launcher2.preferences.migrations.Migration1
import de.mm20.launcher2.preferences.migrations.Migration2
import de.mm20.launcher2.preferences.migrations.Migration3
import de.mm20.launcher2.settings.BaseSettings
internal class LauncherDataStore(
@ -15,6 +16,7 @@ internal class LauncherDataStore(
migrations = listOf(
Migration1(legacyDataStore),
Migration2(),
Migration3(),
),
) {

View File

@ -135,7 +135,9 @@ data class LauncherSettingsData internal constructor(
val weatherProviderSettings: Map<String, ProviderSettings> = emptyMap(),
val weatherImperialUnits: Boolean = false,
@Deprecated("Use locationSearchProviders instead")
val locationSearchEnabled: Boolean = false,
val locationSearchProviders: Set<String> = setOf("openstreetmaps"),
val locationSearchImperialUnits: Boolean = false,
val locationSearchRadius: Int = 1500,
val locationSearchHideUncategorized: Boolean = true,

View File

@ -0,0 +1,24 @@
package de.mm20.launcher2.preferences.migrations
import androidx.datastore.core.DataMigration
import de.mm20.launcher2.preferences.LauncherSettingsData
class Migration3: DataMigration<LauncherSettingsData> {
override suspend fun cleanUp() {
}
override suspend fun shouldMigrate(currentData: LauncherSettingsData): Boolean {
return currentData.schemaVersion < 3
}
override suspend fun migrate(currentData: LauncherSettingsData): LauncherSettingsData {
return currentData.copy(
schemaVersion = 3,
locationSearchProviders = buildSet {
if (currentData.locationSearchEnabled) {
add("openstreetmaps")
}
}
)
}
}

View File

@ -1,16 +1,16 @@
package de.mm20.launcher2.preferences.search
import de.mm20.launcher2.preferences.LauncherDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class LocationSearchSettings internal constructor(
private val launcherDataStore: LauncherDataStore,
) {
val data
get() = launcherDataStore.data.map {
LocationSearchSettingsData(
enabled = it.locationSearchEnabled,
providers = it.locationSearchProviders,
searchRadius = it.locationSearchRadius,
hideUncategorized = it.locationSearchHideUncategorized,
overpassUrl = it.locationSearchOverpassUrl,
@ -22,12 +22,32 @@ class LocationSearchSettings internal constructor(
)
}
val enabled
get() = launcherDataStore.data.map { it.locationSearchEnabled }
val enabledProviders: Flow<Set<String>>
get() = launcherDataStore.data.map { it.locationSearchProviders }
fun setEnabled(enabled: Boolean) {
val osmLocations
get() = launcherDataStore.data.map { it.locationSearchProviders.contains("openstreetmaps") }
fun setOsmLocations(osmLocations: Boolean) {
launcherDataStore.update {
it.copy(locationSearchEnabled = enabled)
if (osmLocations) {
it.copy(locationSearchProviders = it.locationSearchProviders + "openstreetmaps")
} else {
it.copy(locationSearchProviders = it.locationSearchProviders - "openstreetmaps")
}
}
}
val enabledPlugins: Flow<Set<String>>
get() = launcherDataStore.data.map { it.locationSearchProviders - "openstreetmaps" }
fun setPluginEnabled(authority: String, enabled: Boolean) {
launcherDataStore.update {
if (enabled) {
it.copy(locationSearchProviders = it.locationSearchProviders + authority)
} else {
it.copy(locationSearchProviders = it.locationSearchProviders - authority)
}
}
}
@ -76,15 +96,6 @@ class LocationSearchSettings internal constructor(
}
}
val showPositionOnMap
get() = launcherDataStore.data.map { it.locationSearchShowPositionOnMap }
fun setShowPositionOnMap(showPositionOnMap: Boolean) {
launcherDataStore.update {
it.copy(locationSearchShowPositionOnMap = showPositionOnMap)
}
}
val themeMap
get() = launcherDataStore.data.map { it.locationSearchThemeMap }
@ -111,7 +122,7 @@ class LocationSearchSettings internal constructor(
}
data class LocationSearchSettingsData(
val enabled: Boolean = false,
val providers: Set<String> = setOf("openstreetmaps"),
val searchRadius: Int = 1500,
val hideUncategorized: Boolean = true,
val overpassUrl: String = LocationSearchSettings.DefaultOverpassUrl,

View File

@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.serialization)
alias(libs.plugins.dokka)
`maven-publish`
signing
@ -46,6 +47,11 @@ android {
}
}
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.bundles.kotlin)
}
tasks.dokkaHtml {
outputDirectory.set(layout.buildDirectory.dir("dokka"))
}
@ -95,12 +101,16 @@ publishing {
}
repositories {
mavenLocal()
maven {
name = "GitHubPackages"
url = uri("https://maven.pkg.github.com/MM2-0/Kvaesitso")
credentials {
username = project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME")
password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN")
val ghUser = project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME")
if (ghUser == "MM2-0") {
maven {
name = "GitHubPackages"
url = uri("https://maven.pkg.github.com/MM2-0/Kvaesitso")
credentials {
username =
project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME")
password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN")
}
}
}
}

View File

@ -3,4 +3,5 @@ package de.mm20.launcher2.plugin
enum class PluginType {
FileSearch,
Weather,
LocationSearch,
}

View File

@ -1,9 +1,6 @@
package de.mm20.launcher2.plugin.config
import android.os.Bundle
import de.mm20.launcher2.plugin.config.StorageStrategy
data class SearchPluginConfig(
data class QueryPluginConfig(
/**
* Strategy to store items from this provider in the launcher database
* @see [StorageStrategy]

View File

@ -1,9 +1,13 @@
package de.mm20.launcher2.plugin.config
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Defines how the launcher should store search results from a plugin (i.e. when the search result is
* added to favorites).
*/
@Serializable
enum class StorageStrategy {
/**
* The launcher only stores the ID and the plugin provider for the search result. To restore the
@ -13,6 +17,7 @@ enum class StorageStrategy {
* particular, the plugin provider must be able to restore a search result without any network
* requests.
*/
@SerialName("ref")
StoreReference,
/**
@ -21,13 +26,6 @@ enum class StorageStrategy {
* Use this strategy if your plugin needs to perform network requests to retrieve search
* results and if you don't want to implement a cache for search results.
*/
@SerialName("copy")
StoreCopy,
/**
* The launcher stores all relevant information in its own internal database, like [StoreCopy].
* A fresh copy is fetched from the plugin provider when the user opens the search result's
* detail view. This allows the plugin provider to update the search result at a later point in
* time, without the time constraints of [StoreReference].
*/
Deferred,
}

View File

@ -0,0 +1,271 @@
package de.mm20.launcher2.plugin.contracts
import android.database.Cursor
import android.os.Bundle
import android.util.Log
import de.mm20.launcher2.plugin.data.RowBuilderScope
import de.mm20.launcher2.serialization.Json
import kotlinx.serialization.encodeToString
abstract class Columns {
internal val columns = mutableSetOf<String>()
}
@Suppress("UNCHECKED_CAST")
internal inline fun <reified T : Any> Columns.column(name: String): Column<T> {
val column = when (T::class) {
Int::class -> IntColumn(name)
Long::class -> LongColumn(name)
String::class -> StringColumn(name)
Short::class -> ShortColumn(name)
Boolean::class -> BooleanColumn(name)
Double::class -> DoubleColumn(name)
Float::class -> FloatColumn(name)
ByteArray::class -> BlobColumn(name)
else -> {
if (T::class.java.isEnum) {
SerializableColumn(
name,
{ it.toString() },
{ v -> T::class.java.enumConstants?.find { it.toString() == v } }
)
} else {
SerializableColumn(
name,
{ Json.Lenient.encodeToString(it) },
{ Json.Lenient.decodeFromString(it) }
)
}
}
} as Column<T>
columns.add(name)
return column
}
sealed interface Column<T> {
val name: String
fun Cursor.readAtIndex(index: Int): T?
fun RowBuilderScope.toCursorValue(value: T?): Any? {
return value
}
fun Bundle.put(value: T?)
fun Bundle.get(): T?
}
internal class IntColumn(
override val name: String
) : Column<Int> {
override fun Cursor.readAtIndex(index: Int): Int {
return getInt(index)
}
override fun Bundle.put(value: Int?) {
if (value == null) {
remove(name)
} else {
putInt(name, value)
}
}
override fun Bundle.get(): Int {
return getInt(name)
}
}
internal class LongColumn(
override val name: String
) : Column<Long> {
override fun Cursor.readAtIndex(index: Int): Long {
return getLong(index)
}
override fun Bundle.put(value: Long?) {
if (value == null) {
remove(name)
} else {
putLong(name, value)
}
}
override fun Bundle.get(): Long {
return getLong(name)
}
}
internal class DoubleColumn(
override val name: String
) : Column<Double> {
override fun Cursor.readAtIndex(index: Int): Double {
return getDouble(index)
}
override fun Bundle.put(value: Double?) {
if (value == null) {
remove(name)
} else {
putDouble(name, value)
}
}
override fun Bundle.get(): Double {
return getDouble(name)
}
}
internal class FloatColumn(
override val name: String
) : Column<Float> {
override fun Cursor.readAtIndex(index: Int): Float {
return getFloat(index)
}
override fun Bundle.put(value: Float?) {
if (value == null) {
remove(name)
} else {
putFloat(name, value)
}
}
override fun Bundle.get(): Float {
return getFloat(name)
}
}
internal class StringColumn(
override val name: String
) : Column<String> {
override fun Cursor.readAtIndex(index: Int): String? {
return getString(index)
}
override fun Bundle.put(value: String?) {
if (value == null) {
remove(name)
} else {
putString(name, value)
}
}
override fun Bundle.get(): String? {
return getString(name)
}
}
internal class ShortColumn(
override val name: String
) : Column<Short> {
override fun Cursor.readAtIndex(index: Int): Short {
return getShort(index)
}
override fun Bundle.put(value: Short?) {
if (value == null) {
remove(name)
} else {
putShort(name, value)
}
}
override fun Bundle.get(): Short {
return getShort(name)
}
}
internal class BooleanColumn(
override val name: String
) : Column<Boolean> {
override fun Cursor.readAtIndex(index: Int): Boolean {
return getInt(index) != 0
}
override fun RowBuilderScope.toCursorValue(value: Boolean?): Any? {
value ?: return null
return if (value) 1 else 0
}
override fun Bundle.put(value: Boolean?) {
if (value == null) {
remove(name)
} else {
putBoolean(name, value)
}
}
override fun Bundle.get(): Boolean? {
return getBoolean(name)
}
}
internal class BlobColumn(
override val name: String
) : Column<ByteArray> {
override fun Cursor.readAtIndex(index: Int): ByteArray? {
return getBlob(index)
}
override fun Bundle.put(value: ByteArray?) {
if (value == null) {
remove(name)
} else {
putByteArray(name, value)
}
}
override fun Bundle.get(): ByteArray? {
return getByteArray(name)
}
}
internal class SerializableColumn<T>(
override val name: String,
val serialize: (T) -> String,
val deserialize: (String) -> T
) : Column<T> {
override fun Cursor.readAtIndex(index: Int): T? {
val string = getString(index)
try {
return deserialize(string)
} catch (e: Exception) {
Log.e("MM20", "Failed to read column value", e)
return null
}
}
override fun RowBuilderScope.toCursorValue(value: T?): Any? {
return try {
serialize(value ?: return null)
} catch (e: Exception) {
Log.e("MM20", "Failed to write column value", e)
null
}
}
override fun Bundle.put(value: T?) {
val serialized = try {
value?.let(serialize)
} catch (e: Exception) {
Log.e("MM20", "Failed to serialize column value", e)
null
}
if (serialized == null) {
remove(name)
} else {
putString(name, serialized)
}
}
override fun Bundle.get(): T? {
val string = getString(name) ?: return null
return try {
deserialize(string)
} catch (e: Exception) {
Log.e("MM20", "Failed to deserialize column value", e)
null
}
}
}

View File

@ -2,64 +2,63 @@ package de.mm20.launcher2.plugin.contracts
abstract class FilePluginContract {
object FileColumns {
object FileColumns: Columns() {
/**
* The unique ID of the file.
* Type: String
*/
const val Id = "id"
val Id = column<String>("id")
/**
* The display name of the file.
* Type: String
*/
const val DisplayName = "display_name"
val DisplayName = column<String>("display_name")
/**
* The MIME type of the file. This is used to determine how to open the file. Make sure that
* this is either a common MIME type or that your app can handle this MIME type.
* Type: String?
*/
const val MimeType = "mime_type"
val MimeType = column<String>("mime_type")
/**
* The size of the file in bytes.
* Type: Long?
*/
const val Size = "size"
val Size = column<Long>("size")
/**
* The display path of the file.
* Type: String?
*/
const val Path = "path"
val Path = column<String>("path")
/**
* The URI to view the file.
* Type: String?
*/
const val ContentUri = "uri"
val ContentUri = column<String>("uri")
const val ThumbnailUri = "thumbnail_uri"
val ThumbnailUri = column<String>("thumbnail_uri")
/**
* Whether the file is a directory.
* Type: Int
*/
const val IsDirectory = "is_directory"
val IsDirectory = column<Boolean>("is_directory")
const val Owner = "owner"
val Owner = column<String>("owner")
const val MetaTitle = "meta_title"
const val MetaArtist = "meta_artist"
const val MetaAlbum = "meta_album"
const val MetaDuration = "meta_duration"
const val MetaYear = "meta_year"
const val MetaWidth = "meta_width"
const val MetaHeight = "meta_height"
const val MetaLocation = "meta_location"
const val MetaAppName = "meta_app_name"
const val MetaAppPackageName = "meta_app_package_name"
const val MetaAppMinSdkVersion = "meta_app_min_sdk_version"
val MetaTitle = column<String>("meta_title")
val MetaArtist = column<String>("meta_artist")
val MetaAlbum = column<String>("meta_album")
val MetaDuration = column<Long>("meta_duration")
val MetaYear = column<Int>("meta_year")
val MetaWidth = column<Int>("meta_width")
val MetaHeight = column<Int>("meta_height")
val MetaLocation = column<String>("meta_location")
val MetaAppName = column<String>("meta_app_name")
val MetaAppPackageName = column<String>("meta_app_package_name")
}
}

View File

@ -0,0 +1,143 @@
package de.mm20.launcher2.plugin.contracts
import de.mm20.launcher2.search.location.Address
import de.mm20.launcher2.search.location.Attribution
import de.mm20.launcher2.search.location.Departure
import de.mm20.launcher2.search.location.LocationIcon
import de.mm20.launcher2.search.location.OpeningSchedule
abstract class LocationPluginContract {
object Paths {
const val Search = "search"
const val Get = "get"
}
object Params {
/**
* Search query.
* Type: String
*/
const val Query = "query"
/**
* Latitude of user's current location in degrees.
* Type: Double?
*/
const val UserLatitude = "user_latitude"
/**
* Longitude of user's current location in degrees.
* Type: Double?
*/
const val UserLongitude = "user_longitude"
/**
* Search radius in meters.
* Type: Long
*/
const val SearchRadius = "search_radius"
/**
* Whether to allow network requests.
* Type: Boolean
*/
const val AllowNetwork = "network"
}
object GetParams {
/**
* Unique identifier of location to look up.
* Type: String
*/
const val Id = "id"
}
object LocationColumns : Columns() {
/**
* Unique identifier of location.
* Type: String
*/
val Id = column<String>("id")
/**
* Display name of location.
* Type: String
*/
val Label = column<String>("label")
/**
* Latitude of location in degrees.
* Type: Double
*/
val Latitude = column<Double>("latitude")
/**
* Longitude of location in degrees.
* Type: Double
*/
val Longitude = column<Double>("longitude")
/**
* Url for users to report / fix incorrect data.
* Type: String?
*/
val FixMeUrl = column<String>("fix_me_url")
/**
* Icon of location.
* Type: String? (enum LocationIcon)
*/
val Icon = column<LocationIcon>("icon")
/**
* Location category.
* Type: String?
*/
val Category = column<String>("category")
/**
* Street name of location.
* Type: String? (JSON)
*/
val Address = column<Address>("address")
/**
* Opening schedule of location, encoded as JSON.
* Type: String? (JSON)
*/
val OpeningSchedule = column<OpeningSchedule>("opening_schedule")
/**
* Website URL of location.
* Type: String?
*/
val WebsiteUrl = column<String>("website_url")
/**
* Phone number of location.
* Type: String?
*/
val PhoneNumber = column<String>("phone_number")
val EmailAddress = column<String>("email_address")
/**
* User rating of location, from 0.0 (worst) to 1.0 (best)
* Type: Float?
*/
val UserRating = column<Float>("user_rating")
val UserRatingCount = column<Int>("user_rating_count")
/**
* Public transport departures originating from this location, encoded as JSON.
* Type: String? (JSON)
*/
val Departures = column<List<Departure>>("departures")
/**
* Attribution information for location data.
* Type: String? (JSON)
*/
val Attribution = column<Attribution>("attribution")
}
}

View File

@ -4,7 +4,21 @@ abstract class SearchPluginContract {
object Paths {
const val Search = "search"
const val Root = "root"
const val QueryParam = "query"
const val AllowNetworkParam = "network"
const val Refresh = "refresh"
@Deprecated("Use Paths.Query instead")
const val QueryParam = Params.Query
@Deprecated("Use Params.AllowNetwork instead")
const val AllowNetworkParam = Params.AllowNetwork
@Deprecated("Use Params.Lang instead")
const val LangParam = Params.Lang
}
object Params {
const val AllowNetwork = "network"
const val Lang = "lang"
const val UpdatedAt = "updatedAt"
const val Query = "query"
}
object Extras {
const val NotUpdated = "notUpdated"
}
}

View File

@ -1,5 +1,7 @@
package de.mm20.launcher2.plugin.contracts
import de.mm20.launcher2.weather.WeatherIcon
object WeatherPluginContract {
object Paths {
const val Forecasts = "forecasts"
@ -14,25 +16,25 @@ object WeatherPluginContract {
const val Language = "lang"
}
object ForecastColumns {
const val Timestamp = "timestamp"
const val CreatedAt = "created_at"
const val Temperature = "temperature"
const val TemperatureMin = "temperature_min"
const val TemperatureMax = "temperature_max"
const val Pressure = "pressure"
const val Humidity = "humidity"
const val WindSpeed = "wind_speed"
const val WindDirection = "wind_direction"
const val Precipitation = "precipitation"
const val RainProbability = "rain_probability"
const val Clouds = "clouds"
const val Location = "location"
const val Provider = "provider"
const val ProviderUrl = "provider_url"
const val Night = "night"
const val Icon = "icon"
const val Condition = "condition"
object ForecastColumns : Columns() {
val Timestamp = column<Long>("timestamp")
val CreatedAt = column<Long>("created_at")
val Temperature = column<Double>("temperature")
val TemperatureMin = column<Double>("temperature_min")
val TemperatureMax = column<Double>("temperature_max")
val Pressure = column<Double>("pressure")
val Humidity = column<Int>("humidity")
val WindSpeed = column<Double>("wind_speed")
val WindDirection = column<Double>("wind_direction")
val Precipitation = column<Double>("precipitation")
val RainProbability = column<Int>("rain_probability")
val Clouds = column<Int>("clouds")
val Location = column<String>("location")
val Provider = column<String>("provider")
val ProviderUrl = column<String>("provider_url")
val Night = column<Boolean>("night")
val Icon = column<WeatherIcon>("icon")
val Condition = column<String>("condition")
}
object LocationParams {
@ -40,10 +42,10 @@ object WeatherPluginContract {
const val Language = "lang"
}
object LocationColumns {
const val Id = "id"
const val Lat = "lat"
const val Lon = "lon"
const val Name = "name"
object LocationColumns : Columns() {
val Id = column<String>("id")
val Lat = column<Double>("lat")
val Lon = column<Double>("lon")
val Name = column<String>("name")
}
}

View File

@ -0,0 +1,17 @@
package de.mm20.launcher2.plugin.data
import android.os.Bundle
import de.mm20.launcher2.plugin.contracts.Column
operator fun <T> Bundle.get(column: Column<T>): T? {
return with(column) {
if (!containsKey(column.name)) return null
get()
}
}
operator fun <T> Bundle.set(column: Column<T>, value: T?) {
with(column) {
put(value)
}
}

View File

@ -0,0 +1,77 @@
package de.mm20.launcher2.plugin.data
import android.database.Cursor
import android.database.MatrixCursor
import android.util.Log
import de.mm20.launcher2.plugin.contracts.Column
import de.mm20.launcher2.plugin.contracts.Columns
interface ColumnsScope {
operator fun <T : Any> Cursor.get(column: Column<T>): T?
operator fun Cursor.contains(column: Column<*>): Boolean
}
internal class ColumnsScopeImpl(
private val columnIndices: Map<String, Int>,
private val cursor: Cursor
) : ColumnsScope {
override operator fun <T : Any> Cursor.get(column: Column<T>): T? {
val index = columnIndices[column.name] ?: return null
if (index == -1) return null
if (cursor.isNull(index)) return null
try {
return with(column) { cursor.readAtIndex(index) }
} catch (e: Exception) {
Log.e("MM20", "Failed to get column value", e)
}
return null
}
override fun Cursor.contains(column: Column<*>): Boolean {
return columnIndices.containsKey(column.name)
}
}
fun Cursor.withColumns(columns: Columns, block: ColumnsScope.() -> Unit) {
val scope = ColumnsScopeImpl(
columns.columns.associateWith { name -> getColumnIndex(name) },
this
)
scope.block()
}
fun <T> buildCursor(
columns: Columns,
data: List<T>,
rowBuilder: RowBuilderScope.(item: T) -> Unit
): Cursor {
val cursor = MatrixCursor(columns.columns.toTypedArray(), data.size)
val values = Array<Any?>(columns.columns.size) { null }
val scope = RowBuilderScopeImpl(
columnIndices = columns.columns.withIndex().associate { (index, name) -> name to index },
values = values
)
repeat(data.size) {
values.fill(null)
scope.rowBuilder(data[it])
cursor.addRow(scope.values)
}
return cursor
}
interface RowBuilderScope {
fun <T : Any> put(column: Column<T>, value: T?)
}
internal class RowBuilderScopeImpl(
private val columnIndices: Map<String, Int>,
internal val values: Array<Any?>
) : RowBuilderScope {
override fun <T : Any> put(column: Column<T>, value: T?) {
value ?: return
val index = columnIndices[column.name] ?: return
values[index] = with(column) { toCursorValue(value) }
}
}

View File

@ -0,0 +1,35 @@
package de.mm20.launcher2.search.location
import kotlinx.serialization.Serializable
@Serializable
data class Address(
/**
* Address line 1 (e.g. street name and number)
*/
val address: String? = null,
/**
* Address line 2 (e.g. apartment number)
*/
val address2: String? = null,
/**
* Address line 3 (e.g. additional information)
*/
val address3: String? = null,
/**
* City
*/
val city: String? = null,
/**
* State, province, or region
*/
val state: String? = null,
/**
* Postal code
*/
val postalCode: String? = null,
/**
* Country
*/
val country: String? = null,
)

View File

@ -0,0 +1,13 @@
package de.mm20.launcher2.search.location
import android.net.Uri
import de.mm20.launcher2.serialization.UriSerializer
import kotlinx.serialization.Serializable
@Serializable
data class Attribution(
val text: String? = null,
@Serializable(with = UriSerializer::class)
val iconUrl: Uri? = null,
val url: String? = null,
)

View File

@ -0,0 +1,47 @@
package de.mm20.launcher2.search.location
import android.graphics.Color
import de.mm20.launcher2.serialization.ColorSerializer
import de.mm20.launcher2.serialization.DurationSerializer
import de.mm20.launcher2.serialization.ZonedDateTimeSerializer
import kotlinx.serialization.Serializable
import java.time.Duration
import java.time.ZonedDateTime
@Serializable
data class Departure(
/**
* The scheduled time of the departure.
*/
@Serializable(with = ZonedDateTimeSerializer::class)
val time: ZonedDateTime,
/**
* The delay of the departure.
* `Duration.ZERO` if the departure is on time,
* `null` if no real-time data is available.
*/
@Serializable(with = DurationSerializer::class)
val delay: Duration?,
/**
* Name of the line (i.e. "11", "U2", "S1").
*/
val line: String,
val lastStop: String?,
val type: LineType? = null,
@Serializable(with = ColorSerializer::class)
val lineColor: Color?,
)
enum class LineType {
Bus,
Tram,
Subway,
CommuterTrain,
RegionalTrain,
Train,
HighSpeedTrain,
Boat,
Monorail,
CableCar,
Airplane,
}

View File

@ -0,0 +1,139 @@
package de.mm20.launcher2.search.location
enum class LocationIcon {
Car,
CarRental,
CarRepair,
CarWash,
ChargingStation,
GasStation,
GenericTransit,
Parking,
Bus,
Tram,
Train,
Subway,
CableCar,
Airport,
Boat,
Taxi,
Moped,
Bike,
Motorcycle,
ElectricScooter,
ArtGallery,
Museum,
Theater,
MovieTheater,
AmusementPark,
NightClub,
ConcertHall,
Stadium,
Casino,
Circus,
Hotel,
Restaurant,
Cafe,
FastFood,
Pizza,
Burger,
Kebab,
IceCream,
Ramen,
Soup,
Bar,
Brunch,
Breakfast,
Pub,
JapaneseCuisine,
AsianCuisine,
Shopping,
Florist,
Kiosk,
FurnitureStore,
CellPhoneStore,
BookStore,
ClothingStore,
ConvenienceStore,
DiscountStore,
JewelryStore,
LiquorStore,
PetStore,
ShoppingMall,
Supermarket,
Bakery,
Optician,
Pharmacy,
HairSalon,
Laundromat,
Sports,
FitnessCenter,
Soccer,
Basketball,
Tennis,
Golf,
Baseball,
AmericanFootball,
Hiking,
Swimming,
Surfing,
Motorsports,
Handball,
Volleyball,
Skiing,
Kayaking,
Skateboarding,
Cricket,
MartialArts,
NordicWalking,
Paragliding,
Gymnastics,
Snowboarding,
Hockey,
Rugby,
Bank,
Atm,
Physician,
Dentist,
Hospital,
Clinic,
Park,
Forest,
Monument,
Church,
Mosque,
Synagogue,
BuddhistTemple,
HinduTemple,
Candle,
GovernmentBuilding,
Police,
FireDepartment,
Courthouse,
PostOffice,
Library,
School,
University,
PublicBathroom;
companion object {
fun valueOfOrNull(string: String): LocationIcon? {
return try {
valueOf(string)
} catch (e: IllegalArgumentException) {
null
}
}
}
}

View File

@ -0,0 +1,30 @@
package de.mm20.launcher2.search.location
import de.mm20.launcher2.serialization.DurationSerializer
import de.mm20.launcher2.serialization.LocalTimeSerializer
import de.mm20.launcher2.serialization.OpeningScheduleSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import java.time.DayOfWeek
import java.time.Duration
import java.time.LocalTime
@Serializable
data class OpeningHours(
@JsonNames("day")
val dayOfWeek: DayOfWeek,
@JsonNames("openingTime")
@Serializable(with = LocalTimeSerializer::class)
val startTime: LocalTime,
@Serializable(with = DurationSerializer::class)
val duration: Duration
) {
override fun toString(): String = "$dayOfWeek $startTime-${startTime.plus(duration)}"
}
@Serializable(with = OpeningScheduleSerializer::class)
sealed interface OpeningSchedule {
data object TwentyFourSeven : OpeningSchedule
@Serializable
data class Hours(@Serializable val openingHours: List<OpeningHours>) : OpeningSchedule
}

View File

@ -0,0 +1,21 @@
package de.mm20.launcher2.serialization
import android.graphics.Color
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object ColorSerializer: KSerializer<Color> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(javaClass.canonicalName!!, PrimitiveKind.INT)
override fun deserialize(decoder: Decoder): Color {
return Color.valueOf(decoder.decodeInt())
}
override fun serialize(encoder: Encoder, value: Color) {
encoder.encodeInt(value.toArgb())
}
}

View File

@ -0,0 +1,22 @@
package de.mm20.launcher2.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.DayOfWeek
object DayOfWeekSerializer : KSerializer<DayOfWeek> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor(javaClass.canonicalName!!, PrimitiveKind.INT)
override fun deserialize(decoder: Decoder): DayOfWeek {
return DayOfWeek.of(decoder.decodeInt())
}
override fun serialize(encoder: Encoder, value: DayOfWeek) {
encoder.encodeInt(value.value)
}
}

View File

@ -0,0 +1,20 @@
package de.mm20.launcher2.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.Duration
object DurationSerializer: KSerializer<Duration> {
override val descriptor = PrimitiveSerialDescriptor(javaClass.canonicalName!!, PrimitiveKind.LONG)
override fun serialize(encoder: Encoder, value: Duration) {
encoder.encodeLong(value.toMillis())
}
override fun deserialize(decoder: Decoder): Duration {
return Duration.ofMillis(decoder.decodeLong())
}
}

View File

@ -0,0 +1,17 @@
package de.mm20.launcher2.serialization
/**
* Default Json serializer configurations
*/
object Json {
/**
* A Json serializer configuration that aims to be as forgiving as possible.
* Suitable for external data sources, and legacy data that may not be fully compliant with the current schema.
*/
val Lenient = kotlinx.serialization.json.Json {
ignoreUnknownKeys = true
explicitNulls = false
isLenient = true
coerceInputValues = true
}
}

View File

@ -0,0 +1,21 @@
package de.mm20.launcher2.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.LocalTime
object LocalTimeSerializer: KSerializer<LocalTime> {
override val descriptor = PrimitiveSerialDescriptor(javaClass.canonicalName!!, PrimitiveKind.LONG)
override fun serialize(encoder: Encoder, value: LocalTime) {
// We use millis here for backwards compatibility in LocationSerializer
encoder.encodeLong(value.toNanoOfDay() / 1000000L)
}
override fun deserialize(decoder: Decoder): LocalTime {
return LocalTime.ofNanoOfDay(decoder.decodeLong() * 1000000L)
}
}

View File

@ -0,0 +1,55 @@
package de.mm20.launcher2.serialization
import android.util.Log
import de.mm20.launcher2.search.location.OpeningSchedule
import kotlinx.serialization.KSerializer
import kotlinx.serialization.PolymorphicSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
object OpeningScheduleSerializer : KSerializer<OpeningSchedule> {
override val descriptor = PolymorphicSerializer(OpeningSchedule::class).descriptor
override fun serialize(encoder: Encoder, value: OpeningSchedule) {
when (value) {
is OpeningSchedule.TwentyFourSeven -> encoder.encodeString("twentyFourSeven")
is OpeningSchedule.Hours -> encoder.encodeSerializableValue(
OpeningSchedule.Hours.serializer(),
value
)
}
}
override fun deserialize(decoder: Decoder): OpeningSchedule {
decoder as JsonDecoder
val jsonElement = decoder.decodeJsonElement()
if ((jsonElement as? JsonPrimitive)?.content == "twentyFourSeven") {
return OpeningSchedule.TwentyFourSeven
}
if ("openingHours" in jsonElement.jsonObject.keys &&
/* backwards compatibility */ !jsonElement.jsonObject["openingHours"]!!.jsonArray.isEmpty()
) {
return try {
decoder.json.decodeFromJsonElement<OpeningSchedule.Hours>(
jsonElement
)
} catch (e: SerializationException) {
Log.e("MM20", "Failed to deserialize OpeningSchedule.Hours", e)
return OpeningSchedule.Hours(openingHours = emptyList())
}
}
// fallback in case we receive data which was serialized before introducing OpeningScheduleSerializer.
// here openingHours is empty, in which case it has to be 24/7, else it would indicate corrupted data.
return OpeningSchedule.TwentyFourSeven
}
}

View File

@ -0,0 +1,21 @@
package de.mm20.launcher2.serialization
import android.net.Uri
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object UriSerializer : KSerializer<Uri> {
override val descriptor = PrimitiveSerialDescriptor(javaClass.canonicalName!!, PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Uri) {
// We use millis here for backwards compatibility in LocationSerializer
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): Uri {
return Uri.parse(decoder.decodeString())
}
}

View File

@ -0,0 +1,27 @@
package de.mm20.launcher2.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
object ZonedDateTimeSerializer : KSerializer<ZonedDateTime> {
override val descriptor = PrimitiveSerialDescriptor(javaClass.canonicalName!!, PrimitiveKind.LONG)
override fun serialize(encoder: Encoder, value: ZonedDateTime) {
encoder.encodeLong(
value.toEpochSecond()
)
}
override fun deserialize(decoder: Decoder): ZonedDateTime {
return ZonedDateTime.ofInstant(
Instant.ofEpochSecond(decoder.decodeLong()),
ZoneId.systemDefault()
)
}
}

View File

@ -0,0 +1,25 @@
package de.mm20.launcher2.weather
enum class WeatherIcon {
Unknown,
Clear,
Cloudy,
Cold,
Drizzle,
Haze,
Fog,
Hail,
HeavyThunderstorm,
HeavyThunderstormWithRain,
Hot,
MostlyCloudy,
PartlyCloudy,
Showers,
Sleet,
Snow,
Storm,
Thunderstorm,
ThunderstormWithRain,
Wind,
BrokenClouds,
}

View File

@ -24,6 +24,7 @@ import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.UpdateResult
import de.mm20.launcher2.search.asUpdateResult
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.flow.firstOrNull
@ -312,8 +313,7 @@ internal class OwncloudFileDeserializer : SearchableDeserializer {
}
}
internal class PluginFileSerializer(
) : SearchableSerializer {
internal class PluginFileSerializer : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String? {
searchable as PluginFile
if (searchable.storageStrategy == StorageStrategy.StoreReference) {
@ -333,7 +333,8 @@ internal class PluginFileSerializer(
"thumbnailUri" to searchable.thumbnailUri?.toString(),
"isDirectory" to searchable.isDirectory,
"authority" to searchable.authority,
"strategy" to if (searchable.storageStrategy == StorageStrategy.StoreCopy) "copy" else "deferred",
"timestamp" to searchable.timestamp,
"strategy" to "copy",
).toString()
}
}
@ -348,84 +349,46 @@ internal class PluginFileDeserializer(
private val pluginRepository: PluginRepository,
) : SearchableDeserializer {
override suspend fun deserialize(serialized: String): SavableSearchable? {
val jsonObject = JSONObject(serialized)
val obj = JSONObject(serialized)
return when (jsonObject.optString("strategy", "copy")) {
"ref" -> {
getByRef(jsonObject)
}
"deferred" -> {
getDeferred(jsonObject)
}
else -> {
getByCopy(jsonObject)
}
}
}
private suspend fun getByRef(obj: JSONObject): File? {
val authority = obj.getString("authority")
val id = obj.getString("id")
val plugin = pluginRepository.get(authority).firstOrNull() ?: return null
if (!plugin.enabled) return null
val provider = PluginFileProvider(context, authority)
try {
val authority = obj.getString("authority")
val id = obj.getString("id")
val plugin = pluginRepository.get(authority).firstOrNull() ?: return null
if (!plugin.enabled) return null
val provider = PluginFileProvider(context, authority)
return provider.getFile(id)
} catch (e: Exception) {
CrashReporter.logException(e)
return null
}
}
return when (obj.optString("strategy", "copy")) {
"ref" -> {
provider.get(id).getOrNull()
}
private fun getDeferred(obj: JSONObject): File? {
val cached = getByCopy(obj) ?: return null
val timestamp = obj.optLong("timestamp", 0L)
return DeferredFile(
cachedFile = cached as PluginFile,
timestamp = timestamp,
updatedSelf = {
val plugin = pluginRepository.get(cached.authority).firstOrNull()
?: return@DeferredFile UpdateResult.PermanentlyUnavailable()
if (!plugin.enabled) return@DeferredFile UpdateResult.PermanentlyUnavailable()
val provider = PluginFileProvider(context, cached.authority)
try {
val file = provider.getFile(cached.id)
if (file == null) {
UpdateResult.PermanentlyUnavailable()
} else {
UpdateResult.Success(file)
}
} catch (e: Exception) {
CrashReporter.logException(e)
UpdateResult.TemporarilyUnavailable(e)
else -> {
val uri = obj.getString("uri")
val thumbnailUri = obj.optString("thumbnailUri")
val timestamp = obj.optLong("timestamp", 0L)
val file = PluginFile(
id = obj.getString("id"),
path = obj.getString("path"),
mimeType = obj.getString("mimeType"),
size = obj.optLong("size", 0L),
metaData = persistentMapOf(),
label = obj.getString("label"),
uri = Uri.parse(uri),
thumbnailUri = thumbnailUri.takeIf { it.isNotEmpty() }?.let { Uri.parse(it) },
storageStrategy = StorageStrategy.StoreCopy,
isDirectory = obj.optBoolean("isDirectory", false),
authority = obj.getString("authority"),
timestamp = timestamp,
updatedSelf = {
if (it !is PluginFile) UpdateResult.TemporarilyUnavailable()
else provider.refresh(it, timestamp).asUpdateResult()
}
)
return file
}
}
)
}
private fun getByCopy(obj: JSONObject): File? {
try {
val uri = obj.getString("uri")
val thumbnailUri = obj.optString("thumbnailUri")
return PluginFile(
id = obj.getString("id"),
path = obj.getString("path"),
mimeType = obj.getString("mimeType"),
size = obj.optLong("size", 0L),
metaData = persistentMapOf(),
label = obj.getString("label"),
uri = Uri.parse(uri),
thumbnailUri = thumbnailUri.takeIf { it.isNotEmpty() }?.let { Uri.parse(it) },
storageStrategy = StorageStrategy.StoreCopy,
isDirectory = obj.optBoolean("isDirectory", false),
authority = obj.getString("authority"),
)
} catch (e: JSONException) {
CrashReporter.logException(e)
return null
}
}
}

View File

@ -1,11 +1,12 @@
package de.mm20.launcher2.files.providers
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.UpdatableSearchable
import de.mm20.launcher2.search.UpdateResult
class DeferredFile(
cachedFile: File,
override val timestamp: Long,
override var updatedSelf: (suspend () -> UpdateResult<File>)? = null,
override var updatedSelf: (suspend (SavableSearchable) -> UpdateResult<File>)? = null,
) : File by cachedFile, UpdatableSearchable<File>

View File

@ -18,6 +18,8 @@ import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.FileMetaType
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.UpdatableSearchable
import de.mm20.launcher2.search.UpdateResult
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -35,7 +37,9 @@ data class PluginFile(
val authority: String,
internal val storageStrategy: StorageStrategy,
override val labelOverride: String? = null,
) : File {
override val timestamp: Long,
override val updatedSelf: (suspend (SavableSearchable) -> UpdateResult<File>)?,
) : File, UpdatableSearchable<File> {
override val domain: String = Domain
override val key: String

View File

@ -3,100 +3,35 @@ package de.mm20.launcher2.files.providers
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.CancellationSignal
import android.os.Bundle
import android.text.format.DateUtils
import android.util.Log
import androidx.core.database.getIntOrNull
import androidx.core.database.getLongOrNull
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.plugin.PluginApi
import de.mm20.launcher2.plugin.config.SearchPluginConfig
import de.mm20.launcher2.plugin.contracts.FilePluginContract
import de.mm20.launcher2.plugin.contracts.PluginContract
import de.mm20.launcher2.plugin.QueryPluginApi
import de.mm20.launcher2.plugin.config.QueryPluginConfig
import de.mm20.launcher2.plugin.contracts.FilePluginContract.FileColumns
import de.mm20.launcher2.plugin.contracts.SearchPluginContract
import de.mm20.launcher2.search.File
import de.mm20.launcher2.plugin.data.set
import de.mm20.launcher2.plugin.data.withColumns
import de.mm20.launcher2.search.FileMetaType
import de.mm20.launcher2.search.UpdateResult
import de.mm20.launcher2.search.asUpdateResult
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
class PluginFileProvider(
private val context: Context,
private val pluginAuthority: String,
) : FileProvider {
override suspend fun search(query: String, allowNetwork: Boolean): List<File> = withContext(Dispatchers.IO) {
val uri = Uri.Builder()
.scheme("content")
.authority(pluginAuthority)
.path(SearchPluginContract.Paths.Search)
.appendQueryParameter(SearchPluginContract.Paths.QueryParam, query)
.appendQueryParameter(SearchPluginContract.Paths.AllowNetworkParam, allowNetwork.toString())
.build()
val cancellationSignal = CancellationSignal()
) : QueryPluginApi<String, PluginFile>(
context, pluginAuthority
), FileProvider {
return@withContext suspendCancellableCoroutine {
it.invokeOnCancellation {
cancellationSignal.cancel()
}
val cursor = try {
context.contentResolver.query(
uri,
null,
null,
cancellationSignal
)
} catch (e: Exception) {
Log.e("MM20", "Plugin ${pluginAuthority} threw exception")
CrashReporter.logException(e)
it.resume(emptyList())
return@suspendCancellableCoroutine
}
if (cursor == null) {
Log.e("MM20", "Plugin ${pluginAuthority} returned null cursor")
it.resume(emptyList())
return@suspendCancellableCoroutine
}
val results = fromCursor(cursor) ?: emptyList()
it.resume(results)
}
}
private fun getPluginConfig(): SearchPluginConfig? {
private fun getPluginConfig(): QueryPluginConfig? {
return PluginApi(pluginAuthority, context.contentResolver).getSearchPluginConfig()
}
suspend fun getFile(id: String): File? {
val uri = Uri.Builder()
.scheme("content")
.authority(pluginAuthority)
.path(SearchPluginContract.Paths.Root)
.appendPath(id)
.build()
val cancellationSignal = CancellationSignal()
return suspendCancellableCoroutine {
it.invokeOnCancellation {
cancellationSignal.cancel()
}
val cursor = context.contentResolver.query(
uri,
null,
null,
cancellationSignal
) ?: return@suspendCancellableCoroutine it.resume(null)
val results = fromCursor(cursor)
it.resume(results?.firstOrNull())
}
}
private fun fromCursor(cursor: Cursor): List<File>? {
override fun Cursor.getData(): List<PluginFile>? {
val config = getPluginConfig()
val cursor = this
if (config == null) {
Log.e("MM20", "Plugin ${pluginAuthority} returned null config")
@ -104,119 +39,99 @@ class PluginFileProvider(
return null
}
val idIndex = cursor
.getColumnIndex(FilePluginContract.FileColumns.Id)
.takeIf { it >= 0 }
?: return null
val pathIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.Path).takeIf { it >= 0 }
val typeIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.MimeType).takeIf { it >= 0 }
val sizeIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.Size).takeIf { it >= 0 }
val nameIndex = cursor.getColumnIndex(FilePluginContract.FileColumns.DisplayName)
.takeIf { it >= 0 }
?: return null
val contentUriIndex = cursor.getColumnIndex(FilePluginContract.FileColumns.ContentUri)
.takeIf { it >= 0 }
?: return null
val thumbnailUriIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.ThumbnailUri)
.takeIf { it >= 0 }
val directoryIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.IsDirectory).takeIf { it >= 0 }
val ownerIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.Owner).takeIf { it >= 0 }
val metaTitleIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaTitle).takeIf { it >= 0 }
val metaArtistIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaArtist).takeIf { it >= 0 }
val metaAlbumIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaAlbum).takeIf { it >= 0 }
val metaDurationIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaDuration).takeIf { it >= 0 }
val metaYearIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaYear).takeIf { it >= 0 }
val metaWidthIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaWidth).takeIf { it >= 0 }
val metaHeightIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaHeight).takeIf { it >= 0 }
val metaLocationIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaLocation).takeIf { it >= 0 }
val metaAppNameIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaAppName).takeIf { it >= 0 }
val metaAppPackageNameIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaAppPackageName)
.takeIf { it >= 0 }
val results = mutableListOf<File>()
while (cursor.moveToNext()) {
results.add(
PluginFile(
id = cursor.getString(idIndex),
path = pathIndex?.let { cursor.getString(it) } ?: "",
mimeType = typeIndex?.let { cursor.getString(it) }
?: "application/octet-stream",
size = sizeIndex?.let { cursor.getLong(it) } ?: 0,
metaData = buildMap {
metaTitleIndex?.let { cursor.getStringOrNull(it) }?.let {
put(FileMetaType.Title, it)
}
metaArtistIndex?.let { cursor.getStringOrNull(it) }?.let {
put(FileMetaType.Artist, it)
}
metaAlbumIndex?.let { cursor.getStringOrNull(it) }?.let {
put(FileMetaType.Album, it)
}
metaDurationIndex?.let { cursor.getLongOrNull(it) }?.let {
put(FileMetaType.Duration, DateUtils.formatElapsedTime(it / 1000L))
}
metaYearIndex?.let { cursor.getIntOrNull(it) }?.let {
put(FileMetaType.Year, it.toString())
}
if (metaWidthIndex != null && metaHeightIndex != null) {
val width = cursor.getIntOrNull(metaWidthIndex)
val height = cursor.getIntOrNull(metaHeightIndex)
val results = mutableListOf<PluginFile>()
val timestamp = System.currentTimeMillis()
cursor.withColumns(FileColumns) {
while (cursor.moveToNext()) {
results.add(
PluginFile(
id = cursor[FileColumns.Id] ?: continue,
path = cursor[FileColumns.Path] ?: "",
mimeType = cursor[FileColumns.MimeType] ?: "application/octet-stream",
size = cursor[FileColumns.Size] ?: 0L,
metaData = buildMap {
cursor[FileColumns.MetaTitle]?.let {
put(FileMetaType.Title, it)
}
cursor[FileColumns.MetaArtist]?.let {
put(FileMetaType.Artist, it)
}
cursor[FileColumns.MetaAlbum]?.let {
put(FileMetaType.Album, it)
}
cursor[FileColumns.MetaDuration]?.let {
put(FileMetaType.Duration, DateUtils.formatElapsedTime(it / 1000L))
}
cursor[FileColumns.MetaYear]?.let {
put(FileMetaType.Year, it.toString())
}
val width = cursor[FileColumns.MetaWidth]
val height = cursor[FileColumns.MetaHeight]
if (width != null && height != null) {
put(FileMetaType.Dimensions, "${width}x${height}")
}
cursor[FileColumns.MetaLocation]?.let {
put(FileMetaType.Location, it)
}
cursor[FileColumns.MetaAppName]?.let {
put(FileMetaType.AppName, it)
}
cursor[FileColumns.MetaAppPackageName]?.let {
put(FileMetaType.AppPackageName, it)
}
cursor[FileColumns.Owner]?.let {
put(FileMetaType.Owner, it)
}
}.toPersistentMap(),
label = cursor[FileColumns.DisplayName] ?: continue,
uri = cursor[FileColumns.DisplayName]?.let { Uri.parse(it) } ?: continue,
thumbnailUri = cursor[FileColumns.ThumbnailUri]?.let { Uri.parse(it) },
storageStrategy = config.storageStrategy,
isDirectory = cursor[FileColumns.IsDirectory] ?: false,
authority = pluginAuthority,
timestamp = timestamp,
updatedSelf = {
if (it !is PluginFile) UpdateResult.TemporarilyUnavailable()
else refresh(it, timestamp).asUpdateResult()
}
metaLocationIndex?.let { cursor.getStringOrNull(it) }?.let {
put(FileMetaType.Location, it)
}
metaAppNameIndex?.let { cursor.getStringOrNull(it) }?.let {
put(FileMetaType.AppName, it)
}
metaAppPackageNameIndex?.let { cursor.getStringOrNull(it) }?.let {
put(FileMetaType.AppPackageName, it)
}
ownerIndex?.let { cursor.getStringOrNull(it) }?.let {
put(FileMetaType.Owner, it)
}
}.toPersistentMap(),
label = cursor.getString(nameIndex),
uri = Uri.parse(cursor.getString(contentUriIndex)),
thumbnailUri = thumbnailUriIndex?.let {
cursor.getStringOrNull(it)
}?.let { Uri.parse(it) },
storageStrategy = config.storageStrategy,
isDirectory = directoryIndex?.let { cursor.getInt(it) } == 1,
authority = pluginAuthority,
)
)
)
}
}
cursor.close()
return results
}
override fun PluginFile.toBundle(): Bundle {
return Bundle().apply {
set(FileColumns.Id, id)
set(FileColumns.Path, path)
set(FileColumns.MimeType, mimeType)
set(FileColumns.Size, size)
set(FileColumns.MetaTitle, metaData[FileMetaType.Title])
set(FileColumns.MetaArtist, metaData[FileMetaType.Artist])
set(FileColumns.MetaAlbum, metaData[FileMetaType.Album])
set(FileColumns.MetaDuration, metaData[FileMetaType.Duration]?.toLong())
set(FileColumns.MetaYear, metaData[FileMetaType.Year]?.toInt())
set(
FileColumns.MetaWidth,
metaData[FileMetaType.Dimensions]?.split("x")?.getOrNull(0)?.toInt()
)
set(
FileColumns.MetaHeight,
metaData[FileMetaType.Dimensions]?.split("x")?.getOrNull(1)?.toInt()
)
set(FileColumns.MetaLocation, metaData[FileMetaType.Location])
set(FileColumns.MetaAppName, metaData[FileMetaType.AppName])
set(FileColumns.MetaAppPackageName, metaData[FileMetaType.AppPackageName])
set(FileColumns.Owner, metaData[FileMetaType.Owner])
set(FileColumns.DisplayName, label)
set(FileColumns.ThumbnailUri, thumbnailUri?.toString())
set(FileColumns.IsDirectory, isDirectory)
}
}
override fun Uri.Builder.appendQueryParameters(query: String): Uri.Builder = apply {
appendQueryParameter(SearchPluginContract.Params.Query, query)
}
}

View File

@ -56,4 +56,5 @@ dependencies {
implementation(project(":core:permissions"))
implementation(project(":core:crashreporter"))
implementation(project(":core:devicepose"))
implementation(project(":libs:address-formatter"))
}

View File

@ -0,0 +1,2 @@
-keep class de.mm20.launcher2.locations.** { *; }
-keep class kotlin.coroutines.Continuation

View File

@ -0,0 +1,198 @@
package de.mm20.launcher2.locations
import android.content.Context
import android.util.Log
import de.mm20.launcher2.locations.providers.PluginLocation
import de.mm20.launcher2.locations.providers.PluginLocationProvider
import de.mm20.launcher2.locations.providers.openstreetmaps.OsmLocation
import de.mm20.launcher2.locations.providers.openstreetmaps.OsmLocationProvider
import de.mm20.launcher2.plugin.PluginRepository
import de.mm20.launcher2.plugin.config.StorageStrategy
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.UpdateResult
import de.mm20.launcher2.search.asUpdateResult
import de.mm20.launcher2.search.location.Address
import de.mm20.launcher2.search.location.Attribution
import de.mm20.launcher2.search.location.Departure
import de.mm20.launcher2.search.location.LocationIcon
import de.mm20.launcher2.search.location.OpeningSchedule
import de.mm20.launcher2.serialization.Json
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
@Serializable
internal data class SerializedLocation(
val id: String? = null,
val lat: Double? = null,
val lon: Double? = null,
val icon: LocationIcon? = null,
val category: String? = null,
val label: String? = null,
val address: Address? = null,
val websiteUrl: String? = null,
val phoneNumber: String? = null,
val emailAddress: String? = null,
val userRating: Float? = null,
val userRatingCount: Int? = null,
val openingSchedule: OpeningSchedule? = null,
val timestamp: Long? = null,
val departures: List<Departure>? = null,
val fixMeUrl: String? = null,
val attribution: Attribution? = null,
val authority: String? = null,
val storageStrategy: StorageStrategy? = null,
)
internal class OsmLocationSerializer : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as OsmLocation
return Json.Lenient.encodeToString(
SerializedLocation(
id = searchable.id.toString(),
lat = searchable.latitude,
lon = searchable.longitude,
icon = searchable.icon,
category = searchable.category,
label = searchable.label,
address = searchable.address,
websiteUrl = searchable.websiteUrl,
phoneNumber = searchable.phoneNumber,
emailAddress = searchable.emailAddress,
userRating = searchable.userRating,
userRatingCount = searchable.userRatingCount,
openingSchedule = searchable.openingSchedule,
timestamp = searchable.timestamp,
departures = searchable.departures,
fixMeUrl = searchable.fixMeUrl,
)
)
}
override val typePrefix: String
get() = "osmlocation"
}
internal class OsmLocationDeserializer(
private val osmProvider: OsmLocationProvider,
) : SearchableDeserializer {
override suspend fun deserialize(serialized: String): SavableSearchable? {
val json = Json.Lenient.decodeFromString<SerializedLocation>(serialized)
val id = json.id?.toLongOrNull() ?: return null
return OsmLocation(
id = id,
latitude = json.lat ?: return null,
longitude = json.lon ?: return null,
icon = json.icon,
category = json.category,
label = json.label ?: return null,
address = json.address,
websiteUrl = json.websiteUrl,
phoneNumber = json.phoneNumber,
emailAddress = json.emailAddress,
userRating = json.userRating,
openingSchedule = json.openingSchedule,
timestamp = json.timestamp ?: return null,
updatedSelf = {
osmProvider.update(id)
}
)
}
}
internal class PluginLocationSerializer : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as PluginLocation
return when (searchable.storageStrategy) {
StorageStrategy.StoreReference -> Json.Lenient.encodeToString(
SerializedLocation(
id = searchable.id,
authority = searchable.authority,
storageStrategy = StorageStrategy.StoreReference,
)
)
else -> {
Json.Lenient.encodeToString(
SerializedLocation(
id = searchable.id,
lat = searchable.latitude,
lon = searchable.longitude,
icon = searchable.icon,
category = searchable.category,
label = searchable.label,
address = searchable.address,
websiteUrl = searchable.websiteUrl,
phoneNumber = searchable.phoneNumber,
emailAddress = searchable.emailAddress,
userRating = searchable.userRating,
userRatingCount = searchable.userRatingCount,
attribution = searchable.attribution,
openingSchedule = searchable.openingSchedule,
timestamp = searchable.timestamp,
departures = searchable.departures,
fixMeUrl = searchable.fixMeUrl,
authority = searchable.authority,
storageStrategy = searchable.storageStrategy,
)
)
}
}
}
override val typePrefix: String
get() = PluginLocation.DOMAIN
}
internal class PluginLocationDeserializer(
private val context: Context,
private val pluginRepository: PluginRepository,
) : SearchableDeserializer {
override suspend fun deserialize(serialized: String): SavableSearchable? {
val json = Json.Lenient.decodeFromString<SerializedLocation>(serialized)
val authority = json.authority ?: return null
val id = json.id ?: return null
val strategy = json.storageStrategy ?: StorageStrategy.StoreCopy
val plugin = pluginRepository.get(authority).firstOrNull() ?: return null
if (!plugin.enabled) return null
return when (strategy) {
StorageStrategy.StoreReference -> {
PluginLocationProvider(context, authority).get(id).getOrNull()
}
else -> {
val timestamp = json.timestamp ?: 0
PluginLocation(
id = id,
latitude = json.lat ?: return null,
longitude = json.lon ?: return null,
icon = json.icon,
category = json.category,
label = json.label ?: return null,
address = json.address,
websiteUrl = json.websiteUrl,
phoneNumber = json.phoneNumber,
emailAddress = json.emailAddress,
userRating = json.userRating,
userRatingCount = json.userRatingCount,
openingSchedule = json.openingSchedule,
timestamp = timestamp,
departures = json.departures,
fixMeUrl = json.fixMeUrl,
attribution = json.attribution,
authority = authority,
storageStrategy = strategy,
updatedSelf = {
if (it !is PluginLocation) UpdateResult.TemporarilyUnavailable()
else PluginLocationProvider(context, authority).refresh(it, timestamp).asUpdateResult()
}
)
}
}
}
}

View File

@ -0,0 +1,86 @@
package de.mm20.launcher2.locations
import android.content.Context
import de.mm20.launcher2.devicepose.DevicePoseProvider
import de.mm20.launcher2.locations.providers.PluginLocationProvider
import de.mm20.launcher2.locations.providers.openstreetmaps.OsmLocationProvider
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.search.LocationSearchSettings
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.SearchableRepository
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.coroutineContext
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.newCoroutineContext
import kotlinx.coroutines.supervisorScope
internal class LocationsRepository(
private val context: Context,
private val settings: LocationSearchSettings,
private val poseProvider: DevicePoseProvider,
private val permissionsManager: PermissionsManager,
) : SearchableRepository<Location> {
override fun search(
query: String,
allowNetwork: Boolean
): Flow<ImmutableList<Location>> {
if (query.isBlank()) {
return flowOf(persistentListOf())
}
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Location)
return combineTransform(settings.data, hasPermission) { settingsData, permission ->
emit(persistentListOf())
if (!permission || settingsData.providers.isEmpty()) {
return@combineTransform
}
val userLocation = poseProvider.getLocation().firstOrNull()
?: poseProvider.lastLocation
?: return@combineTransform
val providers = settingsData.providers.map {
when (it) {
"openstreetmaps" -> OsmLocationProvider(context, settings)
else -> PluginLocationProvider(context, it)
}
}
supervisorScope {
val result = MutableStateFlow(persistentListOf<Location>())
for (provider in providers) {
launch {
val r = provider.search(
query,
userLocation,
allowNetwork,
settingsData.searchRadius,
settingsData.hideUncategorized
)
result.update {
(it + r).toPersistentList()
}
}
}
emitAll(result)
}
}
}
}

View File

@ -0,0 +1,19 @@
package de.mm20.launcher2.locations
import de.mm20.launcher2.locations.providers.PluginLocation
import de.mm20.launcher2.locations.providers.openstreetmaps.OsmLocation
import de.mm20.launcher2.locations.providers.openstreetmaps.OsmLocationProvider
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableRepository
import org.koin.android.ext.koin.androidContext
import org.koin.core.qualifier.named
import org.koin.dsl.module
val locationsModule = module {
single<OsmLocationProvider> { OsmLocationProvider(androidContext(), get()) }
single<LocationsRepository> { LocationsRepository(androidContext(), get(), get(), get()) }
factory<SearchableRepository<Location>>(named<Location>()) { get<LocationsRepository>() }
factory<SearchableDeserializer>(named(OsmLocation.DOMAIN)) { OsmLocationDeserializer(get()) }
factory<SearchableDeserializer>(named(PluginLocation.DOMAIN)) { PluginLocationDeserializer(androidContext(), get()) }
}

View File

@ -0,0 +1,16 @@
package de.mm20.launcher2.locations.providers
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.UpdateResult
internal typealias AndroidLocation = android.location.Location
internal interface LocationProvider<TId> {
suspend fun search(
query: String,
userLocation: AndroidLocation,
allowNetwork: Boolean,
searchRadiusMeters: Int,
hideUncategorized: Boolean
): List<Location>
}

View File

@ -0,0 +1,66 @@
package de.mm20.launcher2.locations.providers
import android.content.Context
import android.graphics.drawable.Drawable
import de.mm20.launcher2.locations.PluginLocationSerializer
import de.mm20.launcher2.plugin.config.StorageStrategy
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.UpdatableSearchable
import de.mm20.launcher2.search.UpdateResult
import de.mm20.launcher2.search.location.Address
import de.mm20.launcher2.search.location.Attribution
import de.mm20.launcher2.search.location.Departure
import de.mm20.launcher2.search.location.LocationIcon
import de.mm20.launcher2.search.location.OpeningSchedule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
data class PluginLocation(
override val latitude: Double,
override val longitude: Double,
override val fixMeUrl: String?,
override val icon: LocationIcon?,
override val category: String?,
override val address: Address?,
override val openingSchedule: OpeningSchedule?,
override val websiteUrl: String?,
override val phoneNumber: String?,
override val emailAddress: String?,
override val userRating: Float?,
override val userRatingCount: Int?,
override val departures: List<Departure>?,
override val label: String,
override val timestamp: Long,
override val attribution: Attribution?,
override val updatedSelf: (suspend (SavableSearchable) -> UpdateResult<Location>)?,
override val labelOverride: String? = null,
val authority: String,
val id: String,
val storageStrategy: StorageStrategy,
) : Location, UpdatableSearchable<Location> {
override val key: String
get() = "$domain://$authority:$id"
override fun overrideLabel(label: String): PluginLocation {
return this.copy(labelOverride = label)
}
override val domain: String = DOMAIN
override fun getSerializer(): SearchableSerializer {
return PluginLocationSerializer()
}
override suspend fun getProviderIcon(context: Context): Drawable? {
return withContext(Dispatchers.IO) {
context.packageManager.resolveContentProvider(authority, 0)
?.loadIcon(context.packageManager)
}
}
companion object {
const val DOMAIN = "plugin.location"
}
}

View File

@ -0,0 +1,121 @@
package de.mm20.launcher2.locations.providers
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.util.Log
import de.mm20.launcher2.plugin.QueryPluginApi
import de.mm20.launcher2.plugin.contracts.LocationPluginContract
import de.mm20.launcher2.plugin.contracts.LocationPluginContract.LocationColumns
import de.mm20.launcher2.plugin.contracts.SearchPluginContract
import de.mm20.launcher2.plugin.data.set
import de.mm20.launcher2.plugin.data.withColumns
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.UpdateResult
import de.mm20.launcher2.search.asUpdateResult
internal class PluginLocationProvider(
context: Context,
private val pluginAuthority: String
) : QueryPluginApi<Triple<String, AndroidLocation, Int>, PluginLocation>(
context,
pluginAuthority
), LocationProvider<String> {
override suspend fun search(
query: String,
userLocation: AndroidLocation,
allowNetwork: Boolean,
searchRadiusMeters: Int,
hideUncategorized: Boolean
): List<Location> {
return search(
query = Triple(query, userLocation, searchRadiusMeters),
allowNetwork = allowNetwork,
)
}
override fun Uri.Builder.appendQueryParameters(query: Triple<String, AndroidLocation, Int>): Uri.Builder {
return apply {
appendQueryParameter(SearchPluginContract.Params.Query, query.first)
appendQueryParameter(
LocationPluginContract.Params.UserLatitude,
query.second.latitude.toString()
)
appendQueryParameter(
LocationPluginContract.Params.UserLongitude,
query.second.longitude.toString()
)
appendQueryParameter(LocationPluginContract.Params.SearchRadius, query.third.toString())
}
}
override fun Cursor.getData(): List<PluginLocation>? {
val config = getConfig()
val cursor = this
if (config == null) {
Log.e("MM20", "Plugin ${pluginAuthority} returned null config")
cursor.close()
return null
}
val results = mutableListOf<PluginLocation>()
val timestamp = System.currentTimeMillis()
cursor.withColumns(LocationColumns) {
while (cursor.moveToNext()) {
val id = cursor[LocationColumns.Id] ?: continue
results.add(
PluginLocation(
id = id,
label = cursor[LocationColumns.Label] ?: continue,
latitude = cursor[LocationColumns.Latitude] ?: continue,
longitude = cursor[LocationColumns.Longitude] ?: continue,
fixMeUrl = cursor[LocationColumns.FixMeUrl],
icon = cursor[LocationColumns.Icon],
category = cursor[LocationColumns.Category],
address = cursor[LocationColumns.Address],
openingSchedule = cursor[LocationColumns.OpeningSchedule],
websiteUrl = cursor[LocationColumns.WebsiteUrl],
phoneNumber = cursor[LocationColumns.PhoneNumber],
emailAddress = cursor[LocationColumns.EmailAddress],
userRating = cursor[LocationColumns.UserRating],
userRatingCount = cursor[LocationColumns.UserRatingCount],
departures = cursor[LocationColumns.Departures],
attribution = cursor[LocationColumns.Attribution],
authority = pluginAuthority,
updatedSelf = {
if (it !is PluginLocation) UpdateResult.TemporarilyUnavailable()
else refresh(it, timestamp).asUpdateResult()
},
timestamp = timestamp,
storageStrategy = config.storageStrategy,
)
)
}
}
return results
}
override fun PluginLocation.toBundle(): Bundle {
return Bundle().apply {
set(LocationColumns.Id, id)
set(LocationColumns.Label, label)
set(LocationColumns.Latitude, latitude)
set(LocationColumns.Longitude, longitude)
set(LocationColumns.FixMeUrl, fixMeUrl)
set(LocationColumns.Icon, icon)
set(LocationColumns.Category, category)
set(LocationColumns.Address, address)
set(LocationColumns.OpeningSchedule, openingSchedule)
set(LocationColumns.WebsiteUrl, websiteUrl)
set(LocationColumns.PhoneNumber, phoneNumber)
set(LocationColumns.EmailAddress, emailAddress)
set(LocationColumns.UserRating, userRating)
set(LocationColumns.UserRatingCount, userRatingCount)
set(LocationColumns.Departures, departures)
set(LocationColumns.Attribution, attribution)
}
}
}

View File

@ -0,0 +1,495 @@
package de.mm20.launcher2.locations.providers.openstreetmaps
import android.content.Context
import android.util.Log
import de.mm20.launcher2.locations.OsmLocationSerializer
import de.mm20.launcher2.openstreetmaps.R
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.UpdatableSearchable
import de.mm20.launcher2.search.UpdateResult
import de.mm20.launcher2.search.location.Address
import de.mm20.launcher2.search.location.Departure
import de.mm20.launcher2.search.location.LocationIcon
import de.mm20.launcher2.search.location.OpeningHours
import de.mm20.launcher2.search.location.OpeningSchedule
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.woheller69.AndroidAddressFormatter.OsmAddressFormatter
import java.time.DayOfWeek
import java.time.Duration
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.format.ResolverStyle
import java.util.Locale
import kotlin.math.min
internal data class OsmLocation(
internal val id: Long,
override val label: String,
override val icon: LocationIcon?,
override val category: String?,
override val latitude: Double,
override val longitude: Double,
override val address: Address?,
override val openingSchedule: OpeningSchedule?,
override val websiteUrl: String?,
override val phoneNumber: String?,
override val emailAddress: String? = null,
override val labelOverride: String? = null,
override val timestamp: Long,
override var updatedSelf: (suspend (SavableSearchable) -> UpdateResult<Location>)? = null,
override val userRating: Float?
) : Location, UpdatableSearchable<Location> {
override val domain: String
get() = DOMAIN
override val key: String = "$domain://$id"
override val fixMeUrl: String
get() = FIXMEURL
override val userRatingCount: Int? = null
override val departures: List<Departure>? = null
override fun overrideLabel(label: String): OsmLocation {
return this.copy(labelOverride = label)
}
override fun getSerializer(): SearchableSerializer {
return OsmLocationSerializer()
}
companion object {
internal const val DOMAIN = "osm"
internal const val FIXMEURL = "https://www.openstreetmap.org/fixthemap"
internal val addressFormatter =
OsmAddressFormatter(
false,
false,
false
)
fun fromOverpassResponse(
result: OverpassResponse,
context: Context
): List<OsmLocation> = result.elements.mapNotNull {
it.tags ?: return@mapNotNull null
val (category, icon) = it.tags.categorize(context)
icon ?: return@mapNotNull null
OsmLocation(
id = it.id,
label = it.tags["name"] ?: it.tags["brand"] ?: return@mapNotNull null,
icon = icon,
category = category,
latitude = it.lat ?: it.center?.lat ?: return@mapNotNull null,
longitude = it.lon ?: it.center?.lon ?: return@mapNotNull null,
address = it.tags.toAddress(),
openingSchedule = it.tags["opening_hours"]?.let { ot -> parseOpeningSchedule(ot) },
websiteUrl = it.tags["website"] ?: it.tags["contact:website"],
phoneNumber = it.tags["phone"] ?: it.tags["contact:phone"],
emailAddress = it.tags["email"] ?: it.tags["contact:email"],
timestamp = System.currentTimeMillis(),
userRating = it.tags["stars"]?.runCatching { this.toInt() }?.getOrNull()
?.let { min(it, 5) / 5.0f }
)
}
}
}
private fun Map<String, String>.firstOfAlso(vararg strs: String, also: (String) -> Unit): String? {
for (str in strs) {
if (str in this) {
also(str)
return this[str]
}
}
return null
}
private fun Map<String, String>.toAddress(): Address? {
val formatAddrKeys = this.keys.filter { it.contains("addr") }.toMutableSet()
if (formatAddrKeys.isEmpty()) return null
val addr = Address(
city = firstOfAlso("addr:city", "addr:suburb", "addr:hamlet") { formatAddrKeys.remove(it) },
state = firstOfAlso("addr:state", "addr:province") { formatAddrKeys.remove(it) },
postalCode = firstOfAlso("addr:postcode") { formatAddrKeys.remove(it) },
country = firstOfAlso("addr:country") { formatAddrKeys.remove(it) },
)
val formattedRest = buildJsonObject {
formatAddrKeys.mapNotNull {
val (_, subkey) = it.split(':', limit = 2).takeIf { it.size == 2 }
?: return@mapNotNull null
put(subkey, this@toAddress[it])
}
}.takeIf { it.isNotEmpty() }?.toString()?.runCatching {
OsmLocation.addressFormatter.format(
this,
this@toAddress["addr:country"] ?: Locale.getDefault().country
)
}?.getOrNull() ?: return addr
val lines = formattedRest.lines().filter { it.isNotBlank() }
return addr.copy(
address = lines.getOrNull(0),
address2 = lines.getOrNull(1),
address3 = lines.getOrNull(2),
)
}
private class MatchAnyReceiverScope<T, A, B> {
private val pairs = mutableMapOf<T, Pair<A, B>>()
operator fun get(key: T): Pair<A, B>? = pairs[key]
infix fun T.with(pair: Pair<A, B>) = pairs.put(this, pair)
}
private fun <A, B> Map<String, String>.matchAnyTag(
key: String,
block: MatchAnyReceiverScope<String, A, B>.() -> Unit
): Pair<A, B>? {
val scope = MatchAnyReceiverScope<String, A, B>()
scope.block()
return this[key]?.split(' ', ',', '.', ';')?.map { it.trim() }
?.firstNotNullOfOrNull { scope[it] }
}
private fun Map<String, String>.categorize(context: Context): Pair<String, LocationIcon?> {
val category = this.firstNotNullOfOrNull { (tag, value) ->
val values = value.split(' ', ',', '.', ';').map { it.trim() }.toSet()
when (tag.lowercase()) {
"shop" -> values.firstNotNullOfOrNull {
when (it) {
"florist" -> R.string.poi_category_florist to LocationIcon.Florist
"kiosk" -> R.string.poi_category_kiosk to LocationIcon.Kiosk
"furniture" -> R.string.poi_category_furniture to LocationIcon.FurnitureStore
"cell_phones", "mobile_phone" -> R.string.poi_category_mobile_phone to LocationIcon.CellPhoneStore
"books" -> R.string.poi_category_books to LocationIcon.BookStore
"clothes" -> R.string.poi_category_clothes to LocationIcon.ClothingStore
"convenience" -> R.string.poi_category_convenience to LocationIcon.ConvenienceStore
"discount" -> R.string.poi_category_discount_store to LocationIcon.DiscountStore
"jewelry" -> R.string.poi_category_jewelry to LocationIcon.JewelryStore
"alcohol" -> R.string.poi_category_alcohol to LocationIcon.LiquorStore
"pet", "pet_grooming" -> R.string.poi_category_pet to LocationIcon.PetStore
"mall", "shopping_centre", "department_store" -> R.string.poi_category_mall to LocationIcon.ShoppingMall
"supermarket" -> R.string.poi_category_supermarket to LocationIcon.Supermarket
"bakery" -> R.string.poi_category_bakery to LocationIcon.Bakery
"optician" -> R.string.poi_category_optician to LocationIcon.Optician
"hairdresser" -> R.string.poi_category_hairdresser to LocationIcon.HairSalon
"laundry" -> R.string.poi_category_laundry to LocationIcon.Laundromat
else -> R.string.poi_category_shopping to LocationIcon.Shopping
}
}
"amenity" -> values.firstNotNullOfOrNull {
when (it) {
"place_of_worship" -> matchAnyTag<Int, LocationIcon>("religion") {
"christian" with (R.string.poi_category_church to LocationIcon.Church)
"muslim" with (R.string.poi_category_mosque to LocationIcon.Mosque)
"buddhist" with (R.string.poi_category_buddhist_temple to LocationIcon.BuddhistTemple)
"hindu" with (R.string.poi_category_hindu_temple to LocationIcon.HinduTemple)
"jewish" with (R.string.poi_category_synagogue to LocationIcon.Synagogue)
} ?: (R.string.poi_category_place_of_worship to LocationIcon.Candle)
"fast_food" -> R.string.poi_category_fast_food to LocationIcon.FastFood
"cafe" -> R.string.poi_category_cafe to LocationIcon.Cafe
"ice_cream" -> R.string.poi_category_ice_cream to LocationIcon.IceCream
"bar" -> R.string.poi_category_bar to LocationIcon.Bar
"pub" -> R.string.poi_category_pub to LocationIcon.Pub
"restaurant" -> matchAnyTag<Int, LocationIcon>("cuisine") {
"pizza" with (R.string.poi_category_pizza_restaurant to LocationIcon.Pizza)
"burger" with (R.string.poi_category_burger_restaurant to LocationIcon.Burger)
"chinese" with (R.string.poi_category_chinese_restaurant to LocationIcon.Ramen)
"ramen" with (R.string.poi_category_ramen_restaurant to LocationIcon.Ramen)
"japanese" with (R.string.poi_category_japanese_restaurant to LocationIcon.JapaneseCuisine)
"kebab" with (R.string.poi_category_kebab_restaurant to LocationIcon.Kebab)
"asian" with (R.string.poi_category_asian_restaurant to LocationIcon.AsianCuisine)
"soup" with (R.string.poi_category_soup_restaurant to LocationIcon.Soup)
"coffee_shop" with (R.string.poi_category_cafe to LocationIcon.Cafe)
"brunch" with (R.string.poi_category_brunch_restaurant to LocationIcon.Brunch)
"breakfast" with (R.string.poi_category_breakfast_restaurant to LocationIcon.Breakfast)
} ?: (R.string.poi_category_restaurant to LocationIcon.Restaurant)
"fuel" -> R.string.poi_category_fuel to LocationIcon.GasStation
"car_rental", "car_sharing" -> R.string.poi_category_car to LocationIcon.CarRental
"car_wash" -> R.string.poi_category_car_wash to LocationIcon.CarWash
"charging_station" -> R.string.poi_category_charging_station to LocationIcon.ChargingStation
"parking", "parking_space", "motorcycle_parking" -> R.string.poi_category_parking to LocationIcon.Parking
"motorcycle_rental" -> R.string.poi_category_motorcycle_rental to LocationIcon.Motorcycle
"theatre" -> R.string.poi_category_theater to LocationIcon.Theater
"cinema" -> R.string.poi_category_cinema to LocationIcon.MovieTheater
"nightclub" -> R.string.poi_category_nightclub to LocationIcon.NightClub
"concert_hall" -> R.string.poi_category_concert_hall to LocationIcon.ConcertHall
"casino" -> R.string.poi_category_casino to LocationIcon.Casino
"pharmacy" -> R.string.poi_category_pharmacy to LocationIcon.Pharmacy
"bank" -> R.string.poi_category_bank to LocationIcon.Bank
"atm" -> R.string.poi_category_atm to LocationIcon.Atm
"doctors" -> R.string.poi_category_doctors to LocationIcon.Physician
"dentist" -> R.string.poi_category_dentist to LocationIcon.Dentist
"hospital" -> R.string.poi_category_hospital to LocationIcon.Hospital
"clinic" -> R.string.poi_category_clinic to LocationIcon.Clinic
"police" -> R.string.poi_category_police to LocationIcon.Police
"fire_station" -> R.string.poi_category_fire_station to LocationIcon.FireDepartment
"courthouse" -> R.string.poi_category_courthouse to LocationIcon.Courthouse
"post_office" -> R.string.poi_category_post_office to LocationIcon.PostOffice
"library" -> R.string.poi_category_library to LocationIcon.Library
"school" -> R.string.poi_category_school to LocationIcon.School
"university" -> R.string.poi_category_university to LocationIcon.University
"toilets" -> R.string.poi_category_toilets to LocationIcon.PublicBathroom
"townhall" -> R.string.poi_category_townhall to LocationIcon.GovernmentBuilding
else -> null
}
}
"tourism" -> values.firstNotNullOfOrNull {
when (it) {
"gallery" -> R.string.poi_category_gallery to LocationIcon.ArtGallery
"museum" -> R.string.poi_category_museum to LocationIcon.Museum
"theme_park" -> R.string.poi_category_amusement_park to LocationIcon.AmusementPark
"hotel" -> R.string.poi_category_hotel to LocationIcon.Hotel
else -> null
}
}
"leisure" -> values.firstNotNullOfOrNull {
when (it) {
"stadium" -> R.string.poi_category_stadium to LocationIcon.Stadium
"fitness_centre" -> R.string.poi_category_fitness_center to LocationIcon.FitnessCenter
"swimming_pool" -> R.string.poi_category_swimming to LocationIcon.Swimming
"pitch", "sports_centre" -> matchAnyTag<Int, LocationIcon>("sport") {
"soccer" with (R.string.poi_category_soccer to LocationIcon.Soccer)
"tennis" with (R.string.poi_category_tennis to LocationIcon.Tennis)
"basketball" with (R.string.poi_category_basketball to LocationIcon.Basketball)
"gymnastics" with (R.string.poi_category_gymnastics to LocationIcon.Gymnastics)
"martial_arts" with (R.string.poi_category_martial_arts to LocationIcon.MartialArts)
"golf" with (R.string.poi_category_golf to LocationIcon.Golf)
"ice_hockey" with (R.string.poi_category_ice_hockey to LocationIcon.Hockey)
"baseball" with (R.string.poi_category_baseball to LocationIcon.Baseball)
"american_football" with (R.string.poi_category_american_football to LocationIcon.AmericanFootball)
"handball" with (R.string.poi_category_handball to LocationIcon.Handball)
"volleyball" with (R.string.poi_category_volleyball to LocationIcon.Volleyball)
"skiing" with (R.string.poi_category_skiing to LocationIcon.Skiing)
"cricket" with (R.string.poi_category_cricket to LocationIcon.Cricket)
}
"park" -> R.string.poi_category_park to LocationIcon.Park
else -> null
}
}
"historic" -> values.firstNotNullOfOrNull {
when (it) {
"monument" -> R.string.poi_category_monument to LocationIcon.Monument
else -> null
}
}
"building" -> values.firstNotNullOfOrNull {
when (it) {
"government" -> R.string.poi_category_government_building to LocationIcon.GovernmentBuilding
else -> null
}
}
else -> null
}
}
val (rid, icon) = category ?: (R.string.poi_category_other to null)
return context.resources.getString(rid) to icon
}
// allow for 24:00 to be part of the same day
// https://stackoverflow.com/a/31113244
private val DATE_TIME_FORMATTER =
DateTimeFormatter.ISO_LOCAL_TIME.withResolverStyle(ResolverStyle.SMART)
private val timeRegex by lazy {
Regex(
"""^(?:\d{2}:\d{2}-?){2}$""",
RegexOption.IGNORE_CASE
)
}
private val singleDayRegex by lazy {
Regex(
"""^[mtwfsp][ouehra]$""",
RegexOption.IGNORE_CASE
)
}
private val dayRangeRegex by lazy {
Regex(
"""^[mtwfsp][ouehra]-[mtwfsp][ouehra]$""",
RegexOption.IGNORE_CASE
)
}
private val daysOfWeek = enumValues<DayOfWeek>().toList().toImmutableList()
private val twentyFourSeven = daysOfWeek.map {
OpeningHours(
dayOfWeek = it,
startTime = LocalTime.MIDNIGHT,
duration = Duration.ofDays(1)
)
}.toImmutableList()
// If this is not sufficient, resort to implementing https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification
// or port https://github.com/opening-hours/opening_hours.js
internal fun parseOpeningSchedule(it: String?): OpeningSchedule? {
if (it.isNullOrBlank()) return null
val openingHours = mutableListOf<OpeningHours>()
// e.g.
// "Mo-Sa 11:00-14:00, 17:00-23:00; Su 11:00-23:00"
// "Mo-Sa 11:00-21:00; PH,Su off"
// "Mo-Th 10:00-24:00, Fr,Sa 10:00-05:00, PH,Su 12:00-22:00"
var blocks =
it.split(',', ';', ' ').mapNotNull { if (it.isBlank()) null else it.trim() }
if (blocks.first() == "24/7")
return OpeningSchedule.TwentyFourSeven
fun dayOfWeekFromString(it: String): DayOfWeek? = when (it.lowercase()) {
"mo" -> DayOfWeek.MONDAY
"tu" -> DayOfWeek.TUESDAY
"we" -> DayOfWeek.WEDNESDAY
"th" -> DayOfWeek.THURSDAY
"fr" -> DayOfWeek.FRIDAY
"sa" -> DayOfWeek.SATURDAY
"su" -> DayOfWeek.SUNDAY
else -> null
}
var allDay = false
var everyDay = false
fun parseGroup(group: List<String>) {
if (group.isEmpty())
return
var times = group
.filter { timeRegex.matches(it) }
.mapNotNull {
try {
val startTime =
LocalTime.parse(it.substringBefore('-'), DATE_TIME_FORMATTER)
val endTime =
LocalTime.parse(it.substringAfter('-'), DATE_TIME_FORMATTER)
var duration = Duration.between(startTime, endTime)
if (duration.isNegative || duration.isZero)
duration += Duration.ofDays(1)
startTime to duration
} catch (dtpe: DateTimeParseException) {
Log.e(
"OpeningTimeFromOverpassElement",
"Failed to parse opening time $it",
dtpe
)
null
}
}
var days = group
.filter { dayRangeRegex.matches(it) }
.flatMap {
val dowStart = dayOfWeekFromString(it.substringBefore('-'))
?: return@flatMap emptyList()
val dowEnd = dayOfWeekFromString(it.substringAfter('-'))
?: return@flatMap emptyList()
if (dowStart.ordinal <= dowEnd.ordinal)
daysOfWeek.subList(dowStart.ordinal, dowEnd.ordinal + 1)
else // "We-Mo"
daysOfWeek.subList(dowStart.ordinal, daysOfWeek.size)
.union(daysOfWeek.subList(0, dowEnd.ordinal + 1))
}.union(
group.filter { singleDayRegex.matches(it) }
.mapNotNull { dayOfWeekFromString(it) }
)
// if no time specified, treat as "all day"
if (times.isEmpty()) {
allDay = true
times = listOf(LocalTime.MIDNIGHT to Duration.ofDays(1))
}
// if no day specified, treat as "every day"
if (days.isEmpty()) {
if (group.any { it.equals("PH", ignoreCase = true) }) {
times = emptyList()
} else {
everyDay = true
days = daysOfWeek.toSet()
}
}
openingHours.addAll(days.flatMap { day ->
times.map { (start, duration) ->
OpeningHours(
dayOfWeek = day,
startTime = start,
duration = duration
)
}
})
}
while (true) {
if (blocks.isEmpty())
break
// assuming that there are blocks that only contain time
// treating them as "every day of the week"
if (blocks.size < 2) {
parseGroup(blocks)
break
}
val nextTimeIndex =
blocks.indexOfFirst { timeRegex.matches(it) }
// no time left, so probably no sensible information
// willingly skips "off" and "closed" as they are not useful
if (nextTimeIndex == -1)
break
// assuming next block to start with the first date coming after a time block
var nextGroupIndex =
blocks.subList(nextTimeIndex, blocks.size)
.indexOfFirst { !timeRegex.matches(it) }
// no day left, so we are done
if (nextGroupIndex == -1) {
parseGroup(blocks)
break
}
// convert index from sublist context
nextGroupIndex += nextTimeIndex
parseGroup(blocks.subList(0, nextGroupIndex))
blocks = blocks.subList(nextGroupIndex, blocks.size)
}
return if (allDay && everyDay) {
OpeningSchedule.TwentyFourSeven
} else {
OpeningSchedule.Hours(openingHours)
}
}

View File

@ -0,0 +1,171 @@
package de.mm20.launcher2.locations.providers.openstreetmaps
import android.content.Context
import android.util.Log
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.locations.providers.AndroidLocation
import de.mm20.launcher2.locations.providers.LocationProvider
import de.mm20.launcher2.preferences.search.LocationSearchSettings
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.UpdateResult
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.net.UnknownHostException
private val Scope = CoroutineScope(Job() + Dispatchers.IO)
private val HttpClient = OkHttpClient()
internal class OsmLocationProvider(
private val context: Context,
settings: LocationSearchSettings
) : LocationProvider<Long> {
private val overpassApi = settings.overpassUrl.map {
try {
Retrofit.Builder()
.client(HttpClient)
.baseUrl(it.takeIf { it.isNotBlank() }
?: LocationSearchSettings.DefaultOverpassUrl)
.addConverterFactory(OverpassQueryConverterFactory())
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(OverpassApi::class.java)
} catch (e: Exception) {
CrashReporter.logException(e)
null
}
}.stateIn(Scope, SharingStarted.Eagerly, null)
suspend fun update(
id: Long
): UpdateResult<Location> = overpassApi.first()?.runCatching {
this.search(
OverpassIdQuery(
id = id
)
).let {
OsmLocation.fromOverpassResponse(it, context)
}.first().apply {
updatedSelf = { update(id) }
}
}?.fold(
onSuccess = { UpdateResult.Success(it) },
onFailure = {
when (it) {
is CancellationException, is UnknownHostException -> {
// network
UpdateResult.TemporarilyUnavailable(it)
}
is HttpException -> when (it.code()) {
in 400..499 -> UpdateResult.PermanentlyUnavailable(it)
else -> UpdateResult.TemporarilyUnavailable(it)
}
is NoSuchElementException -> {
// empty response
UpdateResult.PermanentlyUnavailable(it)
}
else -> {
if (it is Exception) {
CrashReporter.logException(it)
}
UpdateResult.TemporarilyUnavailable(it)
}
}
}
) ?: let {
Log.e("OsmProvider", "overpassApi was not initialized")
UpdateResult.TemporarilyUnavailable()
}
override suspend fun search(
query: String,
userLocation: AndroidLocation,
allowNetwork: Boolean,
searchRadiusMeters: Int,
hideUncategorized: Boolean,
): List<Location> {
if (!allowNetwork || query.length < 2) {
return emptyList()
}
withContext(Dispatchers.IO) {
HttpClient.dispatcher.cancelAll()
}
suspend fun searchByTag(tag: String): OverpassResponse? =
overpassApi.first()?.runCatching {
this.search(
OverpassFuzzyRadiusQuery(
tag = tag,
query = query,
radius = searchRadiusMeters,
latitude = userLocation.latitude,
longitude = userLocation.longitude,
)
)
}?.onFailure {
if (it !is HttpException && it !is CancellationException) {
Log.e("OsmLocationProvider", "Failed to search for $tag: $query", it)
}
}?.getOrNull()
val result = awaitAll(
// optionally query by "amenity" or "shop" here
// if we want to make searching for locations fuzzier
// however, this would not account for localized queries like "Bäcker" (shop:bakery)
Scope.async { searchByTag("name") },
Scope.async { searchByTag("brand") },
).flatMap {
it?.let {
OsmLocation.fromOverpassResponse(it, context)
} ?: emptyList()
}
return result
.asSequence()
.filter {
!hideUncategorized || (it.category != null)
}
.groupBy {
it.label.lowercase()
}
.flatMap { (_, duplicates) ->
// deduplicate results with same labels, if
// - same category
// - distance is less than 100m
if (duplicates.size < 2) duplicates
else {
val luckyFirst = duplicates.first()
duplicates
.drop(1)
.filter {
it.category != luckyFirst.category ||
it.distanceTo(luckyFirst) > 100.0
} + luckyFirst
}
}
.sortedBy {
it.distanceTo(userLocation)
}
.take(7)
.toImmutableList()
}
}

View File

@ -1,4 +1,4 @@
package de.mm20.launcher2.openstreetmaps
package de.mm20.launcher2.locations.providers.openstreetmaps
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody

View File

@ -1,2 +0,0 @@
-keep class de.mm20.launcher2.openstreetmaps.** { *; }
-keep class kotlin.coroutines.Continuation

View File

@ -1,13 +0,0 @@
package de.mm20.launcher2.openstreetmaps
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableRepository
import org.koin.core.qualifier.named
import org.koin.dsl.module
val openStreetMapsModule = module {
single<OsmRepository> { OsmRepository(get(), get(), get()) }
factory<SearchableRepository<Location>>(named<Location>()) { get<OsmRepository>() }
factory<SearchableDeserializer>(named(OsmLocation.DOMAIN)) { OsmLocationDeserializer(get()) }
}

Some files were not shown because too many files have changed in this diff Show More