diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt index f7b07a00..d475c918 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -49,6 +48,9 @@ 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.outlined.CreditCardOff +import androidx.compose.material.icons.outlined.CreditScore +import androidx.compose.material.icons.outlined.Toll import androidx.compose.material.icons.rounded.AirplanemodeActive import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.Commute @@ -92,9 +94,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign @@ -102,7 +102,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp @@ -115,6 +114,7 @@ import blend.Blend.harmonize import coil.compose.AsyncImage import de.mm20.launcher2.i18n.R import de.mm20.launcher2.icons.CableCar +import de.mm20.launcher2.icons.TollOff import de.mm20.launcher2.ktx.tryStartActivity import de.mm20.launcher2.search.Location import de.mm20.launcher2.search.isOpen @@ -124,6 +124,7 @@ import de.mm20.launcher2.search.location.LineNameComparator 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.search.location.PaymentMethod import de.mm20.launcher2.search.location.isNotEmpty import de.mm20.launcher2.ui.base.LocalTime import de.mm20.launcher2.ui.component.DefaultToolbarAction @@ -229,24 +230,33 @@ fun LocationItem( ) val formattedDistance = distance?.metersToLocalizedString(context, imperialUnits) - val isOpenString = location.openingSchedule?.isOpen() - ?.let { stringResource(if (it) R.string.location_open else R.string.location_closed) } - val sublabel = listOf(location.category, formattedDistance, isOpenString) + val sublabel = listOf(location.category, formattedDistance) .fastFilterNotNull() .joinToString(" • ") + val isOpenString = location.openingSchedule?.isOpen() + ?.let { stringResource(if (it) R.string.location_open else R.string.location_closed) } - if (sublabel.isNotBlank()) { - Text( - sublabel, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier - .padding(top = 2.dp) - .sharedElement( - rememberSharedContentState("sublabel"), - this@AnimatedContent - ) - ) + Row(modifier = Modifier.padding(top = 2.dp)) { + if (sublabel.isNotBlank()) { + Text( + sublabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .sharedElement( + rememberSharedContentState("sublabel"), + this@AnimatedContent + ) + ) + } + if (!isOpenString.isNullOrBlank()) { + Text( + " • $isOpenString", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.animateEnterExit() + ) + } } } Compass( @@ -324,28 +334,55 @@ fun LocationItem( this@AnimatedContent ) ) - val category = location.category - val formattedDistance = distance?.metersToLocalizedString( - context, imperialUnits - ) - if (category != null || formattedDistance != null) { - Text( - when { - category != null && formattedDistance != null -> "$category • $formattedDistance" - - category != null -> category - formattedDistance != null -> formattedDistance - else -> "" - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier - .padding(top = 2.dp) - .sharedElement( - rememberSharedContentState("sublabel"), - this@AnimatedContent - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 2.dp) + ) { + val category = location.category + val acceptedPaymentMethods = location.acceptedPaymentMethods + val formattedDistance = distance?.metersToLocalizedString( + context, imperialUnits ) + if (category != null || formattedDistance != null) { + Text( + when { + category != null && formattedDistance != null -> "$category • $formattedDistance" + + category != null -> category + formattedDistance != null -> formattedDistance + else -> "" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .sharedElement( + rememberSharedContentState("sublabel"), + this@AnimatedContent + ) + ) + } + if (!acceptedPaymentMethods.isNullOrEmpty()) { + Text( + " • ", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.animateEnterExit() + ) + for ((method, available) in acceptedPaymentMethods) { + Icon( + when (method) { + PaymentMethod.Cash -> if (available) Icons.Outlined.Toll else Icons.Outlined.TollOff + PaymentMethod.Card -> if (available) Icons.Outlined.CreditScore else Icons.Outlined.CreditCardOff + }, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .size(14.5.dp) + .padding(end = 2.dp) + .animateEnterExit() + ) + } + } } if (location.userRating != null) { RatingBar( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/MapTiles.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/MapTiles.kt index f068e668..da080a88 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/MapTiles.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/MapTiles.kt @@ -75,6 +75,7 @@ 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.search.location.PaymentMethod import de.mm20.launcher2.ui.ktx.DegreesConverter import de.mm20.launcher2.ui.ktx.contrast import de.mm20.launcher2.ui.ktx.hue @@ -505,6 +506,9 @@ private object MockLocation : Location { override val openingSchedule: OpeningSchedule = OpeningSchedule.TwentyFourSeven + override val acceptedPaymentMethods: Map? + get() = mapOf(PaymentMethod.Card to true, PaymentMethod.Cash to false) + override val websiteUrl: String = "https://en.wikipedia.org/wiki/Brandenburg_Gate" override val phoneNumber: String = "+49 1234567" diff --git a/core/base/src/main/java/de/mm20/launcher2/icons/Icons.kt b/core/base/src/main/java/de/mm20/launcher2/icons/Icons.kt index ac975145..4ab48fe6 100644 --- a/core/base/src/main/java/de/mm20/launcher2/icons/Icons.kt +++ b/core/base/src/main/java/de/mm20/launcher2/icons/Icons.kt @@ -1742,6 +1742,60 @@ private val _BreezyWeather = materialIcon("Icons.Rounded.BreezyWeather") { val Icons.Rounded.BreezyWeather get() = _BreezyWeather +private val _TollOff = materialIcon("Icons.Rounded.TollOff") { + materialPath { + moveTo(2.8066406f, 3.2207031f) + curveToRelative(-0.2556191f, 0f, -0.5111625f, 0.099053f, -0.7070312f, 0.2949219f) + curveToRelative(-0.3917371f, 0.3917372f, -0.3917371f, 1.0223253f, 0f, 1.4140625f) + lineToRelative(1.3339844f, 1.3320312f) + verticalLineToRelative(0.00195f) + curveTo(1.933574f, 7.7156369f, 0.99999996f, 9.7483802f, 1f, 12f) + curveToRelative(3e-7f, 3.23f, 1.9196876f, 6.009532f, 4.6796875f, 7.269531f) + curveTo(6.2896876f, 19.549531f, 7.0000003f, 19.129219f, 7f, 18.449219f) + verticalLineToRelative(-0.179688f) + curveToRelative(-3e-7f, -0.37f, -0.2303127f, -0.689609f, -0.5703125f, -0.849609f) + curveTo(4.399687f, 16.459922f, 2.9999999f, 14.39f, 3f, 12f) + curveToRelative(-2e-7f, -1.698485f, 0.7060878f, -3.2344795f, 1.84375f, -4.3261719f) + lineToRelative(2.3925781f, 2.3906249f) + verticalLineToRelative(0.002f) + curveTo(7.0825208f, 10.685478f, 6.9999996f, 11.333026f, 7f, 12f) + curveToRelative(0f, 4.42f, 3.58f, 8f, 8f, 8f) + curveToRelative(0.666973f, 0f, 1.314475f, -0.08252f, 1.933594f, -0.236328f) + horizontalLineToRelative(0.002f) + lineToRelative(2.134765f, 2.136719f) + curveToRelative(0.391737f, 0.391737f, 1.022326f, 0.391737f, 1.414063f, 0f) + curveToRelative(0.391737f, -0.391738f, 0.391737f, -1.024279f, 0f, -1.416016f) + lineTo(18.955078f, 18.955078f) + lineTo(17.46875f, 17.46875f) + lineTo(9.53125f, 9.53125f) + lineTo(8.0449219f, 8.0449219f) + lineTo(6.5273437f, 6.5273437f) + lineTo(5.0507812f, 5.0507812f) + lineTo(3.515625f, 3.515625f) + curveTo(3.3197565f, 3.3197565f, 3.0622597f, 3.2207031f, 2.8066406f, 3.2207031f) + close() + moveTo(15f, 4f) + curveToRelative(-2.25366f, 2e-7f, -4.288489f, 0.9314658f, -5.7421875f, 2.4296875f) + lineTo(10.673828f, 7.8457031f) + curveTo(11.766133f, 6.7086818f, 13.30143f, 6.0000002f, 15f, 6f) + curveToRelative(3.31f, 4e-7f, 6f, 2.6899997f, 6f, 6f) + curveToRelative(0f, 1.69857f, -0.708682f, 3.233867f, -1.845703f, 4.326172f) + lineToRelative(1.416015f, 1.416015f) + curveTo(22.068533f, 16.288488f, 23f, 14.25366f, 23f, 12f) + curveTo(23f, 7.5799994f, 19.42f, 3.9999997f, 15f, 4f) + close() + moveToRelative(-5.9980469f, 7.832031f) + lineToRelative(6.1660159f, 6.166016f) + curveTo(15.112392f, 17.999575f, 15.055942f, 18f, 15f, 18f) + curveTo(11.69f, 18f, 9.0000004f, 15.31f, 9f, 12f) + curveToRelative(2e-7f, -0.05594f, 0.0004292f, -0.112391f, 0.00195f, -0.167969f) + close() + } +} + +val Icons.Outlined.TollOff + get() = _TollOff + val _CutCorner = materialIcon("Icons.Rounded.CutCorner") { materialPath { moveTo(2f, 3f) diff --git a/core/base/src/main/java/de/mm20/launcher2/search/Location.kt b/core/base/src/main/java/de/mm20/launcher2/search/Location.kt index d106fdad..64bf12a8 100644 --- a/core/base/src/main/java/de/mm20/launcher2/search/Location.kt +++ b/core/base/src/main/java/de/mm20/launcher2/search/Location.kt @@ -129,6 +129,7 @@ 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 de.mm20.launcher2.search.location.PaymentMethod import java.time.LocalDateTime import java.time.temporal.TemporalAdjusters import android.location.Location as AndroidLocation @@ -154,6 +155,8 @@ interface Location : SavableSearchable { val openingSchedule: OpeningSchedule? val departures: List? + val acceptedPaymentMethods: Map? + val attribution: Attribution? get() = null diff --git a/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/LocationPluginContract.kt b/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/LocationPluginContract.kt index d944180e..6a526a85 100644 --- a/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/LocationPluginContract.kt +++ b/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/LocationPluginContract.kt @@ -5,6 +5,7 @@ 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.search.location.PaymentMethod abstract class LocationPluginContract { object Paths { @@ -139,5 +140,11 @@ abstract class LocationPluginContract { * Type: String? (JSON) */ val Attribution = column("attribution") + + /** + * Accepted payment methods at this location, encoded as JSON. + * Type: String? (JSON) + */ + val AcceptedPaymentMethods = column>("accepted_payment_methods") } } \ No newline at end of file diff --git a/core/shared/src/main/java/de/mm20/launcher2/search/location/PaymentMethod.kt b/core/shared/src/main/java/de/mm20/launcher2/search/location/PaymentMethod.kt new file mode 100644 index 00000000..7f1b3782 --- /dev/null +++ b/core/shared/src/main/java/de/mm20/launcher2/search/location/PaymentMethod.kt @@ -0,0 +1,6 @@ +package de.mm20.launcher2.search.location + +enum class PaymentMethod { + Cash, + Card +} \ No newline at end of file diff --git a/data/locations/src/main/java/de/mm20/launcher2/locations/LocationSerialization.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/LocationSerialization.kt index f48714f5..93d5aa7c 100644 --- a/data/locations/src/main/java/de/mm20/launcher2/locations/LocationSerialization.kt +++ b/data/locations/src/main/java/de/mm20/launcher2/locations/LocationSerialization.kt @@ -1,7 +1,6 @@ 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 @@ -18,10 +17,10 @@ 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.search.location.PaymentMethod import de.mm20.launcher2.serialization.Json import kotlinx.coroutines.flow.firstOrNull import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString @Serializable internal data class SerializedLocation( @@ -42,6 +41,7 @@ internal data class SerializedLocation( val departures: List? = null, val fixMeUrl: String? = null, val attribution: Attribution? = null, + val acceptedPaymentMethods: Map? = null, val authority: String? = null, val storageStrategy: StorageStrategy? = null, ) @@ -67,6 +67,7 @@ internal class OsmLocationSerializer : SearchableSerializer { timestamp = searchable.timestamp, departures = searchable.departures, fixMeUrl = searchable.fixMeUrl, + acceptedPaymentMethods = searchable.acceptedPaymentMethods ) ) } @@ -96,6 +97,7 @@ internal class OsmLocationDeserializer( userRating = json.userRating, openingSchedule = json.openingSchedule, timestamp = json.timestamp ?: return null, + acceptedPaymentMethods = json.acceptedPaymentMethods, updatedSelf = { osmProvider.update(id) } @@ -134,6 +136,7 @@ internal class PluginLocationSerializer : SearchableSerializer { openingSchedule = searchable.openingSchedule, timestamp = searchable.timestamp, departures = searchable.departures, + acceptedPaymentMethods = searchable.acceptedPaymentMethods, fixMeUrl = searchable.fixMeUrl, authority = searchable.authority, storageStrategy = searchable.storageStrategy, @@ -185,6 +188,7 @@ internal class PluginLocationDeserializer( departures = json.departures, fixMeUrl = json.fixMeUrl, attribution = json.attribution, + acceptedPaymentMethods = json.acceptedPaymentMethods, authority = authority, storageStrategy = strategy, updatedSelf = { diff --git a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/PluginLocation.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/PluginLocation.kt index fda09d4b..7f58a9e8 100644 --- a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/PluginLocation.kt +++ b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/PluginLocation.kt @@ -14,6 +14,7 @@ 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.search.location.PaymentMethod import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -34,6 +35,7 @@ data class PluginLocation( override val label: String, override val timestamp: Long, override val attribution: Attribution?, + override val acceptedPaymentMethods: Map?, override val updatedSelf: (suspend (SavableSearchable) -> UpdateResult)?, override val labelOverride: String? = null, val authority: String, diff --git a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/PluginLocationProvider.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/PluginLocationProvider.kt index 5a0f2347..b2c42cdc 100644 --- a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/PluginLocationProvider.kt +++ b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/PluginLocationProvider.kt @@ -84,6 +84,7 @@ internal class PluginLocationProvider( userRatingCount = cursor[LocationColumns.UserRatingCount], departures = cursor[LocationColumns.Departures], attribution = cursor[LocationColumns.Attribution], + acceptedPaymentMethods = cursor[LocationColumns.AcceptedPaymentMethods], authority = pluginAuthority, updatedSelf = { if (it !is PluginLocation) UpdateResult.TemporarilyUnavailable() @@ -117,6 +118,7 @@ internal class PluginLocationProvider( set(LocationColumns.UserRatingCount, userRatingCount) set(LocationColumns.Departures, departures) set(LocationColumns.Attribution, attribution) + set(LocationColumns.AcceptedPaymentMethods, acceptedPaymentMethods) } } } \ No newline at end of file diff --git a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocation.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocation.kt index 4e5ac2e3..7ead7b07 100644 --- a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocation.kt +++ b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocation.kt @@ -4,6 +4,7 @@ import android.content.Context import de.mm20.launcher2.ktx.ifNullOrEmpty import de.mm20.launcher2.ktx.into import de.mm20.launcher2.ktx.map +import de.mm20.launcher2.ktx.stripStartOrNull import de.mm20.launcher2.locations.OsmLocationSerializer import de.mm20.launcher2.openstreetmaps.R import de.mm20.launcher2.search.Location @@ -16,6 +17,7 @@ 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 de.mm20.launcher2.search.location.PaymentMethod import de.westnordost.osm_opening_hours.model.ClockTime import de.westnordost.osm_opening_hours.model.ExtendedClockTime import de.westnordost.osm_opening_hours.model.LastNth @@ -62,7 +64,8 @@ internal data class OsmLocation( override val labelOverride: String? = null, override val timestamp: Long, override var updatedSelf: (suspend (SavableSearchable) -> UpdateResult)? = null, - override val userRating: Float? + override val userRating: Float?, + override val acceptedPaymentMethods: Map? ) : Location, UpdatableSearchable { override val domain: String @@ -115,7 +118,25 @@ internal data class OsmLocation( 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 } + ?.let { min(it, 5) / 5.0f }, + acceptedPaymentMethods = with( + it.tags.mapNotNull { (key, value) -> + (key.stripStartOrNull("payment:") ?: return@mapNotNull null) to value + }.toMap() + ) { + // best-effort way to take any method payment as it being available, + // otherwise as being unavailable, or undefined + mapOf( + PaymentMethod.Card to listOf("credit_cards", "debit_cards", "cards"), + PaymentMethod.Cash to listOf("cash") + ).mapNotNull { (method, values) -> + when { + values.any { this[it] in listOf("yes", "only") } -> method to true + values.any { this[it] == "no" } -> method to false + else -> null + } + }.toMap().takeUnless { it.isEmpty() } + } ) } }