From 1e4f1922e88878b3515577ebec80fde80f318a50 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 14:16:04 +0100 Subject: [PATCH] feat(android): bring the Android app level with the iOS app Everything the iOS shell gained, built natively for Android 16 with Material You, and back to Android 7: - Home-screen widgets in Jetpack Glance: trip, today, bookings, to-dos, packing, world map and travel group, from the same snapshot the iOS widgets read. Wallpaper colours on Android 12+, resizable in three layouts, a trip picker per widget (reconfigurable), live previews in Android 15's picker and preview images before it. - The travel day as a Live Update: a promoted ongoing notification with the next stop as the status-bar chip, a countdown and the day's stops as segments (ProgressStyle), a quiet ongoing notification before Android 16. - A share sheet of its own: a Material bottom sheet with the trip picker for files, mails, pages and links shared from any app, and the coming trips as launcher and direct-share shortcuts. - Push: channels per kind of notification (chat, trip updates, invitations, general) by the event the server now names, stacked per channel. - Location for navigator.geolocation from the fused provider, a store for Mapbox's map resources (30 days, 250 MB), Google Play Billing behind the StoreKit methods, and Google Wallet save links. - Large screens: every orientation and window size without a restart, the window's size classes and a foldable's posture and hinge for the UI (windowLayout), the UI's keyboard shortcuts as the app's (keyCommands), predictive back, per-app language. - The native strings in the 28 languages of the iOS app, generated from its strings files (scripts/generate-android-strings.mjs). Debug builds carry a widget gallery that draws every widget at every size. --- client/android/.gitignore | 3 + client/android/app/build.gradle | 46 + .../android/app/src/debug/AndroidManifest.xml | 10 + .../trek/debug/WidgetGalleryActivity.kt | 125 +++ .../android/app/src/main/AndroidManifest.xml | 146 +++- .../java/com/liketrek/trek/MainActivity.java | 31 + .../com/liketrek/trek/TrekPushService.java | 79 +- .../com/liketrek/trek/TrekSharedFiles.java | 24 +- .../com/liketrek/trek/TrekShellPlugin.java | 235 ++++- .../com/liketrek/trek/TrekWebViewClient.java | 10 + .../com/liketrek/trek/billing/TrekBilling.kt | 201 +++++ .../liketrek/trek/location/TrekLocation.kt | 83 ++ .../liketrek/trek/screen/TrekKeyCommands.kt | 101 +++ .../liketrek/trek/screen/TrekWindowLayout.kt | 80 ++ .../com/liketrek/trek/share/ShareActivity.kt | 417 +++++++++ .../com/liketrek/trek/share/ShareHandoff.kt | 68 ++ .../java/com/liketrek/trek/share/ShareItem.kt | 186 ++++ .../com/liketrek/trek/share/TrekShortcuts.kt | 66 ++ .../com/liketrek/trek/tiles/TrekTileStore.kt | 158 ++++ .../trek/tripday/TripDayNotification.kt | 146 ++++ .../com/liketrek/trek/ui/TrekMaterialTheme.kt | 53 ++ .../com/liketrek/trek/widget/TrekIcons.kt | 209 +++++ .../trek/widget/TrekWidgetReceivers.kt | 92 ++ .../com/liketrek/trek/widget/TrekWidgets.kt | 805 ++++++++++++++++++ .../com/liketrek/trek/widget/WidgetArt.kt | 152 ++++ .../trek/widget/WidgetConfigActivity.kt | 175 ++++ .../com/liketrek/trek/widget/WidgetSamples.kt | 89 ++ .../liketrek/trek/widget/WidgetSnapshot.kt | 286 +++++++ .../com/liketrek/trek/widget/WidgetStore.kt | 67 ++ .../java/com/liketrek/trek/widget/WidgetUi.kt | 278 ++++++ .../com/liketrek/trek/widget/WorldGrid.kt | 53 ++ .../drawable-nodpi/widget_preview_atlas.png | Bin 0 -> 45376 bytes .../widget_preview_bookings.png | Bin 0 -> 21868 bytes .../drawable-nodpi/widget_preview_collab.png | Bin 0 -> 29506 bytes .../drawable-nodpi/widget_preview_packing.png | Bin 0 -> 21335 bytes .../drawable-nodpi/widget_preview_today.png | Bin 0 -> 34750 bytes .../drawable-nodpi/widget_preview_todos.png | Bin 0 -> 13522 bytes .../drawable-nodpi/widget_preview_trip.png | Bin 0 -> 70457 bytes .../src/main/res/drawable/widget_scrim.xml | 9 + .../app/src/main/res/resources.properties | 1 + .../src/main/res/values-ar/strings_native.xml | 57 ++ .../src/main/res/values-az/strings_native.xml | 57 ++ .../res/values-b+zh+Hans/strings_native.xml | 57 ++ .../res/values-b+zh+Hant/strings_native.xml | 57 ++ .../src/main/res/values-ca/strings_native.xml | 57 ++ .../src/main/res/values-cs/strings_native.xml | 57 ++ .../src/main/res/values-de/strings_native.xml | 57 ++ .../src/main/res/values-el/strings_native.xml | 57 ++ .../src/main/res/values-es/strings_native.xml | 57 ++ .../src/main/res/values-et/strings_native.xml | 57 ++ .../src/main/res/values-fr/strings_native.xml | 57 ++ .../src/main/res/values-hu/strings_native.xml | 57 ++ .../src/main/res/values-in/strings_native.xml | 57 ++ .../src/main/res/values-it/strings_native.xml | 57 ++ .../src/main/res/values-iw/strings_native.xml | 57 ++ .../src/main/res/values-ja/strings_native.xml | 57 ++ .../src/main/res/values-ko/strings_native.xml | 57 ++ .../src/main/res/values-nl/strings_native.xml | 57 ++ .../src/main/res/values-pl/strings_native.xml | 57 ++ .../main/res/values-pt-rBR/strings_native.xml | 57 ++ .../src/main/res/values-ru/strings_native.xml | 57 ++ .../src/main/res/values-sk/strings_native.xml | 57 ++ .../src/main/res/values-sv/strings_native.xml | 57 ++ .../src/main/res/values-th/strings_native.xml | 57 ++ .../src/main/res/values-tr/strings_native.xml | 57 ++ .../src/main/res/values-uk/strings_native.xml | 57 ++ .../src/main/res/values-vi/strings_native.xml | 57 ++ .../app/src/main/res/values/colors.xml | 7 + .../app/src/main/res/values/strings.xml | 2 - .../src/main/res/values/strings_native.xml | 57 ++ .../app/src/main/res/values/styles.xml | 13 + .../app/src/main/res/xml/shortcuts.xml | 16 + .../app/src/main/res/xml/widget_atlas.xml | 17 + .../app/src/main/res/xml/widget_bookings.xml | 19 + .../app/src/main/res/xml/widget_collab.xml | 19 + .../app/src/main/res/xml/widget_packing.xml | 19 + .../app/src/main/res/xml/widget_today.xml | 19 + .../app/src/main/res/xml/widget_todos.xml | 19 + .../app/src/main/res/xml/widget_trip.xml | 19 + .../java/com/liketrek/trek/NativeLogicTest.kt | 118 +++ .../trek/widget/WidgetSnapshotTest.kt | 102 +++ client/android/build.gradle | 6 +- client/android/variables.gradle | 5 + client/scripts/android-strings.json | 254 ++++++ client/scripts/generate-android-strings.mjs | 76 ++ 85 files changed, 6782 insertions(+), 28 deletions(-) create mode 100644 client/android/app/src/debug/AndroidManifest.xml create mode 100644 client/android/app/src/debug/java/com/liketrek/trek/debug/WidgetGalleryActivity.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/billing/TrekBilling.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/location/TrekLocation.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/screen/TrekKeyCommands.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/screen/TrekWindowLayout.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/share/ShareActivity.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/share/ShareHandoff.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/share/ShareItem.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/share/TrekShortcuts.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/tiles/TrekTileStore.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/tripday/TripDayNotification.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/ui/TrekMaterialTheme.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/widget/TrekIcons.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgetReceivers.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgets.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/widget/WidgetArt.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/widget/WidgetConfigActivity.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/widget/WidgetSamples.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/widget/WidgetSnapshot.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/widget/WidgetStore.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/widget/WidgetUi.kt create mode 100644 client/android/app/src/main/java/com/liketrek/trek/widget/WorldGrid.kt create mode 100644 client/android/app/src/main/res/drawable-nodpi/widget_preview_atlas.png create mode 100644 client/android/app/src/main/res/drawable-nodpi/widget_preview_bookings.png create mode 100644 client/android/app/src/main/res/drawable-nodpi/widget_preview_collab.png create mode 100644 client/android/app/src/main/res/drawable-nodpi/widget_preview_packing.png create mode 100644 client/android/app/src/main/res/drawable-nodpi/widget_preview_today.png create mode 100644 client/android/app/src/main/res/drawable-nodpi/widget_preview_todos.png create mode 100644 client/android/app/src/main/res/drawable-nodpi/widget_preview_trip.png create mode 100644 client/android/app/src/main/res/drawable/widget_scrim.xml create mode 100644 client/android/app/src/main/res/resources.properties create mode 100644 client/android/app/src/main/res/values-ar/strings_native.xml create mode 100644 client/android/app/src/main/res/values-az/strings_native.xml create mode 100644 client/android/app/src/main/res/values-b+zh+Hans/strings_native.xml create mode 100644 client/android/app/src/main/res/values-b+zh+Hant/strings_native.xml create mode 100644 client/android/app/src/main/res/values-ca/strings_native.xml create mode 100644 client/android/app/src/main/res/values-cs/strings_native.xml create mode 100644 client/android/app/src/main/res/values-de/strings_native.xml create mode 100644 client/android/app/src/main/res/values-el/strings_native.xml create mode 100644 client/android/app/src/main/res/values-es/strings_native.xml create mode 100644 client/android/app/src/main/res/values-et/strings_native.xml create mode 100644 client/android/app/src/main/res/values-fr/strings_native.xml create mode 100644 client/android/app/src/main/res/values-hu/strings_native.xml create mode 100644 client/android/app/src/main/res/values-in/strings_native.xml create mode 100644 client/android/app/src/main/res/values-it/strings_native.xml create mode 100644 client/android/app/src/main/res/values-iw/strings_native.xml create mode 100644 client/android/app/src/main/res/values-ja/strings_native.xml create mode 100644 client/android/app/src/main/res/values-ko/strings_native.xml create mode 100644 client/android/app/src/main/res/values-nl/strings_native.xml create mode 100644 client/android/app/src/main/res/values-pl/strings_native.xml create mode 100644 client/android/app/src/main/res/values-pt-rBR/strings_native.xml create mode 100644 client/android/app/src/main/res/values-ru/strings_native.xml create mode 100644 client/android/app/src/main/res/values-sk/strings_native.xml create mode 100644 client/android/app/src/main/res/values-sv/strings_native.xml create mode 100644 client/android/app/src/main/res/values-th/strings_native.xml create mode 100644 client/android/app/src/main/res/values-tr/strings_native.xml create mode 100644 client/android/app/src/main/res/values-uk/strings_native.xml create mode 100644 client/android/app/src/main/res/values-vi/strings_native.xml create mode 100644 client/android/app/src/main/res/values/colors.xml create mode 100644 client/android/app/src/main/res/values/strings_native.xml create mode 100644 client/android/app/src/main/res/xml/shortcuts.xml create mode 100644 client/android/app/src/main/res/xml/widget_atlas.xml create mode 100644 client/android/app/src/main/res/xml/widget_bookings.xml create mode 100644 client/android/app/src/main/res/xml/widget_collab.xml create mode 100644 client/android/app/src/main/res/xml/widget_packing.xml create mode 100644 client/android/app/src/main/res/xml/widget_today.xml create mode 100644 client/android/app/src/main/res/xml/widget_todos.xml create mode 100644 client/android/app/src/main/res/xml/widget_trip.xml create mode 100644 client/android/app/src/test/java/com/liketrek/trek/NativeLogicTest.kt create mode 100644 client/android/app/src/test/java/com/liketrek/trek/widget/WidgetSnapshotTest.kt create mode 100644 client/scripts/android-strings.json create mode 100644 client/scripts/generate-android-strings.mjs diff --git a/client/android/.gitignore b/client/android/.gitignore index e3aa6c5ba..82f691d36 100644 --- a/client/android/.gitignore +++ b/client/android/.gitignore @@ -106,3 +106,6 @@ app/src/main/assets/public app/src/main/assets/capacitor.config.json app/src/main/assets/capacitor.plugins.json app/src/main/res/xml/config.xml + +# Kotlin compiler caches +.kotlin/ diff --git a/client/android/app/build.gradle b/client/android/app/build.gradle index aca4e544e..1a29303e7 100644 --- a/client/android/app/build.gradle +++ b/client/android/app/build.gradle @@ -1,4 +1,6 @@ apply plugin: 'com.android.application' +apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' android { namespace = "com.liketrek.trek" @@ -32,6 +34,25 @@ android { } } } + // java.time in the widgets and the travel-day notification, back to Android 7. + compileOptions { + coreLibraryDesugaringEnabled true + } + buildFeatures { + compose = true + buildConfig = true + } + // The app's languages, for the system's per-app language setting (Android 13+): + // generated from the values-* folders, so a language added there is offered too. + androidResources { + generateLocaleConfig = true + } + testOptions { + unitTests { + includeAndroidResources = true + returnDefaultValues = true + } + } buildTypes { release { if (project.hasProperty('trekKeystore')) { @@ -57,14 +78,39 @@ dependencies { implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation "androidx.browser:browser:$androidxBrowserVersion" implementation "androidx.biometric:biometric:1.1.0" + implementation "androidx.core:core-ktx:$androidxCoreVersion" + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5" + // Home-screen widgets. + implementation "androidx.glance:glance-appwidget:$glanceVersion" + implementation "androidx.glance:glance-material3:$glanceVersion" + // The share sheet and the widget's trip picker. + implementation platform("androidx.compose:compose-bom:$composeBomVersion") + implementation "androidx.compose.material3:material3" + implementation "androidx.compose.material:material-icons-extended" + implementation "androidx.compose.ui:ui-tooling-preview" + debugImplementation "androidx.compose.ui:ui-tooling" + implementation "androidx.activity:activity-compose:$androidxActivityVersion" + // A foldable's posture and hinge, and the window's size, for the UI. + implementation "androidx.window:window:1.4.0" + // Ranks the trip shortcuts in the share sheet on Android 10 and earlier. + implementation "androidx.sharetarget:sharetarget:1.2.0" + // TREK Cloud subscriptions through Google Play, the counterpart of StoreKit. + implementation "com.android.billingclient:billing:$playBillingVersion" // Push through liketrek's relay. No google-services plugin: Firebase reads // its project from res/values/firebase.xml, the strings the plugin would generate. implementation "com.google.firebase:firebase-messaging:24.1.2" implementation project(':capacitor-android') testImplementation "junit:junit:$junitVersion" + testImplementation "org.json:json:20250517" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" implementation project(':capacitor-cordova-android-plugins') } apply from: 'capacitor.build.gradle' + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 + } +} diff --git a/client/android/app/src/debug/AndroidManifest.xml b/client/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..7a510d7af --- /dev/null +++ b/client/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/client/android/app/src/debug/java/com/liketrek/trek/debug/WidgetGalleryActivity.kt b/client/android/app/src/debug/java/com/liketrek/trek/debug/WidgetGalleryActivity.kt new file mode 100644 index 000000000..2b5391300 --- /dev/null +++ b/client/android/app/src/debug/java/com/liketrek/trek/debug/WidgetGalleryActivity.kt @@ -0,0 +1,125 @@ +package com.liketrek.trek.debug + +import android.app.Activity +import android.content.res.Configuration +import android.graphics.Color +import android.os.Bundle +import android.util.TypedValue +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi +import androidx.glance.appwidget.GlanceRemoteViews +import com.liketrek.trek.tripday.TripDayNotification +import com.liketrek.trek.widget.AtlasWidget +import com.liketrek.trek.widget.BookingsWidget +import com.liketrek.trek.widget.CollabWidget +import com.liketrek.trek.widget.PackingWidget +import com.liketrek.trek.widget.TodayWidget +import com.liketrek.trek.widget.TodosWidget +import com.liketrek.trek.widget.TrekSizes +import com.liketrek.trek.widget.TrekWidget +import com.liketrek.trek.widget.TrekWidgetTheme +import com.liketrek.trek.widget.TripWidget +import com.liketrek.trek.widget.WidgetSamples +import com.liketrek.trek.widget.WidgetStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Debug builds only: every widget at every size, drawn by the real Glance code + * into real RemoteViews, so they can be looked at and screenshot without placing + * each on a home screen. + * + * adb shell am start -n com.liketrek.trek/.debug.WidgetGalleryActivity --es widget trip --ez running true --ez dark false + * + * --es seed sample also writes the sample trip as the app's snapshot, so the + * placed widgets, the share sheet and the shortcuts have a trip to show; and + * --ez tripday true posts the travel day's Live Update from the sample. + */ +@OptIn(ExperimentalGlanceRemoteViewsApi::class) +class WidgetGalleryActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val running = intent.getBooleanExtra("running", true) + val dark = intent.getBooleanExtra("dark", false) + val only = intent.getStringExtra("widget") + if (intent.getStringExtra("seed") == "sample") seed() + if (intent.getBooleanExtra("tripday", false)) { + TripDayNotification.show( + this, + TripDayNotification.State( + "Autumn in Kyoto", "/trips/1", "Day 3 of 9", "Kiyomizu-dera", "13:30", 810, 2, 5, + listOf("#D6273B", "#E8A13A", "#2FA37A", "#4A7DDB", "#8E6CD9"), + ), + ) + } + + val config = Configuration(resources.configuration).apply { + uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO + } + val themed = createConfigurationContext(config) + val list = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(px(16), px(if (intent.getBooleanExtra("bare", false)) 96 else 32), px(16), px(32)) + val bg = intent.getStringExtra("bg") + setBackgroundColor(if (bg != null) Color.parseColor("#$bg") else if (dark) Color.rgb(28, 27, 31) else Color.rgb(222, 226, 236)) + } + setContentView(ScrollView(this).apply { addView(list) }) + + val widgets: List> = listOf( + "trip" to TripWidget(), + "today" to TodayWidget(), + "bookings" to BookingsWidget(), + "todos" to TodosWidget(), + "packing" to PackingWidget(), + "atlas" to AtlasWidget(), + "collab" to CollabWidget(), + ).filter { only == null || it.first == only } + // --ez store true draws what the app really wrote, instead of the sample trip. + val snapshot = if (intent.getBooleanExtra("store", false)) WidgetStore.load(this) else WidgetSamples.snapshot(running) + CoroutineScope(Dispatchers.Main).launch { + for ((name, widget) in widgets) { + if (!intent.getBooleanExtra("bare", false)) list.addView(TextView(this@WidgetGalleryActivity).apply { + text = name + setTextColor(if (dark) Color.WHITE else Color.DKGRAY) + setPadding(0, px(12), 0, px(6)) + }) + val onlySize = intent.getStringExtra("size") + val sizes = listOf("small" to TrekSizes.SMALL, "wide" to TrekSizes.WIDE, "large" to TrekSizes.LARGE) + for ((sizeName, size) in sizes) { + if (onlySize != null && onlySize != sizeName) continue + val result = GlanceRemoteViews().compose(themed, size) { + TrekWidgetTheme.Themed { widget.Content(snapshot) } + } + val host = FrameLayout(this@WidgetGalleryActivity) + host.addView(result.remoteViews.apply(themed, host), FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + list.addView(host, LinearLayout.LayoutParams(px(size.width.value), px(size.height.value)).apply { bottomMargin = px(12) }) + } + } + } + } + + private fun seed() { + WidgetStore.write(this, "snapshot", WidgetSamples.json(running = true)) + WidgetStore.write(this, "trip.1", WidgetSamples.json(running = true)) + WidgetStore.write(this, "trip.2", WidgetSamples.json(running = false).replace("Autumn in Kyoto", "Lisbon long weekend")) + WidgetStore.write( + this, + "trips", + """[{"id":1,"title":"Autumn in Kyoto","start":"${java.time.LocalDate.now().minusDays(2)}","end":"${java.time.LocalDate.now().plusDays(6)}"}, + {"id":2,"title":"Lisbon long weekend","start":"${java.time.LocalDate.now().plusDays(12)}","end":"${java.time.LocalDate.now().plusDays(15)}"}, + {"id":3,"title":"Norway by train","start":"2026-06-02","end":"2026-06-12"}]""", + ) + com.liketrek.trek.widget.TrekWidgetUpdater.refreshAll(this) + com.liketrek.trek.share.TrekShortcuts.publish(this) + } + + private fun px(dp: Float): Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics).toInt() + + private fun px(dp: Int): Int = px(dp.toFloat()) + +} diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index 56b0297cd..bcccbaf04 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -9,31 +9,33 @@ android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" + android:enableOnBackInvokedCallback="true" + android:resizeableActivity="true" android:theme="@style/AppTheme"> + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java b/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java index 759651314..9de23bb52 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java +++ b/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java @@ -2,14 +2,25 @@ package com.liketrek.trek; import android.content.Intent; import android.os.Bundle; +import android.view.KeyEvent; +import android.view.KeyboardShortcutGroup; +import android.view.Menu; import android.view.View; import com.getcapacitor.BridgeActivity; +import com.liketrek.trek.screen.TrekKeyCommands; +import com.liketrek.trek.tiles.TrekTileStore; +import com.liketrek.trek.widget.TrekWidgetUpdater; +import java.util.List; public class MainActivity extends BridgeActivity { + /** The share sheet (share/ShareActivity.kt) left an item for the import. */ + public static final String ACTION_SHARE_HANDOFF = "com.liketrek.trek.SHARE_HANDOFF"; + @Override protected void onCreate(Bundle savedInstanceState) { registerPlugin(TrekShellPlugin.class); + TrekTileStore.INSTANCE.init(this); // With a server picked, the WebView loads that server instead of the // bundled address screen, so the UI arrives in the version the server // runs and on its own origin. BridgeActivity reads `config` while it @@ -27,6 +38,26 @@ public class MainActivity extends BridgeActivity { // Set before any WebView callback can run: they are posted to this thread. if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen)); TrekSharedFiles.receive(this, getIntent()); + // Android 15's widget picker draws the widgets live from a sample trip. + TrekWidgetUpdater.publishPreviews(this); + } + + /** + * A hardware keyboard's shortcut runs the UI's command (TrekKeyCommands), + * wherever the focus is; everything else goes on to the WebView as usual. + */ + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + if (TrekKeyCommands.INSTANCE.dispatch(event)) return true; + return super.dispatchKeyEvent(event); + } + + /** The UI's shortcuts in the system's list (Meta + /), under their names. */ + @Override + public void onProvideKeyboardShortcuts(List data, Menu menu, int deviceId) { + super.onProvideKeyboardShortcuts(data, menu, deviceId); + KeyboardShortcutGroup group = TrekKeyCommands.INSTANCE.shortcutGroup(getString(R.string.app_name)); + if (group != null) data.add(group); } @Override diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekPushService.java b/client/android/app/src/main/java/com/liketrek/trek/TrekPushService.java index 8724aa750..695bd9fa9 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekPushService.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekPushService.java @@ -37,6 +37,7 @@ public class TrekPushService extends FirebaseMessagingService { String body = getString(R.string.push_fallback_body); String url = null; String tag = null; + String event = null; try { TrekPushKeys keys = TrekPushKeys.load(this); if (keys != null) { @@ -46,30 +47,74 @@ public class TrekPushService extends FirebaseMessagingService { body = payload.optString("body", body); url = payload.optString("url", null); tag = payload.optString("tag", null); + event = payload.optString("event", null); } } catch (Exception unreadable) { // Shown with the neutral text: the tap still opens the app. } - show(this, title, body, url, tag); + show(this, title, body, url, tag, event); } /** A new token means a new endpoint; the UI re-registers on its next start (resyncPushSubscription). */ @Override public void onNewToken(@NonNull String token) {} + /** The channels, so each kind can be silenced, raised or kept on its own in the system settings. */ + static final String CHANNEL_CHAT = "chat"; + static final String CHANNEL_TRIPS = "trips"; + static final String CHANNEL_INVITES = "invites"; + + /** + * The channel of a notification by the event the server names (PushPayload.event, + * notification-events.ts). A server too old to send one lands in the general channel. + */ + static String channelFor(String event) { + if (event == null) return CHANNEL; + switch (event) { + case "collab_message": + return CHANNEL_CHAT; + case "booking_change": + case "trip_reminder": + case "todo_due": + case "packing_tagged": + return CHANNEL_TRIPS; + case "trip_invite": + case "vacay_invite": + case "vacay_share": + case "collection_invite": + case "photos_shared": + return CHANNEL_INVITES; + default: + return CHANNEL; + } + } + static void ensureChannel(Context context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; NotificationManager manager = context.getSystemService(NotificationManager.class); - if (manager == null || manager.getNotificationChannel(CHANNEL) != null) return; - NotificationChannel channel = new NotificationChannel( - CHANNEL, - context.getString(R.string.push_channel_name), - NotificationManager.IMPORTANCE_DEFAULT - ); - manager.createNotificationChannel(channel); + if (manager == null) return; + create(manager, CHANNEL_CHAT, context.getString(R.string.push_channel_chat), NotificationManager.IMPORTANCE_HIGH); + create(manager, CHANNEL_TRIPS, context.getString(R.string.push_channel_trips), NotificationManager.IMPORTANCE_DEFAULT); + create(manager, CHANNEL_INVITES, context.getString(R.string.push_channel_invites), NotificationManager.IMPORTANCE_DEFAULT); + create(manager, CHANNEL, context.getString(R.string.push_channel_name), NotificationManager.IMPORTANCE_DEFAULT); + com.liketrek.trek.tripday.TripDayNotification.INSTANCE.ensureChannel(context); } - private static void show(Context context, String title, String body, String url, String tag) { + @androidx.annotation.RequiresApi(Build.VERSION_CODES.O) + private static void create(NotificationManager manager, String id, String name, int importance) { + NotificationChannel existing = manager.getNotificationChannel(id); + if (existing != null) { + // Only the name follows the app's language; what the user set stays theirs. + if (!name.contentEquals(existing.getName())) { + existing.setName(name); + manager.createNotificationChannel(existing); + } + return; + } + manager.createNotificationChannel(new NotificationChannel(id, name, importance)); + } + + private static void show(Context context, String title, String body, String url, String tag, String event) { ensureChannel(context); Intent open = new Intent(context, MainActivity.class) .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); @@ -81,12 +126,17 @@ public class TrekPushService extends FirebaseMessagingService { open, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE ); - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL) + String channel = channelFor(event); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channel) .setSmallIcon(R.drawable.ic_stat_trek) .setContentTitle(title) .setContentText(body) .setStyle(new NotificationCompat.BigTextStyle().bigText(body)) .setAutoCancel(true) + .setColor(ContextCompat.getColor(context, R.color.notification_accent)) + .setCategory(CHANNEL_CHAT.equals(channel) ? NotificationCompat.CATEGORY_MESSAGE : NotificationCompat.CATEGORY_EVENT) + // One stack per kind in the shade, rather than a loose pile. + .setGroup("trek." + channel) .setContentIntent(tap); if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { @@ -94,6 +144,15 @@ public class TrekPushService extends FirebaseMessagingService { } try { NotificationManagerCompat.from(context).notify(tag, id, builder.build()); + // The stack's head: the system folds the group under it from two notifications on. + NotificationCompat.Builder summary = new NotificationCompat.Builder(context, channel) + .setSmallIcon(R.drawable.ic_stat_trek) + .setColor(ContextCompat.getColor(context, R.color.notification_accent)) + .setGroup("trek." + channel) + .setGroupSummary(true) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + .setAutoCancel(true); + NotificationManagerCompat.from(context).notify("trek.summary", channel.hashCode(), summary.build()); } catch (SecurityException notAllowed) { // Notifications switched off for the app since it subscribed. } diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekSharedFiles.java b/client/android/app/src/main/java/com/liketrek/trek/TrekSharedFiles.java index d7970f0cb..7974b33c8 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekSharedFiles.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekSharedFiles.java @@ -39,8 +39,16 @@ final class TrekSharedFiles { static void receive(Context context, Intent intent) { if (intent == null) return; + if (MainActivity.ACTION_SHARE_HANDOFF.equals(intent.getAction())) { + receiveHandoff(context); + return; + } Uri uri = null; - if (Intent.ACTION_VIEW.equals(intent.getAction())) uri = intent.getData(); + // A view intent also brings sign-in callbacks and widget links; only a file is a document. + if (Intent.ACTION_VIEW.equals(intent.getAction()) && intent.getData() != null + && ("content".equals(intent.getData().getScheme()) || "file".equals(intent.getData().getScheme()))) { + uri = intent.getData(); + } else if (Intent.ACTION_SEND.equals(intent.getAction())) uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); if (uri == null) return; try (InputStream in = context.getContentResolver().openInputStream(uri)) { @@ -64,6 +72,20 @@ final class TrekSharedFiles { } } + /** The item the share sheet left (share/ShareHandoff.kt), with the trip picked for it there. */ + static void receiveHandoff(Context context) { + kotlin.Pair item = com.liketrek.trek.share.ShareHandoff.INSTANCE.take(context); + if (item == null || item.getSecond().length > MAX_BYTES) return; + org.json.JSONObject meta = item.getFirst(); + JSObject file = new JSObject(); + file.put("name", meta.optString("name", "booking")); + file.put("mimeType", meta.optString("mimeType", "application/octet-stream")); + file.put("data", Base64.encodeToString(item.getSecond(), Base64.NO_WRAP)); + if (meta.has("tripId")) file.put("tripId", meta.optLong("tripId")); + pending = file; + if (listener != null) listener.onFile(file); + } + private static String displayName(Context context, Uri uri) { try (Cursor cursor = context.getContentResolver().query(uri, new String[] { OpenableColumns.DISPLAY_NAME }, null, null, null)) { if (cursor != null && cursor.moveToFirst()) return cursor.getString(0); diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java b/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java index 8c3ff7840..371630f71 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java @@ -22,7 +22,10 @@ import org.json.JSONObject; /** The native half of client/src/native/trekShell.ts. */ @CapacitorPlugin( name = "TrekShell", - permissions = { @Permission(strings = { Manifest.permission.POST_NOTIFICATIONS }, alias = "notifications") } + permissions = { + @Permission(strings = { Manifest.permission.POST_NOTIFICATIONS }, alias = "notifications"), + @Permission(strings = { Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION }, alias = "location"), + } ) public class TrekShellPlugin extends Plugin { @@ -41,7 +44,22 @@ public class TrekShellPlugin extends Plugin { */ static final int SHELL_API = 2; /** The native features by name, so the UI can turn each on by itself. */ - static final String[] FEATURES = { "capabilities", "biometricLock", "sharedFiles", "nativePush" }; + static final String[] FEATURES = { + "capabilities", + "biometricLock", + "sharedFiles", + "nativePush", + "geolocation", + "tileCache", + "widgets", + "widgetTrips", + "liveActivity", + "shareSheet", + "playBilling", + "googleWallet", + "keyCommands", + "windowLayout", + }; /** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */ static final String PUSH_RELAY = "https://push.liketrek.com/v1/fcm/"; @@ -76,6 +94,18 @@ public class TrekShellPlugin extends Plugin { @Override public void load() { TrekSharedFiles.setListener(file -> notifyListeners("sharedFile", file)); + com.liketrek.trek.screen.TrekKeyCommands.INSTANCE.setOnPress(id -> { + JSObject pressed = new JSObject(); + pressed.put("id", id); + notifyListeners("keyCommand", pressed); + return kotlin.Unit.INSTANCE; + }); + if (getActivity() != null) { + com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.observe(getActivity(), layout -> { + notifyListeners("windowLayout", layout); + return kotlin.Unit.INSTANCE; + }); + } if (getActivity() != null && getActivity().getIntent() != null) { openedPushUrl = getActivity().getIntent().getStringExtra(TrekPushService.EXTRA_URL); getActivity().getIntent().removeExtra(TrekPushService.EXTRA_URL); @@ -348,4 +378,205 @@ public class TrekShellPlugin extends Plugin { result.put("opened", false); call.resolve(result); } + + // ---- Widgets, shortcuts and the travel day ---- + + /** + * What the home-screen widgets show (native/widgets.ts), as JSON: "snapshot" + * (the dashboard's), "trip." or "trips" (the list a widget and the share + * sheet pick from). The widgets redraw, and a new trip list rebuilds the + * trip shortcuts. + */ + @PluginMethod + public void setWidgetSnapshot(PluginCall call) { + String json = call.getString("json"); + String key = call.getString("key", "snapshot"); + if (json == null) { + call.reject("json is required"); + return; + } + if (!com.liketrek.trek.widget.WidgetStore.INSTANCE.isValidKey(key)) { + call.reject("key must be snapshot, trips or trip."); + return; + } + android.content.Context context = getContext().getApplicationContext(); + try { + com.liketrek.trek.widget.WidgetStore.INSTANCE.write(context, key, json); + } catch (Exception e) { + call.reject("Could not store the widget snapshot", e); + return; + } + if ("trips".equals(key)) { + new Thread(() -> com.liketrek.trek.share.TrekShortcuts.INSTANCE.publish(context)).start(); + } else { + com.liketrek.trek.widget.TrekWidgetUpdater.refreshAll(context); + } + call.resolve(); + } + + /** Starts the travel day's Live Update, or updates the one showing. */ + @PluginMethod + public void syncTripDay(PluginCall call) { + String title = call.getString("tripTitle"); + String path = call.getString("path"); + String dayLabel = call.getString("dayLabel"); + String nextName = call.getString("nextName"); + if (title == null || path == null || dayLabel == null || nextName == null) { + call.reject("tripTitle, path, dayLabel and nextName are required"); + return; + } + java.util.List colors = new java.util.ArrayList<>(); + com.getcapacitor.JSArray raw = call.getArray("colors"); + if (raw != null) { + for (int i = 0; i < raw.length(); i++) colors.add(raw.isNull(i) ? null : raw.optString(i, null)); + } + com.liketrek.trek.tripday.TripDayNotification.State state = new com.liketrek.trek.tripday.TripDayNotification.State( + title, + path, + dayLabel, + nextName, + call.getString("nextTime"), + call.getInt("nextMinutes"), + call.getInt("done", 0), + call.getInt("total", 0), + colors + ); + boolean running = com.liketrek.trek.tripday.TripDayNotification.INSTANCE.show(getContext(), state); + JSObject result = new JSObject(); + result.put("running", running); + call.resolve(result); + } + + @PluginMethod + public void endTripDay(PluginCall call) { + com.liketrek.trek.tripday.TripDayNotification.INSTANCE.end(getContext()); + call.resolve(); + } + + // ---- Location ---- + + private com.liketrek.trek.location.TrekLocation location; + private boolean locationHighAccuracy; + + private com.liketrek.trek.location.TrekLocation location() { + if (location == null) { + location = new com.liketrek.trek.location.TrekLocation(getContext(), payload -> { + notifyListeners("location", payload); + return kotlin.Unit.INSTANCE; + }); + } + return location; + } + + /** One location stream for the UI's navigator.geolocation, asking for the permission the first time. */ + @PluginMethod + public void startLocation(PluginCall call) { + locationHighAccuracy = Boolean.TRUE.equals(call.getBoolean("highAccuracy", false)); + if (getPermissionState("location") != PermissionState.GRANTED) { + requestPermissionForAlias("location", call, "locationPermissionAnswered"); + return; + } + location().start(locationHighAccuracy); + call.resolve(); + } + + @PermissionCallback + private void locationPermissionAnswered(PluginCall call) { + if (getPermissionState("location") != PermissionState.GRANTED) { + location().emitError(1, "Location permission denied"); + } else { + location().start(locationHighAccuracy); + } + call.resolve(); + } + + @PluginMethod + public void stopLocation(PluginCall call) { + if (location != null) location.stop(); + call.resolve(); + } + + // ---- Map tile store ---- + + @PluginMethod + public void getTileCacheUsage(PluginCall call) { + new Thread(() -> { + JSObject result = new JSObject(); + result.put("bytes", com.liketrek.trek.tiles.TrekTileStore.INSTANCE.usage()); + result.put("limit", com.liketrek.trek.tiles.TrekTileStore.MAX_BYTES); + call.resolve(result); + }).start(); + } + + @PluginMethod + public void clearTileCache(PluginCall call) { + new Thread(() -> { + com.liketrek.trek.tiles.TrekTileStore.INSTANCE.clear(); + call.resolve(); + }).start(); + } + + // ---- Google Play Billing (the StoreKit methods' Android side) ---- + + @PluginMethod + public void storeProducts(PluginCall call) { + com.liketrek.trek.billing.TrekBilling.INSTANCE.products(getContext(), call); + } + + @PluginMethod + public void storePurchase(PluginCall call) { + com.liketrek.trek.billing.TrekBilling.INSTANCE.purchase(getActivity(), call); + } + + @PluginMethod + public void storeFinish(PluginCall call) { + com.liketrek.trek.billing.TrekBilling.INSTANCE.finish(getContext(), call); + } + + @PluginMethod + public void storeRestore(PluginCall call) { + com.liketrek.trek.billing.TrekBilling.INSTANCE.restore(getContext(), call); + } + + // ---- Google Wallet ---- + + /** The only links this hands on: Google Wallet's save-to-wallet page. */ + static final String GOOGLE_WALLET_SAVE = "https://pay.google.com/gp/v/save/"; + + /** + * Feature googleWallet: a booking's pass, by the save link the TREK services + * signed, into Google Wallet. The Wallet app takes the link where it is + * installed, the browser otherwise. + */ + @PluginMethod + public void addToGoogleWallet(PluginCall call) { + String url = call.getString("url"); + if (url == null || !url.startsWith(GOOGLE_WALLET_SAVE)) { + call.reject("not a Google Wallet save link", "INVALID"); + return; + } + try { + getActivity().startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE)); + JSObject result = new JSObject(); + result.put("result", "opened"); + call.resolve(result); + } catch (android.content.ActivityNotFoundException e) { + call.reject("Nothing can open Google Wallet", "UNAVAILABLE"); + } + } + + // ---- Large screens and keyboards ---- + + /** Feature keyCommands: the UI's keyboard shortcuts for the screen it shows. */ + @PluginMethod + public void setKeyCommands(PluginCall call) { + com.liketrek.trek.screen.TrekKeyCommands.INSTANCE.set(call.getArray("commands")); + call.resolve(); + } + + /** Feature windowLayout: the window's size classes and a foldable's posture and hinge. */ + @PluginMethod + public void getWindowLayout(PluginCall call) { + getActivity().runOnUiThread(() -> call.resolve(com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.describe(getActivity(), null))); + } } diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java b/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java index 389b971a9..b60c81e94 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java @@ -24,6 +24,16 @@ class TrekWebViewClient extends BridgeWebViewClient { this.onUnreachable = onUnreachable; } + /** Mapbox's map resources go through the app's own store (tiles/TrekTileStore.kt). */ + @Override + public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { + if (com.liketrek.trek.tiles.TrekTileStore.INSTANCE.handles(request)) { + WebResourceResponse stored = com.liketrek.trek.tiles.TrekTileStore.INSTANCE.serve(request); + if (stored != null) return stored; + } + return super.shouldInterceptRequest(view, request); + } + @Override public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { if (request.isForMainFrame()) { diff --git a/client/android/app/src/main/java/com/liketrek/trek/billing/TrekBilling.kt b/client/android/app/src/main/java/com/liketrek/trek/billing/TrekBilling.kt new file mode 100644 index 000000000..13c1e87a8 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/billing/TrekBilling.kt @@ -0,0 +1,201 @@ +package com.liketrek.trek.billing + +import android.app.Activity +import android.content.Context +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import com.getcapacitor.PluginCall +import com.liketrek.trek.BuildConfig + +/** + * TREK Cloud subscriptions through Google Play Billing, the counterpart of the + * iOS app's StoreKit 2 (TrekShellPlugin.swift, feature storeKit). The UI drives + * it through the same TrekShell methods (storeProducts, storePurchase, + * storeFinish, storeRestore) behind native/iap.ts's IapClient, so the shell's + * plan screens work unchanged; a purchase's token is the receipt the control + * plane verifies with Google (iap-google.ts in trek-hosted). + * + * Products are subscriptions with the ids `cloud..monthly|yearly`, one base + * plan each. A purchase stays unacknowledged until storeFinish, after the + * instance exists, so a purchase whose redeem never happened is offered again + * by a restore rather than lost (Play refunds what is not acknowledged within + * three days, and the control plane acknowledges too once it has verified). + */ +object TrekBilling { + private var client: BillingClient? = null + private var pendingPurchase: PluginCall? = null + private var pendingProduct: String? = null + private val waiting = mutableListOf<(Boolean) -> Unit>() + private var connecting = false + + private val listener = PurchasesUpdatedListener { result, purchases -> onPurchasesUpdated(result, purchases) } + + private fun client(context: Context): BillingClient = client ?: BillingClient.newBuilder(context.applicationContext) + .setListener(listener) + .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().enablePrepaidPlans().build()) + .enableAutoServiceReconnection() + .build() + .also { client = it } + + /** Runs [then] once connected; false when Play is not there (no Play Store, a sideloaded device). */ + private fun ready(context: Context, then: (Boolean) -> Unit) { + val billing = client(context) + if (billing.isReady) return then(true) + synchronized(waiting) { + waiting += then + if (connecting) return + connecting = true + } + billing.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(result: BillingResult) = flush(result.responseCode == BillingClient.BillingResponseCode.OK) + + override fun onBillingServiceDisconnected() = flush(false) + }) + } + + private fun flush(ok: Boolean) { + val callbacks = synchronized(waiting) { + connecting = false + waiting.toList().also { waiting.clear() } + } + callbacks.forEach { it(ok) } + } + + private fun details(context: Context, ids: List, done: (List) -> Unit) { + if (ids.isEmpty()) return done(emptyList()) + val params = QueryProductDetailsParams.newBuilder() + .setProductList(ids.map { QueryProductDetailsParams.Product.newBuilder().setProductId(it).setProductType(BillingClient.ProductType.SUBS).build() }) + .build() + client(context).queryProductDetailsAsync(params) { result, found -> + done(if (result.responseCode == BillingClient.BillingResponseCode.OK) found.productDetailsList else emptyList()) + } + } + + /** The recurring price of a subscription's base plan, as Play formats it for the buyer. */ + internal fun displayPrice(product: ProductDetails): String? { + val offer = baseOffer(product) ?: return null + return offer.pricingPhases.pricingPhaseList.lastOrNull()?.formattedPrice + } + + /** The base plan's own offer: the one without an offer id (free trials and promotions carry one). */ + private fun baseOffer(product: ProductDetails): ProductDetails.SubscriptionOfferDetails? { + val offers = product.subscriptionOfferDetails ?: return null + return offers.firstOrNull { it.offerId == null } ?: offers.firstOrNull() + } + + fun products(context: Context, call: PluginCall) { + val ids = call.getArray("ids")?.toList().orEmpty() + ready(context) { ok -> + val result = JSObject().put("sandbox", BuildConfig.DEBUG) + if (!ok) return@ready call.resolve(result.put("products", JSArray())) + details(context, ids) { found -> + val list = JSArray() + for (product in found) displayPrice(product)?.let { list.put(JSObject().put("id", product.productId).put("displayPrice", it)) } + call.resolve(result.put("products", list)) + } + } + } + + fun purchase(activity: Activity, call: PluginCall) { + val id = call.getString("productId") ?: return call.reject("productId is required") + ready(activity) { ok -> + if (!ok) return@ready call.resolve(JSObject().put("kind", "failed")) + details(activity, listOf(id)) { found -> + val product = found.firstOrNull() + val offer = product?.let(::baseOffer) + if (product == null || offer == null) return@details call.resolve(JSObject().put("kind", "failed")) + pendingPurchase?.resolve(JSObject().put("kind", "cancelled")) + pendingPurchase = call + pendingProduct = id + val params = BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + listOf(BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(product).setOfferToken(offer.offerToken).build()), + ) + .build() + activity.runOnUiThread { + val launch = client(activity).launchBillingFlow(activity, params) + if (launch.responseCode != BillingClient.BillingResponseCode.OK) { + settle(if (launch.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) "cancelled" else "failed", null) + } + } + } + } + } + + private fun onPurchasesUpdated(result: BillingResult, purchases: List?) { + if (pendingPurchase == null) return // A renewal or a pending one going through later: a restore picks it up. + when (result.responseCode) { + BillingClient.BillingResponseCode.OK -> { + val purchase = purchases?.firstOrNull { pendingProduct in it.products } ?: purchases?.firstOrNull() + when (purchase?.purchaseState) { + Purchase.PurchaseState.PURCHASED -> settle("bought", purchase) + Purchase.PurchaseState.PENDING -> settle("pending", null) + else -> settle("failed", null) + } + } + BillingClient.BillingResponseCode.USER_CANCELED -> settle("cancelled", null) + // Already subscribed on this Google account: the shell offers the restore. + BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> settle("failed", null) + else -> settle("failed", null) + } + } + + private fun settle(kind: String, purchase: Purchase?) { + val call = pendingPurchase ?: return + pendingPurchase = null + val product = pendingProduct + pendingProduct = null + val out = JSObject().put("kind", kind) + if (purchase != null) { + out.put("purchaseToken", purchase.purchaseToken) + out.put("transactionId", purchase.purchaseToken) + out.put("productId", purchase.products.firstOrNull() ?: product) + purchase.orderId?.let { out.put("orderId", it) } + } + call.resolve(out) + } + + /** Marks the purchase delivered, once its instance exists: acknowledged with Play. */ + fun finish(context: Context, call: PluginCall) { + val token = call.getString("transactionId") ?: return call.reject("transactionId is required") + ready(context) { ok -> + if (!ok) return@ready call.resolve() + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(token).build() + client(context).acknowledgePurchase(params) { call.resolve() } + } + } + + /** The newest active subscription under `prefix` on this Google account, if any. */ + fun restore(context: Context, call: PluginCall) { + val prefix = call.getString("prefix") ?: "cloud." + ready(context) { ok -> + if (!ok) return@ready call.resolve(JSObject()) + val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build() + client(context).queryPurchasesAsync(params) { result, purchases -> + val found = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, prefix) else null + if (found == null) return@queryPurchasesAsync call.resolve(JSObject()) + call.resolve( + JSObject() + .put("purchaseToken", found.purchaseToken) + .put("transactionId", found.purchaseToken) + .put("productId", found.products.first { it.startsWith(prefix) }), + ) + } + } + } + + internal fun newest(purchases: List, prefix: String): Purchase? = purchases + .filter { p -> p.purchaseState == Purchase.PurchaseState.PURCHASED && p.products.any { it.startsWith(prefix) } } + .maxByOrNull { it.purchaseTime } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/location/TrekLocation.kt b/client/android/app/src/main/java/com/liketrek/trek/location/TrekLocation.kt new file mode 100644 index 000000000..a5823e7e4 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/location/TrekLocation.kt @@ -0,0 +1,83 @@ +package com.liketrek.trek.location + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location +import android.location.LocationManager +import android.os.Build +import androidx.core.location.LocationListenerCompat +import androidx.core.location.LocationManagerCompat +import androidx.core.location.LocationRequestCompat +import androidx.core.content.ContextCompat +import com.getcapacitor.JSObject +import org.json.JSONObject + +/** + * One location stream for the UI's navigator.geolocation (native/geolocation.ts), + * the counterpart of TrekLocation in TrekShellPlugin.swift: the system's fused + * provider where there is one (Android 12+), GPS and network before, with + * positions and errors shaped like the Geolocation API's. Asking for the + * permission is the plugin's part; this starts once it is granted. + */ +class TrekLocation(context: Context, private val emit: (JSObject) -> Unit) { + private val manager = context.getSystemService(LocationManager::class.java) + private val executor = ContextCompat.getMainExecutor(context) + private val listeners = mutableListOf() + + @SuppressLint("MissingPermission") + fun start(highAccuracy: Boolean) { + stop() + if (manager == null || !LocationManagerCompat.isLocationEnabled(manager)) { + emitError(2, "Location services are off") + return + } + val request = LocationRequestCompat.Builder(if (highAccuracy) 2_000L else 10_000L) + .setQuality(if (highAccuracy) LocationRequestCompat.QUALITY_HIGH_ACCURACY else LocationRequestCompat.QUALITY_BALANCED_POWER_ACCURACY) + .setMinUpdateDistanceMeters(if (highAccuracy) 2f else 25f) + .build() + val providers = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && manager.hasProvider(LocationManager.FUSED_PROVIDER) -> listOf(LocationManager.FUSED_PROVIDER) + else -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER).filter { manager.isProviderEnabled(it) } + } + if (providers.isEmpty()) { + emitError(2, "No location provider") + return + } + // The last known fix answers getCurrentPosition at once; a fresh one follows. + providers.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() } + .maxByOrNull { it.time } + ?.takeIf { System.currentTimeMillis() - it.time < 120_000 } + ?.let { emit(payload(it)) } + for (provider in providers) { + val listener = LocationListenerCompat { location -> emit(payload(location)) } + try { + LocationManagerCompat.requestLocationUpdates(manager, provider, request, executor, listener) + listeners += listener + } catch (e: SecurityException) { + emitError(1, "Location permission denied") + return + } + } + } + + fun stop() { + val m = manager ?: return + for (listener in listeners) LocationManagerCompat.removeUpdates(m, listener) + listeners.clear() + } + + fun emitError(code: Int, message: String) { + emit(JSObject().put("error", JSObject().put("code", code).put("message", message))) + } + + companion object { + fun payload(location: Location): JSObject = JSObject() + .put("latitude", location.latitude) + .put("longitude", location.longitude) + .put("accuracy", location.accuracy.toDouble()) + .put("altitude", if (location.hasAltitude()) location.altitude else JSONObject.NULL) + .put("speed", if (location.hasSpeed()) location.speed.toDouble() else JSONObject.NULL) + .put("heading", if (location.hasBearing()) location.bearing.toDouble() else JSONObject.NULL) + .put("timestamp", location.time.toDouble()) + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/screen/TrekKeyCommands.kt b/client/android/app/src/main/java/com/liketrek/trek/screen/TrekKeyCommands.kt new file mode 100644 index 000000000..925b3e191 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/screen/TrekKeyCommands.kt @@ -0,0 +1,101 @@ +package com.liketrek.trek.screen + +import android.view.KeyEvent +import android.view.KeyboardShortcutGroup +import android.view.KeyboardShortcutInfo +import org.json.JSONArray +import org.json.JSONObject + +/** + * The UI's keyboard shortcuts as the app's own (TrekShell.setKeyCommands, feature + * keyCommands), the Android side of TrekKeyCommands in the iOS shell: a hardware + * keyboard's shortcut runs the UI's command even while the focus sits outside the + * page, and the system's shortcut list (Meta + /) shows them under their names in + * the UI's language. Each screen replaces the list, so only what works here is on it. + * + * "command" is Ctrl on Android (Meta is accepted too, for keyboards made for a Mac), + * "option" is Alt. + */ +object TrekKeyCommands { + data class Command(val id: String, val title: String, val keyCode: Int, val modifiers: Int) + + @Volatile var current: List = emptyList() + private set + + var onPress: ((String) -> Unit)? = null + + fun set(raw: JSONArray?) { + current = parse(raw) + } + + fun parse(raw: JSONArray?): List { + if (raw == null) return emptyList() + return (0 until raw.length()).mapNotNull { i -> + val o: JSONObject = raw.optJSONObject(i) ?: return@mapNotNull null + val id = o.optString("id").ifEmpty { return@mapNotNull null } + val title = o.optString("title").ifEmpty { return@mapNotNull null } + val key = keyCode(o.optString("input")) ?: return@mapNotNull null + var meta = 0 + val mods = o.optJSONArray("modifiers") + for (j in 0 until (mods?.length() ?: 0)) { + meta = meta or when (mods!!.optString(j)) { + "command", "control" -> KeyEvent.META_CTRL_ON + "shift" -> KeyEvent.META_SHIFT_ON + "option" -> KeyEvent.META_ALT_ON + else -> 0 + } + } + Command(id, title, key, meta) + } + } + + /** The key a UI input names: a letter, a digit, an arrow, escape or a punctuation mark. */ + fun keyCode(input: String): Int? = when (input.lowercase()) { + "left" -> KeyEvent.KEYCODE_DPAD_LEFT + "right" -> KeyEvent.KEYCODE_DPAD_RIGHT + "up" -> KeyEvent.KEYCODE_DPAD_UP + "down" -> KeyEvent.KEYCODE_DPAD_DOWN + "escape" -> KeyEvent.KEYCODE_ESCAPE + "enter", "\r" -> KeyEvent.KEYCODE_ENTER + "," -> KeyEvent.KEYCODE_COMMA + "." -> KeyEvent.KEYCODE_PERIOD + "/" -> KeyEvent.KEYCODE_SLASH + "[" -> KeyEvent.KEYCODE_LEFT_BRACKET + "]" -> KeyEvent.KEYCODE_RIGHT_BRACKET + "-" -> KeyEvent.KEYCODE_MINUS + "=" -> KeyEvent.KEYCODE_EQUALS + " ", "space" -> KeyEvent.KEYCODE_SPACE + else -> when { + input.length == 1 && input[0].lowercaseChar() in 'a'..'z' -> KeyEvent.KEYCODE_A + (input[0].lowercaseChar() - 'a') + input.length == 1 && input[0] in '0'..'9' -> KeyEvent.KEYCODE_0 + (input[0] - '0') + else -> null + } + } + + private const val MASK = KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON or KeyEvent.META_ALT_ON + + /** The command a key press stands for, if any. Meta counts as Ctrl. */ + fun match(keyCode: Int, metaState: Int): Command? { + var state = metaState + if (state and KeyEvent.META_META_ON != 0) state = state or KeyEvent.META_CTRL_ON + val pressed = state and MASK + return current.firstOrNull { it.keyCode == keyCode && it.modifiers == pressed } + } + + /** Runs the matching command for a key going down. True when one took it. */ + fun dispatch(event: KeyEvent): Boolean { + if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return false + val command = match(event.keyCode, event.metaState) ?: return false + // A plain key (no modifier) is left to a text field that has the focus. + if (command.modifiers == 0 && event.keyCode != KeyEvent.KEYCODE_ESCAPE) return false + val press = onPress ?: return false + press(command.id) + return true + } + + /** The list the system shows for Meta + /. */ + fun shortcutGroup(label: CharSequence): KeyboardShortcutGroup? { + val items = current.map { KeyboardShortcutInfo(it.title, it.keyCode, it.modifiers) } + return if (items.isEmpty()) null else KeyboardShortcutGroup(label, items) + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/screen/TrekWindowLayout.kt b/client/android/app/src/main/java/com/liketrek/trek/screen/TrekWindowLayout.kt new file mode 100644 index 000000000..831b85ae6 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/screen/TrekWindowLayout.kt @@ -0,0 +1,80 @@ +package com.liketrek.trek.screen + +import android.app.Activity +import android.graphics.Rect +import androidx.activity.ComponentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +import androidx.window.layout.WindowLayoutInfo +import androidx.window.layout.WindowMetricsCalculator +import com.getcapacitor.JSObject +import kotlinx.coroutines.launch + +/** + * The window's shape for the UI (TrekShell.getWindowLayout and the + * "windowLayout" event, feature windowLayout): its size class by Android's + * breakpoints, and a foldable's posture and hinge through Jetpack WindowManager. + * + * The layouts themselves follow the CSS size classes (hooks/sizeClass.ts), which + * the WebView answers from the window like any browser; this adds what CSS cannot + * see: that a Galaxy Z Fold or Pixel Fold is half open like a laptop or a book, + * and where the hinge runs, so a layout can keep content off the fold. Lengths + * are in CSS pixels, relative to the window. + */ +object TrekWindowLayout { + @Volatile var latest: JSObject = JSObject() + private set + + fun observe(activity: ComponentActivity, emit: (JSObject) -> Unit) { + latest = describe(activity, null) + activity.lifecycleScope.launch { + activity.repeatOnLifecycle(Lifecycle.State.STARTED) { + WindowInfoTracker.getOrCreate(activity).windowLayoutInfo(activity).collect { info -> + latest = describe(activity, info) + emit(latest) + } + } + } + } + + fun describe(activity: Activity, info: WindowLayoutInfo?): JSObject { + val density = activity.resources.displayMetrics.density + val bounds = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity).bounds + val widthDp = bounds.width() / density + val heightDp = bounds.height() / density + val result = JSObject() + .put("width", widthDp.toInt()) + .put("height", heightDp.toInt()) + .put("widthClass", sizeClass(widthDp, 600f, 840f)) + .put("heightClass", sizeClass(heightDp, 480f, 900f)) + val fold = info?.displayFeatures?.filterIsInstance()?.firstOrNull() + if (fold == null) { + result.put("posture", "none") + } else { + val r: Rect = fold.bounds + result.put("posture", if (fold.state == FoldingFeature.State.HALF_OPENED) "halfOpened" else "flat") + .put("orientation", if (fold.orientation == FoldingFeature.Orientation.VERTICAL) "vertical" else "horizontal") + .put("separating", fold.isSeparating) + .put("occluding", fold.occlusionType == FoldingFeature.OcclusionType.FULL) + .put( + "hinge", + JSObject() + .put("left", ((r.left - bounds.left) / density).toInt()) + .put("top", ((r.top - bounds.top) / density).toInt()) + .put("right", ((r.right - bounds.left) / density).toInt()) + .put("bottom", ((r.bottom - bounds.top) / density).toInt()), + ) + } + return result + } + + /** Android's window size classes: compact, medium, expanded. */ + fun sizeClass(dp: Float, medium: Float, expanded: Float): String = when { + dp < medium -> "compact" + dp < expanded -> "medium" + else -> "expanded" + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/share/ShareActivity.kt b/client/android/app/src/main/java/com/liketrek/trek/share/ShareActivity.kt new file mode 100644 index 000000000..6409ce459 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/share/ShareActivity.kt @@ -0,0 +1,417 @@ +package com.liketrek.trek.share + +import android.content.Intent +import android.graphics.Bitmap +import android.os.Bundle +import android.text.format.Formatter +import android.view.HapticFeedbackConstants +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CalendarMonth +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.Email +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.PictureAsPdf +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.ShortText +import androidx.compose.material.icons.rounded.Wallet +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.lifecycle.lifecycleScope +import com.liketrek.trek.MainActivity +import com.liketrek.trek.R +import com.liketrek.trek.ui.TrekMaterialTheme +import com.liketrek.trek.widget.WidgetArt +import java.time.LocalDate +import java.time.temporal.ChronoUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * "TREK" in the system share sheet (and "Open with TREK"): a Material bottom + * sheet over the app that shared, showing what came in, the trip it goes to and + * one button, the counterpart of the iOS share extension (ShareView.swift). The + * item and the trip are left for the app (ShareHandoff) and TREK opens, where + * the booking import takes over. A direct-share shortcut of a trip + * (TrekShortcuts) arrives with that trip already picked. + */ +class ShareActivity : ComponentActivity() { + private sealed interface Phase { + data object Loading : Phase + data object Ready : Phase + data object Saving : Phase + data object Done : Phase + data class Failed(val message: Int) : Phase + } + + private var phase by mutableStateOf(Phase.Loading) + private var item by mutableStateOf(null) + private var trips by mutableStateOf>(emptyList()) + private var selected by mutableStateOf(null) + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + trips = ShareHandoff.trips(this) + val shortcutTrip = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID)?.removePrefix("trip.")?.toLongOrNull() + selected = shortcutTrip?.takeIf { id -> trips.any { it.id == id } } ?: trips.firstOrNull()?.id + val incoming = intent + lifecycleScope.launch { + phase = try { + item = withContext(Dispatchers.IO) { ShareLoader.load(incoming, contentResolver) } + Phase.Ready + } catch (e: ShareLoadException) { + Phase.Failed(if (e.reason == ShareLoadException.Reason.TOO_LARGE) R.string.share_too_large else R.string.share_unsupported) + } catch (e: Exception) { + Phase.Failed(R.string.share_unsupported) + } + } + setContent { + TrekMaterialTheme { + Sheet() + } + } + } + + private fun add() { + val current = item ?: return + if (phase != Phase.Ready) return + phase = Phase.Saving + lifecycleScope.launch { + val stored = withContext(Dispatchers.IO) { ShareHandoff.store(this@ShareActivity, current, selected) } + if (!stored) { + window.decorView.performHapticFeedback(HapticFeedbackConstants.REJECT) + phase = Phase.Failed(R.string.share_failed) + return@launch + } + window.decorView.performHapticFeedback(HapticFeedbackConstants.CONFIRM) + phase = Phase.Done + delay(850) + startActivity( + Intent(this@ShareActivity, MainActivity::class.java) + .setAction(MainActivity.ACTION_SHARE_HANDOFF) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP), + ) + finish() + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun Sheet() { + val state = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val dismiss: () -> Unit = { + scope.launch { state.hide() }.invokeOnCompletion { finish() } + } + ModalBottomSheet( + onDismissRequest = { finish() }, + sheetState = state, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp)) { + Header(onClose = dismiss) + Spacer(Modifier.height(16.dp)) + when (val now = phase) { + is Phase.Failed -> Failure(stringResource(now.message), onClose = dismiss) + else -> Content(now) + } + } + } + } + + @Composable + private fun Header(onClose: () -> Unit) { + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text("T", color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Black, fontSize = 21.sp) + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text("TREK", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, letterSpacing = 2.4.sp) + Text(stringResource(R.string.share_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + TextButton(onClick = onClose) { Text(stringResource(R.string.share_cancel)) } + } + } + + @Composable + private fun Content(now: Phase) { + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + ItemCard(item, Modifier.padding(horizontal = 20.dp)) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + stringResource(R.string.share_trip).uppercase(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold, + letterSpacing = 1.2.sp, + modifier = Modifier.padding(horizontal = 22.dp), + ) + if (trips.isEmpty()) { + Text( + stringResource(R.string.share_no_trips), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 22.dp), + ) + } else { + TripRail() + } + } + Text( + stringResource(R.string.share_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 22.dp), + ) + AddButton(now) + } + } + + @Composable + private fun ItemCard(item: ShareItem?, modifier: Modifier) { + val context = LocalContext.current + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = modifier.fillMaxWidth(), + ) { + Row(modifier = Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier.size(46.dp).clip(RoundedCornerShape(14.dp)).background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center, + ) { + if (item == null) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Icon(kindIcon(item.kind), null, tint = MaterialTheme.colorScheme.onSecondaryContainer) + } + } + Spacer(Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + item?.title ?: stringResource(R.string.share_reading), + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (item != null) { + val parts = listOfNotNull(stringResource(kindLabel(item.kind)), Formatter.formatShortFileSize(context, item.data.size.toLong()), item.source) + Text(parts.joinToString(", "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + } + } + } + } + + @Composable + private fun TripRail() { + LazyRow(contentPadding = PaddingValues(horizontal = 20.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + items(trips, key = { it.id }) { trip -> + TripCard(trip, selected == trip.id) { selected = trip.id } + } + item { + FilterChip( + selected = selected == null, + onClick = { selected = null }, + label = { Text(stringResource(R.string.share_later)) }, + leadingIcon = { Icon(Icons.Rounded.Schedule, null, modifier = Modifier.size(18.dp)) }, + modifier = Modifier.height(132.dp).padding(vertical = 44.dp), + ) + } + } + } + + @Composable + private fun TripCard(trip: ShareHandoff.Trip, isSelected: Boolean, onClick: () -> Unit) { + val view = LocalView.current + val cover: Bitmap? = remember(trip.id) { WidgetArt.cover(trip.cover, 360) ?: WidgetArt.sky(trip.title, 300, 200) } + val border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + Surface( + shape = RoundedCornerShape(20.dp), + border = border, + modifier = Modifier.width(168.dp).height(132.dp).clip(RoundedCornerShape(20.dp)).clickable { + view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + onClick() + }, + ) { + Box { + if (cover != null) { + Image(cover.asImageBitmap(), null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + } + Box( + modifier = Modifier.fillMaxSize().background( + Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.1f), Color.Black.copy(alpha = 0.68f))), + ), + ) + if (isSelected) { + Box( + modifier = Modifier.padding(8.dp).size(24.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary).align(Alignment.TopEnd), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Rounded.Check, null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(16.dp)) + } + } + Column(modifier = Modifier.align(Alignment.BottomStart).padding(12.dp)) { + Text(when_(trip), color = Color.White.copy(alpha = 0.82f), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold) + Text(trip.title, color = Color.White, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + } + + @Composable + private fun when_(trip: ShareHandoff.Trip): String { + val today = LocalDate.now() + val start = trip.start + val end = trip.end + return when { + end != null && end < today -> stringResource(R.string.share_past) + start == null || start <= today -> stringResource(R.string.share_now) + ChronoUnit.DAYS.between(today, start) == 1L -> stringResource(R.string.share_tomorrow) + else -> stringResource(R.string.share_in_days, ChronoUnit.DAYS.between(today, start).toInt()) + } + } + + @Composable + private fun AddButton(now: Phase) { + val title = trips.firstOrNull { it.id == selected }?.title + Button( + onClick = ::add, + enabled = now == Phase.Ready, + shape = RoundedCornerShape(18.dp), + colors = if (now == Phase.Done) { + ButtonDefaults.buttonColors(containerColor = Color(0xFF2FA37A), disabledContainerColor = Color(0xFF2FA37A), disabledContentColor = Color.White) + } else { + ButtonDefaults.buttonColors() + }, + contentPadding = PaddingValues(vertical = 16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), + ) { + AnimatedContent( + targetState = now, + transitionSpec = { (fadeIn() + scaleIn(spring(dampingRatio = 0.6f))) togetherWith fadeOut() }, + label = "add", + ) { target -> + when (target) { + Phase.Saving, Phase.Loading -> CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + Phase.Done -> Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Check, null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.share_added), fontWeight = FontWeight.Bold) + } + else -> Text( + if (title != null) stringResource(R.string.share_add_to, title) else stringResource(R.string.share_add), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + + @Composable + private fun Failure(message: String, onClose: () -> Unit) { + Column(modifier = Modifier.fillMaxWidth().padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Rounded.ErrorOutline, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(40.dp)) + Spacer(Modifier.height(12.dp)) + Text(message, style = MaterialTheme.typography.bodyLarge) + Spacer(Modifier.height(16.dp)) + Button(onClick = onClose, shape = RoundedCornerShape(18.dp)) { Text(stringResource(R.string.share_close)) } + } + } + + private fun kindIcon(kind: ShareItem.Kind): ImageVector = when (kind) { + ShareItem.Kind.PASS -> Icons.Rounded.Wallet + ShareItem.Kind.PDF -> Icons.Rounded.PictureAsPdf + ShareItem.Kind.MAIL -> Icons.Rounded.Email + ShareItem.Kind.CALENDAR -> Icons.Rounded.CalendarMonth + ShareItem.Kind.PAGE -> Icons.Rounded.Language + ShareItem.Kind.TEXT -> Icons.Rounded.ShortText + ShareItem.Kind.IMAGE -> Icons.Rounded.Image + ShareItem.Kind.FILE -> Icons.Rounded.Description + } + + private fun kindLabel(kind: ShareItem.Kind): Int = when (kind) { + ShareItem.Kind.PASS -> R.string.share_kind_pass + ShareItem.Kind.PDF -> R.string.share_kind_pdf + ShareItem.Kind.MAIL -> R.string.share_kind_mail + ShareItem.Kind.CALENDAR -> R.string.share_kind_file + ShareItem.Kind.PAGE -> R.string.share_kind_page + ShareItem.Kind.TEXT -> R.string.share_kind_text + ShareItem.Kind.IMAGE -> R.string.share_kind_image + ShareItem.Kind.FILE -> R.string.share_kind_file + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/share/ShareHandoff.kt b/client/android/app/src/main/java/com/liketrek/trek/share/ShareHandoff.kt new file mode 100644 index 000000000..388197056 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/share/ShareHandoff.kt @@ -0,0 +1,68 @@ +package com.liketrek.trek.share + +import android.content.Context +import com.liketrek.trek.widget.WidgetSnapshot +import com.liketrek.trek.widget.WidgetStore +import java.io.File +import java.time.LocalDate +import org.json.JSONObject + +/** + * The trips the share sheet offers, and the hand-over of the shared item to the + * app. The trip list is the one the app keeps for the widgets (WidgetStore, key + * "trips"); the item waits in the app's cache until MainActivity hands it to the + * UI (TrekSharedFiles.receiveHandoff), the way ShareHandoff.swift does through + * the keychain on iOS. + */ +object ShareHandoff { + data class Trip( + val id: Long, + val title: String, + val start: LocalDate?, + val end: LocalDate?, + val place: String?, + /** The small cover from the trip's widget snapshot, base64, when the app wrote one. */ + val cover: String?, + ) + + /** The trips that have not ended yet, soonest first, then the six most recent past ones. */ + fun trips(context: Context, today: LocalDate = LocalDate.now()): List { + val all = WidgetStore.trips(context).map { ref -> + val snapshot = WidgetStore.read(context, "trip.${ref.id}")?.let(WidgetSnapshot::parse) + Trip(ref.id, ref.title, WidgetSnapshot.day(ref.start), WidgetSnapshot.day(ref.end), snapshot?.trip?.place, snapshot?.trip?.cover) + } + return order(all, today) + } + + internal fun order(all: List, today: LocalDate): List { + val ahead = all.filter { (it.end ?: LocalDate.MAX) >= today }.sortedBy { it.start ?: LocalDate.MAX } + val past = all.filter { (it.end ?: LocalDate.MAX) < today }.sortedByDescending { it.end ?: LocalDate.MIN } + return ahead + past.take(6) + } + + private fun dir(context: Context) = File(context.cacheDir, "share").apply { mkdirs() } + + /** Leaves the item for the app, with the trip it goes to (null: the app asks). */ + fun store(context: Context, item: ShareItem, tripId: Long?): Boolean = runCatching { + val meta = JSONObject().put("name", item.name).put("mimeType", item.mimeType) + if (tripId != null) meta.put("tripId", tripId) + File(dir(context), "pending.data").writeBytes(item.data) + File(dir(context), "pending.json").writeText(meta.toString()) + true + }.getOrDefault(false) + + /** The item left by [store], taken once. */ + fun take(context: Context): Pair? { + val metaFile = File(dir(context), "pending.json") + val dataFile = File(dir(context), "pending.data") + if (!metaFile.exists() || !dataFile.exists()) return null + return try { + JSONObject(metaFile.readText()) to dataFile.readBytes() + } catch (e: Exception) { + null + } finally { + metaFile.delete() + dataFile.delete() + } + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/share/ShareItem.kt b/client/android/app/src/main/java/com/liketrek/trek/share/ShareItem.kt new file mode 100644 index 000000000..40f068fcf --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/share/ShareItem.kt @@ -0,0 +1,186 @@ +package com.liketrek.trek.share + +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.OpenableColumns +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL + +/** + * What was shared to TREK, read into a file the booking import takes: a wallet + * pass, a PDF, a mail, a calendar entry, a web page, a piece of text or a photo. + * The import in the app (BookingImportModal) decides what it can read; this only + * carries the bytes over with a name and a type, like ShareItem.swift on iOS. + */ +data class ShareItem( + val kind: Kind, + /** The file name the import sees. */ + val name: String, + val mimeType: String, + val data: ByteArray, + /** What the sheet shows: a page title, a file name, the first line of a text. */ + val title: String, + /** A host, for a page. */ + val source: String? = null, +) { + enum class Kind { PASS, PDF, MAIL, CALENDAR, PAGE, TEXT, IMAGE, FILE } + + override fun equals(other: Any?): Boolean = other is ShareItem && other.name == name && other.data.contentEquals(data) + + override fun hashCode(): Int = name.hashCode() * 31 + data.contentHashCode() +} + +class ShareLoadException(val reason: Reason) : Exception(reason.name) { + enum class Reason { UNSUPPORTED, TOO_LARGE } +} + +object ShareLoader { + /** The same ceiling as the app's import of an opened file (TrekSharedFiles). */ + const val MAX_BYTES = 15 * 1024 * 1024 + private const val PAGE_TIMEOUT_MS = 10_000 + + /** Reads what [intent] carries. Runs on a background thread: it may read a file or fetch a page. */ + fun load(intent: Intent, resolver: ContentResolver): ShareItem { + val stream = streamOf(intent) + if (stream != null) return fromUri(stream, intent.type, resolver) + val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.trim() + if (!text.isNullOrEmpty()) return fromText(text, intent.getStringExtra(Intent.EXTRA_SUBJECT)) + throw ShareLoadException(ShareLoadException.Reason.UNSUPPORTED) + } + + private fun streamOf(intent: Intent): Uri? = when (intent.action) { + Intent.ACTION_VIEW -> intent.data?.takeIf { it.scheme == ContentResolver.SCHEME_CONTENT || it.scheme == ContentResolver.SCHEME_FILE } + Intent.ACTION_SEND -> parcelable(intent, Intent.EXTRA_STREAM) + Intent.ACTION_SEND_MULTIPLE -> parcelableList(intent)?.firstOrNull() + else -> null + } + + @Suppress("DEPRECATION") + private fun parcelable(intent: Intent, key: String): Uri? = + if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(key, Uri::class.java) else intent.getParcelableExtra(key) + + @Suppress("DEPRECATION") + private fun parcelableList(intent: Intent): List? = + if (Build.VERSION.SDK_INT >= 33) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + } + + private fun fromUri(uri: Uri, intentType: String?, resolver: ContentResolver): ShareItem { + val name = displayName(uri, resolver) + val size = runCatching { + resolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else null } + }.getOrNull() + if (size != null && size > MAX_BYTES) throw ShareLoadException(ShareLoadException.Reason.TOO_LARGE) + val data = resolver.openInputStream(uri)?.use { readCapped(it) } ?: throw ShareLoadException(ShareLoadException.Reason.UNSUPPORTED) + val mime = resolver.getType(uri) ?: intentType ?: "application/octet-stream" + val (kind, normalized) = classify(name, mime) + return checked(ShareItem(kind, withExtension(name, kind), normalized, data, name.substringBeforeLast('.').ifBlank { name })) + } + + /** A link becomes the page it points at; anything else, a text file the import reads. */ + internal fun fromText(text: String, subject: String?): ShareItem { + val link = firstLink(text) + if (link != null) { + runCatching { return page(link, subject) } + } + val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim() ?: text + return checked(ShareItem(ShareItem.Kind.TEXT, "shared-text.txt", "text/plain", text.toByteArray(), (subject ?: firstLine).take(80))) + } + + /** The first http(s) link in a text, when the text is not much more than that link. */ + internal fun firstLink(text: String): String? { + val match = Regex("https?://[^\\s<>\"]+").find(text) ?: return null + // A long mail body with a link in it is the booking itself, not a pointer to one. + return if (text.length - match.value.length <= 200) match.value.trimEnd('.', ',', ')', ']') else null + } + + private fun page(link: String, subject: String?): ShareItem { + val connection = URL(link).openConnection() as HttpURLConnection + connection.connectTimeout = PAGE_TIMEOUT_MS + connection.readTimeout = PAGE_TIMEOUT_MS + connection.instanceFollowRedirects = true + connection.setRequestProperty("Accept", "text/html") + try { + val code = connection.responseCode + val type = connection.contentType.orEmpty() + if (code !in 200..299 || !type.contains("html")) throw ShareLoadException(ShareLoadException.Reason.UNSUPPORTED) + val data = connection.inputStream.use { readCapped(it) } + val html = String(data, Charsets.UTF_8) + val host = runCatching { Uri.parse(link).host }.getOrNull() + val title = pageTitle(html) ?: subject ?: host ?: "Page" + return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host)) + } finally { + connection.disconnect() + } + } + + /** The kind and the MIME type the import should see, from the name when the type is vague. */ + internal fun classify(name: String, mime: String): Pair { + val ext = name.substringAfterLast('.', "").lowercase() + return when { + ext == "pkpass" || mime == "application/vnd.apple.pkpass" -> ShareItem.Kind.PASS to "application/vnd.apple.pkpass" + ext == "pdf" || mime == "application/pdf" -> ShareItem.Kind.PDF to "application/pdf" + ext == "eml" || mime == "message/rfc822" -> ShareItem.Kind.MAIL to "message/rfc822" + ext == "ics" || mime == "text/calendar" -> ShareItem.Kind.CALENDAR to "text/calendar" + ext == "html" || ext == "htm" || mime == "text/html" -> ShareItem.Kind.PAGE to "text/html" + mime.startsWith("image/") -> ShareItem.Kind.IMAGE to mime + ext == "txt" || mime == "text/plain" -> ShareItem.Kind.TEXT to "text/plain" + else -> ShareItem.Kind.FILE to mime + } + } + + private fun withExtension(name: String, kind: ShareItem.Kind): String { + val ext = when (kind) { + ShareItem.Kind.PASS -> "pkpass" + ShareItem.Kind.PDF -> "pdf" + ShareItem.Kind.MAIL -> "eml" + ShareItem.Kind.CALENDAR -> "ics" + ShareItem.Kind.PAGE -> "html" + else -> return name + } + return if (name.lowercase().endsWith(".$ext")) name else "$name.$ext" + } + + private fun displayName(uri: Uri, resolver: ContentResolver): String { + runCatching { + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { + if (it.moveToFirst() && !it.isNull(0)) return it.getString(0) + } + } + return uri.lastPathSegment?.substringAfterLast('/')?.ifBlank { null } ?: "booking" + } + + private fun readCapped(input: InputStream): ByteArray { + val out = ByteArrayOutputStream() + val buffer = ByteArray(16 * 1024) + while (true) { + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + if (out.size() > MAX_BYTES) throw ShareLoadException(ShareLoadException.Reason.TOO_LARGE) + } + return out.toByteArray() + } + + private fun checked(item: ShareItem): ShareItem { + if (item.data.isEmpty()) throw ShareLoadException(ShareLoadException.Reason.UNSUPPORTED) + if (item.data.size > MAX_BYTES) throw ShareLoadException(ShareLoadException.Reason.TOO_LARGE) + return item + } + + internal fun pageTitle(html: String): String? { + val match = Regex("]*>(.*?)", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)).find(html) ?: return null + return match.groupValues[1].trim().take(120).ifBlank { null } + } + + internal fun slug(title: String): String { + val words = title.lowercase().split(Regex("[^\\p{L}\\p{N}]+")).filter { it.isNotEmpty() } + return words.take(8).joinToString("-").ifBlank { "page" } + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/share/TrekShortcuts.kt b/client/android/app/src/main/java/com/liketrek/trek/share/TrekShortcuts.kt new file mode 100644 index 000000000..4bc8b9da3 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/share/TrekShortcuts.kt @@ -0,0 +1,66 @@ +package com.liketrek.trek.share + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.net.Uri +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import com.liketrek.trek.MainActivity +import com.liketrek.trek.widget.WidgetArt +import com.liketrek.trek.widget.WidgetSnapshot +import com.liketrek.trek.widget.WidgetStore + +/** + * The trips as shortcuts: a long press on TREK's icon lists the coming ones and + * opens a trip straight away, and the system share sheet offers them as direct + * share targets, so a booking goes into its trip in one tap (ShareActivity + * reads the shortcut's id). Rebuilt whenever the app writes its trip list. + */ +object TrekShortcuts { + /** The category res/xml/shortcuts.xml's share-target names. */ + const val SHARE_CATEGORY = "com.liketrek.trek.category.TRIP_SHARE" + private const val MAX = 4 + + fun publish(context: Context) { + val trips = ShareHandoff.trips(context).take(MAX) + val limit = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context).coerceAtLeast(1) + val shortcuts = trips.take(limit).mapIndexed { rank, trip -> + val open = Intent(Intent.ACTION_VIEW, Uri.parse(WidgetSnapshot.link("/trips/${trip.id}")), context, MainActivity::class.java) + ShortcutInfoCompat.Builder(context, "trip.${trip.id}") + .setShortLabel(trip.title.take(24)) + .setLongLabel(trip.title) + .setIcon(IconCompat.createWithAdaptiveBitmap(icon(trip))) + .setIntent(open) + .setCategories(setOf(SHARE_CATEGORY)) + .setLongLived(true) + .setRank(rank) + .build() + } + runCatching { + // Trips no longer listed lose their shortcut; pinned ones stay but are disabled by the system. + val keep = shortcuts.map { it.id }.toSet() + val stale = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id }.filter { it !in keep } + if (stale.isNotEmpty()) ShortcutManagerCompat.removeLongLivedShortcuts(context, stale) + ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts) + } + } + + /** The trip's cover, else its own sky, cropped for an adaptive icon (108dp with a 72dp safe zone). */ + private fun icon(trip: ShareHandoff.Trip): Bitmap { + val size = 216 + val source = WidgetArt.cover(trip.cover, 432) ?: WidgetArt.sky(trip.title, size, size) + val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val side = minOf(source.width, source.height) + val from = Rect((source.width - side) / 2, (source.height - side) / 2, (source.width + side) / 2, (source.height + side) / 2) + Canvas(out).drawBitmap(source, from, Rect(0, 0, size, size), Paint(Paint.FILTER_BITMAP_FLAG)) + return out + } + + /** Whether there is anything to publish: the app wrote a trip list. */ + fun hasTrips(context: Context): Boolean = WidgetStore.trips(context).isNotEmpty() +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/tiles/TrekTileStore.kt b/client/android/app/src/main/java/com/liketrek/trek/tiles/TrekTileStore.kt new file mode 100644 index 000000000..08c7c327a --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/tiles/TrekTileStore.kt @@ -0,0 +1,158 @@ +package com.liketrek.trek.tiles + +import android.content.Context +import android.net.Uri +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import java.io.ByteArrayInputStream +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest + +/** + * The app's own store of Mapbox map resources, the counterpart of the iOS + * TrekTileStore (TrekBridgeViewController.swift). + * + * Android's WebView runs the server's service worker, which keeps every raster + * tile the user looked at. Mapbox's vector tiles, styles, sprites and glyphs it + * may not keep, but a device cache of what was viewed is allowed for 30 days, + * so the WebView client hands those requests here (TrekWebViewClient), and a + * Mapbox map keeps working offline for the places already seen. Nothing is + * prefetched; the store is pruned to [MAX_BYTES], least recently used first. + */ +object TrekTileStore { + const val MAX_AGE_MS = 30L * 24 * 60 * 60 * 1000 + const val MAX_BYTES = 250L * 1024 * 1024 + private const val PRUNE_TARGET = 0.8 + private const val TIMEOUT_MS = 15_000 + /** One resource is a tile, a style or a sprite sheet: far below this. */ + private const val MAX_RESOURCE_BYTES = 8 * 1024 * 1024 + + private val HOST = Regex("^(api\\.mapbox\\.com|[a-z0-9-]+\\.tiles\\.mapbox\\.com)$", RegexOption.IGNORE_CASE) + /** What a map draws; telemetry, sessions and anything else go straight out. */ + private val PATHS = listOf("/v4/", "/styles/", "/fonts/", "/models/", "/rasterarrays/", "/raster/") + private val DROPPED = setOf("access_token", "sku", "key", "api_key", "apikey") + + @Volatile private var dir: File? = null + private var writesSincePrune = 0 + + fun init(context: Context) { + if (dir == null) dir = File(context.cacheDir, "tiles").apply { mkdirs() } + } + + fun handles(request: WebResourceRequest): Boolean = request.method == "GET" && handles(request.url) + + fun handles(url: Uri): Boolean = url.scheme == "https" && HOST.matches(url.host.orEmpty()) && PATHS.any { url.path.orEmpty().startsWith(it) } + + /** The key of a resource: its URL without the credentials and the billing session, which change. */ + fun key(url: Uri): String { + val builder = url.buildUpon().clearQuery() + url.queryParameterNames.filter { it.lowercase() !in DROPPED }.sorted().forEach { name -> + url.getQueryParameters(name).forEach { builder.appendQueryParameter(name, it) } + } + val digest = MessageDigest.getInstance("SHA-256").digest(builder.build().toString().toByteArray()) + return digest.joinToString("") { "%02x".format(it) } + } + + /** + * Answers a Mapbox request from the store when it is younger than 30 days, + * else from the network, keeping what came back. Null lets the WebView load + * it itself (no store, or the network failed). + */ + fun serve(request: WebResourceRequest): WebResourceResponse? { + val root = dir ?: return null + val url = request.url + val key = key(url) + val body = File(root, "$key.bin") + val meta = File(root, "$key.type") + val now = System.currentTimeMillis() + if (body.exists() && now - created(meta) < MAX_AGE_MS) { + body.setLastModified(now) + val type = runCatching { meta.readLines().getOrNull(1) }.getOrNull() ?: "application/octet-stream" + return runCatching { response(body.readBytes(), type) }.getOrNull() + } + try { + val connection = URL(url.toString()).openConnection() as HttpURLConnection + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = TIMEOUT_MS + request.requestHeaders["Accept"]?.let { connection.setRequestProperty("Accept", it) } + try { + val code = connection.responseCode + if (code == 200) { + val bytes = connection.inputStream.use { it.readBytes() } + if (bytes.size <= MAX_RESOURCE_BYTES) { + val type = connection.contentType ?: "application/octet-stream" + store(body, meta, bytes, type, now) + return response(bytes, type) + } + } else { + // 401, 404 and the like belong to the map, which reports them itself. + val error = connection.errorStream?.use { it.readBytes() } ?: ByteArray(0) + return WebResourceResponse(connection.contentType ?: "text/plain", null, code, connection.responseMessage ?: "Error", cors(), ByteArrayInputStream(error)) + } + } finally { + connection.disconnect() + } + } catch (e: Exception) { + // Offline and never seen: the map shows the gap as it would in a browser. + } + return null + } + + /** HttpURLConnection has already undone any gzip, so no Content-Encoding goes along. */ + private fun response(bytes: ByteArray, type: String): WebResourceResponse { + val mime = type.substringBefore(';').trim() + val charset = Regex("charset=([^;]+)").find(type)?.groupValues?.get(1) + return WebResourceResponse(mime, charset, 200, "OK", cors(), ByteArrayInputStream(bytes)) + } + + /** The map fetches from the server's origin; the store's answer has to allow it as Mapbox's own does. */ + private fun cors(): MutableMap = mutableMapOf("Access-Control-Allow-Origin" to "*", "Cache-Control" to "no-store") + + private fun created(meta: File): Long = runCatching { meta.readLines().first().toLong() }.getOrDefault(0L) + + @Synchronized + private fun store(body: File, meta: File, bytes: ByteArray, type: String, now: Long) { + runCatching { + body.writeBytes(bytes) + meta.writeText("$now\n$type") + } + if (++writesSincePrune >= 200) { + writesSincePrune = 0 + prune() + } + } + + /** Drops what is past 30 days, then the least recently used while the store is over its limit. */ + @Synchronized + fun prune() { + val root = dir ?: return + val now = System.currentTimeMillis() + val bodies = root.listFiles { f -> f.name.endsWith(".bin") }?.toMutableList() ?: return + bodies.filter { now - created(File(root, it.name.removeSuffix(".bin") + ".type")) > MAX_AGE_MS }.forEach { + remove(root, it) + bodies.remove(it) + } + var total = bodies.sumOf { it.length() } + if (total <= MAX_BYTES) return + val target = (MAX_BYTES * PRUNE_TARGET).toLong() + for (file in bodies.sortedBy { it.lastModified() }) { + if (total <= target) break + total -= file.length() + remove(root, file) + } + } + + private fun remove(root: File, body: File) { + body.delete() + File(root, body.name.removeSuffix(".bin") + ".type").delete() + } + + fun usage(): Long = dir?.listFiles()?.sumOf { it.length() } ?: 0L + + @Synchronized + fun clear() { + dir?.listFiles()?.forEach { it.delete() } + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/tripday/TripDayNotification.kt b/client/android/app/src/main/java/com/liketrek/trek/tripday/TripDayNotification.kt new file mode 100644 index 000000000..6c87bb891 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/tripday/TripDayNotification.kt @@ -0,0 +1,146 @@ +package com.liketrek.trek.tripday + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.IconCompat +import com.liketrek.trek.MainActivity +import com.liketrek.trek.R +import com.liketrek.trek.widget.TrekIcons +import com.liketrek.trek.widget.WidgetSnapshot +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId + +/** + * The travel day as a Live Update, the Android counterpart of the iOS Live + * Activity (TripDayLiveActivity.swift): an ongoing notification with the next + * stop, its time as the status-bar chip and a countdown, and the day's stops as + * a segmented progress bar. Android 16 promotes it to the top of the shade and + * the lock screen (ProgressStyle, POST_PROMOTED_NOTIFICATIONS); older versions + * show the same content as a quiet ongoing notification with a plain bar. + * + * The UI starts, updates and ends it (TrekShell.syncTripDay / endTripDay), and + * it lapses on its own at midnight, so a forgotten one never lingers. + */ +object TripDayNotification { + const val CHANNEL = "tripday" + private const val ID = 0x7d1 + + data class State( + val tripTitle: String, + val path: String, + val dayLabel: String, + val nextName: String, + val nextTime: String?, + /** Minutes after midnight of the next stop, for the countdown. */ + val nextMinutes: Int?, + val done: Int, + val total: Int, + /** #rrggbb of each stop, for the segments; may be empty. */ + val colors: List = emptyList(), + ) + + fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(NotificationManager::class.java) ?: return + if (manager.getNotificationChannel(CHANNEL) != null) return + // Default importance: low would keep it out of the status bar chip and promotion. + val channel = NotificationChannel(CHANNEL, context.getString(R.string.push_channel_tripday), NotificationManager.IMPORTANCE_DEFAULT).apply { + description = context.getString(R.string.push_channel_tripday_description) + setSound(null, null) + enableVibration(false) + setShowBadge(false) + } + manager.createNotificationChannel(channel) + } + + /** Whether it can be shown at all: notifications allowed, and the channel not switched off. */ + fun allowed(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return false + } + val manager = NotificationManagerCompat.from(context) + if (!manager.areNotificationsEnabled()) return false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = context.getSystemService(NotificationManager::class.java)?.getNotificationChannel(CHANNEL) + if (channel != null && channel.importance == NotificationManager.IMPORTANCE_NONE) return false + } + return true + } + + /** Shows the day, or updates the one showing. False when notifications are off. */ + fun show(context: Context, state: State): Boolean { + ensureChannel(context) + if (!allowed(context)) return false + try { + NotificationManagerCompat.from(context).notify(ID, build(context, state)) + } catch (e: SecurityException) { + return false + } + return true + } + + fun end(context: Context) { + NotificationManagerCompat.from(context).cancel(ID) + } + + fun build(context: Context, state: State, today: LocalDate = LocalDate.now()): android.app.Notification { + val open = Intent(Intent.ACTION_VIEW, Uri.parse(WidgetSnapshot.link(state.path)), context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + val tap = PendingIntent.getActivity(context, ID, open, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val zone = ZoneId.systemDefault() + val midnight = today.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() + val remaining = (midnight - System.currentTimeMillis()).coerceAtLeast(60_000L) + + val builder = NotificationCompat.Builder(context, CHANNEL) + .setSmallIcon(R.drawable.ic_stat_trek) + .setContentTitle(state.nextName) + .setContentText(listOfNotNull(state.nextTime, state.tripTitle).joinToString(" ")) + .setSubText(state.dayLabel) + .setContentIntent(tap) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setTimeoutAfter(remaining) + .setRequestPromotedOngoing(true) + + state.nextTime?.let { builder.setShortCriticalText(it) } + state.nextMinutes?.let { minutes -> + val at = today.atTime(LocalTime.of((minutes / 60).coerceIn(0, 23), minutes % 60)).atZone(zone).toInstant().toEpochMilli() + if (at > System.currentTimeMillis()) { + builder.setWhen(at).setShowWhen(true).setUsesChronometer(true).setChronometerCountDown(true) + } + } + + val total = state.total.coerceAtLeast(1) + val done = state.done.coerceIn(0, total) + val style = NotificationCompat.ProgressStyle() + .setStyledByProgress(true) + .setProgressTrackerIcon(IconCompat.createWithBitmap(TrekIcons.bitmap(TrekIcons.vector("figure.walk"), 72, ContextCompat.getColor(context, R.color.notification_accent)))) + for (i in 0 until total) { + val color = state.colors.getOrNull(i)?.let(::parseColor) ?: ContextCompat.getColor(context, R.color.tripday_segment) + style.addProgressSegment(NotificationCompat.ProgressStyle.Segment(1).setColor(color)) + } + style.setProgress(done) + builder.setStyle(style) + // Before Android 16 the style falls back to nothing: the plain bar carries the progress. + if (Build.VERSION.SDK_INT < 36) builder.setProgress(total, done, false) + return builder.build() + } + + private fun parseColor(css: String): Int? = runCatching { android.graphics.Color.parseColor(css) }.getOrNull() +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/ui/TrekMaterialTheme.kt b/client/android/app/src/main/java/com/liketrek/trek/ui/TrekMaterialTheme.kt new file mode 100644 index 000000000..43252338a --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/ui/TrekMaterialTheme.kt @@ -0,0 +1,53 @@ +package com.liketrek.trek.ui + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +/** + * The theme of the app's own native screens (the share sheet, the widget's trip + * picker): Material You's wallpaper colours on Android 12 and later, TREK's + * neutral palette before that, light and dark with the system. + */ +@Composable +fun TrekMaterialTheme(content: @Composable () -> Unit) { + val dark = isSystemInDarkTheme() + val context = LocalContext.current + val colors = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + dark -> darkColorScheme( + primary = Color(0xFFE4E4E7), + onPrimary = Color(0xFF101013), + secondary = Color(0xFF8AAEF0), + secondaryContainer = Color(0xFF233250), + onSecondaryContainer = Color(0xFFDCE6FA), + surface = Color(0xFF0C0C0F), + surfaceContainerLow = Color(0xFF16161A), + surfaceContainer = Color(0xFF1B1B21), + surfaceContainerHigh = Color(0xFF232329), + onSurface = Color(0xFFF5F5F7), + onSurfaceVariant = Color(0xFF9C9CA3), + ) + else -> lightColorScheme( + primary = Color(0xFF111827), + onPrimary = Color(0xFFFFFFFF), + secondary = Color(0xFF4A7DDB), + secondaryContainer = Color(0xFFE4EAF7), + onSecondaryContainer = Color(0xFF1B2F57), + surface = Color(0xFFFDFCF9), + surfaceContainerLow = Color(0xFFF4F2EE), + surfaceContainer = Color(0xFFEFEDE8), + surfaceContainerHigh = Color(0xFFE9E7E1), + onSurface = Color(0xFF101013), + onSurfaceVariant = Color(0xFF68686F), + ) + } + MaterialTheme(colorScheme = colors, content = content) +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/TrekIcons.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/TrekIcons.kt new file mode 100644 index 000000000..3dec16d4a --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/TrekIcons.kt @@ -0,0 +1,209 @@ +package com.liketrek.trek.widget + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AcUnit +import androidx.compose.material.icons.rounded.AccountBalance +import androidx.compose.material.icons.rounded.Backpack +import androidx.compose.material.icons.rounded.BeachAccess +import androidx.compose.material.icons.rounded.Bed +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.Bookmark +import androidx.compose.material.icons.rounded.CalendarMonth +import androidx.compose.material.icons.rounded.CameraAlt +import androidx.compose.material.icons.rounded.Chat +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Checklist +import androidx.compose.material.icons.rounded.Cloud +import androidx.compose.material.icons.rounded.CreditCard +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.DirectionsRun +import androidx.compose.material.icons.rounded.DirectionsWalk +import androidx.compose.material.icons.rounded.Event +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.FitnessCenter +import androidx.compose.material.icons.rounded.Flag +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Dehaze +import androidx.compose.material.icons.rounded.Forest +import androidx.compose.material.icons.rounded.Grain +import androidx.compose.material.icons.rounded.Group +import androidx.compose.material.icons.rounded.Hiking +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Landscape +import androidx.compose.material.icons.rounded.LocalBar +import androidx.compose.material.icons.rounded.LocalCafe +import androidx.compose.material.icons.rounded.LocalHospital +import androidx.compose.material.icons.rounded.LocalLibrary +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.Map +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.Navigation +import androidx.compose.material.icons.rounded.Park +import androidx.compose.material.icons.rounded.Place +import androidx.compose.material.icons.rounded.Poll +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Sailing +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.ShoppingBag +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.Storefront +import androidx.compose.material.icons.rounded.Thunderstorm +import androidx.compose.material.icons.rounded.TheaterComedy +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Train +import androidx.compose.material.icons.rounded.Water +import androidx.compose.material.icons.rounded.WbCloudy +import androidx.compose.material.icons.rounded.WbSunny +import androidx.compose.material.icons.rounded.Cabin +import androidx.compose.ui.graphics.asAndroidPath +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.VectorGroup +import androidx.compose.ui.graphics.vector.VectorNode +import androidx.compose.ui.graphics.vector.VectorPath +import androidx.compose.ui.graphics.vector.PathParser +import androidx.glance.ImageProvider + +/** + * The widgets' icons: the Material Symbols (rounded) counterparts of the SF + * Symbols the snapshot names, so one snapshot draws on both platforms. A widget + * is RemoteViews, which cannot draw a Compose vector, so each icon is drawn + * once into a small white bitmap and tinted by the widget's theme colour. + */ +object TrekIcons { + /** The SF Symbol names client/src/native/widgetExtras.ts and widgets.ts use, and a few of the widgets' own. */ + private val SYMBOLS: Map = mapOf( + "airplane" to Icons.Rounded.Flight, + "airplane.departure" to Icons.Rounded.FlightTakeoff, + "tram.fill" to Icons.Rounded.Train, + "bus.fill" to Icons.Rounded.DirectionsBus, + "car.fill" to Icons.Rounded.DirectionsCar, + "ferry.fill" to Icons.Rounded.DirectionsBoat, + "sailboat.fill" to Icons.Rounded.Sailing, + "bed.double.fill" to Icons.Rounded.Hotel, + "house.fill" to Icons.Rounded.Home, + "tent.fill" to Icons.Rounded.Cabin, + "fork.knife" to Icons.Rounded.Restaurant, + "cup.and.saucer.fill" to Icons.Rounded.LocalCafe, + "mug.fill" to Icons.Rounded.LocalCafe, + "wineglass.fill" to Icons.Rounded.LocalBar, + "ticket.fill" to Icons.Rounded.ConfirmationNumber, + "theatermasks.fill" to Icons.Rounded.TheaterComedy, + "music.note" to Icons.Rounded.MusicNote, + "figure.hiking" to Icons.Rounded.Hiking, + "figure.walk" to Icons.Rounded.DirectionsWalk, + "figure.run" to Icons.Rounded.DirectionsRun, + "dumbbell.fill" to Icons.Rounded.FitnessCenter, + "bookmark.fill" to Icons.Rounded.Bookmark, + "building.columns.fill" to Icons.Rounded.AccountBalance, + "building.2.fill" to Icons.Rounded.AccountBalance, + "books.vertical.fill" to Icons.Rounded.LocalLibrary, + "storefront.fill" to Icons.Rounded.Storefront, + "bag.fill" to Icons.Rounded.ShoppingBag, + "backpack.fill" to Icons.Rounded.Backpack, + "suitcase.rolling.fill" to Icons.Rounded.Luggage, + "creditcard.fill" to Icons.Rounded.CreditCard, + "cross.case.fill" to Icons.Rounded.LocalHospital, + "camera.fill" to Icons.Rounded.CameraAlt, + "camera.macro" to Icons.Rounded.Park, + "leaf.fill" to Icons.Rounded.Park, + "tree.fill" to Icons.Rounded.Forest, + "mountain.2.fill" to Icons.Rounded.Landscape, + "water.waves" to Icons.Rounded.Water, + "beach.umbrella.fill" to Icons.Rounded.BeachAccess, + "sun.max.fill" to Icons.Rounded.WbSunny, + "bolt.fill" to Icons.Rounded.Bolt, + "heart.fill" to Icons.Rounded.Favorite, + "star.fill" to Icons.Rounded.Star, + "flag.fill" to Icons.Rounded.Flag, + "map.fill" to Icons.Rounded.Map, + "safari.fill" to Icons.Rounded.Navigation, + "location.north.fill" to Icons.Rounded.Navigation, + "location.fill" to Icons.Rounded.Place, + "globe.europe.africa.fill" to Icons.Rounded.Public, + "calendar" to Icons.Rounded.CalendarMonth, + "clock" to Icons.Rounded.Schedule, + "checklist" to Icons.Rounded.Checklist, + "checkmark.circle.fill" to Icons.Rounded.CheckCircle, + "person.2.fill" to Icons.Rounded.Group, + "bubble.left.fill" to Icons.Rounded.Chat, + "chart.bar.fill" to Icons.Rounded.Poll, + "event" to Icons.Rounded.Event, + ) + + /** A booking kind's symbol and colour, the same pairs as TrekStyle.swift. */ + fun booking(kind: String): Pair = when (kind) { + "flight" -> "airplane" to 0xFF4A7DDB.toInt() + "train" -> "tram.fill" to 0xFF2FA37A.toInt() + "bus" -> "bus.fill" to 0xFF2BA6A6.toInt() + "car" -> "car.fill" to 0xFFD6673B.toInt() + "cruise", "ferry", "boat" -> "ferry.fill" to 0xFF2E8BC0.toInt() + "hotel" -> "bed.double.fill" to 0xFF8E6CD9.toInt() + "restaurant" -> "fork.knife" to 0xFFE8A13A.toInt() + "event" -> "ticket.fill" to 0xFFD6408B.toInt() + "tour" -> "figure.hiking" to 0xFF4E9F3D.toInt() + else -> "bookmark.fill" to 0xFF7A7A85.toInt() + } + + /** The provider's main weather condition as an icon. */ + fun weather(main: String): ImageVector = when (main.lowercase()) { + "clear" -> Icons.Rounded.WbSunny + "clouds" -> Icons.Rounded.WbCloudy + "rain", "drizzle" -> Icons.Rounded.Grain + "thunderstorm" -> Icons.Rounded.Thunderstorm + "snow" -> Icons.Rounded.AcUnit + "mist", "fog", "haze" -> Icons.Rounded.Dehaze + else -> Icons.Rounded.Cloud + } + + fun vector(symbol: String?): ImageVector = symbol?.let { SYMBOLS[it] } ?: Icons.Rounded.Place + + private val cache = HashMap, Bitmap>() + + /** The icon as a white bitmap of [sizePx], tinted where it is drawn. */ + @Synchronized + fun bitmap(vector: ImageVector, sizePx: Int = 48): Bitmap = cache.getOrPut(vector to sizePx) { render(vector, sizePx) } + + fun provider(symbol: String?, sizePx: Int = 48): ImageProvider = ImageProvider(bitmap(vector(symbol), sizePx)) + + fun provider(vector: ImageVector, sizePx: Int = 48): ImageProvider = ImageProvider(bitmap(vector, sizePx)) + + /** The icon in [color], for places that draw it as it is (a notification's progress tracker). */ + fun bitmap(vector: ImageVector, sizePx: Int, color: Int): Bitmap = render(vector, sizePx, color) + + private fun render(vector: ImageVector, sizePx: Int, color: Int = android.graphics.Color.WHITE): Bitmap { + val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val scale = Matrix().apply { setScale(sizePx / vector.viewportWidth, sizePx / vector.viewportHeight) } + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + this.color = color + style = Paint.Style.FILL + } + fun draw(node: VectorNode) { + when (node) { + is VectorGroup -> node.forEach { draw(it) } + is VectorPath -> { + val path: Path = PathParser().addPathNodes(node.pathData).toPath().asAndroidPath() + path.fillType = if (node.pathFillType == androidx.compose.ui.graphics.PathFillType.EvenOdd) { + Path.FillType.EVEN_ODD + } else { + Path.FillType.WINDING + } + path.transform(scale) + canvas.drawPath(path, paint) + } + } + } + draw(vector.root) + return bitmap + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgetReceivers.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgetReceivers.kt new file mode 100644 index 000000000..10a9b6805 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgetReceivers.kt @@ -0,0 +1,92 @@ +package com.liketrek.trek.widget + +import android.content.Context +import android.os.Build +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.updateAll +import kotlin.reflect.KClass +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** The widget providers the manifest lists, one per widget in the picker. */ +abstract class TrekWidgetReceiver : GlanceAppWidgetReceiver() { + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) + WidgetStore.forget(context, appWidgetIds) + } +} + +class TripWidgetReceiver : TrekWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = TripWidget() +} + +class TodayWidgetReceiver : TrekWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = TodayWidget() +} + +class BookingsWidgetReceiver : TrekWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = BookingsWidget() +} + +class TodosWidgetReceiver : TrekWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = TodosWidget() +} + +class PackingWidgetReceiver : TrekWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = PackingWidget() +} + +class AtlasWidgetReceiver : TrekWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = AtlasWidget() +} + +class CollabWidgetReceiver : TrekWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = CollabWidget() +} + +/** Redraws every placed TREK widget after the app wrote a new snapshot. */ +object TrekWidgetUpdater { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val receivers: List> = listOf( + TripWidgetReceiver::class, + TodayWidgetReceiver::class, + BookingsWidgetReceiver::class, + TodosWidgetReceiver::class, + PackingWidgetReceiver::class, + AtlasWidgetReceiver::class, + CollabWidgetReceiver::class, + ) + + fun widgets(): List = + listOf(TripWidget(), TodayWidget(), BookingsWidget(), TodosWidget(), PackingWidget(), AtlasWidget(), CollabWidget()) + + @JvmStatic + fun refreshAll(context: Context) { + val app = context.applicationContext + scope.launch { for (widget in widgets()) runCatching { widget.updateAll(app) } } + } + + /** + * Android 15's widget picker shows a live preview drawn by the widget itself + * (providePreview) instead of a static image. The system rate-limits the + * call, so it runs once per app version. + */ + @JvmStatic + fun publishPreviews(context: Context) { + if (Build.VERSION.SDK_INT < 35) return + val app = context.applicationContext + val prefs = app.getSharedPreferences("trek.widgets", Context.MODE_PRIVATE) + val version = runCatching { app.packageManager.getPackageInfo(app.packageName, 0).lastUpdateTime }.getOrDefault(0L) + if (prefs.getLong("previews", -1L) == version) return + scope.launch { + val manager = GlanceAppWidgetManager(app) + val all = receivers.all { runCatching { manager.setWidgetPreviews(it); true }.getOrDefault(false) } + if (all) prefs.edit().putLong("previews", version).apply() + } + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgets.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgets.kt new file mode 100644 index 000000000..e23a80075 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgets.kt @@ -0,0 +1,805 @@ +package com.liketrek.trek.widget + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CalendarMonth +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.Group +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.Place +import androidx.compose.material.icons.rounded.Public +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ColumnScope +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import java.time.LocalDate + +/** The sizes every TREK widget lays itself out for (a 2x2, a 4x2 and a 4x4 cell block). */ +object TrekSizes { + val SMALL = DpSize(120.dp, 120.dp) + val WIDE = DpSize(250.dp, 120.dp) + val LARGE = DpSize(250.dp, 260.dp) + val all = setOf(SMALL, WIDE, LARGE) +} + +private val DpSize.isWide get() = width >= 220.dp +private val DpSize.isTall get() = height >= 220.dp + +/** + * A TREK widget: reads the snapshot of the trip it is set to (WidgetStore) and + * draws [Content]. The app writes the snapshot and refreshes the widgets + * (TrekWidgetUpdater); [preview] draws the same views from a sample trip for + * the widget picker and the screenshot tests. + */ +abstract class TrekWidget : GlanceAppWidget() { + override val sizeMode: SizeMode = SizeMode.Responsive(TrekSizes.all) + override val previewSizeMode = SizeMode.Responsive(TrekSizes.all) + + /** Whether the widget shows one trip, so the trip picker applies to it. */ + open val perTrip: Boolean = true + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val appWidgetId = runCatching { GlanceAppWidgetManager(context).getAppWidgetId(id) }.getOrDefault(-1) + val tripId = if (perTrip && appWidgetId >= 0) WidgetStore.tripFor(context, appWidgetId) else WidgetStore.AUTO + val snapshot = WidgetStore.load(context, tripId) + provideContent { TrekWidgetTheme.Themed { Content(snapshot) } } + } + + override suspend fun providePreview(context: Context, widgetCategory: Int) { + provideContent { TrekWidgetTheme.Themed { Content(WidgetSamples.snapshot(running = false)) } } + } + + @Composable + abstract fun Content(snapshot: WidgetSnapshot?) +} + +// ---- Trip ---- + +/** The trip: a countdown on its own cover before it starts, the day and what comes next while it runs. */ +class TripWidget : TrekWidget() { + @Composable + override fun Content(snapshot: WidgetSnapshot?) { + val context = LocalContext.current + val trip = snapshot?.trip + if (snapshot == null || trip == null) { + EmptyWidget(snapshot?.labels?.noTrip ?: WidgetSamples.emptyText(context)) + return + } + val size = LocalSize.current + WidgetFrame(onClick = openPath(context, trip.path), background = TrekStatus.scrim, padding = 0.dp) { + Box(modifier = GlanceModifier.fillMaxSize()) { + Image( + provider = ImageProvider(WidgetArt.cover(trip.cover) ?: WidgetArt.sky(trip.title)), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = GlanceModifier.fillMaxSize(), + ) + Image( + provider = ImageProvider(com.liketrek.trek.R.drawable.widget_scrim), + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = GlanceModifier.fillMaxSize(), + ) + Column(modifier = GlanceModifier.fillMaxSize().padding(14.dp)) { + val today = snapshot.today + if (today != null) Running(snapshot, trip, today, size) else Upcoming(snapshot, trip, size) + } + } + } + } + + @Composable + private fun ColumnScope.Upcoming(snapshot: WidgetSnapshot, trip: WidgetSnapshot.Trip, size: DpSize) { + val context = LocalContext.current + val labels = snapshot.labels + val days = (snapshot.daysUntilStart() ?: 0L).coerceAtLeast(0L) + if (!size.isWide) { + // Small: the number carries it, with the trip's name under it. + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Flight, TrekStatus.white, 16.dp) + Spacer(GlanceModifier.defaultWeight()) + snapshot.weather?.let { WeatherChip(it, onPhoto = true) } + } + Spacer(GlanceModifier.defaultWeight()) + Countdown(days, labels, ring = false) + Line(trip.title, 14.sp, TrekStatus.white, FontWeight.Bold, maxLines = 1) + return + } + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = GlanceModifier.defaultWeight()) { + Eyebrow(labels.nextTrip, TrekStatus.whiteMuted) + Spacer(GlanceModifier.height(2.dp)) + Line(trip.title, if (size.isTall) 22.sp else 18.sp, TrekStatus.white, FontWeight.Bold, maxLines = 2) + trip.place?.let { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Place, TrekStatus.whiteMuted, 12.dp) + Spacer(GlanceModifier.width(3.dp)) + Line(it, 12.sp, TrekStatus.whiteMuted, FontWeight.Medium) + } + } + if (size.isTall) { + RelativeDay.range(context, trip.start, trip.end)?.let { + Spacer(GlanceModifier.height(6.dp)) + Chip(it, TrekStatus.glass, TrekStatus.white, TrekIcons.provider(Icons.Rounded.CalendarMonth), TrekStatus.white) + } + } + } + Spacer(GlanceModifier.width(10.dp)) + Countdown(days, labels, ring = true) + } + if (size.isTall) { + Spacer(GlanceModifier.defaultWeight()) + TripFacts(snapshot, onPhoto = true) + snapshot.bookings.firstOrNull()?.let { + Spacer(GlanceModifier.height(8.dp)) + BookingRow(it, labels, onPhoto = true) + } + } + } + + /** The days to go: a big number, or today and tomorrow in words; around a ring of the last 60 days on the wide sizes. */ + @Composable + private fun Countdown(days: Long, labels: WidgetSnapshot.Labels, ring: Boolean) { + val number: @Composable () -> Unit = { + when (days) { + 0L -> Line(labels.todayShort, 26.sp, TrekStatus.white, FontWeight.Bold) + 1L -> Line(labels.tomorrow, 20.sp, TrekStatus.white, FontWeight.Bold) + else -> if (ring) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = days.toString(), + style = TextStyle(color = TrekStatus.white, fontSize = if (days > 99) 22.sp else 28.sp, fontWeight = FontWeight.Bold), + maxLines = 1, + ) + Eyebrow(labels.days, TrekStatus.whiteMuted) + } + } else { + Row(verticalAlignment = Alignment.Bottom) { + Text(days.toString(), style = TextStyle(color = TrekStatus.white, fontSize = 34.sp, fontWeight = FontWeight.Bold), maxLines = 1) + Spacer(GlanceModifier.width(4.dp)) + Line(labels.days, 13.sp, TrekStatus.whiteMuted, FontWeight.Bold, modifier = GlanceModifier.padding(bottom = 6.dp)) + } + } + } + } + if (!ring) { + number() + return + } + val progress = 1f - (days.coerceAtMost(60L).toFloat() / 60f) + Box(modifier = GlanceModifier.size(84.dp), contentAlignment = Alignment.Center) { + Image( + provider = ImageProvider(WidgetArt.ring(progress)), + contentDescription = null, + colorFilter = ColorFilter.tint(TrekStatus.white), + modifier = GlanceModifier.fillMaxSize(), + ) + number() + } + } + + @Composable + private fun ColumnScope.Running(snapshot: WidgetSnapshot, trip: WidgetSnapshot.Trip, today: WidgetSnapshot.Today, size: DpSize) { + val labels = snapshot.labels + val now = nowMinutes() + val next = today.stops.firstOrNull { (it.minutes ?: -1) >= now } ?: today.stops.lastOrNull() + val index = today.index ?: 1 + val total = today.total ?: snapshot.tripLength ?: 1 + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Eyebrow(if (size.isWide) trip.title else today.dayLabel, TrekStatus.whiteMuted, GlanceModifier.defaultWeight()) + snapshot.weather?.let { WeatherChip(it, onPhoto = true) } + } + if (size.isWide) Line(today.dayLabel, 20.sp, TrekStatus.white, FontWeight.Bold, modifier = GlanceModifier.padding(top = 2.dp)) + Spacer(GlanceModifier.height(8.dp)) + DaySegments(index, total, TrekStatus.white) + Spacer(GlanceModifier.defaultWeight()) + if (size.isWide) { + Column(modifier = GlanceModifier.fillMaxWidth().background(TrekStatus.glass).cornerRadius(16.dp).padding(10.dp)) { + StopList(today.stops, now, limit = if (size.isTall) 5 else 1, onPhoto = true, nothing = labels.nothingPlanned, nowLabel = labels.now) + } + } else if (next != null) { + Line(next.time ?: labels.today, 26.sp, TrekStatus.white, FontWeight.Bold) + Line(next.name, 14.sp, TrekStatus.white, FontWeight.Bold, maxLines = 2) + } else { + Line(labels.nothingPlanned, 14.sp, TrekStatus.white, FontWeight.Bold, maxLines = 3) + } + } +} + +/** The trip's numbers: days, places, travellers. */ +@Composable +private fun TripFacts(snapshot: WidgetSnapshot, onPhoto: Boolean) { + val labels = snapshot.labels + val facts = listOfNotNull( + snapshot.tripLength?.let { it to labels.days }, + snapshot.trip?.placeCount?.let { it to labels.places }, + snapshot.trip?.memberCount?.let { it to labels.travellers }, + ) + Row(modifier = GlanceModifier.fillMaxWidth()) { + facts.forEachIndexed { i, (value, label) -> + Column( + modifier = GlanceModifier.defaultWeight().background(if (onPhoto) TrekStatus.glass else GlanceTheme.colors.secondaryContainer) + .cornerRadius(14.dp).padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Line(value.toString(), 18.sp, if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSecondaryContainer, FontWeight.Bold) + Line(label, 11.sp, if (onPhoto) TrekStatus.whiteMuted else GlanceTheme.colors.onSecondaryContainer) + } + if (i < facts.size - 1) Spacer(GlanceModifier.width(6.dp)) + } + } +} + +// ---- Today ---- + +/** Today's plan as a timeline, with the weather and what is next. */ +class TodayWidget : TrekWidget() { + @Composable + override fun Content(snapshot: WidgetSnapshot?) { + val context = LocalContext.current + val size = LocalSize.current + val labels = snapshot?.labels + val today = snapshot?.today + val trip = snapshot?.trip + if (snapshot == null || labels == null || trip == null) { + EmptyWidget(labels?.noTrip ?: WidgetSamples.emptyText(context)) + return + } + WidgetFrame(onClick = openPath(context, trip.path)) { + Column(modifier = GlanceModifier.fillMaxSize()) { + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = GlanceModifier.defaultWeight()) { + if (size.isTall) Eyebrow(if (today != null) labels.today else labels.nextTrip, GlanceTheme.colors.primary) + Line(today?.dayLabel ?: trip.title, if (size.isWide) 17.sp else 14.sp, weight = FontWeight.Bold) + } + snapshot.weather?.let { WeatherChip(it, onPhoto = false) } + } + Spacer(GlanceModifier.height(if (size.isTall) 10.dp else 6.dp)) + if (today == null || today.stops.isEmpty()) { + Spacer(GlanceModifier.defaultWeight()) + val days = snapshot.daysUntilStart() + val text = if (today == null && days != null && days > 0) labels.inDays.replace("{n}", days.toString()) else labels.nothingPlanned + Line(text, 14.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium, maxLines = 3) + Spacer(GlanceModifier.defaultWeight()) + } else { + val limit = when { + size.isTall -> 6 + size.isWide -> 2 + else -> 1 + } + StopList(today.stops, nowMinutes(), limit, onPhoto = false, nothing = labels.nothingPlanned, nowLabel = labels.now, compact = !size.isWide) + } + } + } + } +} + +/** The stops from the next one on, at most [limit], the next one marked. */ +@Composable +fun StopList( + stops: List, + nowMinutes: Int, + limit: Int, + onPhoto: Boolean, + nothing: String, + nowLabel: String, + compact: Boolean = false, +) { + if (stops.isEmpty()) { + Line(nothing, 13.sp, if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSurfaceVariant) + return + } + val nextIndex = stops.indexOfFirst { (it.minutes ?: -1) >= nowMinutes }.let { if (it < 0) stops.size - 1 else it } + // Keep the last done stop in view when there is room, so the day reads as a line. + val from = (nextIndex - if (limit > 3) 1 else 0).coerceIn(0, (stops.size - limit).coerceAtLeast(0)) + val shown = stops.drop(from).take(limit) + shown.forEachIndexed { i, stop -> + val index = from + i + val isNext = index == nextIndex + val done = index < nextIndex + val ink = when { + onPhoto -> if (done) TrekStatus.whiteFaint else TrekStatus.white + done -> GlanceTheme.colors.onSurfaceVariant + else -> GlanceTheme.colors.onSurface + } + Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 3.dp), verticalAlignment = Alignment.CenterVertically) { + val accent = cssColor(stop.color) ?: if (onPhoto) TrekStatus.white else GlanceTheme.colors.primary + if (compact) { + Box(modifier = GlanceModifier.size(8.dp).cornerRadius(4.dp).background(accent)) {} + } else { + IconBadge( + stop.symbol, + tint = if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSecondaryContainer, + background = if (onPhoto) TrekStatus.glass else GlanceTheme.colors.secondaryContainer, + size = 26.dp, + ) + } + Spacer(GlanceModifier.width(8.dp)) + Column(modifier = GlanceModifier.defaultWeight()) { + Line(stop.name, 13.sp, ink, if (isNext) FontWeight.Bold else FontWeight.Medium) + if (compact && stop.time != null) Line(stop.time, 11.sp, if (onPhoto) TrekStatus.whiteMuted else GlanceTheme.colors.onSurfaceVariant) + } + if (!compact) { + if (isNext && !onPhoto) { + Chip(stop.time ?: nowLabel, GlanceTheme.colors.primary, GlanceTheme.colors.onPrimary) + } else { + Line(stop.time ?: "", 12.sp, ink, if (isNext) FontWeight.Bold else FontWeight.Normal) + } + } + } + } +} + +// ---- Bookings ---- + +/** The next flights, trains, hotels and tables, and when they are due. */ +class BookingsWidget : TrekWidget() { + @Composable + override fun Content(snapshot: WidgetSnapshot?) { + val context = LocalContext.current + val size = LocalSize.current + val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context)) + val bookings = snapshot.bookings + WidgetFrame(onClick = openPath(context, bookings.firstOrNull()?.path ?: snapshot.trip?.path ?: "/")) { + Column(modifier = GlanceModifier.fillMaxSize()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Flight, GlanceTheme.colors.primary, 14.dp) + Spacer(GlanceModifier.width(5.dp)) + Eyebrow(labels.bookings, GlanceTheme.colors.primary) + } + Spacer(GlanceModifier.height(8.dp)) + if (bookings.isEmpty()) { + Spacer(GlanceModifier.defaultWeight()) + Line(labels.noBookings, 14.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium, maxLines = 2) + Spacer(GlanceModifier.defaultWeight()) + } else if (!size.isWide) { + val first = bookings.first() + val (symbol, color) = TrekIcons.booking(first.kind) + IconBadge(symbol, TrekStatus.white, ColorProvider(androidx.compose.ui.graphics.Color(color)), 30.dp) + Spacer(GlanceModifier.defaultWeight()) + Line(first.title, 14.sp, weight = FontWeight.Bold, maxLines = 1) + Line( + listOfNotNull(RelativeDay.label(first.date, labels), first.time).joinToString(", "), + 12.sp, + GlanceTheme.colors.onSurfaceVariant, + ) + } else { + val limit = if (size.isTall) 4 else 1 + bookings.take(limit).forEach { booking -> + BookingRow(booking, labels, onPhoto = false) + Spacer(GlanceModifier.height(5.dp)) + } + } + } + } + } +} + +@Composable +private fun BookingRow(booking: WidgetSnapshot.Booking, labels: WidgetSnapshot.Labels, onPhoto: Boolean) { + val context = LocalContext.current + val (symbol, color) = TrekIcons.booking(booking.kind) + Row( + modifier = GlanceModifier.fillMaxWidth() + .background(if (onPhoto) TrekStatus.glass else GlanceTheme.colors.surfaceVariant) + .cornerRadius(14.dp).padding(horizontal = 8.dp, vertical = 6.dp) + .clickable(openPath(context, booking.path)), + verticalAlignment = Alignment.CenterVertically, + ) { + IconBadge(symbol, TrekStatus.white, ColorProvider(androidx.compose.ui.graphics.Color(color)), 30.dp) + Spacer(GlanceModifier.width(10.dp)) + Column(modifier = GlanceModifier.defaultWeight()) { + Line(booking.title, 13.sp, if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSurface, FontWeight.Bold) + booking.detail?.let { Line(it, 11.sp, if (onPhoto) TrekStatus.whiteMuted else GlanceTheme.colors.onSurfaceVariant) } + } + Spacer(GlanceModifier.width(6.dp)) + Column(horizontalAlignment = Alignment.End) { + RelativeDay.label(booking.date, labels)?.let { Line(it, 12.sp, if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSurface, FontWeight.Bold) } + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = GlanceModifier.size(6.dp).cornerRadius(3.dp).background(if (booking.confirmed) TrekStatus.confirmed else TrekStatus.pending)) {} + Spacer(GlanceModifier.width(4.dp)) + Line(booking.time ?: if (booking.confirmed) labels.confirmed else labels.pending, 11.sp, if (onPhoto) TrekStatus.whiteMuted else GlanceTheme.colors.onSurfaceVariant) + } + } + } +} + +// ---- To-dos ---- + +/** What is still open for the trip, and how far the packing has come. */ +class TodosWidget : TrekWidget() { + @Composable + override fun Content(snapshot: WidgetSnapshot?) { + val context = LocalContext.current + val size = LocalSize.current + val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context)) + val todos = snapshot.todos + WidgetFrame(onClick = openPath(context, todos?.path ?: snapshot.trip?.path ?: "/")) { + Column(modifier = GlanceModifier.fillMaxSize()) { + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.CheckCircle, GlanceTheme.colors.primary, 14.dp) + Spacer(GlanceModifier.width(5.dp)) + Eyebrow(labels.todos, GlanceTheme.colors.primary, GlanceModifier.defaultWeight()) + if (todos != null && todos.total > 0) Line("${todos.total - todos.open}/${todos.total}", 12.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Bold) + } + Spacer(GlanceModifier.height(8.dp)) + if (todos == null || todos.open == 0) { + Spacer(GlanceModifier.defaultWeight()) + Line(labels.noTodos, 16.sp, weight = FontWeight.Bold) + snapshot.trip?.title?.let { Line(it, 12.sp, GlanceTheme.colors.onSurfaceVariant) } + Spacer(GlanceModifier.defaultWeight()) + } else { + if (!size.isWide) { + Line(todos.open.toString(), 34.sp, GlanceTheme.colors.primary, FontWeight.Bold) + Spacer(GlanceModifier.defaultWeight()) + } + val limit = when { + size.isTall -> 6 + size.isWide -> 3 + else -> 1 + } + Column(modifier = GlanceModifier.fillMaxWidth()) { + todos.items.take(limit).forEach { todo -> + Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 3.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = GlanceModifier.size(14.dp).cornerRadius(7.dp).background(priorityColor(todo.priority) ?: GlanceTheme.colors.outline), + contentAlignment = Alignment.Center, + ) { + Box(modifier = GlanceModifier.size(10.dp).cornerRadius(5.dp).background(GlanceTheme.colors.widgetBackground)) {} + } + Spacer(GlanceModifier.width(8.dp)) + Line(todo.name, 13.sp, modifier = GlanceModifier.defaultWeight()) + if (size.isWide) RelativeDay.label(todo.due, labels)?.let { Line(it, 11.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium) } + } + } + } + if (size.isWide) Spacer(GlanceModifier.defaultWeight()) + } + val packing = snapshot.packing + if (size.isTall && packing != null && packing.total > 0) { + Spacer(GlanceModifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Luggage, GlanceTheme.colors.onSurfaceVariant, 13.dp) + Spacer(GlanceModifier.width(5.dp)) + Line("${labels.packing}: ${packing.packed}/${packing.total}", 12.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium) + } + Spacer(GlanceModifier.height(5.dp)) + Bar(packing.packed.toFloat() / packing.total, GlanceTheme.colors.primary, GlanceTheme.colors.surfaceVariant) + } + } + } + } + + private fun priorityColor(priority: Int): ColorProvider? = when (priority) { + 3 -> TrekStatus.danger + 2 -> TrekStatus.pending + 1 -> ColorProvider(androidx.compose.ui.graphics.Color(0xFF4A7DDB)) + else -> null + } +} + +// ---- Packing ---- + +/** How full the bags are, their weight, and what is still missing. */ +class PackingWidget : TrekWidget() { + @Composable + override fun Content(snapshot: WidgetSnapshot?) { + val context = LocalContext.current + val size = LocalSize.current + val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context)) + val packing = snapshot.packing + WidgetFrame(onClick = openPath(context, packing?.path ?: snapshot.trip?.path ?: "/")) { + Column(modifier = GlanceModifier.fillMaxSize()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Luggage, GlanceTheme.colors.primary, 14.dp) + Spacer(GlanceModifier.width(5.dp)) + Eyebrow(labels.packing, GlanceTheme.colors.primary) + } + if (packing == null || packing.total == 0) { + Spacer(GlanceModifier.defaultWeight()) + Line(snapshot.trip?.title ?: labels.noTrip, 14.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium, maxLines = 3) + Spacer(GlanceModifier.defaultWeight()) + return@Column + } + val progress = packing.packed.toFloat() / packing.total + Spacer(GlanceModifier.height(if (size.isWide) 8.dp else 4.dp)) + if (!size.isWide) { + // Small: the ring is the widget. + Box(modifier = GlanceModifier.fillMaxWidth().defaultWeight(), contentAlignment = Alignment.Center) { + PackRing(progress, 64.dp) + } + Line("${packing.packed}/${packing.total} ${labels.packed}", 11.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium) + return@Column + } + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + PackRing(progress, 64.dp) + Spacer(GlanceModifier.width(12.dp)) + Column(modifier = GlanceModifier.defaultWeight()) { + Line("${packing.packed}/${packing.total}", 18.sp, weight = FontWeight.Bold) + Line(labels.packed, 12.sp, GlanceTheme.colors.onSurfaceVariant) + if (!size.isTall && packing.missing.isNotEmpty()) { + Line("${packing.missing.size} ${labels.missing}", 12.sp, GlanceTheme.colors.primary, FontWeight.Medium) + } + } + } + if (size.isTall) { + Spacer(GlanceModifier.height(10.dp)) + // Each group in a column of its own: a Glance column takes ten children at most. + Column(modifier = GlanceModifier.fillMaxWidth()) { + packing.bags.take(2).forEach { bag -> BagRow(bag) } + } + if (packing.missing.isNotEmpty()) { + Spacer(GlanceModifier.height(4.dp)) + Column(modifier = GlanceModifier.fillMaxWidth()) { + Eyebrow(labels.missing) + Spacer(GlanceModifier.height(2.dp)) + packing.missing.take(2).forEach { item -> + Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = GlanceModifier.size(6.dp).cornerRadius(3.dp).background(cssColor(item.color) ?: GlanceTheme.colors.outline)) {} + Spacer(GlanceModifier.width(8.dp)) + Line(item.name, 12.sp, modifier = GlanceModifier.defaultWeight()) + item.category?.let { Line(it, 11.sp, GlanceTheme.colors.onSurfaceVariant) } + } + } + } + } + } + } + } + } + + @Composable + private fun PackRing(progress: Float, size: androidx.compose.ui.unit.Dp) { + Box(modifier = GlanceModifier.size(size), contentAlignment = Alignment.Center) { + Image( + provider = ImageProvider(WidgetArt.ring(progress, stroke = 16f)), + contentDescription = null, + colorFilter = ColorFilter.tint(GlanceTheme.colors.primary), + modifier = GlanceModifier.fillMaxSize(), + ) + Line("${(progress * 100).toInt()}%", 13.sp, weight = FontWeight.Bold) + } + } + + @Composable + private fun BagRow(bag: WidgetSnapshot.Bag) { + val bagColor = cssColor(bag.color) ?: GlanceTheme.colors.primary + Column(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 6.dp)) { + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = GlanceModifier.size(8.dp).cornerRadius(4.dp).background(bagColor)) {} + Spacer(GlanceModifier.width(6.dp)) + Line(bag.name, 12.sp, weight = FontWeight.Medium, modifier = GlanceModifier.defaultWeight()) + Line(weight(bag.grams) + (bag.limit?.let { " / ${weight(it)}" } ?: ""), 11.sp, GlanceTheme.colors.onSurfaceVariant) + } + Spacer(GlanceModifier.height(4.dp)) + Bar(if (bag.limit != null && bag.limit > 0) bag.grams.toFloat() / bag.limit else bag.packed.toFloat() / bag.total.coerceAtLeast(1), bagColor, GlanceTheme.colors.surfaceVariant, 5.dp) + } + } + + private fun weight(grams: Int): String = if (grams >= 1000) "%.1f kg".format(grams / 1000f) else "$grams g" +} + +// ---- Atlas ---- + +/** The countries the user has been to, glowing on the world map. Not tied to a trip. */ +class AtlasWidget : TrekWidget() { + override val perTrip = false + + @Composable + override fun Content(snapshot: WidgetSnapshot?) { + val context = LocalContext.current + val size = LocalSize.current + val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context)) + val atlas = snapshot.atlas + val codes = atlas?.codes?.toSet() ?: emptySet() + WidgetFrame(onClick = openPath(context, "/atlas")) { + Column(modifier = GlanceModifier.fillMaxSize()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Public, GlanceTheme.colors.primary, 14.dp) + Spacer(GlanceModifier.width(5.dp)) + Eyebrow(context.getString(com.liketrek.trek.R.string.widget_atlas_name), GlanceTheme.colors.primary) + } + Spacer(GlanceModifier.height(4.dp)) + val count: @Composable () -> Unit = { + Row(verticalAlignment = Alignment.Bottom) { + Line((atlas?.count ?: 0).toString(), if (size.isWide) 34.sp else 30.sp, GlanceTheme.colors.onSurface, FontWeight.Bold) + Spacer(GlanceModifier.width(6.dp)) + Column(modifier = GlanceModifier.padding(bottom = 4.dp)) { + Line(labels.countries, 12.sp, GlanceTheme.colors.onSurface, FontWeight.Bold) + // 195 countries make the world, as on the Atlas page. + if (size.isWide) Line("${((atlas?.count ?: 0) * 100 / 195)}% ${labels.world}", 11.sp, GlanceTheme.colors.onSurfaceVariant) + } + } + } + if (size.isWide && !size.isTall) { + // Wide: the count beside the map, which gets the height. + Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight(), verticalAlignment = Alignment.CenterVertically) { + count() + Spacer(GlanceModifier.width(10.dp)) + WorldMap(codes, GlanceModifier.defaultWeight().fillMaxHeight()) + } + } else { + count() + Spacer(GlanceModifier.height(6.dp)) + WorldMap(codes, GlanceModifier.fillMaxWidth().defaultWeight()) + } + if (size.isTall && atlas != null) { + Spacer(GlanceModifier.height(10.dp)) + Row(modifier = GlanceModifier.fillMaxWidth()) { + atlas.recent.take(4).forEach { country -> + Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.CenterHorizontally) { + Text(flag(country.code), style = TextStyle(fontSize = 20.sp, textAlign = TextAlign.Center)) + Line(country.name, 10.sp, GlanceTheme.colors.onSurfaceVariant) + } + } + } + } + } + } + } + + @Composable + private fun WorldMap(codes: Set, modifier: GlanceModifier) { + Box(modifier = modifier) { + Image( + provider = ImageProvider(WidgetArt.worldDots(codes, visited = false)), + contentDescription = null, + colorFilter = ColorFilter.tint(GlanceTheme.colors.outline), + contentScale = ContentScale.Fit, + modifier = GlanceModifier.fillMaxSize(), + ) + Image( + provider = ImageProvider(WidgetArt.worldDots(codes, visited = true)), + contentDescription = null, + colorFilter = ColorFilter.tint(GlanceTheme.colors.primary), + contentScale = ContentScale.Fit, + modifier = GlanceModifier.fillMaxSize(), + ) + } + } + + /** The flag emoji of an ISO 3166-1 alpha-2 code. */ + private fun flag(code: String): String { + if (code.length != 2) return "" + val first = Character.toChars(0x1F1E6 + (code[0].uppercaseChar() - 'A')) + val second = Character.toChars(0x1F1E6 + (code[1].uppercaseChar() - 'A')) + return String(first) + String(second) + } +} + +// ---- Travel group ---- + +/** Who comes along, the latest messages and the open poll. */ +class CollabWidget : TrekWidget() { + @Composable + override fun Content(snapshot: WidgetSnapshot?) { + val context = LocalContext.current + val size = LocalSize.current + val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context)) + val collab = snapshot.collab + WidgetFrame(onClick = openPath(context, collab?.path ?: snapshot.trip?.path ?: "/")) { + Column(modifier = GlanceModifier.fillMaxSize()) { + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Group, GlanceTheme.colors.primary, 14.dp) + Spacer(GlanceModifier.width(5.dp)) + if (size.isWide) Eyebrow(labels.group, GlanceTheme.colors.primary, GlanceModifier.defaultWeight()) else Spacer(GlanceModifier.defaultWeight()) + collab?.members?.take(if (size.isWide) 4 else 3)?.forEach { member -> + Spacer(GlanceModifier.width(2.dp)) + Initials(member.initials, 22.dp) + } + } + Spacer(GlanceModifier.height(8.dp)) + val messages = collab?.messages.orEmpty() + if (messages.isEmpty() && collab?.poll == null) { + Spacer(GlanceModifier.defaultWeight()) + Line(labels.noMessages, 14.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium, maxLines = 2) + Spacer(GlanceModifier.defaultWeight()) + return@Column + } + messages.takeLast(if (size.isTall) (if (collab?.poll != null) 2 else 4) else 1).forEach { message -> + Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 3.dp), verticalAlignment = Alignment.Top) { + if (!message.mine) { + Initials(message.initials, 24.dp) + Spacer(GlanceModifier.width(6.dp)) + } else { + Spacer(GlanceModifier.defaultWeight()) + } + Column( + modifier = GlanceModifier + .background(if (message.mine) GlanceTheme.colors.primary else GlanceTheme.colors.surfaceVariant) + .cornerRadius(14.dp).padding(horizontal = 10.dp, vertical = 6.dp), + ) { + if (!message.mine && size.isWide) Line(message.author, 10.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Bold) + Line( + message.text, + 12.sp, + if (message.mine) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurface, + maxLines = if (size.isWide) 2 else 3, + ) + } + } + } + val poll = collab?.poll + if (size.isTall && poll != null) { + Spacer(GlanceModifier.defaultWeight()) + Column(modifier = GlanceModifier.fillMaxWidth().background(GlanceTheme.colors.secondaryContainer).cornerRadius(14.dp).padding(10.dp)) { + Eyebrow("${labels.poll}: ${poll.voters} ${labels.votes}", GlanceTheme.colors.onSecondaryContainer) + Line(poll.question, 13.sp, GlanceTheme.colors.onSecondaryContainer, FontWeight.Bold) + Spacer(GlanceModifier.height(4.dp)) + val most = poll.options.maxOfOrNull { it.votes }?.coerceAtLeast(1) ?: 1 + poll.options.take(3).forEach { option -> + Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + Line(option.label, 12.sp, GlanceTheme.colors.onSecondaryContainer, modifier = GlanceModifier.width(70.dp)) + Bar(option.votes.toFloat() / most, GlanceTheme.colors.primary, GlanceTheme.colors.surface, 5.dp, GlanceModifier.defaultWeight()) + Spacer(GlanceModifier.width(6.dp)) + Line(option.votes.toString(), 11.sp, GlanceTheme.colors.onSecondaryContainer, FontWeight.Bold) + } + } + } + } + } + } + } + + @Composable + private fun Initials(text: String, size: androidx.compose.ui.unit.Dp) { + Box( + modifier = GlanceModifier.size(size).cornerRadius(size / 2).background(GlanceTheme.colors.tertiaryContainer), + contentAlignment = Alignment.Center, + ) { + Text(text, style = TextStyle(color = GlanceTheme.colors.onTertiaryContainer, fontSize = 9.sp, fontWeight = FontWeight.Bold), maxLines = 1) + } + } +} + +/** A widget with nothing to show yet: TREK's mark and what to do. */ +@Composable +fun EmptyWidget(text: String) { + val context = LocalContext.current + WidgetFrame(onClick = openPath(context, "/")) { + Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = GlanceModifier.size(34.dp).cornerRadius(11.dp).background(GlanceTheme.colors.primary), + contentAlignment = Alignment.Center, + ) { + Text("T", style = TextStyle(color = GlanceTheme.colors.onPrimary, fontSize = 18.sp, fontWeight = FontWeight.Bold)) + } + Spacer(GlanceModifier.height(10.dp)) + Line(text, 13.sp, GlanceTheme.colors.onSurface, FontWeight.Medium, maxLines = 4) + } + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetArt.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetArt.kt new file mode 100644 index 000000000..24e3a67cd --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetArt.kt @@ -0,0 +1,152 @@ +package com.liketrek.trek.widget + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RadialGradient +import android.graphics.Shader +import android.util.Base64 +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin + +/** + * The pictures a widget shows that RemoteViews cannot draw: the trip's cover, + * its own sky when it has none, and the world map's dots. Drawn once per + * update into bitmaps, kept small (a widget's RemoteViews have a size budget). + */ +object WidgetArt { + /** A stable number for a text (FNV-1a), the same as TrekStyle.swift's seed. */ + fun seed(text: String): Long { + var hash = 1469598103934665603L + for (byte in text.toByteArray(Charsets.UTF_8)) { + hash = (hash xor (byte.toLong() and 0xff)) * 1099511628211L + } + return hash + } + + /** The trip's two colours, from its title: the same trip always gets the same sky. */ + fun tripHues(title: String): Pair { + val base = (java.lang.Long.remainderUnsigned(seed(title), 360)).toFloat() + val first = Color.HSVToColor(floatArrayOf(base, 0.62f, 0.78f)) + val second = Color.HSVToColor(floatArrayOf((base + 40f) % 360f, 0.7f, 0.42f)) + return first to second + } + + /** The cover the app sent, base64 JPEG, decoded down to at most [maxSide] pixels. */ + fun cover(base64: String?, maxSide: Int = 720): Bitmap? { + if (base64.isNullOrBlank()) return null + return try { + val bytes = Base64.decode(base64, Base64.DEFAULT) + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + var sample = 1 + while (bounds.outWidth / (sample * 2) >= maxSide || bounds.outHeight / (sample * 2) >= maxSide) sample *= 2 + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) + } catch (e: IllegalArgumentException) { + null + } + } + + /** A trip without a cover: its two colours as a sky, with contour lines over it. */ + fun sky(title: String, width: Int = 480, height: Int = 480): Bitmap { + val (first, second) = tripHues(title) + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.shader = LinearGradient(0f, 0f, width.toFloat(), height.toFloat(), first, second, Shader.TileMode.CLAMP) + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint) + paint.shader = RadialGradient(width * 0.85f, height * 0.05f, width * 0.6f, Color.argb(70, 255, 255, 255), Color.TRANSPARENT, Shader.TileMode.CLAMP) + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint) + paint.shader = null + drawContours(canvas, width, height, java.lang.Long.remainderUnsigned(seed(title), 997).toInt(), Color.argb(36, 255, 255, 255)) + return bitmap + } + + /** Concentric wobbly rings, the topographic lines that stand for travel in TREK. */ + private fun drawContours(canvas: Canvas, width: Int, height: Int, seed: Int, color: Int) { + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 2f + this.color = color + } + val cx = width * (0.25f + (seed % 50) / 100f) + val cy = height * (0.35f + (seed % 30) / 100f) + val maxR = min(width, height) * 1.1f + val rings = 11 + for (ring in 1..rings) { + val r = maxR * ring / rings + val path = Path() + val steps = 72 + for (i in 0..steps) { + val a = 2 * PI * i / steps + val wobble = 1 + 0.08 * sin(a * 3 + seed + ring) + 0.05 * cos(a * 5 + seed * 0.7) + val x = cx + (r * wobble * cos(a)).toFloat() + val y = cy + (r * wobble * sin(a) * 0.8).toFloat() + if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + canvas.drawPath(path, paint) + } + } + + /** + * The world map as dots, white so the widget tints it: [visited] true draws + * only the countries in [codes], false only the others. + */ + fun worldDots(codes: Set, visited: Boolean, dot: Int = 8): Bitmap { + val rows = WorldGrid.ROWS.size + val bitmap = Bitmap.createBitmap(WorldGrid.COLUMNS * dot, rows * dot, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = Color.WHITE } + val radius = dot * 0.36f + for (row in 0 until rows) { + for (column in 0 until WorldGrid.COLUMNS) { + val code = WorldGrid.code(row, column) ?: continue + if ((code in codes) != visited) continue + canvas.drawCircle(column * dot + dot / 2f, row * dot + dot / 2f, radius, paint) + } + } + return bitmap + } + + /** A ring of [progress] (0..1), white on a faint track, for the countdown and the packing. */ + fun ring(progress: Float, sizePx: Int = 160, stroke: Float = 14f, track: Int = Color.argb(60, 255, 255, 255)): Bitmap { + val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = stroke + strokeCap = Paint.Cap.ROUND + } + val inset = stroke / 2 + 1 + val oval = android.graphics.RectF(inset, inset, sizePx - inset, sizePx - inset) + paint.color = track + canvas.drawArc(oval, 0f, 360f, false, paint) + paint.color = Color.WHITE + val sweep = 360f * progress.coerceIn(0f, 1f) + if (sweep > 0) canvas.drawArc(oval, -90f, sweep, false, paint) + return bitmap + } + + /** A trip's days as segments, filled up to [index]: white, the widget tints it. Track at a third. */ + fun segments(index: Int, total: Int, width: Int = 600, height: Int = 10): Bitmap { + val count = total.coerceIn(1, 31) + val filled = if (total > 31) (index * 31f / total).toInt().coerceAtLeast(1) else index + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + val gap = if (count > 14) 4f else 8f + val w = (width - gap * (count - 1)) / count + for (i in 0 until count) { + paint.color = if (i < filled) Color.WHITE else Color.argb(85, 255, 255, 255) + val left = i * (w + gap) + canvas.drawRoundRect(android.graphics.RectF(left, 0f, left + w, height.toFloat()), height / 2f, height / 2f, paint) + } + return bitmap + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetConfigActivity.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetConfigActivity.kt new file mode 100644 index 000000000..010afa23b --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetConfigActivity.kt @@ -0,0 +1,175 @@ +package com.liketrek.trek.widget + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.lifecycle.lifecycleScope +import com.liketrek.trek.R +import com.liketrek.trek.ui.TrekMaterialTheme +import kotlinx.coroutines.launch + +/** + * "Choose trip" for a placed widget, the counterpart of the iOS widget's Edit + * Widget (TripIntent.swift): Automatic follows the dashboard's trip, or one of + * the trips the app listed (key "trips"). Shown when the widget is placed + * (optional, it starts on Automatic) and again from the widget's long press. + */ +class WidgetConfigActivity : ComponentActivity() { + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + appWidgetId = intent?.extras?.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + ?: AppWidgetManager.INVALID_APPWIDGET_ID + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + // Backing out keeps the widget on what it had (Automatic for a new one). + setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)) + val trips = WidgetStore.trips(this) + val current = WidgetStore.tripFor(this, appWidgetId) + setContent { + TrekMaterialTheme { + TripPicker(trips, current, onPick = ::pick) + } + } + } + + private fun pick(tripId: String) { + WidgetStore.setTripFor(this, appWidgetId, tripId) + lifecycleScope.launch { + val manager = GlanceAppWidgetManager(this@WidgetConfigActivity) + val glanceId = runCatching { manager.getGlanceIdBy(appWidgetId) }.getOrNull() + if (glanceId != null) { + for (widget in TrekWidgetUpdater.widgets()) runCatching { widget.update(this@WidgetConfigActivity, glanceId) } + } + setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)) + finish() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TripPicker(trips: List, current: String, onPick: (String) -> Unit) { + var selected by remember { mutableStateOf(current) } + val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + Scaffold( + modifier = Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection), + topBar = { + LargeTopAppBar( + title = { Text(stringResource(R.string.widget_intent_title)) }, + scrollBehavior = scroll, + ) + }, + ) { padding -> + LazyColumn( + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = padding.calculateTopPadding(), bottom = padding.calculateBottomPadding() + 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + Text( + stringResource(R.string.widget_intent_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp), + ) + } + item { + PickRow( + title = stringResource(R.string.widget_intent_auto), + subtitle = stringResource(R.string.widget_intent_auto_hint), + leading = { Icon(Icons.Rounded.AutoAwesome, null, tint = MaterialTheme.colorScheme.primary) }, + checked = selected == WidgetStore.AUTO, + ) { + selected = WidgetStore.AUTO + onPick(WidgetStore.AUTO) + } + } + items(trips, key = { it.id }) { trip -> + PickRow( + title = trip.title, + subtitle = RelativeDayRange(trip), + leading = { Icon(Icons.Rounded.Luggage, null, tint = MaterialTheme.colorScheme.secondary) }, + checked = selected == trip.id.toString(), + ) { + selected = trip.id.toString() + onPick(trip.id.toString()) + } + } + } + } +} + +@Composable +private fun RelativeDayRange(trip: TripRef): String? { + val context = androidx.compose.ui.platform.LocalContext.current + return RelativeDay.range(context, trip.start, trip.end) +} + +@Composable +private fun PickRow(title: String, subtitle: String?, leading: @Composable () -> Unit, checked: Boolean, onClick: () -> Unit) { + Surface( + shape = RoundedCornerShape(20.dp), + color = if (checked) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + Row(modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { + Surface(shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surface, modifier = Modifier.size(40.dp)) { + Row(horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { leading() } + } + Spacer(Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (subtitle != null) { + Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + } + RadioButton(selected = checked, onClick = onClick) + } + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetSamples.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetSamples.kt new file mode 100644 index 000000000..1288e2c27 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetSamples.kt @@ -0,0 +1,89 @@ +package com.liketrek.trek.widget + +import android.content.Context +import com.liketrek.trek.R +import java.time.LocalDate +import org.json.JSONObject + +/** + * The sample trip the widget picker shows before the app ever wrote a snapshot, + * and what the screenshot tests draw: the same trip to Kyoto as the iOS gallery + * (WidgetSnapshot.sample in TrekFamily.swift). + */ +object WidgetSamples { + fun emptyText(context: Context): String = context.getString(R.string.widget_empty) + + fun snapshot(running: Boolean, now: LocalDate = LocalDate.now()): WidgetSnapshot = WidgetSnapshot.fromJson(JSONObject(json(running, now))) + + fun json(running: Boolean, now: LocalDate = LocalDate.now()): String { + fun iso(offset: Long) = now.plusDays(offset).toString() + val start = if (running) -2L else 12L + val today = if (running) { + """ + "today": {"dayLabel": "Day 3 of 9", "index": 3, "total": 9, "stops": [ + {"time": "08:30", "name": "Fushimi Inari Taisha", "minutes": 510, "color": "#D6273B", "symbol": "building.columns.fill"}, + {"time": "11:00", "name": "Nishiki Market", "minutes": 660, "color": "#E8A13A", "symbol": "storefront.fill"}, + {"time": "13:30", "name": "Kiyomizu-dera", "minutes": 810, "color": "#2FA37A", "symbol": "leaf.fill"}, + {"time": "16:00", "name": "Gion walk", "minutes": 960, "color": "#4A7DDB", "symbol": "figure.walk"}, + {"time": "19:30", "name": "Dinner at Pontocho", "minutes": 1170, "color": "#8E6CD9", "symbol": "fork.knife"} + ]}, + """ + } else { + "" + } + return """ + { + "trip": {"title": "Autumn in Kyoto", "start": "${iso(start)}", "end": "${iso(start + 8)}", "path": "/trips/1", + "place": "Kyoto, Japan", "dayCount": 9, "placeCount": 34, "memberCount": 3}, + $today + "weather": {"temp": 18, "high": 21, "low": 11, "main": "${if (running) "Clear" else "Clouds"}", "label": "Mostly sunny", "place": "Kyoto", "rain": 10}, + "bookings": [ + {"title": "LH 716", "kind": "flight", "date": "${iso(if (running) 6 else 12)}", "time": "13:05", "detail": "FRA → HND", "confirmed": true, "path": "/trips/1"}, + {"title": "Shinkansen Nozomi 21", "kind": "train", "date": "${iso(if (running) 0 else 13)}", "time": "09:12", "detail": "Tokyo → Kyoto", "confirmed": true, "path": "/trips/1"}, + {"title": "Hotel Kanra Kyoto", "kind": "hotel", "date": "${iso(if (running) 0 else 13)}", "time": "15:00", "detail": "6 nights", "confirmed": true, "path": "/trips/1"}, + {"title": "Kaiseki at Kikunoi", "kind": "restaurant", "date": "${iso(if (running) 1 else 15)}", "time": "19:00", "detail": "Higashiyama", "confirmed": false, "path": "/trips/1"}, + {"title": "Tea ceremony", "kind": "event", "date": "${iso(if (running) 2 else 16)}", "time": "10:30", "detail": "Camellia Flower", "confirmed": true, "path": "/trips/1"} + ], + "todos": {"open": 4, "total": 11, "path": "/trips/1/todos", "items": [ + {"name": "Order JR Pass", "due": "${iso(if (running) 0 else 3)}", "priority": 3}, + {"name": "Book Ghibli Museum tickets", "due": "${iso(if (running) 1 else 5)}", "priority": 2}, + {"name": "Get yen in cash", "priority": 1}, + {"name": "Download offline maps", "priority": 0} + ]}, + "packing": {"packed": 23, "total": 31, "path": "/trips/1/packing", + "bags": [ + {"name": "Suitcase", "color": "#4A7DDB", "grams": 14200, "limit": 23000, "packed": 15, "total": 19}, + {"name": "Daypack", "color": "#2FA37A", "grams": 4100, "limit": 8000, "packed": 8, "total": 12} + ], + "missing": [ + {"name": "Rain jacket", "category": "Clothes", "color": "#4A7DDB"}, + {"name": "Power bank", "category": "Tech", "color": "#2FA37A"}, + {"name": "Passport", "category": "Documents", "color": "#2FA37A"}, + {"name": "Sandals", "category": "Shoes", "color": "#4A7DDB"} + ]}, + "atlas": {"count": 23, "trips": 41, "days": 318, + "codes": ["DE","AT","CH","FR","IT","ES","PT","NL","BE","DK","NO","SE","GB","IE","GR","HR","JP","TH","VN","US","CA","MA","AU"], + "recent": [{"code": "JP", "name": "Japan"}, {"code": "NO", "name": "Norway"}, {"code": "PT", "name": "Portugal"}, {"code": "MA", "name": "Morocco"}], + "continents": [{"name": "Europe", "count": 16}, {"name": "Asia", "count": 3}, {"name": "North America", "count": 2}, {"name": "Africa", "count": 1}, {"name": "Oceania", "count": 1}]}, + "collab": {"path": "/trips/1?tab=collab", + "members": [{"name": "Maurice", "initials": "MA"}, {"name": "Julien", "initials": "JU"}, {"name": "Elmo", "initials": "EL"}, {"name": "Sara", "initials": "SA"}], + "messages": [ + {"author": "Julien", "initials": "JU", "text": "Got the JR Pass vouchers, all four of us", "mine": false}, + {"author": "Sara", "initials": "SA", "text": "Can we push dinner to 20:00? Train is late", "mine": false}, + {"author": "Maurice", "initials": "MA", "text": "Sure, I moved the table at Pontocho", "mine": true} + ], + "poll": {"question": "Day trip on Saturday?", "voters": 4, "options": [{"label": "Nara", "votes": 3}, {"label": "Uji", "votes": 1}, {"label": "Kurama", "votes": 0}]}}, + "labels": { + "nextTrip": "Next trip", "today": "Today", "noTrip": "No trip coming up. Plan the next one in TREK.", + "inDays": "in {n} days", "tomorrow": "Tomorrow", "nothingPlanned": "Nothing planned today", + "days": "days", "bookings": "Coming up", "noBookings": "No bookings ahead", + "todos": "To-dos", "noTodos": "All done", "packing": "Packing", "packed": "packed", + "confirmed": "Confirmed", "pending": "Pending", "todayShort": "Today", "tomorrowShort": "Tomorrow", + "now": "Now", "places": "places", "travellers": "travellers", "allDone": "Day done", + "missing": "still to pack", "countries": "countries", "world": "of the world", "group": "Travel group", + "poll": "Poll", "votes": "votes", "noMessages": "No messages yet", "departure": "Departure" + } + } + """.trimIndent() + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetSnapshot.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetSnapshot.kt new file mode 100644 index 000000000..23c41c7d2 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetSnapshot.kt @@ -0,0 +1,286 @@ +package com.liketrek.trek.widget + +import java.time.LocalDate +import java.time.format.DateTimeParseException +import java.time.temporal.ChronoUnit +import org.json.JSONArray +import org.json.JSONObject + +/** + * What the app last told the widgets: client/src/native/widgets.ts builds it and + * hands it over through TrekShell.setWidgetSnapshot, the same JSON the iOS + * widgets read (client/ios/App/TREKWidgets/WidgetSnapshot.swift). Texts arrive + * already translated, so a widget always speaks the language the app does. + * + * Everything past trip, today and labels is optional: a binary newer than the + * server's UI shows what it gets, and unknown fields are ignored. + */ +data class WidgetSnapshot( + val trip: Trip?, + val today: Today?, + val weather: Weather?, + val bookings: List, + val todos: Todos?, + val packing: Packing?, + val atlas: Atlas?, + val collab: Collab?, + val labels: Labels, +) { + data class Trip( + val title: String, + val start: String, + val end: String, + val path: String, + val place: String?, + /** A small JPEG of the cover, base64. */ + val cover: String?, + val dayCount: Int?, + val placeCount: Int?, + val memberCount: Int?, + ) + + data class Stop( + val time: String?, + val name: String, + /** Minutes after midnight. */ + val minutes: Int?, + /** #rrggbb */ + val color: String?, + /** The SF Symbol name the iOS widget uses; [TrekIcons] maps it. */ + val symbol: String?, + ) + + data class Today(val dayLabel: String, val stops: List, val index: Int?, val total: Int?) + + data class Weather( + val temp: Int, + val high: Int?, + val low: Int?, + val main: String, + val label: String, + val place: String?, + val rain: Int?, + ) + + data class Booking( + val title: String, + val kind: String, + val date: String?, + val time: String?, + val detail: String?, + val confirmed: Boolean, + val path: String, + ) + + data class Todo(val name: String, val due: String?, val priority: Int) + + data class Todos(val open: Int, val total: Int, val items: List, val path: String) + + data class Bag(val name: String, val color: String, val grams: Int, val limit: Int?, val packed: Int, val total: Int) + + data class PackItem(val name: String, val category: String?, val color: String?) + + data class Packing(val packed: Int, val total: Int, val path: String, val bags: List, val missing: List) + + data class Country(val code: String, val name: String) + + data class Continent(val name: String, val count: Int) + + data class Atlas( + val count: Int, + val codes: List, + val recent: List, + val continents: List, + val trips: Int?, + val days: Int?, + ) + + data class Member(val name: String, val initials: String) + + data class Message(val author: String, val initials: String, val text: String, val at: String?, val mine: Boolean) + + data class PollOption(val label: String, val votes: Int) + + data class Poll(val question: String, val options: List, val voters: Int) + + data class Collab(val members: List, val messages: List, val poll: Poll?, val path: String) + + /** The labels, translated by the app. The optional ones fall back to English. */ + data class Labels(private val raw: JSONObject) { + val nextTrip get() = text("nextTrip", "Next trip") + val today get() = text("today", "Today") + val noTrip get() = text("noTrip", "No trip coming up. Plan the next one in TREK.") + val inDays get() = text("inDays", "in {n} days") + val tomorrow get() = text("tomorrow", "Tomorrow") + val nothingPlanned get() = text("nothingPlanned", "Nothing planned today") + val days get() = text("days", "days") + val bookings get() = text("bookings", "Coming up") + val noBookings get() = text("noBookings", "No bookings ahead") + val todos get() = text("todos", "To-dos") + val noTodos get() = text("noTodos", "All done") + val packing get() = text("packing", "Packing") + val packed get() = text("packed", "packed") + val confirmed get() = text("confirmed", "Confirmed") + val pending get() = text("pending", "Pending") + val todayShort get() = text("todayShort", today) + val tomorrowShort get() = text("tomorrowShort", tomorrow) + val now get() = text("now", "Now") + val places get() = text("places", "places") + val travellers get() = text("travellers", "travellers") + val allDone get() = text("allDone", "Day done") + val openTrek get() = text("openTrek", "Open TREK") + val missing get() = text("missing", "still to pack") + val countries get() = text("countries", "countries") + val world get() = text("world", "of the world") + val group get() = text("group", "Travel group") + val poll get() = text("poll", "Poll") + val votes get() = text("votes", "votes") + val noMessages get() = text("noMessages", "No messages yet") + val departure get() = text("departure", "Departure") + + private fun text(key: String, fallback: String): String = raw.optString(key).ifBlank { fallback } + } + + /** Whole days from [now] to the trip's start. */ + fun daysUntilStart(now: LocalDate = LocalDate.now()): Long? = trip?.let { day(it.start) }?.let { ChronoUnit.DAYS.between(now, it) } + + /** The trip's length in days, from its dates when the app sent no count. */ + val tripLength: Int? + get() { + trip?.dayCount?.takeIf { it > 0 }?.let { return it } + val start = trip?.let { day(it.start) } ?: return null + val end = day(trip.end) ?: return null + return ChronoUnit.DAYS.between(start, end).toInt() + 1 + } + + companion object { + fun parse(json: String): WidgetSnapshot? = try { + fromJson(JSONObject(json)) + } catch (e: Exception) { + null + } + + fun fromJson(o: JSONObject): WidgetSnapshot = WidgetSnapshot( + trip = o.optJSONObject("trip")?.let { + Trip( + title = it.optString("title"), + start = it.optString("start"), + end = it.optString("end"), + path = it.optString("path", "/"), + place = it.str("place"), + cover = it.str("cover"), + dayCount = it.int("dayCount"), + placeCount = it.int("placeCount"), + memberCount = it.int("memberCount"), + ) + }, + today = o.optJSONObject("today")?.let { t -> + Today( + dayLabel = t.optString("dayLabel"), + stops = t.optJSONArray("stops").objects().map { + Stop(it.str("time"), it.optString("name"), it.int("minutes"), it.str("color"), it.str("symbol")) + }, + index = t.int("index"), + total = t.int("total"), + ) + }, + weather = o.optJSONObject("weather")?.let { + Weather(it.optInt("temp"), it.int("high"), it.int("low"), it.optString("main"), it.optString("label"), it.str("place"), it.int("rain")) + }, + bookings = o.optJSONArray("bookings").objects().map { + Booking( + title = it.optString("title"), + kind = it.optString("kind", "other"), + date = it.str("date"), + time = it.str("time"), + detail = it.str("detail"), + confirmed = it.optBoolean("confirmed"), + path = it.optString("path", "/"), + ) + }, + todos = o.optJSONObject("todos")?.let { t -> + Todos( + open = t.optInt("open"), + total = t.optInt("total"), + items = t.optJSONArray("items").objects().map { Todo(it.optString("name"), it.str("due"), it.optInt("priority")) }, + path = t.optString("path", "/"), + ) + }, + packing = o.optJSONObject("packing")?.let { p -> + Packing( + packed = p.optInt("packed"), + total = p.optInt("total"), + path = p.optString("path", "/"), + bags = p.optJSONArray("bags").objects().map { + Bag(it.optString("name"), it.optString("color", "#4A7DDB"), it.optInt("grams"), it.int("limit"), it.optInt("packed"), it.optInt("total")) + }, + missing = p.optJSONArray("missing").objects().map { PackItem(it.optString("name"), it.str("category"), it.str("color")) }, + ) + }, + atlas = o.optJSONObject("atlas")?.let { a -> + Atlas( + count = a.optInt("count"), + codes = a.optJSONArray("codes").strings(), + recent = a.optJSONArray("recent").objects().map { Country(it.optString("code"), it.optString("name")) }, + continents = a.optJSONArray("continents").objects().map { Continent(it.optString("name"), it.optInt("count")) }, + trips = a.int("trips"), + days = a.int("days"), + ) + }, + collab = o.optJSONObject("collab")?.let { c -> + Collab( + members = c.optJSONArray("members").objects().map { Member(it.optString("name"), it.optString("initials")) }, + messages = c.optJSONArray("messages").objects().map { + Message(it.optString("author"), it.optString("initials"), it.optString("text"), it.str("at"), it.optBoolean("mine")) + }, + poll = c.optJSONObject("poll")?.let { p -> + Poll(p.optString("question"), p.optJSONArray("options").objects().map { PollOption(it.optString("label"), it.optInt("votes")) }, p.optInt("voters")) + }, + path = c.optString("path", "/"), + ) + }, + labels = Labels(o.optJSONObject("labels") ?: JSONObject()), + ) + + fun day(iso: String?): LocalDate? { + if (iso.isNullOrBlank() || iso.length < 10) return null + return try { + LocalDate.parse(iso.substring(0, 10)) + } catch (e: DateTimeParseException) { + null + } + } + + /** The app link a tap opens: com.liketrek.trek://navigate, which the UI routes (nativeApp.ts). */ + fun link(path: String): String = "com.liketrek.trek://navigate" + if (path.startsWith("/")) path else "/$path" + + private fun JSONObject.str(key: String): String? = if (isNull(key)) null else optString(key).ifEmpty { null } + + private fun JSONObject.int(key: String): Int? = if (has(key) && !isNull(key)) optInt(key) else null + + private fun JSONArray?.objects(): List = + if (this == null) emptyList() else (0 until length()).mapNotNull { optJSONObject(it) } + + private fun JSONArray?.strings(): List = + if (this == null) emptyList() else (0 until length()).mapNotNull { optString(it).ifEmpty { null } } + } +} + +/** A trip a widget can be set to, as the app lists them (key "trips"). */ +data class TripRef(val id: Long, val title: String, val start: String, val end: String) { + companion object { + fun parseList(json: String?): List { + if (json.isNullOrBlank()) return emptyList() + return try { + val array = JSONArray(json) + (0 until array.length()).mapNotNull { i -> + val o = array.optJSONObject(i) ?: return@mapNotNull null + if (!o.has("id")) return@mapNotNull null + TripRef(o.optLong("id"), o.optString("title"), o.optString("start"), o.optString("end")) + } + } catch (e: Exception) { + emptyList() + } + } + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetStore.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetStore.kt new file mode 100644 index 000000000..6232f3a9a --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetStore.kt @@ -0,0 +1,67 @@ +package com.liketrek.trek.widget + +import android.content.Context +import java.io.File + +/** + * Where the widgets' snapshots live: one JSON file per key in the app's own + * storage. The widgets run in the app's process, so no shared container is + * needed as on iOS; the keys are the same ones the UI writes there + * ("snapshot", "trips", "trip."). + */ +object WidgetStore { + private val KEY = Regex("^(snapshot|trips|trip\\.[0-9]{1,12})$") + private const val PREFS = "trek.widgets" + const val AUTO = "auto" + + fun isValidKey(key: String): Boolean = KEY.matches(key) + + private fun dir(context: Context): File = File(context.filesDir, "widgets").apply { mkdirs() } + + fun write(context: Context, key: String, json: String) { + require(isValidKey(key)) { "key must be snapshot, trips or trip." } + val target = File(dir(context), "$key.json") + val temp = File(dir(context), "$key.json.tmp") + temp.writeText(json) + if (!temp.renameTo(target)) { + target.delete() + temp.renameTo(target) + } + } + + fun read(context: Context, key: String): String? { + if (!isValidKey(key)) return null + val file = File(dir(context), "$key.json") + return if (file.exists()) runCatching { file.readText() }.getOrNull() else null + } + + /** The dashboard's snapshot. */ + fun load(context: Context): WidgetSnapshot? = read(context, "snapshot")?.let(WidgetSnapshot::parse) + + /** + * The snapshot of the trip a widget is set to; the dashboard's for none or + * "auto", and the dashboard's too while the app has not written that trip. + * A trip's snapshot carries no Atlas, so it borrows the dashboard's. + */ + fun load(context: Context, tripId: String?): WidgetSnapshot? { + if (tripId == null || tripId == AUTO) return load(context) + val own = read(context, "trip.$tripId")?.let(WidgetSnapshot::parse) ?: return load(context) + return if (own.atlas == null) own.copy(atlas = load(context)?.atlas) else own + } + + fun trips(context: Context): List = TripRef.parseList(read(context, "trips")) + + /** The trip picked for one placed widget, "auto" when none was. */ + fun tripFor(context: Context, appWidgetId: Int): String = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("trip.$appWidgetId", AUTO) ?: AUTO + + fun setTripFor(context: Context, appWidgetId: Int, tripId: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString("trip.$appWidgetId", tripId).apply() + } + + fun forget(context: Context, appWidgetIds: IntArray) { + val editor = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + for (id in appWidgetIds) editor.remove("trip.$id") + editor.apply() + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetUi.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetUi.kt new file mode 100644 index 000000000..955d13ffd --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetUi.kt @@ -0,0 +1,278 @@ +package com.liketrek.trek.widget + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.text.format.DateFormat +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.GlanceComposable +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.appWidgetBackground +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.material3.ColorProviders +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import com.liketrek.trek.MainActivity +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.Locale + +/** + * What every TREK widget shares: Material You's colours on Android 12 and later + * (the wallpaper's), TREK's own before that, the system's corner radius, the + * tap that opens a place in the app, and the small pieces (eyebrow, chip, icon + * badge) the widgets are built from. + */ +object TrekWidgetTheme { + /** TREK's palette (client/src/mobile/mobile.css) for systems without dynamic colour. */ + val fallback = ColorProviders( + light = lightColorScheme( + primary = Color(0xFF101013), + onPrimary = Color(0xFFF5F5F7), + primaryContainer = Color(0xFFE7E5DF), + onPrimaryContainer = Color(0xFF101013), + secondary = Color(0xFF4A7DDB), + secondaryContainer = Color(0xFFE4EAF7), + onSecondaryContainer = Color(0xFF1B2F57), + tertiary = Color(0xFF2FA37A), + surface = Color(0xFFF7F6F2), + onSurface = Color(0xFF101013), + onSurfaceVariant = Color(0xFF68686F), + surfaceVariant = Color(0xFFECEAE4), + outline = Color(0xFF9A9AA1), + background = Color(0xFFF1EFEA), + error = Color(0xFFD6273B), + ), + dark = darkColorScheme( + primary = Color(0xFFF5F5F7), + onPrimary = Color(0xFF101013), + primaryContainer = Color(0xFF26262C), + onPrimaryContainer = Color(0xFFF5F5F7), + secondary = Color(0xFF8AAEF0), + secondaryContainer = Color(0xFF233250), + onSecondaryContainer = Color(0xFFDCE6FA), + tertiary = Color(0xFF5CC79E), + surface = Color(0xFF121216), + onSurface = Color(0xFFF5F5F7), + onSurfaceVariant = Color(0xFF9C9CA3), + surfaceVariant = Color(0xFF1E1E24), + outline = Color(0xFF6E6E76), + background = Color(0xFF0C0C0F), + error = Color(0xFFFF6B7A), + ), + ) + + @Composable + fun Themed(content: @Composable () -> Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) GlanceTheme(content = content) else GlanceTheme(colors = fallback, content = content) + } +} + +/** The status colours, the same on every wallpaper so they keep their meaning. */ +object TrekStatus { + val confirmed = ColorProvider(Color(0xFF2FA37A)) + val pending = ColorProvider(Color(0xFFE8A13A)) + val danger = ColorProvider(Color(0xFFD6273B)) + val white = ColorProvider(Color.White) + val whiteMuted = ColorProvider(Color(0xCCFFFFFF)) + val whiteFaint = ColorProvider(Color(0x99FFFFFF)) + val scrim = ColorProvider(Color(0x66000000)) + val glass = ColorProvider(Color(0x33000000)) +} + +/** Opens [path] of the user's server in the app (nativeApp.ts routes com.liketrek.trek://navigate). */ +fun openPath(context: Context, path: String): Action = actionStartActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(WidgetSnapshot.link(path)), context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP), +) + +/** The ground of a widget: the system's widget shape and the theme's widget background. */ +@Composable +fun WidgetFrame( + onClick: Action?, + background: ColorProvider = GlanceTheme.colors.widgetBackground, + padding: Dp = 14.dp, + content: @Composable () -> Unit, +) { + var modifier = GlanceModifier.fillMaxSize().appWidgetBackground().background(background).padding(padding) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) modifier = modifier.cornerRadius(android.R.dimen.system_app_widget_background_radius) + if (onClick != null) modifier = modifier.clickable(onClick) + Box(modifier = modifier) { content() } +} + +/** A small, bold, upper-case line above a widget's content. */ +@Composable +@GlanceComposable +fun Eyebrow(text: String, color: ColorProvider = GlanceTheme.colors.onSurfaceVariant, modifier: GlanceModifier = GlanceModifier) { + Text( + text = text.uppercase(Locale.getDefault()), + style = TextStyle(color = color, fontSize = 11.sp, fontWeight = FontWeight.Bold), + maxLines = 1, + modifier = modifier, + ) +} + +@Composable +fun Line( + text: String, + size: TextUnit = 13.sp, + color: ColorProvider = GlanceTheme.colors.onSurface, + weight: FontWeight = FontWeight.Normal, + maxLines: Int = 1, + modifier: GlanceModifier = GlanceModifier, +) { + Text(text = text, style = TextStyle(color = color, fontSize = size, fontWeight = weight), maxLines = maxLines, modifier = modifier) +} + +/** An icon in a tinted circle: a booking's kind, a stop's category. */ +@Composable +fun IconBadge(symbol: String?, tint: ColorProvider, background: ColorProvider, size: Dp = 28.dp) { + Box( + modifier = GlanceModifier.size(size).background(background).cornerRadius(size / 2), + contentAlignment = Alignment.Center, + ) { + Image( + provider = TrekIcons.provider(symbol), + contentDescription = null, + colorFilter = ColorFilter.tint(tint), + modifier = GlanceModifier.size(size * 0.58f), + ) + } +} + +@Composable +fun Icon(vector: ImageVector, tint: ColorProvider, size: Dp = 14.dp) { + Image(provider = TrekIcons.provider(vector), contentDescription = null, colorFilter = ColorFilter.tint(tint), modifier = GlanceModifier.size(size)) +} + +@Composable +fun SymbolIcon(symbol: String, tint: ColorProvider, size: Dp = 14.dp) { + Image(provider = TrekIcons.provider(symbol), contentDescription = null, colorFilter = ColorFilter.tint(tint), modifier = GlanceModifier.size(size)) +} + +/** A rounded pill with a short text: a date range, a weather reading, a status. */ +@Composable +fun Chip( + text: String, + background: ColorProvider, + color: ColorProvider, + leading: ImageProvider? = null, + leadingTint: ColorProvider? = null, +) { + Row( + modifier = GlanceModifier.background(background).cornerRadius(100.dp).padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (leading != null) { + Image( + provider = leading, + contentDescription = null, + colorFilter = leadingTint?.let { ColorFilter.tint(it) }, + modifier = GlanceModifier.size(12.dp), + ) + Spacer(GlanceModifier.width(4.dp)) + } + Text(text = text, style = TextStyle(color = color, fontSize = 11.sp, fontWeight = FontWeight.Medium), maxLines = 1) + } +} + +@Composable +fun WeatherChip(weather: WidgetSnapshot.Weather, onPhoto: Boolean) { + Chip( + text = "${weather.temp}°", + background = if (onPhoto) TrekStatus.glass else GlanceTheme.colors.secondaryContainer, + color = if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSecondaryContainer, + leading = TrekIcons.provider(TrekIcons.weather(weather.main)), + leadingTint = if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSecondaryContainer, + ) +} + +/** A horizontal bar of [value] (0..1): Glance's progress indicator, rounded. */ +@Composable +fun Bar(value: Float, color: ColorProvider, track: ColorProvider, height: Dp = 6.dp, modifier: GlanceModifier = GlanceModifier.fillMaxWidth()) { + androidx.glance.appwidget.LinearProgressIndicator( + progress = value.coerceIn(0f, 1f), + color = color, + backgroundColor = track, + modifier = modifier.height(height).cornerRadius(height / 2), + ) +} + +/** The day segments of a trip: one bar per day, filled up to today. Drawn as one image (a Row takes ten children at most). */ +@Composable +fun DaySegments(index: Int, total: Int, color: ColorProvider) { + Image( + provider = ImageProvider(WidgetArt.segments(index, total)), + contentDescription = "$index / $total", + colorFilter = ColorFilter.tint(color), + contentScale = androidx.glance.layout.ContentScale.FillBounds, + modifier = GlanceModifier.fillMaxWidth().height(4.dp), + ) +} + +/** Relative days and ranges in the device's language. */ +object RelativeDay { + fun label(iso: String?, labels: WidgetSnapshot.Labels, today: LocalDate = LocalDate.now(), locale: Locale = Locale.getDefault()): String? { + val date = WidgetSnapshot.day(iso) ?: return null + val days = ChronoUnit.DAYS.between(today, date) + if (days == 0L) return labels.todayShort + if (days == 1L) return labels.tomorrowShort + val skeleton = if (days in 2..6) "EEE" else "EEEdMMM" + return DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, skeleton), locale).format(date) + } + + /** "1 – 9 Oct" in the device's own way of writing a range. */ + fun range(context: Context, start: String, end: String): String? { + val from = WidgetSnapshot.day(start) ?: return null + val to = WidgetSnapshot.day(end) ?: return null + val zone = java.time.ZoneId.systemDefault() + val startMs = from.atStartOfDay(zone).toInstant().toEpochMilli() + // formatDateRange's end is exclusive: noon of the last day keeps it in the range. + val endMs = to.atTime(12, 0).atZone(zone).toInstant().toEpochMilli() + val flags = android.text.format.DateUtils.FORMAT_SHOW_DATE or android.text.format.DateUtils.FORMAT_ABBREV_MONTH or + android.text.format.DateUtils.FORMAT_NO_YEAR + return android.text.format.DateUtils.formatDateRange(context, startMs, endMs, flags) + } +} + +/** Minutes after midnight, now. */ +fun nowMinutes(): Int = java.time.LocalTime.now().let { it.hour * 60 + it.minute } + +/** "#rrggbb" as a colour, null for anything else. */ +fun cssColor(css: String?): ColorProvider? { + val hex = css?.removePrefix("#") ?: return null + if (hex.length != 6) return null + val value = hex.toLongOrNull(16) ?: return null + return ColorProvider(Color(0xFF000000 or value)) +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/WorldGrid.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/WorldGrid.kt new file mode 100644 index 000000000..7632d4a98 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/WorldGrid.kt @@ -0,0 +1,53 @@ +package com.liketrek.trek.widget + +/** + * The world as 76 by 34 dots from 80 degrees north to 58 south, each the ISO + * code of the country under it, ".." for sea. The same grid as the iOS widget + * (client/ios/App/TREKWidgets/WorldGrid.swift, generated from + * server/assets/atlas/admin0.geojson.gz); copy it over when that one changes. + */ +object WorldGrid { + const val COLUMNS = 76 + val ROWS: List = listOf( + "........................CACACA..CACACACACACA..GLGLGLGLGLGLGLGLGLGLGL............NONONO..................RU..........RURURU..............................", + "......................CACACACACA..CACACACACA........GLGLGLGLGLGLGLGL..............................RURU....RURURURURURURURURURURURURU..RURURU............", + "......USUSUSUSUSCACACACACACACACACACACACACACACACA....GLGLGLGLGLGLGL................NONONORURU....RURU..RURURURURURURURURURURURURURURURURURURURURURURURURU", + "RURU..USUSUSUSUSCACACACACACACACACACACACACACACACACA....GLGLGLGL....ISIS..........NOSESEFIRURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURU", + "......USUSUSUSUSCACACACACACACACACACACA....CACACACA....GLGL....................NONOSEFIFIRURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURU", + "........USUS......USCACACACACACACACACACA..CACACACA........................GB..NOSESELVLVRURURURURURURURURURURURURURURURURURURURURURURURU......RURU......", + "......US............CACACACACACACACACACACACACACACACA....................IEGBGBNLDEPLPLBYBYRURURURURURUKZKZRURURURURURURURURURURURURURURURU....RU........", + "......................CACACACACACACACACACACACACACACACA....................FRFRDEDECZPLUAUAUARURUKZKZKZKZKZKZKZKZRUMNMNMNMNRUMNCNCNRURURURU..............", + "........................USUSUSUSUSUSUSUSCACACAUSCA........................FRFRITITHRROROMDUARURURUKZKZKZKZKZKZCNCNMNMNMNMNMNCNCNCNCNRU..................", + "......................USUSUSUSUSUSUSUSUSUSUSUS..........................ESESES..ITITALBGTRTRTRGEAZTMTMUZKZKGKGCNCNCNCNCNCNCNCNCNCNCN..JPJP..............", + "........................USUSUSUSUSUSUSUSUSUS............................ESESDZDZ....GRTRTRTRTRIRIRIRIRTMTJTJCNCNCNCNCNCNCNCNCNCNKPKR..JP................", + "..........................USUSUSUSUSUSUSUSUS............................MAMADZDZTN..LY....LBIQIQIRIRIRAFAFPKINCNCNCNCNCNCNCNCNCNKRJPJP..................", + "..........................MXMXMXMXUSUS..US............................MADZDZDZDZLYLYLYEGEGSASAIQIRIRIRPKPKININNPCNCNCNCNCNCNCNCN........................", + "............................MXMXMXUS....US..........................EHEHMRDZDZDZLYLYLYEGEGSASASASAAEIRPKPKINININININMMCNCNCNCNCN........................", + "..............................MXMXMX..MXCUCUBS......................MRMRMRMLDZDZNETDLYSDSDSDSASASASAOM..INININININMMMMLAVNCN............................", + "................................MXMXMXMX..JM........................MRMRMRMLMLNENETDTDSDSDSDERYEYEOM......INININ....MMTHVN....PH........................", + "....................................GTSVNI....VE....................SNSNMLBFNENENGTDTDSDSDSDETYEYE........ININ......MMTHKH....PH........................", + "........................................CRPACOVEVEVE..................SLCIGHBJNGNGTDCFSSSSETETSOSO..........IN....IN....VN......PH......................", + "..........................................COCOCOVEGYSR..................CICI..NGCMCFCFCDSSKEETSOSO..................IDMY....MY..ID......................", + "..........................................ECCOBRBRBRBRBR........................GACGCDCDUGKEKESO......................ID..IDIDID........................", + "..........................................ECPEBRBRBRBRBRBRBR....................CGCDCDCDBITZKE........................IDIDIDIDID..IDIDIDPG..PG..........", + "..........................................PEBRBRBRBRBRBRBRBRBR..................AOAOCDCDTZTZTZ..........................IDIDID........IDPGPGPG..........", + "..........................................PEPEBOBRBRBRBRBRBRBR....................AOAOCDZMTZMZKM..................................AUAU....PG............", + "............................................PEBOBOBRBRBRBRBR....................AOAOAOZMMZMZMZMGMG..............................AUAUAU..AU..............", + "..............................................CLBOPYBRBRBRBR....................NANABWBWZWMZ..MGMG............................AUAUAUAUAUAUAU....FR......", + "..............................................CLARPYBRBRBR........................NABWBWZAMZ..MG............................AUAUAUAUAUAUAUAU............", + "..............................................ARARARBRBR..........................NAZAZAZA..................................AUAUAUAUAUAUAUAUAU..........", + "..............................................ARARARUY............................ZAZAZAZA..................................AUAUAUAUAUAUAUAUAU..........", + "............................................CLARARARUY..............................ZA......................................AU........AUAUAU............", + "............................................CLARARAR....................................................................................AU............NZ", + "............................................CLARAR......................................................................................AU........NZNZ..", + "............................................CLAR........................................................................................................", + "............................................CLAR........................................................................................................", + "..............................................CL........................................................................................................", + ) + + /** The country code of a dot, null for sea. */ + fun code(row: Int, column: Int): String? { + val code = ROWS[row].substring(column * 2, column * 2 + 2) + return if (code == "..") null else code + } +} diff --git a/client/android/app/src/main/res/drawable-nodpi/widget_preview_atlas.png b/client/android/app/src/main/res/drawable-nodpi/widget_preview_atlas.png new file mode 100644 index 0000000000000000000000000000000000000000..b469b971c6afc325cb1ab16cab0f99ac2f572626 GIT binary patch literal 45376 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 W$nnBj#7hPPedMG*OV)}T1^o|qVm~tg literal 0 HcmV?d00001 diff --git a/client/android/app/src/main/res/drawable-nodpi/widget_preview_bookings.png b/client/android/app/src/main/res/drawable-nodpi/widget_preview_bookings.png new file mode 100644 index 0000000000000000000000000000000000000000..f05eea50f642de14353228022a4fbcd0f3cba84a GIT binary patch literal 21868 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 GkpBXG6ZiiB literal 0 HcmV?d00001 diff --git a/client/android/app/src/main/res/drawable-nodpi/widget_preview_collab.png b/client/android/app/src/main/res/drawable-nodpi/widget_preview_collab.png new file mode 100644 index 0000000000000000000000000000000000000000..1e8040aa1098c8acd276bff18e6528ead6b3435f GIT binary patch literal 29506 zcmafaWl&sA(C+Ty?!nzH!QDMTaCe6UcZbE@-AQn_;O+zsA-J=+yUQi-_tpJ%tL~4j zn(94gru*rho;lOcL?|gpqaYF>0ssJ%&oUA!003~~y}b($`%m(yApih4BYc(+Rr6Rn z>2&u|AI^At^R!TCRqvF`4~!SNj_t*R1bRoA($+z)ZMd>*mQHD&R$t7yvzWVIcAMMj zvux_Kn&{o!AlS5ygGO0v-Z*5?V(_D+0iJ&We00c@EOj~h)K_ftPC@pT0 z6L2CAGHFw!UL~g>k&<=1nY07XH42|+0rFGZnAYW6`Go>KNxwicd;o8_v4Ig&3odLh zbRl%1tm1i3+<#s5)9b9GQG!{)>^84Qglm@ z0M9TaJqS4I*s({CZOA0SN5Ih;v5I$PGN)Df*Z?yC8<@g2K-}->AgkRf4-McOp&w=m zLg88hs4{D=j@ zo{2L4DHda6zj>D32P0Sh@dB)mAWIFTzNYwX#X&HELN-EF>nl=@F`CvRXTs@+|H$!j zY|HqrD6PSO*!;jeE0n4adAcJEou;8p%)6N@U@;pI5R|CgvJksk95wYsI-moJXpR9$ zcNG+mYTy547#Q|sCCheUN%;>HZoX*X3TWUncO5F>Tfq-sKD1o|5-Wl7wH^GfCm3B}&1?$%5hQhClI2$R+ZIOy%mSp3ss zRa82&dpvHm674uF9W{~Jvgh9{1pref*uovs{5ZPQW>x9y;NMNT9| zdct#;Zwdb}u)`F7eF6H*<5#0_af}qRu@&1vxLN_-EfS z-I)BG0^?xgD?-k`lB}g|>G=G!so((DI5X_-pA-i>rVViSvETA5i4djF|Nf27)Yk|1 zTfnFI9gV1I0jSt#jn!X!5YJCoi@61~y8p(?Y+z;T*%wV)0MquxO-C{|fxTZfW=ANV zWxig_zUv#a+u?zl&z8U(bzYF$tD?AVD^I3Av6_&8<+U>b6?_f93<^B+!icmrHT)Mtl;WSk zAhPlPyppb(VuM6Lg?CC$Y5$(DI-T3kn}73$V1jzH1zdwT7{TXbuv%dg&4I{w3Fq3L z8H-~;lYYAQ-!mv5E0zPNp+56FSl*L+9sT%F>m}xB49QBa`CmQ9q(I4NG&Vk}g5U2Y zVR)MGrp_};arh3k-;r==no`o=uyo<8JaX(l85V@!1o+T=GmW}H{-}o3W?P5o*}U=9 z5WOku&t2xb&VkG87Af9{E6N*U0l}cp;J;*|8(z2ds}yDw-y7$D$P2PR;$Bq5HgTI$qPd`DB{y#Fc--iDr+ zcCdvtW|N6!v|9E%01fHGP*NMyV7Jgp3)xAX+XO6C*-`8^}~47!IR>9O6b7*48!=*h{k;B@Q1Np z0gwNT+b;bA1`I#VrVg?Kxc8=^LiqG=zGKuFUb9lI38IExUjsgozt>7+b@jcIbZ;a= zdfBzqp33l-pkZ4KJ>ej%4NdozH1H+Blqr2VMtYX1F}f*{e*hHwtcK$;^ShG4_;70pbPW-4lx}8NW;p2=AZ(m>bYX2V&f^~eVoIm@0n*b8kDwQQTiHketg{$;_5nN zD-Cm;4voOcXW8Zp`JmyolIt6!)sLJn)iR`z93m`I5(;hnnUHit{ofRsn25-Thi`1J z-7?vTc2TP&l|71l7v$XICj{K zf{#}#z*U;P6B0=A^Y@R#TZ;;?CdHVjFp?!C<)KB?N!eVDKFCsfhtFq0IY6Wt?zF7p z@Edg*`aZ_Fq*O^s1x^~gi|59vI!Uw2`3>T|cL!s$8hz(icsB&RvI;@F9$XywYY(FW zb7R8@jPXA@{1RZ*b)_V8n9;_I_jtGC^1%SU(doM5;yj0o8nSttM5Iy$?k&T*9n z<0QemL|s2z?nK(_QT#rM$sO~ACi%+#5n~_IXw8*7kepE|_c1h6fUL)koTQdFQsBG2 z)H~kJ+Fb?aF$QhvMImoaA-~rl`ZHhpjgD^Wh1wN;URKw3knVA!mt}5!n>zebYiR*l z1tZJ@P?pdhsH5Y@LY{N)2_*}_PQMu!ew2A=HVl1Siq}e}#3$z6bJ?k&g@C~C^!0;) zyV??tPv@j5LT%z@bTfo6qsPKc#~BD7>?$2qL%ed`eD{1%7Twa~JfmIszLuc`nEw!S z$d{ODeX|3uu8VyIBRVKP_sm$epqjjV3XvQ0vaN`Y7x4b7siB%V6Xyr@)E+fWcfmjJ zsTARLL>*PHE&6Xsf|j|PT2IIKAwiJ4Q{LE${4c^EE>!eAq!=6X2LcPOKslpt-ouMBu|K-}&co?%ns_~W%$3ytLxC1c_eBiixE#z{D{#@c9 zQddJv&toMwkhnbn6#cmSCo;&Zgx=?i1}S?*=tr<41$hgHIMLHi{F^MqKXO zHQ!PVPCR(~2bx@?UuBDYp7>fg1al8D{lPx72T6SX*hBCUIcR zeQf#b+Lhq6aqlTM8>Q{Ts5}W8_D+w$_86UMh z=h{rf^cTdsa_0)us`+yeFP&HRsj$aeT3CmfamIteX}vX`N~zcYSEGPl%yjDNf}3{Z zDdV%SsZL`zw_y>?4OmEf9O>Chg(bO*(QQICCHW1LjATw&T4hBBVX2du+30|gH@8+_JSdcO83@K%I7mw8Q?1XLv zNj&@~OU`NdW(-PuXL=H*JZqQ7BvpZvJSuES1O||PxZmDjnU0uRPeG!ARzue|XVt*> z3Zc2~fMaJTMBP{;9R)uKgvZ)ut`noJlP=z^UpM{PGq)ZED{hr{zR4DnC>i2Rw%NAT z)0Jd1v)Nyl+C)MDs=s3MJoT1TBvRDY+UnU#aLVZ1-vlzY!CaC-&??`g{5VO_C zq^5~YLCelldx6iC8jF5;zNz|ibEEgZE~$iCk1bNJA=^`ZHUsNhRM9$H2KvDlQA>B^ zvAHv+#MlB*s;!XI9+8E%&jvpmY~_lN2uHTnRXF$|X-Jndh5GukJjcLVx_RSy9FRhR zNRXP5mLm#bh*VNZhnXSUzwT^CKx9ce?C@+D;4!*nZ#NqbBCx=f2?gT$Qj`H&KK{+m z3JnLQvep);=DH(5k{o<(v~X`yv`z-T?Dz2ypcgE>Q1uX~JKj_6 zk#GefE-?0D@|;SSz@;N<3DJQsA$$P`7%bZEv%1)VcTC%9Mfav020{W$(RGJJ~VB<5D@O z->_%oB;U)tDR&|w2`y7p>wV^U`-gDZb_TEI;Br5DQG7U4Axv$Zzr7l+6}uJ%%irT` z*2I&%A~g^O1{Cmk`fIe_r{k&D^V{jQqs#3|CTwlZMY6{xcUDk@!LSwITxlqKQa<1QtUTh^lWL@zK6|Vb7K{*u+KxxeN#*f zFp-HW7#s+)WNL{-&HrWt6nfr1N!L;ZQ3n)4wr1zuwJ#(PB{*DApL7a$EJDurJDnYd z1NpRyfs2+Y@6+zQL36%vu9DY?9#?d_d2F#g=qYXHBd4i?`DGB=+AgVT@)ucP=QMOG z^Sb$s#Ma0p>Q=zAWgm!i3r5>@TV%LtM;Z|iBeJ1hSyesoCJU9I{f-;I_$zYsr%ty& zJFYfk1GY$q0}!;y4-cEQMUr^-Su{xfLJRof(uIN#bEE1SY^g>oEDlpA?v#wQ-Xk9~ z!@v8z`Pr?sxKMRV7Vs`jb~6`A*?^ znD8@f^^wWrc-ds|Xv7?h`_2pG1LA1@cNmtB#w*aJ#BfbCcV~PXX)1jz(G5$FpQGuS z`+wrA=5QMt{_^7Uj&5bQENF7u8rgMkacl?N0j9c|hHGVkEXu|s&qqmba>;(^(z+`6 z(F?pEH+;h~d#yg|DQ=zDapC#wTaQpTJbC=R7C`7XO5^fm=o}kkMCCW1aiaLZ4Iiym zzKY zalr@i?cvzT%p6)tRb601mRgq9jT_gu

C;Ge;nbX&(n%#q3K?-pW? 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⩔fNN{0?31$ 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 XXD{_?a1Jr^00000NkvXXu0mjfDkeGb literal 0 HcmV?d00001 diff --git a/client/android/app/src/main/res/drawable-nodpi/widget_preview_todos.png b/client/android/app/src/main/res/drawable-nodpi/widget_preview_todos.png new file mode 100644 index 0000000000000000000000000000000000000000..88ab2dda371f6c1bf91a76bb33b57a9512c63fbe GIT binary patch literal 13522 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

ld+O!k>MA)aVrJGsLR4w109!$aO>FbAGk)%iv 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

  • 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 K3MgUx?Y{wiYRN(X literal 0 HcmV?d00001 diff --git a/client/android/app/src/main/res/drawable-nodpi/widget_preview_trip.png b/client/android/app/src/main/res/drawable-nodpi/widget_preview_trip.png new file mode 100644 index 0000000000000000000000000000000000000000..bab4da1a0df2c46a9dd1310b37616745aa5d1caf GIT binary patch literal 70457 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%0AQFWFUbIdn#(^XRgI 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 b+wlJZT3XzzWeU!@00000NkvXXu0mjfte6w~ literal 0 HcmV?d00001 diff --git a/client/android/app/src/main/res/drawable/widget_scrim.xml b/client/android/app/src/main/res/drawable/widget_scrim.xml new file mode 100644 index 000000000..fabef7c70 --- /dev/null +++ b/client/android/app/src/main/res/drawable/widget_scrim.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/client/android/app/src/main/res/resources.properties b/client/android/app/src/main/res/resources.properties new file mode 100644 index 000000000..467b3efec --- /dev/null +++ b/client/android/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en-US diff --git a/client/android/app/src/main/res/values-ar/strings_native.xml b/client/android/app/src/main/res/values-ar/strings_native.xml new file mode 100644 index 000000000..1fb3ec9d1 --- /dev/null +++ b/client/android/app/src/main/res/values-ar/strings_native.xml @@ -0,0 +1,57 @@ + + + + رحلة + عدّ تنازلي لرحلتك القادمة، ثم اليوم وما يليه أثناء السفر. + اليوم + خطة اليوم كخط زمني، مع الطقس والمحطة التالية. + الحجوزات + رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها. + المهام + ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة. + قائمة الأمتعة + مدى امتلاء الحقائب ووزنها وما الذي ما زال ناقصًا. + خريطة العالم + الدول التي زرتها مضيئة على الخريطة. + مجموعة السفر + من يرافقك وأحدث الرسائل والتصويت المفتوح. + اختر الرحلة + الرحلة التي تعرضها هذه الأداة. + الرحلة + تلقائي + نفس رحلة لوحة التحكم + إضافة إلى TREK + إلغاء + إغلاق + الرحلة + افتح TREK مرة واحدة وستظهر رحلاتك هنا. + يقرأ TREK الحجز ويضيفه إلى الرحلة. + الاختيار لاحقًا + إضافة + إضافة إلى %1$s + تمت الإضافة + جارٍ القراءة… + هذا الملف أكبر من أن يُستورد. + لا يستطيع TREK استيراد هذا. + تعذّر تمرير هذا إلى TREK. حاول مرة أخرى. + الآن + انتهت + غدًا + بعد %1$d يومًا + بطاقة Wallet + PDF + بريد + صفحة ويب + نص + صورة + ملف + افتح TREK مرة واحدة وستظهر رحلاتك هنا. + دردشة الرحلة + تحديثات الرحلة + الدعوات والمشاركات + عام + يوم السفر + إشعار جديد + يوم الرحلة الجارية ومحطتها التالية. + diff --git a/client/android/app/src/main/res/values-az/strings_native.xml b/client/android/app/src/main/res/values-az/strings_native.xml new file mode 100644 index 000000000..f6de238e9 --- /dev/null +++ b/client/android/app/src/main/res/values-az/strings_native.xml @@ -0,0 +1,57 @@ + + + + Səyahət + Növbəti səyahətə qədər günləri sayır, yolda isə günü və sonrakını göstərir. + Bu gün + Günün planı zaman xətti kimi, hava və növbəti dayanacaqla. + Bronlar + Növbəti uçuşlar, qatarlar, otellər və restoranlar, nə vaxt olduqları ilə. + Tapşırıqlar + Səyahət üçün hələ nəyin qaldığı və yığışmağın nə yerdə olduğu. + Yük siyahısı + Çantaların doluluğu, çəkisi və hələ nəyin çatışmadığı. + Dünya xəritəsi + Olduğun ölkələr xəritədə parlayır. + Səyahət qrupu + Kim gəlir, son mesajlar və açıq səsvermə. + Səyahəti seç + Bu vidcetin göstərdiyi səyahət. + Səyahət + Avtomatik + İdarə panelindəki səyahət + TREK-ə əlavə et + Ləğv et + Bağla + Səyahət + TREK-i bir dəfə açın, səyahətləriniz burada görünəcək. + TREK rezervasiyanı oxuyur və səyahətə əlavə edir. + Sonra seç + Əlavə et + %1$s səyahətinə əlavə et + Əlavə edildi + Oxunur… + Bu fayl idxal üçün çox böyükdür. + TREK bunu idxal edə bilmir. + Bunu TREK-ə ötürmək alınmadı. Yenidən cəhd edin. + İndi + Keçib + Sabah + %1$d gün sonra + Wallet kartı + PDF + E-poçt + Veb səhifə + Mətn + Foto + Fayl + TREK-i bir dəfə açın, səyahətləriniz burada görünəcək. + Səyahət söhbəti + Səyahət yenilikləri + Dəvətlər və paylaşımlar + Ümumi + Səyahət günü + Yeni bildiriş + Davam edən səyahətin günü və növbəti dayanacağı. + diff --git a/client/android/app/src/main/res/values-b+zh+Hans/strings_native.xml b/client/android/app/src/main/res/values-b+zh+Hans/strings_native.xml new file mode 100644 index 000000000..5e0b0d11c --- /dev/null +++ b/client/android/app/src/main/res/values-b+zh+Hans/strings_native.xml @@ -0,0 +1,57 @@ + + + + 旅行 + 倒数到下一次旅行,旅途中显示当天行程和接下来的安排。 + 今天 + 以时间线显示今天的计划,附带天气和下一站。 + 预订 + 你接下来的航班、火车、酒店和餐厅,以及时间。 + 待办 + 旅行还剩哪些事没做,以及行李收拾到哪一步。 + 行李 + 包装得多满、重量多少,以及还缺什么。 + 世界地图 + 你去过的国家在地图上亮起。 + 旅行小组 + 谁一起去、最新消息和进行中的投票。 + 选择旅行 + 此小组件显示的旅行。 + 旅行 + 自动 + 与仪表板相同的旅行 + 添加到 TREK + 取消 + 关闭 + 行程 + 打开一次 TREK,你的行程就会显示在这里。 + TREK 会读取预订并添加到行程中。 + 稍后选择 + 添加 + 添加到 %1$s + 已添加 + 正在读取… + 此文件太大,无法导入。 + TREK 无法导入此内容。 + 无法传给 TREK。请重试。 + 进行中 + 已结束 + 明天 + %1$d 天后 + Wallet 卡券 + PDF + 邮件 + 网页 + 文本 + 照片 + 文件 + 打开一次 TREK,你的行程就会显示在这里。 + 行程聊天 + 行程动态 + 邀请与共享 + 常规 + 旅行日 + 新通知 + 进行中行程的当天安排和下一站。 + diff --git a/client/android/app/src/main/res/values-b+zh+Hant/strings_native.xml b/client/android/app/src/main/res/values-b+zh+Hant/strings_native.xml new file mode 100644 index 000000000..eea536a1a --- /dev/null +++ b/client/android/app/src/main/res/values-b+zh+Hant/strings_native.xml @@ -0,0 +1,57 @@ + + + + 旅行 + 倒數到下一次旅行,旅途中顯示當天行程與接下來的安排。 + 今天 + 以時間軸顯示今天的計畫,附上天氣與下一站。 + 預訂 + 你接下來的航班、火車、飯店與餐廳,以及時間。 + 待辦 + 旅行還剩哪些事沒做,以及行李整理到哪一步。 + 行李 + 包包裝得多滿、重量多少,以及還缺什麼。 + 世界地圖 + 你去過的國家在地圖上亮起。 + 旅行小組 + 誰一起去、最新訊息與進行中的投票。 + 選擇旅行 + 此小工具顯示的旅行。 + 旅行 + 自動 + 與儀表板相同的旅行 + 加入 TREK + 取消 + 關閉 + 行程 + 開啟一次 TREK,你的行程就會顯示在這裡。 + TREK 會讀取訂位並加入行程。 + 稍後選擇 + 加入 + 加入 %1$s + 已加入 + 正在讀取… + 此檔案太大,無法匯入。 + TREK 無法匯入此內容。 + 無法傳給 TREK。請重試。 + 進行中 + 已結束 + 明天 + %1$d 天後 + Wallet 票卡 + PDF + 郵件 + 網頁 + 文字 + 照片 + 檔案 + 開啟一次 TREK,你的行程就會顯示在這裡。 + 行程聊天 + 行程動態 + 邀請與共享 + 一般 + 旅行日 + 新通知 + 進行中行程的當天安排與下一站。 + diff --git a/client/android/app/src/main/res/values-ca/strings_native.xml b/client/android/app/src/main/res/values-ca/strings_native.xml new file mode 100644 index 000000000..9f0512344 --- /dev/null +++ b/client/android/app/src/main/res/values-ca/strings_native.xml @@ -0,0 +1,57 @@ + + + + Viatge + Compta els dies fins al proper viatge i, durant el viatge, mostra el dia i el que ve després. + Avui + El pla del dia com a línia de temps, amb el temps i la propera parada. + Reserves + Els teus propers vols, trens, hotels i taules, i quan toquen. + Tasques + El que encara queda per al viatge i com va l\'equipatge. + Equipatge + Com de plenes són les maletes, el pes i el que encara falta. + Mapa del món + Els països on has estat, brillant al mapa. + Grup de viatge + Qui ve, els darrers missatges i la votació oberta. + Tria el viatge + El viatge que mostra aquest giny. + Viatge + Automàtic + El mateix viatge que el tauler + Afegeix a TREK + Cancel·la + Tanca + Viatge + Obre TREK una vegada i aquí apareixeran els teus viatges. + TREK llegeix la reserva i l’afegeix al viatge. + Tria més tard + Afegeix + Afegeix a %1$s + Afegit + Llegint… + Aquest fitxer és massa gran per importar-lo. + TREK no pot importar això. + No s’ha pogut passar a TREK. Torna-ho a provar. + Ara + Passat + Demà + D’aquí a %1$d dies + Passi de Wallet + PDF + Correu + Pàgina web + Text + Foto + Fitxer + Obre TREK una vegada i aquí apareixeran els teus viatges. + Xat del viatge + Novetats del viatge + Invitacions i comparticions + General + Dia de viatge + Notificació nova + El dia d\'un viatge en curs i la seva propera parada. + diff --git a/client/android/app/src/main/res/values-cs/strings_native.xml b/client/android/app/src/main/res/values-cs/strings_native.xml new file mode 100644 index 000000000..b651b22dd --- /dev/null +++ b/client/android/app/src/main/res/values-cs/strings_native.xml @@ -0,0 +1,57 @@ + + + + Cesta + Odpočítává dny do další cesty a během ní ukazuje den a co přijde dál. + Dnes + Plán dne jako časová osa, s počasím a další zastávkou. + Rezervace + Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají. + Úkoly + Co ještě zbývá k cestě a jak daleko je balení. + Balení + Jak plná jsou zavazadla, jejich váha a co ještě chybí. + Mapa světa + Země, kde jsi byl, zářící na mapě. + Cestovní skupina + Kdo jede s tebou, nejnovější zprávy a otevřené hlasování. + Vybrat cestu + Cesta, kterou tento widget zobrazuje. + Cesta + Automaticky + Stejná cesta jako na nástěnce + Přidat do TREK + Zrušit + Zavřít + Cesta + Jednou otevřete TREK a vaše cesty se zobrazí zde. + TREK přečte rezervaci a přidá ji k cestě. + Vybrat později + Přidat + Přidat do %1$s + Přidáno + Načítání… + Tento soubor je pro import příliš velký. + Tohle TREK neumí importovat. + Nepodařilo se to předat do TREK. Zkuste to znovu. + Teď + Proběhla + Zítra + Za %1$d dní + Karta Wallet + PDF + E-mail + Webová stránka + Text + Fotka + Soubor + Jednou otevřete TREK a vaše cesty se zobrazí zde. + Chat výletu + Novinky z výletu + Pozvánky a sdílení + Obecné + Cestovní den + Nové oznámení + Den probíhajícího výletu a jeho další zastávka. + diff --git a/client/android/app/src/main/res/values-de/strings_native.xml b/client/android/app/src/main/res/values-de/strings_native.xml new file mode 100644 index 000000000..27aa8e892 --- /dev/null +++ b/client/android/app/src/main/res/values-de/strings_native.xml @@ -0,0 +1,57 @@ + + + + Reise + Zählt die Tage bis zur nächsten Reise und zeigt unterwegs den Tag und was als Nächstes kommt. + Heute + Der Tagesplan als Zeitleiste, mit dem Wetter und dem nächsten Stopp. + Buchungen + Deine nächsten Flüge, Züge, Hotels und Tische, und wann sie anstehen. + To-dos + Was für die Reise noch offen ist und wie weit das Packen ist. + Packliste + Wie voll die Taschen sind, ihr Gewicht und was noch fehlt. + Weltkarte + Die Länder, in denen du warst, leuchtend auf der Karte. + Reisegruppe + Wer mitreist, die neuesten Nachrichten und die offene Abstimmung. + Reise wählen + Die Reise, die dieses Widget zeigt. + Reise + Automatisch + Dieselbe Reise wie im Dashboard + Zu TREK hinzufügen + Abbrechen + Schließen + Reise + Öffne TREK einmal, dann erscheinen hier deine Reisen. + TREK liest die Buchung und trägt sie in die Reise ein. + Später wählen + Hinzufügen + Zu %1$s hinzufügen + Hinzugefügt + Wird gelesen… + Diese Datei ist zu groß für den Import. + Das kann TREK nicht importieren. + Das konnte nicht an TREK übergeben werden. Bitte versuch es erneut. + Läuft + Vorbei + Morgen + In %1$d Tagen + Wallet-Pass + PDF + E-Mail + Webseite + Text + Foto + Datei + Öffne TREK einmal, dann erscheinen hier deine Reisen. + Reise-Chat + Neues zur Reise + Einladungen und Freigaben + Allgemein + Reisetag + Neue Benachrichtigung + Der Tag einer laufenden Reise und der nächste Stopp. + diff --git a/client/android/app/src/main/res/values-el/strings_native.xml b/client/android/app/src/main/res/values-el/strings_native.xml new file mode 100644 index 000000000..ec7c830ca --- /dev/null +++ b/client/android/app/src/main/res/values-el/strings_native.xml @@ -0,0 +1,57 @@ + + + + Ταξίδι + Μετρά αντίστροφα ως το επόμενο ταξίδι και στη διάρκειά του δείχνει τη μέρα και τι ακολουθεί. + Σήμερα + Το πρόγραμμα της ημέρας ως χρονολόγιο, με τον καιρό και την επόμενη στάση. + Κρατήσεις + Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι. + Εκκρεμότητες + Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα. + Αποσκευές + Πόσο γεμάτες είναι οι τσάντες, το βάρος τους και τι λείπει ακόμα. + Παγκόσμιος χάρτης + Οι χώρες που έχεις επισκεφθεί, φωτεινές στον χάρτη. + Ομάδα ταξιδιού + Ποιος έρχεται, τα τελευταία μηνύματα και η ανοιχτή ψηφοφορία. + Επιλογή ταξιδιού + Το ταξίδι που δείχνει αυτό το widget. + Ταξίδι + Αυτόματα + Το ίδιο ταξίδι με τον πίνακα + Προσθήκη στο TREK + Ακύρωση + Κλείσιμο + Ταξίδι + Ανοίξτε το TREK μία φορά και τα ταξίδια σας θα εμφανιστούν εδώ. + Το TREK διαβάζει την κράτηση και την προσθέτει στο ταξίδι. + Επιλογή αργότερα + Προσθήκη + Προσθήκη στο %1$s + Προστέθηκε + Ανάγνωση… + Το αρχείο είναι πολύ μεγάλο για εισαγωγή. + Το TREK δεν μπορεί να το εισαγάγει. + Δεν ήταν δυνατή η μεταφορά στο TREK. Δοκιμάστε ξανά. + Τώρα + Παρελθόν + Αύριο + Σε %1$d ημέρες + Κάρτα Wallet + PDF + Email + Ιστοσελίδα + Κείμενο + Φωτογραφία + Αρχείο + Ανοίξτε το TREK μία φορά και τα ταξίδια σας θα εμφανιστούν εδώ. + Συνομιλία ταξιδιού + Ενημερώσεις ταξιδιού + Προσκλήσεις και κοινοποιήσεις + Γενικά + Ημέρα ταξιδιού + Νέα ειδοποίηση + Η ημέρα ενός ταξιδιού σε εξέλιξη και η επόμενη στάση του. + diff --git a/client/android/app/src/main/res/values-es/strings_native.xml b/client/android/app/src/main/res/values-es/strings_native.xml new file mode 100644 index 000000000..afd51797f --- /dev/null +++ b/client/android/app/src/main/res/values-es/strings_native.xml @@ -0,0 +1,57 @@ + + + + Viaje + Cuenta los días hasta tu próximo viaje y, durante él, muestra el día y lo que sigue. + Hoy + El plan del día como línea de tiempo, con el tiempo y la próxima parada. + Reservas + Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan. + Tareas + Lo que aún queda para el viaje y cómo va la maleta. + Equipaje + Lo llenas que van las maletas, su peso y lo que aún falta. + Mapa del mundo + Los países donde has estado, brillando en el mapa. + Grupo de viaje + Quién viene, los últimos mensajes y la votación abierta. + Elegir viaje + El viaje que muestra este widget. + Viaje + Automático + El mismo viaje que el panel + Añadir a TREK + Cancelar + Cerrar + Viaje + Abre TREK una vez y tus viajes aparecerán aquí. + TREK lee la reserva y la añade al viaje. + Elegir después + Añadir + Añadir a %1$s + Añadido + Leyendo… + Este archivo es demasiado grande para importarlo. + TREK no puede importar esto. + No se pudo pasar a TREK. Inténtalo de nuevo. + En curso + Pasado + Mañana + En %1$d días + Pase de Wallet + PDF + Correo + Página web + Texto + Foto + Archivo + Abre TREK una vez y tus viajes aparecerán aquí. + Chat del viaje + Novedades del viaje + Invitaciones y compartidos + General + Día de viaje + Notificación nueva + El día de un viaje en curso y su próxima parada. + diff --git a/client/android/app/src/main/res/values-et/strings_native.xml b/client/android/app/src/main/res/values-et/strings_native.xml new file mode 100644 index 000000000..4f6414da0 --- /dev/null +++ b/client/android/app/src/main/res/values-et/strings_native.xml @@ -0,0 +1,57 @@ + + + + Reis + Loeb päevi järgmise reisini ja näitab reisi ajal päeva ning seda, mis järgmisena tuleb. + Täna + Päeva plaan ajajoonena, koos ilma ja järgmise peatusega. + Broneeringud + Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on. + Ülesanded + Mis reisi jaoks veel tegemata on ja kui kaugel pakkimine on. + Pakkimine + Kui täis kotid on, nende kaal ja mis veel puudu on. + Maailmakaart + Riigid, kus oled käinud, kaardil helendamas. + Reisiseltskond + Kes tuleb kaasa, viimased sõnumid ja avatud hääletus. + Vali reis + Reis, mida see vidin näitab. + Reis + Automaatne + Sama reis mis töölaual + Lisa TREK-i + Tühista + Sulge + Reis + Ava TREK korra ja sinu reisid ilmuvad siia. + TREK loeb broneeringu ja lisab selle reisile. + Vali hiljem + Lisa + Lisa reisile %1$s + Lisatud + Loen… + See fail on importimiseks liiga suur. + TREK ei saa seda importida. + Seda ei õnnestunud TREK-ile üle anda. Proovi uuesti. + Praegu + Möödas + Homme + %1$d päeva pärast + Wallet’i pass + PDF + E-kiri + Veebileht + Tekst + Foto + Fail + Ava TREK korra ja sinu reisid ilmuvad siia. + Reisi vestlus + Reisi uudised + Kutsed ja jagamised + Üldine + Reisipäev + Uus teavitus + Käimasoleva reisi päev ja järgmine peatus. + diff --git a/client/android/app/src/main/res/values-fr/strings_native.xml b/client/android/app/src/main/res/values-fr/strings_native.xml new file mode 100644 index 000000000..60ebc96ef --- /dev/null +++ b/client/android/app/src/main/res/values-fr/strings_native.xml @@ -0,0 +1,57 @@ + + + + Voyage + Compte les jours jusqu\'au prochain voyage, puis montre la journée et la suite pendant le voyage. + Aujourd\'hui + Le programme du jour en frise, avec la météo et la prochaine étape. + Réservations + Vos prochains vols, trains, hôtels et tables, et quand ils arrivent. + À faire + Ce qui reste à faire pour le voyage et où en sont les bagages. + Bagages + Le remplissage des sacs, leur poids et ce qui manque encore. + Carte du monde + Les pays où vous êtes allé, illuminés sur la carte. + Groupe de voyage + Qui vient, les derniers messages et le sondage ouvert. + Choisir le voyage + Le voyage affiché par ce widget. + Voyage + Automatique + Le même voyage que le tableau de bord + Ajouter à TREK + Annuler + Fermer + Voyage + Ouvrez TREK une fois et vos voyages apparaîtront ici. + TREK lit la réservation et l’ajoute au voyage. + Choisir plus tard + Ajouter + Ajouter à %1$s + Ajouté + Lecture… + Ce fichier est trop volumineux pour être importé. + TREK ne peut pas importer ceci. + Impossible de transmettre ceci à TREK. Réessayez. + En cours + Passé + Demain + Dans %1$d jours + Carte Wallet + PDF + E-mail + Page web + Texte + Photo + Fichier + Ouvrez TREK une fois et vos voyages apparaîtront ici. + Discussion du voyage + Nouvelles du voyage + Invitations et partages + Général + Jour de voyage + Nouvelle notification + La journée d\'un voyage en cours et sa prochaine étape. + diff --git a/client/android/app/src/main/res/values-hu/strings_native.xml b/client/android/app/src/main/res/values-hu/strings_native.xml new file mode 100644 index 000000000..8f3c3df97 --- /dev/null +++ b/client/android/app/src/main/res/values-hu/strings_native.xml @@ -0,0 +1,57 @@ + + + + Utazás + Visszaszámol a következő utazásig, útközben pedig a napot és a következő programot mutatja. + Ma + A nap terve idővonalon, az időjárással és a következő megállóval. + Foglalások + A következő járataid, vonataid, szállásaid és asztalfoglalásaid, és hogy mikor esedékesek. + Teendők + Ami még hátravan az utazáshoz, és hol tart a csomagolás. + Csomagolás + Mennyire telik a táska, a súlya, és mi hiányzik még. + Világtérkép + Az országok, ahol jártál, világítva a térképen. + Útitársak + Ki jön veled, a legújabb üzenetek és a nyitott szavazás. + Utazás választása + Az utazás, amelyet ez a widget mutat. + Utazás + Automatikus + Ugyanaz az utazás, mint az irányítópulton + Hozzáadás a TREK-hez + Mégse + Bezárás + Utazás + Nyisd meg egyszer a TREK-et, és itt megjelennek az utazásaid. + A TREK beolvassa a foglalást, és hozzáadja az utazáshoz. + Később választok + Hozzáadás + Hozzáadás ehhez: %1$s + Hozzáadva + Olvasás… + Ez a fájl túl nagy az importáláshoz. + Ezt a TREK nem tudja importálni. + Nem sikerült átadni a TREK-nek. Próbáld újra. + Most + Elmúlt + Holnap + %1$d nap múlva + Wallet-kártya + PDF + E-mail + Weboldal + Szöveg + Fotó + Fájl + Nyisd meg egyszer a TREK-et, és itt megjelennek az utazásaid. + Utazási csevegés + Utazási hírek + Meghívások és megosztások + Általános + Utazási nap + Új értesítés + Egy folyamatban lévő utazás napja és a következő megálló. + diff --git a/client/android/app/src/main/res/values-in/strings_native.xml b/client/android/app/src/main/res/values-in/strings_native.xml new file mode 100644 index 000000000..849c7db00 --- /dev/null +++ b/client/android/app/src/main/res/values-in/strings_native.xml @@ -0,0 +1,57 @@ + + + + Perjalanan + Menghitung mundur ke perjalanan berikutnya, lalu menampilkan hari dan apa yang berikutnya. + Hari ini + Rencana hari ini sebagai linimasa, dengan cuaca dan perhentian berikutnya. + Pemesanan + Penerbangan, kereta, hotel, dan meja berikutnya, beserta waktunya. + Tugas + Yang masih tersisa untuk perjalanan dan sejauh mana persiapan barang. + Barang bawaan + Seberapa penuh tas, beratnya, dan yang masih kurang. + Peta dunia + Negara yang pernah kamu kunjungi, bersinar di peta. + Rombongan + Siapa yang ikut, pesan terbaru, dan polling yang terbuka. + Pilih perjalanan + Perjalanan yang ditampilkan widget ini. + Perjalanan + Otomatis + Perjalanan yang sama dengan dasbor + Tambahkan ke TREK + Batal + Tutup + Perjalanan + Buka TREK sekali dan perjalanan Anda akan muncul di sini. + TREK membaca pemesanan dan menambahkannya ke perjalanan. + Pilih nanti + Tambahkan + Tambahkan ke %1$s + Ditambahkan + Membaca… + File ini terlalu besar untuk diimpor. + TREK tidak dapat mengimpor ini. + Tidak dapat diteruskan ke TREK. Silakan coba lagi. + Sekarang + Lewat + Besok + Dalam %1$d hari + Pass Wallet + PDF + Email + Halaman web + Teks + Foto + File + Buka TREK sekali dan perjalanan Anda akan muncul di sini. + Obrolan perjalanan + Kabar perjalanan + Undangan dan berbagi + Umum + Hari perjalanan + Notifikasi baru + Hari dari perjalanan yang berlangsung dan perhentian berikutnya. + diff --git a/client/android/app/src/main/res/values-it/strings_native.xml b/client/android/app/src/main/res/values-it/strings_native.xml new file mode 100644 index 000000000..4fa6ef2af --- /dev/null +++ b/client/android/app/src/main/res/values-it/strings_native.xml @@ -0,0 +1,57 @@ + + + + Viaggio + Conta i giorni al prossimo viaggio e durante il viaggio mostra la giornata e cosa viene dopo. + Oggi + Il programma del giorno come linea del tempo, con il meteo e la prossima tappa. + Prenotazioni + I tuoi prossimi voli, treni, hotel e tavoli, e quando sono. + Da fare + Cosa manca ancora per il viaggio e a che punto è la valigia. + Bagagli + Quanto sono piene le borse, il loro peso e cosa manca ancora. + Mappa del mondo + I paesi in cui sei stato, illuminati sulla mappa. + Gruppo di viaggio + Chi viene, gli ultimi messaggi e il sondaggio aperto. + Scegli viaggio + Il viaggio mostrato da questo widget. + Viaggio + Automatico + Lo stesso viaggio della dashboard + Aggiungi a TREK + Annulla + Chiudi + Viaggio + Apri TREK una volta e qui compariranno i tuoi viaggi. + TREK legge la prenotazione e la aggiunge al viaggio. + Scegli dopo + Aggiungi + Aggiungi a %1$s + Aggiunto + Lettura… + Questo file è troppo grande da importare. + TREK non può importare questo contenuto. + Impossibile passarlo a TREK. Riprova. + In corso + Passato + Domani + Tra %1$d giorni + Pass di Wallet + PDF + E-mail + Pagina web + Testo + Foto + File + Apri TREK una volta e qui compariranno i tuoi viaggi. + Chat del viaggio + Novità del viaggio + Inviti e condivisioni + Generali + Giorno di viaggio + Nuova notifica + La giornata di un viaggio in corso e la prossima tappa. + diff --git a/client/android/app/src/main/res/values-iw/strings_native.xml b/client/android/app/src/main/res/values-iw/strings_native.xml new file mode 100644 index 000000000..125f97677 --- /dev/null +++ b/client/android/app/src/main/res/values-iw/strings_native.xml @@ -0,0 +1,57 @@ + + + + טיול + סופר את הימים עד הטיול הבא, ובמהלכו מציג את היום ואת מה שבא אחריו. + היום + תוכנית היום כציר זמן, עם מזג האוויר והעצירה הבאה. + הזמנות + הטיסות, הרכבות, המלונות והשולחנות הבאים שלך, ומתי הם. + משימות + מה עוד פתוח לטיול וכמה התקדמה האריזה. + אריזה + כמה מלאים התיקים, המשקל שלהם ומה עוד חסר. + מפת העולם + המדינות שביקרת בהן, זוהרות על המפה. + קבוצת הטיול + מי מצטרף, ההודעות האחרונות והסקר הפתוח. + בחירת טיול + הטיול שהווידג׳ט הזה מציג. + טיול + אוטומטי + אותו טיול כמו בלוח הבקרה + הוספה ל-TREK + ביטול + סגירה + טיול + פתחו את TREK פעם אחת והטיולים שלכם יופיעו כאן. + TREK קורא את ההזמנה ומוסיף אותה לטיול. + לבחור אחר כך + הוספה + הוספה אל %1$s + נוסף + קורא… + הקובץ גדול מדי לייבוא. + TREK לא יכול לייבא את זה. + לא ניתן היה להעביר את זה ל-TREK. אנא נסו שוב. + עכשיו + עבר + מחר + בעוד %1$d ימים + כרטיס Wallet + PDF + דוא״ל + דף אינטרנט + טקסט + תמונה + קובץ + פתחו את TREK פעם אחת והטיולים שלכם יופיעו כאן. + צ׳אט הטיול + עדכוני הטיול + הזמנות ושיתופים + כללי + יום טיול + התראה חדשה + היום של טיול פעיל והעצירה הבאה שלו. + diff --git a/client/android/app/src/main/res/values-ja/strings_native.xml b/client/android/app/src/main/res/values-ja/strings_native.xml new file mode 100644 index 000000000..55f87af91 --- /dev/null +++ b/client/android/app/src/main/res/values-ja/strings_native.xml @@ -0,0 +1,57 @@ + + + + 旅行 + 次の旅行までをカウントダウンし、旅行中はその日の予定と次の行き先を表示します。 + 今日 + 今日の予定をタイムラインで、天気と次の予定とともに表示します。 + 予約 + 次のフライト、列車、ホテル、レストランとその日時。 + やること + 旅行のために残っていることと、荷造りの進み具合。 + 持ち物 + バッグの詰め具合と重さ、まだ足りないもの。 + 世界地図 + 訪れた国が地図上で光ります。 + 旅行グループ + 一緒に行くメンバー、最新のメッセージ、受付中の投票。 + 旅行を選択 + このウィジェットに表示する旅行。 + 旅行 + 自動 + ダッシュボードと同じ旅行 + TREKに追加 + キャンセル + 閉じる + 旅行 + 一度TREKを開くと、ここに旅行が表示されます。 + TREKが予約を読み取り、旅行に追加します。 + あとで選ぶ + 追加 + %1$sに追加 + 追加しました + 読み込み中… + このファイルは大きすぎて読み込めません。 + TREKではこれを読み込めません。 + TREKに渡せませんでした。もう一度お試しください。 + 旅行中 + 終了 + 明日 + %1$d日後 + Walletパス + PDF + メール + Webページ + テキスト + 写真 + ファイル + 一度TREKを開くと、ここに旅行が表示されます。 + 旅行チャット + 旅行の更新 + 招待と共有 + 一般 + 旅行日 + 新しい通知 + 進行中の旅行の一日と次の立ち寄り先。 + diff --git a/client/android/app/src/main/res/values-ko/strings_native.xml b/client/android/app/src/main/res/values-ko/strings_native.xml new file mode 100644 index 000000000..f80b26e46 --- /dev/null +++ b/client/android/app/src/main/res/values-ko/strings_native.xml @@ -0,0 +1,57 @@ + + + + 여행 + 다음 여행까지 남은 날을 세고, 여행 중에는 오늘과 다음 일정을 보여 줍니다. + 오늘 + 오늘 일정을 타임라인으로, 날씨와 다음 장소와 함께 보여 줍니다. + 예약 + 다가오는 항공편, 기차, 호텔, 식당과 그 시간. + 할 일 + 여행을 위해 남은 일과 짐 싸기 진행 상황. + 짐 싸기 + 가방이 얼마나 찼는지, 무게, 아직 빠진 것. + 세계 지도 + 다녀온 나라가 지도 위에서 빛납니다. + 여행 그룹 + 함께 가는 사람, 최근 메시지, 진행 중인 투표. + 여행 선택 + 이 위젯에 표시할 여행. + 여행 + 자동 + 대시보드와 같은 여행 + TREK에 추가 + 취소 + 닫기 + 여행 + TREK을 한 번 열면 여기에 여행이 표시됩니다. + TREK이 예약을 읽고 여행에 추가합니다. + 나중에 선택 + 추가 + %1$s에 추가 + 추가됨 + 읽는 중… + 이 파일은 가져오기에는 너무 큽니다. + TREK에서 가져올 수 없는 항목입니다. + TREK에 전달하지 못했습니다. 다시 시도하세요. + 진행 중 + 지난 여행 + 내일 + %1$d일 후 + Wallet 패스 + PDF + 이메일 + 웹 페이지 + 텍스트 + 사진 + 파일 + TREK을 한 번 열면 여기에 여행이 표시됩니다. + 여행 채팅 + 여행 소식 + 초대 및 공유 + 일반 + 여행일 + 새 알림 + 진행 중인 여행의 하루와 다음 장소. + diff --git a/client/android/app/src/main/res/values-nl/strings_native.xml b/client/android/app/src/main/res/values-nl/strings_native.xml new file mode 100644 index 000000000..02fa935fc --- /dev/null +++ b/client/android/app/src/main/res/values-nl/strings_native.xml @@ -0,0 +1,57 @@ + + + + Reis + Telt af naar je volgende reis en toont onderweg de dag en wat er hierna komt. + Vandaag + Het dagplan als tijdlijn, met het weer en de volgende stop. + Boekingen + Je volgende vluchten, treinen, hotels en tafels, en wanneer ze zijn. + Taken + Wat er nog openstaat voor de reis en hoe ver het inpakken is. + Bagage + Hoe vol de tassen zijn, hun gewicht en wat er nog mist. + Wereldkaart + De landen waar je bent geweest, oplichtend op de kaart. + Reisgezelschap + Wie er meegaat, de nieuwste berichten en de open peiling. + Reis kiezen + De reis die deze widget toont. + Reis + Automatisch + Dezelfde reis als het dashboard + Toevoegen aan TREK + Annuleer + Sluit + Reis + Open TREK één keer, dan verschijnen je reizen hier. + TREK leest de boeking en zet hem in de reis. + Later kiezen + Toevoegen + Toevoegen aan %1$s + Toegevoegd + Lezen… + Dit bestand is te groot om te importeren. + Dit kan TREK niet importeren. + Dit kon niet aan TREK worden doorgegeven. Probeer het opnieuw. + Nu + Voorbij + Morgen + Over %1$d dagen + Wallet-pas + PDF + E-mail + Webpagina + Tekst + Foto + Bestand + Open TREK één keer, dan verschijnen je reizen hier. + Reischat + Reisupdates + Uitnodigingen en delen + Algemeen + Reisdag + Nieuwe melding + De dag van een lopende reis en de volgende stop. + diff --git a/client/android/app/src/main/res/values-pl/strings_native.xml b/client/android/app/src/main/res/values-pl/strings_native.xml new file mode 100644 index 000000000..b15c0462b --- /dev/null +++ b/client/android/app/src/main/res/values-pl/strings_native.xml @@ -0,0 +1,57 @@ + + + + Podróż + Odlicza dni do następnej podróży, a w trakcie pokazuje dzień i to, co dalej. + Dziś + Plan dnia jako oś czasu, z pogodą i kolejnym przystankiem. + Rezerwacje + Twoje najbliższe loty, pociągi, hotele i stoliki oraz ich terminy. + Zadania + Co jeszcze zostało do zrobienia przed podróżą i jak idzie pakowanie. + Pakowanie + Jak pełne są torby, ich waga i czego jeszcze brakuje. + Mapa świata + Kraje, w których byłeś, świecące na mapie. + Grupa podróży + Kto jedzie, najnowsze wiadomości i otwarta ankieta. + Wybierz podróż + Podróż, którą pokazuje ten widżet. + Podróż + Automatycznie + Ta sama podróż co na pulpicie + Dodaj do TREK + Anuluj + Zamknij + Podróż + Otwórz TREK raz, a Twoje podróże pojawią się tutaj. + TREK odczyta rezerwację i doda ją do podróży. + Wybierz później + Dodaj + Dodaj do %1$s + Dodano + Odczytywanie… + Ten plik jest za duży do zaimportowania. + TREK nie może tego zaimportować. + Nie udało się przekazać tego do TREK. Spróbuj ponownie. + Teraz + Minęła + Jutro + Za %1$d dni + Karta Wallet + PDF + E-mail + Strona + Tekst + Zdjęcie + Plik + Otwórz TREK raz, a Twoje podróże pojawią się tutaj. + Czat podróży + Nowości o podróży + Zaproszenia i udostępnienia + Ogólne + Dzień podróży + Nowe powiadomienie + Dzień trwającej podróży i jej następny przystanek. + diff --git a/client/android/app/src/main/res/values-pt-rBR/strings_native.xml b/client/android/app/src/main/res/values-pt-rBR/strings_native.xml new file mode 100644 index 000000000..84c4e30b2 --- /dev/null +++ b/client/android/app/src/main/res/values-pt-rBR/strings_native.xml @@ -0,0 +1,57 @@ + + + + Viagem + Conta os dias até a próxima viagem e, durante ela, mostra o dia e o que vem a seguir. + Hoje + O plano do dia como linha do tempo, com o clima e a próxima parada. + Reservas + Seus próximos voos, trens, hotéis e mesas, e quando acontecem. + Tarefas + O que ainda falta para a viagem e quanto da mala já está pronto. + Bagagem + Quão cheias estão as malas, o peso e o que ainda falta. + Mapa-múndi + Os países em que você esteve, brilhando no mapa. + Grupo de viagem + Quem vai junto, as mensagens recentes e a votação aberta. + Escolher viagem + A viagem que este widget mostra. + Viagem + Automático + A mesma viagem do painel + Adicionar ao TREK + Cancelar + Fechar + Viagem + Abra o TREK uma vez e suas viagens aparecerão aqui. + O TREK lê a reserva e a adiciona à viagem. + Escolher depois + Adicionar + Adicionar a %1$s + Adicionado + Lendo… + Este arquivo é grande demais para importar. + O TREK não consegue importar isto. + Não foi possível passar isto ao TREK. Tente novamente. + Agora + Passada + Amanhã + Em %1$d dias + Passe do Wallet + PDF + E-mail + Página web + Texto + Foto + Arquivo + Abra o TREK uma vez e suas viagens aparecerão aqui. + Chat da viagem + Novidades da viagem + Convites e compartilhamentos + Geral + Dia de viagem + Nova notificação + O dia de uma viagem em andamento e sua próxima parada. + diff --git a/client/android/app/src/main/res/values-ru/strings_native.xml b/client/android/app/src/main/res/values-ru/strings_native.xml new file mode 100644 index 000000000..1f24f5960 --- /dev/null +++ b/client/android/app/src/main/res/values-ru/strings_native.xml @@ -0,0 +1,57 @@ + + + + Поездка + Отсчитывает дни до следующей поездки, а в пути показывает день и что дальше. + Сегодня + План дня в виде ленты времени, с погодой и следующей остановкой. + Бронирования + Ближайшие рейсы, поезда, отели и столики и когда они. + Задачи + Что ещё осталось сделать к поездке и как идут сборы. + Сборы + Насколько заполнены сумки, их вес и чего ещё не хватает. + Карта мира + Страны, где вы были, светятся на карте. + Группа поездки + Кто едет, последние сообщения и открытый опрос. + Выбрать поездку + Поездка, которую показывает этот виджет. + Поездка + Автоматически + Та же поездка, что и на главной + Добавить в TREK + Отменить + Закрыть + Поездка + Откройте TREK один раз, и ваши поездки появятся здесь. + TREK прочитает бронь и добавит её в поездку. + Выбрать позже + Добавить + Добавить в «%1$s» + Добавлено + Чтение… + Файл слишком большой для импорта. + TREK не может это импортировать. + Не удалось передать это в TREK. Повторите попытку. + Сейчас + Прошла + Завтра + Через %1$d дн. + Карта Wallet + PDF + Письмо + Веб-страница + Текст + Фото + Файл + Откройте TREK один раз, и ваши поездки появятся здесь. + Чат поездки + Новости поездки + Приглашения и доступ + Общие + День поездки + Новое уведомление + День текущей поездки и следующая остановка. + diff --git a/client/android/app/src/main/res/values-sk/strings_native.xml b/client/android/app/src/main/res/values-sk/strings_native.xml new file mode 100644 index 000000000..85f559129 --- /dev/null +++ b/client/android/app/src/main/res/values-sk/strings_native.xml @@ -0,0 +1,57 @@ + + + + Cesta + Odpočítava dni do ďalšej cesty a počas nej ukazuje deň a čo príde ďalej. + Dnes + Plán dňa ako časová os, s počasím a ďalšou zastávkou. + Rezervácie + Tvoje ďalšie lety, vlaky, hotely a stoly a kedy ťa čakajú. + Úlohy + Čo ešte zostáva na cestu a ako ďaleko je balenie. + Balenie + Aké plné sú tašky, ich váha a čo ešte chýba. + Mapa sveta + Krajiny, kde si bol, žiariace na mape. + Cestovná skupina + Kto ide s tebou, najnovšie správy a otvorené hlasovanie. + Vybrať cestu + Cesta, ktorú zobrazuje tento widget. + Cesta + Automaticky + Rovnaká cesta ako na nástenke + Pridať do TREK + Zrušiť + Zavrieť + Cesta + Raz otvorte TREK a vaše cesty sa zobrazia tu. + TREK prečíta rezerváciu a pridá ju k ceste. + Vybrať neskôr + Pridať + Pridať do %1$s + Pridané + Načítava sa… + Tento súbor je na import príliš veľký. + Toto TREK nevie importovať. + Nepodarilo sa to odovzdať do TREK. Skúste to znova. + Teraz + Prebehla + Zajtra + O %1$d dní + Karta Wallet + PDF + E-mail + Webová stránka + Text + Fotka + Súbor + Raz otvorte TREK a vaše cesty sa zobrazia tu. + Chat výletu + Novinky z výletu + Pozvánky a zdieľania + Všeobecné + Cestovný deň + Nové upozornenie + Deň prebiehajúceho výletu a jeho ďalšia zastávka. + diff --git a/client/android/app/src/main/res/values-sv/strings_native.xml b/client/android/app/src/main/res/values-sv/strings_native.xml new file mode 100644 index 000000000..12fc2d505 --- /dev/null +++ b/client/android/app/src/main/res/values-sv/strings_native.xml @@ -0,0 +1,57 @@ + + + + Resa + Räknar ner till nästa resa och visar under resan dagen och vad som kommer härnäst. + Idag + Dagens plan som tidslinje, med vädret och nästa stopp. + Bokningar + Dina nästa flyg, tåg, hotell och bord, och när de är. + Att göra + Vad som återstår inför resan och hur långt packningen har kommit. + Packning + Hur fulla väskorna är, deras vikt och vad som fortfarande saknas. + Världskarta + Länderna du har besökt, lysande på kartan. + Resesällskap + Vem som följer med, de senaste meddelandena och den öppna omröstningen. + Välj resa + Resan som den här widgeten visar. + Resa + Automatiskt + Samma resa som på startsidan + Lägg till i TREK + Avbryt + Stäng + Resa + Öppna TREK en gång så visas dina resor här. + TREK läser bokningen och lägger till den i resan. + Välj senare + Lägg till + Lägg till i %1$s + Tillagd + Läser… + Filen är för stor för att importeras. + Det här kan TREK inte importera. + Det gick inte att lämna över till TREK. Försök igen. + Nu + Förbi + I morgon + Om %1$d dagar + Wallet-kort + PDF + Mejl + Webbsida + Text + Foto + Fil + Öppna TREK en gång så visas dina resor här. + Resechatt + Nytt om resan + Inbjudningar och delningar + Allmänt + Resdag + Ny avisering + Dagen för en pågående resa och nästa stopp. + diff --git a/client/android/app/src/main/res/values-th/strings_native.xml b/client/android/app/src/main/res/values-th/strings_native.xml new file mode 100644 index 000000000..bdf11b6e2 --- /dev/null +++ b/client/android/app/src/main/res/values-th/strings_native.xml @@ -0,0 +1,57 @@ + + + + ทริป + นับถอยหลังสู่ทริปถัดไป และระหว่างทริปแสดงวันนี้และสิ่งที่จะตามมา + วันนี้ + แผนของวันนี้เป็นไทม์ไลน์ พร้อมสภาพอากาศและจุดหมายถัดไป + การจอง + เที่ยวบิน รถไฟ โรงแรม และร้านอาหารถัดไปของคุณ และเวลาของแต่ละรายการ + สิ่งที่ต้องทำ + สิ่งที่ยังค้างอยู่สำหรับทริป และความคืบหน้าการจัดกระเป๋า + จัดกระเป๋า + กระเป๋าเต็มแค่ไหน น้ำหนัก และอะไรที่ยังขาด + แผนที่โลก + ประเทศที่คุณเคยไป ส่องแสงบนแผนที่ + กลุ่มเดินทาง + ใครไปด้วย ข้อความล่าสุด และโหวตที่เปิดอยู่ + เลือกทริป + ทริปที่วิดเจ็ตนี้แสดง + ทริป + อัตโนมัติ + ทริปเดียวกับแดชบอร์ด + เพิ่มลงใน TREK + ยกเลิก + ปิด + ทริป + เปิด TREK หนึ่งครั้ง แล้วทริปของคุณจะแสดงที่นี่ + TREK จะอ่านการจองและเพิ่มลงในทริป + เลือกภายหลัง + เพิ่ม + เพิ่มลงใน %1$s + เพิ่มแล้ว + กำลังอ่าน… + ไฟล์นี้ใหญ่เกินไปสำหรับการนำเข้า + TREK นำเข้ารายการนี้ไม่ได้ + ส่งต่อให้ TREK ไม่สำเร็จ โปรดลองอีกครั้ง + ตอนนี้ + ผ่านไปแล้ว + พรุ่งนี้ + อีก %1$d วัน + บัตร Wallet + PDF + อีเมล + หน้าเว็บ + ข้อความ + รูปภาพ + ไฟล์ + เปิด TREK หนึ่งครั้ง แล้วทริปของคุณจะแสดงที่นี่ + แชทการเดินทาง + อัปเดตการเดินทาง + คำเชิญและการแชร์ + ทั่วไป + วันเดินทาง + การแจ้งเตือนใหม่ + วันของทริปที่กำลังดำเนินอยู่และจุดหมายถัดไป + diff --git a/client/android/app/src/main/res/values-tr/strings_native.xml b/client/android/app/src/main/res/values-tr/strings_native.xml new file mode 100644 index 000000000..b0a746e9d --- /dev/null +++ b/client/android/app/src/main/res/values-tr/strings_native.xml @@ -0,0 +1,57 @@ + + + + Seyahat + Sonraki seyahate kalan günleri sayar, yolculukta günü ve sırada ne olduğunu gösterir. + Bugün + Günün planı zaman çizelgesi olarak, hava durumu ve sonraki durakla. + Rezervasyonlar + Sıradaki uçuşların, trenlerin, otellerin ve masaların, ne zaman oldukları ile. + Yapılacaklar + Seyahat için hâlâ açık olanlar ve bavulun ne durumda olduğu. + Bavul + Çantaların ne kadar dolu olduğu, ağırlıkları ve eksikler. + Dünya haritası + Bulunduğun ülkeler haritada parlıyor. + Seyahat grubu + Kimler geliyor, son mesajlar ve açık anket. + Seyahat seç + Bu widget\'ın gösterdiği seyahat. + Seyahat + Otomatik + Paneldeki seyahatin aynısı + TREK’e ekle + Vazgeç + Kapat + Seyahat + TREK’i bir kez açın, seyahatleriniz burada görünür. + TREK rezervasyonu okur ve seyahate ekler. + Sonra seç + Ekle + %1$s seyahatine ekle + Eklendi + Okunuyor… + Bu dosya içe aktarmak için çok büyük. + TREK bunu içe aktaramaz. + Bu TREK’e aktarılamadı. Lütfen tekrar deneyin. + Şimdi + Geçti + Yarın + %1$d gün sonra + Wallet kartı + PDF + E-posta + Web sayfası + Metin + Fotoğraf + Dosya + TREK’i bir kez açın, seyahatleriniz burada görünür. + Seyahat sohbeti + Seyahat güncellemeleri + Davetler ve paylaşımlar + Genel + Seyahat günü + Yeni bildirim + Süren bir seyahatin günü ve sonraki durağı. + diff --git a/client/android/app/src/main/res/values-uk/strings_native.xml b/client/android/app/src/main/res/values-uk/strings_native.xml new file mode 100644 index 000000000..6888e2e84 --- /dev/null +++ b/client/android/app/src/main/res/values-uk/strings_native.xml @@ -0,0 +1,57 @@ + + + + Подорож + Відлічує дні до наступної подорожі, а в дорозі показує день і що далі. + Сьогодні + План дня у вигляді стрічки часу, з погодою і наступною зупинкою. + Бронювання + Найближчі рейси, поїзди, готелі й столики та коли вони. + Завдання + Що ще лишилося зробити до подорожі і як просувається пакування. + Пакування + Наскільки заповнені сумки, їхня вага і чого ще бракує. + Мапа світу + Країни, де ви були, світяться на мапі. + Група подорожі + Хто їде, останні повідомлення і відкрите опитування. + Вибрати подорож + Подорож, яку показує цей віджет. + Подорож + Автоматично + Та сама подорож, що й на панелі + Додати до TREK + Скасувати + Закрити + Подорож + Відкрийте TREK один раз, і ваші подорожі з’являться тут. + TREK прочитає бронювання й додасть його до подорожі. + Вибрати пізніше + Додати + Додати до «%1$s» + Додано + Читання… + Файл завеликий для імпорту. + TREK не може це імпортувати. + Не вдалося передати це до TREK. Повторіть спробу. + Зараз + Минула + Завтра + Через %1$d дн. + Картка Wallet + PDF + Лист + Вебсторінка + Текст + Фото + Файл + Відкрийте TREK один раз, і ваші подорожі з’являться тут. + Чат подорожі + Новини подорожі + Запрошення та доступ + Загальні + День подорожі + Нове сповіщення + День поточної подорожі та наступна зупинка. + diff --git a/client/android/app/src/main/res/values-vi/strings_native.xml b/client/android/app/src/main/res/values-vi/strings_native.xml new file mode 100644 index 000000000..228e0600e --- /dev/null +++ b/client/android/app/src/main/res/values-vi/strings_native.xml @@ -0,0 +1,57 @@ + + + + Chuyến đi + Đếm ngược đến chuyến đi tiếp theo, trong chuyến đi thì hiện ngày và điểm tiếp theo. + Hôm nay + Kế hoạch hôm nay dạng dòng thời gian, kèm thời tiết và điểm dừng tiếp theo. + Đặt chỗ + Các chuyến bay, tàu, khách sạn và bàn sắp tới của bạn, cùng thời gian. + Việc cần làm + Những gì còn lại cho chuyến đi và việc soạn hành lý đã tới đâu. + Hành lý + Túi đã đầy đến đâu, cân nặng và những gì còn thiếu. + Bản đồ thế giới + Những quốc gia bạn đã đến, sáng lên trên bản đồ. + Nhóm du lịch + Ai đi cùng, tin nhắn mới nhất và cuộc bình chọn đang mở. + Chọn chuyến đi + Chuyến đi mà tiện ích này hiển thị. + Chuyến đi + Tự động + Cùng chuyến đi với bảng điều khiển + Thêm vào TREK + Hủy + Đóng + Chuyến đi + Mở TREK một lần, các chuyến đi của bạn sẽ hiện ở đây. + TREK đọc đặt chỗ và thêm vào chuyến đi. + Chọn sau + Thêm + Thêm vào %1$s + Đã thêm + Đang đọc… + Tệp này quá lớn để nhập. + TREK không thể nhập nội dung này. + Không thể chuyển sang TREK. Vui lòng thử lại. + Đang diễn ra + Đã qua + Ngày mai + Sau %1$d ngày + Thẻ Wallet + PDF + Email + Trang web + Văn bản + Ảnh + Tệp + Mở TREK một lần, các chuyến đi của bạn sẽ hiện ở đây. + Trò chuyện chuyến đi + Cập nhật chuyến đi + Lời mời và chia sẻ + Chung + Ngày đi + Thông báo mới + Ngày của chuyến đi đang diễn ra và điểm dừng tiếp theo. + diff --git a/client/android/app/src/main/res/values/colors.xml b/client/android/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..e7073645c --- /dev/null +++ b/client/android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + + #4A7DDB + + #4A7DDB + diff --git a/client/android/app/src/main/res/values/strings.xml b/client/android/app/src/main/res/values/strings.xml index e66821104..2a657b6f5 100644 --- a/client/android/app/src/main/res/values/strings.xml +++ b/client/android/app/src/main/res/values/strings.xml @@ -4,7 +4,5 @@ TREK com.liketrek.trek com.liketrek.trek - Notifications TREK - New notification diff --git a/client/android/app/src/main/res/values/strings_native.xml b/client/android/app/src/main/res/values/strings_native.xml new file mode 100644 index 000000000..0c3758f61 --- /dev/null +++ b/client/android/app/src/main/res/values/strings_native.xml @@ -0,0 +1,57 @@ + + + + Trip + Counts down to your next trip, then shows the day and what comes next. + Today + Today\'s plan as a timeline, with the weather and what is next. + Bookings + Your next flights, trains, hotels and tables, and when they are due. + To-dos + What is still open for the trip, and how far the packing has come. + Packing + How full the bags are, their weight, and what is still missing. + World map + The countries you have been to, glowing on the map. + Travel group + Who comes along, the latest messages and the open poll. + Choose trip + The trip this widget shows. + Trip + Automatic + Same trip as the dashboard + Add to TREK + Cancel + Close + Trip + Open TREK once and your trips show up here. + TREK reads the booking and adds it to the trip. + Choose later + Add + Add to %1$s + Added + Reading… + This file is too large to import. + TREK cannot import this. + This could not be handed to TREK. Please try again. + Now + Past + Tomorrow + In %1$d days + Wallet pass + PDF + Email + Web page + Text + Photo + File + Open TREK once and your trips show up here. + Trip chat + Trip updates + Invitations and shares + General + Travel day + New notification + The day of a running trip and its next stop. + diff --git a/client/android/app/src/main/res/values/styles.xml b/client/android/app/src/main/res/values/styles.xml index 3ee713faa..7a61ed65a 100644 --- a/client/android/app/src/main/res/values/styles.xml +++ b/client/android/app/src/main/res/values/styles.xml @@ -16,6 +16,19 @@ + + + + + +