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 000000000..b469b971c Binary files /dev/null and b/client/android/app/src/main/res/drawable-nodpi/widget_preview_atlas.png differ 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 000000000..f05eea50f Binary files /dev/null and b/client/android/app/src/main/res/drawable-nodpi/widget_preview_bookings.png differ 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 000000000..1e8040aa1 Binary files /dev/null and b/client/android/app/src/main/res/drawable-nodpi/widget_preview_collab.png differ diff --git a/client/android/app/src/main/res/drawable-nodpi/widget_preview_packing.png b/client/android/app/src/main/res/drawable-nodpi/widget_preview_packing.png new file mode 100644 index 000000000..89a3a0578 Binary files /dev/null and b/client/android/app/src/main/res/drawable-nodpi/widget_preview_packing.png differ diff --git a/client/android/app/src/main/res/drawable-nodpi/widget_preview_today.png b/client/android/app/src/main/res/drawable-nodpi/widget_preview_today.png new file mode 100644 index 000000000..ad3b7b6e1 Binary files /dev/null and b/client/android/app/src/main/res/drawable-nodpi/widget_preview_today.png differ 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 000000000..88ab2dda3 Binary files /dev/null and b/client/android/app/src/main/res/drawable-nodpi/widget_preview_todos.png differ 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 000000000..bab4da1a0 Binary files /dev/null and b/client/android/app/src/main/res/drawable-nodpi/widget_preview_trip.png differ 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 @@ + + + + + +