mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-09 22:00:57 +00:00
Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ca59a4ba1 | |||
| 5420077b98 | |||
| 5cfc7a602a | |||
| b48e344749 | |||
| e58a880f34 | |||
| 17ff7f6bd8 | |||
| f03bb97d50 | |||
| ed0142be7d | |||
| 6e9f89a25f | |||
| a3c7c3d76a | |||
| c043ca4bba | |||
| 004c6212c0 | |||
| 07448e662c | |||
| d3bdc6de62 | |||
| a9bfcd0fd5 | |||
| 12caa7fbde | |||
| 1cbd4acf01 | |||
| 687e477ca5 | |||
| 3a66f2a8da | |||
| 7f52e424e8 | |||
| 2479e5661b | |||
| bc2fb1ce2d | |||
| ffa07b3d05 | |||
| 306753d6a1 | |||
| 80c04df4fc | |||
| c9a91bbdad | |||
| d06168d94a | |||
| d9baaa8957 | |||
| fede671f3b | |||
| b3113e66b9 | |||
| 286b0b09ec | |||
| c0b39abe18 | |||
| 6e3a6386b8 | |||
| 5e7d778740 | |||
| 3be87343b8 | |||
| 517efabbca | |||
| 1866dca818 | |||
| 1400b5d308 | |||
| b529cf4e57 | |||
| 6cc3d235b1 | |||
| a5b1156612 | |||
| c6ffd48e26 | |||
| afb6eae4c8 | |||
| cd1f39377d | |||
| 3a99e8576d | |||
| 36c8c5bc48 | |||
| 4ba7db90e6 | |||
| ab6cd7009e | |||
| b16ac683cf | |||
| 95d62b7a02 | |||
| e444e855c0 | |||
| 4ab42c06c9 | |||
| 9f6ab11839 | |||
| 5f711d5374 | |||
| 4d550112a1 | |||
| 26dc88181d | |||
| 3d4f8bfe12 | |||
| 5fb1838c7a | |||
| 38e5d76ad2 | |||
| a9af19f02a | |||
| 305b00ae01 | |||
| ed6724035e | |||
| eea83bb1c9 | |||
| 06c03cbded | |||
| b73f749880 | |||
| 53c18b861a | |||
| 9b1bf89382 | |||
| c7f144d3ff | |||
| cb41f08199 | |||
| eafb30555c | |||
| 4f1f48eec5 | |||
| 42341c2c76 | |||
| 6a60fc183a | |||
| 236b4c34ac | |||
| c6cb3dc64a | |||
| 3fc7487b58 | |||
| ac055d531f | |||
| 0ef3cbf520 | |||
| 3458238db0 | |||
| afaacec61a | |||
| 5f4fb5dae7 | |||
| 788b1f63d0 | |||
| bfb1b04f8d | |||
| a77ec6e225 | |||
| 2851eb7afe | |||
| 6c7605b4a6 | |||
| 3b286d04c3 | |||
| 21afe89b13 | |||
| 060a43ba64 | |||
| dedbf16f03 | |||
| 796f39fe1f | |||
| ee9cbce23d | |||
| 82fcdcd0fc | |||
| 3b7cdf516a | |||
| 41752887dc | |||
| b9d0d3397e | |||
| 2a2dfec247 | |||
| 21e07e20a1 | |||
| 322d5d5209 | |||
| 0346f9c13d | |||
| dfd87d413e | |||
| 27aab5c783 | |||
| a7cc1b9e57 | |||
| 8955790f17 | |||
| 61d925f3dd | |||
| bf00412863 | |||
| 2a6c771ad9 | |||
| 641b1882b5 | |||
| 5346182807 | |||
| 4dfccaa2a4 | |||
| 18a16d2aa2 | |||
| b7a91fc84e | |||
| bb725ac8d8 | |||
| b644235eb6 | |||
| d259ecb11c | |||
| 5d05363f36 | |||
| 6906f80ff8 | |||
| 7083667cbf | |||
| 0b7cee0b0d | |||
| 36ed0e0d23 | |||
| b1c0353548 | |||
| c204a25a9c | |||
| b008e010a5 |
@@ -140,7 +140,9 @@ jobs:
|
||||
set -eu
|
||||
|
||||
# Derive version from git tags, no package.json dependency
|
||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
||||
# Only a plain vX.Y.Z is a release: the glob alone also matches the apps' TestFlight tags
|
||||
# (v5.1.0-tf.15), whose suffix then broke the arithmetic below.
|
||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
STABLE="${STABLE_TAG#v}"
|
||||
STABLE="${STABLE:-0.0.0}"
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
@@ -121,6 +121,21 @@ jobs:
|
||||
working-directory: client
|
||||
run: npm run cap:sync -- ios
|
||||
|
||||
# Capacitor's xcframeworks come from GitHub release downloads, which answer
|
||||
# 504 now and then; SwiftPM then reports the half-written file as "already
|
||||
# exists in file system". Resolve on a clean artifact cache, a few times
|
||||
# with growing pauses, before the build needs the packages.
|
||||
- name: Resolve the Swift packages
|
||||
working-directory: client/ios/App
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi
|
||||
echo "Resolving the Swift packages failed (attempt $attempt of 5)"
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
exit 1
|
||||
|
||||
# No -sdk: the scheme builds the embedded watch app and its complications
|
||||
# against the watchOS simulator SDK and the rest against the iOS one.
|
||||
- name: Build for the simulator
|
||||
@@ -136,7 +151,10 @@ jobs:
|
||||
# The widgets drawn to PNGs (client/ios/WidgetPreviews): every size, light and
|
||||
# dark, for a trip ahead, a trip under way and none, plus a home screen and
|
||||
# a lock screen of them. Nobody has to install a build to see a widget change.
|
||||
# Started by hand only (Run workflow): the apps are on TestFlight and Play now,
|
||||
# where a change is looked at on a device; on every PR it only held the queue.
|
||||
widget-previews:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -193,7 +211,9 @@ jobs:
|
||||
# The watch app on watchOS simulators in its screenshot mode (DemoMode.swift):
|
||||
# every screen on the largest and the smallest watch, the main ones in every
|
||||
# language, and the complications, as PNGs to look at without a watch.
|
||||
# Started by hand only, like the widget previews: up to 90 minutes of macOS.
|
||||
watch-screens:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
@@ -220,6 +240,21 @@ jobs:
|
||||
xcrun simctl list runtimes | grep -q watchOS || xcodebuild -downloadPlatform watchOS
|
||||
xcrun simctl list runtimes
|
||||
|
||||
# Capacitor's xcframeworks come from GitHub release downloads, which answer
|
||||
# 504 now and then; SwiftPM then reports the half-written file as "already
|
||||
# exists in file system". Resolve on a clean artifact cache, a few times
|
||||
# with growing pauses, before the build needs the packages.
|
||||
- name: Resolve the Swift packages
|
||||
working-directory: client/ios/App
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi
|
||||
echo "Resolving the Swift packages failed (attempt $attempt of 5)"
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Build the watch app for the simulator
|
||||
working-directory: client/ios/App
|
||||
run: |
|
||||
|
||||
@@ -1236,10 +1236,12 @@ jobs:
|
||||
bin="$RUNNER_TEMP/bin"
|
||||
mkdir -p "$bin"
|
||||
cd "$RUNNER_TEMP"
|
||||
curl -sSfLo helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz"
|
||||
# GitHub's release downloads answer 504 now and then: retry, the checksum still decides.
|
||||
fetch() { curl -sSfL --retry 5 --retry-delay 15 --retry-all-errors -o "$1" "$2"; }
|
||||
fetch helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz"
|
||||
echo "$HELM_SHA256 helm.tgz" | sha256sum -c -
|
||||
tar -xzf helm.tgz --strip-components=1 -C "$bin" linux-amd64/helm
|
||||
curl -sSfLo kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz"
|
||||
fetch kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz"
|
||||
echo "$KUBECONFORM_SHA256 kubeconform.tgz" | sha256sum -c -
|
||||
tar -xzf kubeconform.tgz -C "$bin" kubeconform
|
||||
echo "$bin" >> "$GITHUB_PATH"
|
||||
|
||||
@@ -70,7 +70,10 @@ COPY server/package.json ./server/
|
||||
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
|
||||
# deps are esbuild's binary check, redundant next to its optional platform
|
||||
# package, and a telemetry ping.
|
||||
# The upgrade takes the Debian security fixes the base image was built before
|
||||
# (zlib, 11/2026, failed the image scan until node:24-trixie-slim caught up).
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y --no-install-recommends && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
|
||||
libkitinerary-bin libsqlite3-0 && \
|
||||
npm ci --workspace=server --omit=dev --ignore-scripts && \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 5.1.0
|
||||
version: 5.1.2
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "5.1.0"
|
||||
appVersion: "5.1.2"
|
||||
|
||||
@@ -9,7 +9,9 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-action-sheet')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-dialog')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-keyboard')
|
||||
|
||||
@@ -36,7 +36,7 @@ public class MainActivity extends BridgeActivity {
|
||||
// scroller natively. It scales the whole view, top bar and bottom dock included.
|
||||
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
// Set before any WebView callback can run: they are posted to this thread.
|
||||
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen));
|
||||
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen, TrekServerStore.getSignInHosts(this)));
|
||||
TrekSharedFiles.receive(this, getIntent());
|
||||
// Android 15's widget picker draws the widgets live from a sample trip.
|
||||
TrekWidgetUpdater.publishPreviews(this);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* A sign-in page in front of the server that the address screen never saw.
|
||||
* A proxy can let the app's check through (an Authelia or Authentik rule that
|
||||
* bypasses /api) and still send the page itself to its sign-in. Handed to the
|
||||
* browser, that sign-in leaves its session there and the app stays blank on
|
||||
* every launch. So a redirect away from the server, while the server's first
|
||||
* page is still loading, is taken as the proxy's sign-in: it loads in the web
|
||||
* view and its host is kept, the way the address screen keeps the ones it saw.
|
||||
* A link the user follows is no redirect, and once a page of the server has
|
||||
* shown, nothing is learned any more.
|
||||
*/
|
||||
final class SignInRedirect {
|
||||
|
||||
private SignInRedirect() {}
|
||||
|
||||
/** The host (host:port with an explicit port) to keep as a sign-in host, or null. */
|
||||
static String learnedHost(String server, String target, boolean mainFrame, boolean redirect, boolean serverShown) {
|
||||
if (!mainFrame || !redirect || serverShown || server == null || target == null) return null;
|
||||
URI from;
|
||||
URI to;
|
||||
try {
|
||||
from = new URI(server);
|
||||
to = new URI(target);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
String fromHost = lower(from.getHost());
|
||||
String toHost = lower(to.getHost());
|
||||
String toScheme = lower(to.getScheme());
|
||||
if (fromHost == null || toHost == null) return null;
|
||||
if (!"https".equals(toScheme) && !"http".equals(toScheme)) return null;
|
||||
// Never from https down to plain http: the sign-in would send a password in the clear.
|
||||
if ("https".equals(lower(from.getScheme())) && !"https".equals(toScheme)) return null;
|
||||
if (toHost.equals(fromHost)) return null;
|
||||
return to.getPort() == -1 ? toHost : toHost + ":" + to.getPort();
|
||||
}
|
||||
|
||||
/** Whether `url` is on the server's host, where the app's own pages come from. */
|
||||
static boolean onServer(String server, String url) {
|
||||
if (server == null || url == null) return false;
|
||||
try {
|
||||
String serverHost = lower(new URI(server).getHost());
|
||||
return serverHost != null && serverHost.equals(lower(new URI(url).getHost()));
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String lower(String value) {
|
||||
return value == null ? null : value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
@@ -18,26 +22,80 @@ final class TrekServerStore {
|
||||
|
||||
private static final String PREFS = "trek_shell";
|
||||
private static final String KEY_URL = "server_url";
|
||||
private static final String KEY_SIGN_IN_HOSTS = "sign_in_hosts";
|
||||
private static final String KEY_SHELL_ONCE = "shell_once";
|
||||
private static final String KEY_RESTART = "restart";
|
||||
private static final String KEY_WELCOME = "welcome";
|
||||
private static final String CONFIG_DIR = "trek-server-config";
|
||||
|
||||
private TrekServerStore() {}
|
||||
|
||||
/**
|
||||
* The one-time code of a TREK Cloud purchase (setServer's `welcome`). The
|
||||
* server's first page opens its welcome page with it once, which signs the
|
||||
* buyer in (TrekWebViewClient). Kept only until the restart has loaded.
|
||||
*/
|
||||
static void setWelcome(Context context, String code) {
|
||||
SharedPreferences.Editor editor = prefs(context).edit();
|
||||
if (code == null) editor.remove(KEY_WELCOME);
|
||||
else editor.putString(KEY_WELCOME, code);
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
/** The welcome page for the pending code, handed out once; null when there is none. */
|
||||
static String takeWelcomeUrl(Context context) {
|
||||
String code = prefs(context).getString(KEY_WELCOME, null);
|
||||
String server = getUrl(context);
|
||||
if (code == null) return null;
|
||||
prefs(context).edit().remove(KEY_WELCOME).commit();
|
||||
if (server == null) return null;
|
||||
return (server.endsWith("/") ? server : server + "/") + "api/managed/welcome#" + code;
|
||||
}
|
||||
|
||||
static String getUrl(Context context) {
|
||||
return prefs(context).getString(KEY_URL, null);
|
||||
}
|
||||
|
||||
/** Remembers the server; its sign-in hosts go with it and are replaced or dropped too. */
|
||||
static void setUrl(Context context, String url) {
|
||||
setServer(context, url, null);
|
||||
}
|
||||
|
||||
static void setServer(Context context, String url, Set<String> signInHosts) {
|
||||
SharedPreferences.Editor editor = prefs(context).edit();
|
||||
if (url == null) {
|
||||
editor.remove(KEY_URL);
|
||||
} else {
|
||||
editor.putString(KEY_URL, url);
|
||||
}
|
||||
if (signInHosts == null || signInHosts.isEmpty()) {
|
||||
editor.remove(KEY_SIGN_IN_HOSTS);
|
||||
} else {
|
||||
Set<String> lower = new HashSet<>();
|
||||
for (String host : signInHosts) lower.add(host.toLowerCase(Locale.ROOT));
|
||||
editor.putStringSet(KEY_SIGN_IN_HOSTS, lower);
|
||||
}
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* The hosts of a sign-in page that stands in front of the server (a proxy
|
||||
* such as Cloudflare Access or Pangolin), as the address screen found them.
|
||||
* Only these may load inside the web view besides the server itself, so
|
||||
* the proxy's session lands where the app can use it (TrekWebViewClient).
|
||||
*/
|
||||
static Set<String> getSignInHosts(Context context) {
|
||||
Set<String> hosts = prefs(context).getStringSet(KEY_SIGN_IN_HOSTS, null);
|
||||
return hosts == null ? Collections.emptySet() : Collections.unmodifiableSet(new HashSet<>(hosts));
|
||||
}
|
||||
|
||||
/** Keeps one more sign-in host for the remembered server, as the web view met it (SignInRedirect). */
|
||||
static void addSignInHost(Context context, String host) {
|
||||
Set<String> hosts = new HashSet<>(getSignInHosts(context));
|
||||
if (!hosts.add(host.toLowerCase(Locale.ROOT))) return;
|
||||
prefs(context).edit().putStringSet(KEY_SIGN_IN_HOSTS, hosts).commit();
|
||||
}
|
||||
|
||||
/** Start the next launch on the bundled address screen, even with a server remembered. */
|
||||
static void showShellOnce(Context context) {
|
||||
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
|
||||
|
||||
@@ -83,6 +83,12 @@ final class TrekSharedFiles {
|
||||
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"));
|
||||
// A page or a link: where it came from, so the UI can keep it among the trip's links.
|
||||
String url = meta.optString("url", "");
|
||||
if (url.startsWith("https://") || url.startsWith("http://")) {
|
||||
file.put("url", url);
|
||||
file.put("title", meta.optString("title", url));
|
||||
}
|
||||
pending = file;
|
||||
if (listener != null) listener.onFile(file);
|
||||
}
|
||||
|
||||
@@ -6,17 +6,25 @@ import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
import androidx.activity.result.ActivityResult;
|
||||
import com.liketrek.trek.clientcert.ClientCertActions;
|
||||
import com.liketrek.trek.clientcert.TrekClientIdentity;
|
||||
import androidx.browser.customtabs.CustomTabsIntent;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import com.getcapacitor.JSArray;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.PermissionState;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.ActivityCallback;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import com.getcapacitor.annotation.Permission;
|
||||
import com.getcapacitor.annotation.PermissionCallback;
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** The native half of client/src/native/trekShell.ts. */
|
||||
@@ -29,6 +37,8 @@ import org.json.JSONObject;
|
||||
)
|
||||
public class TrekShellPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "TrekShell";
|
||||
|
||||
/** How long to wait after returning from the browser for the redirect to land. */
|
||||
private static final long CANCEL_GRACE_MS = 600;
|
||||
|
||||
@@ -59,6 +69,7 @@ public class TrekShellPlugin extends Plugin {
|
||||
"googleWallet",
|
||||
"keyCommands",
|
||||
"windowLayout",
|
||||
"clientCertificate",
|
||||
};
|
||||
|
||||
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
|
||||
@@ -272,11 +283,26 @@ public class TrekShellPlugin extends Plugin {
|
||||
call.reject("A http(s) URL is required");
|
||||
return;
|
||||
}
|
||||
TrekServerStore.setUrl(getContext(), url);
|
||||
TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
|
||||
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
|
||||
String welcome = call.getString("welcome");
|
||||
TrekServerStore.setWelcome(getContext(), welcome != null && welcome.matches("[A-Za-z0-9_-]{43}") ? welcome : null);
|
||||
call.resolve();
|
||||
restart();
|
||||
}
|
||||
|
||||
/** The sign-in hosts the address screen passes along, plain host names only. */
|
||||
private static Set<String> signInHostsOf(PluginCall call) {
|
||||
Set<String> hosts = new HashSet<>();
|
||||
JSArray list = call.getArray("signInHosts");
|
||||
if (list == null) return hosts;
|
||||
for (int i = 0; i < list.length(); i++) {
|
||||
String host = list.optString(i, "");
|
||||
if (host.matches("[A-Za-z0-9.-]+(:[0-9]+)?")) hosts.add(host);
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void resetServer(PluginCall call) {
|
||||
TrekServerStore.setUrl(getContext(), null);
|
||||
@@ -315,7 +341,32 @@ public class TrekShellPlugin extends Plugin {
|
||||
// A Custom Tab shares the browser's cookies, so a running SSO session at
|
||||
// the identity provider carries over. The redirect back to the app's
|
||||
// scheme arrives through the intent-filter in AndroidManifest.xml.
|
||||
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), Uri.parse(url));
|
||||
Uri target = Uri.parse(url);
|
||||
getActivity().runOnUiThread(() -> openSignIn(target));
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the sign-in in a Custom Tab, or in the browser itself where no
|
||||
* Custom Tab can come up. Launched on the UI thread: some devices dropped a
|
||||
* launch from the bridge's thread without a word, and the sign-in then
|
||||
* looked cancelled. A browser that cannot open at all is reported as such.
|
||||
*/
|
||||
private void openSignIn(Uri target) {
|
||||
try {
|
||||
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), target);
|
||||
return;
|
||||
} catch (RuntimeException tabFailed) {
|
||||
Log.w(TAG, "No Custom Tab for the sign-in, trying the browser", tabFailed);
|
||||
}
|
||||
try {
|
||||
getActivity().startActivity(new Intent(Intent.ACTION_VIEW, target).addCategory(Intent.CATEGORY_BROWSABLE));
|
||||
} catch (RuntimeException browserFailed) {
|
||||
Log.w(TAG, "No browser for the sign-in", browserFailed);
|
||||
PluginCall call = pendingAuthCall();
|
||||
if (call == null) return;
|
||||
call.reject("No browser could open the sign-in", "NO_BROWSER");
|
||||
finishAuth(call);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -630,4 +681,129 @@ public class TrekShellPlugin extends Plugin {
|
||||
// The last layout WindowManager reported, posture and hinge included; the bare window before the first.
|
||||
getActivity().runOnUiThread(() -> call.resolve(com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.current(getActivity())));
|
||||
}
|
||||
|
||||
// ---- Client certificates (mutual TLS) ----
|
||||
|
||||
/**
|
||||
* The address screen's check of a server, made natively so it notices
|
||||
* whether the server asks for a client certificate and can show the stored
|
||||
* one (clientcert/ClientCertActions.request).
|
||||
*/
|
||||
@PluginMethod
|
||||
public void requestServer(PluginCall call) {
|
||||
String url = call.getString("url");
|
||||
Uri parsed = url == null ? null : Uri.parse(url);
|
||||
if (parsed == null || parsed.getHost() == null || !("https".equals(parsed.getScheme()) || "http".equals(parsed.getScheme()))) {
|
||||
call.reject("A http(s) URL is required");
|
||||
return;
|
||||
}
|
||||
int timeout = Math.max(1000, Math.min(call.getInt("timeoutMs", 10000), 30000));
|
||||
new Thread(() -> {
|
||||
try {
|
||||
call.resolve(ClientCertActions.request(getContext(), url, timeout));
|
||||
} catch (Exception e) {
|
||||
call.reject(e.getMessage() == null ? "request failed" : e.getMessage());
|
||||
}
|
||||
}, "trek-request-server").start();
|
||||
}
|
||||
|
||||
/** The stored certificate's details, or none. */
|
||||
@PluginMethod
|
||||
public void clientCertificate(PluginCall call) {
|
||||
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
|
||||
JSObject result = new JSObject();
|
||||
result.put("certificate", info == null ? JSONObject.NULL : ClientCertActions.infoObject(info));
|
||||
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
|
||||
if (asked != null) {
|
||||
JSObject request = new JSObject();
|
||||
request.put("host", asked.getHost());
|
||||
request.put("port", asked.getPort());
|
||||
request.put("served", asked.getServed());
|
||||
result.put("request", request);
|
||||
}
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void removeClientCertificate(PluginCall call) {
|
||||
TrekClientIdentity.remove(getContext());
|
||||
forgetCertificateAnswers();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/** The WebView keeps its answer to a certificate request per host; a changed certificate needs a fresh one. */
|
||||
private void forgetCertificateAnswers() {
|
||||
com.liketrek.trek.clientcert.ClientCertWatch.reset();
|
||||
getActivity().runOnUiThread(() -> android.webkit.WebView.clearClientCertPreferences(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a .p12/.pfx file, asks for its password until it opens or the user
|
||||
* gives up, and keeps it for the server at host and port. Resolves with
|
||||
* result "imported" (and the certificate), "cancelled", or "failed" and why.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void importClientCertificate(PluginCall call) {
|
||||
String host = call.getString("host");
|
||||
if (host == null || !host.matches("[A-Za-z0-9.-]+")) {
|
||||
call.reject("A host is required");
|
||||
return;
|
||||
}
|
||||
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*");
|
||||
pick.putExtra(Intent.EXTRA_MIME_TYPES, new String[] { "application/x-pkcs12", "application/pkcs12", "application/octet-stream" });
|
||||
startActivityForResult(call, pick, "clientCertificatePicked");
|
||||
}
|
||||
|
||||
@ActivityCallback
|
||||
private void clientCertificatePicked(PluginCall call, ActivityResult picked) {
|
||||
if (call == null) return;
|
||||
Uri uri = picked.getData() == null ? null : picked.getData().getData();
|
||||
if (picked.getResultCode() != android.app.Activity.RESULT_OK || uri == null) {
|
||||
resolveImport(call, "cancelled", null);
|
||||
return;
|
||||
}
|
||||
new Thread(() -> {
|
||||
byte[] bytes = ClientCertActions.readFile(getContext(), uri);
|
||||
if (bytes == null) resolveImport(call, "failed", TrekClientIdentity.Failure.FORMAT.name());
|
||||
else askPasswordFor(call, bytes, false);
|
||||
}, "trek-client-cert").start();
|
||||
}
|
||||
|
||||
private void askPasswordFor(PluginCall call, byte[] bytes, boolean wrong) {
|
||||
JSObject labels = call.getObject("labels", new JSObject());
|
||||
ClientCertActions.askPassword(getActivity(), labels, wrong, password -> {
|
||||
if (password == null) {
|
||||
resolveImport(call, "cancelled", null);
|
||||
return kotlin.Unit.INSTANCE;
|
||||
}
|
||||
new Thread(() -> openWith(call, bytes, password), "trek-client-cert").start();
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private void openWith(PluginCall call, byte[] bytes, char[] password) {
|
||||
try {
|
||||
TrekClientIdentity.Identity identity = TrekClientIdentity.read(bytes, password);
|
||||
TrekClientIdentity.save(getContext(), identity, call.getString("host"), call.getInt("port", 443));
|
||||
forgetCertificateAnswers();
|
||||
resolveImport(call, "imported", null);
|
||||
} catch (TrekClientIdentity.ImportException e) {
|
||||
if (e.getReason() == TrekClientIdentity.Failure.PASSWORD) askPasswordFor(call, bytes, true);
|
||||
else resolveImport(call, "failed", e.getReason().name());
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "client certificate not kept", e);
|
||||
resolveImport(call, "failed", "STORE");
|
||||
} finally {
|
||||
java.util.Arrays.fill(password, '\0');
|
||||
}
|
||||
}
|
||||
|
||||
private void resolveImport(PluginCall call, String result, String failure) {
|
||||
JSObject out = new JSObject();
|
||||
out.put("result", result);
|
||||
if (failure != null) out.put("failure", failure.toLowerCase(java.util.Locale.ROOT));
|
||||
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
|
||||
if ("imported".equals(result) && info != null) out.put("certificate", ClientCertActions.infoObject(info));
|
||||
call.resolve(out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.webkit.ClientCertRequest;
|
||||
import android.webkit.WebResourceError;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebView;
|
||||
import com.getcapacitor.Bridge;
|
||||
import com.getcapacitor.BridgeWebViewClient;
|
||||
import com.liketrek.trek.clientcert.TrekClientIdentity;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Used while the WebView shows the user's server. When that server cannot be
|
||||
@@ -18,10 +24,58 @@ import com.getcapacitor.BridgeWebViewClient;
|
||||
class TrekWebViewClient extends BridgeWebViewClient {
|
||||
|
||||
private final Runnable onUnreachable;
|
||||
private final Set<String> signInHosts;
|
||||
/** A page of the server has shown since launch; from then on no sign-in host is learned (SignInRedirect). */
|
||||
private boolean serverShown;
|
||||
|
||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable) {
|
||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) {
|
||||
super(bridge);
|
||||
this.onUnreachable = onUnreachable;
|
||||
this.signInHosts = new HashSet<>(signInHosts);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sign-in page of a proxy in front of the server loads here rather than
|
||||
* in the browser, so its session cookie is the web view's and every request
|
||||
* of the app carries it, as in a browser. Only the hosts the address screen
|
||||
* saw that proxy send it to (TrekServerStore.getSignInHosts), and only as a
|
||||
* page: Capacitor injects its bridge into the server's origin alone, so the
|
||||
* proxy's page gets no access to the app. Everything else goes on to
|
||||
* Capacitor, which opens a foreign page in the browser.
|
||||
*/
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
|
||||
if (request.isForMainFrame() && isSignInHost(request.getUrl())) return false;
|
||||
String learned = SignInRedirect.learnedHost(
|
||||
TrekServerStore.getUrl(view.getContext()),
|
||||
request.getUrl().toString(),
|
||||
request.isForMainFrame(),
|
||||
request.isRedirect(),
|
||||
serverShown
|
||||
);
|
||||
if (learned != null) {
|
||||
signInHosts.add(learned);
|
||||
TrekServerStore.addSignInHost(view.getContext(), learned);
|
||||
return false;
|
||||
}
|
||||
return super.shouldOverrideUrlLoading(view, request);
|
||||
}
|
||||
|
||||
/** After the server's first page, a pending welcome code opens the welcome page once (TrekServerStore.takeWelcomeUrl). */
|
||||
@Override
|
||||
public void onPageFinished(WebView view, String url) {
|
||||
super.onPageFinished(view, url);
|
||||
if (SignInRedirect.onServer(TrekServerStore.getUrl(view.getContext()), url)) serverShown = true;
|
||||
String welcome = TrekServerStore.takeWelcomeUrl(view.getContext());
|
||||
if (welcome != null) view.loadUrl(welcome);
|
||||
}
|
||||
|
||||
private boolean isSignInHost(Uri url) {
|
||||
String scheme = url.getScheme();
|
||||
String host = url.getHost();
|
||||
if (host == null || !("https".equals(scheme) || "http".equals(scheme))) return false;
|
||||
String key = host.toLowerCase(Locale.ROOT);
|
||||
return signInHosts.contains(key) || (url.getPort() != -1 && signInHosts.contains(key + ":" + url.getPort()));
|
||||
}
|
||||
|
||||
/** Mapbox's map resources go through the app's own store (tiles/TrekTileStore.kt). */
|
||||
@@ -34,6 +88,28 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
||||
return super.shouldInterceptRequest(view, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* A proxy in front of the server asks for a client certificate (mutual TLS).
|
||||
* The stored one goes only to the host and port it was imported for; every
|
||||
* other site that asks is told there is none, as a browser without a
|
||||
* matching certificate would. The WebView remembers either answer for the
|
||||
* host until the app restarts, which a new certificate always brings along.
|
||||
*/
|
||||
@Override
|
||||
public void onReceivedClientCertRequest(WebView view, ClientCertRequest request) {
|
||||
TrekClientIdentity.Identity identity = TrekClientIdentity.identityFor(view.getContext(), request.getHost(), request.getPort());
|
||||
TrekClientIdentity.noteRequest(request.getHost(), request.getPort(), identity != null);
|
||||
if (identity != null) request.proceed(identity.getKey(), identity.getChain());
|
||||
else request.ignore();
|
||||
// The page may still come from the service worker's cache while every
|
||||
// request fails: check natively whether the server lets the app in.
|
||||
String server = TrekServerStore.getUrl(view.getContext());
|
||||
Uri serverUri = server == null ? null : Uri.parse(server);
|
||||
if (serverUri != null && TrekClientIdentity.matches(String.valueOf(serverUri.getHost()), serverUri.getPort() > 0 ? serverUri.getPort() : 443, request.getHost(), request.getPort())) {
|
||||
com.liketrek.trek.clientcert.ClientCertWatch.serverAsked(view.getContext(), server, onUnreachable);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
|
||||
if (request.isForMainFrame()) {
|
||||
@@ -46,14 +122,22 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
||||
/**
|
||||
* Only a 5xx on the page itself means the server is down (a proxy's 502 or
|
||||
* 503 while TREK restarts). A 4xx page is left alone: the server answered,
|
||||
* and TREK serves its UI for every other path.
|
||||
* and TREK serves its UI for every other path. The exception is a proxy
|
||||
* that asked this page for a client certificate and got none, as Cloudflare
|
||||
* does with a 403: the address screen then says so and offers to add one.
|
||||
*/
|
||||
@Override
|
||||
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
|
||||
int status = errorResponse.getStatusCode();
|
||||
if (!request.isForMainFrame()) {
|
||||
super.onReceivedHttpError(view, request, errorResponse);
|
||||
} else if (errorResponse.getStatusCode() >= 500) {
|
||||
} else if (status >= 500 || (status >= 400 && wantedCertificate(request.getUrl()))) {
|
||||
onUnreachable.run();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean wantedCertificate(Uri url) {
|
||||
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
|
||||
return asked != null && !asked.getServed() && TrekClientIdentity.matches(asked.getHost(), asked.getPort(), url.getHost(), url.getPort());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.liketrek.trek.clientcert
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.text.InputType
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import com.getcapacitor.JSObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
|
||||
/**
|
||||
* What TrekShellPlugin does for client certificates (TrekClientIdentity):
|
||||
* check a server while noticing whether it asks for one, read a picked file,
|
||||
* and ask for its password.
|
||||
*/
|
||||
object ClientCertActions {
|
||||
|
||||
private const val MAX_BODY_BYTES = 512 * 1024
|
||||
private const val MAX_FILE_BYTES = 256 * 1024
|
||||
private const val MAX_REDIRECTS = 5
|
||||
|
||||
/**
|
||||
* A GET against the user's server for the address screen: status, body, final
|
||||
* URL and headers, plus whether the server asked for a client certificate and
|
||||
* whether one was shown. A failed connection answers status 0 instead of
|
||||
* throwing, so a handshake the server broke off for want of a certificate
|
||||
* still reports that it asked.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun request(context: Context, url: String, timeoutMs: Int): JSObject {
|
||||
var asked: Triple<String, Int, Boolean>? = null
|
||||
val factory = TrekClientIdentity.socketFactory(context) { host, port, served -> asked = Triple(host, port, served) }
|
||||
val result = JSObject()
|
||||
var current = URL(url)
|
||||
try {
|
||||
for (hop in 0..MAX_REDIRECTS) {
|
||||
val connection = current.openConnection() as HttpURLConnection
|
||||
if (connection is HttpsURLConnection) connection.sslSocketFactory = factory
|
||||
connection.instanceFollowRedirects = false
|
||||
connection.connectTimeout = timeoutMs
|
||||
connection.readTimeout = timeoutMs
|
||||
connection.setRequestProperty("Accept", "application/json, text/html;q=0.9, */*;q=0.8")
|
||||
try {
|
||||
val status = connection.responseCode
|
||||
val location = connection.getHeaderField("Location")
|
||||
if (status in 300..399 && location != null && hop < MAX_REDIRECTS) {
|
||||
current = URL(current, location)
|
||||
continue
|
||||
}
|
||||
val stream = if (status >= 400) connection.errorStream else connection.inputStream
|
||||
result.put("status", status)
|
||||
result.put("data", stream?.use { readCapped(it, MAX_BODY_BYTES) }?.toString(Charsets.UTF_8) ?: "")
|
||||
result.put("url", current.toString())
|
||||
val headers = JSObject()
|
||||
for ((name, values) in connection.headerFields) if (name != null && values.isNotEmpty()) headers.put(name, values.first())
|
||||
result.put("headers", headers)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
break
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
result.put("status", 0)
|
||||
result.put("data", "")
|
||||
result.put("url", current.toString())
|
||||
result.put("error", e.javaClass.simpleName)
|
||||
}
|
||||
val seen = asked
|
||||
result.put("certRequested", seen != null)
|
||||
result.put("certSent", seen?.third == true)
|
||||
if (seen != null) result.put("certHost", "${seen.first}:${seen.second}")
|
||||
return result
|
||||
}
|
||||
|
||||
/** The bytes of a picked file, or null when it cannot be read or is far too big for a certificate. */
|
||||
@JvmStatic
|
||||
fun readFile(context: Context, uri: Uri): ByteArray? = try {
|
||||
context.contentResolver.openInputStream(uri)?.use { readCapped(it, MAX_FILE_BYTES + 1) }?.takeIf { it.size <= MAX_FILE_BYTES }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* The password prompt, with the UI's own words (`labels`: title, message,
|
||||
* placeholder, ok, cancel, wrongPassword). `wrong` shows that the last try
|
||||
* did not open the file. `done` gets the password, or null when cancelled.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun askPassword(activity: Activity, labels: JSObject, wrong: Boolean, done: (CharArray?) -> Unit) {
|
||||
activity.runOnUiThread {
|
||||
val input = EditText(activity).apply {
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
hint = labels.getString("placeholder") ?: ""
|
||||
}
|
||||
val frame = FrameLayout(activity).apply {
|
||||
val pad = (20 * activity.resources.displayMetrics.density).toInt()
|
||||
setPadding(pad, pad / 2, pad, 0)
|
||||
addView(input, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||
}
|
||||
val message = if (wrong) labels.getString("wrongPassword") else labels.getString("message")
|
||||
var answered = false
|
||||
val dialog = AlertDialog.Builder(activity)
|
||||
.setTitle(labels.getString("title"))
|
||||
.setMessage(message)
|
||||
.setView(frame)
|
||||
.setPositiveButton(labels.getString("ok") ?: "OK") { _, _ ->
|
||||
answered = true
|
||||
val text = input.text
|
||||
val password = CharArray(text.length) { text[it] }
|
||||
done(password)
|
||||
}
|
||||
.setNegativeButton(labels.getString("cancel") ?: "Cancel") { _, _ ->
|
||||
answered = true
|
||||
done(null)
|
||||
}
|
||||
.setOnDismissListener { if (!answered) done(null) }
|
||||
.create()
|
||||
dialog.show()
|
||||
input.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
/** The settings' view of the stored certificate. */
|
||||
@JvmStatic
|
||||
fun infoObject(info: TrekClientIdentity.Info): JSObject = JSObject().apply {
|
||||
put("host", info.host)
|
||||
put("port", info.port)
|
||||
put("subject", info.subject)
|
||||
put("issuer", info.issuer)
|
||||
put("notAfter", info.notAfter)
|
||||
}
|
||||
|
||||
private fun readCapped(stream: InputStream, max: Int): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(8192)
|
||||
while (out.size() < max) {
|
||||
val n = stream.read(buffer, 0, minOf(buffer.size, max - out.size()))
|
||||
if (n < 0) break
|
||||
out.write(buffer, 0, n)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.liketrek.trek.clientcert
|
||||
|
||||
import android.content.Context
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Notices when the user's server stops letting the app in for want of a
|
||||
* client certificate: one removed, expired, or no longer trusted by the proxy.
|
||||
*
|
||||
* The page alone does not show it. The service worker serves TREK's start page
|
||||
* from its cache, so the page loads and only its requests fail, and the UI
|
||||
* reports a server it cannot reach with no way to add a certificate. So when
|
||||
* the server asks for one, the app checks once natively whether TREK answers
|
||||
* with what it holds. Only a check that fails at the certificate (the handshake
|
||||
* broke off, or the proxy answered with its own 4xx) sends the app to the
|
||||
* address screen, which says so and offers to add one. A server that merely
|
||||
* asks and lets everyone in answers TREK's 200, and nothing happens.
|
||||
*/
|
||||
object ClientCertWatch {
|
||||
|
||||
private const val MIN_INTERVAL_MS = 60_000L
|
||||
private const val FEATURES_PATH = "/api/health/features"
|
||||
private const val TIMEOUT_MS = 8000
|
||||
|
||||
private val lastCheck = AtomicLong(0)
|
||||
|
||||
/** Called where the server asked for a certificate; `onRefused` runs at most once a minute, on a background thread. */
|
||||
@JvmStatic
|
||||
fun serverAsked(context: Context, serverUrl: String?, onRefused: Runnable) {
|
||||
if (serverUrl == null) return
|
||||
val now = System.currentTimeMillis()
|
||||
val last = lastCheck.get()
|
||||
if (now - last < MIN_INTERVAL_MS || !lastCheck.compareAndSet(last, now)) return
|
||||
val appContext = context.applicationContext
|
||||
Thread({
|
||||
val result = ClientCertActions.request(appContext, serverUrl.trimEnd('/') + FEATURES_PATH, TIMEOUT_MS)
|
||||
if (refused(result.optBoolean("certRequested"), result.optInt("status"))) onRefused.run()
|
||||
}, "trek-client-cert-watch").start()
|
||||
}
|
||||
|
||||
/** A new or removed certificate is worth a check of its own, whenever the last one ran. */
|
||||
@JvmStatic
|
||||
fun reset() {
|
||||
lastCheck.set(0)
|
||||
}
|
||||
|
||||
/** The server asked for a certificate and did not answer with TREK. */
|
||||
@JvmStatic
|
||||
fun refused(certRequested: Boolean, status: Int): Boolean = certRequested && (status == 0 || status in 400..499)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.liketrek.trek.clientcert
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.net.Socket
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.security.PrivateKey
|
||||
import java.security.UnrecoverableKeyException
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
|
||||
/**
|
||||
* The client certificate a user's TREK server asks for (mutual TLS): a proxy
|
||||
* such as Cloudflare, nginx, Caddy or Traefik in front of TREK lets through only
|
||||
* devices that show one. The app holds at most one, for one server.
|
||||
*
|
||||
* It comes in as a PKCS#12 file (.p12, .pfx) and its password. Neither is kept:
|
||||
* the private key is sealed with an AES key of the Android keystore, which
|
||||
* never leaves it, and stored with the certificate chain in the app's private
|
||||
* preferences. In memory the key is an ordinary one, which TLS 1.2 and 1.3 and
|
||||
* the WebView all take, whatever its kind; a key imported into the keystore
|
||||
* itself would be refused by some of them for its padding or digest rules.
|
||||
*
|
||||
* Only the host and port it was imported for ever see it (`identityFor`), so a
|
||||
* login page, a map tile provider or liketrek.com never learns of it.
|
||||
*/
|
||||
object TrekClientIdentity {
|
||||
|
||||
private const val PREFS = "trek_client_identity"
|
||||
private const val WRAP_ALIAS = "trek-client-identity-wrap"
|
||||
private const val KEYMANAGER_ALIAS = "trek-client-identity"
|
||||
private const val GCM_TAG_BITS = 128
|
||||
|
||||
/** Why a file could not be taken. */
|
||||
enum class Failure { PASSWORD, FORMAT, NO_KEY, EXPIRED, NOT_YET_VALID }
|
||||
|
||||
class ImportException(val reason: Failure) : Exception(reason.name)
|
||||
|
||||
class Identity(val key: PrivateKey, val chain: Array<X509Certificate>)
|
||||
|
||||
/** What the settings show: who the certificate names, who issued it, until when, and for which server. */
|
||||
data class Info(val host: String, val port: Int, val subject: String, val issuer: String, val notAfter: Long)
|
||||
|
||||
/** The opened identity, with the sealed key text it was opened from: opened once, not per request. */
|
||||
@Volatile private var cached: Pair<String, Identity>? = null
|
||||
|
||||
/** The last time a server asked for a certificate while the app showed it: for the address screen. */
|
||||
@Volatile var lastRequest: Request? = null
|
||||
private set
|
||||
|
||||
data class Request(val host: String, val port: Int, val served: Boolean)
|
||||
|
||||
private class Stored(val host: String, val port: Int, val algorithm: String, val sealedText: String, val iv: ByteArray, val chain: List<ByteArray>)
|
||||
|
||||
/**
|
||||
* Reads a PKCS#12 file: the first entry that holds a private key, with its chain.
|
||||
* Throws ImportException naming what is wrong with it.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
@Throws(ImportException::class)
|
||||
fun read(bytes: ByteArray, password: CharArray, now: Date = Date()): Identity {
|
||||
val store = KeyStore.getInstance("PKCS12")
|
||||
try {
|
||||
store.load(ByteArrayInputStream(bytes), password)
|
||||
} catch (e: IOException) {
|
||||
throw ImportException(if (isPasswordFailure(e)) Failure.PASSWORD else Failure.FORMAT)
|
||||
} catch (e: Exception) {
|
||||
throw ImportException(Failure.FORMAT)
|
||||
}
|
||||
val alias = store.aliases().toList().firstOrNull { store.isKeyEntry(it) } ?: throw ImportException(Failure.NO_KEY)
|
||||
val key = try {
|
||||
store.getKey(alias, password) as? PrivateKey
|
||||
} catch (e: UnrecoverableKeyException) {
|
||||
throw ImportException(Failure.PASSWORD)
|
||||
} ?: throw ImportException(Failure.NO_KEY)
|
||||
val chain = store.getCertificateChain(alias)?.mapNotNull { it as? X509Certificate }?.toTypedArray()
|
||||
if (chain.isNullOrEmpty()) throw ImportException(Failure.NO_KEY)
|
||||
val leaf = chain[0]
|
||||
if (now.after(leaf.notAfter)) throw ImportException(Failure.EXPIRED)
|
||||
if (now.before(leaf.notBefore)) throw ImportException(Failure.NOT_YET_VALID)
|
||||
return Identity(key, chain)
|
||||
}
|
||||
|
||||
/** A wrong password shows up as a failed MAC check or an unrecoverable key, wrapped in an IOException. */
|
||||
private fun isPasswordFailure(e: Throwable): Boolean {
|
||||
var cause: Throwable? = e
|
||||
while (cause != null) {
|
||||
if (cause is UnrecoverableKeyException || cause is javax.crypto.BadPaddingException) return true
|
||||
if (cause.message?.lowercase(Locale.ROOT)?.contains("password") == true) return true
|
||||
cause = cause.cause
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Keeps the identity for this host and port, replacing any other. */
|
||||
@JvmStatic
|
||||
fun save(context: Context, identity: Identity, host: String, port: Int) {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, wrapKey())
|
||||
val sealed = cipher.doFinal(identity.key.encoded)
|
||||
prefs(context).edit()
|
||||
.putString("host", host.lowercase(Locale.ROOT))
|
||||
.putInt("port", port)
|
||||
.putString("algorithm", identity.key.algorithm)
|
||||
.putString("key", encode(sealed))
|
||||
.putString("iv", encode(cipher.iv))
|
||||
.putString("chain", identity.chain.joinToString(",") { encode(it.encoded) })
|
||||
.apply()
|
||||
cached = null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun remove(context: Context) {
|
||||
prefs(context).edit().clear().apply()
|
||||
cached = null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun info(context: Context): Info? {
|
||||
val stored = stored(context) ?: return null
|
||||
val leaf = runCatching { certificate(stored.chain.first()) }.getOrNull() ?: return null
|
||||
return Info(stored.host, stored.port, commonName(leaf.subjectX500Principal), commonName(leaf.issuerX500Principal), leaf.notAfter.time)
|
||||
}
|
||||
|
||||
/** The identity for a server at this host and port, or null when there is none for it. */
|
||||
@JvmStatic
|
||||
fun identityFor(context: Context, host: String?, port: Int): Identity? {
|
||||
val stored = stored(context) ?: return null
|
||||
if (!matches(stored.host, stored.port, host, port)) return null
|
||||
return opened(stored)
|
||||
}
|
||||
|
||||
private fun opened(stored: Stored): Identity? {
|
||||
cached?.let { (from, identity) -> if (from == stored.sealedText) return identity }
|
||||
val identity = runCatching { open(stored) }.getOrNull() ?: return null
|
||||
cached = stored.sealedText to identity
|
||||
return identity
|
||||
}
|
||||
|
||||
/** Whether a request to host and port (-1 or 0 for the scheme's default) goes to the server the identity is for. */
|
||||
@JvmStatic
|
||||
fun matches(storedHost: String, storedPort: Int, host: String?, port: Int): Boolean {
|
||||
if (host == null || !host.equals(storedHost, ignoreCase = true)) return false
|
||||
val effective = if (port <= 0) 443 else port
|
||||
return effective == storedPort
|
||||
}
|
||||
|
||||
/** Called where a server asked for a certificate, so the address screen can offer one. */
|
||||
@JvmStatic
|
||||
fun noteRequest(host: String?, port: Int, served: Boolean) {
|
||||
if (host == null) return
|
||||
lastRequest = Request(host.lowercase(Locale.ROOT), if (port <= 0) 443 else port, served)
|
||||
}
|
||||
|
||||
/**
|
||||
* A socket factory for the app's own requests (the server check, map tiles).
|
||||
* It offers the identity only when the server it is talking to asks for one
|
||||
* and is the one the identity is for, and tells `onAsked` about every ask.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun socketFactory(context: Context, onAsked: ((host: String, port: Int, served: Boolean) -> Unit)? = null): SSLSocketFactory {
|
||||
val appContext = context.applicationContext
|
||||
val manager = object : X509ExtendedKeyManager() {
|
||||
// Decided per handshake in choose(); the key and chain are then read for
|
||||
// the one alias, so parallel connections never see each other's choice.
|
||||
private fun choose(host: String?, port: Int): String? {
|
||||
val identity = identityFor(appContext, host, port)
|
||||
if (host != null) {
|
||||
noteRequest(host, port, identity != null)
|
||||
onAsked?.invoke(host.lowercase(Locale.ROOT), if (port <= 0) 443 else port, identity != null)
|
||||
}
|
||||
return if (identity != null) KEYMANAGER_ALIAS else null
|
||||
}
|
||||
|
||||
override fun chooseClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, socket: Socket?): String? {
|
||||
val session = (socket as? SSLSocket)?.handshakeSession
|
||||
return choose(session?.peerHost, session?.peerPort ?: -1)
|
||||
}
|
||||
|
||||
override fun chooseEngineClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, engine: SSLEngine?): String? =
|
||||
choose(engine?.peerHost, engine?.peerPort ?: -1)
|
||||
|
||||
override fun getCertificateChain(alias: String?): Array<X509Certificate>? =
|
||||
if (alias == KEYMANAGER_ALIAS) stored(appContext)?.let { opened(it) }?.chain else null
|
||||
override fun getPrivateKey(alias: String?): PrivateKey? =
|
||||
if (alias == KEYMANAGER_ALIAS) stored(appContext)?.let { opened(it) }?.key else null
|
||||
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
|
||||
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
|
||||
override fun chooseServerAlias(keyType: String?, issuers: Array<out Principal>?, socket: Socket?): String? = null
|
||||
}
|
||||
val tls = SSLContext.getInstance("TLS")
|
||||
tls.init(arrayOf(manager), null, null)
|
||||
return tls.socketFactory
|
||||
}
|
||||
|
||||
private fun open(stored: Stored): Identity {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), GCMParameterSpec(GCM_TAG_BITS, stored.iv))
|
||||
val key = KeyFactory.getInstance(stored.algorithm).generatePrivate(PKCS8EncodedKeySpec(cipher.doFinal(decode(stored.sealedText))))
|
||||
return Identity(key, stored.chain.map { certificate(it) }.toTypedArray())
|
||||
}
|
||||
|
||||
private fun stored(context: Context): Stored? {
|
||||
val p = prefs(context)
|
||||
val host = p.getString("host", null) ?: return null
|
||||
val algorithm = p.getString("algorithm", null) ?: return null
|
||||
val key = p.getString("key", null) ?: return null
|
||||
val iv = p.getString("iv", null) ?: return null
|
||||
val chain = p.getString("chain", null)?.split(",")?.filter { it.isNotEmpty() }?.map { decode(it) } ?: return null
|
||||
if (chain.isEmpty()) return null
|
||||
return Stored(host, p.getInt("port", 443), algorithm, key, decode(iv), chain)
|
||||
}
|
||||
|
||||
/** The keystore's AES key that seals the private key; made once, never leaves the keystore. */
|
||||
private fun wrapKey(): SecretKey {
|
||||
val keystore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(keystore.getKey(WRAP_ALIAS, null) as? SecretKey)?.let { return it }
|
||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||
generator.init(
|
||||
KeyGenParameterSpec.Builder(WRAP_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
|
||||
private fun certificate(der: ByteArray): X509Certificate =
|
||||
CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(der)) as X509Certificate
|
||||
|
||||
/** The CN of a name, or the whole name when it has none. */
|
||||
@JvmStatic
|
||||
fun commonName(principal: javax.security.auth.x500.X500Principal): String {
|
||||
val name = principal.getName(javax.security.auth.x500.X500Principal.RFC2253)
|
||||
return Regex("(?:^|,)CN=((?:\\\\.|[^,])*)").find(name)?.groupValues?.get(1)?.replace("\\", "") ?: name
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private fun encode(bytes: ByteArray) = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
private fun decode(text: String) = Base64.decode(text, Base64.NO_WRAP)
|
||||
}
|
||||
@@ -46,6 +46,7 @@ object ShareHandoff {
|
||||
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)
|
||||
if (item.url != null) meta.put("url", item.url).put("title", item.title)
|
||||
File(dir(context), "pending.data").writeBytes(item.data)
|
||||
File(dir(context), "pending.json").writeText(meta.toString())
|
||||
true
|
||||
|
||||
@@ -26,6 +26,11 @@ data class ShareItem(
|
||||
val title: String,
|
||||
/** A host, for a page. */
|
||||
val source: String? = null,
|
||||
/**
|
||||
* The web address it came from, when it is a page or a link: the app offers to
|
||||
* keep it among the trip's links as well as to read a booking out of it.
|
||||
*/
|
||||
val url: String? = null,
|
||||
) {
|
||||
enum class Kind { PASS, PDF, MAIL, CALENDAR, PAGE, TEXT, IMAGE, FILE }
|
||||
|
||||
@@ -90,11 +95,16 @@ object ShareLoader {
|
||||
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. */
|
||||
/**
|
||||
* A link becomes the page it points at, or the bare link when the page cannot be
|
||||
* read (a sign-in wall, an app-only page, no network), which the app can still
|
||||
* keep among the trip's links. 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) }
|
||||
return linkOnly(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)))
|
||||
@@ -121,12 +131,19 @@ object ShareLoader {
|
||||
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))
|
||||
return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host, link))
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** The link alone, as a text/uri-list the app recognises as nothing to read a booking from. */
|
||||
internal fun linkOnly(link: String, subject: String?): ShareItem {
|
||||
// java.net.URI rather than android.net.Uri: the same host, and readable in a JVM unit test.
|
||||
val host = runCatching { java.net.URI(link).host }.getOrNull()
|
||||
return ShareItem(ShareItem.Kind.PAGE, "link.url", "text/uri-list", link.toByteArray(), subject ?: host ?: link, host, link)
|
||||
}
|
||||
|
||||
/** 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<ShareItem.Kind, String> {
|
||||
val ext = name.substringAfterLast('.', "").lowercase()
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.liketrek.trek
|
||||
|
||||
import com.liketrek.trek.clientcert.ClientCertWatch
|
||||
import com.liketrek.trek.clientcert.TrekClientIdentity
|
||||
import com.liketrek.trek.clientcert.TrekClientIdentity.Failure
|
||||
import java.util.Date
|
||||
import javax.security.auth.x500.X500Principal
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Reading a client certificate file (TrekClientIdentity.read) and the rule for
|
||||
* which server sees it. The files in test/resources/clientcert are test-only:
|
||||
* a throwaway CA, password "trek-test". valid.p12 has OpenSSL 3's default
|
||||
* encryption (AES-256, PBKDF2), valid-legacy.p12 the older RC2/3DES one that
|
||||
* many routers and NAS systems still export.
|
||||
*/
|
||||
class ClientIdentityTest {
|
||||
private val password = "trek-test".toCharArray()
|
||||
private val inside = Date(1_800_000_000_000L) // 2027-01-15, inside every valid test certificate
|
||||
|
||||
private fun file(name: String): ByteArray =
|
||||
requireNotNull(javaClass.classLoader?.getResourceAsStream("clientcert/$name")) { name }.use { it.readBytes() }
|
||||
|
||||
private fun failure(name: String, pass: CharArray = password, now: Date = inside): Failure {
|
||||
try {
|
||||
TrekClientIdentity.read(file(name), pass, now)
|
||||
} catch (e: TrekClientIdentity.ImportException) {
|
||||
return e.reason
|
||||
}
|
||||
fail("$name opened")
|
||||
throw AssertionError()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opensModernAndLegacyFiles() {
|
||||
for (name in listOf("valid.p12", "valid-legacy.p12")) {
|
||||
val identity = TrekClientIdentity.read(file(name), password, inside)
|
||||
assertEquals("trek-test-valid", TrekClientIdentity.commonName(identity.chain[0].subjectX500Principal))
|
||||
assertEquals("TREK mTLS Test CA", TrekClientIdentity.commonName(identity.chain[0].issuerX500Principal))
|
||||
assertEquals("RSA", identity.key.algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun namesWhatIsWrongWithAFile() {
|
||||
assertEquals(Failure.PASSWORD, failure("valid.p12", "wrong".toCharArray()))
|
||||
assertEquals(Failure.PASSWORD, failure("valid-legacy.p12", "wrong".toCharArray()))
|
||||
assertEquals(Failure.EXPIRED, failure("expired.p12"))
|
||||
assertEquals(Failure.NOT_YET_VALID, failure("future.p12"))
|
||||
assertEquals(Failure.NO_KEY, failure("cert-only.p12"))
|
||||
try {
|
||||
TrekClientIdentity.read("not a certificate".toByteArray(), password, inside)
|
||||
fail("text opened")
|
||||
} catch (e: TrekClientIdentity.ImportException) {
|
||||
assertEquals(Failure.FORMAT, e.reason)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsTheCertificateOnlyToItsHostAndPort() {
|
||||
assertTrue(TrekClientIdentity.matches("trek.example.com", 443, "Trek.Example.com", -1))
|
||||
assertTrue(TrekClientIdentity.matches("trek.example.com", 443, "trek.example.com", 443))
|
||||
assertTrue(TrekClientIdentity.matches("nas.local", 8443, "nas.local", 8443))
|
||||
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, "auth.example.com", 443))
|
||||
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, "trek.example.com", 8443))
|
||||
assertFalse(TrekClientIdentity.matches("nas.local", 8443, "nas.local", -1))
|
||||
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, null, 443))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesForTheAddressScreenOnlyWhenTheCertificateIsWhatFails() {
|
||||
assertTrue(ClientCertWatch.refused(true, 0)) // handshake broken off: nginx, Caddy, Traefik
|
||||
assertTrue(ClientCertWatch.refused(true, 403)) // Cloudflare's block page
|
||||
assertTrue(ClientCertWatch.refused(true, 400)) // nginx without a valid certificate
|
||||
assertFalse(ClientCertWatch.refused(true, 200)) // asks, but lets everyone in
|
||||
assertFalse(ClientCertWatch.refused(true, 502)) // TREK restarting behind the proxy
|
||||
assertFalse(ClientCertWatch.refused(false, 0)) // simply offline
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsTheCommonNameOrFallsBackToTheWholeName() {
|
||||
assertEquals("Phone, home", TrekClientIdentity.commonName(X500Principal("CN=Phone\\, home,O=Family")))
|
||||
assertEquals("O=Family", TrekClientIdentity.commonName(X500Principal("O=Family")))
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,33 @@ import java.time.LocalDate
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** The native side's own decisions, apart from the views. */
|
||||
class NativeLogicTest {
|
||||
@Test
|
||||
fun aRedirectAwayWhileTheServerLoadsIsTakenAsItsSignIn() {
|
||||
val server = "https://trek.example.com"
|
||||
// Authelia in front of the page, its check let through on /api.
|
||||
assertEquals("auth.example.com", SignInRedirect.learnedHost(server, "https://auth.example.com/?rd=https%3A%2F%2Ftrek.example.com%2F", true, true, false))
|
||||
assertEquals("auth.example.com:9091", SignInRedirect.learnedHost(server, "https://Auth.example.com:9091/", true, true, false))
|
||||
// A link the user follows, a frame, or anything after the server's page has shown: the browser, as before.
|
||||
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", true, false, false))
|
||||
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", false, true, false))
|
||||
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", true, true, true))
|
||||
// The server's own host, another scheme, or a step down to plain http.
|
||||
assertNull(SignInRedirect.learnedHost(server, "https://trek.example.com/login", true, true, false))
|
||||
assertNull(SignInRedirect.learnedHost(server, "intent://auth.example.com/", true, true, false))
|
||||
assertNull(SignInRedirect.learnedHost(server, "http://auth.example.com/", true, true, false))
|
||||
assertEquals("auth.lan", SignInRedirect.learnedHost("http://trek.lan:3000", "http://auth.lan/", true, true, false))
|
||||
assertNull(SignInRedirect.learnedHost(null, "https://auth.example.com/", true, true, false))
|
||||
assertTrue(SignInRedirect.onServer(server, "https://TREK.example.com/dashboard"))
|
||||
assertFalse(SignInRedirect.onServer(server, "https://auth.example.com/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pushesLandInTheChannelOfTheirEvent() {
|
||||
assertEquals(TrekPushService.CHANNEL_CHAT, TrekPushService.channelFor("collab_message"))
|
||||
@@ -49,6 +71,16 @@ class NativeLogicTest {
|
||||
assertEquals("page", ShareLoader.slug("!!!"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLinkWhosePageCannotBeReadIsHandedOverAsTheLinkItself() {
|
||||
val item = ShareLoader.linkOnly("https://www.instagram.com/p/abc", null)
|
||||
assertEquals("text/uri-list", item.mimeType)
|
||||
assertEquals("https://www.instagram.com/p/abc", item.url)
|
||||
assertEquals("https://www.instagram.com/p/abc", String(item.data))
|
||||
assertEquals("www.instagram.com", item.title)
|
||||
assertEquals("Ramen place", ShareLoader.linkOnly("https://maps.app.goo.gl/x", "Ramen place").title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theShareSheetOffersComingTripsFirstThenRecentPastOnes() {
|
||||
val today = LocalDate.of(2026, 11, 7)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,9 +2,15 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-action-sheet'
|
||||
project(':capacitor-action-sheet').projectDir = new File('../../node_modules/@capacitor/action-sheet/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-dialog'
|
||||
project(':capacitor-dialog').projectDir = new File('../../node_modules/@capacitor/dialog/android')
|
||||
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
|
||||
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */; };
|
||||
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */; };
|
||||
896952F864B35C647FF6910A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
|
||||
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
|
||||
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
|
||||
@@ -172,6 +173,7 @@
|
||||
10FB55D221F4BA18795F33B5 /* WatchSample.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSample.swift; sourceTree = "<group>"; };
|
||||
114C13FB91B554AE8DC4372B /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekWatchLink.swift; sourceTree = "<group>"; };
|
||||
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekClientIdentity.swift; sourceTree = "<group>"; };
|
||||
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1335F1EEB49D744B27A00F31 /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
@@ -420,6 +422,7 @@
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */,
|
||||
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
@@ -823,6 +826,7 @@
|
||||
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
|
||||
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
|
||||
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */,
|
||||
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -25,6 +25,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
TrekPush.shared.didFail(error)
|
||||
}
|
||||
|
||||
/// A silent push from the TREK server: a list of a trip changed, so the Apple
|
||||
/// Watch is brought up to date (TrekWatchRefresher). Nothing else arrives this way.
|
||||
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
guard userInfo["trek"] as? String == "watch-sync" else { return completionHandler(.noData) }
|
||||
TrekWatchRefresher.refresh(completion: completionHandler)
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
completionHandler([.banner, .list, .sound])
|
||||
@@ -60,6 +68,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
// MARK: - Hardware keyboard
|
||||
|
||||
/// The UI's shortcuts for the screen on show (TrekKeyCommands), in the app's
|
||||
/// menu: the overlay a held Command key brings up lists them there.
|
||||
override func buildMenu(with builder: UIMenuBuilder) {
|
||||
super.buildMenu(with: builder)
|
||||
guard builder.system == .main, !TrekKeyCommands.current.isEmpty else { return }
|
||||
let parent: UIMenu.Identifier = builder.menu(for: .application) != nil ? .application : .root
|
||||
builder.insertChild(TrekKeyCommands.menu(), atStartOfMenu: parent)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
|
||||
@@ -120,6 +120,10 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
@@ -160,6 +164,8 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -6,6 +6,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
// Launched by a quick action on the app icon: held until the UI asks for it.
|
||||
if let item = connectionOptions.shortcutItem { _ = TrekQuickActions.open(item) }
|
||||
|
||||
window = UIWindow(windowScene: windowScene)
|
||||
window?.rootViewController = TrekBridgeViewController()
|
||||
@@ -45,6 +47,72 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
|
||||
}
|
||||
|
||||
// A quick action on the app icon while the app was running in the background.
|
||||
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||
completionHandler: @escaping (Bool) -> Void) {
|
||||
completionHandler(TrekQuickActions.open(shortcutItem))
|
||||
}
|
||||
}
|
||||
|
||||
/// The quick actions on the app icon (a long press on it): "New trip" and the
|
||||
/// trip coming up, set by the UI in its language (TrekShell.setQuickActions).
|
||||
/// Each carries the path it opens; a tap goes to the UI as that path, at once
|
||||
/// when it listens, or held for it to take when the tap launched the app.
|
||||
enum TrekQuickActions {
|
||||
private static let pathKey = "path"
|
||||
private static var pending: String?
|
||||
static var onOpen: ((String) -> Void)?
|
||||
|
||||
struct Item {
|
||||
let type: String
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let icon: String
|
||||
let path: String
|
||||
|
||||
init?(raw: JSObject) {
|
||||
guard let type = raw["type"] as? String, let title = raw["title"] as? String,
|
||||
let path = raw["path"] as? String, path.hasPrefix("/") else { return nil }
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.subtitle = raw["subtitle"] as? String
|
||||
self.icon = raw["icon"] as? String ?? ""
|
||||
self.path = path
|
||||
}
|
||||
}
|
||||
|
||||
static func set(_ items: [Item]) {
|
||||
UIApplication.shared.shortcutItems = items.prefix(4).map { item in
|
||||
UIApplicationShortcutItem(
|
||||
type: "com.liketrek.trek.\(item.type)",
|
||||
localizedTitle: item.title,
|
||||
localizedSubtitle: item.subtitle,
|
||||
icon: icon(item.icon),
|
||||
userInfo: [pathKey: item.path as NSString]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func icon(_ name: String) -> UIApplicationShortcutIcon {
|
||||
switch name {
|
||||
case "add": return UIApplicationShortcutIcon(type: .add)
|
||||
case "trip": return UIApplicationShortcutIcon(systemImageName: "suitcase")
|
||||
default: return UIApplicationShortcutIcon(type: .compose)
|
||||
}
|
||||
}
|
||||
|
||||
static func open(_ item: UIApplicationShortcutItem) -> Bool {
|
||||
guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false }
|
||||
if let onOpen { onOpen(path) } else { pending = path }
|
||||
return true
|
||||
}
|
||||
|
||||
/// The action the app was launched with, once.
|
||||
static func take() -> String? {
|
||||
defer { pending = nil }
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
/// The blur over the app while it sits in the app switcher, when the app lock
|
||||
@@ -110,6 +178,11 @@ enum TrekSharedFiles {
|
||||
"data": data.base64EncodedString(),
|
||||
]
|
||||
if let tripId = info["tripId"] as? Int { file["tripId"] = tripId }
|
||||
// A page or a link: where it came from, so the UI can keep it among the trip's links.
|
||||
if let url = info["url"] as? String, url.hasPrefix("https://") || url.hasPrefix("http://") {
|
||||
file["url"] = url
|
||||
if let title = info["title"] as? String { file["title"] = title }
|
||||
}
|
||||
pending = file
|
||||
onReceive?(file)
|
||||
}
|
||||
|
||||
@@ -43,12 +43,113 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
navigationProxy = proxy
|
||||
webView.navigationDelegate = proxy
|
||||
}
|
||||
guardBridgeMessages()
|
||||
bridge?.registerPluginInstance(TrekShellPlugin())
|
||||
// WebKit's own swipe back slides away a screenshot of the whole page,
|
||||
// closes no sheet and takes every drag near the edge from the UI. The UI
|
||||
// draws its own (mobile/components/MEdgeBack.tsx, feature "edgeBack"):
|
||||
// the page follows the finger and an open sheet closes first.
|
||||
webView?.allowsBackForwardNavigationGestures = false
|
||||
// A long press on a link would lift a preview of the page it points to,
|
||||
// which no native list does; the press means "drag" or nothing here.
|
||||
webView?.allowsLinkPreview = false
|
||||
}
|
||||
|
||||
/// Kept alive here: the content controller holds it, but only for as long as it is registered.
|
||||
private var messageGuard: TrekBridgeMessageGuard?
|
||||
|
||||
/// Capacitor answers bridge messages from any page in the web view. With a
|
||||
/// proxy's sign-in page loading there, its messages are dropped before they
|
||||
/// reach the plugins. Always in place: a sign-in host can also be learned
|
||||
/// while the app runs (TrekSignInRedirect).
|
||||
private func guardBridgeMessages() {
|
||||
guard let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler,
|
||||
let controller = webView?.configuration.userContentController else { return }
|
||||
let guarded = TrekBridgeMessageGuard(inner: handler)
|
||||
controller.removeScriptMessageHandler(forName: TrekBridgeMessageGuard.name)
|
||||
controller.add(guarded, name: TrekBridgeMessageGuard.name)
|
||||
messageGuard = guarded
|
||||
}
|
||||
|
||||
/// A key command of the UI's (TrekKeyCommands) was pressed. The commands sit
|
||||
/// in the main menu, which sends their action up the responder chain from
|
||||
/// the web view, and this controller is the first stop that answers it.
|
||||
@objc func trekKeyCommand(_ sender: UIKeyCommand) {
|
||||
guard let id = sender.propertyList as? String else { return }
|
||||
TrekKeyCommands.onPress?(id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hardware keyboard (TrekShell.setKeyCommands)
|
||||
|
||||
/// The UI's keyboard shortcuts as key commands in the app's main menu. There
|
||||
/// they show in the overlay a held Command key brings up and, on iPadOS 26, in
|
||||
/// the menu bar, under the names the UI gives them in its language. Each screen
|
||||
/// replaces the list (mobile/keyboard/MKeyboardHost.tsx), and the menu is
|
||||
/// rebuilt with it, so only what works here and now is ever offered.
|
||||
enum TrekKeyCommands {
|
||||
struct Command {
|
||||
let id: String
|
||||
let title: String
|
||||
let input: String
|
||||
let modifiers: UIKeyModifierFlags
|
||||
|
||||
init?(raw: JSObject) {
|
||||
guard let id = raw["id"] as? String, let title = raw["title"] as? String,
|
||||
let input = raw["input"] as? String, !input.isEmpty else { return nil }
|
||||
var flags: UIKeyModifierFlags = []
|
||||
for name in raw["modifiers"] as? [String] ?? [] {
|
||||
switch name {
|
||||
case "command": flags.insert(.command)
|
||||
case "shift": flags.insert(.shift)
|
||||
case "option": flags.insert(.alternate)
|
||||
case "control": flags.insert(.control)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.input = Command.key(input)
|
||||
self.modifiers = flags
|
||||
}
|
||||
|
||||
private static func key(_ name: String) -> String {
|
||||
switch name {
|
||||
case "left": return UIKeyCommand.inputLeftArrow
|
||||
case "right": return UIKeyCommand.inputRightArrow
|
||||
case "up": return UIKeyCommand.inputUpArrow
|
||||
case "down": return UIKeyCommand.inputDownArrow
|
||||
case "escape": return UIKeyCommand.inputEscape
|
||||
default: return name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static let menuIdentifier = UIMenu.Identifier("com.liketrek.trek.shortcuts")
|
||||
private(set) static var current: [Command] = []
|
||||
static var onPress: ((String) -> Void)?
|
||||
|
||||
static func set(_ commands: [Command]) {
|
||||
current = commands
|
||||
UIMenuSystem.main.setNeedsRebuild()
|
||||
}
|
||||
|
||||
/// The commands as one menu, without a title of its own: it joins the app's
|
||||
/// menu, where iPadOS lists the shortcuts under the app's name.
|
||||
static func menu() -> UIMenu {
|
||||
let children = current.map { command -> UIKeyCommand in
|
||||
let key = UIKeyCommand(
|
||||
title: command.title,
|
||||
action: #selector(TrekBridgeViewController.trekKeyCommand(_:)),
|
||||
input: command.input,
|
||||
modifierFlags: command.modifiers,
|
||||
propertyList: command.id
|
||||
)
|
||||
// ⌘[ and ⌘, belong to the app here, not to the web view's editing.
|
||||
key.wantsPriorityOverSystemBehavior = true
|
||||
return key
|
||||
}
|
||||
return UIMenu(title: "", identifier: menuIdentifier, options: .displayInline, children: children)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,14 +157,63 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
enum TrekServerStore {
|
||||
private static let key = "TrekServerURL"
|
||||
|
||||
private static let signInHostsKey = "TrekServerSignInHosts"
|
||||
|
||||
/// Setting the server drops the sign-in hosts of the one before it.
|
||||
static var url: String? {
|
||||
get { UserDefaults.standard.string(forKey: key) }
|
||||
set {
|
||||
if let newValue { UserDefaults.standard.set(newValue, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) }
|
||||
UserDefaults.standard.removeObject(forKey: signInHostsKey)
|
||||
try? FileManager.default.removeItem(at: offlineCopyURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// The hosts of a sign-in page in front of the server (a proxy such as
|
||||
/// Cloudflare Access or Pangolin), as the address screen found them. Only
|
||||
/// these load in the web view besides the server, so the proxy's session
|
||||
/// cookie is the web view's (TrekNavigationProxy), and none of them may
|
||||
/// talk to the app (TrekBridgeMessageGuard).
|
||||
static var signInHosts: Set<String> {
|
||||
get { Set((UserDefaults.standard.stringArray(forKey: signInHostsKey) ?? []).map { $0.lowercased() }) }
|
||||
set {
|
||||
if newValue.isEmpty {
|
||||
UserDefaults.standard.removeObject(forKey: signInHostsKey)
|
||||
} else {
|
||||
UserDefaults.standard.set(Array(newValue).sorted(), forKey: signInHostsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps one more sign-in host for the remembered server, as the web view met it (TrekSignInRedirect).
|
||||
static func addSignInHost(_ host: String) {
|
||||
var hosts = signInHosts
|
||||
guard hosts.insert(host.lowercased()).inserted else { return }
|
||||
signInHosts = hosts
|
||||
}
|
||||
|
||||
/// Whether `url` is one of the sign-in hosts, matched on host or host:port.
|
||||
static func isSignInHost(_ url: URL?) -> Bool {
|
||||
guard let url, ["https", "http"].contains(url.scheme?.lowercased() ?? ""), let host = url.host?.lowercased() else { return false }
|
||||
let hosts = signInHosts
|
||||
if hosts.contains(host) { return true }
|
||||
if let port = url.port { return hosts.contains("\(host):\(port)") }
|
||||
return false
|
||||
}
|
||||
|
||||
/// The one-time code of a TREK Cloud purchase (setServer's `welcome`). The
|
||||
/// server's first page opens its welcome page with it once, which signs the
|
||||
/// buyer in (TrekNavigationProxy). Kept in memory only, never on disk.
|
||||
static var pendingWelcome: String?
|
||||
|
||||
/// The welcome page for the pending code, handed out once.
|
||||
static func takeWelcomeURL() -> URL? {
|
||||
guard let code = pendingWelcome, let server = url else { return nil }
|
||||
pendingWelcome = nil
|
||||
let base = server.hasSuffix("/") ? server : server + "/"
|
||||
return URL(string: base + "api/managed/welcome#" + code)
|
||||
}
|
||||
|
||||
static var offlineCopyURL: URL {
|
||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
return support.appendingPathComponent("offline-start-page.html")
|
||||
@@ -285,12 +435,44 @@ final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sign-in page in front of the server that the address screen never saw.
|
||||
/// A proxy can let the app's check through (an Authelia or Authentik rule that
|
||||
/// bypasses /api) and still send the page itself to its sign-in. Handed to
|
||||
/// Safari, that sign-in leaves its session there and the app stays blank on
|
||||
/// every launch. So a redirect away from the server, while the server's first
|
||||
/// page is still loading, is taken as the proxy's sign-in: it loads in the web
|
||||
/// view and its host is kept, the way the address screen keeps the ones it saw.
|
||||
/// A link the user taps is no redirect, and once a page of the server has
|
||||
/// shown, nothing is learned any more.
|
||||
enum TrekSignInRedirect {
|
||||
/// The host (host:port with an explicit port) to keep as a sign-in host, or nil.
|
||||
static func learnedHost(server: String?, target: URL?, mainFrame: Bool, redirect: Bool, serverShown: Bool) -> String? {
|
||||
guard mainFrame, redirect, !serverShown,
|
||||
let server, let from = URL(string: server), let fromHost = from.host?.lowercased(),
|
||||
let target, let toHost = target.host?.lowercased(),
|
||||
let toScheme = target.scheme?.lowercased(), ["https", "http"].contains(toScheme) else { return nil }
|
||||
// Never from https down to plain http: the sign-in would send a password in the clear.
|
||||
if from.scheme?.lowercased() == "https", toScheme != "https" { return nil }
|
||||
guard toHost != fromHost else { return nil }
|
||||
if let port = target.port { return "\(toHost):\(port)" }
|
||||
return toHost
|
||||
}
|
||||
|
||||
/// Whether `url` is on the server's host, where the app's own pages come from.
|
||||
static func onServer(server: String?, url: URL?) -> Bool {
|
||||
guard let server, let host = URL(string: server)?.host?.lowercased() else { return false }
|
||||
return url?.host?.lowercased() == host
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands in front of Capacitor's navigation delegate and hands it everything
|
||||
/// but the failures of a navigation that another one replaced: a cancelled
|
||||
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
|
||||
/// are no fault, and Capacitor would answer them with its error page.
|
||||
final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
private let inner: WKNavigationDelegate
|
||||
/// A page of the server has shown since launch; from then on no sign-in host is learned (TrekSignInRedirect).
|
||||
private var serverShown = false
|
||||
|
||||
init(inner: WKNavigationDelegate) {
|
||||
self.inner = inner
|
||||
@@ -310,6 +492,88 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
(inner as AnyObject).responds(to: aSelector) ? inner : nil
|
||||
}
|
||||
|
||||
/// The sign-in page of a proxy in front of the server loads here instead of
|
||||
/// in Safari, so its session is the web view's and every request carries it.
|
||||
/// Only the hosts the address screen saw (TrekServerStore.signInHosts), and
|
||||
/// only in the main frame; everything else is Capacitor's decision as before.
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
if navigationAction.targetFrame?.isMainFrame == true, TrekServerStore.isSignInHost(navigationAction.request.url) {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
// WebKit asks again for every server redirect, as a navigation of type .other.
|
||||
if let host = TrekSignInRedirect.learnedHost(
|
||||
server: TrekServerStore.url,
|
||||
target: navigationAction.request.url,
|
||||
mainFrame: navigationAction.targetFrame?.isMainFrame == true,
|
||||
redirect: navigationAction.navigationType == .other,
|
||||
serverShown: serverShown
|
||||
) {
|
||||
TrekServerStore.addSignInHost(host)
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
let selector = NSSelectorFromString("webView:decidePolicyForNavigationAction:decisionHandler:")
|
||||
guard (inner as AnyObject).responds(to: selector) else {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
inner.webView?(webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler)
|
||||
}
|
||||
|
||||
/// After the server's first page, a pending welcome code opens the welcome
|
||||
/// page once (TrekServerStore.takeWelcomeURL); it is on the server's origin.
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
inner.webView?(webView, didFinish: navigation)
|
||||
if TrekSignInRedirect.onServer(server: TrekServerStore.url, url: webView.url) { serverShown = true }
|
||||
if let welcome = TrekServerStore.takeWelcomeURL() {
|
||||
webView.load(URLRequest(url: welcome))
|
||||
}
|
||||
}
|
||||
|
||||
/// A proxy in front of the server asks for a client certificate (mutual TLS):
|
||||
/// the stored one goes to the host and port it was imported for and nowhere
|
||||
/// else (TrekClientIdentity). Every other challenge is Capacitor's as before.
|
||||
func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||
if TrekClientIdentity.answer(challenge, completionHandler) {
|
||||
let space = challenge.protectionSpace
|
||||
TrekClientCertWatch.serverAsked(host: space.host, port: space.port) { [weak self, weak webView] in
|
||||
guard let self, let webView else { return }
|
||||
// Capacitor's own answer to a failed load: the address screen (errorPath).
|
||||
self.inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRejected))
|
||||
}
|
||||
return
|
||||
}
|
||||
let selector = NSSelectorFromString("webView:didReceiveAuthenticationChallenge:completionHandler:")
|
||||
guard (inner as AnyObject).responds(to: selector) else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
inner.webView?(webView, didReceive: challenge, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
/// A proxy that asked the page for a client certificate, got none and
|
||||
/// answered with its own refusal (Cloudflare's 403, nginx's 400) sends the
|
||||
/// app to the address screen, which says so and offers to add one, rather
|
||||
/// than leaving the proxy's error page up.
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||
if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse,
|
||||
http.statusCode >= 400, http.statusCode < 500, let asked = TrekClientIdentity.lastRequest, !asked.served,
|
||||
let url = http.url, url.host?.lowercased() == asked.host, (url.port ?? 443) == asked.port {
|
||||
decisionHandler(.cancel)
|
||||
inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRequired))
|
||||
return
|
||||
}
|
||||
let selector = NSSelectorFromString("webView:decidePolicyForNavigationResponse:decisionHandler:")
|
||||
guard (inner as AnyObject).responds(to: selector) else {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
inner.webView?(webView, decidePolicyFor: navigationResponse, decisionHandler: decisionHandler)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.isReplaced(error) { return }
|
||||
inner.webView?(webView, didFail: navigation, withError: error)
|
||||
@@ -320,3 +584,26 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands in front of Capacitor's bridge message handler and drops what a
|
||||
/// proxy's sign-in page posts: that page loads in the web view (see
|
||||
/// TrekNavigationProxy), and Capacitor itself would hand its messages to the
|
||||
/// plugins whatever page they come from.
|
||||
final class TrekBridgeMessageGuard: NSObject, WKScriptMessageHandler {
|
||||
static let name = "bridge"
|
||||
private let inner: WKScriptMessageHandler
|
||||
|
||||
init(inner: WKScriptMessageHandler) {
|
||||
self.inner = inner
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
let origin = message.frameInfo.securityOrigin
|
||||
var components = URLComponents()
|
||||
components.scheme = origin.protocol
|
||||
components.host = origin.host
|
||||
if origin.port != 0 { components.port = origin.port }
|
||||
if TrekServerStore.isSignInHost(components.url) { return }
|
||||
inner.userContentController(userContentController, didReceive: message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// The client certificate a user's TREK server asks for (mutual TLS): a proxy
|
||||
/// such as Cloudflare, nginx, Caddy or Traefik in front of TREK lets through
|
||||
/// only devices that show one. The app holds at most one, for one server.
|
||||
///
|
||||
/// It comes in as a PKCS#12 file (.p12, .pfx) and its password. The password is
|
||||
/// never kept; the identity goes into the keychain, this device only, readable
|
||||
/// after the first unlock (so a background refresh can show it too). Only the
|
||||
/// host and port it was imported for ever see it (`identity(forHost:port:)`), so a
|
||||
/// sign-in page, a map tile provider or liketrek.com never learns of it.
|
||||
enum TrekClientIdentity {
|
||||
private static let label = "trek-client-identity"
|
||||
private static let hostKey = "TrekClientIdentityHost"
|
||||
private static let portKey = "TrekClientIdentityPort"
|
||||
|
||||
enum Failure: String, Error {
|
||||
case password, format, noKey = "no_key", expired, notYetValid = "not_yet_valid", store
|
||||
}
|
||||
|
||||
/// The last server that asked for a certificate while the app ran, and whether it got one.
|
||||
struct Request {
|
||||
let host: String
|
||||
let port: Int
|
||||
let served: Bool
|
||||
}
|
||||
|
||||
private static let lock = NSLock()
|
||||
private static var _lastRequest: Request?
|
||||
static var lastRequest: Request? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return _lastRequest
|
||||
}
|
||||
|
||||
static func noteRequest(host: String, port: Int, served: Bool) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
_lastRequest = Request(host: host.lowercased(), port: port > 0 ? port : 443, served: served)
|
||||
}
|
||||
|
||||
// MARK: Reading a file
|
||||
|
||||
/// Opens a PKCS#12 file: the identity and the rest of its chain.
|
||||
static func read(_ data: Data, password: String, now: Date = Date()) -> Result<(SecIdentity, [SecCertificate]), Failure> {
|
||||
var items: CFArray?
|
||||
let status = SecPKCS12Import(data as CFData, [kSecImportExportPassphrase as String: password] as CFDictionary, &items)
|
||||
switch status {
|
||||
case errSecSuccess: break
|
||||
case errSecAuthFailed, errSecPkcs12VerifyFailure: return .failure(.password)
|
||||
default: return .failure(.format)
|
||||
}
|
||||
guard let first = (items as? [[String: Any]])?.first,
|
||||
let value = first[kSecImportItemIdentity as String] else { return .failure(.noKey) }
|
||||
let identity = value as! SecIdentity
|
||||
var leaf: SecCertificate?
|
||||
guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { return .failure(.noKey) }
|
||||
if let validity = validity(of: leaf) {
|
||||
if now > validity.notAfter { return .failure(.expired) }
|
||||
if now < validity.notBefore { return .failure(.notYetValid) }
|
||||
}
|
||||
let leafData = SecCertificateCopyData(leaf) as Data
|
||||
let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate] ?? []).filter {
|
||||
SecCertificateCopyData($0) as Data != leafData
|
||||
}
|
||||
return .success((identity, chain))
|
||||
}
|
||||
|
||||
// MARK: Keeping it
|
||||
|
||||
/// Keeps the identity for this host and port, replacing any other.
|
||||
static func save(_ identity: SecIdentity, chain: [SecCertificate], host: String, port: Int) -> Bool {
|
||||
remove()
|
||||
let query: [String: Any] = [
|
||||
kSecValueRef as String: identity,
|
||||
kSecAttrLabel as String: label,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { return false }
|
||||
// The chain's intermediates go along so a proxy that wants them gets them.
|
||||
let encoded = chain.map { SecCertificateCopyData($0) as Data }
|
||||
UserDefaults.standard.set(encoded, forKey: chainKey)
|
||||
UserDefaults.standard.set(host.lowercased(), forKey: hostKey)
|
||||
UserDefaults.standard.set(port, forKey: portKey)
|
||||
return true
|
||||
}
|
||||
|
||||
private static let chainKey = "TrekClientIdentityChain"
|
||||
|
||||
static func remove() {
|
||||
TrekClientCertWatch.reset()
|
||||
for itemClass in [kSecClassIdentity, kSecClassCertificate, kSecClassKey] {
|
||||
SecItemDelete([kSecClass as String: itemClass, kSecAttrLabel as String: label] as CFDictionary)
|
||||
}
|
||||
UserDefaults.standard.removeObject(forKey: hostKey)
|
||||
UserDefaults.standard.removeObject(forKey: portKey)
|
||||
UserDefaults.standard.removeObject(forKey: chainKey)
|
||||
}
|
||||
|
||||
private static func storedIdentity() -> SecIdentity? {
|
||||
var item: CFTypeRef?
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassIdentity,
|
||||
kSecAttrLabel as String: label,
|
||||
kSecReturnRef as String: true,
|
||||
]
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let item else { return nil }
|
||||
return (item as! SecIdentity)
|
||||
}
|
||||
|
||||
/// Whether a request to host and port (0 or less for https' default) goes to the server the identity is for.
|
||||
static func matches(host: String?, port: Int) -> Bool {
|
||||
guard let host, let stored = UserDefaults.standard.string(forKey: hostKey) else { return false }
|
||||
let storedPort = UserDefaults.standard.integer(forKey: portKey)
|
||||
return host.lowercased() == stored && (port > 0 ? port : 443) == (storedPort > 0 ? storedPort : 443)
|
||||
}
|
||||
|
||||
/// The credential for a server at host and port, or nil when the stored one is for another.
|
||||
static func credential(forHost host: String, port: Int) -> URLCredential? {
|
||||
guard matches(host: host, port: port), let identity = storedIdentity() else { return nil }
|
||||
let chain = (UserDefaults.standard.array(forKey: chainKey) as? [Data] ?? []).compactMap {
|
||||
SecCertificateCreateWithData(nil, $0 as CFData)
|
||||
}
|
||||
// Per session: a replaced certificate takes effect on the next connection.
|
||||
return URLCredential(identity: identity, certificates: chain.isEmpty ? nil : chain, persistence: .forSession)
|
||||
}
|
||||
|
||||
/// Answers a challenge if it asks for a client certificate; false leaves it to the caller.
|
||||
static func answer(_ challenge: URLAuthenticationChallenge, _ completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Bool {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate else { return false }
|
||||
let space = challenge.protectionSpace
|
||||
let credential = credential(forHost: space.host, port: space.port)
|
||||
noteRequest(host: space.host, port: space.port, served: credential != nil)
|
||||
if let credential {
|
||||
completion(.useCredential, credential)
|
||||
} else {
|
||||
// As a browser without a matching certificate: go on without one.
|
||||
completion(.performDefaultHandling, nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: What the settings show
|
||||
|
||||
static func info() -> [String: Any]? {
|
||||
guard let host = UserDefaults.standard.string(forKey: hostKey), let identity = storedIdentity() else { return nil }
|
||||
var leaf: SecCertificate?
|
||||
guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { return nil }
|
||||
let port = UserDefaults.standard.integer(forKey: portKey)
|
||||
var result: [String: Any] = [
|
||||
"host": host,
|
||||
"port": port > 0 ? port : 443,
|
||||
"subject": SecCertificateCopySubjectSummary(leaf) as String? ?? "",
|
||||
"issuer": issuerName(of: leaf) ?? "",
|
||||
]
|
||||
if let validity = validity(of: leaf) { result["notAfter"] = Int(validity.notAfter.timeIntervalSince1970 * 1000) }
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: Certificate fields
|
||||
|
||||
/// notBefore and notAfter from the DER: iOS has no public API for them.
|
||||
static func validity(of certificate: SecCertificate) -> (notBefore: Date, notAfter: Date)? {
|
||||
DER.validity(SecCertificateCopyData(certificate) as Data)
|
||||
}
|
||||
|
||||
private static func issuerName(of certificate: SecCertificate) -> String? {
|
||||
DER.issuerCommonName(SecCertificateCopyData(certificate) as Data)
|
||||
}
|
||||
|
||||
/// Just enough DER to read an X.509 certificate's validity and a name's CN.
|
||||
enum DER {
|
||||
private struct Node {
|
||||
let tag: UInt8
|
||||
let body: Range<Int>
|
||||
let end: Int
|
||||
}
|
||||
|
||||
private static func node(_ bytes: [UInt8], at offset: Int) -> Node? {
|
||||
guard offset + 2 <= bytes.count else { return nil }
|
||||
let tag = bytes[offset]
|
||||
var length = Int(bytes[offset + 1])
|
||||
var start = offset + 2
|
||||
if length & 0x80 != 0 {
|
||||
let count = length & 0x7f
|
||||
guard count > 0, count <= 4, start + count <= bytes.count else { return nil }
|
||||
length = bytes[start..<start + count].reduce(0) { $0 << 8 | Int($1) }
|
||||
start += count
|
||||
}
|
||||
guard start + length <= bytes.count else { return nil }
|
||||
return Node(tag: tag, body: start..<start + length, end: start + length)
|
||||
}
|
||||
|
||||
private static func children(_ bytes: [UInt8], in range: Range<Int>) -> [Node] {
|
||||
var result: [Node] = []
|
||||
var offset = range.lowerBound
|
||||
while offset < range.upperBound, let child = node(bytes, at: offset) {
|
||||
result.append(child)
|
||||
offset = child.end
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// The fields of the TBSCertificate after the version: serial, signature algorithm, issuer, validity, ...
|
||||
private static func tbsFields(_ bytes: [UInt8]) -> [Node]? {
|
||||
guard let certificate = node(bytes, at: 0), let tbs = children(bytes, in: certificate.body).first else { return nil }
|
||||
var fields = children(bytes, in: tbs.body)
|
||||
if fields.first?.tag == 0xa0 { fields.removeFirst() }
|
||||
return fields.count > 3 ? fields : nil
|
||||
}
|
||||
|
||||
static func validity(_ data: Data) -> (notBefore: Date, notAfter: Date)? {
|
||||
let bytes = [UInt8](data)
|
||||
guard let fields = tbsFields(bytes) else { return nil }
|
||||
let times = children(bytes, in: fields[3].body)
|
||||
guard times.count == 2, let from = time(bytes, times[0]), let to = time(bytes, times[1]) else { return nil }
|
||||
return (from, to)
|
||||
}
|
||||
|
||||
private static func time(_ bytes: [UInt8], _ node: Node) -> Date? {
|
||||
guard let text = String(bytes: bytes[node.body], encoding: .ascii) else { return nil }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||
formatter.dateFormat = node.tag == 0x17 ? "yyMMddHHmmss'Z'" : "yyyyMMddHHmmss'Z'"
|
||||
return formatter.date(from: text)
|
||||
}
|
||||
|
||||
/// The CN of the issuer's Name (a SEQUENCE of SETs of type and value), or nil.
|
||||
static func issuerCommonName(_ data: Data) -> String? {
|
||||
let bytes = [UInt8](data)
|
||||
guard let fields = tbsFields(bytes) else { return nil }
|
||||
for set in children(bytes, in: fields[2].body) {
|
||||
for pair in children(bytes, in: set.body) {
|
||||
let parts = children(bytes, in: pair.body)
|
||||
guard parts.count == 2, Array(bytes[parts[0].body]) == [0x55, 0x04, 0x03] else { continue }
|
||||
return String(bytes: bytes[parts[1].body], encoding: .utf8)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notices when the user's server stops letting the app in for want of a
|
||||
/// client certificate (one removed, expired, or no longer trusted). The page can
|
||||
/// still be the stored offline copy while every request fails, so when the server
|
||||
/// asks for a certificate the app checks once natively whether TREK answers with
|
||||
/// what it holds. Only a check that fails at the certificate (the handshake broke
|
||||
/// off, or the proxy's own 4xx) calls `onRefused`; a server that merely asks and
|
||||
/// lets everyone in answers TREK's 200. At most once a minute.
|
||||
enum TrekClientCertWatch {
|
||||
private static let lock = NSLock()
|
||||
private static var lastCheck = Date.distantPast
|
||||
|
||||
/// A new or removed certificate is worth a check of its own, whenever the last one ran.
|
||||
static func reset() {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
lastCheck = .distantPast
|
||||
}
|
||||
|
||||
static func serverAsked(host: String, port: Int, onRefused: @escaping () -> Void) {
|
||||
guard let raw = TrekServerStore.url, let server = URL(string: raw), server.scheme == "https",
|
||||
server.host?.lowercased() == host.lowercased(), (server.port ?? 443) == (port > 0 ? port : 443),
|
||||
let features = URL(string: raw.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/health/features") else { return }
|
||||
lock.lock()
|
||||
let due = Date().timeIntervalSince(lastCheck) >= 60
|
||||
if due { lastCheck = Date() }
|
||||
lock.unlock()
|
||||
guard due else { return }
|
||||
var request = URLRequest(url: features, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 8)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
var task: URLSessionDataTask?
|
||||
task = TrekClientCertSessionDelegate.session.dataTask(with: request) { _, response, _ in
|
||||
let asked = task.flatMap { TrekClientCertSessionDelegate.shared.ask(for: $0) } != nil
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if asked && (status == 0 || (400..<500).contains(status)) {
|
||||
DispatchQueue.main.async(execute: onRefused)
|
||||
}
|
||||
}
|
||||
task?.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// A URLSession delegate for the app's own requests to the user's server: it
|
||||
/// shows the client certificate where the server asks for it and notes the ask.
|
||||
final class TrekClientCertSessionDelegate: NSObject, URLSessionTaskDelegate {
|
||||
static let shared = TrekClientCertSessionDelegate()
|
||||
|
||||
/// The app's session for the user's server (offline copy, watch, address check).
|
||||
static let session = URLSession(configuration: .default, delegate: shared, delegateQueue: nil)
|
||||
|
||||
/// Per task: whether the server asked, and whether it got a certificate (address check only).
|
||||
private let lock = NSLock()
|
||||
private var asks: [Int: Bool] = [:]
|
||||
|
||||
func ask(for task: URLSessionTask) -> Bool? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return asks.removeValue(forKey: task.taskIdentifier)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||
let handled = TrekClientIdentity.answer(challenge) { disposition, credential in
|
||||
self.lock.lock()
|
||||
self.asks[task.taskIdentifier] = credential != nil
|
||||
self.lock.unlock()
|
||||
completionHandler(disposition, credential)
|
||||
}
|
||||
if !handled { completionHandler(.performDefaultHandling, nil) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The file picker and password prompt of an import, in the UI's words.
|
||||
final class TrekClientCertImporter: NSObject, UIDocumentPickerDelegate {
|
||||
private let presenter: UIViewController
|
||||
private let labels: [String: String]
|
||||
private let host: String
|
||||
private let port: Int
|
||||
private let done: ([String: Any]) -> Void
|
||||
private var keepAlive: TrekClientCertImporter?
|
||||
|
||||
init(presenter: UIViewController, labels: [String: String], host: String, port: Int, done: @escaping ([String: Any]) -> Void) {
|
||||
self.presenter = presenter
|
||||
self.labels = labels
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.done = done
|
||||
}
|
||||
|
||||
func start() {
|
||||
keepAlive = self
|
||||
let types = [UTType(filenameExtension: "p12"), UTType(filenameExtension: "pfx"), UTType("com.rsa.pkcs-12")].compactMap { $0 }
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types.isEmpty ? [.data] : types, asCopy: true)
|
||||
picker.delegate = self
|
||||
presenter.present(picker, animated: true)
|
||||
}
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
finish(["result": "cancelled"])
|
||||
}
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
guard let url = urls.first,
|
||||
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
(attributes[.size] as? Int ?? 0) <= 256 * 1024,
|
||||
let data = try? Data(contentsOf: url) else {
|
||||
finish(["result": "failed", "failure": TrekClientIdentity.Failure.format.rawValue])
|
||||
return
|
||||
}
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
askPassword(for: data, wrong: false)
|
||||
}
|
||||
|
||||
private func askPassword(for data: Data, wrong: Bool) {
|
||||
let alert = UIAlertController(title: labels["title"], message: wrong ? labels["wrongPassword"] : labels["message"], preferredStyle: .alert)
|
||||
alert.addTextField { field in
|
||||
field.isSecureTextEntry = true
|
||||
field.placeholder = self.labels["placeholder"]
|
||||
field.textContentType = .password
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: labels["cancel"] ?? "Cancel", style: .cancel) { _ in
|
||||
self.finish(["result": "cancelled"])
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: labels["ok"] ?? "OK", style: .default) { _ in
|
||||
self.open(data, password: alert.textFields?.first?.text ?? "")
|
||||
})
|
||||
presenter.present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func open(_ data: Data, password: String) {
|
||||
switch TrekClientIdentity.read(data, password: password) {
|
||||
case .failure(.password):
|
||||
askPassword(for: data, wrong: true)
|
||||
case .failure(let failure):
|
||||
finish(["result": "failed", "failure": failure.rawValue])
|
||||
case .success(let (identity, chain)):
|
||||
guard TrekClientIdentity.save(identity, chain: chain, host: host, port: port) else {
|
||||
finish(["result": "failed", "failure": TrekClientIdentity.Failure.store.rawValue])
|
||||
return
|
||||
}
|
||||
var result: [String: Any] = ["result": "imported"]
|
||||
if let info = TrekClientIdentity.info() { result["certificate"] = info }
|
||||
finish(result)
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ result: [String: Any]) {
|
||||
done(result)
|
||||
keepAlive = nil
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,13 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setKeyCommands", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setQuickActions", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "takeQuickAction", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "requestServer", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "clientCertificate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "importClientCertificate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "removeClientCertificate", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// What this binary offers the server's UI, raised with every native feature
|
||||
@@ -55,10 +62,19 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
|
||||
static let shellApi = 2
|
||||
/// The native features by name, so the UI can turn each on by itself.
|
||||
/// "watch" only where WatchConnectivity is (an iPhone, not an iPad), so the UI never builds a snapshot for nothing.
|
||||
/// "watch" only where WatchConnectivity is (an iPhone, not an iPad), so the UI never
|
||||
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
|
||||
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
|
||||
static var features: [String] {
|
||||
["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "wallet"]
|
||||
+ (TrekWatchLink.isAvailable ? ["watch"] : [])
|
||||
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate"]
|
||||
if canAddWalletPasses { names.append("wallet") }
|
||||
if TrekWatchLink.isAvailable { names.append("watch") }
|
||||
return names
|
||||
}
|
||||
|
||||
/// Whether this device can add a pass to Apple Wallet (an iPhone; not an iPad).
|
||||
static var canAddWalletPasses: Bool {
|
||||
PKPassLibrary.isPassLibraryAvailable() && PKAddPassesViewController.canAddPasses()
|
||||
}
|
||||
|
||||
// MARK: - Shared booking documents
|
||||
@@ -72,6 +88,47 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
}
|
||||
TrekStore.watchUpdates()
|
||||
TrekWatchLink.shared.onAction = { [weak self] in self?.notifyListeners("watchAction", data: [:]) }
|
||||
TrekKeyCommands.onPress = { [weak self] id in
|
||||
self?.notifyListeners("keyCommand", data: ["id": id])
|
||||
}
|
||||
TrekQuickActions.onOpen = { [weak self] path in
|
||||
DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path]) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Quick actions on the app icon
|
||||
|
||||
/// The app icon's quick actions, in the UI's language (TrekQuickActions).
|
||||
@objc func setQuickActions(_ call: CAPPluginCall) {
|
||||
let items = (call.getArray("items", JSObject.self) ?? []).compactMap { TrekQuickActions.Item(raw: $0) }
|
||||
DispatchQueue.main.async {
|
||||
TrekQuickActions.set(items)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/// The quick action the app was launched with, before the UI was there to hear of it.
|
||||
@objc func takeQuickAction(_ call: CAPPluginCall) {
|
||||
DispatchQueue.main.async {
|
||||
if let path = TrekQuickActions.take() {
|
||||
call.resolve(["path": path])
|
||||
} else {
|
||||
call.resolve(["path": NSNull()])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hardware keyboard
|
||||
|
||||
/// The UI's keyboard shortcuts for the screen it shows (mobile/keyboard),
|
||||
/// named in its language, as key commands in the app's menu.
|
||||
@objc func setKeyCommands(_ call: CAPPluginCall) {
|
||||
let raw = call.getArray("commands", JSObject.self) ?? []
|
||||
let commands = raw.compactMap { TrekKeyCommands.Command(raw: $0) }
|
||||
DispatchQueue.main.async {
|
||||
TrekKeyCommands.set(commands)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widgets and the travel-day Live Activity
|
||||
@@ -298,6 +355,12 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
return
|
||||
}
|
||||
TrekServerStore.url = raw
|
||||
// After the URL: setting it clears the hosts of the server before.
|
||||
let hosts = (call.getArray("signInHosts", String.self) ?? []).filter { $0.range(of: "^[A-Za-z0-9.-]+(:[0-9]+)?$", options: .regularExpression) != nil }
|
||||
TrekServerStore.signInHosts = Set(hosts.map { $0.lowercased() })
|
||||
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
|
||||
let welcome = call.getString("welcome") ?? ""
|
||||
TrekServerStore.pendingWelcome = welcome.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil ? welcome : nil
|
||||
call.resolve()
|
||||
restartBridge()
|
||||
}
|
||||
@@ -370,7 +433,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
}
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
||||
URLSession.shared.dataTask(with: request) { data, response, _ in
|
||||
// The app's session, so a server behind mutual TLS gets the client certificate.
|
||||
TrekClientCertSessionDelegate.session.dataTask(with: request) { data, response, _ in
|
||||
guard let data, let http = response as? HTTPURLResponse, http.statusCode == 200,
|
||||
http.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("text/html") == true,
|
||||
TrekServerStore.url == raw else {
|
||||
@@ -409,6 +473,86 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Client certificates (mutual TLS)
|
||||
|
||||
extension TrekShellPlugin {
|
||||
/// The address screen's check of a server, made natively so it notices
|
||||
/// whether the server asks for a client certificate and can show the stored
|
||||
/// one. A failed connection resolves with status 0 instead of rejecting.
|
||||
@objc func requestServer(_ call: CAPPluginCall) {
|
||||
guard let raw = call.getString("url"), let url = URL(string: raw),
|
||||
["https", "http"].contains(url.scheme?.lowercased() ?? ""), url.host != nil else {
|
||||
call.reject("A http(s) URL is required")
|
||||
return
|
||||
}
|
||||
let timeout = min(max(Double(call.getInt("timeoutMs") ?? 10000), 1000), 30000) / 1000
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
|
||||
request.setValue("application/json, text/html;q=0.9, */*;q=0.8", forHTTPHeaderField: "Accept")
|
||||
let delegate = TrekClientCertSessionDelegate.shared
|
||||
var task: URLSessionDataTask?
|
||||
task = TrekClientCertSessionDelegate.session.dataTask(with: request) { data, response, error in
|
||||
let asked = task.flatMap { delegate.ask(for: $0) }
|
||||
var result: [String: Any] = [
|
||||
"certRequested": asked != nil,
|
||||
"certSent": asked == true,
|
||||
"url": response?.url?.absoluteString ?? raw,
|
||||
]
|
||||
if asked != nil, let host = url.host?.lowercased() { result["certHost"] = "\(host):\(url.port ?? 443)" }
|
||||
if let http = response as? HTTPURLResponse {
|
||||
result["status"] = http.statusCode
|
||||
let body = (data ?? Data()).prefix(512 * 1024)
|
||||
result["data"] = String(data: body, encoding: .utf8) ?? ""
|
||||
var headers: [String: String] = [:]
|
||||
for (name, value) in http.allHeaderFields {
|
||||
if let name = name as? String, let value = value as? String { headers[name] = value }
|
||||
}
|
||||
result["headers"] = headers
|
||||
} else {
|
||||
result["status"] = 0
|
||||
result["data"] = ""
|
||||
if let error { result["error"] = (error as NSError).domain + ":" + String((error as NSError).code) }
|
||||
}
|
||||
call.resolve(result)
|
||||
}
|
||||
task?.resume()
|
||||
}
|
||||
|
||||
/// The stored client certificate's details, and the last server that asked for one.
|
||||
@objc func clientCertificate(_ call: CAPPluginCall) {
|
||||
var result: [String: Any] = [:]
|
||||
if let info = TrekClientIdentity.info() { result["certificate"] = info } else { result["certificate"] = NSNull() }
|
||||
if let asked = TrekClientIdentity.lastRequest {
|
||||
result["request"] = ["host": asked.host, "port": asked.port, "served": asked.served]
|
||||
}
|
||||
call.resolve(result)
|
||||
}
|
||||
|
||||
@objc func removeClientCertificate(_ call: CAPPluginCall) {
|
||||
TrekClientIdentity.remove()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
/// Picks a .p12/.pfx file, asks for its password until it opens or the user
|
||||
/// gives up, and keeps it for the server at host and port.
|
||||
@objc func importClientCertificate(_ call: CAPPluginCall) {
|
||||
guard let host = call.getString("host"), host.range(of: "^[A-Za-z0-9.-]+$", options: .regularExpression) != nil else {
|
||||
call.reject("A host is required")
|
||||
return
|
||||
}
|
||||
let port = call.getInt("port") ?? 443
|
||||
let labels = (call.getObject("labels") ?? [:]).compactMapValues { $0 as? String }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let presenter = self?.bridge?.viewController else {
|
||||
call.resolve(["result": "failed", "failure": TrekClientIdentity.Failure.store.rawValue])
|
||||
return
|
||||
}
|
||||
TrekClientCertImporter(presenter: presenter, labels: labels, host: host, port: port) { result in
|
||||
call.resolve(result)
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
|
||||
/// When-In-Use permission asked the first time.
|
||||
// MARK: - Apple Wallet
|
||||
|
||||
@@ -2,6 +2,7 @@ import CryptoKit
|
||||
import Foundation
|
||||
import UIKit
|
||||
import WatchConnectivity
|
||||
import WebKit
|
||||
|
||||
/// The phone's end of WatchConnectivity (feature "watch"). The UI hands over the
|
||||
/// watch's snapshot (client/src/native/watch.ts through TrekShell.setWatchSnapshot);
|
||||
@@ -93,6 +94,11 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
return result
|
||||
}
|
||||
|
||||
/// The snapshot the watch has now, as last published.
|
||||
func storedSnapshot() -> Data? {
|
||||
queue.sync { UserDefaults.standard.data(forKey: snapshotKey) }
|
||||
}
|
||||
|
||||
// MARK: Ticks from the watch
|
||||
|
||||
/// The ticks queued so far, oldest first, and the queue emptied.
|
||||
@@ -110,6 +116,25 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
return list
|
||||
}
|
||||
|
||||
/// While the app is in front, its UI writes the tick, through its offline queue
|
||||
/// as a tap would. In the background the WebView is asleep, so the tick is
|
||||
/// written to the server from here at once (TrekWatchWriter), and the trip's
|
||||
/// other devices hear of it over their WebSocket; only a write that fails is
|
||||
/// queued for the UI, which then tries again once it runs.
|
||||
private func receive(_ action: [String: Any], then done: @escaping () -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
guard UIApplication.shared.applicationState != .active else {
|
||||
self.enqueue(action)
|
||||
done()
|
||||
return
|
||||
}
|
||||
TrekWatchWriter.write(action) { written in
|
||||
if !written { self.enqueue(action) }
|
||||
done()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(_ action: [String: Any]) {
|
||||
queue.async {
|
||||
// A tick sent twice (the watch resends after a restart) replaces the earlier copy.
|
||||
@@ -145,11 +170,16 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
if let action = userInfo["action"] as? [String: Any] { enqueue(action) }
|
||||
if let action = userInfo["action"] as? [String: Any] { receive(action) {} }
|
||||
}
|
||||
|
||||
/// The watch asks for the latest snapshot when it opens while the phone is in reach.
|
||||
/// The watch asks for the latest snapshot when it opens while the phone is in
|
||||
/// reach, and sends its ticks this way while it is (WatchLink.send).
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
if let action = message["action"] as? [String: Any] {
|
||||
receive(action) { replyHandler(["queued": true]) }
|
||||
return
|
||||
}
|
||||
queue.async {
|
||||
if message["request"] as? String == "snapshot", let data = UserDefaults.standard.data(forKey: self.snapshotKey) {
|
||||
replyHandler(["snapshot": data])
|
||||
@@ -159,3 +189,211 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a tick from the watch straight to the TREK server, for when the app is
|
||||
/// in the background and its UI cannot. The same request the UI makes for a tap
|
||||
/// (PUT /api/trips/:trip/packing|todo/:item with the box's new state), with the
|
||||
/// session the WebView holds (its cookies) and the tick's id as the idempotency
|
||||
/// key, so a tick that also reaches the server another way is taken once.
|
||||
enum TrekWatchWriter {
|
||||
private final class BackgroundTask {
|
||||
var id = UIBackgroundTaskIdentifier.invalid
|
||||
|
||||
func end() {
|
||||
guard id != .invalid else { return }
|
||||
UIApplication.shared.endBackgroundTask(id)
|
||||
id = .invalid
|
||||
}
|
||||
}
|
||||
|
||||
/// Called on the main queue, as is `completion`.
|
||||
static func write(_ action: [String: Any], completion: @escaping (Bool) -> Void) {
|
||||
guard let request = Self.request(for: action) else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
let task = BackgroundTask()
|
||||
task.id = UIApplication.shared.beginBackgroundTask(withName: "trek.watch.tick") { task.end() }
|
||||
let finish: (Bool) -> Void = { written in
|
||||
DispatchQueue.main.async {
|
||||
completion(written)
|
||||
task.end()
|
||||
}
|
||||
}
|
||||
perform(request) { _, ok in finish(ok) }
|
||||
}
|
||||
|
||||
/// Sends a request with the WebView's session (its cookies for the server).
|
||||
/// Called on the main queue; `completion` runs on a background queue.
|
||||
static func perform(_ request: URLRequest, completion: @escaping (Data?, Bool) -> Void) {
|
||||
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||
guard let host = request.url?.host else { return completion(nil, false) }
|
||||
let session = cookies.filter { cookie in
|
||||
let domain = cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain
|
||||
return host == domain || host.hasSuffix("." + domain)
|
||||
}
|
||||
guard !session.isEmpty else { return completion(nil, false) }
|
||||
var signed = request
|
||||
for (field, value) in HTTPCookie.requestHeaderFields(with: session) {
|
||||
signed.setValue(value, forHTTPHeaderField: field)
|
||||
}
|
||||
TrekClientCertSessionDelegate.session.dataTask(with: signed) { data, response, error in
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
completion(data, error == nil && (200..<300).contains(status))
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's address for an API path, or nil without a server.
|
||||
static func url(_ path: [String]) -> URL? {
|
||||
guard let server = TrekServerStore.url, let base = URL(string: server),
|
||||
base.scheme == "https" || base.scheme == "http" else { return nil }
|
||||
return path.reduce(base.appendingPathComponent("api")) { $0.appendingPathComponent($1) }
|
||||
}
|
||||
|
||||
/// The write for a tick as the watch sends it, or nil for anything else.
|
||||
static func request(for action: [String: Any]) -> URLRequest? {
|
||||
guard let server = TrekServerStore.url, let base = URL(string: server),
|
||||
base.scheme == "https" || base.scheme == "http",
|
||||
let id = action["id"] as? String, !id.isEmpty, id.count <= 64,
|
||||
let tripId = action["tripId"] as? Int, let itemId = action["itemId"] as? Int,
|
||||
let list = action["list"] as? String, list == "packing" || list == "todo",
|
||||
let done = action["done"] as? Bool,
|
||||
let body = try? JSONSerialization.data(withJSONObject: ["checked": done]) else { return nil }
|
||||
let url = base.appendingPathComponent("api").appendingPathComponent("trips")
|
||||
.appendingPathComponent(String(tripId)).appendingPathComponent(list).appendingPathComponent(String(itemId))
|
||||
var request = URLRequest(url: url, timeoutInterval: 10)
|
||||
request.httpMethod = "PUT"
|
||||
request.httpShouldHandleCookies = false
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("watch-\(id)", forHTTPHeaderField: "X-Idempotency-Key")
|
||||
request.httpBody = body
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
/// Brings the watch's packing list and to-dos up to date while the app is in the
|
||||
/// background, woken by the server's silent push (WatchSyncPushService in TREK):
|
||||
/// reads both lists of the trip the watch shows from the server and puts them
|
||||
/// into the snapshot the watch has, built the way the UI builds them
|
||||
/// (watchPacking and watchTodos in client/src/native/watch.ts). Everything else
|
||||
/// in the snapshot stays as the UI last made it. In front, the UI does this
|
||||
/// itself as soon as the lists change, so nothing is done here then.
|
||||
enum TrekWatchRefresher {
|
||||
private static let maxName = 80
|
||||
private static let maxGroup = 40
|
||||
/// Below what WatchConnectivity carries in one application context.
|
||||
private static let maxBytes = 55_000
|
||||
|
||||
static func refresh(completion: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
guard TrekWatchLink.isAvailable, UIApplication.shared.applicationState != .active,
|
||||
let stored = TrekWatchLink.shared.storedSnapshot(),
|
||||
var snapshot = (try? JSONSerialization.jsonObject(with: stored)) as? [String: Any],
|
||||
let trip = snapshot["trip"] as? [String: Any], let tripId = trip["id"] as? Int,
|
||||
let packingURL = TrekWatchWriter.url(["trips", String(tripId), "packing"]),
|
||||
let todoURL = TrekWatchWriter.url(["trips", String(tripId), "todo"]) else {
|
||||
return completion(.noData)
|
||||
}
|
||||
let group = DispatchGroup()
|
||||
var packing: [[String: Any]]?
|
||||
var todos: [[String: Any]]?
|
||||
group.enter()
|
||||
items(at: packingURL) { list in
|
||||
packing = list
|
||||
group.leave()
|
||||
}
|
||||
group.enter()
|
||||
items(at: todoURL) { list in
|
||||
todos = list
|
||||
group.leave()
|
||||
}
|
||||
group.notify(queue: .main) {
|
||||
guard let packing, let todos else { return completion(.failed) }
|
||||
snapshot["packing"] = packingList(packing)
|
||||
snapshot["todos"] = todoList(todos)
|
||||
snapshot["generatedAt"] = ISO8601DateFormatter().string(from: Date())
|
||||
guard let data = fitted(snapshot) else { return completion(.failed) }
|
||||
TrekWatchLink.shared.publish(data)
|
||||
completion(.newData)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `items` of a list endpoint, on the main queue; nil when it could not be read.
|
||||
private static func items(at url: URL, then completion: @escaping ([[String: Any]]?) -> Void) {
|
||||
var request = URLRequest(url: url, timeoutInterval: 10)
|
||||
request.httpShouldHandleCookies = false
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
TrekWatchWriter.perform(request) { data, ok in
|
||||
var list: [[String: Any]]?
|
||||
if ok, let data, let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
|
||||
list = body["items"] as? [[String: Any]]
|
||||
}
|
||||
DispatchQueue.main.async { completion(list) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func done(_ item: [String: Any]) -> Bool {
|
||||
(item["checked"] as? Bool) ?? ((item["checked"] as? Int).map { $0 != 0 } ?? false)
|
||||
}
|
||||
|
||||
private static func trimmed(_ value: Any?, _ max: Int) -> String? {
|
||||
guard let text = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil }
|
||||
return String(text.prefix(max))
|
||||
}
|
||||
|
||||
private static func entry(_ item: [String: Any]) -> [String: Any]? {
|
||||
guard let id = item["id"] as? Int, let name = item["name"] as? String else { return nil }
|
||||
var out: [String: Any] = ["id": id, "name": String(name.prefix(maxName)), "done": done(item)]
|
||||
if let group = trimmed(item["category"], maxGroup) { out["group"] = group }
|
||||
return out
|
||||
}
|
||||
|
||||
/// Unpacked first, then packed, each in the list's own order.
|
||||
static func packingList(_ items: [[String: Any]]) -> Any {
|
||||
guard !items.isEmpty else { return NSNull() }
|
||||
let sorted = items.sorted {
|
||||
let (a, b) = (done($0) ? 1 : 0, done($1) ? 1 : 0)
|
||||
return a != b ? a < b : (($0["sort_order"] as? Int) ?? 0) < (($1["sort_order"] as? Int) ?? 0)
|
||||
}
|
||||
let packed = items.filter { done($0) }.count
|
||||
return ["items": sorted.compactMap(entry), "packed": packed, "total": items.count]
|
||||
}
|
||||
|
||||
/// Open ones first by due date (undated last) and priority, then the done ones.
|
||||
static func todoList(_ items: [[String: Any]]) -> Any {
|
||||
guard !items.isEmpty else { return NSNull() }
|
||||
let due = { (item: [String: Any]) -> String? in trimmed(item["due_date"], 10) }
|
||||
let sorted = items.sorted { a, b in
|
||||
if done(a) != done(b) { return !done(a) }
|
||||
if (due(a) == nil) != (due(b) == nil) { return due(a) != nil }
|
||||
if let da = due(a), let db = due(b), da != db { return da < db }
|
||||
let (pa, pb) = ((a["priority"] as? Int) ?? 0, (b["priority"] as? Int) ?? 0)
|
||||
if pa != pb { return pa > pb }
|
||||
return ((a["sort_order"] as? Int) ?? 0) < ((b["sort_order"] as? Int) ?? 0)
|
||||
}
|
||||
let rows = sorted.compactMap { item -> [String: Any]? in
|
||||
guard var row = entry(item) else { return nil }
|
||||
if let date = due(item) { row["due"] = date }
|
||||
if let priority = item["priority"] as? Int, priority > 0 { row["priority"] = min(3, priority) }
|
||||
return row
|
||||
}
|
||||
return ["items": rows, "open": items.filter { !done($0) }.count, "total": items.count]
|
||||
}
|
||||
|
||||
/// The snapshot as JSON within the size the watch takes: done items go first, as on the UI's side.
|
||||
private static func fitted(_ snapshot: [String: Any]) -> Data? {
|
||||
var object = snapshot
|
||||
// A list that is now empty leaves the snapshot, as the UI leaves it out.
|
||||
for key in ["packing", "todos"] where object[key] is NSNull { object.removeValue(forKey: key) }
|
||||
guard var data = try? JSONSerialization.data(withJSONObject: object) else { return nil }
|
||||
if data.count > maxBytes {
|
||||
for key in ["packing", "todos"] {
|
||||
guard var list = object[key] as? [String: Any], let items = list["items"] as? [[String: Any]] else { continue }
|
||||
list["items"] = items.filter { ($0["done"] as? Bool) != true }
|
||||
object[key] = list
|
||||
}
|
||||
data = (try? JSONSerialization.data(withJSONObject: object)) ?? data
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2"),
|
||||
.package(name: "CapacitorActionSheet", path: "../../../../node_modules/@capacitor/action-sheet"),
|
||||
.package(name: "CapacitorApp", path: "../../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorDialog", path: "../../../../node_modules/@capacitor/dialog"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorHaptics", path: "../../../../node_modules/@capacitor/haptics"),
|
||||
.package(name: "CapacitorKeyboard", path: "../../../../node_modules/@capacitor/keyboard"),
|
||||
@@ -26,7 +28,9 @@ let package = Package(
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorActionSheet", package: "CapacitorActionSheet"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorDialog", package: "CapacitorDialog"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
|
||||
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
|
||||
|
||||
@@ -54,6 +54,10 @@ enum ShareHandoff {
|
||||
static func store(_ item: ShareItem, tripId: Int?) -> Bool {
|
||||
var meta: [String: Any] = ["name": item.name, "mimeType": item.mimeType]
|
||||
if let tripId { meta["tripId"] = tripId }
|
||||
if let url = item.url {
|
||||
meta["url"] = url
|
||||
meta["title"] = item.title
|
||||
}
|
||||
guard let metaData = try? JSONSerialization.data(withJSONObject: meta) else { return false }
|
||||
return write(dataAccount, item.data) && write(metaAccount, metaData)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ struct ShareItem {
|
||||
var title: String
|
||||
/// A host, for a page.
|
||||
var source: String?
|
||||
/// The web address it came from, when it is a page or a link: the app offers to
|
||||
/// keep it among the trip's links as well as to read a booking out of it.
|
||||
var url: String? = nil
|
||||
|
||||
var sizeLabel: String {
|
||||
ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
|
||||
@@ -88,6 +91,10 @@ enum ShareLoader {
|
||||
let text = (item as? String) ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw ShareLoadError.unsupported }
|
||||
// A text that is just a link (what many apps share) is that link.
|
||||
if !trimmed.contains(where: \.isWhitespace), let url = URL(string: trimmed), webAddress(url) != nil {
|
||||
return try await page(at: url)
|
||||
}
|
||||
let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed
|
||||
return try checked(ShareItem(kind: .text, name: "shared-text.txt", mimeType: "text/plain",
|
||||
data: Data(trimmed.utf8), title: String(firstLine.prefix(80)), source: nil))
|
||||
@@ -133,21 +140,39 @@ enum ShareLoader {
|
||||
let url = (results["url"] as? String).flatMap(URL.init(string:))
|
||||
let title = (results["title"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? url?.host ?? "Page"
|
||||
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
|
||||
data: Data(html.utf8), title: title, source: url?.host))
|
||||
data: Data(html.utf8), title: title, source: url?.host,
|
||||
url: url.flatMap(webAddress)))
|
||||
}
|
||||
|
||||
/// A link shared from an app other than Safari: the page as the server sends it.
|
||||
/// A link shared from an app other than Safari: the page as the server sends it,
|
||||
/// or the bare link when the page cannot be read (a sign-in wall, an app-only
|
||||
/// page, no network), which the app can still keep among the trip's links.
|
||||
private static func page(at url: URL) async throws -> ShareItem {
|
||||
guard url.scheme == "https" || url.scheme == "http" else { throw ShareLoadError.unsupported }
|
||||
guard let address = webAddress(url) else { throw ShareLoadError.unsupported }
|
||||
var request = URLRequest(url: url, timeoutInterval: 10)
|
||||
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||
(http.mimeType ?? "").contains("html") else { throw ShareLoadError.unsupported }
|
||||
let html = String(decoding: data, as: UTF8.self)
|
||||
let title = pageTitle(html) ?? url.host ?? "Page"
|
||||
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
|
||||
data: data, title: title, source: url.host))
|
||||
if let fetched = try? await URLSession.shared.data(for: request),
|
||||
let http = fetched.1 as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||
(http.mimeType ?? "").contains("html"), !fetched.0.isEmpty, fetched.0.count <= maxBytes {
|
||||
let data = fetched.0
|
||||
let html = String(decoding: data, as: UTF8.self)
|
||||
let title = pageTitle(html) ?? url.host ?? "Page"
|
||||
return ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
|
||||
data: data, title: title, source: url.host, url: address)
|
||||
}
|
||||
return link(url, address)
|
||||
}
|
||||
|
||||
/// The link alone, as a text/uri-list the app recognises as nothing to read a booking from.
|
||||
private static func link(_ url: URL, _ address: String) -> ShareItem {
|
||||
ShareItem(kind: .page, name: "link.url", mimeType: "text/uri-list", data: Data(address.utf8),
|
||||
title: url.host ?? address, source: url.host, url: address)
|
||||
}
|
||||
|
||||
/// An http(s) address, the only kind a trip's links take.
|
||||
private static func webAddress(_ url: URL) -> String? {
|
||||
guard url.scheme == "https" || url.scheme == "http", url.host != nil else { return nil }
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
private static func fromFile(_ url: URL) throws -> ShareItem {
|
||||
|
||||
@@ -6,16 +6,24 @@ import SwiftUI
|
||||
struct ShareView: View {
|
||||
@ObservedObject var model: ShareModel
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
@Environment(\.horizontalSizeClass) private var widthClass
|
||||
@State private var shown = false
|
||||
|
||||
/// An iPad window of regular width: the card stands centred over the dimmed
|
||||
/// app, as a form does there, instead of rising from the bottom edge. A
|
||||
/// narrow Split View or Slide Over window keeps the iPhone's bottom card.
|
||||
private var centred: Bool { widthClass == .regular }
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Color.black.opacity(shown ? 0.28 : 0)
|
||||
ZStack(alignment: centred ? .center : .bottom) {
|
||||
Color.black.opacity(shown ? (centred ? 0.36 : 0.28) : 0)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture { dismiss() }
|
||||
|
||||
card
|
||||
.offset(y: shown ? 0 : 520)
|
||||
.offset(y: shown ? 0 : (centred ? 40 : 520))
|
||||
.opacity(centred && !shown ? 0 : 1)
|
||||
.padding(.vertical, centred ? 24 : 0)
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.86)) { shown = true }
|
||||
@@ -31,14 +39,17 @@ struct ShareView: View {
|
||||
|
||||
private var card: some View {
|
||||
VStack(spacing: 0) {
|
||||
Capsule()
|
||||
.fill(ShareStyle.faint(scheme).opacity(0.5))
|
||||
.frame(width: 38, height: 5)
|
||||
.padding(.top, 8)
|
||||
// The grabber belongs to a card that rises from the bottom edge.
|
||||
if !centred {
|
||||
Capsule()
|
||||
.fill(ShareStyle.faint(scheme).opacity(0.5))
|
||||
.frame(width: 38, height: 5)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
header
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 14)
|
||||
.padding(.top, centred ? 22 : 14)
|
||||
|
||||
Group {
|
||||
switch model.phase {
|
||||
@@ -50,7 +61,7 @@ struct ShareView: View {
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
.padding(.bottom, centred ? 20 : 12)
|
||||
.frame(maxWidth: 560)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
||||
@@ -60,9 +71,9 @@ struct ShareView: View {
|
||||
.strokeBorder(ShareStyle.edge(scheme), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.25), radius: 30, y: 10)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
.ignoresSafeArea(edges: centred ? [] : .bottom)
|
||||
)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.horizontal, centred ? 24 : 6)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
|
||||
@@ -9,6 +9,24 @@ import UIKit
|
||||
final class ShareViewController: UIViewController {
|
||||
private let model = ShareModel()
|
||||
|
||||
// On an iPad the system would put the extension in a form sheet, a box in the
|
||||
// middle of the screen with this sheet's own dimming and card inside it. Over
|
||||
// the full screen instead, the card stands centred over the app that shared,
|
||||
// the way an iPad presents a small form.
|
||||
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
||||
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||||
presentOverFullScreenOnPad()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
presentOverFullScreenOnPad()
|
||||
}
|
||||
|
||||
private func presentOverFullScreenOnPad() {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad { modalPresentationStyle = .overFullScreen }
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .clear
|
||||
|
||||
@@ -100,9 +100,27 @@ final class WatchLink: NSObject, WCSessionDelegate {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
}
|
||||
|
||||
/// A tick to the phone. In reach it goes as a message, which arrives at once and
|
||||
/// wakes the phone app for it, so the trip's other devices see it within seconds.
|
||||
/// Out of reach, or when the message fails or a phone app too old to take it
|
||||
/// answers without "queued", it goes as user info, which the system delivers later.
|
||||
func send(_ action: PendingAction) {
|
||||
guard WCSession.default.activationState == .activated else { return }
|
||||
WCSession.default.transferUserInfo(["action": action.message])
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated else { return }
|
||||
let message: [String: Any] = ["action": action.message]
|
||||
guard session.isReachable else {
|
||||
session.transferUserInfo(message)
|
||||
return
|
||||
}
|
||||
session.sendMessage(message, replyHandler: { reply in
|
||||
if reply["queued"] as? Bool == true {
|
||||
Task { @MainActor in WatchModel.shared.markSent(action.id) }
|
||||
} else {
|
||||
session.transferUserInfo(message)
|
||||
}
|
||||
}, errorHandler: { _ in
|
||||
session.transferUserInfo(message)
|
||||
})
|
||||
}
|
||||
|
||||
/// Asks the phone for a fresh snapshot while it is in reach (the app answers from what it stored).
|
||||
|
||||
@@ -44,9 +44,16 @@ struct AtlasContent: View {
|
||||
Text("\(Image(systemName: "globe.europe.africa.fill")) \(atlas?.count ?? 0) \(labels?.countries ?? "")")
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
|
||||
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
|
||||
// The word goes under the number where both do not fit on one line (an iPad mini).
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
|
||||
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: -2) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
|
||||
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
|
||||
}
|
||||
}
|
||||
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
|
||||
Spacer(minLength: 6)
|
||||
@@ -71,18 +78,15 @@ struct AtlasContent: View {
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .lastTextBaseline, spacing: 6) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(40, .heavy)).foregroundStyle(ink)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(labels?.countries ?? "").font(.system(size: 14, weight: .heavy)).foregroundStyle(ink)
|
||||
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
if let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) }
|
||||
if let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) }
|
||||
// The trips and days step aside before the share of the world is cut (an iPad mini).
|
||||
ViewThatFits(in: .horizontal) {
|
||||
largeHeader(atlas, labels, stats: true)
|
||||
largeHeader(atlas, labels, stats: false)
|
||||
}
|
||||
// The map takes what the rows leave, so a smaller large widget (an
|
||||
// iPad mini's) keeps the continents and the latest countries whole.
|
||||
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 2.2)
|
||||
.frame(height: 150)
|
||||
.frame(minHeight: 60, maxHeight: 150)
|
||||
ContinentBars(continents: atlas?.continents ?? [], scheme: scheme)
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array((atlas?.recent ?? []).prefix(3).enumerated()), id: \.offset) { index, country in
|
||||
@@ -94,6 +98,19 @@ struct AtlasContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func largeHeader(_ atlas: WidgetSnapshot.Atlas?, _ labels: WidgetSnapshot.Labels?, stats: Bool) -> some View {
|
||||
HStack(alignment: .lastTextBaseline, spacing: 6) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(40, .heavy)).foregroundStyle(ink)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(labels?.countries ?? "").font(.system(size: 14, weight: .heavy)).foregroundStyle(ink)
|
||||
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
if stats, let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) }
|
||||
if stats, let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) }
|
||||
}
|
||||
}
|
||||
|
||||
private var ink: Color { Trek.ink(scheme) }
|
||||
private var muted: Color { Trek.muted(scheme) }
|
||||
private var glow: [Color] { [Color(hex: 0x3DDC97), Color(hex: 0x4A7DDB)] }
|
||||
|
||||
@@ -29,6 +29,7 @@ struct BookingsContent: View {
|
||||
switch family {
|
||||
case .small: hero(first, labels)
|
||||
case .medium: list(Array(bookings.prefix(2)), labels, featureFirst: true)
|
||||
case .extraLarge: wide(bookings, labels)
|
||||
default: list(Array(bookings.prefix(5)), labels, featureFirst: true)
|
||||
}
|
||||
} else {
|
||||
@@ -74,7 +75,8 @@ struct BookingsContent: View {
|
||||
// MARK: Medium and large: a list, the first one featured
|
||||
|
||||
private func list(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels, featureFirst: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: family == .large ? 8 : 7) {
|
||||
// The extra large's list stands beside a card as tall as the widget: its rows space out to match.
|
||||
VStack(alignment: .leading, spacing: family == .extraLarge ? 14 : family.isTall ? 8 : 7) {
|
||||
HStack {
|
||||
Text(labels.bookings ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
@@ -89,7 +91,33 @@ struct BookingsContent: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if family == .large { Spacer(minLength: 0) }
|
||||
if family.isTall { Spacer(minLength: 0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Extra large (iPad): the next one as a card, the rest beside it
|
||||
|
||||
private func wide(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
let first = bookings[0]
|
||||
let look = Trek.booking(first.kind)
|
||||
let rest = Array(bookings.dropFirst().prefix(5))
|
||||
return HStack(alignment: .top, spacing: 18) {
|
||||
RowLink(path: first.path) {
|
||||
hero(first, labels)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(LinearGradient(colors: [look.color.opacity(scheme == .dark ? 0.24 : 0.14), look.color.opacity(scheme == .dark ? 0.08 : 0.04)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: 18, style: .continuous).stroke(look.color.opacity(0.25), lineWidth: 0.8))
|
||||
}
|
||||
.frame(maxWidth: rest.isEmpty ? CGFloat.infinity : 240)
|
||||
if !rest.isEmpty {
|
||||
list(rest, labels, featureFirst: false)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ extension TrekFamily {
|
||||
switch family {
|
||||
case .systemSmall: self = .small
|
||||
case .systemMedium: self = .medium
|
||||
case .systemExtraLarge: self = .extraLarge
|
||||
case .accessoryCircular: self = .circular
|
||||
case .accessoryRectangular: self = .rectangular
|
||||
case .accessoryInline: self = .inline
|
||||
@@ -92,7 +93,8 @@ struct NextTripWidget: Widget {
|
||||
}
|
||||
.configurationDisplayName("widget.trip.name")
|
||||
.description("widget.trip.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||
// systemExtraLarge is the iPad's; an iPhone leaves it out of its gallery.
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +125,7 @@ struct TodayWidget: Widget {
|
||||
}
|
||||
.configurationDisplayName("widget.today.name")
|
||||
.description("widget.today.description")
|
||||
.supportedFamilies([.systemMedium, .systemLarge])
|
||||
.supportedFamilies([.systemMedium, .systemLarge, .systemExtraLarge])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +148,7 @@ struct BookingsWidget: Widget {
|
||||
}
|
||||
.configurationDisplayName("widget.bookings.name")
|
||||
.description("widget.bookings.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, .accessoryInline])
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,55 +14,49 @@ struct TodayContent: View {
|
||||
let total = today.total ?? snapshot.tripLength ?? 1
|
||||
let timed = today.stops.filter { $0.minutes != nil }
|
||||
let done = timed.filter { ($0.minutes ?? 0) < nowMinutes }.count
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
DayBadge(index: today.index ?? 1, total: total)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(trip.title).trekEyebrow(scheme)
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: family == .large ? 19 : 16, weight: .heavy))
|
||||
.foregroundStyle(Trek.ink(scheme))
|
||||
.lineLimit(1)
|
||||
if family == .extraLarge {
|
||||
wide(snapshot, trip, today, total: total, done: done, timed: timed.count, nowMinutes: nowMinutes)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// On a narrow widget (an iPad mini's) the weather shrinks to its
|
||||
// chip, then steps aside, before the trip's name is cut short.
|
||||
ViewThatFits(in: .horizontal) {
|
||||
header(trip, today, total: total, weather: family == .large ? .full : .chip)
|
||||
header(trip, today, total: total, weather: .chip)
|
||||
header(trip, today, total: total, weather: .hidden)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather {
|
||||
if family == .large {
|
||||
PaperWeather(weather: weather)
|
||||
} else {
|
||||
WeatherChip(weather: weather)
|
||||
if family == .large {
|
||||
DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 14)
|
||||
} else {
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
StopList(
|
||||
stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 7 : 3,
|
||||
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
|
||||
)
|
||||
if family == .large {
|
||||
Spacer(minLength: 8)
|
||||
if let booking = snapshot.bookings?.first {
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
if family == .large {
|
||||
DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 14)
|
||||
} else {
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
StopList(
|
||||
stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 7 : 3,
|
||||
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
|
||||
)
|
||||
if family == .large {
|
||||
Spacer(minLength: 8)
|
||||
if let booking = snapshot.bookings?.first {
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let snapshot, let trip = snapshot.trip {
|
||||
// Before the trip there is no day yet: count down to it instead.
|
||||
let days = max(snapshot.daysUntilStart(from: now) ?? 0, 0)
|
||||
let roomy = family == .extraLarge
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(snapshot.labels.nextTrip).trekEyebrow(scheme)
|
||||
Text(trip.title).font(.system(size: 17, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
|
||||
Text(trip.title).font(.system(size: roomy ? 24 : 17, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(days)").font(Trek.number(40, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
Text(snapshot.labels.days ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
Text("\(days)").font(Trek.number(roomy ? 56 : 40, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
Text(snapshot.labels.days ?? "").font(.system(size: roomy ? 16 : 13, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: Trek.ink(scheme)).frame(height: 24)
|
||||
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: Trek.ink(scheme)).frame(height: roomy ? 34 : 24)
|
||||
}
|
||||
} else {
|
||||
EmptyTrip(text: snapshot?.labels.noTrip ?? "", family: family)
|
||||
@@ -70,6 +64,66 @@ struct TodayContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
extension TodayContent {
|
||||
enum HeaderWeather { case full, chip, hidden }
|
||||
|
||||
/// The day's badge, the trip and the day, and the weather as much as fits.
|
||||
func header(_ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today, total: Int, weather: HeaderWeather) -> some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
DayBadge(index: today.index ?? 1, total: total)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(trip.title).trekEyebrow(scheme)
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: family == .medium ? 16 : 19, weight: .heavy))
|
||||
.foregroundStyle(Trek.ink(scheme))
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let forecast = snapshot?.weather {
|
||||
switch weather {
|
||||
case .full: PaperWeather(weather: forecast)
|
||||
case .chip: WeatherChip(weather: forecast)
|
||||
case .hidden: EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The iPad's extra large: the day, its progress and the weather beside
|
||||
/// the day's timeline, and the next two bookings across the foot.
|
||||
func wide(
|
||||
_ snapshot: WidgetSnapshot, _ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today,
|
||||
total: Int, done: Int, timed: Int, nowMinutes: Int
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 22) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header(trip, today, total: total, weather: .hidden)
|
||||
DayProgress(done: done, total: timed, scheme: scheme, label: snapshot.labels.allDone)
|
||||
.padding(.top, 12)
|
||||
if let weather = snapshot.weather {
|
||||
PaperWeather(weather: weather).padding(.top, 16)
|
||||
}
|
||||
}
|
||||
.frame(width: 250)
|
||||
StopList(
|
||||
stops: today.stops, nowMinutes: nowMinutes, limit: 6,
|
||||
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
Spacer(minLength: 10)
|
||||
if let bookings = snapshot.bookings, !bookings.isEmpty {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(Array(bookings.prefix(2).enumerated()), id: \.offset) { _, booking in
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's number in a dark tile with the trip's length under it.
|
||||
struct DayBadge: View {
|
||||
let index: Int
|
||||
@@ -142,3 +196,64 @@ struct DayProgress: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The travel day as its Live Activity shows it on the lock screen (iPhone and
|
||||
/// iPad): trip and day on top, the next stop large, the day's stops as
|
||||
/// segments. Plain values rather than the activity's attributes, so the
|
||||
/// preview renderer can draw it at every width the lock screen gives it.
|
||||
struct TripDayBanner: View {
|
||||
let tripTitle: String
|
||||
let dayLabel: String
|
||||
let nextName: String
|
||||
let nextTime: String?
|
||||
let done: Int
|
||||
let total: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
|
||||
Circle()
|
||||
.trim(from: 0, to: total > 0 ? max(0.02, Double(done) / Double(total)) : 0)
|
||||
.stroke(
|
||||
AngularGradient(colors: [Trek.confirmed.opacity(0.7), Trek.confirmed], center: .center),
|
||||
style: StrokeStyle(lineWidth: 5, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(tripTitle)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.tracking(0.5)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(dayLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1).minimumScaleFactor(0.8)
|
||||
Spacer(minLength: 4)
|
||||
if let nextTime {
|
||||
Text(nextTime).font(Trek.number(18, .heavy)).foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
DayStopProgress(done: done, total: total).padding(.top, 3)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's timed stops as segments, those behind filled (the Live Activity's bar).
|
||||
struct DayStopProgress: View {
|
||||
let done: Int
|
||||
let total: Int
|
||||
|
||||
var body: some View {
|
||||
DaySegments(index: done + 1, total: max(total, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,22 @@ import Foundation
|
||||
/// so the views also render outside a widget (the preview renderer in
|
||||
/// client/ios/WidgetPreviews draws every size to a PNG on the CI's Mac).
|
||||
enum TrekFamily: String, CaseIterable {
|
||||
case small, medium, large, circular, rectangular, inline
|
||||
case small, medium, large, extraLarge, circular, rectangular, inline
|
||||
|
||||
var isAccessory: Bool { self == .circular || self == .rectangular || self == .inline }
|
||||
|
||||
/// The point size of the family on a 6.1" iPhone, for the renderer.
|
||||
/// A home-screen widget as tall as a large one: the large and the iPad's
|
||||
/// extra large, which have room for a list with a foot under it.
|
||||
var isTall: Bool { self == .large || self == .extraLarge }
|
||||
|
||||
/// The point size of the family on a 6.1" iPhone, for the renderer. The
|
||||
/// extra large exists on an iPad only; its size is that of an 11" iPad.
|
||||
var previewSize: CGSize {
|
||||
switch self {
|
||||
case .small: return CGSize(width: 170, height: 170)
|
||||
case .medium: return CGSize(width: 364, height: 170)
|
||||
case .large: return CGSize(width: 364, height: 382)
|
||||
case .extraLarge: return CGSize(width: 715.5, height: 342)
|
||||
case .circular: return CGSize(width: 76, height: 76)
|
||||
case .rectangular: return CGSize(width: 172, height: 76)
|
||||
case .inline: return CGSize(width: 250, height: 26)
|
||||
@@ -21,6 +27,34 @@ enum TrekFamily: String, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The iPads the renderer draws the home-screen widgets for, with the sizes
|
||||
/// iPadOS gives each family there (Human Interface Guidelines, Widgets). An
|
||||
/// iPad mini's small widget is smaller than an iPhone's, a 13" iPad's medium
|
||||
/// and large are wider.
|
||||
enum TrekPad: String, CaseIterable {
|
||||
case ipad13, ipad11, ipadMini
|
||||
|
||||
/// The small widget's side, the medium's and large's width, the extra large's width.
|
||||
private var metrics: (small: CGFloat, wide: CGFloat, extraWide: CGFloat) {
|
||||
switch self {
|
||||
case .ipad13: return (170, 378.5, 795)
|
||||
case .ipad11: return (155, 342, 715.5)
|
||||
case .ipadMini: return (141, 305.5, 634.5)
|
||||
}
|
||||
}
|
||||
|
||||
func size(_ family: TrekFamily) -> CGSize {
|
||||
let m = metrics
|
||||
switch family {
|
||||
case .small: return CGSize(width: m.small, height: m.small)
|
||||
case .medium: return CGSize(width: m.wide, height: m.small)
|
||||
case .large: return CGSize(width: m.wide, height: m.wide)
|
||||
case .extraLarge: return CGSize(width: m.extraWide, height: m.wide)
|
||||
default: return family.previewSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WidgetSnapshot {
|
||||
/// What the widget gallery shows before the app ever wrote a snapshot, and
|
||||
/// what the renderer draws: a trip to Kyoto with a full day, bookings and to-dos.
|
||||
|
||||
@@ -85,6 +85,53 @@ struct TripContent: View {
|
||||
}
|
||||
.frame(width: 96, height: 96)
|
||||
}
|
||||
case .extraLarge:
|
||||
// The iPad's wide widget: the trip and its countdown on the left, the
|
||||
// bookings ahead in a column on the right.
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
Text(snapshot.labels.nextTrip).trekEyebrowOnPhoto()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true) }
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
HStack(alignment: .bottom, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(trip.title)
|
||||
.font(.system(size: 28, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.75)
|
||||
if let place = trip.place {
|
||||
Label(place, systemImage: "location.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
countdown(days, snapshot.labels, size: 54)
|
||||
}
|
||||
RouteArc(progress: progress, color: .white)
|
||||
.frame(height: 34)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
TripFacts(snapshot: snapshot, onPhoto: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
if let bookings = snapshot.bookings, !bookings.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(snapshot.labels.bookings ?? "").trekEyebrowOnPhoto()
|
||||
ForEach(Array(bookings.prefix(4).enumerated()), id: \.offset) { _, booking in
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(width: 290)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
@@ -154,10 +201,17 @@ struct TripContent: View {
|
||||
switch family {
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
Text(today.dayLabel).trekEyebrowOnPhoto()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
|
||||
// An iPad mini's small widget has no room for both: the day stays, the weather gives way.
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .top) {
|
||||
Text(today.dayLabel).trekEyebrowOnPhoto()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
|
||||
}
|
||||
HStack(alignment: .top) {
|
||||
Text(today.dayLabel).trekEyebrowOnPhoto()
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 7)
|
||||
Spacer(minLength: 0)
|
||||
@@ -175,31 +229,87 @@ struct TripContent: View {
|
||||
Text(trip.title).font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)).lineLimit(1).padding(.top, 2)
|
||||
}
|
||||
case .medium:
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(trip.title).trekEyebrowOnPhoto()
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: 21, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.top, 3)
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8)
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather {
|
||||
HStack(spacing: 6) {
|
||||
WeatherChip(weather: weather, onPhoto: true)
|
||||
if let high = weather.high, let low = weather.low {
|
||||
Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8))
|
||||
// 176 pt for the stops on an iPhone and an 11" or 13" iPad; an iPad
|
||||
// mini's medium widget is narrower, so the panel gives up some width
|
||||
// and the weather keeps to its chip, and the day still reads whole.
|
||||
// The line is drawn by the iPad mini alone: its medium widget is
|
||||
// 305.5 pt wide, 273.5 inside the content margins, while the smallest
|
||||
// iPhone's (a 4.7" SE) is 321 pt, 289 inside, and keeps the full layout.
|
||||
GeometryReader { geo in
|
||||
let narrow = geo.size.width < 281
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(trip.title).trekEyebrowOnPhoto()
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: 21, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.top, 3)
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8)
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather {
|
||||
HStack(spacing: 6) {
|
||||
WeatherChip(weather: weather, onPhoto: true, compact: narrow)
|
||||
if !narrow, let high = weather.high, let low = weather.low {
|
||||
Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
GlassPanel {
|
||||
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
|
||||
}
|
||||
.frame(width: narrow ? 150 : 176)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
GlassPanel {
|
||||
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
case .extraLarge:
|
||||
// The iPad's wide widget: the day and the next stop on the left, the
|
||||
// day's stops and the next booking on the right, each column
|
||||
// anchored at its top and its foot.
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(trip.title).trekEyebrowOnPhoto()
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: 26, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { BigWeather(weather: weather) }
|
||||
}
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 10)
|
||||
Spacer(minLength: 8)
|
||||
if let next {
|
||||
Text(next.time ?? snapshot.labels.today)
|
||||
.font(Trek.number(34, .heavy))
|
||||
.foregroundStyle(.white)
|
||||
Text(next.name)
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
} else {
|
||||
Text(snapshot.labels.nothingPlanned).font(.system(size: 17, weight: .bold)).foregroundStyle(.white).lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(width: 176)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
GlassPanel {
|
||||
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 6, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let booking = snapshot.bookings?.first {
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
|
||||
}
|
||||
}
|
||||
.frame(width: 300)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
|
||||
@@ -7,7 +7,11 @@ import WidgetKit
|
||||
struct TripDayLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: TripDayAttributes.self) { context in
|
||||
TripDayBanner(attributes: context.attributes, state: context.state)
|
||||
TripDayBanner(
|
||||
tripTitle: context.attributes.tripTitle, dayLabel: context.state.dayLabel,
|
||||
nextName: context.state.nextName, nextTime: context.state.nextTime,
|
||||
done: context.state.done, total: context.state.total
|
||||
)
|
||||
.activityBackgroundTint(Color(hex: 0x0C0C0F).opacity(0.92))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
.widgetURL(WidgetSnapshot.link(context.attributes.path))
|
||||
@@ -31,7 +35,7 @@ struct TripDayLiveActivity: Widget {
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(state.nextName).font(.system(size: 17, weight: .heavy)).lineLimit(1)
|
||||
StopProgress(done: state.done, total: state.total)
|
||||
DayStopProgress(done: state.done, total: state.total)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
@@ -55,58 +59,3 @@ struct TripDayLiveActivity: Widget {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The lock-screen banner: trip and day on top, the next stop large, the
|
||||
/// day's stops as segments.
|
||||
private struct TripDayBanner: View {
|
||||
let attributes: TripDayAttributes
|
||||
let state: TripDayAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
|
||||
Circle()
|
||||
.trim(from: 0, to: state.total > 0 ? max(0.02, Double(state.done) / Double(state.total)) : 0)
|
||||
.stroke(
|
||||
AngularGradient(colors: [Trek.confirmed.opacity(0.7), Trek.confirmed], center: .center),
|
||||
style: StrokeStyle(lineWidth: 5, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(attributes.tripTitle)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.tracking(0.5)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(state.dayLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(state.nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if let time = state.nextTime {
|
||||
Text(time).font(Trek.number(18, .heavy)).foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
StopProgress(done: state.done, total: state.total).padding(.top, 3)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's timed stops as segments, those behind filled.
|
||||
private struct StopProgress: View {
|
||||
let done: Int
|
||||
let total: Int
|
||||
|
||||
var body: some View {
|
||||
DaySegments(index: done + 1, total: max(total, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,23 @@ struct Render {
|
||||
}
|
||||
let lock = LockScreen(snapshots: states.prefix(3).map(\.1), now: now).environment(\.colorScheme, scheme)
|
||||
try write(lock, to: out.appendingPathComponent("lock-\(schemeName).png"))
|
||||
|
||||
// The iPad: every home-screen size of each widget as that iPad gives
|
||||
// them, the extra large included, per kind in one strip.
|
||||
for pad in TrekPad.allCases {
|
||||
for (stateName, snapshot) in states where ["upcoming", "coverrunning", "empty"].contains(stateName) {
|
||||
for kind in WidgetKind.allCases {
|
||||
let strip = PadStrip(kind: kind, pad: pad, snapshot: snapshot, now: now)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.environment(\.trekStaticPreview, true)
|
||||
try write(strip, to: out.appendingPathComponent("pad-\(pad.rawValue)-\(kind.rawValue)-\(stateName)-\(schemeName).png"), scale: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
let padLock = PadLockScreen(snapshot: states[1].1, now: now).environment(\.colorScheme, scheme)
|
||||
try write(padLock, to: out.appendingPathComponent("pad-lock-\(schemeName).png"), scale: 2)
|
||||
}
|
||||
try write(LiveActivityWidths(), to: out.appendingPathComponent("liveactivity-widths.png"))
|
||||
let args = Array(CommandLine.arguments.dropFirst())
|
||||
try Mockups.render(to: out, iconPath: args.count > 1 ? args[1] : nil, now: now)
|
||||
print("wrote \(try FileManager.default.contentsOfDirectory(atPath: out.path).count) images to \(out.path)")
|
||||
@@ -77,9 +93,9 @@ struct Render {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func write<V: View>(_ view: V, to url: URL) throws {
|
||||
static func write<V: View>(_ view: V, to url: URL, scale: CGFloat = 3) throws {
|
||||
let renderer = ImageRenderer(content: view)
|
||||
renderer.scale = 3
|
||||
renderer.scale = scale
|
||||
guard let image = renderer.nsImage,
|
||||
let tiff = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiff),
|
||||
@@ -95,9 +111,9 @@ enum WidgetKind: String, CaseIterable {
|
||||
|
||||
var families: [TrekFamily] {
|
||||
switch self {
|
||||
case .trip: return [.small, .medium, .large, .circular, .rectangular, .inline]
|
||||
case .today: return [.medium, .large]
|
||||
case .bookings: return [.small, .medium, .large, .rectangular, .inline]
|
||||
case .trip: return [.small, .medium, .large, .extraLarge, .circular, .rectangular, .inline]
|
||||
case .today: return [.medium, .large, .extraLarge]
|
||||
case .bookings: return [.small, .medium, .large, .extraLarge, .rectangular, .inline]
|
||||
case .todos: return [.small, .medium, .large, .circular, .rectangular]
|
||||
case .packing: return [.small, .medium, .large, .circular, .rectangular]
|
||||
case .atlas: return [.small, .medium, .large, .circular, .rectangular, .inline]
|
||||
@@ -113,9 +129,11 @@ struct WidgetFrame: View {
|
||||
let family: TrekFamily
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
/// The device's size for the family, when it is not the 6.1" iPhone's.
|
||||
var size: CGSize? = nil
|
||||
|
||||
var body: some View {
|
||||
let size = family.previewSize
|
||||
let size = self.size ?? family.previewSize
|
||||
if family.isAccessory {
|
||||
// As the lock screen draws them: white on the wallpaper, no box of their own
|
||||
// (the circular one gets the system's soft disc).
|
||||
@@ -270,3 +288,98 @@ struct NewWidgets: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One widget kind in every home-screen size an iPad gives it, side by side on
|
||||
/// that iPad's wallpaper, the extra large last.
|
||||
struct PadStrip: View {
|
||||
let kind: WidgetKind
|
||||
let pad: TrekPad
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
ForEach(kind.families.filter { !$0.isAccessory }, id: \.self) { family in
|
||||
WidgetFrame(kind: kind, family: family, snapshot: snapshot, now: now, size: pad.size(family))
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: scheme == .dark ? [Color(hex: 0x1D2440), Color(hex: 0x0B0F1E)] : [Color(hex: 0xC9D8F0), Color(hex: 0xF3E3D3)],
|
||||
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// An 11" iPad's lock screen on its side: the clock with the accessories under
|
||||
/// it on the left, as iPadOS places them, and the travel day's Live Activity.
|
||||
struct PadLockScreen: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
WidgetFrame(kind: .trip, family: .inline, snapshot: snapshot, now: now)
|
||||
Text(now, format: .dateTime.hour().minute())
|
||||
.font(.system(size: 96, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
HStack(spacing: 10) {
|
||||
WidgetFrame(kind: .trip, family: .circular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .circular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .packing, family: .circular, snapshot: snapshot, now: now)
|
||||
}
|
||||
WidgetFrame(kind: .trip, family: .rectangular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .rectangular, snapshot: snapshot, now: now)
|
||||
}
|
||||
.padding(.leading, 64)
|
||||
.padding(.top, 70)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(width: 1194, height: 834, alignment: .topLeading)
|
||||
.overlay(alignment: .bottom) {
|
||||
LiveActivityCard(width: 500).padding(.bottom, 40)
|
||||
}
|
||||
.background(
|
||||
LinearGradient(colors: [Color(hex: 0x2B3A67), Color(hex: 0x6B4A6E), Color(hex: 0x1C1726)], startPoint: .top, endPoint: .bottom)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The travel day's Live Activity as the lock screen tints it.
|
||||
struct LiveActivityCard: View {
|
||||
let width: CGFloat
|
||||
|
||||
var body: some View {
|
||||
TripDayBanner(
|
||||
tripTitle: "Autumn in Kyoto", dayLabel: "Day 3 of 9",
|
||||
nextName: "Kiyomizu-dera", nextTime: "13:30", done: 2, total: 5
|
||||
)
|
||||
.frame(width: width)
|
||||
.background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.92)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The Live Activity at the widths an iPhone's and an iPad's lock screen give
|
||||
/// it, and with a long German day and stop, to see it hold at each.
|
||||
struct LiveActivityWidths: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
ForEach([364, 500, 640] as [CGFloat], id: \.self) { width in
|
||||
LiveActivityCard(width: width)
|
||||
}
|
||||
TripDayBanner(
|
||||
tripTitle: "Herbst in Kyoto und Nara", dayLabel: "Tag 3 von 9",
|
||||
nextName: "Kiyomizu-dera und Sannenzaka-Spaziergang", nextTime: "13:30", done: 2, total: 5
|
||||
)
|
||||
.frame(width: 364)
|
||||
.background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.92)))
|
||||
}
|
||||
.padding(28)
|
||||
.background(Color(hex: 0x1E2A3A))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,12 +66,12 @@ wait_ready() {
|
||||
|
||||
TOKEN=0
|
||||
# shot <udid> <file> <screen> <scenario> <time or -> <language>: one launch, retried
|
||||
# after 2, 5 and 10 seconds when the simulator refuses it.
|
||||
# after 2, 5, 10 and 20 seconds when the simulator refuses it, saying which step failed.
|
||||
shot() {
|
||||
local udid=$1 file=$2 screen=$3 scenario=$4 clock=$5 lang=$6
|
||||
local locale=${lang//-/_} attempt
|
||||
for attempt in 0 2 5 10; do
|
||||
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s"; sleep "$attempt"; }
|
||||
local locale=${lang//-/_} attempt failed=
|
||||
for attempt in 0 2 5 10 20; do
|
||||
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s ($failed)"; sleep "$attempt"; }
|
||||
TOKEN=$((TOKEN + 1))
|
||||
xcrun simctl terminate "$udid" "$BUNDLE" >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
@@ -79,11 +79,12 @@ shot() {
|
||||
[ "$clock" = - ] || extra=(-TrekNow "$clock")
|
||||
xcrun simctl launch "$udid" "$BUNDLE" \
|
||||
-AppleLanguages "($lang)" -AppleLocale "$locale" -TrekDemo "$screen" -TrekScenario "$scenario" \
|
||||
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || continue
|
||||
wait_ready "$udid" "$TOKEN" || continue
|
||||
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || { failed=launch; continue; }
|
||||
wait_ready "$udid" "$TOKEN" || { failed='not ready'; continue; }
|
||||
# The map's tiles come over the network after the screen is up.
|
||||
[ "$screen" = place ] && sleep 6
|
||||
xcrun simctl io "$udid" screenshot --type=png "$file" >/dev/null 2>&1 && return 0
|
||||
failed=screenshot
|
||||
done
|
||||
echo "gave up on $(basename "$file")"
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared/i18n'
|
||||
import { SUPPORTED_LANGUAGE_CODES, isRtlLanguage } from '@trek/shared/i18n'
|
||||
import type { TranslationStrings } from '@trek/shared/i18n'
|
||||
|
||||
// Only the native strings of every locale, not the whole UI: this page ships
|
||||
@@ -32,3 +32,8 @@ export function translator(code: string) {
|
||||
: key
|
||||
}
|
||||
}
|
||||
|
||||
/** Arabic and Hebrew run right to left. */
|
||||
export function directionOf(code: string): 'rtl' | 'ltr' {
|
||||
return isRtlLanguage(code) ? 'rtl' : 'ltr'
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<title>TREK</title>
|
||||
|
||||
+459
-91
@@ -1,3 +1,4 @@
|
||||
import { App } from '@capacitor/app';
|
||||
import { Capacitor, CapacitorHttp } from '@capacitor/core';
|
||||
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
|
||||
import { SplashScreen } from '@capacitor/splash-screen';
|
||||
@@ -5,11 +6,28 @@ import { StatusBar, Style } from '@capacitor/status-bar';
|
||||
import logoUrl from '../public/icons/icon-white.svg';
|
||||
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
|
||||
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
|
||||
import { AUTH_GATE_NAMES, canSignInInApp, type AuthGate } from '../src/native/authGate';
|
||||
import { opensSoon, refundedBeforeOpening } from '../src/native/cloudOrders';
|
||||
import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap';
|
||||
import { CLOUD_TERMS_URL, CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL, STORE_TERMS_URL } from '../src/native/links';
|
||||
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
|
||||
import { TrekShell } from '../src/native/trekShell';
|
||||
import { pickLocale, translator } from './i18n';
|
||||
import {
|
||||
AUTH_PROXY_DOCS_URL,
|
||||
CLIENT_CERT_DOCS_URL,
|
||||
CLOUD_TERMS_URL,
|
||||
CLOUD_URL,
|
||||
PRIVACY_URL,
|
||||
SERVER_DOCS_URL,
|
||||
STORE_TERMS_URL,
|
||||
} from '../src/native/links';
|
||||
import {
|
||||
probeDemo,
|
||||
probeServer,
|
||||
type ClientCertDemand,
|
||||
type ProbeFailure,
|
||||
type ProbeGet,
|
||||
} from '../src/native/serverAddress';
|
||||
import { passwordProblem } from '../../shared/src/auth/password-policy';
|
||||
import { TrekShell, type ClientCertificateFailure } from '../src/native/trekShell';
|
||||
import { directionOf, pickLocale, translator } from './i18n';
|
||||
import './shell.css';
|
||||
|
||||
/**
|
||||
@@ -24,7 +42,7 @@ const locale = pickLocale(navigator.languages?.length ? navigator.languages : [n
|
||||
const onPlay = Capacitor.getPlatform() === 'android';
|
||||
const t = translator(locale);
|
||||
document.documentElement.lang = locale;
|
||||
document.documentElement.dir = locale === 'ar' ? 'rtl' : 'ltr';
|
||||
document.documentElement.dir = directionOf(locale);
|
||||
|
||||
const root = document.getElementById('shell')!;
|
||||
const PROBE_TIMEOUT_MS = 8000;
|
||||
@@ -46,11 +64,34 @@ function cloudSuffix(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON bodies arrive as text from the app's own request; everything else stays text. */
|
||||
function parseBody(data: unknown): unknown {
|
||||
if (typeof data !== 'string') return data;
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
// Native HTTP rather than fetch: the probe goes to a server of the user's
|
||||
// choosing, which has no reason to answer this page's origin with CORS.
|
||||
// choosing, which has no reason to answer this page's origin with CORS. The
|
||||
// app's own request comes first: it shows a stored client certificate and
|
||||
// reports whether the server asked for one. A web preview has only Capacitor's.
|
||||
const get: ProbeGet = async (url) => {
|
||||
const response = await CapacitorHttp.get({ url, connectTimeout: PROBE_TIMEOUT_MS, readTimeout: PROBE_TIMEOUT_MS });
|
||||
return { status: response.status, data: response.data, url: response.url };
|
||||
try {
|
||||
const response = await TrekShell.requestServer({ url, timeoutMs: PROBE_TIMEOUT_MS });
|
||||
return {
|
||||
status: response.status,
|
||||
data: parseBody(response.data),
|
||||
url: response.url,
|
||||
headers: response.headers,
|
||||
clientCert: { requested: response.certRequested, sent: response.certSent },
|
||||
};
|
||||
} catch {
|
||||
const response = await CapacitorHttp.get({ url, connectTimeout: PROBE_TIMEOUT_MS, readTimeout: PROBE_TIMEOUT_MS });
|
||||
return { status: response.status, data: response.data, url: response.url, headers: response.headers };
|
||||
}
|
||||
};
|
||||
|
||||
function failureMessage(reason: ProbeFailure): string {
|
||||
@@ -61,6 +102,10 @@ function failureMessage(reason: ProbeFailure): string {
|
||||
return t('native.connect.errorUnreachable');
|
||||
case 'notTrek':
|
||||
return t('native.connect.errorNotTrek');
|
||||
case 'authGate':
|
||||
return t('native.gate.titleUnknown');
|
||||
case 'clientCert':
|
||||
return t('native.cert.title');
|
||||
case 'tooOld':
|
||||
return t('native.connect.errorTooOld');
|
||||
}
|
||||
@@ -128,6 +173,11 @@ function show(screen: HTMLElement, opts: { back?: boolean } = {}): void {
|
||||
if (opts.back) screen.classList.add('screen-back');
|
||||
const previous = current;
|
||||
if (previous) {
|
||||
// The leaving screen is lifted out of the flow where it stands, so it fades
|
||||
// from its own place rather than jumping to the top of a centred layout.
|
||||
previous.style.top = `${previous.offsetTop}px`;
|
||||
previous.style.left = `${previous.offsetLeft}px`;
|
||||
previous.style.width = `${previous.offsetWidth}px`;
|
||||
previous.classList.remove('screen-enter');
|
||||
previous.classList.add('screen-leave');
|
||||
if (opts.back) previous.classList.add('screen-back');
|
||||
@@ -144,6 +194,15 @@ function externalLink(text: string, href: string): HTMLAnchorElement {
|
||||
function footLinks(...extra: Node[]): HTMLElement {
|
||||
return el('div', { className: 'foot-links' }, ...extra, externalLink(t('native.privacy'), PRIVACY_URL));
|
||||
}
|
||||
/**
|
||||
* A screen's two halves: what it is (brand, title, explanation) and what to do
|
||||
* on it (the choices, the form). Stacked in one column they read as before; on
|
||||
* a phone or a foldable on its side the screen is too short for one column, so
|
||||
* the halves stand side by side there (shell.css, `.col`).
|
||||
*/
|
||||
function columns(about: Node[], act: Node[]): HTMLElement[] {
|
||||
return [el('div', { className: 'col col-about' }, ...about), el('div', { className: 'col col-act' }, ...act)];
|
||||
}
|
||||
function topbar(onBack: () => void): HTMLElement {
|
||||
const back = el('button', { type: 'button', className: 'back' }, icon('M15 18l-6-6 6-6', 18), t('native.back'));
|
||||
back.addEventListener('click', () => {
|
||||
@@ -202,13 +261,14 @@ function showWelcome(opts: { back?: boolean } = {}): void {
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
hero,
|
||||
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
|
||||
el('p', { className: 'sub' }, t('native.welcome.sub')),
|
||||
cloud,
|
||||
connect,
|
||||
demo,
|
||||
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
|
||||
...columns(
|
||||
[
|
||||
hero,
|
||||
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
|
||||
el('p', { className: 'sub' }, t('native.welcome.sub')),
|
||||
],
|
||||
[cloud, connect, demo, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -225,6 +285,8 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
});
|
||||
input.setAttribute('inputmode', 'url');
|
||||
input.setAttribute('autocomplete', 'url');
|
||||
// An address reads left to right in every language, Arabic too.
|
||||
input.dir = 'ltr';
|
||||
// The on-screen keyboard's return key submits the address; it reads "Go", not a newline.
|
||||
input.setAttribute('enterkeyhint', 'go');
|
||||
input.setAttribute('aria-label', t('native.connect.title'));
|
||||
@@ -233,24 +295,36 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
const label = el('span', {}, t('native.connect.submit'));
|
||||
const submit = el('button', { type: 'submit', className: 'btn' }, label);
|
||||
const error = el('p', { className: 'error', role: 'alert' });
|
||||
const form = el('form', { className: 'form', noValidate: true }, field, submit, error);
|
||||
const gatePanel = el('div', { className: 'gate', role: 'alert', hidden: true });
|
||||
const form = el('form', { className: 'form', noValidate: true }, field, submit, error, gatePanel);
|
||||
|
||||
const fail = (reason: ProbeFailure): void => {
|
||||
const fail = (reason: ProbeFailure, gate?: AuthGate, server?: string, cert?: ClientCertDemand): void => {
|
||||
void notify(NotificationType.Error);
|
||||
field.classList.add('bad');
|
||||
setTimeout(() => field.classList.remove('bad'), 350);
|
||||
error.textContent = failureMessage(reason);
|
||||
if (reason === 'authGate' && gate && server) {
|
||||
error.textContent = '';
|
||||
fillGatePanel(gatePanel, gate, server);
|
||||
} else if (reason === 'clientCert' && cert) {
|
||||
error.textContent = '';
|
||||
fillCertPanel(gatePanel, cert, () => form.requestSubmit());
|
||||
} else {
|
||||
error.textContent = failureMessage(reason);
|
||||
}
|
||||
input.disabled = false;
|
||||
submit.disabled = false;
|
||||
label.textContent = t('native.connect.submit');
|
||||
submit.replaceChildren(label);
|
||||
input.focus();
|
||||
// A panel with its own next step stays in view; the keyboard would cover it.
|
||||
if (gatePanel.hidden) input.focus();
|
||||
};
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
void tap(ImpactStyle.Medium);
|
||||
error.textContent = '';
|
||||
gatePanel.hidden = true;
|
||||
gatePanel.replaceChildren();
|
||||
input.disabled = true;
|
||||
submit.disabled = true;
|
||||
submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking'));
|
||||
@@ -259,24 +333,145 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
await connected(result.url);
|
||||
return;
|
||||
}
|
||||
fail(result.reason);
|
||||
fail(result.reason, result.gate, result.url, result.cert);
|
||||
});
|
||||
|
||||
show(
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.connect.title')),
|
||||
el('p', { className: 'sub' }, t('native.connect.hint')),
|
||||
form,
|
||||
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
|
||||
...columns(
|
||||
[
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.connect.title')),
|
||||
el('p', { className: 'sub' }, t('native.connect.hint')),
|
||||
],
|
||||
[form, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
requestAnimationFrame(() => input.focus());
|
||||
}
|
||||
|
||||
/**
|
||||
* A sign-in page of the user's own stands in front of the server (authGate.ts):
|
||||
* say which one, offer to sign in to it inside the app, which keeps its session
|
||||
* as a browser would, and how to let the app through instead.
|
||||
*/
|
||||
function fillGatePanel(panel: HTMLElement, gate: AuthGate, server: string): void {
|
||||
const name = gate.kind === 'unknown' || gate.kind === 'basic' ? null : AUTH_GATE_NAMES[gate.kind];
|
||||
const title =
|
||||
gate.kind === 'basic'
|
||||
? t('native.gate.titleBasic')
|
||||
: name
|
||||
? t('native.gate.title', { name })
|
||||
: t('native.gate.titleUnknown');
|
||||
const children: Node[] = [el('p', { className: 'gate-title' }, title)];
|
||||
if (canSignInInApp(gate)) {
|
||||
const signIn = el(
|
||||
'button',
|
||||
{ type: 'button', className: 'btn' },
|
||||
name ? t('native.gate.signIn', { name }) : t('native.gate.signInGeneric')
|
||||
);
|
||||
signIn.addEventListener('click', () => {
|
||||
void tap(ImpactStyle.Medium);
|
||||
void connected(server, gate.loginHost ? [gate.loginHost] : []);
|
||||
});
|
||||
children.push(
|
||||
el('p', { className: 'gate-text' }, t('native.gate.hint')),
|
||||
signIn,
|
||||
el('p', { className: 'gate-note' }, t('native.gate.googleNote'))
|
||||
);
|
||||
} else {
|
||||
children.push(el('p', { className: 'gate-text' }, t('native.gate.basicHint')));
|
||||
}
|
||||
children.push(externalLink(t('native.gate.help'), AUTH_PROXY_DOCS_URL));
|
||||
panel.replaceChildren(...children);
|
||||
panel.hidden = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A proxy in front of the server lets only devices with a client certificate
|
||||
* through (mutual TLS: Cloudflare, nginx, Caddy, Traefik). The panel offers to
|
||||
* add the certificate file for it, or another one when the proxy refused the
|
||||
* one shown; `retry` checks the server again once it is added.
|
||||
*/
|
||||
function fillCertPanel(panel: HTMLElement, cert: ClientCertDemand, retry: () => void): void {
|
||||
const status = el('p', { className: 'gate-note', role: 'status' });
|
||||
const choose = el(
|
||||
'button',
|
||||
{ type: 'button', className: 'btn' },
|
||||
cert.sent ? t('native.cert.chooseOther') : t('native.cert.choose')
|
||||
);
|
||||
choose.addEventListener('click', async () => {
|
||||
void tap(ImpactStyle.Medium);
|
||||
choose.disabled = true;
|
||||
status.textContent = '';
|
||||
const outcome = await importCertificate(cert.host, cert.port);
|
||||
choose.disabled = false;
|
||||
if (outcome === 'imported') {
|
||||
void notify(NotificationType.Success);
|
||||
retry();
|
||||
} else if (outcome !== 'cancelled') {
|
||||
void notify(NotificationType.Error);
|
||||
status.textContent = certFailureMessage(outcome);
|
||||
}
|
||||
});
|
||||
panel.replaceChildren(
|
||||
el('p', { className: 'gate-title' }, cert.sent ? t('native.cert.titleRefused') : t('native.cert.title')),
|
||||
el(
|
||||
'p',
|
||||
{ className: 'gate-text' },
|
||||
cert.sent ? t('native.cert.hintRefused') : t('native.cert.hint', { host: cert.host })
|
||||
),
|
||||
choose,
|
||||
status,
|
||||
externalLink(t('native.cert.help'), CLIENT_CERT_DOCS_URL)
|
||||
);
|
||||
panel.hidden = false;
|
||||
}
|
||||
|
||||
/** Imports a certificate file for host and port: 'imported', 'cancelled', or why it failed. */
|
||||
async function importCertificate(
|
||||
host: string,
|
||||
port: number
|
||||
): Promise<'imported' | 'cancelled' | ClientCertificateFailure> {
|
||||
try {
|
||||
const result = await TrekShell.importClientCertificate({
|
||||
host,
|
||||
port,
|
||||
labels: {
|
||||
title: t('native.cert.passwordTitle'),
|
||||
message: t('native.cert.passwordMessage'),
|
||||
placeholder: t('native.cert.passwordPlaceholder'),
|
||||
ok: t('native.cert.passwordOk'),
|
||||
cancel: t('native.cert.passwordCancel'),
|
||||
wrongPassword: t('native.cert.passwordWrong'),
|
||||
},
|
||||
});
|
||||
if (result.result === 'failed') return result.failure ?? 'format';
|
||||
return result.result;
|
||||
} catch {
|
||||
return 'store';
|
||||
}
|
||||
}
|
||||
|
||||
function certFailureMessage(failure: ClientCertificateFailure): string {
|
||||
switch (failure) {
|
||||
case 'no_key':
|
||||
return t('native.cert.errorNoKey');
|
||||
case 'expired':
|
||||
return t('native.cert.errorExpired');
|
||||
case 'not_yet_valid':
|
||||
return t('native.cert.errorNotYetValid');
|
||||
case 'store':
|
||||
return t('native.cert.errorStore');
|
||||
case 'format':
|
||||
return t('native.cert.errorFormat');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Get TREK Cloud ──────────────────────────────────────────────────────────
|
||||
function feature(path: string, text: string): HTMLElement {
|
||||
return el(
|
||||
@@ -288,7 +483,13 @@ function feature(path: string, text: string): HTMLElement {
|
||||
}
|
||||
|
||||
function showCloud(opts: { back?: boolean } = {}): void {
|
||||
const cta = el('button', { type: 'button', className: 'btn' }, t('native.cloud.cta'), icon(I.chevron, 20, 'chev'));
|
||||
// The label centred, the chevron at the far edge: it points on, not at the word.
|
||||
const cta = el(
|
||||
'button',
|
||||
{ type: 'button', className: 'btn btn-go' },
|
||||
el('span', { className: 'btn-label' }, t('native.cloud.cta')),
|
||||
icon(I.chevron, 20, 'chev')
|
||||
);
|
||||
cta.addEventListener('click', () => {
|
||||
void tap();
|
||||
showPlans();
|
||||
@@ -304,25 +505,32 @@ function showCloud(opts: { back?: boolean } = {}): void {
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
el(
|
||||
'div',
|
||||
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title') },
|
||||
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
|
||||
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
|
||||
el('span', { className: 'cloud-tag' }, 'cloud')
|
||||
),
|
||||
el('p', { className: 'sub' }, t('native.cloud.sub')),
|
||||
el(
|
||||
'div',
|
||||
{ className: 'features' },
|
||||
feature(I.globe, t('native.cloud.f1')),
|
||||
feature(I.bolt, t('native.cloud.f2')),
|
||||
feature(I.shield, t('native.cloud.f3'))
|
||||
),
|
||||
cta,
|
||||
have,
|
||||
footLinks()
|
||||
...columns(
|
||||
[
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
// The lockup is a logo: "trek" then "cloud" in every language, never mirrored.
|
||||
el(
|
||||
'div',
|
||||
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title'), dir: 'ltr' },
|
||||
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
|
||||
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
|
||||
el('span', { className: 'cloud-tag' }, 'cloud')
|
||||
),
|
||||
el('p', { className: 'sub' }, t('native.cloud.sub')),
|
||||
],
|
||||
[
|
||||
el(
|
||||
'div',
|
||||
{ className: 'features' },
|
||||
feature(I.globe, t('native.cloud.f1')),
|
||||
feature(I.bolt, t('native.cloud.f2')),
|
||||
feature(I.shield, t('native.cloud.f3'))
|
||||
),
|
||||
cta,
|
||||
have,
|
||||
footLinks(),
|
||||
]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -396,12 +604,14 @@ function offerFor(offer: Offer, plan: CloudPlan, interval: 'month' | 'year'): {
|
||||
|
||||
async function loadOffer(list: CloudPlans): Promise<Offer> {
|
||||
const available = await iapReady;
|
||||
// Closed: a store purchase would be refused at the cloud, so only a test build may buy.
|
||||
const opensSoon = available && list.closed && !(await iap.isSandbox().catch(() => false));
|
||||
const canBuy = available && !opensSoon;
|
||||
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon };
|
||||
// Closed: a real purchase is refused at the cloud, so only a test build may buy on iOS;
|
||||
// Android keeps the button for the licence testers (see cloudOrders.ts).
|
||||
const sandbox = list.closed && !onPlay ? await iap.isSandbox().catch(() => false) : false;
|
||||
const soon = opensSoon({ available, closed: list.closed, sandbox, onPlay });
|
||||
const canBuy = available && !soon;
|
||||
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon: soon };
|
||||
const ids = list.plans.flatMap((p) => [productIdFor(p.id, 'month'), productIdFor(p.id, 'year')]);
|
||||
return { canBuy, storePrices: await iap.prices(ids), opensSoon };
|
||||
return { canBuy, storePrices: await iap.prices(ids), opensSoon: soon };
|
||||
}
|
||||
|
||||
function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HTMLElement {
|
||||
@@ -427,7 +637,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HT
|
||||
{ className: 'plan-top' },
|
||||
el('span', { className: 'plan-name' }, plan.name),
|
||||
// No price yet (orders not open): the plan shows without one rather than with an empty one.
|
||||
...(label ? [el('span', { className: 'plan-price' }, label, el('small', {}, per))] : [])
|
||||
// The price is figures and a currency sign: one left-to-right run, so Arabic
|
||||
// keeps "5,99 €" whole, with the period standing apart beside it.
|
||||
...(label ? [el('span', { className: 'plan-price' }, el('bdi', { dir: 'ltr' }, label), el('small', {}, per))] : [])
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
@@ -491,7 +703,10 @@ function planFooter(offer: Offer, plans: CloudPlan[]): Node[] {
|
||||
void notify(NotificationType.Error);
|
||||
restore.disabled = false;
|
||||
status.className = 'hint bad';
|
||||
status.textContent = t('native.plans.restoreFailed');
|
||||
// Bought before the opening: refused, unacknowledged, and refunded by Google Play.
|
||||
status.textContent = refundedBeforeOpening(redeemed.code, onPlay)
|
||||
? t('native.claim.closedRefund')
|
||||
: t('native.plans.restoreFailed');
|
||||
});
|
||||
|
||||
// A payment that was pending (cash, some carrier billing) and has cleared since:
|
||||
@@ -578,12 +793,15 @@ function showPlans(opts: { back?: boolean } = {}): void {
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
topbar(() => showCloud({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.plans.title')),
|
||||
el('p', { className: 'sub' }, t('native.plans.sub')),
|
||||
seg,
|
||||
content,
|
||||
footer
|
||||
...columns(
|
||||
[
|
||||
topbar(() => showCloud({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.plans.title')),
|
||||
el('p', { className: 'sub' }, t('native.plans.sub')),
|
||||
seg,
|
||||
],
|
||||
[content, footer]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -609,10 +827,12 @@ interface RedeemResult {
|
||||
ok: boolean;
|
||||
/** Set when ok: the address the app loads. */
|
||||
instanceUrl?: string;
|
||||
/** Set when ok and a password was chosen: the one-time code that signs the buyer in once the instance answers. */
|
||||
welcome?: string;
|
||||
/** Set when not ok: a stable refusal code. */
|
||||
code?: string;
|
||||
}
|
||||
async function redeemPurchase(slug: string, email: string, purchase: Purchase): Promise<RedeemResult> {
|
||||
async function redeemPurchase(slug: string, email: string, purchase: Purchase, password = ''): Promise<RedeemResult> {
|
||||
try {
|
||||
const res = await CapacitorHttp.post({
|
||||
url: `${CLOUD_URL}/api/iap/redeem`,
|
||||
@@ -626,13 +846,16 @@ async function redeemPurchase(slug: string, email: string, purchase: Purchase):
|
||||
receipt: purchase.receipt,
|
||||
slug,
|
||||
email,
|
||||
// Hashed by the control plane on arrival and set on the new instance; empty for a restore.
|
||||
...(password ? { password } : {}),
|
||||
locale,
|
||||
},
|
||||
});
|
||||
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
|
||||
const body = (data ?? {}) as { instanceUrl?: string; code?: string };
|
||||
const body = (data ?? {}) as { instanceUrl?: string; code?: string; welcome?: string };
|
||||
if ((res.status === 200 || res.status === 201) && typeof body.instanceUrl === 'string') {
|
||||
return { ok: true, instanceUrl: body.instanceUrl };
|
||||
const welcome = typeof body.welcome === 'string' && /^[A-Za-z0-9_-]{43}$/.test(body.welcome) ? body.welcome : undefined;
|
||||
return { ok: true, instanceUrl: body.instanceUrl, ...(welcome ? { welcome } : {}) };
|
||||
}
|
||||
return { ok: false, code: body.code ?? 'redeem-failed' };
|
||||
} catch {
|
||||
@@ -661,9 +884,11 @@ function showClaim(
|
||||
slugInput.setAttribute('autocomplete', 'off');
|
||||
slugInput.setAttribute('maxlength', '40');
|
||||
slugInput.setAttribute('aria-label', t('native.claim.addressLabel'));
|
||||
slugInput.setAttribute('enterkeyhint', 'next');
|
||||
// "name.liketrek.com" is one address, left to right in every language.
|
||||
const slugField = el(
|
||||
'div',
|
||||
{ className: 'field slug-field' },
|
||||
{ className: 'field slug-field', dir: 'ltr' },
|
||||
slugInput,
|
||||
el('span', { className: 'slug-suffix' }, cloudSuffix())
|
||||
);
|
||||
@@ -678,10 +903,29 @@ function showClaim(
|
||||
});
|
||||
emailInput.setAttribute('inputmode', 'email');
|
||||
emailInput.setAttribute('autocomplete', 'email');
|
||||
emailInput.setAttribute('enterkeyhint', 'go');
|
||||
emailInput.setAttribute('enterkeyhint', 'next');
|
||||
emailInput.setAttribute('aria-label', t('native.claim.emailLabel'));
|
||||
emailInput.dir = 'ltr';
|
||||
emailInput.classList.add('ltr-value');
|
||||
const emailField = el('div', { className: 'field' }, emailInput);
|
||||
|
||||
// The password for the new TREK, chosen here so the buyer lands signed in (no password by mail).
|
||||
const passwordInput = el('input', {
|
||||
type: 'password',
|
||||
value: '',
|
||||
autocapitalize: 'off',
|
||||
spellcheck: false,
|
||||
placeholder: t('native.claim.passwordLabel'),
|
||||
});
|
||||
passwordInput.setAttribute('autocomplete', 'new-password');
|
||||
passwordInput.setAttribute('enterkeyhint', 'go');
|
||||
passwordInput.setAttribute('maxlength', '128');
|
||||
passwordInput.setAttribute('aria-label', t('native.claim.passwordLabel'));
|
||||
passwordInput.dir = 'ltr';
|
||||
passwordInput.classList.add('ltr-value');
|
||||
const passwordField = el('div', { className: 'field' }, passwordInput);
|
||||
const passwordHint = el('p', { className: 'hint' }, t('native.claim.passwordHint'));
|
||||
|
||||
const submit = el('button', { type: 'submit', className: 'btn' }, t('native.claim.subscribe', { price }));
|
||||
const error = el('p', { className: 'error', role: 'alert' });
|
||||
const form = el(
|
||||
@@ -691,6 +935,8 @@ function showClaim(
|
||||
slugNote,
|
||||
emailField,
|
||||
el('p', { className: 'hint' }, t('native.claim.emailHint')),
|
||||
passwordField,
|
||||
passwordHint,
|
||||
// Our terms govern the instance (terms § 5a): named before the purchase, not only in the confirmation mail.
|
||||
el(
|
||||
'p',
|
||||
@@ -703,6 +949,17 @@ function showClaim(
|
||||
error
|
||||
);
|
||||
|
||||
// "Next" moves on to the next field instead of sending the form half filled;
|
||||
// "Go" on the password sends it.
|
||||
const nextOnEnter = (from: HTMLInputElement, to: HTMLInputElement) =>
|
||||
from.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' || e.isComposing) return;
|
||||
e.preventDefault();
|
||||
to.focus();
|
||||
});
|
||||
nextOnEnter(slugInput, emailInput);
|
||||
nextOnEnter(emailInput, passwordInput);
|
||||
|
||||
let status: SlugStatus | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const noteFor: Partial<Record<SlugStatus, string>> = {
|
||||
@@ -749,11 +1006,23 @@ function showClaim(
|
||||
emailInput.focus();
|
||||
return;
|
||||
}
|
||||
// Before anything is bought: a password TREK would refuse must not cost a purchase first.
|
||||
const password = passwordInput.value;
|
||||
if (passwordProblem(password) || password.length > 128) {
|
||||
shake(passwordField);
|
||||
passwordHint.className = 'hint bad';
|
||||
passwordInput.focus();
|
||||
return;
|
||||
}
|
||||
passwordHint.className = 'hint';
|
||||
|
||||
void tap(ImpactStyle.Medium);
|
||||
// The purchase sheet and the progress want the whole screen, not the keyboard.
|
||||
passwordInput.blur();
|
||||
const lock = (on: boolean) => {
|
||||
slugInput.disabled = on;
|
||||
emailInput.disabled = on;
|
||||
passwordInput.disabled = on;
|
||||
submit.disabled = on;
|
||||
};
|
||||
lock(true);
|
||||
@@ -784,10 +1053,10 @@ function showClaim(
|
||||
bought = outcome.purchase;
|
||||
}
|
||||
|
||||
const redeemed = await redeemPurchase(slug, email, bought);
|
||||
const redeemed = await redeemPurchase(slug, email, bought, password);
|
||||
if (redeemed.ok && redeemed.instanceUrl) {
|
||||
await iap.finish(bought);
|
||||
showProvisioning(redeemed.instanceUrl);
|
||||
showProvisioning(redeemed.instanceUrl, redeemed.welcome);
|
||||
return;
|
||||
}
|
||||
void notify(NotificationType.Error);
|
||||
@@ -796,8 +1065,14 @@ function showClaim(
|
||||
shake(slugField);
|
||||
slugNote.textContent = t('native.claim.taken');
|
||||
slugNote.className = 'hint bad';
|
||||
} else if (redeemed.code === 'password-weak') {
|
||||
// A common password the list on the server knows: the purchase waits, unredeemed, for a better one.
|
||||
shake(passwordField);
|
||||
passwordHint.className = 'hint bad';
|
||||
} else {
|
||||
error.textContent = t('native.claim.redeemFailed');
|
||||
error.textContent = refundedBeforeOpening(redeemed.code, onPlay)
|
||||
? t('native.claim.closedRefund')
|
||||
: t('native.claim.redeemFailed');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -805,10 +1080,14 @@ function showClaim(
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
topbar(() => showPlans({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.claim.title')),
|
||||
el('p', { className: 'sub' }, t('native.claim.sub')),
|
||||
form
|
||||
...columns(
|
||||
[
|
||||
topbar(() => showPlans({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.claim.title')),
|
||||
el('p', { className: 'sub' }, t('native.claim.sub')),
|
||||
],
|
||||
[form]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -816,7 +1095,7 @@ function showClaim(
|
||||
}
|
||||
|
||||
/** The build runs on the server; the app waits on the new address and loads it the moment it answers. */
|
||||
function showProvisioning(instanceUrl: string): void {
|
||||
function showProvisioning(instanceUrl: string, welcome?: string): void {
|
||||
const host = (() => {
|
||||
try {
|
||||
return new URL(instanceUrl).host;
|
||||
@@ -840,11 +1119,11 @@ function showProvisioning(instanceUrl: string): void {
|
||||
const poll = async (): Promise<void> => {
|
||||
const result = await probeServer(instanceUrl, get);
|
||||
if (result.ok) {
|
||||
await connected(result.url);
|
||||
await connected(result.url, [], welcome);
|
||||
return;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
showProvisioningSlow(instanceUrl);
|
||||
showProvisioningSlow(instanceUrl, welcome);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => void poll(), 4000);
|
||||
@@ -852,12 +1131,12 @@ function showProvisioning(instanceUrl: string): void {
|
||||
setTimeout(() => void poll(), 3000);
|
||||
}
|
||||
|
||||
function showProvisioningSlow(instanceUrl: string): void {
|
||||
function showProvisioningSlow(instanceUrl: string, welcome?: string): void {
|
||||
void notify(NotificationType.Warning);
|
||||
const retry = el('button', { type: 'button', className: 'btn' }, t('native.provisioning.retry'));
|
||||
retry.addEventListener('click', () => {
|
||||
void tap();
|
||||
showProvisioning(instanceUrl);
|
||||
showProvisioning(instanceUrl, welcome);
|
||||
});
|
||||
show(
|
||||
el(
|
||||
@@ -888,8 +1167,13 @@ async function runDemo(button: HTMLButtonElement, error?: HTMLElement): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
/** The tick, then the server takes over. A beat so the success registers. */
|
||||
async function connected(url: string): Promise<void> {
|
||||
/**
|
||||
* The tick, then the server takes over. A beat so the success registers. With a
|
||||
* welcome code (a purchase with a chosen password) the app opens the instance's
|
||||
* welcome page once, which signs the buyer in; an app build without that option
|
||||
* opens the sign-in page instead, where the chosen password works.
|
||||
*/
|
||||
async function connected(url: string, signInHosts: string[] = [], welcome?: string): Promise<void> {
|
||||
void notify(NotificationType.Success);
|
||||
const host = (() => {
|
||||
try {
|
||||
@@ -914,11 +1198,17 @@ async function connected(url: string): Promise<void> {
|
||||
el('div', { className: 'success' }, tick, el('h1', { className: 'title-sm' }, t('native.connecting', { host })))
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
await TrekShell.setServer({ url });
|
||||
await TrekShell.setServer({
|
||||
url,
|
||||
...(signInHosts.length ? { signInHosts } : {}),
|
||||
...(welcome ? { welcome } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function showUnreachable(server: string): void {
|
||||
const retry = el('button', { type: 'button', className: 'btn' }, t('native.offline.retry'));
|
||||
function showUnreachable(server: string, cert?: ClientCertDemand): void {
|
||||
const certPanel = el('div', { className: 'gate', role: 'alert', hidden: true });
|
||||
if (cert) fillCertPanel(certPanel, cert, () => void TrekShell.reload());
|
||||
const retry = el('button', { type: 'button', className: cert ? 'linkbtn' : 'btn' }, t('native.offline.retry'));
|
||||
retry.addEventListener('click', () => {
|
||||
void tap();
|
||||
retry.disabled = true;
|
||||
@@ -933,12 +1223,20 @@ function showUnreachable(server: string): void {
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
|
||||
el('h1', { className: 'title-sm' }, t('native.offline.title')),
|
||||
el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })),
|
||||
retry,
|
||||
change,
|
||||
footLinks()
|
||||
...columns(
|
||||
[
|
||||
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
|
||||
el('h1', { className: 'title-sm' }, cert ? t('native.cert.unreachableTitle') : t('native.offline.title')),
|
||||
el(
|
||||
'p',
|
||||
{ className: 'sub' },
|
||||
cert
|
||||
? t('native.cert.unreachableHint', { server: new URL(server).host })
|
||||
: t('native.offline.hint', { server: new URL(server).host })
|
||||
),
|
||||
],
|
||||
[certPanel, retry, change, footLinks()]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -958,8 +1256,71 @@ async function paintStatusBar(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Android's back gesture steps back through the first screens as their own
|
||||
* back button does, and only leaves the app from a screen that has none.
|
||||
*/
|
||||
function followBackGesture(): void {
|
||||
try {
|
||||
void App.addListener('backButton', () => {
|
||||
const back = current?.querySelector<HTMLButtonElement>('.topbar .back');
|
||||
if (back) back.click();
|
||||
else void App.minimizeApp();
|
||||
});
|
||||
} catch {
|
||||
/* web / unavailable: no system back gesture */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The keyboard closes the ways it does in an iOS app: a tap beside the fields,
|
||||
* or a drag of the screen. A web view keeps it open for both, and on the claim
|
||||
* screen the keyboard covered the hints and the terms with no way to put it away.
|
||||
*/
|
||||
function letKeyboardClose(): void {
|
||||
const isField = (target: EventTarget | null) =>
|
||||
target instanceof Element && !!target.closest('input, textarea, select, [contenteditable="true"], .field');
|
||||
const close = () => {
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement && isField(active)) active.blur();
|
||||
};
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
if (!isField(e.target)) close();
|
||||
}, true);
|
||||
let startY: number | null = null;
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
startY = isField(e.target) ? null : (e.touches[0]?.clientY ?? null);
|
||||
}, { passive: true });
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
const y = e.touches[0]?.clientY;
|
||||
if (startY !== null && y !== undefined && Math.abs(y - startY) > 10) {
|
||||
startY = null;
|
||||
close();
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the remembered server failed for want of a client certificate. Asked
|
||||
* only when the app saw that server ask for one in this run, and then checked
|
||||
* once more, so a server that is merely offline is not taken for one that
|
||||
* refused the certificate.
|
||||
*/
|
||||
async function certificateDemand(server: string): Promise<ClientCertDemand | null> {
|
||||
try {
|
||||
const { request } = await TrekShell.clientCertificate();
|
||||
if (!request || request.host !== new URL(server).hostname.toLowerCase()) return null;
|
||||
const result = await probeServer(server, get);
|
||||
return result.ok ? null : (result.cert ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
void paintStatusBar();
|
||||
followBackGesture();
|
||||
letKeyboardClose();
|
||||
let url: string | null;
|
||||
try {
|
||||
({ url } = await TrekShell.getServer());
|
||||
@@ -974,11 +1335,18 @@ async function start(): Promise<void> {
|
||||
if (!url) {
|
||||
showWelcome();
|
||||
} else {
|
||||
// Only reached when loading the server failed. On iOS the stored copy of
|
||||
// its start page brings the offline-capable UI up anyway.
|
||||
const { opened } = await TrekShell.openOfflineCopy();
|
||||
if (opened) return;
|
||||
showUnreachable(url);
|
||||
// Only reached when loading the server failed. A proxy that wanted a client
|
||||
// certificate says so here, before the offline copy would start a UI that
|
||||
// cannot reach its server.
|
||||
const cert = await certificateDemand(url);
|
||||
if (cert) {
|
||||
showUnreachable(url, cert);
|
||||
} else {
|
||||
// On iOS the stored copy of its start page brings the offline-capable UI up anyway.
|
||||
const { opened } = await TrekShell.openOfflineCopy();
|
||||
if (opened) return;
|
||||
showUnreachable(url);
|
||||
}
|
||||
}
|
||||
await SplashScreen.hide();
|
||||
}
|
||||
|
||||
+368
-42
@@ -38,6 +38,45 @@
|
||||
font-display: swap;
|
||||
src: url('./fonts/museomoderno-latin-600.woff2') format('woff2');
|
||||
}
|
||||
/*
|
||||
* Poppins and MuseoModerno have no Cyrillic, so Russian and Ukrainian fell back
|
||||
* to the system's plain sans. Each family gets a Cyrillic-only stand-in under its
|
||||
* own name, picked for the same build: Montserrat for the geometric text face,
|
||||
* Comfortaa for the rounded headline. The unicode-range keeps Latin on TREK's
|
||||
* own faces and loads these only when a Cyrillic letter is on screen.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./fonts/montserrat-cyrillic-500.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/montserrat-cyrillic-600.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/montserrat-cyrillic-700.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'MuseoModerno';
|
||||
font-style: normal;
|
||||
font-weight: 600 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/comfortaa-cyrillic-700.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* Italic, subset to the letters of "cloud" — the wordmark's badge, nothing else. */
|
||||
@font-face {
|
||||
font-family: 'MuseoModerno';
|
||||
@@ -64,6 +103,19 @@
|
||||
/* The hairline the ambient grid is drawn in, and a travel-dusk wash for depth. */
|
||||
--grid: rgba(255, 255, 255, 0.055);
|
||||
--wash-1: rgba(129, 140, 248, 0.12);
|
||||
/* Mixes with transparent, spelled out per scheme: older Android web views (Chrome 113)
|
||||
resolved color-mix() towards transparent as opaque and drew solid halos and rings. */
|
||||
--accent-ink-a16: rgba(9, 9, 11, 0.16);
|
||||
--accent-ink-a35: rgba(9, 9, 11, 0.35);
|
||||
--danger-a16: rgba(248, 113, 113, 0.16);
|
||||
--ink-a22: rgba(250, 250, 250, 0.22);
|
||||
--ink-a3: rgba(250, 250, 250, 0.03);
|
||||
--ink-a4: rgba(250, 250, 250, 0.04);
|
||||
--ink-a9: rgba(250, 250, 250, 0.09);
|
||||
--ok-a16: rgba(74, 222, 128, 0.16);
|
||||
--ok-a22: rgba(74, 222, 128, 0.22);
|
||||
--wash-1-a85: rgba(129, 140, 248, 0.102);
|
||||
--wash-1-a90: rgba(129, 140, 248, 0.108);
|
||||
--wash-2: rgba(244, 114, 182, 0.08);
|
||||
--veil-top: rgba(9, 9, 11, 0.4);
|
||||
--veil-mid: rgba(9, 9, 11, 0.72);
|
||||
@@ -71,8 +123,13 @@
|
||||
--radius: 16px;
|
||||
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
/* 1 left to right, -1 in Arabic: a screen that comes next slides in from the trailing side. */
|
||||
--flow: 1;
|
||||
color-scheme: dark;
|
||||
}
|
||||
:root[dir='rtl'] {
|
||||
--flow: -1;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
@@ -91,15 +148,29 @@
|
||||
--ok: #16a34a;
|
||||
--grid: rgba(17, 24, 39, 0.075);
|
||||
--wash-1: rgba(79, 70, 229, 0.1);
|
||||
--accent-ink-a16: rgba(255, 255, 255, 0.16);
|
||||
--accent-ink-a35: rgba(255, 255, 255, 0.35);
|
||||
--danger-a16: rgba(220, 38, 38, 0.16);
|
||||
--ink-a22: rgba(17, 24, 39, 0.22);
|
||||
--ink-a3: rgba(17, 24, 39, 0.03);
|
||||
--ink-a4: rgba(17, 24, 39, 0.04);
|
||||
--ink-a9: rgba(17, 24, 39, 0.09);
|
||||
--ok-a16: rgba(22, 163, 74, 0.16);
|
||||
--ok-a22: rgba(22, 163, 74, 0.22);
|
||||
--wash-1-a85: rgba(79, 70, 229, 0.085);
|
||||
--wash-1-a90: rgba(79, 70, 229, 0.09);
|
||||
--wash-2: rgba(219, 39, 119, 0.07);
|
||||
--veil-top: rgba(255, 255, 255, 0.12);
|
||||
--veil-mid: rgba(255, 255, 255, 0.74);
|
||||
color-scheme: light;
|
||||
}
|
||||
.shell-logo,
|
||||
.mark {
|
||||
filter: none;
|
||||
}
|
||||
/* The app icon is drawn white; on the light surface it turns black. */
|
||||
.shell-logo {
|
||||
filter: brightness(0);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -121,6 +192,9 @@ body {
|
||||
text-rendering: optimizeLegibility;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
/* A double tap on a button zoomed the screen on iOS. An app does not zoom:
|
||||
taps stay taps, panning still scrolls. */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -167,6 +241,18 @@ body::after {
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
/* In Arabic the photo holds the leading (right) side and the grid the other. */
|
||||
:root[dir='rtl'] body::after {
|
||||
background-position:
|
||||
0 0,
|
||||
right 28%;
|
||||
-webkit-mask-image: linear-gradient(270deg, #000 0%, #000 40%, transparent 80%);
|
||||
mask-image: linear-gradient(270deg, #000 0%, #000 40%, transparent 80%);
|
||||
}
|
||||
:root[dir='rtl'] body::before {
|
||||
-webkit-mask-image: radial-gradient(135% 95% at 0% 0%, #000 34%, transparent 80%);
|
||||
mask-image: radial-gradient(135% 95% at 0% 0%, #000 34%, transparent 80%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
@@ -175,11 +261,12 @@ body::after {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
max-width: 440px;
|
||||
margin: 0 auto;
|
||||
padding: calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 24px);
|
||||
overflow: hidden;
|
||||
/* Clip only the sideways slide: a screen taller than the window scrolls the
|
||||
page instead (and a sticky column needs an ancestor that does not scroll). */
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* ── Screen transitions ──────────────────────────────────────────────────── */
|
||||
@@ -187,14 +274,22 @@ body::after {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
/* Centred while it fits; taller than the window it starts at the top and the
|
||||
page scrolls, rather than overflowing both edges as justify-content would. */
|
||||
margin-block: auto;
|
||||
}
|
||||
/* A screen's two halves (main.ts, columns()): one column unless the layout asks. */
|
||||
.col {
|
||||
display: contents;
|
||||
}
|
||||
.screen-enter {
|
||||
animation: screenIn 0.5s var(--ease) both;
|
||||
}
|
||||
/* Lifted out where it stood: main.ts sets its top, left and width. */
|
||||
.screen-leave {
|
||||
animation: screenOut 0.28s var(--ease-in) both;
|
||||
position: absolute;
|
||||
inset: calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 24px);
|
||||
margin: 0;
|
||||
}
|
||||
.screen-back.screen-enter {
|
||||
animation-name: screenInBack;
|
||||
@@ -205,7 +300,7 @@ body::after {
|
||||
@keyframes screenIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(26px);
|
||||
transform: translateX(calc(26px * var(--flow)));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -215,13 +310,13 @@ body::after {
|
||||
@keyframes screenOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-22px);
|
||||
transform: translateX(calc(-22px * var(--flow)));
|
||||
}
|
||||
}
|
||||
@keyframes screenInBack {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-26px);
|
||||
transform: translateX(calc(-26px * var(--flow)));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -231,14 +326,40 @@ body::after {
|
||||
@keyframes screenOutBack {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(22px);
|
||||
transform: translateX(calc(22px * var(--flow)));
|
||||
}
|
||||
}
|
||||
|
||||
/* Children stagger in, so a screen assembles rather than snaps. */
|
||||
.screen-enter > * {
|
||||
/* Children stagger in, so a screen assembles rather than snaps. Inside the two
|
||||
halves the explanation comes first and the choices follow. */
|
||||
.screen-enter > :not(.col),
|
||||
.screen-enter > .col > * {
|
||||
animation: riseIn 0.55s var(--ease) both;
|
||||
}
|
||||
.screen-enter > .col > :nth-child(1) {
|
||||
animation-delay: 0.02s;
|
||||
}
|
||||
.screen-enter > .col > :nth-child(2) {
|
||||
animation-delay: 0.07s;
|
||||
}
|
||||
.screen-enter > .col > :nth-child(3) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
.screen-enter > .col > :nth-child(4) {
|
||||
animation-delay: 0.17s;
|
||||
}
|
||||
.screen-enter > .col-act > :nth-child(1) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.screen-enter > .col-act > :nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.screen-enter > .col-act > :nth-child(3) {
|
||||
animation-delay: 0.25s;
|
||||
}
|
||||
.screen-enter > .col-act > :nth-child(4) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.screen-enter > *:nth-child(1) {
|
||||
animation-delay: 0.02s;
|
||||
}
|
||||
@@ -307,16 +428,16 @@ body::after {
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
animation: floaty 6s ease-in-out infinite;
|
||||
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
|
||||
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
|
||||
filter: drop-shadow(0 14px 36px var(--wash-1-a90))
|
||||
drop-shadow(0 6px 16px var(--ink-a22));
|
||||
}
|
||||
|
||||
/* The full TREK wordmark on the welcome screen, in the variant that reads on the scheme. */
|
||||
.hero-logo {
|
||||
display: block;
|
||||
margin-bottom: 22px;
|
||||
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
|
||||
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
|
||||
filter: drop-shadow(0 14px 36px var(--wash-1-a90))
|
||||
drop-shadow(0 6px 16px var(--ink-a22));
|
||||
animation: logoRise 0.7s var(--ease) both;
|
||||
}
|
||||
.hero-logo img {
|
||||
@@ -364,6 +485,7 @@ body::after {
|
||||
margin: 0;
|
||||
/* Honour the newline in the headline string (e.g. "Deine Reisen.\nDein Plan."). */
|
||||
white-space: pre-line;
|
||||
text-wrap: balance;
|
||||
}
|
||||
/* The welcome headline as the brand says it: MuseoModerno, lower case. */
|
||||
.brand-title {
|
||||
@@ -380,6 +502,8 @@ body::after {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
/* Left to right inside (dir="ltr"), yet on the screen's start edge in Arabic. */
|
||||
align-self: flex-start;
|
||||
}
|
||||
.cloud-wordmark img {
|
||||
display: block;
|
||||
@@ -414,6 +538,7 @@ body::after {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.sub {
|
||||
color: var(--muted);
|
||||
@@ -421,6 +546,8 @@ body::after {
|
||||
line-height: 1.55;
|
||||
font-size: 0.9rem;
|
||||
overflow-wrap: anywhere;
|
||||
/* No lone last word ("AI.") on a line of its own. */
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
@@ -428,9 +555,10 @@ body::after {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 1.02rem;
|
||||
padding: 16px 18px;
|
||||
padding: 16px 22px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
/* A pill, as the app's buttons are. */
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
cursor: pointer;
|
||||
@@ -455,13 +583,18 @@ body::after {
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
/* A two-line choice (welcome) is a card rather than a button: the app's card radius. */
|
||||
.btn:has(.btn-row) {
|
||||
border-radius: 22px;
|
||||
padding-inline: 18px;
|
||||
}
|
||||
.btn-ghost {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
border-color: var(--edge);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.22),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
inset 0 0 0 1px var(--ink-a4);
|
||||
}
|
||||
.btn-ghost:active {
|
||||
background: var(--surface-2);
|
||||
@@ -471,6 +604,8 @@ body::after {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
/* A subtitle that wraps keeps to the start edge under its title. */
|
||||
text-align: start;
|
||||
}
|
||||
.btn-row b {
|
||||
font-weight: 650;
|
||||
@@ -481,9 +616,20 @@ body::after {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.btn .chev {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
opacity: 0.5;
|
||||
}
|
||||
/* A centred label with the chevron at the far edge, the label optically centred. */
|
||||
.btn-go .btn-label {
|
||||
flex: 1;
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
/* Chevrons point along the reading direction. */
|
||||
:root[dir='rtl'] .chev,
|
||||
:root[dir='rtl'] .back svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.linkbtn {
|
||||
font: inherit;
|
||||
@@ -507,7 +653,7 @@ body::after {
|
||||
margin-top: 2px;
|
||||
padding: 9px 18px;
|
||||
min-height: 40px;
|
||||
border-radius: var(--radius);
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--edge);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
@@ -565,15 +711,15 @@ a.linkbtn {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
padding: 13px 16px;
|
||||
border-radius: var(--radius);
|
||||
padding: 13px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--edge);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.18),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
inset 0 1px 0 var(--ink-a4);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.25s;
|
||||
@@ -585,11 +731,11 @@ a.linkbtn {
|
||||
}
|
||||
.field input:focus {
|
||||
border-color: var(--edge-strong);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
|
||||
box-shadow: 0 0 0 4px var(--ink-a9);
|
||||
}
|
||||
.field.bad input {
|
||||
border-color: var(--danger);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
box-shadow: 0 0 0 4px var(--danger-a16);
|
||||
animation: shake 0.3s;
|
||||
}
|
||||
@keyframes shake {
|
||||
@@ -613,11 +759,48 @@ a.linkbtn {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
/* A sign-in proxy in front of the server: which one, sign in to it here, or let the app through. */
|
||||
.gate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 22px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--edge);
|
||||
text-align: start;
|
||||
}
|
||||
.gate[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.gate-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.gate-text {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.gate-note {
|
||||
margin: 0;
|
||||
color: var(--faint);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.gate a {
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Spinner inside the connect button while probing. */
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid color-mix(in srgb, var(--accent-ink) 35%, transparent);
|
||||
border: 2px solid var(--accent-ink-a35);
|
||||
border-top-color: var(--accent-ink);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
@@ -672,7 +855,7 @@ a.linkbtn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--ok) 16%, transparent);
|
||||
background: var(--ok-a16);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 8px;
|
||||
@@ -707,7 +890,8 @@ a.linkbtn {
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 0 2px -8px;
|
||||
margin-block: 0 2px;
|
||||
margin-inline-start: -8px;
|
||||
}
|
||||
.back {
|
||||
font: inherit;
|
||||
@@ -741,7 +925,7 @@ a.linkbtn {
|
||||
padding: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 13px;
|
||||
border-radius: 999px;
|
||||
align-self: stretch;
|
||||
}
|
||||
.segmented button {
|
||||
@@ -751,7 +935,7 @@ a.linkbtn {
|
||||
font-size: 0.95rem;
|
||||
padding: 9px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
@@ -772,11 +956,11 @@ a.linkbtn {
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ok) 22%, transparent);
|
||||
background: var(--ok-a22);
|
||||
color: var(--ok);
|
||||
}
|
||||
.segmented button[aria-pressed='true'] .save {
|
||||
background: color-mix(in srgb, var(--accent-ink) 16%, transparent);
|
||||
background: var(--accent-ink-a16);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
@@ -792,7 +976,7 @@ a.linkbtn {
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
0 2px 10px -4px rgba(0, 0, 0, 0.3),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--ink) 3%, transparent);
|
||||
inset 0 0 0 1px var(--ink-a3);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s,
|
||||
@@ -800,7 +984,7 @@ a.linkbtn {
|
||||
}
|
||||
.plan.featured {
|
||||
box-shadow:
|
||||
0 10px 30px -10px color-mix(in srgb, var(--wash-1) 85%, transparent),
|
||||
0 10px 30px -10px var(--wash-1-a85),
|
||||
0 2px 8px -3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.plan:active {
|
||||
@@ -829,7 +1013,7 @@ a.linkbtn {
|
||||
font-weight: 500;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-left: 7px;
|
||||
margin-inline-start: 7px;
|
||||
}
|
||||
.plan-feats {
|
||||
margin: 10px 0 14px;
|
||||
@@ -902,39 +1086,45 @@ a.linkbtn {
|
||||
.slug-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 16px;
|
||||
border-radius: var(--radius);
|
||||
padding-inline-end: 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--edge);
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.18),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
inset 0 1px 0 var(--ink-a4);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.25s;
|
||||
}
|
||||
.slug-field .slug-input {
|
||||
.slug-field .slug-input,
|
||||
.slug-field .slug-input:focus {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
/* The ring is the whole field's (focus-within below), not a second one inside it. */
|
||||
box-shadow: none;
|
||||
}
|
||||
/* An email is typed left to right, but its Arabic placeholder reads from the right. */
|
||||
:root[dir='rtl'] .field input.ltr-value:placeholder-shown {
|
||||
direction: rtl;
|
||||
}
|
||||
.slug-field:focus-within {
|
||||
border-color: var(--edge-strong);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
|
||||
box-shadow: 0 0 0 4px var(--ink-a9);
|
||||
}
|
||||
.slug-field.bad {
|
||||
border-color: var(--danger);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
box-shadow: 0 0 0 4px var(--danger-a16);
|
||||
animation: shake 0.3s;
|
||||
}
|
||||
.slug-suffix {
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
padding-left: 4px;
|
||||
padding-inline-start: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
.hint {
|
||||
@@ -976,3 +1166,139 @@ a.linkbtn {
|
||||
border-color: var(--edge-strong);
|
||||
border-top-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── A large screen ──────────────────────────────────────────────────────────
|
||||
On its side (an iPad, a foldable unfolded) the photo becomes a panel of its
|
||||
own on the leading half, full height, and the column of choices stands
|
||||
centred in the other half, the way a split sign-in page uses the width.
|
||||
Upright, the photo band grows with the screen instead of stopping at a
|
||||
phone's 440px. Logical sides, so Arabic mirrors it. */
|
||||
@media (min-width: 840px) and (min-height: 600px) and (orientation: landscape) {
|
||||
body::after,
|
||||
:root[dir='rtl'] body::after {
|
||||
inset: 16px auto 16px 16px;
|
||||
inset-block: 16px;
|
||||
inset-inline: 16px auto;
|
||||
width: calc(50% - 24px);
|
||||
height: auto;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(180deg, transparent 50%, rgba(0, 0, 0, 0.32) 100%),
|
||||
url('./img/kyoto.webp') 30% 45% / cover no-repeat;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
box-shadow: 0 30px 70px -30px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
body::before {
|
||||
inset: 0;
|
||||
inset-inline-start: 50%;
|
||||
height: auto;
|
||||
}
|
||||
.shell {
|
||||
max-width: none;
|
||||
width: 50%;
|
||||
margin-inline: 50% 0;
|
||||
padding-inline: max(32px, calc(25vw - 220px));
|
||||
}
|
||||
}
|
||||
/* With the whole half of a 13" iPad on its side, the plans stand two by two
|
||||
rather than in a column to scroll through. */
|
||||
@media (min-width: 1200px) and (min-height: 600px) and (orientation: landscape) {
|
||||
.shell:has(.plans) {
|
||||
padding-inline: max(32px, calc(25vw - 320px));
|
||||
}
|
||||
.plans {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
/* An iPad upright, a foldable unfolded, half of an iPad in Split View: the photo
|
||||
spans the whole width as a band across the top and fades out only downwards,
|
||||
and the choices stand under it in a wider column than a phone's, centred in
|
||||
the room the band leaves, so the page reads as one composition rather than a
|
||||
phone screen at the top of the glass. The plans take two columns. */
|
||||
@media (min-width: 600px) and (min-height: 800px) and (orientation: portrait) {
|
||||
body::before,
|
||||
body::after {
|
||||
height: 56vh;
|
||||
}
|
||||
body::after,
|
||||
:root[dir='rtl'] body::after {
|
||||
background-position:
|
||||
0 0,
|
||||
center 30%;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
.shell {
|
||||
max-width: 560px;
|
||||
padding-top: calc(env(safe-area-inset-top) + 28vh);
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 6vh);
|
||||
}
|
||||
.shell:has(.plans) {
|
||||
max-width: 760px;
|
||||
padding-top: calc(env(safe-area-inset-top) + 16vh);
|
||||
}
|
||||
.plans {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── A short screen on its side ──────────────────────────────────────────────
|
||||
A phone, or a foldable folded, turned landscape: one column of choices is
|
||||
taller than the glass, and the second choice or the main button ended up
|
||||
below the edge. The two halves of a screen stand side by side instead, what
|
||||
it is on the leading side and what to do on the other, top-aligned under the
|
||||
status bar. The leading half stays put while a long list beside it (the
|
||||
plans) scrolls, so Back is always in reach. */
|
||||
@media (orientation: landscape) and (max-height: 500px) {
|
||||
.shell {
|
||||
max-width: 880px;
|
||||
padding-top: calc(env(safe-area-inset-top) + 16px);
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 16px);
|
||||
padding-inline: max(28px, env(safe-area-inset-left)) max(28px, env(safe-area-inset-right));
|
||||
}
|
||||
.screen:has(> .col) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
column-gap: 40px;
|
||||
align-items: center;
|
||||
}
|
||||
/* The plans run longer than the window: both halves start at the top. */
|
||||
.screen:has(.plans) {
|
||||
align-items: start;
|
||||
}
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.col-about {
|
||||
position: sticky;
|
||||
top: calc(env(safe-area-inset-top) + 16px);
|
||||
}
|
||||
.hero-logo {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.hero-logo img {
|
||||
height: 46px;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.btn {
|
||||
min-height: 50px;
|
||||
padding-block: 12px;
|
||||
}
|
||||
.features {
|
||||
margin: 0;
|
||||
}
|
||||
.feature {
|
||||
padding: 7px 0;
|
||||
}
|
||||
.foot-links {
|
||||
margin-top: 0;
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "5.1.0",
|
||||
"version": "5.1.2",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
@@ -53,9 +53,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@capacitor/action-sheet": "^8.1.1",
|
||||
"@capacitor/android": "^8.5.0",
|
||||
"@capacitor/app": "^8.1.1",
|
||||
"@capacitor/core": "^8.5.0",
|
||||
"@capacitor/dialog": "^8.0.1",
|
||||
"@capacitor/filesystem": "^8.1.3",
|
||||
"@capacitor/haptics": "^8.0.2",
|
||||
"@capacitor/ios": "^8.5.0",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import autoprefixer from 'autoprefixer';
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import touchHover from './postcss/touchHover.js';
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
// touchHover last: it guards the :hover rules Tailwind has just written too.
|
||||
plugins: [tailwindcss(), autoprefixer(), touchHover()],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Hover styles only while a pointer drives the UI.
|
||||
*
|
||||
* A finger has no hover, yet WebKit and Chrome apply :hover to whatever was
|
||||
* tapped and keep it there until the next tap lands somewhere else. In the
|
||||
* iOS and Android app that left buttons and rows tinted after every tap, a
|
||||
* tell of a web page that no native app has. The app marks <html> with
|
||||
* data-touch-input while a finger is in use (src/native/touchInput.ts), and
|
||||
* this plugin makes every :hover rule, Tailwind's and our own, wait for that
|
||||
* mark to be gone. A browser never sets it, so nothing changes there, and an
|
||||
* iPad with a trackpad clears it on the first pointer move.
|
||||
*
|
||||
* The condition sits on the hovered element itself, wrapped in :where(), so a
|
||||
* rule keeps its specificity and still wins or loses where it did before. A
|
||||
* :hover after a pseudo-element (::-webkit-slider-thumb:hover) stays as it is:
|
||||
* nothing may follow it there, and a rule with an invalid selector is dropped
|
||||
* whole.
|
||||
*/
|
||||
|
||||
export const TOUCH_INPUT_ATTR = 'data-touch-input';
|
||||
const GUARD = `:where(:root:not([${TOUCH_INPUT_ATTR}]) *)`;
|
||||
// :hover itself, not the escaped `hover\:` inside a Tailwind class name, and not :hover-something.
|
||||
const HOVER = /(?<!\\):hover(?![\w-])/g;
|
||||
const COMPOUND_START = /[\s>+~(,]/;
|
||||
|
||||
function compoundBefore(selector, index) {
|
||||
let i = index - 1;
|
||||
while (i >= 0 && !COMPOUND_START.test(selector[i])) i--;
|
||||
return selector.slice(i + 1, index);
|
||||
}
|
||||
|
||||
export function guardHover(selector) {
|
||||
if (selector.includes(GUARD)) return selector;
|
||||
return selector.replace(HOVER, (match, offset) =>
|
||||
compoundBefore(selector, offset).includes('::') ? match : `${match}${GUARD}`
|
||||
);
|
||||
}
|
||||
|
||||
export default function touchHover() {
|
||||
return {
|
||||
postcssPlugin: 'trek-touch-hover',
|
||||
Rule(rule) {
|
||||
if (!rule.selector.includes(':hover')) return;
|
||||
const next = rule.selectors.map(guardHover);
|
||||
if (next.some((s, i) => s !== rule.selectors[i])) rule.selectors = next;
|
||||
},
|
||||
};
|
||||
}
|
||||
touchHover.postcss = true;
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 375,
|
||||
"@typescript-eslint/no-misused-promises": 847,
|
||||
"@typescript-eslint/no-misused-promises": 843,
|
||||
"no-console": 88,
|
||||
"complexity": 218,
|
||||
"@typescript-eslint/no-explicit-any": 144,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 489,
|
||||
"@typescript-eslint/no-non-null-assertion": 1912,
|
||||
"@typescript-eslint/no-non-null-assertion": 1911,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -282,11 +282,17 @@
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx", "src/mobile/screens/trip/tabs/MCostsTab.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Costs/budget ledger: useCostsLedger, useSettlementForm, useExpenseLinks",
|
||||
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts", "src/components/Budget/useExpenseLinks.ts"],
|
||||
"feature": "Costs/budget ledger: useCostsLedger, useExpenseLinks",
|
||||
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useExpenseLinks.ts"],
|
||||
"desktop": ["src/components/Budget/CostsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MCostsTab.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Costs/budget settle-up payment: useSettlementForm",
|
||||
"hooks": ["src/components/Budget/useSettlementForm.ts"],
|
||||
"desktop": ["src/components/Budget/CostsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MCostsPayment.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Create/edit trip form",
|
||||
"hooks": ["src/components/Trips/tripFormModel.ts", "src/components/Trips/useTripCoverPicker.ts"],
|
||||
@@ -663,6 +669,12 @@
|
||||
"desktop": ["src/components/Roadtrip/RoadtripStopPopup.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Road-trip Google Maps route import",
|
||||
"hooks": ["src/components/Roadtrip/useGoogleRouteImport.ts"],
|
||||
"desktop": ["src/components/Roadtrip/GoogleRouteImport.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtRouteImportSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Road-trip stay length draft",
|
||||
"hooks": ["src/components/Roadtrip/useStayDraft.ts"],
|
||||
@@ -822,6 +834,12 @@
|
||||
"desktop": ["src/components/shared/Toast.tsx"],
|
||||
"mobile": ["src/mobile/components/MToastHost.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Tour editor workspace",
|
||||
"hooks": ["src/pages/tripPlanner/useTripPlannerPage.ts"],
|
||||
"desktop": ["src/pages/TripPlannerPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tours/MTourWorkspace.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Tours list filter",
|
||||
"hooks": ["src/components/Tours/tourPresentation.ts"],
|
||||
@@ -1002,12 +1020,15 @@
|
||||
"src/mobile/screens/collections/MCollInvites.tsx": "Markup only; the invites and accept and decline come through props from useCollections via MCollections.",
|
||||
"src/mobile/screens/collections/MCollLabelsSheet.tsx": "Phone label sheet; create, update, delete and assign come through props from useCollections.",
|
||||
"src/mobile/screens/collections/MCollLinksEditor.tsx": "Markup only; link rows and the change handler come through props from the collection sheets.",
|
||||
"src/mobile/screens/collections/MCollListNav.tsx": "Markup only; the collections' lists as the header dropdown or as the column beside the places on a wide window, with the lists and the selection handed in through props from useCollections.",
|
||||
"src/mobile/screens/collections/MCollListPickerSheet.tsx": "Phone list picker sheet; the move or copy action comes through onPick from useCollections.",
|
||||
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
|
||||
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
|
||||
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
|
||||
"src/mobile/screens/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.",
|
||||
"src/mobile/screens/journey/MJourneyFeed.tsx": "Markup only; the journey's entries as a feed beside the map on a wide window, with the entries, the active one and the tap handed in through props from useJourneyDetail and useJourneyCarouselSync.",
|
||||
"src/mobile/screens/settings/MAppLockCard.tsx": "Phone only: the app lock exists only in the native app, so there is no desktop setting to share with.",
|
||||
"src/mobile/screens/settings/MClientCertCard.tsx": "Phone only: the client certificate is kept by the native app, so there is no desktop setting to share with.",
|
||||
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
|
||||
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
|
||||
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
|
||||
@@ -1018,6 +1039,9 @@
|
||||
"src/mobile/screens/settings/MSettingsUi.tsx": "Phone only settings UI primitives (card, row, input, button, segments, hint).",
|
||||
"src/mobile/screens/settings/MTileCacheUsage.tsx": "Phone only: the native tile cache size and clear action, which the browser build has no counterpart for.",
|
||||
"src/mobile/screens/settings/MWebPushCard.tsx": "Already shares hooks/useWebPush with components/Settings/WebPushCard.tsx.",
|
||||
"src/mobile/screens/trip/tabHeadSlot.ts": "Phone shell only; the place in the shell's top row the tours tab and the journal fill with their own switches. The desktop heads its panels itself.",
|
||||
"src/mobile/screens/trip/MTripRail.tsx": "Phone shell only; the trip's sections on the leading edge of a wide window in place of the dock, with the tabs and handlers MTripShell hands in.",
|
||||
"src/mobile/screens/trip/MTripShellParts.tsx": "Markup only; small pieces of MTripShell's top controls (segment pill, compact toggle, document sync button), with the state and handlers MTripShell hands in.",
|
||||
"src/mobile/screens/trip/dockTabs.ts": "Phone only helper that picks which trip tabs get a seat in the mobile dock.",
|
||||
"src/mobile/screens/trip/lib/dayNotes.ts": "Phone only helper that splits a leading time off a day note for the plan timeline.",
|
||||
"src/mobile/screens/trip/map/MMapArea.tsx": "Phone map stage framing; already shares usePoiExplore and useMergedMapPois with the desktop trip planner.",
|
||||
@@ -1035,8 +1059,10 @@
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": "Markup only; its corridor state comes through props from useMRtCorridor.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": "Phone corridor search sheet over useMRtCorridor and the planner's shared corridor state.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": "Read only figures sheet that already shares useHotelBookends, dayWindow and parseAvoid with RoadtripLimitsCard.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx": "Large screen form inside MRtInfoSheet over the planner's saveRoadtripLimit, the desktop dialog's own save, and the shared dayWindow, parseAvoid and rangeFromSpec.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtTrackSheet.tsx": "Large screen sheet over the planner's followTrack, the same hook RoadtripTrackModal runs on; the phone has no entry to it.",
|
||||
"src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.",
|
||||
"src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": "Phone stage screen adapter over the shared roadtripRowModel plus a live clock tick.",
|
||||
"src/mobile/screens/trip/roadtrip/useMRtAlternatives.ts": "Tap to preview, confirm to write layer over the planner's shared route alternatives.",
|
||||
@@ -1053,10 +1079,13 @@
|
||||
"src/mobile/screens/trip/sheets/PlTimeFields.tsx": "Phone time fields that already share timeCollisions with the desktop PlaceFormModal.",
|
||||
"src/mobile/screens/trip/sheets/tourSheetOpen.ts": "Phone only context for MTourDetailShell: whether the tour sheet is open and the name it carries.",
|
||||
"src/mobile/screens/trip/tabs/MCollabTab.tsx": "Phone collab sub tab router that reuses the desktop CollabLinks component.",
|
||||
"src/mobile/screens/trip/tabs/MFilesBoard.tsx": "Markup only; the trip's files as tiles and the picked file's preview on a wide window, with the files and their actions handed in through props from MFilesTab.",
|
||||
"src/mobile/screens/trip/tabs/MListsTab.tsx": "Phone router between the packing and to-do bodies, with no logic of its own.",
|
||||
"src/mobile/screens/trip/tabs/MReceiptScanButton.tsx": "Already shares useReceiptScan and ReceiptScanModal with the desktop CostsPanel.",
|
||||
"src/mobile/screens/trip/tabs/MReservationCardParts.tsx": "Markup only: the title, actions, date, files and delete sheet parts both phone booking tabs draw.",
|
||||
"src/mobile/screens/trip/tabs/MTodoSidebar.tsx": "Markup only; the to-do filters as a pane beside the tasks on a wide window, with the filter state and the counts handed in through props from useTodoView.",
|
||||
"src/mobile/screens/trip/tabs/MTripTabPanel.tsx": "Phone tab router that mounts the per tab screens and plugin frames, with no logic of its own.",
|
||||
"src/mobile/screens/trip/tabs/collabPanes.ts": "Phone only; decides whether the collab tab shows the chat beside a board on a wide window, a layout question of the touch shell that the desktop planner does not have.",
|
||||
"src/mobile/screens/trip/tabs/listsModel.ts": "Priority colour and label constants only the phone to-do list and task sheet use.",
|
||||
"src/mobile/screens/trip/tabs/tabChrome.tsx": "Phone tab chrome primitives (scroller, section headers, fields, traveler chips, plugin slots).",
|
||||
"src/mobile/screens/trip/tabs/tabModel.ts": "Phone tab prop type and status colour tokens only the phone tabs use.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"admin.ts": 112,
|
||||
"atlas.ts": 9,
|
||||
"backup.ts": 10,
|
||||
"budget.ts": 36,
|
||||
"budget.ts": 35,
|
||||
"categories.ts": 1,
|
||||
"collab.ts": 9,
|
||||
"collection.ts": 35,
|
||||
@@ -35,7 +35,7 @@
|
||||
"shared.ts": 2,
|
||||
"storage.ts": 16,
|
||||
"system_notice.ts": 32,
|
||||
"todo.ts": 3,
|
||||
"todo.ts": 2,
|
||||
"tours.ts": 12,
|
||||
"trip.ts": 10,
|
||||
"undo.ts": 1,
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
"components/Map/DawarichTrailPill.tsx": 1,
|
||||
"components/Map/LocationButton.tsx": 1,
|
||||
"components/Map/MapTogglePill.tsx": 1,
|
||||
"components/Map/MapView.tsx": 6,
|
||||
"components/Map/MapViewGL.tsx": 8,
|
||||
"components/Map/MapView.tsx": 5,
|
||||
"components/Map/MapViewGL.tsx": 7,
|
||||
"components/Map/NightPauseTooltip.tsx": 1,
|
||||
"components/Map/PlaceHoverCard.tsx": 1,
|
||||
"components/Map/PoiCategoryPill.tsx": 1,
|
||||
@@ -34,6 +34,7 @@
|
||||
"components/Map/locationMarkerMapbox.ts": 2,
|
||||
"components/Map/nightPauseMarker.ts": 4,
|
||||
"components/Map/ratingBadge.ts": 1,
|
||||
"components/Map/switcherRow.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 7,
|
||||
"components/PDF/tripMapImage.ts": 1,
|
||||
"components/Packing/PackingListPanelBagCard.tsx": 1,
|
||||
@@ -79,15 +80,10 @@
|
||||
"hooks/useAnchoredPosition.ts": 3,
|
||||
"hooks/useElementRect.ts": 3,
|
||||
"mobile/components/MSegmented.tsx": 1,
|
||||
"mobile/components/MSheet.tsx": 1,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 1,
|
||||
"mobile/screens/journey/MJourneyDetail.tsx": 3,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 1,
|
||||
"mobile/screens/trip/MTripShell.tsx": 3,
|
||||
"mobile/screens/trip/map/MMapArea.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 1,
|
||||
"mobile/screens/trip/tabs/MFileLightbox.tsx": 2,
|
||||
"mobile/screens/vacay/MVacay.tsx": 1,
|
||||
"mobile/screens/vacay/MVacayMonth.tsx": 1,
|
||||
"pages/AtlasPage.tsx": 2,
|
||||
"pages/JourneyDetailPage.tsx": 1,
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2730,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1214,
|
||||
"src/components/Map/MapView.tsx": 1200,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2127,
|
||||
"src/components/Map/MapViewGL.tsx": 2126,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
||||
@@ -20,7 +20,7 @@
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4028,
|
||||
"src/pages/LoginPage.tsx": 1347,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
"src/pages/atlas/useAtlas.ts": 1210,
|
||||
"src/pages/atlas/useAtlas.ts": 1207,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 1918
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"components/Journey/JourneyDetailPageProviderPicker.tsx": 163,
|
||||
"components/Journey/JourneyDetailPageScrollTrigger.tsx": 4,
|
||||
"components/Journey/JourneyDetailPageVerdictSection.tsx": 51,
|
||||
"components/Journey/JourneyEntryCover.tsx": 15,
|
||||
"components/Journey/JourneyEntryCover.tsx": 13,
|
||||
"components/Journey/JourneyMap.tsx": 3,
|
||||
"components/Journey/JourneyMapGL.tsx": 5,
|
||||
"components/Journey/MobileEntryView.tsx": 94,
|
||||
@@ -60,8 +60,8 @@
|
||||
"components/Map/LocationButton.tsx": 1,
|
||||
"components/Map/MapPluginMarkers.tsx": 2,
|
||||
"components/Map/MapTogglePill.tsx": 1,
|
||||
"components/Map/MapView.tsx": 8,
|
||||
"components/Map/MapViewGL.tsx": 11,
|
||||
"components/Map/MapView.tsx": 7,
|
||||
"components/Map/MapViewGL.tsx": 10,
|
||||
"components/Map/NightPauseTooltip.tsx": 1,
|
||||
"components/Map/PlaceHoverCard.tsx": 1,
|
||||
"components/Map/ReservationOverlay.tsx": 5,
|
||||
@@ -71,6 +71,7 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Map/switcherRow.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 51,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
@@ -146,7 +147,7 @@
|
||||
"mobile/components/MStorageTestResult.tsx": 3,
|
||||
"mobile/components/MToastHost.tsx": 2,
|
||||
"mobile/components/MUserPickerList.tsx": 1,
|
||||
"mobile/screens/admin/MAdmin.tsx": 9,
|
||||
"mobile/screens/admin/MAdmin.tsx": 8,
|
||||
"mobile/screens/admin/MAdminAddonManager.tsx": 9,
|
||||
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
|
||||
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
|
||||
@@ -181,9 +182,9 @@
|
||||
"mobile/screens/collections/MCollShareSheet.tsx": 14,
|
||||
"mobile/screens/collections/MCollSheetKit.tsx": 5,
|
||||
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
|
||||
"mobile/screens/collections/MCollections.tsx": 18,
|
||||
"mobile/screens/collections/MCollections.tsx": 14,
|
||||
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 31,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 30,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 12,
|
||||
"mobile/screens/dashboard/MUserMenu.tsx": 7,
|
||||
"mobile/screens/journey/MJourney.tsx": 10,
|
||||
@@ -202,7 +203,7 @@
|
||||
"mobile/screens/settings/MPhotoProvidersSection.tsx": 2,
|
||||
"mobile/screens/settings/MScopeGroupPicker.tsx": 5,
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 1,
|
||||
"mobile/screens/settings/MSettings.tsx": 2,
|
||||
"mobile/screens/settings/MSettings.tsx": 1,
|
||||
"mobile/screens/settings/MSettingsAbout.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 20,
|
||||
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
|
||||
@@ -214,7 +215,7 @@
|
||||
"mobile/screens/settings/MSettingsUi.tsx": 11,
|
||||
"mobile/screens/settings/MWebPushCard.tsx": 1,
|
||||
"mobile/screens/trip/MTripLoadingSplash.tsx": 2,
|
||||
"mobile/screens/trip/MTripShell.tsx": 9,
|
||||
"mobile/screens/trip/MTripShell.tsx": 7,
|
||||
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
|
||||
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 2,
|
||||
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
|
||||
@@ -262,7 +263,7 @@
|
||||
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
|
||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
|
||||
"mobile/screens/trip/tabs/MCollabTab.tsx": 1,
|
||||
"mobile/screens/trip/tabs/MCostsTab.tsx": 71,
|
||||
"mobile/screens/trip/tabs/MCostsTab.tsx": 70,
|
||||
"mobile/screens/trip/tabs/MDocSyncSheet.tsx": 43,
|
||||
"mobile/screens/trip/tabs/MFileLightbox.tsx": 12,
|
||||
"mobile/screens/trip/tabs/MFileLinkSheet.tsx": 2,
|
||||
|
||||
@@ -36,7 +36,9 @@ import NewVersionNotice from './components/Layout/NewVersionNotice'
|
||||
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
|
||||
import AppLockGate from './components/Layout/AppLockGate'
|
||||
import NativeImportSheet from './mobile/components/NativeImportSheet'
|
||||
import MPushPrompt from './mobile/components/MPushPrompt'
|
||||
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
|
||||
import NativeQuickActionsSync from './components/Layout/NativeQuickActionsSync'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
import HelpPanel from './components/Help/HelpPanel'
|
||||
// Notice action registrations (side-effect imports):
|
||||
@@ -453,7 +455,9 @@ export default function App() {
|
||||
<ErrorBoundary boundaryId="widget:native-update" fallback={null}><NativeUpdateNotice /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:app-lock" fallback={null}><AppLockGate /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:native-import" fallback={null}><NativeImportSheet /></ErrorBoundary>
|
||||
{!hideAuthedWidgets && isPhone && <ErrorBoundary boundaryId="widget:push-prompt" fallback={null}><MPushPrompt /></ErrorBoundary>}
|
||||
<ErrorBoundary boundaryId="widget:native-widgets" fallback={null}><NativeWidgetsSync /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:native-quick-actions" fallback={null}><NativeQuickActionsSync /></ErrorBoundary>
|
||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||
across navigations. react-router runs location updates inside a transition,
|
||||
so a mounted boundary keeps the current page on screen instead of flashing
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-COLLAB-CHATDATA-001 to FE-COLLAB-CHATDATA-016: the trip chat logic behind the desktop
|
||||
// FE-COLLAB-CHATDATA-001 to FE-COLLAB-CHATDATA-018: the trip chat logic behind the desktop
|
||||
// Collab panel (quiet older-page errors, scroll on remote delete, upload progress) and the
|
||||
// phone's chat tab (toasted older-page errors, sending gated on the edit permission).
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
|
||||
import { createElement, type ChangeEvent } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { collabApi } from '../../api/client';
|
||||
@@ -285,3 +285,80 @@ describe('useCollabChatData', () => {
|
||||
expect(toast.error).toHaveBeenCalledWith('common.error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCollabChatData on a box that changes size', () => {
|
||||
// The observer reports when the test says the list's box has changed.
|
||||
let report: () => void = () => {};
|
||||
let observed: Element[] = [];
|
||||
class Observer {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
report = () => callback([], this as unknown as ResizeObserver);
|
||||
}
|
||||
observe(el: Element) {
|
||||
observed.push(el);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The list as both views draw it: only once the messages are in, so at mount there
|
||||
* is no element yet. Its sizes are the test's to set, since jsdom lays nothing out.
|
||||
*/
|
||||
function ChatList({ scrollHeight }: { scrollHeight: () => number }) {
|
||||
const chat = useCollabChatData({
|
||||
tripId: 1,
|
||||
t,
|
||||
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
});
|
||||
const setList = (el: HTMLDivElement | null) => {
|
||||
if (el && !Object.getOwnPropertyDescriptor(el, 'scrollHeight')) {
|
||||
let scrollTop = 0;
|
||||
Object.defineProperty(el, 'scrollHeight', { configurable: true, get: scrollHeight });
|
||||
Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => 400 });
|
||||
Object.defineProperty(el, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (v: number) => {
|
||||
scrollTop = v;
|
||||
},
|
||||
});
|
||||
el.scrollTo = () => {};
|
||||
}
|
||||
(chat.scrollRef as { current: HTMLDivElement | null }).current = el;
|
||||
};
|
||||
if (chat.loading || chat.messages.length === 0) return createElement('p', null, 'loading');
|
||||
return createElement('div', { 'data-testid': 'list', ref: setList, onScroll: chat.checkAtBottom });
|
||||
}
|
||||
|
||||
async function mountList(height: number) {
|
||||
let scrollHeight = height;
|
||||
render(createElement(ChatList, { scrollHeight: () => scrollHeight }));
|
||||
const list = await screen.findByTestId('list');
|
||||
return { list, grow: (to: number) => (scrollHeight = to) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observed = [];
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('FE-COLLAB-CHATDATA-017: a list at its newest message stays there when a rotation or a fold resizes it', async () => {
|
||||
vi.stubGlobal('ResizeObserver', Observer);
|
||||
const { list, grow } = await mountList(400);
|
||||
expect(observed).toContain(list);
|
||||
grow(900);
|
||||
report();
|
||||
expect(list.scrollTop).toBe(900);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-CHATDATA-018: a list read further up keeps its place', async () => {
|
||||
vi.stubGlobal('ResizeObserver', Observer);
|
||||
const { list, grow } = await mountList(2000);
|
||||
list.scrollTop = 200;
|
||||
fireEvent.scroll(list);
|
||||
grow(2600);
|
||||
report();
|
||||
expect(list.scrollTop).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,22 @@ export function useCollabChatData({
|
||||
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
|
||||
}, []);
|
||||
|
||||
// A list that sat at its newest message stays there when its box changes size (a
|
||||
// rotation, a fold, a pane opening beside it): the messages wrap anew and the list
|
||||
// grows, which left the newest ones below the composer. Both views draw the list
|
||||
// only once there are messages to show, so the observer attaches when it appears,
|
||||
// not at mount, when there is nothing yet to observe.
|
||||
const listShown = !loading && messages.length > 0;
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!listShown || !el || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (isAtBottom.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [listShown]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
@@ -77,10 +77,24 @@ describe('CollectionsWidget', () => {
|
||||
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLWIDGET-008: nothing renders while the fetch is still in flight', async () => {
|
||||
render(<CollectionsWidget onOpen={() => {}} />);
|
||||
it('FE-COMP-COLWIDGET-008: only a placeholder shows while the fetch is still in flight', async () => {
|
||||
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
|
||||
|
||||
expect(screen.queryByText('No saved places yet')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('No saved places yet')).toBeInTheDocument());
|
||||
// Nothing remembered yet: the empty hint's shape, hidden from assistive tech, holds the place.
|
||||
expect(container.querySelector('.col-empty.trek-skeleton')).toHaveAttribute('aria-hidden', 'true');
|
||||
await waitFor(() => expect(container.querySelector('.trek-skeleton')).toBeNull());
|
||||
expect(screen.getByText('No saved places yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLWIDGET-009: #3009 holds the badges it showed last time while the lists load, then remembers the new count', async () => {
|
||||
localStorage.setItem('trek_dash_collections_shown', '3');
|
||||
collections([1, 2].map(id => list({ id, name: `List ${id}` })));
|
||||
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
|
||||
|
||||
expect(container.querySelectorAll('.col-badge-pending')).toHaveLength(3);
|
||||
expect(await screen.findByText('List 1')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.col-badge-pending')).toHaveLength(0);
|
||||
await waitFor(() => expect(localStorage.getItem('trek_dash_collections_shown')).toBe('2'));
|
||||
localStorage.clear();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Bookmark, ArrowRight, MapPin } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { entityGradient } from '../../utils/gradients'
|
||||
import { useCollectionLists } from './useCollectionLists'
|
||||
import { readShownCollections, rememberShownCollections } from './collectionsWidgetMemory'
|
||||
|
||||
/**
|
||||
* Dashboard sidebar widget — a glassy `.tool` card that surfaces the user's
|
||||
@@ -16,6 +17,10 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { lists, loading } = useCollectionLists()
|
||||
const [expected] = useState(readShownCollections)
|
||||
useEffect(() => {
|
||||
if (!loading) rememberShownCollections(Math.min(lists.length, 6))
|
||||
}, [loading, lists.length])
|
||||
|
||||
return (
|
||||
<div className="tool">
|
||||
@@ -25,7 +30,15 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{loading ? null : lists.length === 0 ? (
|
||||
{loading ? (
|
||||
expected ? (
|
||||
<div className="col-badges" aria-hidden>
|
||||
{Array.from({ length: expected }, (_, i) => <div key={i} className="col-badge col-badge-pending trek-skeleton" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-empty trek-skeleton" aria-hidden>{t('collections.widget.empty')}</div>
|
||||
)
|
||||
) : lists.length === 0 ? (
|
||||
<div className="col-empty">{t('collections.widget.empty')}</div>
|
||||
) : (
|
||||
<div className="col-badges">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// FE-COMP-COLMEMORY-001 to -002: how many lists the collections widget showed last time.
|
||||
import { readShownCollections, rememberShownCollections } from './collectionsWidgetMemory';
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
describe('collectionsWidgetMemory', () => {
|
||||
it('FE-COMP-COLMEMORY-001: remembers the count, kept within the six badges the widget shows', () => {
|
||||
expect(readShownCollections()).toBeNull();
|
||||
rememberShownCollections(4);
|
||||
expect(readShownCollections()).toBe(4);
|
||||
rememberShownCollections(9);
|
||||
expect(readShownCollections()).toBe(6);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMEMORY-002: ignores a value that is not a count', () => {
|
||||
localStorage.setItem('trek_dash_collections_shown', 'many');
|
||||
expect(readShownCollections()).toBeNull();
|
||||
localStorage.setItem('trek_dash_collections_shown', '42');
|
||||
expect(readShownCollections()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
const KEY = 'trek_dash_collections_shown';
|
||||
|
||||
/**
|
||||
* How many lists the dashboard's collections widget showed last time. While the
|
||||
* lists load, the widget stands in with that many placeholder badges, so it
|
||||
* opens at the height it will have and the widgets under it stay put (#3009).
|
||||
* Null when nothing is remembered yet, or storage is unavailable.
|
||||
*/
|
||||
export function readShownCollections(): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw == null) return null;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isInteger(n) && n >= 0 && n <= 6 ? n : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberShownCollections(count: number): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, String(Math.max(0, Math.min(6, count))));
|
||||
} catch {
|
||||
/* storage blocked: the widget just opens at its empty height next time */
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-FXHOOK-001 to -008: the currency converter both dashboards share.
|
||||
// FE-COMP-FXHOOK-001 to -010: the currency converter both dashboards share.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -13,7 +13,7 @@ const RATES = [
|
||||
const updateSetting = vi.fn<(key: string, value: unknown) => Promise<void>>();
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function seedSettings(over: Record<string, unknown> = {}, isLoaded = false) {
|
||||
function seedSettings(over: Record<string, unknown> = {}, isLoaded = true) {
|
||||
useSettingsStore.setState((s) => ({
|
||||
isLoaded,
|
||||
updateSetting: updateSetting as unknown as typeof s.updateSetting,
|
||||
@@ -133,4 +133,33 @@ describe('useCurrencyConverter', () => {
|
||||
});
|
||||
expect(result.current.rate).toBe(1.3);
|
||||
});
|
||||
|
||||
it('FE-COMP-FXHOOK-009: #3009 waits for the saved pair before it fetches or shows a pair', async () => {
|
||||
seedSettings({}, false);
|
||||
const { result } = renderHook(() => useCurrencyConverter());
|
||||
expect(result.current.pairPending).toBe(true);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
act(() => seedSettings({ dashboard_fx_from: 'JPY', dashboard_fx_to: 'EUR' }));
|
||||
expect(result.current.pairPending).toBe(false);
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(fetchMock.mock.calls[0][0]).toContain('base=JPY');
|
||||
});
|
||||
|
||||
it('FE-COMP-FXHOOK-010: #3009 never prices a new pair with the previous base and says it is still loading', async () => {
|
||||
fetchMock = vi.fn((url: string) =>
|
||||
respond(url.endsWith('base=JPY') ? [{ quote: 'EUR', rate: 0.0056 }] : [{ quote: 'USD', rate: 1.1 }])
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { result } = renderHook(() => useCurrencyConverter());
|
||||
await waitFor(() => expect(result.current.rate).toBe(1.1));
|
||||
|
||||
act(() => seedSettings({ dashboard_fx_from: 'JPY', dashboard_fx_to: 'EUR' }));
|
||||
// EUR's map holds EUR at 1, which used to show as "1 JPY = 1.0000 EUR".
|
||||
expect(result.current.rate).toBeNull();
|
||||
expect(result.current.ratePending).toBe(true);
|
||||
|
||||
await waitFor(() => expect(result.current.rate).toBe(0.0056));
|
||||
expect(result.current.ratePending).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,10 +23,18 @@ export function useCurrencyConverter() {
|
||||
updateSetting('dashboard_fx_to', v).catch(() => {});
|
||||
};
|
||||
const [amount, setAmount] = useState('100');
|
||||
const [rates, setRates] = useState<Record<string, number> | null>(null);
|
||||
// The rates carry the base they were fetched for: the pair can change before
|
||||
// the new base's rates arrive, and the old map priced the new pair wrongly
|
||||
// (EUR's self-rate of 1 showed as "1 JPY = 1.0000 EUR", #3009).
|
||||
const [rates, setRates] = useState<{ base: string; map: Record<string, number> } | null>(null);
|
||||
// The base whose rates could not be had, so a failure belongs to its pair only.
|
||||
const [failedBase, setFailedBase] = useState<string | null>(null);
|
||||
const inFlight = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchRates = useCallback(() => {
|
||||
// Not before the user's pair is known: the defaults' rates were fetched and
|
||||
// shown first, then replaced by the saved pair (#3009).
|
||||
if (!isLoaded) return;
|
||||
inFlight.current?.abort();
|
||||
const controller = new AbortController();
|
||||
inFlight.current = controller;
|
||||
@@ -37,18 +45,22 @@ export function useCurrencyConverter() {
|
||||
if (signal.aborted) return;
|
||||
if (!Array.isArray(d)) {
|
||||
setRates(null);
|
||||
setFailedBase(from);
|
||||
return;
|
||||
}
|
||||
// Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
|
||||
const map: Record<string, number> = { [from]: 1 };
|
||||
for (const r of d) map[r.quote] = r.rate;
|
||||
setRates(map);
|
||||
setFailedBase(null);
|
||||
setRates({ base: from, map });
|
||||
})
|
||||
.catch(() => {
|
||||
// An abort is not a failure: it means nobody is waiting for the answer.
|
||||
if (!signal.aborted) setRates(null);
|
||||
if (signal.aborted) return;
|
||||
setRates(null);
|
||||
setFailedBase(from);
|
||||
});
|
||||
}, [from]);
|
||||
}, [from, isLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRates();
|
||||
@@ -78,13 +90,32 @@ export function useCurrencyConverter() {
|
||||
});
|
||||
}, [isLoaded, updateSetting]);
|
||||
|
||||
const currencies: string[] = rates ? Object.keys(rates).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES;
|
||||
const rate = rates?.[to] ?? null;
|
||||
const current = rates?.base === from ? rates.map : null;
|
||||
const currencies: string[] = current ? Object.keys(current).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES;
|
||||
const rate = current?.[to] ?? null;
|
||||
// The pair is not known yet, or its rates are still on their way: neither is
|
||||
// "rate unavailable", and neither may show a rate.
|
||||
const pairPending = !isLoaded;
|
||||
const ratePending = isLoaded && !current && failedBase !== from;
|
||||
const converted = rate != null ? (Number.parseFloat(amount.replace(',', '.')) || 0) * rate : null;
|
||||
const swap = () => {
|
||||
setFrom(to);
|
||||
setTo(from);
|
||||
};
|
||||
|
||||
return { from, to, setFrom, setTo, amount, setAmount, currencies, rate, converted, swap, fetchRates };
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
setFrom,
|
||||
setTo,
|
||||
amount,
|
||||
setAmount,
|
||||
currencies,
|
||||
rate,
|
||||
converted,
|
||||
swap,
|
||||
fetchRates,
|
||||
pairPending,
|
||||
ratePending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,8 +77,9 @@ describe('JournalBody', () => {
|
||||
const { container } = render(<JournalBody text={'# Big\n\n### Small'} />);
|
||||
expect(container.querySelector('h1')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('h3')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Big').tagName).toBe('P');
|
||||
expect(screen.getByText('Small').tagName).toBe('P');
|
||||
// Each paragraph's text sits in an isolate (<bdi>) so it keeps its own direction.
|
||||
expect(screen.getByText('Big').parentElement?.tagName).toBe('P');
|
||||
expect(screen.getByText('Small').parentElement?.tagName).toBe('P');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-012: keeps a setext underline as text instead of a heading', () => {
|
||||
|
||||
@@ -15,13 +15,14 @@ export default function JournalBody({ text, dark }: Props) {
|
||||
lineHeight: 1.6,
|
||||
color: 'inherit',
|
||||
}}>
|
||||
{/* Each paragraph in its own direction (bdi): a Latin story in the Arabic UI keeps its full stop at its end. */}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={{
|
||||
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
p: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote style={{
|
||||
borderInlineStart: `3px solid var(--journal-accent)`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-COVER-001 to FE-JRN-COVER-010
|
||||
// FE-JRN-COVER-001 to FE-JRN-COVER-011
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -133,4 +133,25 @@ describe('JourneyEntryCover', () => {
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
|
||||
expect(container.querySelector('svg.lucide-play')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-011: only the phone draws a card without a photo in the deep day colour, opaque even as a suggestion', () => {
|
||||
const entry = buildEntry({ photos: [], type: 'skeleton' })
|
||||
const phone = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} tone="mobile" />)
|
||||
const phoneCard = phone.getByRole('button')
|
||||
// d9, the deep wash (jsdom spells the colour out as rgba)
|
||||
expect(phoneCard.style.backgroundImage).toMatch(/rgba\(99, 102, 241, 0\.85/)
|
||||
expect(phoneCard.className).not.toContain('opacity-90')
|
||||
expect(phone.container.querySelector('svg.lucide-map-pin')?.getAttribute('class')).toContain('-mt-6')
|
||||
phone.unmount()
|
||||
|
||||
// The desktop and shared timelines keep their faint wash and the day-coloured pin.
|
||||
const desk = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
|
||||
const deskCard = desk.getByRole('button')
|
||||
// 2e, the faint one
|
||||
expect(deskCard.style.backgroundImage).toMatch(/rgba\(99, 102, 241, 0\.18/)
|
||||
expect(deskCard.className).toContain('opacity-90')
|
||||
const pin = desk.container.querySelector('svg.lucide-map-pin') as SVGElement
|
||||
expect(pin.getAttribute('class')).not.toContain('-mt-6')
|
||||
expect(pin.style.color).not.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,6 +62,12 @@ interface Props {
|
||||
/** The public journey page serves photos through a share token. */
|
||||
photoUrlFor?: (photoId: number) => string
|
||||
tone?: 'app' | 'mobile'
|
||||
/**
|
||||
* large: the card a wide window shows (an iPad, a foldable unfolded), with
|
||||
* the room for a two-line name and the whole place under it. compact: a phone
|
||||
* on its side, where a regular card left the map a strip 30px tall.
|
||||
*/
|
||||
size?: 'compact' | 'regular' | 'large'
|
||||
}
|
||||
|
||||
export default function JourneyEntryCover({
|
||||
@@ -73,7 +79,10 @@ export default function JourneyEntryCover({
|
||||
showWeather = true,
|
||||
photoUrlFor,
|
||||
tone = 'app',
|
||||
size = 'regular',
|
||||
}: Props) {
|
||||
const large = size === 'large'
|
||||
const box = large ? 'h-[196px] w-[176px]' : size === 'compact' ? 'h-[112px] w-[124px]' : 'h-[152px] w-[136px]'
|
||||
const { t, locale } = useTranslation()
|
||||
const isSuggestion = entry.type === 'skeleton'
|
||||
|
||||
@@ -90,7 +99,12 @@ export default function JourneyEntryCover({
|
||||
const mood = showMood ? moodMeta(entry.mood) : undefined
|
||||
const weather = showWeather ? weatherMeta(entry.weather) : undefined
|
||||
|
||||
const emptyGround = tone === 'mobile' ? 'bg-[color:var(--m-sheetop)]' : 'bg-white dark:bg-zinc-800'
|
||||
const mobile = tone === 'mobile'
|
||||
const emptyGround = mobile ? 'bg-[color:var(--m-sheetop)]' : 'bg-white dark:bg-zinc-800'
|
||||
// The mark of a card without a photo: white and raised clear of the name on the
|
||||
// phone's deep day colour, the day's own colour on the faint wash elsewhere.
|
||||
const emptyIcon = mobile ? '-mt-6 opacity-80' : 'opacity-60'
|
||||
const emptyIconStyle = mobile ? undefined : { color: dayColor }
|
||||
// One ground for every corner mark. Dark rather than a white veil: these sit on
|
||||
// whatever photograph the traveller took, and a bright sky swallowed the white one.
|
||||
const badge = 'rounded-full bg-black/45 px-[7px] py-[2px] text-[10px] font-bold whitespace-nowrap text-white backdrop-blur-[2px]'
|
||||
@@ -108,9 +122,11 @@ export default function JourneyEntryCover({
|
||||
// growing its box moved every card after it once a swipe had landed, and the
|
||||
// snap then pulled the strip again, which read as a jolt (carousels keep the
|
||||
// room for it with their gap and top padding).
|
||||
className={`relative h-[152px] w-[136px] flex-none origin-bottom overflow-hidden rounded-[18px] text-start transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
|
||||
// White on the root: every mark and line on the card is white, over a photo or
|
||||
// over the day's colour alike.
|
||||
className={`relative text-white ${box} flex-none origin-bottom overflow-hidden rounded-[18px] text-start transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
|
||||
isActive ? 'z-[1] scale-[1.18] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'scale-100 shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
|
||||
} ${src ? '' : emptyGround} ${isSuggestion ? 'opacity-90' : ''}`}
|
||||
} ${src ? '' : emptyGround} ${isSuggestion && !mobile ? 'opacity-90' : ''}`}
|
||||
// backgroundImage, not background: the shorthand would drop the opaque colour
|
||||
// the class supplies and leave the map showing through a card that is only a
|
||||
// faint wash of the day's colour.
|
||||
@@ -122,7 +138,14 @@ export default function JourneyEntryCover({
|
||||
// ring lost its top edge to that clip.
|
||||
style={{
|
||||
boxShadow: `inset 0 0 0 ${isActive ? 2.5 : 1.5}px ${dayColor}${isActive ? '' : 'b3'}`,
|
||||
...(src ? null : { backgroundImage: `linear-gradient(160deg, ${dayColor}2e, ${dayColor}0d)` }),
|
||||
// Without a photograph the phone's card is the day's colour, deep enough to
|
||||
// carry the white name like a photo does, rather than a grey that read as
|
||||
// empty. Its ground stays opaque (no dimming for a suggestion either): it
|
||||
// floats over the map, whose labels read through anything less. The desktop
|
||||
// and shared timelines keep their faint wash of the day.
|
||||
...(src ? null : { backgroundImage: mobile
|
||||
? `linear-gradient(160deg, ${dayColor}d9, ${dayColor}8c 55%, ${dayColor}59)`
|
||||
: `linear-gradient(160deg, ${dayColor}2e, ${dayColor}0d)` }),
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
@@ -130,8 +153,8 @@ export default function JourneyEntryCover({
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
{posterless
|
||||
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={{ color: dayColor }} className="ml-0.5 opacity-60" />
|
||||
: <MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />}
|
||||
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={emptyIconStyle} className={`ml-0.5 ${emptyIcon}`} />
|
||||
: <MapPin size={24} strokeWidth={1.8} style={emptyIconStyle} className={emptyIcon} />}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -155,7 +178,7 @@ export default function JourneyEntryCover({
|
||||
italic, dimmed title carries the rest. */}
|
||||
{isSuggestion && (
|
||||
<span
|
||||
className={`absolute top-2 ${entry.country_code ? 'start-[30px]' : 'start-2'} ${badgeDot} text-white`}
|
||||
className={`absolute top-2 ${entry.country_code ? 'start-[30px]' : 'start-2'} ${badgeDot}`}
|
||||
title={t('journey.entry.suggestion')}
|
||||
aria-label={t('journey.entry.suggestion')}
|
||||
>
|
||||
@@ -172,7 +195,7 @@ export default function JourneyEntryCover({
|
||||
</span>
|
||||
)}
|
||||
{weather && (
|
||||
<span className={`${badgeDot} text-white`}>
|
||||
<span className={`${badgeDot}`}>
|
||||
<weather.icon size={11} strokeWidth={2.4} />
|
||||
</span>
|
||||
)}
|
||||
@@ -181,11 +204,11 @@ export default function JourneyEntryCover({
|
||||
|
||||
{/* Bottom: the name gets the room the thumbnail layout never had. */}
|
||||
<span className="absolute inset-x-0 bottom-0 flex flex-col gap-[1px] px-[10px] pb-[10px]">
|
||||
<span className={`line-clamp-2 text-[13px] leading-[1.25] font-bold text-white drop-shadow-[0_1px_3px_rgba(0,0,0,.6)] ${isSuggestion ? 'italic' : ''}`}>
|
||||
<span className={`line-clamp-2 text-[13px] leading-[1.25] font-bold drop-shadow-[0_1px_3px_rgba(0,0,0,.6)] ${isSuggestion ? 'italic' : ''}`}>
|
||||
{title}
|
||||
</span>
|
||||
{place && (
|
||||
<span className="truncate text-[10.5px] leading-[1.3] font-medium text-white/80 drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]">
|
||||
<span className={`${large ? 'line-clamp-2' : 'truncate'} text-[10.5px] leading-[1.3] font-medium text-white/80 drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]`}>
|
||||
{place}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JourneyMapPlace } from '@trek/shared';
|
||||
import { useCallback, useState, type ComponentProps, type Ref } from 'react';
|
||||
import JourneyMap, { type JourneyMapHandle } from './JourneyMap';
|
||||
import JourneyMapAuto from './JourneyMapAuto';
|
||||
@@ -27,6 +28,13 @@ interface Props extends MapProps {
|
||||
/** Leads from a picked place or stay to its entry. */
|
||||
onOpenEntry?: (entryId: string) => void;
|
||||
entryTitleOf?: (entryId: number) => string | null;
|
||||
/** Starts an entry about a picked planned place nothing was written about; absent for a reader who cannot. */
|
||||
onWriteAboutPlace?: (place: JourneyMapPlace) => void;
|
||||
}
|
||||
|
||||
/** A card action, only where the screen offers one. */
|
||||
function offeredIf<T>(offered: unknown, action: T): T | undefined {
|
||||
return offered ? action : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +52,7 @@ export default function JourneyLayeredMap({
|
||||
controlsTop = 12,
|
||||
onOpenEntry,
|
||||
entryTitleOf,
|
||||
onWriteAboutPlace,
|
||||
photos,
|
||||
tracks,
|
||||
height = 220,
|
||||
@@ -55,6 +64,13 @@ export default function JourneyLayeredMap({
|
||||
setPicked({ marks, key: marks.map((m) => m.key).join(',') });
|
||||
}, []);
|
||||
const close = useCallback(() => setPicked(null), []);
|
||||
const writeAbout = useCallback(
|
||||
(place: JourneyMapPlace) => {
|
||||
setPicked(null);
|
||||
onWriteAboutPlace?.(place);
|
||||
},
|
||||
[onWriteAboutPlace]
|
||||
);
|
||||
const openEntry = useCallback(
|
||||
(entryId: string) => {
|
||||
setPicked(null);
|
||||
@@ -99,6 +115,7 @@ export default function JourneyLayeredMap({
|
||||
marks={card.marks}
|
||||
onClose={close}
|
||||
onOpenEntry={onOpenEntry ? openEntry : undefined}
|
||||
onWriteAbout={offeredIf(onWriteAboutPlace, writeAbout)}
|
||||
entryTitleOf={entryTitleOf}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-062
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-063
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
@@ -592,6 +592,28 @@ describe('JourneyMap', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-063: paddingStart keeps the journey clear of a rail on the leading side, either way round', () => {
|
||||
const origRAF = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
|
||||
try {
|
||||
const { unmount } = render(<JourneyMap checkins={[]} entries={entriesWithCoords} paddingBottom={200} paddingStart={100} />);
|
||||
expect(mockedMap().fitBounds).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ paddingTopLeft: [150, 50], paddingBottomRight: [50, 200] }),
|
||||
);
|
||||
unmount();
|
||||
document.documentElement.dir = 'rtl';
|
||||
render(<JourneyMap checkins={[]} entries={entriesWithCoords} paddingBottom={200} paddingStart={100} />);
|
||||
expect(mockedMap().fitBounds).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ paddingTopLeft: [50, 50], paddingBottomRight: [150, 200] }),
|
||||
);
|
||||
} finally {
|
||||
document.documentElement.dir = '';
|
||||
globalThis.requestAnimationFrame = origRAF;
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-026: dark mode picks the dark basemap', async () => {
|
||||
render(<JourneyMap checkins={[]} entries={entriesWithCoords} dark />);
|
||||
// The default basemap is a vector style now, so it arrives as a style option
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref, type RefObject } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref, type RefObject } from 'react'
|
||||
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
||||
import L from 'leaflet'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -76,6 +76,12 @@ interface Props {
|
||||
*/
|
||||
onMarkerCardClick?: (entryId: string) => void
|
||||
paddingBottom?: number
|
||||
/**
|
||||
* How much of the map's leading edge a bar laid over it covers (the touch
|
||||
* shell's rail), in px: framing keeps the journey clear of it. Read when the
|
||||
* map frames, so a change alone does not rebuild the map.
|
||||
*/
|
||||
paddingStart?: number
|
||||
/** CARTO key from the share payload: the public journey has no settings store to read. */
|
||||
cartoApiKey?: string
|
||||
/**
|
||||
@@ -281,13 +287,23 @@ function markCoords(marks: JourneyMapMark[] | undefined): L.LatLngTuple[] {
|
||||
return (marks ?? []).map(mark => [mark.lat, mark.lng] as L.LatLngTuple)
|
||||
}
|
||||
|
||||
function fitOptions(paddingBottom: number | undefined): L.FitBoundsOptions {
|
||||
return { paddingTopLeft: [50, 50], paddingBottomRight: [50, paddingBottom || 50], maxZoom: 16 }
|
||||
function fitOptions(paddingBottom: number | undefined, paddingStart = 0): L.FitBoundsOptions {
|
||||
const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'
|
||||
return {
|
||||
paddingTopLeft: [50 + (rtl ? 0 : paddingStart), 50],
|
||||
paddingBottomRight: [50 + (rtl ? paddingStart : 0), paddingBottom || 50],
|
||||
maxZoom: 16,
|
||||
}
|
||||
}
|
||||
|
||||
function JourneyMap(
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, onMarkerCardClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, paddingStart = 0, cartoApiKey, onMarkerCardClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
|
||||
) {
|
||||
// The leading inset is read when the map frames, not a reason to rebuild it.
|
||||
const paddingStartRef = useRef(paddingStart)
|
||||
useLayoutEffect(() => {
|
||||
paddingStartRef.current = paddingStart
|
||||
})
|
||||
// Read through a ref: the handler is fixed per surface, and putting it in the
|
||||
// marker effect's deps would rebuild every marker for nothing.
|
||||
const onMarkerCardClickRef = useRef(onMarkerCardClick)
|
||||
@@ -536,7 +552,7 @@ function JourneyMap(
|
||||
const pointCoords = [...allCoords, ...markCoords(marksRef.current)]
|
||||
const fitCoords = pointCoords.length > 0 ? pointCoords : trackCoords
|
||||
marksFramedRef.current = (marksRef.current?.length ?? 0) > 0
|
||||
if (fitCoords.length > 0) map.fitBounds(L.latLngBounds(fitCoords), fitOptions(paddingBottom))
|
||||
if (fitCoords.length > 0) map.fitBounds(L.latLngBounds(fitCoords), fitOptions(paddingBottom, paddingStartRef.current))
|
||||
else map.setView([30, 0], 2)
|
||||
} catch {}
|
||||
})
|
||||
@@ -652,7 +668,7 @@ function JourneyMap(
|
||||
const coords = markCoords(marks)
|
||||
if (!marksFramedRef.current && coords.length > 0) {
|
||||
marksFramedRef.current = true
|
||||
if (!readerMovedRef.current) map.fitBounds(L.latLngBounds([...entryCoordsRef.current, ...coords]), fitOptions(paddingBottom))
|
||||
if (!readerMovedRef.current) map.fitBounds(L.latLngBounds([...entryCoordsRef.current, ...coords]), fitOptions(paddingBottom, paddingStartRef.current))
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
@@ -39,6 +39,12 @@ interface Props {
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
/**
|
||||
* How much of the map's leading edge a bar laid over it covers (the touch
|
||||
* shell's rail), in px: framing keeps the journey clear of it. Read when the
|
||||
* map frames, so a change alone does not rebuild the map.
|
||||
*/
|
||||
paddingStart?: number
|
||||
/** Open the entry a marker card names when the card is tapped, both renderers. */
|
||||
onMarkerCardClick?: (entryId: string) => void
|
||||
/** Open an entry's photos from the marker card's thumbnail strip. GL renderer only. */
|
||||
|
||||
@@ -317,6 +317,14 @@ describe('JourneyMapGL', () => {
|
||||
expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'journey-route-line' }))
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-049: paddingStart widens the fit on the side of the rail', () => {
|
||||
withToken()
|
||||
render(<JourneyMapGL checkins={[]} entries={entries} paddingBottom={200} paddingStart={100} fullScreen />)
|
||||
expect(gl.map.fitBounds).toHaveBeenCalledWith(gl.boundsList[0], expect.objectContaining({
|
||||
padding: { top: 50, bottom: 200, left: 150, right: 50 },
|
||||
}))
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-010: the camera fits entries and trail with the requested bottom padding', () => {
|
||||
withToken()
|
||||
render(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
@@ -57,6 +57,12 @@ interface Props {
|
||||
photos?: MapPhoto[]
|
||||
onPhotoClick?: (photoIds: string[]) => void
|
||||
paddingBottom?: number
|
||||
/**
|
||||
* How much of the map's leading edge a bar laid over it covers (the touch
|
||||
* shell's rail), in px: framing keeps the journey clear of it. Read when the
|
||||
* map frames, so a change alone does not rebuild the map.
|
||||
*/
|
||||
paddingStart?: number
|
||||
/** See the Leaflet twin (JRN-04): hidden layers go off the map in place. */
|
||||
layerVisibility?: JourneyMapLayerVisibility
|
||||
marks?: JourneyMapMark[]
|
||||
@@ -185,9 +191,10 @@ function markBounds(gl: Props['gl'], marks: JourneyMapMark[], points: [number, n
|
||||
return bounds
|
||||
}
|
||||
|
||||
function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefined) {
|
||||
function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefined, paddingStart = 0) {
|
||||
const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'
|
||||
return {
|
||||
padding: { top: 50, bottom: paddingBottom || 50, left: 50, right: 50 },
|
||||
padding: { top: 50, bottom: paddingBottom || 50, left: 50 + (rtl ? 0 : paddingStart), right: 50 + (rtl ? paddingStart : 0) },
|
||||
maxZoom: 16,
|
||||
pitch: tilted ? 45 : 0,
|
||||
duration: 0,
|
||||
@@ -195,8 +202,13 @@ function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefin
|
||||
}
|
||||
|
||||
function JourneyMapGL(
|
||||
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, onMarkerCardClick, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
|
||||
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, paddingStart = 0, glProvider = 'mapbox-gl', gl, onMarkerCardClick, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
|
||||
) {
|
||||
// The leading inset is read when the map frames, not a reason to rebuild it.
|
||||
const paddingStartRef = useRef(paddingStart)
|
||||
useLayoutEffect(() => {
|
||||
paddingStartRef.current = paddingStart
|
||||
})
|
||||
const onMarkerCardClickRef = useRef(onMarkerCardClick)
|
||||
onMarkerCardClickRef.current = onMarkerCardClick
|
||||
const onMarkerPhotoClickRef = useRef(onMarkerPhotoClick)
|
||||
@@ -526,7 +538,7 @@ function JourneyMapGL(
|
||||
known.forEach(mark => bounds.extend([mark.lng, mark.lat]))
|
||||
if (hasPoints || known.length > 0) {
|
||||
try {
|
||||
map.fitBounds(bounds, fitOptions(paddingBottom, enableMapbox3d && fullScreen))
|
||||
map.fitBounds(bounds, fitOptions(paddingBottom, enableMapbox3d && fullScreen, paddingStartRef.current))
|
||||
} catch { /* empty bounds */ }
|
||||
}
|
||||
})
|
||||
@@ -631,7 +643,7 @@ function JourneyMapGL(
|
||||
marksFramedRef.current = true
|
||||
if (!readerMovedRef.current) {
|
||||
try {
|
||||
map.fitBounds(markBounds(gl, marks, entryPointsRef.current), fitOptions(paddingBottom, enableMapbox3d && fullScreen))
|
||||
map.fitBounds(markBounds(gl, marks, entryPointsRef.current), fitOptions(paddingBottom, enableMapbox3d && fullScreen, paddingStartRef.current))
|
||||
} catch { /* empty bounds */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,4 +86,23 @@ describe('JourneyMapPickCard', () => {
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-JOURNEY-MAPPICK-004: a place nothing was written about offers a new entry to whoever can write', () => {
|
||||
const onWriteAbout = vi.fn();
|
||||
const { unmount } = render(<JourneyMapPickCard marks={[plan()]} onClose={vi.fn()} onWriteAbout={onWriteAbout} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New Entry' }));
|
||||
expect(onWriteAbout).toHaveBeenCalledWith(expect.objectContaining({ place_id: 4, name: 'Piazza San Marco' }));
|
||||
unmount();
|
||||
|
||||
// A reader who cannot write, or a place already written about, gets no such button.
|
||||
render(
|
||||
<JourneyMapPickCard
|
||||
marks={[plan({ entry_id: 7 })]}
|
||||
onClose={vi.fn()}
|
||||
onOpenEntry={vi.fn()}
|
||||
onWriteAbout={onWriteAbout}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: 'New Entry' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
|
||||
import { CalendarRange, ChevronLeft, ChevronRight, Clock, MapPin, NotebookPen, X } from 'lucide-react';
|
||||
import { CalendarRange, ChevronLeft, ChevronRight, Clock, MapPin, NotebookPen, PenLine, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { SoftPill, WhiteBadge } from '../Planner/planParts';
|
||||
@@ -14,6 +14,8 @@ interface Props {
|
||||
onOpenEntry?: (entryId: string) => void;
|
||||
/** The title of an entry the reader can see, for the stay that belongs to it. */
|
||||
entryTitleOf?: (entryId: number) => string | null;
|
||||
/** Starts an entry about a planned place nothing was written about; absent where the reader cannot write. */
|
||||
onWriteAbout?: (place: JourneyMapPlace) => void;
|
||||
}
|
||||
|
||||
function dayLabel(date: string, locale: string): string {
|
||||
@@ -41,7 +43,15 @@ function OpenEntry({ entryId, onOpenEntry }: { entryId: number; onOpenEntry?: (e
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceDetail({ place, onOpenEntry }: { place: JourneyMapPlace; onOpenEntry?: (entryId: string) => void }) {
|
||||
function PlaceDetail({
|
||||
place,
|
||||
onOpenEntry,
|
||||
onWriteAbout,
|
||||
}: {
|
||||
place: JourneyMapPlace;
|
||||
onOpenEntry?: (entryId: string) => void;
|
||||
onWriteAbout?: (place: JourneyMapPlace) => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -56,9 +66,22 @@ function PlaceDetail({ place, onOpenEntry }: { place: JourneyMapPlace; onOpenEnt
|
||||
{place.entry_id !== null ? (
|
||||
<OpenEntry entryId={place.entry_id} onOpenEntry={onOpenEntry} />
|
||||
) : (
|
||||
<p className="text-content-muted" style={fs(11.5, 'body')}>
|
||||
{t('journey.map.pick.notWritten')}
|
||||
</p>
|
||||
<>
|
||||
<p className="text-content-muted" style={fs(11.5, 'body')}>
|
||||
{t('journey.map.pick.notWritten')}
|
||||
</p>
|
||||
{onWriteAbout && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWriteAbout(place)}
|
||||
className="inline-flex items-center gap-1 self-start rounded-lg bg-accent px-2.5 py-1.5 font-semibold text-accent-text hover:opacity-90"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<PenLine size={12} strokeWidth={2.2} aria-hidden />
|
||||
{t('journey.detail.newEntry')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -106,7 +129,7 @@ function markName(mark: JourneyMapMark): string {
|
||||
* one spot come as a list first, each with what kind it is, so overlapping
|
||||
* hits stay reachable one by one.
|
||||
*/
|
||||
export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryTitleOf }: Props) {
|
||||
export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryTitleOf, onWriteAbout }: Props) {
|
||||
const { t, locale } = useTranslation();
|
||||
const [picked, setPicked] = useState<JourneyMapMark | null>(marks.length === 1 ? marks[0] : null);
|
||||
|
||||
@@ -150,7 +173,9 @@ export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryT
|
||||
<X size={14} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
{picked?.kind === 'plan' && <PlaceDetail place={picked.place} onOpenEntry={onOpenEntry} />}
|
||||
{picked?.kind === 'plan' && (
|
||||
<PlaceDetail place={picked.place} onOpenEntry={onOpenEntry} onWriteAbout={onWriteAbout} />
|
||||
)}
|
||||
{picked?.kind === 'stay' && (
|
||||
<StayDetail stay={picked.stay} onOpenEntry={onOpenEntry} entryTitleOf={entryTitleOf} />
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import JourneyDayScrubber from './JourneyDayScrubber'
|
||||
import { dayColorOf } from './journeyCard'
|
||||
import { useJourneyCarouselSync } from './useJourneyCarouselSync'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import type { JourneyMapPlace, JourneyTrack } from '@trek/shared'
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
@@ -40,6 +40,8 @@ interface Props {
|
||||
/** The map's layers (JRN-04); a picked place or stay leads to its entry's card. */
|
||||
layers?: JourneyMapLayersView | null
|
||||
entryTitleOf?: (entryId: number) => string | null
|
||||
/** Starts an entry about a picked planned place nothing was written about. */
|
||||
onWriteAboutPlace?: (place: JourneyMapPlace) => void
|
||||
/** Geotagged photos on the map, a tap opening them. */
|
||||
photos?: MapPhoto[]
|
||||
onPhotoClick?: (photoIds: string[]) => void
|
||||
@@ -64,6 +66,7 @@ export default function MobileMapTimeline({
|
||||
initialEntryId,
|
||||
layers,
|
||||
entryTitleOf,
|
||||
onWriteAboutPlace,
|
||||
photos,
|
||||
onPhotoClick,
|
||||
controlsTop,
|
||||
@@ -118,6 +121,7 @@ export default function MobileMapTimeline({
|
||||
hideLayerControl
|
||||
controlsTop={controlsTop}
|
||||
onOpenEntry={handleMarkerClick}
|
||||
onWriteAboutPlace={onWriteAboutPlace}
|
||||
entryTitleOf={entryTitleOf}
|
||||
entries={mapEntries}
|
||||
checkins={[]}
|
||||
@@ -160,6 +164,7 @@ export default function MobileMapTimeline({
|
||||
hideLayerControl
|
||||
controlsTop={controlsTop}
|
||||
onOpenEntry={handleMarkerClick}
|
||||
onWriteAboutPlace={onWriteAboutPlace}
|
||||
entryTitleOf={entryTitleOf}
|
||||
entries={mapEntries}
|
||||
checkins={[]}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-009: the card carousel over the journey map,
|
||||
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-010: the card carousel over the journey map,
|
||||
// behind both the phone journey screen and the narrow desktop and public timeline.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -184,4 +184,33 @@ describe('useJourneyCarouselSync', () => {
|
||||
settleAt(0);
|
||||
expect(hook.result.current.activeIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-JRN-CAROUSEL-010: a strip that comes back (the feed on a wide window gave way to it) is tracked again', () => {
|
||||
const carousel = document.createElement('div');
|
||||
carousel.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
|
||||
Object.defineProperty(carousel, 'clientWidth', { value: 500 });
|
||||
let strip: HTMLDivElement | null = null;
|
||||
const hook = renderHook(
|
||||
(p: Options) => {
|
||||
const sync = useJourneyCarouselSync(p);
|
||||
(sync.carouselRef as { current: HTMLDivElement | null }).current = strip;
|
||||
return sync;
|
||||
},
|
||||
{ initialProps: { entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn(), layoutKey: true } }
|
||||
);
|
||||
ENTRIES.forEach((_, i) => {
|
||||
const node = document.createElement('div');
|
||||
node.getBoundingClientRect = () => ({ left: i * 200, width: 100 }) as DOMRect;
|
||||
hook.result.current.cardRefs.current.set(i, node);
|
||||
});
|
||||
|
||||
// The strip mounts with the layout's turn, with the same entries as before.
|
||||
strip = carousel;
|
||||
hook.rerender({ entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn(), layoutKey: false });
|
||||
carousel.dispatchEvent(new Event('scroll'));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(90);
|
||||
});
|
||||
expect(hook.result.current.activeIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
|
||||
|
||||
import type { JourneyMapHandle } from './JourneyMap';
|
||||
import { journeyDays } from './journeyCard';
|
||||
@@ -17,6 +17,11 @@ export interface JourneyCarouselSyncOptions<E extends CarouselEntry> {
|
||||
onOpenEntry: (entry: E) => void;
|
||||
/** A tap on another card also moves the map to it (the phone screen). */
|
||||
syncMapOnTap?: boolean;
|
||||
/**
|
||||
* Changes whenever the strip comes or goes (the phone screen shows a feed in its
|
||||
* place on a wide window), so its scroll tracking attaches to the strip on show.
|
||||
*/
|
||||
layoutKey?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +60,56 @@ function nearestCardIndex(el: HTMLDivElement | null, cards: Map<number, HTMLDivE
|
||||
*/
|
||||
const SETTLE_MS = 90;
|
||||
|
||||
interface StripScrollOptions {
|
||||
carouselRef: RefObject<HTMLDivElement | null>;
|
||||
count: number;
|
||||
nearestCard: () => number | null;
|
||||
pickNearestCard: () => void;
|
||||
onLive: (index: number | null) => void;
|
||||
layoutKey: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the strip's scrolling: the card under the centre while it moves, the
|
||||
* active card once it settles. Every state update waits for the strip to come
|
||||
* to rest, because updating the active card mid swipe resizes the cards and
|
||||
* reflows the layout on every frame. Attaches again whenever `layoutKey`
|
||||
* changes, since the strip may only now be on screen.
|
||||
*/
|
||||
function useStripScroll({ carouselRef, count, nearestCard, pickNearestCard, onLive, layoutKey }: StripScrollOptions) {
|
||||
useEffect(() => {
|
||||
const el = carouselRef.current;
|
||||
if (!el || count === 0) return;
|
||||
let settleTimer: number | null = null;
|
||||
let frame: number | null = null;
|
||||
const onScroll = () => {
|
||||
if (frame == null) {
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
onLive(nearestCard());
|
||||
});
|
||||
}
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
settleTimer = window.setTimeout(pickNearestCard, SETTLE_MS);
|
||||
};
|
||||
// Where the browser says when the snap has landed, the card grows right then
|
||||
// instead of a beat later; the timer stays for the ones that do not.
|
||||
const onScrollEnd = () => {
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
settleTimer = null;
|
||||
pickNearestCard();
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
el.addEventListener('scrollend', onScrollEnd, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll);
|
||||
el.removeEventListener('scrollend', onScrollEnd);
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
if (frame != null) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [carouselRef, count, pickNearestCard, nearestCard, onLive, layoutKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The card carousel over the journey map, behind both the phone journey screen and the
|
||||
* narrow desktop and public timeline: the active card is the one nearest the centre
|
||||
@@ -66,6 +121,7 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
|
||||
mapEntries,
|
||||
onOpenEntry,
|
||||
syncMapOnTap = false,
|
||||
layoutKey,
|
||||
}: JourneyCarouselSyncOptions<E>) {
|
||||
const mapRef = useRef<JourneyMapHandle>(null);
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
@@ -106,39 +162,7 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
|
||||
});
|
||||
}, [nearestCard, syncMapToCard]);
|
||||
|
||||
// Defer every state update until scrolling settles: updating the active card
|
||||
// mid swipe resizes the cards and reflows the layout on every frame.
|
||||
useEffect(() => {
|
||||
const el = carouselRef.current;
|
||||
if (!el || entries.length === 0) return;
|
||||
let settleTimer: number | null = null;
|
||||
let frame: number | null = null;
|
||||
const onScroll = () => {
|
||||
if (frame == null) {
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
setLiveIndex(nearestCard());
|
||||
});
|
||||
}
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
settleTimer = window.setTimeout(pickNearestCard, SETTLE_MS);
|
||||
};
|
||||
// Where the browser says when the snap has landed, the card grows right then
|
||||
// instead of a beat later; the timer stays for the ones that do not.
|
||||
const onScrollEnd = () => {
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
settleTimer = null;
|
||||
pickNearestCard();
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
el.addEventListener('scrollend', onScrollEnd, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll);
|
||||
el.removeEventListener('scrollend', onScrollEnd);
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
if (frame != null) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [entries.length, pickNearestCard, nearestCard]);
|
||||
useStripScroll({ carouselRef, count: entries.length, nearestCard, pickNearestCard, onLive: setLiveIndex, layoutKey });
|
||||
|
||||
const scrollCardIntoCenter = useCallback((idx: number) => {
|
||||
cardRefs.current.get(idx)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* NativeQuickActionsSync: keeps the app icon's quick actions (native/quickActions.ts)
|
||||
* in step with the trips on this device and the app's language. Runs at start,
|
||||
* on a change of language or account, and whenever the app comes back to the
|
||||
* foreground. Signed out, the icon offers nothing that would open a trip.
|
||||
* Renders nothing; outside an iOS binary with the feature it does nothing.
|
||||
*/
|
||||
import { App } from '@capacitor/app';
|
||||
import { useEffect } from 'react';
|
||||
import { offlineDb } from '../../db/offlineDb';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
import { publishQuickActions, quickActionsActive, quickActionsFor } from '../../native/quickActions';
|
||||
import { pickTrip } from '../../native/widgets';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { localDateStr } from '../../vacay/monthGrid';
|
||||
|
||||
export default function NativeQuickActionsSync() {
|
||||
const { t, language } = useTranslation();
|
||||
const { caps } = useNativeCapabilities();
|
||||
const userId = useAuthStore((s) => (s.isAuthenticated ? (s.user?.id ?? null) : null));
|
||||
|
||||
useEffect(() => {
|
||||
if (!quickActionsActive()) return;
|
||||
let cancelled = false;
|
||||
|
||||
const sync = async () => {
|
||||
if (userId === null) {
|
||||
await publishQuickActions([]);
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const today = localDateStr(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const trips = await offlineDb.trips.toArray();
|
||||
if (cancelled) return;
|
||||
await publishQuickActions(quickActionsFor(pickTrip(trips, today), today, t));
|
||||
};
|
||||
|
||||
void sync();
|
||||
const listener = App.addListener('appStateChange', ({ isActive }) => {
|
||||
if (isActive) void sync();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void listener.then((handle) => handle.remove());
|
||||
};
|
||||
}, [caps, t, language, userId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from '../../i18n';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
import { TrekShell } from '../../native/trekShell';
|
||||
import { applyWatchActions, buildWatchSnapshot, publishWatch, takeWatchActions, watchActive } from '../../native/watch';
|
||||
import { onWatchListsChange } from '../../native/watchListChanges';
|
||||
import { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
|
||||
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
|
||||
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
|
||||
@@ -229,8 +230,11 @@ export default function NativeWidgetsSync(): null {
|
||||
if (isActive) void sync().then(drainWatch);
|
||||
});
|
||||
const watchListener = watchActive() ? TrekShell.addListener('watchAction', () => void drainWatch()) : null;
|
||||
// A tick here, or one another member made, reaches the watch at once rather than on the next refresh.
|
||||
const stopListWatch = watchActive() ? onWatchListsChange(() => void sync()) : null;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopListWatch?.();
|
||||
window.clearInterval(timer);
|
||||
void listener.then((handle) => handle.remove());
|
||||
void watchListener?.then((handle) => handle.remove());
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
height: 'var(--nav-h)',
|
||||
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
|
||||
}} data-desktop-navbar="" className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
|
||||
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
|
||||
the tab pill between them sits in the middle of the bar rather than
|
||||
being centred on top of both (#1983). Both columns keep at least their
|
||||
|
||||
@@ -31,18 +31,10 @@ export function useCreateAction(phone: boolean): CreateAction {
|
||||
const inCollection = useMatch('/collections/:id');
|
||||
|
||||
if (inTrip) {
|
||||
// The "+" is context-aware per active tab: Bookings → reservation,
|
||||
// Transports → transport, Costs → expense. Tabs without a create modal
|
||||
// (lists / files / collab) fall through to adding a place. #1349
|
||||
const id = inTrip.params.id;
|
||||
const id = inTrip.params.id ?? '';
|
||||
const tripTab = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`trip-tab-${id}`) : null;
|
||||
if (tripTab === 'finanzplan')
|
||||
return { label: t('costs.addExpense'), run: () => navigate(`/trips/${id}?create=expense`) };
|
||||
if (tripTab === 'buchungen')
|
||||
return { label: t('reservations.addManual'), run: () => navigate(`/trips/${id}?create=reservation`) };
|
||||
if (tripTab === 'transports')
|
||||
return { label: t('transport.addManual'), run: () => navigate(`/trips/${id}?create=transport`) };
|
||||
return { label: t('places.addPlace'), run: () => navigate(`/trips/${id}?create=place`) };
|
||||
const intent = tripCreateIntent(id, tripTab);
|
||||
return { label: t(intent.labelKey), run: () => navigate(intent.to) };
|
||||
}
|
||||
if (inJourney) {
|
||||
// Context-aware per tab, like the trip's "+": the Gallery holds photos, so
|
||||
@@ -69,6 +61,19 @@ export function useCreateAction(phone: boolean): CreateAction {
|
||||
return { label: t('dashboard.newTrip'), run: () => navigate('/dashboard?create=1') };
|
||||
}
|
||||
|
||||
/**
|
||||
* What the "+" creates inside a trip, by its active tab: Bookings a reservation,
|
||||
* Transports a transport, Costs an expense. Tabs without a create modal (lists,
|
||||
* files, collab) fall through to adding a place (#1349). Pure, so a caller that
|
||||
* knows the tab itself (the trip's own ⌘N, MTripShell) asks with that one.
|
||||
*/
|
||||
export function tripCreateIntent(tripId: string, tab: string | null): { labelKey: string; to: string } {
|
||||
if (tab === 'finanzplan') return { labelKey: 'costs.addExpense', to: `/trips/${tripId}?create=expense` };
|
||||
if (tab === 'buchungen') return { labelKey: 'reservations.addManual', to: `/trips/${tripId}?create=reservation` };
|
||||
if (tab === 'transports') return { labelKey: 'transport.addManual', to: `/trips/${tripId}?create=transport` };
|
||||
return { labelKey: 'places.addPlace', to: `/trips/${tripId}?create=place` };
|
||||
}
|
||||
|
||||
/** Whether a nav item is the current route: the dashboard only on its exact path, the rest by prefix. */
|
||||
export function isNavItemActive(pathname: string, to: string): boolean {
|
||||
return to === '/dashboard' ? pathname === '/dashboard' : pathname.startsWith(to);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// FE-MAP-RESIZE-001 onwards
|
||||
|
||||
const map = vi.hoisted(() => ({
|
||||
container: {} as HTMLElement,
|
||||
invalidateSize: vi.fn(),
|
||||
getContainer: vi.fn(),
|
||||
getCenter: vi.fn(() => ({ lat: 35, lng: 135.7 })),
|
||||
getZoom: vi.fn(() => 12),
|
||||
getSize: vi.fn(() => ({ x: 1376, y: 1032 })),
|
||||
setView: vi.fn(),
|
||||
whenReady: vi.fn((fn: () => void) => fn()),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
}));
|
||||
vi.mock('react-leaflet', () => ({ useMap: () => map }));
|
||||
vi.mock('./lngFrameContext', () => ({ useLngFrame: () => null }));
|
||||
|
||||
import { computeMapViewport, TILE_SIZE_RASTER } from '../../utils/mapViewport';
|
||||
import ContainerResizeWatcher, { MapCameraSnapshot, OpeningFrameFit } from './ContainerResizeWatcher';
|
||||
import type { OpeningFrame } from './openingView';
|
||||
|
||||
const realObserver = globalThis.ResizeObserver;
|
||||
|
||||
describe('ContainerResizeWatcher', () => {
|
||||
afterEach(() => {
|
||||
globalThis.ResizeObserver = realObserver;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('FE-MAP-RESIZE-001: the map measures itself again whenever its box changes size', () => {
|
||||
const element = document.createElement('div');
|
||||
map.getContainer.mockReturnValue(element);
|
||||
let fire: () => void = () => {};
|
||||
const observe = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(cb: () => void) {
|
||||
fire = cb;
|
||||
}
|
||||
observe = observe;
|
||||
unobserve = vi.fn();
|
||||
disconnect = disconnect;
|
||||
} as unknown as typeof ResizeObserver;
|
||||
|
||||
const { unmount } = render(<ContainerResizeWatcher />);
|
||||
expect(observe).toHaveBeenCalledWith(element);
|
||||
fire();
|
||||
expect(map.invalidateSize).toHaveBeenCalledWith();
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MAP-RESIZE-002: the camera snapshot reports where the map looks and stops on unmount', () => {
|
||||
const onChange = vi.fn();
|
||||
const { unmount } = render(<MapCameraSnapshot onChange={onChange} />);
|
||||
expect(onChange).toHaveBeenCalledWith([35, 135.7], 12);
|
||||
expect(map.on).toHaveBeenCalledWith('moveend zoomend', expect.any(Function));
|
||||
unmount();
|
||||
expect(map.off).toHaveBeenCalledWith('moveend zoomend', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpeningFrameFit', () => {
|
||||
// A day across Kyoto from Arashiyama in the west to Ginkaku-ji in the east, wider than tall.
|
||||
const points = [
|
||||
{ lat: 35.0094, lng: 135.6668 },
|
||||
{ lat: 35.005, lng: 135.7649 },
|
||||
{ lat: 35.0037, lng: 135.7788 },
|
||||
{ lat: 35.027, lng: 135.7982 },
|
||||
];
|
||||
const padding = { top: 20, right: 40, bottom: 20, left: 40 };
|
||||
const windowFrame = (): OpeningFrame => {
|
||||
const view = computeMapViewport(points, { tileSize: TILE_SIZE_RASTER, padding, width: 1376, height: 1032 });
|
||||
if (!view) throw new Error('the day has places to frame');
|
||||
return { points, padding, wrapsWorld: false, center: view.center, zoom: view.zoom, width: 1376, height: 1032 };
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it('FE-MAP-RESIZE-004: a map beside the plan, narrower than the window, frames the day again for its own size', () => {
|
||||
const frame = windowFrame();
|
||||
map.getSize.mockReturnValue({ x: 824, y: 1032 });
|
||||
map.getZoom.mockReturnValue(frame.zoom);
|
||||
map.getCenter.mockReturnValue({ lat: frame.center[0], lng: frame.center[1] });
|
||||
render(<OpeningFrameFit frame={frame} />);
|
||||
expect(map.setView).toHaveBeenCalledOnce();
|
||||
const [, zoom, options] = map.setView.mock.calls[0];
|
||||
// Further out than the window's frame, so the whole day fits the narrower map.
|
||||
expect(zoom).toBeLessThan(frame.zoom);
|
||||
expect(options).toEqual({ animate: false });
|
||||
});
|
||||
|
||||
it('FE-MAP-RESIZE-005: a map the size of the window, or one that opened at a kept camera, stays where it is', () => {
|
||||
const frame = windowFrame();
|
||||
map.getSize.mockReturnValue({ x: 1376, y: 1000 });
|
||||
map.getZoom.mockReturnValue(frame.zoom);
|
||||
map.getCenter.mockReturnValue({ lat: frame.center[0], lng: frame.center[1] });
|
||||
const { unmount } = render(<OpeningFrameFit frame={frame} />);
|
||||
expect(map.setView).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
|
||||
map.getSize.mockReturnValue({ x: 824, y: 1032 });
|
||||
map.getZoom.mockReturnValue(9);
|
||||
render(<OpeningFrameFit frame={frame} />);
|
||||
expect(map.setView).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
|
||||
import { frameLng } from '../../utils/lngFrame';
|
||||
import { computeMapViewport, TILE_SIZE_RASTER } from '../../utils/mapViewport';
|
||||
import { useLngFrame } from './lngFrameContext';
|
||||
import type { OpeningFrame } from './openingView';
|
||||
|
||||
/**
|
||||
* Leaflet measures its container on window resizes only. The touch shell on a
|
||||
* wide window resizes the map without one: the trip's plan pane opens and
|
||||
* closes beside it, and a foldable folds. Without a fresh measure the map kept
|
||||
* its old size, with grey bands or tiles cut off at the old edge.
|
||||
*/
|
||||
export default function ContainerResizeWatcher() {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
// Leaflet's default keeps the centre where it was, so what the map framed stays
|
||||
// in the middle of the room it now has.
|
||||
const observer = new ResizeObserver(() => map.invalidateSize());
|
||||
observer.observe(map.getContainer());
|
||||
return () => observer.disconnect();
|
||||
}, [map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reports where the map looks after every move, so a remount can come back to it. */
|
||||
export function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
const save = () => {
|
||||
const center = map.getCenter();
|
||||
onChange([center.lat, center.lng], map.getZoom());
|
||||
};
|
||||
map.whenReady(save);
|
||||
map.on('moveend zoomend', save);
|
||||
return () => {
|
||||
map.off('moveend zoomend', save);
|
||||
};
|
||||
}, [map, onChange]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The map opens framed on the trip's places, a frame worked out before the
|
||||
* map exists and so for the window's size. Where the map is much smaller
|
||||
* than the window (the plan beside it on an iPad or an unfolded foldable),
|
||||
* that frame is too close and the day runs off the map. Once the map knows
|
||||
* its size, the frame is worked out again for it. A map that opened anywhere
|
||||
* else (a camera kept from before a remount) is left where it is.
|
||||
*/
|
||||
export function OpeningFrameFit({ frame }: { frame: OpeningFrame }) {
|
||||
const map = useMap();
|
||||
const lngFrame = useLngFrame();
|
||||
const done = useRef(false);
|
||||
useEffect(() => {
|
||||
if (done.current) return;
|
||||
done.current = true;
|
||||
const { x, y } = map.getSize();
|
||||
if (x <= 0 || y <= 0) return;
|
||||
if (Math.abs(x - frame.width) <= frame.width * 0.1 && Math.abs(y - frame.height) <= frame.height * 0.1) return;
|
||||
const at = map.getCenter();
|
||||
const opened =
|
||||
Math.abs(map.getZoom() - frame.zoom) < 0.01 &&
|
||||
Math.abs(at.lat - frame.center[0]) < 1e-6 &&
|
||||
Math.abs(at.lng - frame.center[1]) < 1e-6;
|
||||
if (!opened) return;
|
||||
const view = computeMapViewport(frame.points, {
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
padding: frame.padding,
|
||||
wrapsWorld: frame.wrapsWorld,
|
||||
width: x,
|
||||
height: y,
|
||||
});
|
||||
// A whole zoom level, as Leaflet snaps to them, and the one that fits rather than the nearest.
|
||||
if (view)
|
||||
map.setView([view.center[0], frameLng(lngFrame, view.center[1])], Math.floor(view.zoom), { animate: false });
|
||||
}, [map, lngFrame, frame]);
|
||||
return null;
|
||||
}
|
||||
@@ -312,3 +312,50 @@ describe('TourMapLayerSwitcher piste switch (SKI-01)', () => {
|
||||
expect(screen.getByRole('status')).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TourMapLayerSwitcher fitting its map', () => {
|
||||
/** A parent of `room` px, and the pill reporting `withWords` px while its words show. */
|
||||
function renderIn(room: { width: number }, withWords: number) {
|
||||
const host = document.createElement('div')
|
||||
Object.defineProperty(host, 'clientWidth', { get: () => room.width })
|
||||
document.body.appendChild(host)
|
||||
const view = render(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />, { container: host })
|
||||
const pill = () => screen.getByRole('group')
|
||||
Object.defineProperty(pill(), 'scrollWidth', {
|
||||
get: () => (pill().dataset.layout === 'labels' ? withWords : 200),
|
||||
})
|
||||
return { ...view, pill }
|
||||
}
|
||||
|
||||
it('FE-COMP-MAPLAYER-025: keeps its words while they fit, goes to its icons on a narrow map, and back once there is room', () => {
|
||||
const room = { width: 900 }
|
||||
const { pill, rerender } = renderIn(room, 600)
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||
expect(pill().dataset.layout).toBe('labels')
|
||||
expect(screen.getByText('Topo')).toBeInTheDocument()
|
||||
|
||||
room.width = 500
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||
expect(pill().dataset.layout).toBe('icons')
|
||||
expect(screen.queryByText('Topo')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Topo' })).toBeInTheDocument()
|
||||
|
||||
room.width = 599
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||
expect(pill().dataset.layout).toBe('icons')
|
||||
room.width = 600
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||
expect(pill().dataset.layout).toBe('labels')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-026: stood on the side it is a column of icons, and a set compact is never second-guessed', () => {
|
||||
const { rerender } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} vertical />)
|
||||
expect(screen.getByRole('group').dataset.layout).toBe('vertical')
|
||||
expect(screen.getByRole('group').style.flexDirection).toBe('column')
|
||||
expect(screen.queryByText('Topo')).toBeNull()
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} compact={false} />)
|
||||
expect(screen.getByRole('group').dataset.layout).toBe('labels')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
|
||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
|
||||
import { PisteKeyButton } from '../Tours/winter/PisteKey'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { ComponentType, CSSProperties } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
||||
@@ -11,6 +11,7 @@ import { Tooltip } from '../shared/Tooltip'
|
||||
import { FROSTED_SHELL, segmentStyle } from './mapControlShadow'
|
||||
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
||||
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
|
||||
import { useLabelFit } from './useLabelFit'
|
||||
|
||||
export type BaseLayer = 'default' | 'satellite'
|
||||
export type TourBaseLayer = BaseLayer | TourRasterBase
|
||||
@@ -31,6 +32,7 @@ export interface TourLayerOffer {
|
||||
|
||||
/** Distance from the map's left edge (plus any side panel) to the switcher. */
|
||||
export const MAP_LAYER_SWITCHER_INSET = 20
|
||||
|
||||
/**
|
||||
* Outer size of the round shell: a 34px button in 4px of padding, the same shell as
|
||||
* MapCompassPill. The markup below keeps its literals, and FE-COMP-MAPLAYER-006 holds
|
||||
@@ -257,11 +259,31 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
|
||||
)
|
||||
}
|
||||
|
||||
/** Words, icons only, or a column on the map's side: set by the caller, or fitted to the room. */
|
||||
function useSwitcherLayout(compactProp: boolean | undefined, vertical: boolean) {
|
||||
const { attach, iconsOnly } = useLabelFit(compactProp === undefined && !vertical)
|
||||
const compact = vertical || (compactProp ?? iconsOnly)
|
||||
const layout = vertical ? 'vertical' : compact ? 'icons' : 'labels'
|
||||
return { attachFit: attach, compact, layout }
|
||||
}
|
||||
|
||||
function shellStyle(vertical: boolean): CSSProperties {
|
||||
return vertical ? { ...FROSTED_SHELL, gap: 2, flexDirection: 'column', borderRadius: 22 } : { ...FROSTED_SHELL, gap: 2 }
|
||||
}
|
||||
|
||||
function Divider({ vertical }: { vertical: boolean }) {
|
||||
return <span aria-hidden className={vertical ? 'my-0.5 h-px w-5 bg-edge' : 'mx-0.5 h-5 w-px bg-edge'} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-view Tours control. It never reads or writes the global map setting.
|
||||
* `compact` shows the base layers and the via ferrata switch by their icons
|
||||
* alone, each named by its tooltip, for a bottom row that has to share its
|
||||
* width (the 3D map's, beside the flyover dock).
|
||||
* width (the 3D map's, beside the flyover dock). Left out, the control goes to
|
||||
* its icons by itself once its words no longer fit the room its parent gives
|
||||
* it (a narrow map, such as the iPad's beside the tour pane).
|
||||
* `vertical` stands it on the map's side, icons only (the 3D map, where its
|
||||
* bottom row has no room left for it).
|
||||
*/
|
||||
export function TourMapLayerSwitcher({
|
||||
active,
|
||||
@@ -271,7 +293,8 @@ export function TourMapLayerSwitcher({
|
||||
ferrataLayer,
|
||||
placeTool,
|
||||
layers,
|
||||
compact = false,
|
||||
compact: compactProp,
|
||||
vertical = false,
|
||||
}: {
|
||||
active: TourBaseLayer
|
||||
onChange: (layer: TourBaseLayer) => void
|
||||
@@ -285,15 +308,23 @@ export function TourMapLayerSwitcher({
|
||||
/** Offers the "set points" switch, while a tour is being edited. */
|
||||
placeTool?: MapPlaceTool
|
||||
compact?: boolean
|
||||
vertical?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { attachFit, compact, layout } = useSwitcherLayout(compactProp, vertical)
|
||||
const bases = layers?.bases
|
||||
const overlays = layers?.overlays
|
||||
// A map on terms is a best-effort service: while it is shown and stops answering, its segment says so.
|
||||
const health = layers?.baseHealth
|
||||
|
||||
return (
|
||||
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
|
||||
<div
|
||||
ref={attachFit}
|
||||
style={shellStyle(vertical)}
|
||||
role="group"
|
||||
aria-label={t('settings.mapStyle')}
|
||||
data-layout={layout}
|
||||
>
|
||||
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
|
||||
const selected = active === id
|
||||
const label = t(labelKey)
|
||||
@@ -321,7 +352,7 @@ export function TourMapLayerSwitcher({
|
||||
{health.base === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')}
|
||||
</span>
|
||||
)}
|
||||
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
|
||||
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <Divider vertical={vertical} />}
|
||||
{placeTool && <PlaceToolToggle tool={placeTool} />}
|
||||
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
|
||||
{overlays?.map(overlay => <OverlayToggle key={overlay.id} overlay={overlay} compact={compact} />)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { render, screen } from '../../../tests/helpers/render'
|
||||
import { act, fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { setSizeClass } from '../../../tests/helpers/sizeClass'
|
||||
import { buildPlace, buildReservation } from '../../../tests/helpers/factories'
|
||||
import { AMAP_ROAD, AMAP_SATELLITE, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL } from '../../constants/mapDefaults'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
@@ -16,6 +17,8 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
const mapMock = vi.hoisted(() => ({
|
||||
getContainer: vi.fn(() => document.createElement('div')),
|
||||
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
// The map fills the window here, so the opening frame stands (OpeningFrameFit).
|
||||
getSize: vi.fn(() => ({ x: window.innerWidth, y: window.innerHeight })),
|
||||
panTo: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
@@ -204,6 +207,21 @@ vi.mock('../../services/photoService', () => ({
|
||||
getAllThumbs: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
// The app shows the touch shell at every size; a test can say it does (undefined: as in a browser).
|
||||
const shell = vi.hoisted(() => ({ phone: undefined as boolean | undefined }))
|
||||
vi.mock('../../mobile/useIsPhone', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../mobile/useIsPhone')>()
|
||||
// The real hook runs every time, so a test that changes the answer never changes the
|
||||
// number of hooks a mounted map has (React throws on that).
|
||||
return {
|
||||
...actual,
|
||||
useIsPhone: () => {
|
||||
const real = actual.useIsPhone()
|
||||
return shell.phone ?? real
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./gcj02Crs', () => ({
|
||||
crsForBasemap: (gcj02: boolean) => gcj02 ? { code: 'TREK:GCJ02' } : undefined,
|
||||
}))
|
||||
@@ -227,6 +245,7 @@ const ORIGINAL_WIDTH = window.innerWidth
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
shell.phone = undefined
|
||||
resetAllStores()
|
||||
clusterMock.markers.clear()
|
||||
clusterMock.asked.length = 0
|
||||
@@ -1296,7 +1315,8 @@ describe('MapView selection panning (#921)', () => {
|
||||
afterEach(() => {
|
||||
delete projectable.project
|
||||
delete projectable.unproject
|
||||
delete projectable.getSize
|
||||
// Back to the map that fills the window (the mock's own size).
|
||||
projectable.getSize = vi.fn(() => ({ x: window.innerWidth, y: window.innerHeight }))
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-090: pans the pin back out once the card measured taller than the allowance', () => {
|
||||
@@ -1574,6 +1594,37 @@ describe('MapView bounds fitting', () => {
|
||||
expect(mapMock.fitBounds.mock.calls[2][1]).toMatchObject({ paddingTopLeft: [40, 20], paddingBottomRight: [40, 20] })
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-094: beside the pane the touch shell opens at the whole zoom level that fits; the desktop keeps its own opening', () => {
|
||||
// A day spread so that its exact fit lies between two levels.
|
||||
const places = [buildMapPlace({ id: 1, lat: 34.9671, lng: 135.7727 }), buildMapPlace({ id: 2, lat: 35.005, lng: 135.7649 })]
|
||||
shell.phone = true
|
||||
setSizeClass('wide')
|
||||
const { unmount } = render(<MapView places={places} />)
|
||||
const paned = Number(screen.getByTestId('map-container').getAttribute('data-zoom'))
|
||||
expect(Number.isInteger(paned)).toBe(true)
|
||||
unmount()
|
||||
|
||||
// The desktop planner hands Leaflet the fitted level, which it rounds to the nearest
|
||||
// as it always has, and fits no second time.
|
||||
shell.phone = false
|
||||
setSizeClass('compact')
|
||||
mapMock.setView.mockClear()
|
||||
render(<MapView places={places} />)
|
||||
const desk = Number(screen.getByTestId('map-container').getAttribute('data-zoom'))
|
||||
expect(Number.isInteger(desk)).toBe(false)
|
||||
expect(mapMock.setView).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-093: on an iPad the touch shell frames a day clear of its floating toolbar and corner buttons', () => {
|
||||
shell.phone = true
|
||||
setSizeClass('wide')
|
||||
const places = [buildMapPlace({ id: 1, lat: 35, lng: 135.7 }), buildMapPlace({ id: 2, lat: 35.04, lng: 135.78 })]
|
||||
const { rerender } = render(<MapView places={places} fitKey={1} />)
|
||||
rerender(<MapView places={places} fitKey={2} />)
|
||||
const opts = mapMock.fitBounds.mock.calls[mapMock.fitBounds.mock.calls.length - 1]?.[1]
|
||||
expect(opts).toMatchObject({ paddingTopLeft: [40, 84], paddingBottomRight: [40, 56] })
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-079: the caller padding is read by value, so only new numbers refit the points on screen', () => {
|
||||
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState, useMemo, useCallback, memo, type Component
|
||||
import DOM from 'react-dom'
|
||||
import { categoryIconSvg } from './categoryIconSvg'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
|
||||
import ContainerResizeWatcher, { MapCameraSnapshot, OpeningFrameFit } from './ContainerResizeWatcher'
|
||||
import { openingView } from './openingView'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
|
||||
@@ -71,9 +73,10 @@ import VectorBasemap from './VectorBasemap'
|
||||
import { nativeRasterTemplate } from '../../native/tileCache'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { switcherRowStyle } from './switcherRow'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
|
||||
import type { ViewportPadding } from '../../utils/mapViewport'
|
||||
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
|
||||
|
||||
import type { MapViewProps } from './mapViewContract'
|
||||
@@ -244,20 +247,6 @@ function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { cur
|
||||
return null
|
||||
}
|
||||
|
||||
function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
const save = () => {
|
||||
const center = map.getCenter()
|
||||
onChange([center.lat, center.lng], map.getZoom())
|
||||
}
|
||||
map.whenReady(save)
|
||||
map.on('moveend zoomend', save)
|
||||
return () => { map.off('moveend zoomend', save) }
|
||||
}, [map, onChange])
|
||||
return null
|
||||
}
|
||||
|
||||
interface MapControllerProps {
|
||||
center: [number, number]
|
||||
zoom: number
|
||||
@@ -421,6 +410,7 @@ import { markerPhotoHtml, placePhotoFull, placePhotoUrl } from './placePhoto'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { useIsWide } from '../../hooks/sizeClass'
|
||||
|
||||
// Live-location rendering inside the Leaflet map. Subscribes via the
|
||||
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
|
||||
@@ -683,40 +673,37 @@ export const MapView = memo(function MapView({
|
||||
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
|
||||
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
|
||||
const transportRoutes = useTransportRoutes(visibleReservations)
|
||||
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
|
||||
// The chrome overlaying the map (side panels, day detail). Kept as a plain box so both the
|
||||
// Leaflet fit options and the opening-camera maths can read the same numbers.
|
||||
// Followed rather than read once: a phone turned sideways and back crosses
|
||||
// the breakpoint twice, and the padding here and the locate button further
|
||||
// down used to stay with whatever the map saw when it mounted.
|
||||
const isMobile = useIsPhone()
|
||||
// The touch shell on an iPad or an unfolded foldable floats its toolbar over the
|
||||
// top of the map and its buttons in the corners, and its pins stand above their
|
||||
// spot: a day framed to the phone's thin margin ran under the toolbar.
|
||||
const isWide = useIsWide()
|
||||
const paddingBox = useMemo((): ViewportPadding => {
|
||||
if (isMobile) return { top: 20, right: 40, bottom: 20, left: 40 }
|
||||
if (isMobile) {
|
||||
const margin = { top: 20, right: 40, bottom: 20, left: 40 }
|
||||
return isWide ? { ...margin, top: 84, bottom: 56 } : margin
|
||||
}
|
||||
return {
|
||||
top: 60,
|
||||
right: rightWidth + 40,
|
||||
bottom: bottomChromePadding({ hasInspector, inspectorHeight, hasDayDetail }),
|
||||
left: leftWidth + 40,
|
||||
}
|
||||
}, [isMobile, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail])
|
||||
}, [isMobile, isWide, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail])
|
||||
|
||||
const paddingOpts = useMemo(() => leafletPadding(paddingBox), [paddingBox])
|
||||
|
||||
// Open framed on the places rather than on the caller's default, so a trip in Japan shows
|
||||
// Japan straight away instead of the world view followed by a flight across the planet.
|
||||
// The initializer runs once, at mount — exactly when this should be decided; afterwards the
|
||||
// camera belongs to the user. `framed` is false when no place has coordinates (a new trip),
|
||||
// and then the caller's center/zoom stands.
|
||||
const [initialView] = useState(() => {
|
||||
const framed = computeMapViewport(dayPlaces.length > 0 ? dayPlaces : places, {
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
padding: paddingBox,
|
||||
// With a longitude frame the trip lies on one copy, so it can be framed the short way.
|
||||
wrapsWorld: lngFrame !== null,
|
||||
})
|
||||
const opening: [number, number] | undefined = framed ? [framed.center[0], frameLng(lngFrame, framed.center[1])] : undefined
|
||||
return { center: opening ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
|
||||
})
|
||||
// Open framed on the places, not on the caller's default: a trip in Japan shows Japan at once instead
|
||||
// of the world and a flight across it. Decided once, at mount; afterwards the camera is the user's.
|
||||
// `framed` is false when no place has coordinates (a new trip), and then the caller's center/zoom stands.
|
||||
const [initialView] = useState(() => openingView(dayPlaces.length > 0 ? dayPlaces : places, {
|
||||
padding: paddingBox, lngFrame, center, zoom, paned: isMobile && isWide,
|
||||
}))
|
||||
const cameraSnapshotRef = useRef<{ center: [number, number]; zoom: number } | null>(null)
|
||||
const saveCameraSnapshot = useCallback((cameraCenter: [number, number], cameraZoom: number) => {
|
||||
cameraSnapshotRef.current = { center: cameraCenter, zoom: cameraZoom }
|
||||
@@ -952,6 +939,8 @@ export const MapView = memo(function MapView({
|
||||
{/* First, so a fit or a camera request in the same commit starts from the trip's world copy. */}
|
||||
<LngFrameFollower />
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<ContainerResizeWatcher />
|
||||
{initialView.refit && <OpeningFrameFit frame={initialView.refit} />}
|
||||
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
|
||||
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingBox={paddingBox} />
|
||||
@@ -1138,7 +1127,7 @@ export const MapView = memo(function MapView({
|
||||
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
|
||||
smaller gap its shadow ran into the sidebar edge and the two read as one
|
||||
surface. */}
|
||||
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ ...switcherRowStyle(leftWidth, rightWidth), bottom: switcherBottom }}>
|
||||
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
||||
{hasViewBaseLayer
|
||||
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user