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 @@
+
+
+
+
+
+