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:
parent
cfe80ff3e5
commit
65a9c8c1fe
4
.gitignore
vendored
4
.gitignore
vendored
@ -305,4 +305,6 @@ fabric.properties
|
||||
.idea/deploymentTargetDropDown.xml
|
||||
.idea/deploymentTargetSelector.xml
|
||||
.idea/copilot
|
||||
.idea/other.xml
|
||||
.idea/other.xml
|
||||
|
||||
.kotlin
|
||||
28
.idea/inspectionProfiles/Project_Default.xml
generated
28
.idea/inspectionProfiles/Project_Default.xml
generated
@ -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
2
.idea/kotlinc.xml
generated
@ -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>
|
||||
@ -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"))
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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, "")
|
||||
}
|
||||
@ -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) }
|
||||
|
||||
@ -69,8 +69,6 @@ fun ListItem(
|
||||
)
|
||||
)
|
||||
|
||||
val item = viewModel.searchable.collectAsState().value ?: item
|
||||
|
||||
var bounds by remember { mutableStateOf(Rect.Zero) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) } },
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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? {
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
),
|
||||
) {
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,4 +3,5 @@ package de.mm20.launcher2.plugin
|
||||
enum class PluginType {
|
||||
FileSearch,
|
||||
Weather,
|
||||
LocationSearch,
|
||||
}
|
||||
@ -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]
|
||||
@ -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,
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
@ -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,
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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>
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -56,4 +56,5 @@ dependencies {
|
||||
implementation(project(":core:permissions"))
|
||||
implementation(project(":core:crashreporter"))
|
||||
implementation(project(":core:devicepose"))
|
||||
implementation(project(":libs:address-formatter"))
|
||||
}
|
||||
2
data/locations/consumer-rules.pro
Normal file
2
data/locations/consumer-rules.pro
Normal file
@ -0,0 +1,2 @@
|
||||
-keep class de.mm20.launcher2.locations.** { *; }
|
||||
-keep class kotlin.coroutines.Continuation
|
||||
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()) }
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package de.mm20.launcher2.openstreetmaps
|
||||
package de.mm20.launcher2.locations.providers.openstreetmaps
|
||||
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@ -1,2 +0,0 @@
|
||||
-keep class de.mm20.launcher2.openstreetmaps.** { *; }
|
||||
-keep class kotlin.coroutines.Continuation
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user