mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-09 22:00:57 +00:00
Compare commits
140 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 | |||
| 02b0454b3d | |||
| 9c1e63e64b | |||
| dacc413c34 | |||
| b8f6a99895 | |||
| db82e04b49 | |||
| 3abc9eacf4 | |||
| 3a0edafdf0 | |||
| 35d2b19f4c | |||
| 6b5b2f614d | |||
| 92f10531d0 | |||
| d5ba4439f2 | |||
| 78a28e2747 | |||
| 71e3ddabed | |||
| e59af6dcb3 | |||
| 9755330113 | |||
| 5ca44453ae | |||
| 4461899910 |
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@ on:
|
||||
- 'client/ios/**'
|
||||
- 'client/native-shell/**'
|
||||
- 'client/src/native/trekShell.ts'
|
||||
- 'client/src/native/watch.ts'
|
||||
- 'client/src/native/serverAddress.ts'
|
||||
- 'client/capacitor.config.ts'
|
||||
- 'client/vite.native.config.js'
|
||||
@@ -59,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
|
||||
@@ -103,7 +104,7 @@ jobs:
|
||||
xcodebuild -version
|
||||
|
||||
- name: Check the property lists
|
||||
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings client/ios/App/ShareExtension/Info.plist client/ios/App/ShareExtension/ShareExtension.entitlements client/ios/App/ShareExtension/*.lproj/Localizable.strings
|
||||
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings client/ios/App/ShareExtension/Info.plist client/ios/App/ShareExtension/ShareExtension.entitlements client/ios/App/ShareExtension/*.lproj/Localizable.strings client/ios/App/TREKWatch/Info.plist client/ios/App/TREKWatch/TREKWatch.entitlements client/ios/App/TREKWatch/*.lproj/Localizable.strings client/ios/App/TREKWatchWidgets/Info.plist client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -120,6 +121,23 @@ 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
|
||||
working-directory: client/ios/App
|
||||
run: |
|
||||
@@ -127,14 +145,16 @@ jobs:
|
||||
-project App.xcodeproj \
|
||||
-scheme App \
|
||||
-configuration Debug \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
# 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:
|
||||
@@ -159,3 +179,99 @@ jobs:
|
||||
name: widget-previews
|
||||
path: client/ios/WidgetPreviews/out
|
||||
retention-days: 14
|
||||
|
||||
# The watch's half in the UI (client/src/native/watch.ts): the snapshot it
|
||||
# builds and the ticks it takes back, typechecked and tested here as well,
|
||||
# so a change to the bridge is checked next to the Swift that reads it.
|
||||
watch-bridge:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- name: Install
|
||||
run: npm ci --workspace=shared --workspace=client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Typecheck the client
|
||||
working-directory: client
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test the widget and watch snapshots
|
||||
working-directory: client
|
||||
run: npx vitest run src/native/watch.test.ts src/native/widgets.test.ts src/native/widgetExtras.test.ts
|
||||
|
||||
# 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:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Select Xcode 26
|
||||
run: |
|
||||
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
|
||||
test -n "$XCODE"
|
||||
sudo xcode-select -s "$XCODE"
|
||||
|
||||
# The project resolves the Capacitor packages from node_modules, even for
|
||||
# a target that uses none of them.
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- name: Install
|
||||
run: npm ci --workspace=shared --workspace=client
|
||||
|
||||
- name: Make sure a watchOS simulator runtime is there
|
||||
run: |
|
||||
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: |
|
||||
xcodebuild build \
|
||||
-project App.xcodeproj \
|
||||
-target TREKWatch \
|
||||
-configuration Debug \
|
||||
-sdk watchsimulator \
|
||||
SYMROOT="$PWD/build" \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
- name: Capture the screens
|
||||
run: bash client/ios/scripts/watch-screens.sh client/ios/App/build/Debug-watchsimulator/TREKWatch.app "$PWD/watch-screens"
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: watch-screens
|
||||
path: watch-screens
|
||||
retention-days: 14
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -100,13 +100,13 @@ object TrekTileStore {
|
||||
return null
|
||||
}
|
||||
|
||||
/** HttpURLConnection has already undone any gzip, so no Content-Encoding goes along. */
|
||||
/** Headers that are the request's own; the connection sets the hop-by-hop ones itself. */
|
||||
private val HOP_BY_HOP = setOf("connection", "keep-alive", "transfer-encoding", "te", "upgrade", "host", "content-length", "accept-encoding")
|
||||
|
||||
internal fun forwardedHeaders(headers: Map<String, String>?): Map<String, String> =
|
||||
headers.orEmpty().filterKeys { it.lowercase() !in HOP_BY_HOP }
|
||||
|
||||
/** HttpURLConnection has already undone any gzip, so no Content-Encoding goes along. */
|
||||
private fun response(bytes: ByteArray, type: String): WebResourceResponse {
|
||||
val mime = type.substringBefore(';').trim()
|
||||
val charset = Regex("charset=([^;]+)").find(type)?.groupValues?.get(1)
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ SIGNED_TARGETS = {
|
||||
"NotificationService" => "com.liketrek.trek.notifications",
|
||||
"TREKWidgets" => "com.liketrek.trek.widgets",
|
||||
"ShareExtension" => "com.liketrek.trek.share",
|
||||
"TREKWatch" => "com.liketrek.trek.watchkitapp",
|
||||
"TREKWatchWidgets" => "com.liketrek.trek.watchkitapp.widgets",
|
||||
}
|
||||
|
||||
platform :ios do
|
||||
|
||||
@@ -10,11 +10,19 @@
|
||||
01FD437981FC32C2C2C5BDCE /* TREKWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
0520A7C30D8D3FACCBB784BE /* ShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2A15008B5664C48B639F94 /* ShareItem.swift */; };
|
||||
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */; };
|
||||
0D801738BB09A41A62E9B57F /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
|
||||
1091DAAC693DAE3E1CBB7D69 /* WatchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8850ACF43C79E9D879ACC44 /* WatchStore.swift */; };
|
||||
11B5B80E66AB03C92B0EADBD /* TREKWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CE3377B458EDD77B809F61 /* TREKWatchApp.swift */; };
|
||||
157A7B737E3FFB610A218D47 /* AtlasContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D004544572B0E85DE29A7B8 /* AtlasContent.swift */; };
|
||||
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
|
||||
1F8D58E1B4FD9BB84172527A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
|
||||
2162CD349AFFDF070C16A58A /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E901288FC198E4D33BE754F /* SwiftUI.framework */; };
|
||||
228EA0E0270314581194EC3A /* TREKWatchWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D88ABD8284E0A39E7586BE /* TREKWatchWidgets.swift */; };
|
||||
239FC886F02275957A1D449A /* FacesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 787A9D6B7D9EB7FED319C1B5 /* FacesView.swift */; };
|
||||
2CFE220A73ACB585F3D4CB8F /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2EE43D236E661AA975612A4B /* UserNotifications.framework */; };
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
2FB9B214E9DA5C7A25BE0EA1 /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
|
||||
318BBC653A438E6D1D493F50 /* DemoMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9B6357E3FBBA4D9CDD1C7A3 /* DemoMode.swift */; };
|
||||
32797E66C09CF7579AC323FD /* TREKWidgetsBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */; };
|
||||
3FB1872A25267A8E88FCBE3C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC64330AC19625949E8DC05A /* WidgetKit.framework */; };
|
||||
45B990B86488D5CC2B5222B5 /* WorldGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC751335E0136865E08990 /* WorldGrid.swift */; };
|
||||
@@ -31,30 +39,57 @@
|
||||
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */; };
|
||||
5BBE9EE02A47EC67C31F5E21 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D0A4B2C6C93971E884561CFA /* Localizable.strings */; };
|
||||
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = A030A06726542A2D7F61C54B /* TrekFamily.swift */; };
|
||||
634F4E63DC7C2A0027F0D118 /* Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = F406A4A31BD875A880A7CA14 /* Details.swift */; };
|
||||
661AEED37F1F278677F4F306 /* WatchStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B991C938B24A275550C15977 /* WatchStyle.swift */; };
|
||||
6680B5C572AA37E6E3DFC3A2 /* ComplicationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */; };
|
||||
6EB76B1C6AB4AC78FF021994 /* WatchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8850ACF43C79E9D879ACC44 /* WatchStore.swift */; };
|
||||
7060F9DB820849A45ECE0507 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3C3D5E2482AC7D20D2054623 /* Assets.xcassets */; };
|
||||
7A1E00032C0000000000A003 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */; };
|
||||
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */; };
|
||||
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00062C0000000000A006 /* TrekShellPlugin.swift */; };
|
||||
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
|
||||
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 */; };
|
||||
91B14997828BEF884E1239B4 /* Pages.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB5322D218E340690F499CA2 /* Pages.swift */; };
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
|
||||
96DBDCE4766AC9F13F100A06 /* SharePage.js in Resources */ = {isa = PBXBuildFile; fileRef = 9DC696E1A1FD3A1A1695F9EA /* SharePage.js */; };
|
||||
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B175DE91E1E6E850DB0285 /* TodayContent.swift */; };
|
||||
A0E8821CA7CF51AF2D6D8422 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 248A72D1C511B4969D89333A /* Localizable.strings */; };
|
||||
A2B048E183B19BB17E264FDE /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED561D7B7219D23A99FAEB11 /* ShareViewController.swift */; };
|
||||
A8FDD4DBE3F078BF935CC4B9 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA8802204810ED580DE46F3 /* NotificationService.swift */; };
|
||||
AAE5F2A3903F1E241C395BB8 /* WatchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42EBD981456D3493C3947441 /* WatchModel.swift */; };
|
||||
AEE032DFFB63C7BEB8AFABBF /* TREKWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = CF4BA0BDDABFBDB24394025D /* TREKWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
B4C0FC386F71D849E141C91E /* NextTripWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */; };
|
||||
B65B7CFC62EEFF443491EA64 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 192E69C7E66646BC39566925 /* ActivityKit.framework */; };
|
||||
B8D1816D155C8D52DFCAE25B /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6831E849332626BE8D54CEF /* ShareView.swift */; };
|
||||
BE2DBB926DB04D6CEEFD8B43 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 694631A1018DCE5678F29930 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
C109B48C5A072AF9BE685BE4 /* ComplicationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */; };
|
||||
C3959ACB55B495F7A86F89A9 /* WatchStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B991C938B24A275550C15977 /* WatchStyle.swift */; };
|
||||
D0983EB4FB8015F96E232919 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
|
||||
D3C699E2258FBA6A11C1DDE4 /* TripIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */; };
|
||||
DE14C398EAFA594CB32ABBF2 /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
|
||||
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */; };
|
||||
EA5EDDD4E5F9BC9BC46C563F /* CollabContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D5041DDDAFF5B491B053999 /* CollabContent.swift */; };
|
||||
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */; };
|
||||
F30AA6CFFE04035E7D6AC546 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
|
||||
F9947489F5B9323E519D3903 /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
|
||||
FEB91385917AC44D5CDAA59C /* PackingContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C0771C8582246A26E0C98E /* PackingContent.swift */; };
|
||||
FECB6034B17C07C879908CEF /* TREKWatchWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
201AA9320B6C5DCBCC3429BF /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 59A840D13C3074A09FCCC7CB;
|
||||
remoteInfo = TREKWatch;
|
||||
};
|
||||
3BB8A38B2B2C8614B24CF2AD /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
@@ -76,9 +111,38 @@
|
||||
remoteGlobalIDString = A3BE5653156025C97CA62C68;
|
||||
remoteInfo = TREKWidgets;
|
||||
};
|
||||
EA68232794738AB11E866FA3 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 5B5EA8FB8F58C6BFA3B77661;
|
||||
remoteInfo = TREKWatchWidgets;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9545786868FF757328C69E1E /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
FECB6034B17C07C879908CEF /* TREKWatchWidgets.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
986D3C6100A1AB6BE603CF1C /* Embed Watch Content */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
AEE032DFFB63C7BEB8AFABBF /* TREKWatch.app in Embed Watch Content */,
|
||||
);
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -95,14 +159,23 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
00B7BB68930D45D35C9AD54A /* az */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
037DA05706AE051CEFCA7D75 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
04C0771C8582246A26E0C98E /* PackingContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackingContent.swift; sourceTree = "<group>"; };
|
||||
07C00C53BA5641B009E40EF1 /* zh-Hans */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
07DF720DA5E63F842526A075 /* vi */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
090D74588DA8436DEC275567 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
095A4E5D36F6E22070842EC1 /* nl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
0A7C4491A7ABDDBEBE00E5BF /* sk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
0BA66BF6D1F4FB57EFF31981 /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
||||
0CC4BCF06E7719280549FFF1 /* el */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TREKWatchWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
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>"; };
|
||||
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekStyle.swift; sourceTree = "<group>"; };
|
||||
192E69C7E66646BC39566925 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
|
||||
@@ -111,19 +184,27 @@
|
||||
1E7522315D47F086145DC4B8 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
1ED347C91E03E51FDF8ABC1A /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
22A904A2E55946892F488B1A /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
230D9B2E5898FCEC3EC4FB44 /* pl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
23AE662FD3762641A2047F72 /* ja */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
241C37D114DDD53C7E915CB8 /* id */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
24B175DE91E1E6E850DB0285 /* TodayContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayContent.swift; sourceTree = "<group>"; };
|
||||
2D75538DBBAA6B2366CD8B69 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
2EE43D236E661AA975612A4B /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; };
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
2FD81D4601E9EB8744171B09 /* zh-Hant */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayLiveActivity.swift; sourceTree = "<group>"; };
|
||||
32A62EE30F8AD514D7DB2CAA /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayAttributes.swift; sourceTree = "<group>"; };
|
||||
37FA9D6C0C247EC699CF326C /* TREKWatchWidgets.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWatchWidgets.entitlements; sourceTree = "<group>"; };
|
||||
38C632E7EE9C0DF1CA0BF0A0 /* cs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
3C3D5E2482AC7D20D2054623 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
3CB565FC61AB5AEA87A3F615 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
3DF4F4305878F949DD09193F /* NotificationService.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = "<group>"; };
|
||||
405B04DFF3A978D1046147C6 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
408F0E543D49BFCCF6B19194 /* es */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
4167040E2C29E44D55580916 /* ru */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
42EBD981456D3493C3947441 /* WatchModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchModel.swift; sourceTree = "<group>"; };
|
||||
467929A8EB65D22F8FA91588 /* ca */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
4D004544572B0E85DE29A7B8 /* AtlasContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtlasContent.swift; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -134,6 +215,7 @@
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
56050CCA8DE4BEB4680D9E0B /* ShareHandoff.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareHandoff.swift; sourceTree = "<group>"; };
|
||||
57DF93CD1B88A7135481BF03 /* de */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
5C0109EA628FA897B7BFDD3E /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
608F00CBD96957EEA888E50A /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
60EB00F3670B41B898765DF9 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
@@ -143,24 +225,34 @@
|
||||
683725459244601B71D92E2D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
6932A417DC4BC05BF07D80E2 /* it */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
694631A1018DCE5678F29930 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
697E5726770333015DDD444D /* TREKWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWatch.entitlements; sourceTree = "<group>"; };
|
||||
6E901288FC198E4D33BE754F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TREKWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7328217E23761C22814FEC25 /* az */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
772816394C94096090C2F8FE /* ar */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
77A0112CA76E5C1787073AEB /* hu */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
77C217524DA98A6A92F422F4 /* zh-Hant */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
787A9D6B7D9EB7FED319C1B5 /* FacesView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FacesView.swift; sourceTree = "<group>"; };
|
||||
7887877A91D6C782111A8388 /* it */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
7A1E00012C0000000000A001 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekBridgeViewController.swift; sourceTree = "<group>"; };
|
||||
7A1E00062C0000000000A006 /* TrekShellPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekShellPlugin.swift; sourceTree = "<group>"; };
|
||||
7D5041DDDAFF5B491B053999 /* CollabContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CollabContent.swift; sourceTree = "<group>"; };
|
||||
7D525040492AFBC121FD4CC6 /* ar */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSnapshot.swift; sourceTree = "<group>"; };
|
||||
8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripIntent.swift; sourceTree = "<group>"; };
|
||||
849A85B6008760CCD7D9AE6A /* id */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
84CB5C3B6BD4E1DE1A8BD2B4 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
889DA33D98BC601268861D54 /* fr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8A0289CB321AE008D8815A19 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWidgetsBundle.swift; sourceTree = "<group>"; };
|
||||
8CBB0E5B9BE05CE82C8DA7F4 /* sv */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8D361F7B14AE9F5ACE3BFE78 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
91F0DD852C0D6D4578449977 /* en */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
96CE3377B458EDD77B809F61 /* TREKWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWatchApp.swift; sourceTree = "<group>"; };
|
||||
9A3FAA9DBC67D12389EEF0CF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9D673E1909A5B750376ECB41 /* en */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9DC696E1A1FD3A1A1695F9EA /* SharePage.js */ = {isa = PBXFileReference; includeInIndex = 1; path = SharePage.js; sourceTree = "<group>"; };
|
||||
@@ -172,35 +264,56 @@
|
||||
A479036A85683EFF2922D096 /* el */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
A5C182FC253CBEE8D5439536 /* ca */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
AA02F5CBC22E08415CC1C9BE /* pl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
AB5322D218E340690F499CA2 /* Pages.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Pages.swift; sourceTree = "<group>"; };
|
||||
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodosContent.swift; sourceTree = "<group>"; };
|
||||
B0450E66BFCF00CC4097330A /* he */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B680CBF1268D2EE1982B165C /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B7F33B7A5C27C126DF29DCA4 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B8850ACF43C79E9D879ACC44 /* WatchStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStore.swift; sourceTree = "<group>"; };
|
||||
B991C938B24A275550C15977 /* WatchStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStyle.swift; sourceTree = "<group>"; };
|
||||
B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationViews.swift; sourceTree = "<group>"; };
|
||||
BB2A15008B5664C48B639F94 /* ShareItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareItem.swift; sourceTree = "<group>"; };
|
||||
BBB2E19CD540AB30B44BECCA /* TREKWidgets.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWidgets.entitlements; sourceTree = "<group>"; };
|
||||
BC64330AC19625949E8DC05A /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
BE41D7212DCB0C78E00A8462 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
BF10C31D56A1C10C6BF62046 /* pt-BR */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
C24CDF14EBF4CF52CBF3DDD0 /* he */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C333CDB6CAD048E3F9242222 /* ru */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C6A6008115A85C9AAE181C5E /* sk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NextTripWidget.swift; sourceTree = "<group>"; };
|
||||
C73847E8A007B21B8AD1DCD4 /* tr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C996A3060A2D20A4B5C9C377 /* ko */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C9B6357E3FBBA4D9CDD1C7A3 /* DemoMode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoMode.swift; sourceTree = "<group>"; };
|
||||
CC077989456FB9DB5F4FF676 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
CD6763B965A87DDB6FCC2D05 /* de */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
CD83ADB6D661D6F7A64CDAB6 /* es */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
CE79157F1170A47CF7FC6FE0 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
CF4BA0BDDABFBDB24394025D /* TREKWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TREKWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D0AC751335E0136865E08990 /* WorldGrid.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorldGrid.swift; sourceTree = "<group>"; };
|
||||
D84D92C18CC022646692CB9B /* sv */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
D863C8994C9DAA79D236401C /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = "<group>"; };
|
||||
DC0C9AB4D77D77A7BC2BA85B /* ja */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
DD341749E47B7DF89BA9A46D /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
DE0046E6067251CB0D61EC7A /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripContent.swift; sourceTree = "<group>"; };
|
||||
DE8B592FE52EC6FFB589751E /* fr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E2CFBDBCC474A7A72E053A8E /* uk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; };
|
||||
E47D5FF1A217D5F77B3C7548 /* uk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E4AA747C2B20D0CDA0A9538A /* hu */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E5800E4ECECED54A47CDABAA /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E6831E849332626BE8D54CEF /* ShareView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
|
||||
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = "<group>"; };
|
||||
EAFBDD97043B34E4B64FDA86 /* pt-BR */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
EC8D1292C9FC6430C059960A /* nl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
ECC80E1314B31ED2D2347411 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
ED561D7B7219D23A99FAEB11 /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
EE30A6DF943052C6ADE92D63 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
F406A4A31BD875A880A7CA14 /* Details.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Details.swift; sourceTree = "<group>"; };
|
||||
F410423B093EBD2DD11C0FFC /* ko */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
F4D88ABD8284E0A39E7586BE /* TREKWatchWidgets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWatchWidgets.swift; sourceTree = "<group>"; };
|
||||
FB2D9CFF5AC22AEB8CA3D7BD /* cs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -223,6 +336,20 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
622430061CE31239E1D64FB8 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
69112BCF6652B92DFA2AE6BB /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C9AA4ADC0D397C620A35B6A3 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -259,6 +386,9 @@
|
||||
74C1FC6022E562372A71EEC9 /* NotificationService */,
|
||||
FB9226BC82835538BCE3DC0A /* TREKWidgets */,
|
||||
DFD2010504102AC0E682D00F /* ShareExtension */,
|
||||
A5731FDC6B43DF41413C28A6 /* TREKWatch */,
|
||||
83BCA52D4B145E66DC1B0111 /* TREKWatchKit */,
|
||||
F3ECD357918C4A98B023F65F /* TREKWatchWidgets */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -269,6 +399,8 @@
|
||||
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */,
|
||||
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */,
|
||||
694631A1018DCE5678F29930 /* ShareExtension.appex */,
|
||||
CF4BA0BDDABFBDB24394025D /* TREKWatch.app */,
|
||||
0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -289,6 +421,8 @@
|
||||
7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */,
|
||||
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
@@ -302,6 +436,7 @@
|
||||
6E901288FC198E4D33BE754F /* SwiftUI.framework */,
|
||||
192E69C7E66646BC39566925 /* ActivityKit.framework */,
|
||||
E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */,
|
||||
DC1BBE9AA7035B32D3653122 /* watchOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -317,6 +452,44 @@
|
||||
path = NotificationService;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
83BCA52D4B145E66DC1B0111 /* TREKWatchKit */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */,
|
||||
10FB55D221F4BA18795F33B5 /* WatchSample.swift */,
|
||||
842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */,
|
||||
B8850ACF43C79E9D879ACC44 /* WatchStore.swift */,
|
||||
B991C938B24A275550C15977 /* WatchStyle.swift */,
|
||||
);
|
||||
name = TREKWatchKit;
|
||||
path = TREKWatchKit;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A5731FDC6B43DF41413C28A6 /* TREKWatch */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3C3D5E2482AC7D20D2054623 /* Assets.xcassets */,
|
||||
C9B6357E3FBBA4D9CDD1C7A3 /* DemoMode.swift */,
|
||||
F406A4A31BD875A880A7CA14 /* Details.swift */,
|
||||
787A9D6B7D9EB7FED319C1B5 /* FacesView.swift */,
|
||||
D863C8994C9DAA79D236401C /* Info.plist */,
|
||||
AB5322D218E340690F499CA2 /* Pages.swift */,
|
||||
697E5726770333015DDD444D /* TREKWatch.entitlements */,
|
||||
96CE3377B458EDD77B809F61 /* TREKWatchApp.swift */,
|
||||
42EBD981456D3493C3947441 /* WatchModel.swift */,
|
||||
3A6054202DCC03D83C2C9602 /* Localizable.strings */,
|
||||
);
|
||||
name = TREKWatch;
|
||||
path = TREKWatch;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DC1BBE9AA7035B32D3653122 /* watchOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = watchOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DFD2010504102AC0E682D00F /* ShareExtension */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -333,6 +506,17 @@
|
||||
path = ShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F3ECD357918C4A98B023F65F /* TREKWatchWidgets */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D75538DBBAA6B2366CD8B69 /* Info.plist */,
|
||||
37FA9D6C0C247EC699CF326C /* TREKWatchWidgets.entitlements */,
|
||||
F4D88ABD8284E0A39E7586BE /* TREKWatchWidgets.swift */,
|
||||
);
|
||||
name = TREKWatchWidgets;
|
||||
path = TREKWatchWidgets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FB9226BC82835538BCE3DC0A /* TREKWidgets */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -388,6 +572,7 @@
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */,
|
||||
986D3C6100A1AB6BE603CF1C /* Embed Watch Content */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -395,6 +580,7 @@
|
||||
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */,
|
||||
4CD7F5AF02CB806E4189489A /* PBXTargetDependency */,
|
||||
855AE76937B942D62DFFD7E2 /* PBXTargetDependency */,
|
||||
DC6A321F5C3C7D5EF0973677 /* PBXTargetDependency */,
|
||||
);
|
||||
name = App;
|
||||
packageProductDependencies = (
|
||||
@@ -404,6 +590,42 @@
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
59A840D13C3074A09FCCC7CB /* TREKWatch */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = DF9244A5B1D6A7560CB92D4E /* Build configuration list for PBXNativeTarget "TREKWatch" */;
|
||||
buildPhases = (
|
||||
B3023E0FD7245E6465981AC5 /* Sources */,
|
||||
622430061CE31239E1D64FB8 /* Frameworks */,
|
||||
E46DCC12600D7B9D1381FBCE /* Resources */,
|
||||
9545786868FF757328C69E1E /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
5D0E9608BF309D8A27F5C4B6 /* PBXTargetDependency */,
|
||||
);
|
||||
name = TREKWatch;
|
||||
productName = TREKWatch;
|
||||
productReference = CF4BA0BDDABFBDB24394025D /* TREKWatch.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
5B5EA8FB8F58C6BFA3B77661 /* TREKWatchWidgets */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = C24DF86369B92FC86F735AEE /* Build configuration list for PBXNativeTarget "TREKWatchWidgets" */;
|
||||
buildPhases = (
|
||||
068381E1E877C97A3390DE57 /* Sources */,
|
||||
69112BCF6652B92DFA2AE6BB /* Frameworks */,
|
||||
F36545081C7270E279639BF4 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = TREKWatchWidgets;
|
||||
productName = TREKWatchWidgets;
|
||||
productReference = 0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
9480DFBC2C2A112973DA468A /* NotificationService */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 4E3E5FA93A15425251E22D48 /* Build configuration list for PBXNativeTarget "NotificationService" */;
|
||||
@@ -506,6 +728,8 @@
|
||||
9480DFBC2C2A112973DA468A /* NotificationService */,
|
||||
A3BE5653156025C97CA62C68 /* TREKWidgets */,
|
||||
01A63CD6CFBC1463CAA8FFF0 /* ShareExtension */,
|
||||
59A840D13C3074A09FCCC7CB /* TREKWatch */,
|
||||
5B5EA8FB8F58C6BFA3B77661 /* TREKWatchWidgets */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -540,6 +764,15 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E46DCC12600D7B9D1381FBCE /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7060F9DB820849A45ECE0507 /* Assets.xcassets in Resources */,
|
||||
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EB430D4F409D7961AF5603C9 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -549,9 +782,32 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F36545081C7270E279639BF4 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F30AA6CFFE04035E7D6AC546 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
068381E1E877C97A3390DE57 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
228EA0E0270314581194EC3A /* TREKWatchWidgets.swift in Sources */,
|
||||
C109B48C5A072AF9BE685BE4 /* ComplicationViews.swift in Sources */,
|
||||
896952F864B35C647FF6910A /* WatchSample.swift in Sources */,
|
||||
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */,
|
||||
6EB76B1C6AB4AC78FF021994 /* WatchStore.swift in Sources */,
|
||||
661AEED37F1F278677F4F306 /* WatchStyle.swift in Sources */,
|
||||
0D801738BB09A41A62E9B57F /* TrekStyle.swift in Sources */,
|
||||
DE14C398EAFA594CB32ABBF2 /* WidgetSnapshot.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1EA0B85A2E70AC56F83C09C9 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -569,6 +825,8 @@
|
||||
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */,
|
||||
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
|
||||
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
|
||||
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */,
|
||||
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -595,6 +853,26 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B3023E0FD7245E6465981AC5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
318BBC653A438E6D1D493F50 /* DemoMode.swift in Sources */,
|
||||
634F4E63DC7C2A0027F0D118 /* Details.swift in Sources */,
|
||||
239FC886F02275957A1D449A /* FacesView.swift in Sources */,
|
||||
91B14997828BEF884E1239B4 /* Pages.swift in Sources */,
|
||||
11B5B80E66AB03C92B0EADBD /* TREKWatchApp.swift in Sources */,
|
||||
AAE5F2A3903F1E241C395BB8 /* WatchModel.swift in Sources */,
|
||||
6680B5C572AA37E6E3DFC3A2 /* ComplicationViews.swift in Sources */,
|
||||
1F8D58E1B4FD9BB84172527A /* WatchSample.swift in Sources */,
|
||||
D0983EB4FB8015F96E232919 /* WatchSnapshot.swift in Sources */,
|
||||
1091DAAC693DAE3E1CBB7D69 /* WatchStore.swift in Sources */,
|
||||
C3959ACB55B495F7A86F89A9 /* WatchStyle.swift in Sources */,
|
||||
2FB9B214E9DA5C7A25BE0EA1 /* TrekStyle.swift in Sources */,
|
||||
F9947489F5B9323E519D3903 /* WidgetSnapshot.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E0B13DA0CF9C1103058FE869 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -615,12 +893,24 @@
|
||||
target = A3BE5653156025C97CA62C68 /* TREKWidgets */;
|
||||
targetProxy = B8BBE7F7A64678D780A7F3D1 /* PBXContainerItemProxy */;
|
||||
};
|
||||
5D0E9608BF309D8A27F5C4B6 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = TREKWatchWidgets;
|
||||
target = 5B5EA8FB8F58C6BFA3B77661 /* TREKWatchWidgets */;
|
||||
targetProxy = EA68232794738AB11E866FA3 /* PBXContainerItemProxy */;
|
||||
};
|
||||
855AE76937B942D62DFFD7E2 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = ShareExtension;
|
||||
target = 01A63CD6CFBC1463CAA8FFF0 /* ShareExtension */;
|
||||
targetProxy = 64B91219C04CABCE204C7CCC /* PBXContainerItemProxy */;
|
||||
};
|
||||
DC6A321F5C3C7D5EF0973677 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = TREKWatch;
|
||||
target = 59A840D13C3074A09FCCC7CB /* TREKWatch */;
|
||||
targetProxy = 201AA9320B6C5DCBCC3429BF /* PBXContainerItemProxy */;
|
||||
};
|
||||
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = NotificationService;
|
||||
@@ -665,6 +955,41 @@
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3A6054202DCC03D83C2C9602 /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
772816394C94096090C2F8FE /* ar */,
|
||||
00B7BB68930D45D35C9AD54A /* az */,
|
||||
467929A8EB65D22F8FA91588 /* ca */,
|
||||
FB2D9CFF5AC22AEB8CA3D7BD /* cs */,
|
||||
57DF93CD1B88A7135481BF03 /* de */,
|
||||
0CC4BCF06E7719280549FFF1 /* el */,
|
||||
91F0DD852C0D6D4578449977 /* en */,
|
||||
CD83ADB6D661D6F7A64CDAB6 /* es */,
|
||||
DE0046E6067251CB0D61EC7A /* et */,
|
||||
889DA33D98BC601268861D54 /* fr */,
|
||||
C24CDF14EBF4CF52CBF3DDD0 /* he */,
|
||||
E4AA747C2B20D0CDA0A9538A /* hu */,
|
||||
849A85B6008760CCD7D9AE6A /* id */,
|
||||
7887877A91D6C782111A8388 /* it */,
|
||||
23AE662FD3762641A2047F72 /* ja */,
|
||||
F410423B093EBD2DD11C0FFC /* ko */,
|
||||
EC8D1292C9FC6430C059960A /* nl */,
|
||||
230D9B2E5898FCEC3EC4FB44 /* pl */,
|
||||
BF10C31D56A1C10C6BF62046 /* pt-BR */,
|
||||
C333CDB6CAD048E3F9242222 /* ru */,
|
||||
C6A6008115A85C9AAE181C5E /* sk */,
|
||||
8CBB0E5B9BE05CE82C8DA7F4 /* sv */,
|
||||
1335F1EEB49D744B27A00F31 /* th */,
|
||||
C73847E8A007B21B8AD1DCD4 /* tr */,
|
||||
E47D5FF1A217D5F77B3C7548 /* uk */,
|
||||
07DF720DA5E63F842526A075 /* vi */,
|
||||
07C00C53BA5641B009E40EF1 /* zh-Hans */,
|
||||
2FD81D4601E9EB8744171B09 /* zh-Hant */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
@@ -719,6 +1044,33 @@
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
1F7D993B412B63E918648BA1 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = TREKWatch/TREKWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWatch/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
31CC9E868E52D06AD12E83D6 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -950,6 +1302,32 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
57D289EF9468E358528F5FBB /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = TREKWatch/TREKWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWatch/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
686FA31AEC6FE5BA3EF95E99 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -1001,6 +1379,30 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
C04A1F63D9298FA52E6C727E /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = TREKWatchWidgets/TREKWatchWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWatchWidgets/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
C5621F5D75100AAEBB1BD22A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -1027,6 +1429,31 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
DF4816365A7D101A9EA8A0C7 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = TREKWatchWidgets/TREKWatchWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWatchWidgets/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -1075,6 +1502,24 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
C24DF86369B92FC86F735AEE /* Build configuration list for PBXNativeTarget "TREKWatchWidgets" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
DF4816365A7D101A9EA8A0C7 /* Release */,
|
||||
C04A1F63D9298FA52E6C727E /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
DF9244A5B1D6A7560CB92D4E /* Build configuration list for PBXNativeTarget "TREKWatch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1F7D993B412B63E918648BA1 /* Release */,
|
||||
57D289EF9468E358528F5FBB /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
|
||||
@@ -10,6 +10,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Taps on a TREK notification, and banners while the app is open.
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
// Early, so ticks the watch sent while the app was closed arrive with this launch.
|
||||
TrekWatchLink.shared.activate()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -23,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])
|
||||
@@ -58,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
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,15 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise),
|
||||
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
|
||||
@@ -53,7 +62,20 @@ 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.
|
||||
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "wallet"]
|
||||
/// "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] {
|
||||
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
|
||||
|
||||
@@ -65,6 +87,48 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
DispatchQueue.main.async { self?.notifyListeners("pushOpened", data: ["url": url]) }
|
||||
}
|
||||
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
|
||||
@@ -137,6 +201,27 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
// MARK: - Apple Watch
|
||||
|
||||
/// Passes what the watch app shows (native/watch.ts builds it) on to the watch.
|
||||
@objc func setWatchSnapshot(_ call: CAPPluginCall) {
|
||||
guard TrekWatchLink.isAvailable else {
|
||||
call.resolve()
|
||||
return
|
||||
}
|
||||
guard let json = call.getString("json"), let data = json.data(using: .utf8) else {
|
||||
call.reject("json is required")
|
||||
return
|
||||
}
|
||||
TrekWatchLink.shared.publish(data)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
/// The packing items and to-dos ticked on the watch since the last call, oldest first.
|
||||
@objc func takeWatchActions(_ call: CAPPluginCall) {
|
||||
call.resolve(["actions": TrekWatchLink.shared.takeActions()])
|
||||
}
|
||||
|
||||
// MARK: - Push
|
||||
|
||||
/// Asks for permission, registers with APNs and answers with a Web Push
|
||||
@@ -270,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()
|
||||
}
|
||||
@@ -342,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 {
|
||||
@@ -381,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
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
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);
|
||||
/// this keeps the latest one, sends it as the application context, and as
|
||||
/// complication user info while the day's budget for those lasts and something
|
||||
/// a complication shows has changed. Ticks from the watch arrive as user info,
|
||||
/// are queued here until the UI takes them (TrekShell.takeWatchActions) and
|
||||
/// writes them through its offline-first path, and announced to it at once.
|
||||
final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
static let shared = TrekWatchLink()
|
||||
|
||||
/// Told when a tick arrived, on the main queue.
|
||||
var onAction: (() -> Void)?
|
||||
|
||||
private let queue = DispatchQueue(label: "com.liketrek.trek.watch")
|
||||
private let snapshotKey = "trek.watch.snapshot"
|
||||
private let complicationKey = "trek.watch.complicationDigest"
|
||||
private let actionsKey = "trek.watch.actions"
|
||||
/// The most WatchConnectivity carries in one application context, with room to spare.
|
||||
private let maxBytes = 60_000
|
||||
|
||||
/// False on an iPad (and anything else without a paired watch): then this is
|
||||
/// a no-op throughout, and the UI is not offered the feature at all.
|
||||
static var isAvailable: Bool { WCSession.isSupported() }
|
||||
|
||||
func activate() {
|
||||
guard Self.isAvailable else { return }
|
||||
WCSession.default.delegate = self
|
||||
WCSession.default.activate()
|
||||
}
|
||||
|
||||
// MARK: Snapshots to the watch
|
||||
|
||||
func publish(_ json: Data) {
|
||||
guard Self.isAvailable else { return }
|
||||
let data = withSmallCover(json)
|
||||
queue.async {
|
||||
UserDefaults.standard.set(data, forKey: self.snapshotKey)
|
||||
self.send(data)
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ data: Data) {
|
||||
guard Self.isAvailable else { return }
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return }
|
||||
try? session.updateApplicationContext(["snapshot": data])
|
||||
let digest = complicationDigest(data)
|
||||
if session.isComplicationEnabled, session.remainingComplicationUserInfoTransfers > 0,
|
||||
digest != UserDefaults.standard.string(forKey: complicationKey) {
|
||||
session.transferCurrentComplicationUserInfo(["snapshot": data])
|
||||
UserDefaults.standard.set(digest, forKey: complicationKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the complications show of a snapshot: everything but when it was built and the applied ticks.
|
||||
private func complicationDigest(_ data: Data) -> String {
|
||||
guard var object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { return "" }
|
||||
object.removeValue(forKey: "generatedAt")
|
||||
object.removeValue(forKey: "applied")
|
||||
object.removeValue(forKey: "packing")
|
||||
object.removeValue(forKey: "todos")
|
||||
let canonical = (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])) ?? Data()
|
||||
return SHA256.hash(data: canonical).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// The trip's cover scaled down to a thumbnail the watch can draw, or dropped when it would not fit.
|
||||
private func withSmallCover(_ json: Data) -> Data {
|
||||
guard var object = (try? JSONSerialization.jsonObject(with: json)) as? [String: Any],
|
||||
var trip = object["trip"] as? [String: Any], let cover = trip["cover"] as? String else { return json }
|
||||
trip.removeValue(forKey: "cover")
|
||||
if let data = Data(base64Encoded: cover), let image = UIImage(data: data) {
|
||||
let width: CGFloat = 220
|
||||
let size = CGSize(width: width, height: (width * image.size.height / max(image.size.width, 1)).rounded())
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1
|
||||
let small = UIGraphicsImageRenderer(size: size, format: format).image { _ in image.draw(in: CGRect(origin: .zero, size: size)) }
|
||||
if let jpeg = small.jpegData(compressionQuality: 0.6), jpeg.count < 24_000 {
|
||||
trip["cover"] = jpeg.base64EncodedString()
|
||||
}
|
||||
}
|
||||
object["trip"] = trip
|
||||
guard let result = try? JSONSerialization.data(withJSONObject: object) else { return json }
|
||||
if result.count > maxBytes, trip["cover"] != nil {
|
||||
trip.removeValue(forKey: "cover")
|
||||
object["trip"] = trip
|
||||
return (try? JSONSerialization.data(withJSONObject: object)) ?? json
|
||||
}
|
||||
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.
|
||||
func takeActions() -> [[String: Any]] {
|
||||
queue.sync {
|
||||
let actions = storedActions()
|
||||
UserDefaults.standard.removeObject(forKey: actionsKey)
|
||||
return actions
|
||||
}
|
||||
}
|
||||
|
||||
private func storedActions() -> [[String: Any]] {
|
||||
guard let data = UserDefaults.standard.data(forKey: actionsKey),
|
||||
let list = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else { return [] }
|
||||
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.
|
||||
var actions = self.storedActions().filter { ($0["id"] as? String) != (action["id"] as? String) }
|
||||
actions.append(action)
|
||||
if let data = try? JSONSerialization.data(withJSONObject: Array(actions.suffix(200))) {
|
||||
UserDefaults.standard.set(data, forKey: self.actionsKey)
|
||||
}
|
||||
DispatchQueue.main.async { self.onAction?() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: WCSessionDelegate
|
||||
|
||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
guard activationState == .activated else { return }
|
||||
queue.async {
|
||||
if let data = UserDefaults.standard.data(forKey: self.snapshotKey) { self.send(data) }
|
||||
}
|
||||
}
|
||||
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
/// After switching watches the session has to be activated again for the new one.
|
||||
func sessionDidDeactivate(_ session: WCSession) {
|
||||
WCSession.default.activate()
|
||||
}
|
||||
|
||||
func sessionWatchStateDidChange(_ session: WCSession) {
|
||||
queue.async {
|
||||
if let data = UserDefaults.standard.data(forKey: self.snapshotKey) { self.send(data) }
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
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, 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])
|
||||
} else {
|
||||
replyHandler([:])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"colors": [
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": { "alpha": "1.000", "blue": "1.000", "green": "1.000", "red": "1.000" }
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "AppIcon-1024.png",
|
||||
"idiom": "universal",
|
||||
"platform": "watchos",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
/// The screenshot mode: started with launch arguments, the app runs on the
|
||||
/// sample trip at a fixed time and opens on one screen, so the CI can capture
|
||||
/// every screen in every size and language from a simulator
|
||||
/// (.github/workflows/native.yml, watch-screens). Nothing is sent or stored.
|
||||
///
|
||||
/// -TrekDemo <screen> today, day, place, booking, booking-detail, bookings,
|
||||
/// trip, days, packing, todos, lists, countdown, empty,
|
||||
/// nosync, faces
|
||||
/// -TrekScenario <name> running, travelday or upcoming (default by screen)
|
||||
/// -TrekReady <token> once the screen has drawn, writes Documents/ready-<token>
|
||||
/// in the app's container, which the script waits for
|
||||
/// -TrekNow <HH:mm> the time the screens think it is; without it the
|
||||
/// real time, which the capture script moves to the
|
||||
/// hour it wants with the app's time zone (TZ), so the
|
||||
/// clock in the corner agrees with the screen
|
||||
enum DemoMode {
|
||||
enum Screen: String {
|
||||
case today, day, place, booking, bookings, trip, days, packing, todos, lists, countdown, empty, nosync, faces
|
||||
case bookingDetail = "booking-detail"
|
||||
}
|
||||
|
||||
static var screen: Screen? {
|
||||
value(after: "-TrekDemo").flatMap(Screen.init(rawValue:))
|
||||
}
|
||||
|
||||
/// Tells the capture script the screen is up: a moment after it appeared,
|
||||
/// so the navigation to it and the first layout have settled.
|
||||
static func signalReady() {
|
||||
guard let token = value(after: "-TrekReady"),
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
||||
try? FileManager.default.createDirectory(at: documents, withIntermediateDirectories: true)
|
||||
try? Data().write(to: documents.appendingPathComponent("ready-\(token)"))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func start() {
|
||||
guard let screen else { return }
|
||||
let now = clock()
|
||||
let scenario = value(after: "-TrekScenario").flatMap(WatchSample.Scenario.init(rawValue:)) ?? defaultScenario(screen)
|
||||
switch screen {
|
||||
case .nosync:
|
||||
WatchModel.shared.startDemo(nil, now: now)
|
||||
case .empty:
|
||||
WatchModel.shared.startDemo(WatchSnapshot(generatedAt: ISO8601DateFormatter().string(from: now), trip: nil), now: now)
|
||||
default:
|
||||
WatchModel.shared.startDemo(WatchSample.snapshot(scenario, now: now), now: now)
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultScenario(_ screen: Screen) -> WatchSample.Scenario {
|
||||
switch screen {
|
||||
case .booking, .bookings, .bookingDetail: return .travelday
|
||||
case .countdown, .packing, .lists: return .upcoming
|
||||
default: return .running
|
||||
}
|
||||
}
|
||||
|
||||
private static func clock() -> Date {
|
||||
guard let parts = value(after: "-TrekNow")?.split(separator: ":").compactMap({ Int($0) }), parts.count == 2 else { return Date() }
|
||||
return TrekDate.at(parts[0] * 60 + parts[1], on: Date())
|
||||
}
|
||||
|
||||
private static func value(after flag: String) -> String? {
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
guard let index = args.firstIndex(of: flag), index + 1 < args.count else { return nil }
|
||||
return args[index + 1]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import MapKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Days
|
||||
|
||||
/// Every day of the trip, today marked, each opening its plan.
|
||||
struct DaysListView: View {
|
||||
let snapshot: WatchSnapshot
|
||||
@Environment(\.trekNow) private var now
|
||||
|
||||
var body: some View {
|
||||
let today = snapshot.todayIndex(at: now)
|
||||
List {
|
||||
ForEach(Array(snapshot.days.enumerated()), id: \.offset) { index, day in
|
||||
NavigationLink(value: Route.day(index)) {
|
||||
HStack(spacing: 10) {
|
||||
Text("\(day.number)")
|
||||
.font(.system(.headline, design: .rounded).weight(.heavy).monospacedDigit())
|
||||
.foregroundStyle(index == today ? Color.black : WatchLook.ink)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Circle().fill(index == today ? Color.white : Color.white.opacity(0.12)))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(day.title ?? day.dateValue.map { WatchFormat.day($0) } ?? WatchFormat.dayOf(day.number, snapshot.days.count))
|
||||
.font(.system(.body, design: .rounded).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(summary(day)).userText(summary(day))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.opacity(isPast(day) ? 0.55 : 1)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L("trip.days"))
|
||||
.containerBackground(WatchLook.tripGradient(snapshot.trip), for: .navigation)
|
||||
}
|
||||
|
||||
private func summary(_ day: WatchSnapshot.Day) -> String {
|
||||
guard let first = day.stops.first else { return L("day.empty") }
|
||||
return first.booking.map { "\(WatchLook.kindName($0)) \(first.name)" } ?? first.name
|
||||
}
|
||||
|
||||
private func isPast(_ day: WatchSnapshot.Day) -> Bool {
|
||||
guard let date = day.dateValue else { return false }
|
||||
return TrekDate.daysBetween(now, date) < 0
|
||||
}
|
||||
}
|
||||
|
||||
/// One day's plan as a timeline: times on the leading edge, the line between
|
||||
/// the stops filled up to now, the current stop lit. The crown scrolls it.
|
||||
struct DayView: View {
|
||||
let snapshot: WatchSnapshot
|
||||
let index: Int
|
||||
@Environment(\.trekNow) private var now
|
||||
|
||||
var body: some View {
|
||||
let day = snapshot.days.indices.contains(index) ? snapshot.days[index] : nil
|
||||
let isToday = day?.date == TrekDate.iso(now)
|
||||
let nowMinutes = isToday ? TrekDate.minutesOfDay(now) : (isPastDay(day) ? 24 * 60 : -1)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(WatchFormat.dayOf(day?.number ?? index + 1, max(snapshot.tripLength, snapshot.days.count)))
|
||||
.watchEyebrow(.white.opacity(0.85))
|
||||
if let date = day?.dateValue {
|
||||
Text(WatchFormat.day(date))
|
||||
.font(.system(.headline, design: .rounded))
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
let stops = day?.stops ?? []
|
||||
if stops.isEmpty {
|
||||
Text(L("day.empty"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
ForEach(Array(stops.enumerated()), id: \.offset) { offset, stop in
|
||||
NavigationLink(value: Route.stop(day: index, stop: offset)) {
|
||||
TimelineRow(
|
||||
stop: stop,
|
||||
hour12: snapshot.hour12,
|
||||
state: state(stop, next: offset + 1 < stops.count ? stops[offset + 1] : nil, nowMinutes: nowMinutes),
|
||||
isLast: offset == stops.count - 1
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(day?.dateValue.map { WatchFormat.weekday($0) } ?? L("trip.days"))
|
||||
.containerBackground(WatchLook.tripGradient(snapshot.trip), for: .navigation)
|
||||
}
|
||||
|
||||
private func isPastDay(_ day: WatchSnapshot.Day?) -> Bool {
|
||||
guard let date = day?.dateValue else { return false }
|
||||
return TrekDate.daysBetween(now, date) < 0
|
||||
}
|
||||
|
||||
private func state(_ stop: WatchSnapshot.Stop, next: WatchSnapshot.Stop?, nowMinutes: Int) -> TimelineRow.State {
|
||||
guard let start = stop.minutes else { return .ahead }
|
||||
let end = stop.endMinutes ?? next?.minutes ?? start + 60
|
||||
if nowMinutes >= end { return .past }
|
||||
if nowMinutes >= start { return .now }
|
||||
return .ahead
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineRow: View {
|
||||
enum State { case past, now, ahead }
|
||||
let stop: WatchSnapshot.Stop
|
||||
let hour12: Bool
|
||||
let state: State
|
||||
let isLast: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text(stop.minutes.map { WatchFormat.time(minutes: $0, hour12: hour12) } ?? "")
|
||||
.font(.system(.caption2, design: .rounded).weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(state == .now ? Trek.confirmed : WatchLook.muted)
|
||||
.frame(width: (hour12 ? 50 : 36) - (WatchScreen.compact ? 4 : 0), alignment: .trailing)
|
||||
.padding(.top, 5)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
VStack(spacing: 0) {
|
||||
StopGlyph(stop: stop, size: WatchScreen.compact ? 18 : 22, dimmed: state == .past)
|
||||
.overlay(Circle().stroke(Trek.confirmed, lineWidth: state == .now ? 2 : 0).padding(-3))
|
||||
if !isLast {
|
||||
Rectangle()
|
||||
.fill(state == .ahead ? Color.white.opacity(0.18) : WatchLook.stopColor(stop).opacity(0.7))
|
||||
.frame(width: 2)
|
||||
.frame(minHeight: 14, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(stop.name).userText(stop.name)
|
||||
.font(.system(.footnote, design: .rounded).weight(state == .now ? .bold : .semibold))
|
||||
.foregroundStyle(state == .past ? WatchLook.muted : WatchLook.ink)
|
||||
.lineLimit(2)
|
||||
// The street, whole: the first part of the address, on up to two lines.
|
||||
if let detail = stop.booking.map(WatchLook.kindName) ?? stop.address?.split(separator: ",").first.map(String.init) {
|
||||
Text(detail).userText(detail)
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(WatchLook.faint)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(.top, 3)
|
||||
.padding(.bottom, 10)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - A stop
|
||||
|
||||
/// A stop of the plan: where it is on the map, when, the note, and Directions into Apple Maps.
|
||||
struct StopDetailView: View {
|
||||
let snapshot: WatchSnapshot
|
||||
let day: Int
|
||||
let stop: Int
|
||||
|
||||
var body: some View {
|
||||
let item = snapshot.days.indices.contains(day) && snapshot.days[day].stops.indices.contains(stop) ? snapshot.days[day].stops[stop] : nil
|
||||
ScrollView {
|
||||
if let item {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let lat = item.lat, let lng = item.lng {
|
||||
MiniMap(name: item.name, lat: lat, lng: lng, color: WatchLook.stopColor(item))
|
||||
}
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
StopGlyph(stop: item, size: 26)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(item.name).userText(item.name)
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.lineLimit(3)
|
||||
if let start = item.minutes {
|
||||
let from = WatchFormat.time(minutes: start, hour12: snapshot.hour12)
|
||||
Text(item.endMinutes.map { "\(from) – \(WatchFormat.time(minutes: $0, hour12: snapshot.hour12))" } ?? from)
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let address = item.address {
|
||||
Text(address).userText(address)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
if let note = item.note {
|
||||
Text(note).userText(note)
|
||||
.font(.footnote)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.watchCard(cornerRadius: 12)
|
||||
}
|
||||
if let lat = item.lat, let lng = item.lng {
|
||||
DirectionsButton(name: item.name, lat: lat, lng: lng)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(item?.booking.map(WatchLook.kindName) ?? L("place.title"))
|
||||
.containerBackground(WatchLook.tint(item.map(WatchLook.stopColor) ?? Trek.info), for: .navigation)
|
||||
}
|
||||
}
|
||||
|
||||
/// A small map of one spot, with its pin.
|
||||
struct MiniMap: View {
|
||||
let name: String
|
||||
let lat: Double
|
||||
let lng: Double
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
||||
// From above, without street names or points of interest, so the stop's
|
||||
// own pin is what stands out; Apple's attribution stays in its corner.
|
||||
Map(initialPosition: .region(MKCoordinateRegion(center: coordinate, latitudinalMeters: 1100, longitudinalMeters: 1100)), interactionModes: []) {
|
||||
Annotation(name, coordinate: coordinate, anchor: .center) {
|
||||
Circle()
|
||||
.fill(color.gradient)
|
||||
.frame(width: 22, height: 22)
|
||||
.overlay(Circle().stroke(.white, lineWidth: 2.5))
|
||||
.shadow(color: .black.opacity(0.4), radius: 3, y: 1)
|
||||
}
|
||||
.annotationTitles(.hidden)
|
||||
}
|
||||
.mapStyle(.standard(elevation: .flat, emphasis: .muted, pointsOfInterest: .excludingAll, showsTraffic: false))
|
||||
.frame(height: 118)
|
||||
// A soft fade over the top edge only, where labels would be cut; the
|
||||
// attribution sits at the bottom and stays as MapKit draws it.
|
||||
.overlay(alignment: .top) {
|
||||
LinearGradient(colors: [.black.opacity(0.55), .clear], startPoint: .top, endPoint: .bottom)
|
||||
.frame(height: 26)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens Apple Maps with directions to a spot.
|
||||
struct DirectionsButton: View {
|
||||
let name: String
|
||||
let lat: Double
|
||||
let lng: Double
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
let item = MKMapItem(placemark: MKPlacemark(coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lng)))
|
||||
item.name = name
|
||||
_ = item.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault])
|
||||
} label: {
|
||||
Label(L("place.directions"), systemImage: "arrow.triangle.turn.up.right.diamond.fill")
|
||||
.font(.system(.body, design: .rounded).weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.tint(Trek.info)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bookings
|
||||
|
||||
struct BookingDetailView: View {
|
||||
let snapshot: WatchSnapshot
|
||||
let id: Int
|
||||
|
||||
var body: some View {
|
||||
let booking = snapshot.bookings.first { $0.id == id }
|
||||
ScrollView {
|
||||
if let booking {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
BookingSummary(booking: booking, hour12: snapshot.hour12)
|
||||
if let from = booking.from, let to = booking.to {
|
||||
LegRow(label: L("booking.departs"), point: from, date: booking.date, time: booking.time, hour12: snapshot.hour12)
|
||||
LegRow(label: L("booking.arrives"), point: to, date: booking.endDate, time: booking.endTime, hour12: snapshot.hour12)
|
||||
} else if booking.kind == "hotel", let checkOut = TrekDate.minutes(booking.checkOut ?? booking.endTime) {
|
||||
FactTile(label: L("booking.checkOut"), value: [TrekDate.day(booking.endDate).map { WatchFormat.day($0) }, WatchFormat.time(minutes: checkOut, hour12: snapshot.hour12)].compactMap { $0 }.joined(separator: " "))
|
||||
}
|
||||
if let location = booking.location, booking.route == nil {
|
||||
Text(location).userText(location)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
if let target = booking.destination {
|
||||
DirectionsButton(name: target.name, lat: target.lat, lng: target.lng)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(booking.map { WatchLook.kindName($0.kind) } ?? "")
|
||||
.containerBackground(WatchLook.tint(Trek.booking(booking?.kind ?? "other").color), for: .navigation)
|
||||
}
|
||||
}
|
||||
|
||||
/// One end of a ride: the place, its code, the day and time.
|
||||
struct LegRow: View {
|
||||
let label: String
|
||||
let point: WatchSnapshot.Point
|
||||
let date: String?
|
||||
let time: String?
|
||||
let hour12: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(label).watchEyebrow(WatchLook.faint)
|
||||
Text(point.name).userText(point.name)
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 1) {
|
||||
if let minutes = TrekDate.minutes(time) {
|
||||
Text(WatchFormat.time(minutes: minutes, hour12: hour12))
|
||||
.font(.system(.footnote, design: .rounded).weight(.bold).monospacedDigit())
|
||||
}
|
||||
if let day = TrekDate.day(date) {
|
||||
Text(WatchFormat.day(day))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.watchCard(cornerRadius: 12)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
struct BookingsListView: View {
|
||||
let snapshot: WatchSnapshot
|
||||
@Environment(\.trekNow) private var now
|
||||
|
||||
var body: some View {
|
||||
let bookings = snapshot.bookingsAhead(at: now)
|
||||
List {
|
||||
if bookings.isEmpty {
|
||||
Text(L("booking.none")).foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
ForEach(bookings) { booking in
|
||||
NavigationLink(value: Route.booking(booking.id)) {
|
||||
HStack(spacing: 8) {
|
||||
KindBadge(kind: booking.kind, size: 24)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(booking.route ?? booking.title).userText((booking.route ?? booking.title))
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(when(booking))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L("booking.all"))
|
||||
.containerBackground(WatchLook.tripGradient(snapshot.trip), for: .navigation)
|
||||
}
|
||||
|
||||
private func when(_ booking: WatchSnapshot.Booking) -> String {
|
||||
guard let day = TrekDate.day(booking.date) else { return WatchLook.kindName(booking.kind) }
|
||||
let days = TrekDate.daysBetween(now, day)
|
||||
let label = days == 0 ? L("time.today") : days == 1 ? L("time.tomorrow") : WatchFormat.day(day)
|
||||
guard let minutes = TrekDate.minutes(booking.time) else { return label }
|
||||
return "\(label) \(WatchFormat.time(minutes: minutes, hour12: snapshot.hour12))"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The lists
|
||||
|
||||
/// The packing list: what is still to pack, by category, ticked off with a tap.
|
||||
struct PackingListView: View {
|
||||
private let model = WatchModel.shared
|
||||
|
||||
var body: some View {
|
||||
let packing = model.display?.packing
|
||||
let items = packing?.items ?? []
|
||||
ChecklistView(
|
||||
title: L("packing.title"),
|
||||
color: WatchLook.packing,
|
||||
open: items.filter { !$0.done },
|
||||
done: items.filter(\.done),
|
||||
progress: packing.map { ($0.packed, $0.total) },
|
||||
doneTitle: L("packing.packedSection"),
|
||||
allDone: L("packing.allPacked"),
|
||||
grouped: true
|
||||
) { model.toggle(.packing, item: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
struct TodoListView: View {
|
||||
private let model = WatchModel.shared
|
||||
|
||||
var body: some View {
|
||||
let todos = model.display?.todos
|
||||
let items = todos?.items ?? []
|
||||
ChecklistView(
|
||||
title: L("todos.title"),
|
||||
color: WatchLook.todo,
|
||||
open: items.filter { !$0.done },
|
||||
done: items.filter(\.done),
|
||||
progress: todos.map { ($0.total - $0.open, $0.total) },
|
||||
doneTitle: L("todos.doneSection"),
|
||||
allDone: L("todos.allDone"),
|
||||
grouped: false
|
||||
) { model.toggle(.todo, item: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A list to tick off: the open items (grouped by category when asked), then the done ones, dimmed.
|
||||
struct ChecklistView: View {
|
||||
let title: String
|
||||
let color: Color
|
||||
let open: [WatchSnapshot.Item]
|
||||
let done: [WatchSnapshot.Item]
|
||||
let progress: (done: Int, total: Int)?
|
||||
let doneTitle: String
|
||||
let allDone: String
|
||||
let grouped: Bool
|
||||
let toggle: (WatchSnapshot.Item) -> Void
|
||||
@State private var ticks = 0
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// The progress sits in the first section's header, so the list starts right under it.
|
||||
if open.isEmpty {
|
||||
Section {
|
||||
Label(allDone, systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(color)
|
||||
.font(.system(.body, design: .rounded).weight(.semibold))
|
||||
} header: {
|
||||
header(nil)
|
||||
}
|
||||
} else if grouped {
|
||||
ForEach(Array(groups.enumerated()), id: \.element.name) { index, group in
|
||||
Section {
|
||||
ForEach(group.items) { row($0) }
|
||||
} header: {
|
||||
if index == 0 { header(group.name) } else { Text(group.name).watchEyebrow() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ForEach(open) { row($0) }
|
||||
} header: {
|
||||
header(nil)
|
||||
}
|
||||
}
|
||||
if !done.isEmpty {
|
||||
Section {
|
||||
ForEach(done) { row($0) }
|
||||
} header: {
|
||||
Text(doneTitle).watchEyebrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listSectionSpacing(.compact)
|
||||
.navigationTitle(title)
|
||||
.sensoryFeedback(.success, trigger: ticks)
|
||||
.containerBackground(WatchLook.tint(color), for: .navigation)
|
||||
}
|
||||
|
||||
/// The bar with its count, and under it the first group's name when there is one.
|
||||
@ViewBuilder
|
||||
private func header(_ group: String?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let progress {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView(value: Double(progress.done), total: Double(max(progress.total, 1)))
|
||||
.tint(color)
|
||||
Text("\(progress.done)/\(progress.total)")
|
||||
.font(.system(.footnote, design: .rounded).weight(.bold).monospacedDigit())
|
||||
.foregroundStyle(color)
|
||||
.fixedSize()
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
if let group { Text(group).watchEyebrow() }
|
||||
}
|
||||
.textCase(nil)
|
||||
}
|
||||
|
||||
private var groups: [(name: String, items: [WatchSnapshot.Item])] {
|
||||
var order: [String] = []
|
||||
var byName: [String: [WatchSnapshot.Item]] = [:]
|
||||
for item in open {
|
||||
let name = item.group ?? L("packing.other")
|
||||
if byName[name] == nil { order.append(name) }
|
||||
byName[name, default: []].append(item)
|
||||
}
|
||||
return order.map { ($0, byName[$0] ?? []) }
|
||||
}
|
||||
|
||||
private func row(_ item: WatchSnapshot.Item) -> some View {
|
||||
Button {
|
||||
ticks += 1
|
||||
withAnimation(.snappy) { toggle(item) }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: item.done ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(item.done ? color : WatchLook.faint)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(item.name).userText(item.name)
|
||||
.font(.system(.body, design: .rounded).weight(.medium))
|
||||
.strikethrough(item.done, color: WatchLook.faint)
|
||||
.foregroundStyle(item.done ? WatchLook.muted : WatchLook.ink)
|
||||
.lineLimit(2)
|
||||
if !item.done, let due = TrekDate.day(item.due) {
|
||||
HStack(spacing: 3) {
|
||||
if let priority = item.priority, let tint = Trek.priority(priority) {
|
||||
Circle().fill(tint).frame(width: 5, height: 5)
|
||||
}
|
||||
Text(WatchFormat.day(due))
|
||||
}
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
} else if !item.done, let priority = item.priority, let tint = Trek.priority(priority) {
|
||||
Circle().fill(tint).frame(width: 5, height: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityValue(L(item.done ? "a11y.done" : "a11y.open"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Screenshot mode only (-TrekDemo faces): every complication in every family
|
||||
/// it ships in, at the sizes a watch face gives them on this watch, to review
|
||||
/// them without setting up a face. Circular and corner ones three to a row,
|
||||
/// rectangular and inline ones across the screen's width.
|
||||
struct FacesView: View {
|
||||
let snapshot: WatchSnapshot?
|
||||
let now: Date
|
||||
/// A circular complication's diameter, from the screen: 42 pt on 40 mm, 50 on 49 mm.
|
||||
@State private var dial: CGFloat = 44
|
||||
|
||||
var body: some View {
|
||||
let state = ComplicationState(snapshot, now: now)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
round { NextStopComplication(state: state, family: .circular, now: now) }
|
||||
round { CountdownComplication(state: state, family: .circular, now: now) }
|
||||
round { TripDayComplication(state: state, family: .circular) }
|
||||
}
|
||||
Divider().opacity(0.3)
|
||||
HStack(spacing: 8) {
|
||||
round { NextStopComplication(state: state, family: .corner, now: now) }
|
||||
round { CountdownComplication(state: state, family: .corner, now: now) }
|
||||
round { TripDayComplication(state: state, family: .corner) }
|
||||
}
|
||||
Divider().opacity(0.3)
|
||||
wide(56) { NextStopComplication(state: state, family: .rectangular, now: now) }
|
||||
wide(56) { CountdownComplication(state: state, family: .rectangular, now: now) }
|
||||
wide(56) { TripDayComplication(state: state, family: .rectangular) }
|
||||
Divider().opacity(0.3)
|
||||
wide(18) { NextStopComplication(state: state, family: .inline, now: now) }
|
||||
wide(18) { CountdownComplication(state: state, family: .inline, now: now) }
|
||||
wide(18) { TripDayComplication(state: state, family: .inline) }
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.tint(Color(hex: 0xFF9F0A))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in
|
||||
dial = min(50, max(38, (width - 16) / 3))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func round<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
||||
content()
|
||||
.frame(width: dial, height: dial)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private func wide<Content: View>(_ height: CGFloat, @ViewBuilder _ content: () -> Content) -> some View {
|
||||
content()
|
||||
.frame(maxWidth: .infinity, minHeight: height, maxHeight: height, alignment: .leading)
|
||||
.padding(.horizontal, 6)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.06)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>TREK</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>com.liketrek.trek</string>
|
||||
<key>WKRunsIndependentlyOfCompanionApp</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,728 @@
|
||||
import SwiftUI
|
||||
import WatchKit
|
||||
|
||||
/// The 40 and 41 mm cases, where a line less decides whether a screen ends clean.
|
||||
enum WatchScreen {
|
||||
static let compact = WKInterfaceDevice.current().screenBounds.width < 180
|
||||
}
|
||||
|
||||
// MARK: - Today
|
||||
|
||||
/// The trip day at a glance: what is on now or next, how long until it, how
|
||||
/// far the day has come and what follows. A tap opens the stop.
|
||||
struct TodayPage: View {
|
||||
let snapshot: WatchSnapshot
|
||||
@Environment(\.trekNow) private var now
|
||||
@Environment(\.isLuminanceReduced) private var reduced
|
||||
|
||||
var body: some View {
|
||||
let todayIndex = snapshot.todayIndex(at: now)
|
||||
let day = todayIndex.map { snapshot.days[$0] }
|
||||
let stops = day?.stops ?? []
|
||||
let nowMinutes = TrekDate.minutesOfDay(now)
|
||||
let currentIndex = stops.firstIndex { stop in
|
||||
guard let start = stop.minutes, let end = stop.endMinutes else { return false }
|
||||
return start <= nowMinutes && nowMinutes < end
|
||||
}
|
||||
let nextIndex = stops.firstIndex { ($0.minutes ?? -1) >= nowMinutes }
|
||||
// A day planned without times still has its places: the first of them,
|
||||
// and "day done" only once every stop has a time and it is behind.
|
||||
let focus = currentIndex ?? nextIndex ?? stops.firstIndex { $0.minutes == nil }
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
header
|
||||
if let focus, let todayIndex {
|
||||
NavigationLink(value: Route.stop(day: todayIndex, stop: focus)) {
|
||||
FocusCard(
|
||||
stop: stops[focus],
|
||||
isNow: focus == currentIndex,
|
||||
hour12: snapshot.hour12,
|
||||
then: reduced ? nil : stops.dropFirst(focus + 1).first
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
DayProgressBar(stops: stops, nowMinutes: nowMinutes)
|
||||
.padding(.horizontal, 2)
|
||||
} else if stops.isEmpty {
|
||||
DoneCard(title: L("today.free"), detail: L("today.freeBody"), symbol: "sun.max.fill")
|
||||
} else {
|
||||
DoneCard(title: L("today.done"), detail: L("today.doneBody"), symbol: "checkmark.circle.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentMargins(.horizontal, 5, for: .scrollContent)
|
||||
.containerBackground(WatchLook.tripGradient(snapshot.trip), for: .tabView)
|
||||
.toolbar {
|
||||
if let weather = snapshot.weather {
|
||||
ToolbarItem(placement: .topBarTrailing) { WeatherBadge(weather: weather) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The day and the trip; a tap opens the whole day's timeline.
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
if case .running(let index, let total) = snapshot.phase(at: now), let todayIndex = snapshot.todayIndex(at: now) {
|
||||
NavigationLink(value: Route.day(todayIndex)) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 3) {
|
||||
Text(WatchFormat.dayOf(index, total)).watchEyebrow(.white.opacity(0.85))
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.system(size: 9, weight: .heavy))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
Text(snapshot.trip?.title ?? "").userText((snapshot.trip?.title ?? ""))
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityHint(L("today.plan"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stop that is on now or comes next, large.
|
||||
struct FocusCard: View {
|
||||
let stop: WatchSnapshot.Stop
|
||||
let isNow: Bool
|
||||
let hour12: Bool
|
||||
/// The stop after it, in a line at the foot.
|
||||
var then: WatchSnapshot.Stop?
|
||||
@Environment(\.trekNow) private var now
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
StopGlyph(stop: stop, size: 24)
|
||||
Text(L(isNow ? "today.now" : "today.next"))
|
||||
.watchEyebrow(isNow ? Trek.confirmed : WatchLook.muted)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Text(stop.name).userText(stop.name)
|
||||
.font(.system(WatchScreen.compact ? .headline : .title3, design: .rounded).weight(.bold))
|
||||
.foregroundStyle(WatchLook.ink)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.75)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
if let times = timeRange {
|
||||
Text(times)
|
||||
.font(.system(.footnote, design: .rounded).weight(.medium).monospacedDigit())
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
Spacer(minLength: 2)
|
||||
if let target = countdownTarget, target > now {
|
||||
Text(WatchFormat.relative(target, now: now))
|
||||
.font(.system(.footnote, design: .rounded).weight(.bold))
|
||||
.foregroundStyle(isNow ? WatchLook.muted : Trek.confirmed)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
}
|
||||
if let then {
|
||||
Divider().overlay(Color.white.opacity(0.12)).padding(.vertical, 2)
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "arrow.turn.down.right")
|
||||
.foregroundStyle(WatchLook.faint)
|
||||
if let minutes = then.minutes {
|
||||
Text(WatchFormat.time(minutes: minutes, hour12: hour12)).monospacedDigit()
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
Text(then.name).userText(then.name)
|
||||
.foregroundStyle(WatchLook.ink.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.font(.system(.caption, design: .rounded).weight(.semibold))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(L("today.later")): \(then.name)")
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.watchCard()
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
/// Next: until it starts. Now: until it ends.
|
||||
private var countdownTarget: Date? {
|
||||
guard !isNow, let minutes = stop.minutes else { return nil }
|
||||
let start = TrekDate.at(minutes, on: now)
|
||||
return start.timeIntervalSince(now) >= 60 ? start : nil
|
||||
}
|
||||
|
||||
private var timeRange: String? {
|
||||
guard let start = stop.minutes else { return nil }
|
||||
let from = WatchFormat.time(minutes: start, hour12: hour12)
|
||||
guard let end = stop.endMinutes else { return from }
|
||||
return "\(from) – \(WatchFormat.time(minutes: end, hour12: hour12))"
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's timed stops as segments, those behind filled.
|
||||
struct DayProgressBar: View {
|
||||
let stops: [WatchSnapshot.Stop]
|
||||
let nowMinutes: Int
|
||||
|
||||
var body: some View {
|
||||
let timed = stops.filter { $0.minutes != nil }
|
||||
if timed.count > 1 {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(Array(timed.enumerated()), id: \.offset) { _, stop in
|
||||
Capsule()
|
||||
.fill((stop.minutes ?? 0) < nowMinutes ? WatchLook.stopColor(stop) : Color.white.opacity(0.18))
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CompactStopRow: View {
|
||||
let stop: WatchSnapshot.Stop
|
||||
let hour12: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
StopGlyph(stop: stop, size: 20)
|
||||
Text(stop.name).userText(stop.name)
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(WatchLook.ink)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if let minutes = stop.minutes {
|
||||
Text(WatchFormat.time(minutes: minutes, hour12: hour12))
|
||||
.font(.system(.caption2, design: .rounded).weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
struct DoneCard: View {
|
||||
let title: String
|
||||
let detail: String
|
||||
let symbol: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Image(systemName: symbol)
|
||||
.font(.title2)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.foregroundStyle(Trek.confirmed)
|
||||
Text(title).font(.system(.headline, design: .rounded)).foregroundStyle(WatchLook.ink)
|
||||
Text(detail).userText(detail).font(.footnote).foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.watchCard()
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
struct WeatherBadge: View {
|
||||
let weather: WatchSnapshot.Weather
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: Trek.weatherSymbol(weather.main)).symbolRenderingMode(.multicolor)
|
||||
Text("\(weather.temp)°").monospacedDigit()
|
||||
}
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||
.accessibilityLabel("\(weather.label), \(weather.temp)°")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Before the trip
|
||||
|
||||
/// The days to the trip, big, over its cover or its own colours.
|
||||
struct CountdownPage: View {
|
||||
let snapshot: WatchSnapshot
|
||||
let days: Int
|
||||
@ScaledMetric(relativeTo: .largeTitle) private var numberSize: CGFloat = 54
|
||||
|
||||
var body: some View {
|
||||
let trip = snapshot.trip
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(L("countdown.title")).watchEyebrow(.white.opacity(0.85))
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 0)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(days)")
|
||||
.font(.system(size: numberSize, weight: .heavy, design: .rounded).monospacedDigit())
|
||||
.minimumScaleFactor(0.5)
|
||||
Text(WatchFormat.daysUnit(days))
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
Text(trip?.title ?? "").userText((trip?.title ?? ""))
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
HStack(spacing: 4) {
|
||||
if let range = trip.flatMap({ WatchFormat.range($0.start, $0.end) }) {
|
||||
Text(range)
|
||||
}
|
||||
if let place = trip?.place?.split(separator: ",").first {
|
||||
Text("·")
|
||||
Text(place)
|
||||
}
|
||||
}
|
||||
.font(.system(.caption2, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
.lineLimit(1)
|
||||
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: .white)
|
||||
.frame(height: 20)
|
||||
.padding(.top, 6)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.accessibilityElement(children: .combine)
|
||||
.padding(.horizontal, 5)
|
||||
.containerBackground(for: .tabView) {
|
||||
if trip?.cover != nil {
|
||||
TripBackdrop(trip: widgetTrip(trip), shade: 0.7)
|
||||
} else {
|
||||
let (first, second) = WatchLook.tripColors(trip)
|
||||
ZStack {
|
||||
LinearGradient(colors: [first, second, .black], startPoint: .topLeading, endPoint: .bottom)
|
||||
ContourLines(seed: UInt64(trip?.id ?? 1) % 997, rings: 11)
|
||||
.stroke(Color.white.opacity(0.12), lineWidth: 0.9)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
if let weather = snapshot.weather {
|
||||
ToolbarItem(placement: .topBarTrailing) { WeatherBadge(weather: weather) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func widgetTrip(_ trip: WatchSnapshot.Trip?) -> WidgetSnapshot.Trip? {
|
||||
trip.map { WidgetSnapshot.Trip(title: $0.title, start: $0.start, end: $0.end, path: "", place: $0.place, cover: $0.cover) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The next booking
|
||||
|
||||
/// The next flight, train, hotel or table: where to, when, the countdown, and
|
||||
/// what is asked for at the gate or the desk.
|
||||
struct BookingPage: View {
|
||||
let booking: WatchSnapshot.Booking
|
||||
let hour12: Bool
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
NavigationLink(value: Route.booking(booking.id)) {
|
||||
BookingSummary(booking: booking, hour12: hour12, compactFacts: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.contentMargins(.horizontal, 5, for: .scrollContent)
|
||||
.containerBackground(WatchLook.tint(Trek.booking(booking.kind).color), for: .tabView)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/// The head of a booking, shared by its page and its detail.
|
||||
struct BookingSummary: View {
|
||||
let booking: WatchSnapshot.Booking
|
||||
let hour12: Bool
|
||||
var showFacts = true
|
||||
/// The page's single row of facts rather than all of them.
|
||||
var compactFacts = false
|
||||
@Environment(\.trekNow) private var now
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: WatchScreen.compact ? 4 : 6) {
|
||||
// The kind and the status in one line ("FLIGHT · Confirmed"), the status under it when that is too long.
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 5) {
|
||||
KindBadge(kind: booking.kind, size: 20)
|
||||
kindLabel.fixedSize()
|
||||
status.fixedSize()
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
KindBadge(kind: booking.kind, size: 22)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
kindLabel
|
||||
status
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
if let route = booking.route {
|
||||
Text(route)
|
||||
.font(.system(.title2, design: .rounded).weight(.heavy))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.55)
|
||||
Text(booking.carrier ?? booking.title)
|
||||
.font(.system(WatchScreen.compact ? .caption : .footnote, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text(booking.title).userText(booking.title)
|
||||
.font(.system(.title3, design: .rounded).weight(.bold))
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
timing
|
||||
if showFacts { facts }
|
||||
}
|
||||
.foregroundStyle(WatchLook.ink)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
/// The time it starts (or, for a stay under way, ends), its day, and how long until then, in one line.
|
||||
@ViewBuilder
|
||||
private var timing: some View {
|
||||
let running = booking.kind == "hotel" && (booking.start ?? .distantFuture) < now
|
||||
let moment = running ? booking.end : booking.start
|
||||
let clock = running ? booking.checkOut ?? booking.endTime : booking.time
|
||||
if let moment {
|
||||
// The day goes first when the line is too short for all three.
|
||||
let label = running ? L("booking.checkOut") : dayLabel(moment)
|
||||
// A booking with no time lasts the day: its day, but no countdown to midnight.
|
||||
let counts = running ? clock != nil : !booking.isAllDay
|
||||
ViewThatFits(in: .horizontal) {
|
||||
timeLine(clock: clock, label: label, moment: moment, pill: counts)
|
||||
timeLine(clock: clock, label: nil, moment: moment, pill: counts)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
timeLine(clock: clock, label: label, moment: moment, pill: false)
|
||||
if counts { countdownPill(moment) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func timeLine(clock: String?, label: String?, moment: Date, pill: Bool) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 5) {
|
||||
if let clock, let minutes = TrekDate.minutes(clock) {
|
||||
Text(WatchFormat.time(minutes: minutes, hour12: hour12))
|
||||
.font(.system(.title3, design: .rounded).weight(.bold).monospacedDigit())
|
||||
.fixedSize()
|
||||
}
|
||||
if let label {
|
||||
Text(label)
|
||||
.font(.system(.caption2, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.fixedSize()
|
||||
}
|
||||
if pill {
|
||||
Spacer(minLength: 4)
|
||||
countdownPill(moment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func countdownPill(_ moment: Date) -> some View {
|
||||
if moment > now {
|
||||
Text(WatchFormat.relative(moment, now: now))
|
||||
.font(.system(.caption2, design: .rounded).weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.fixedSize()
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(Trek.booking(booking.kind).color.opacity(0.85)))
|
||||
}
|
||||
}
|
||||
|
||||
private var kindLabel: some View {
|
||||
Text(WatchLook.kindName(booking.kind))
|
||||
.font(.system(.caption2, design: .rounded).weight(.bold))
|
||||
.textCase(.uppercase)
|
||||
.tracking(0.4)
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
private var status: some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(booking.confirmed ? Trek.confirmed : Trek.pending).frame(width: 5, height: 5)
|
||||
Text(L(booking.confirmed ? "booking.confirmed" : "booking.pending"))
|
||||
.font(.system(.caption2, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private var factTiles: [(label: String, value: String)] {
|
||||
var tiles: [(label: String, value: String)] = []
|
||||
if let seat = booking.seat { tiles.append((L("booking.seat"), seat)) }
|
||||
if let platform = booking.platform { tiles.append((L("booking.platform"), platform)) }
|
||||
if booking.kind == "hotel", let checkIn = TrekDate.minutes(booking.checkIn) {
|
||||
tiles.append((L("booking.checkIn"), WatchFormat.time(minutes: checkIn, hour12: hour12)))
|
||||
}
|
||||
return tiles
|
||||
}
|
||||
|
||||
private func dayLabel(_ date: Date) -> String {
|
||||
let days = TrekDate.daysBetween(now, date)
|
||||
if days == 0 { return L("time.today") }
|
||||
if days == 1 { return L("time.tomorrow") }
|
||||
return WatchFormat.day(date)
|
||||
}
|
||||
|
||||
/// On the page one row, the reference and the seat; in the detail all of them.
|
||||
@ViewBuilder
|
||||
private var facts: some View {
|
||||
let tiles = factTiles
|
||||
if compactFacts {
|
||||
let row = [booking.code.map { (L("booking.ref"), $0, true) }, tiles.first.map { ($0.label, $0.value, false) }].compactMap { $0 }
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(row, id: \.0) { FactTile(label: $0.0, value: $0.1, mono: $0.2, fits: true) }
|
||||
}
|
||||
VStack(spacing: 6) {
|
||||
ForEach(row, id: \.0) { FactTile(label: $0.0, value: $0.1, mono: $0.2) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
factsInFull(tiles)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func factsInFull(_ tiles: [(label: String, value: String)]) -> some View {
|
||||
if !tiles.isEmpty {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(tiles.prefix(2), id: \.label) { tile in
|
||||
FactTile(label: tile.label, value: tile.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let code = booking.code {
|
||||
FactTile(label: L("booking.ref"), value: code, mono: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The trip
|
||||
|
||||
/// The trip as a whole: how far along, and the ways into its days, bookings and lists.
|
||||
struct TripPage: View {
|
||||
let snapshot: WatchSnapshot
|
||||
@Environment(\.trekNow) private var now
|
||||
|
||||
var body: some View {
|
||||
let trip = snapshot.trip
|
||||
let total = snapshot.tripLength
|
||||
let index: Int = {
|
||||
if case .running(let day, _) = snapshot.phase(at: now) { return day }
|
||||
return 0
|
||||
}()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
ProgressRing(value: Double(index) / Double(max(total, 1)), lineWidth: 5, colors: [.white.opacity(0.7), .white], trackColor: .white.opacity(0.2)) {
|
||||
VStack(spacing: -2) {
|
||||
Text(index > 0 ? "\(index)" : "\(total)")
|
||||
.font(.system(.headline, design: .rounded).weight(.heavy).monospacedDigit())
|
||||
Text(index > 0 ? "/\(total)" : WatchFormat.daysUnit(total))
|
||||
.font(.system(size: 9, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
.frame(width: 46, height: 46)
|
||||
.accessibilityLabel(index > 0 ? WatchFormat.dayOf(index, total) : "\(total) \(WatchFormat.daysUnit(total))")
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(trip?.title ?? "").userText((trip?.title ?? ""))
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
if let range = trip.flatMap({ WatchFormat.range($0.start, $0.end) }) {
|
||||
Text(range)
|
||||
.font(.system(.caption2, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
NavigationLink(value: Route.days) {
|
||||
MenuRow(symbol: "calendar", color: Trek.info, title: L("trip.days"), value: "\(total)")
|
||||
}
|
||||
if !snapshot.bookings.isEmpty {
|
||||
NavigationLink(value: Route.bookings) {
|
||||
MenuRow(symbol: "ticket.fill", color: Trek.booking("event").color, title: L("booking.all"), value: "\(snapshot.bookingsAhead(at: now).count)")
|
||||
}
|
||||
}
|
||||
if let packing = snapshot.packing {
|
||||
NavigationLink(value: Route.packing) {
|
||||
MenuRow(symbol: "suitcase.rolling.fill", color: WatchLook.packing, title: L("packing.title"), value: "\(packing.packed)/\(packing.total)")
|
||||
}
|
||||
}
|
||||
if let todos = snapshot.todos {
|
||||
NavigationLink(value: Route.todos) {
|
||||
MenuRow(symbol: "checklist", color: WatchLook.todo, title: L("todos.title"), value: "\(todos.open)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(WatchLook.ink)
|
||||
}
|
||||
.contentMargins(.horizontal, 5, for: .scrollContent)
|
||||
.containerBackground(WatchLook.tripGradient(trip), for: .tabView)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/// A way into a part of the trip: its symbol, its name, a count.
|
||||
struct MenuRow: View {
|
||||
let symbol: String
|
||||
let color: Color
|
||||
let title: String
|
||||
let value: String?
|
||||
|
||||
var body: some View {
|
||||
let compact = WatchScreen.compact
|
||||
HStack(spacing: compact ? 6 : 8) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: compact ? 11 : 13, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: compact ? 22 : 26, height: compact ? 22 : 26)
|
||||
.background(Circle().fill(color.gradient))
|
||||
Text(title)
|
||||
.font(.system(compact ? .callout : .body, design: .rounded).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.65)
|
||||
.allowsTightening(true)
|
||||
Spacer(minLength: 2)
|
||||
if let value {
|
||||
Text(value)
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, WatchScreen.compact ? 8 : 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.watchCard(cornerRadius: 16)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The lists
|
||||
|
||||
/// The packing list and the to-dos as two rings, each opening its list.
|
||||
struct ListsPage: View {
|
||||
let snapshot: WatchSnapshot
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
if let packing = snapshot.packing {
|
||||
NavigationLink(value: Route.packing) {
|
||||
ListRing(value: Double(packing.packed) / Double(max(packing.total, 1)), color: WatchLook.packing,
|
||||
number: "\(packing.packed)", of: "/\(packing.total)", title: L("packing.title"), detail: L("packing.packed"))
|
||||
}
|
||||
}
|
||||
if let todos = snapshot.todos {
|
||||
NavigationLink(value: Route.todos) {
|
||||
ListRing(value: Double(todos.total - todos.open) / Double(max(todos.total, 1)), color: WatchLook.todo,
|
||||
number: "\(todos.open)", of: nil, title: L("todos.title"), detail: L("todos.open"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if let next = snapshot.packing?.items.first(where: { !$0.done }) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "circle").foregroundStyle(WatchLook.packing)
|
||||
Text(next.name).userText(next.name).lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.system(.footnote, design: .rounded).weight(.medium))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.watchCard(cornerRadius: 12)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.horizontal, 5)
|
||||
.containerBackground(WatchLook.tint(WatchLook.packing), for: .tabView)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct ListRing: View {
|
||||
let value: Double
|
||||
let color: Color
|
||||
let number: String
|
||||
let of: String?
|
||||
let title: String
|
||||
let detail: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 5) {
|
||||
ProgressRing(value: value, lineWidth: 6, colors: [color.opacity(0.7), color], trackColor: color.opacity(0.22)) {
|
||||
VStack(spacing: -3) {
|
||||
Text(number).font(.system(.title3, design: .rounded).weight(.heavy).monospacedDigit())
|
||||
if let of {
|
||||
Text(of).font(.system(size: 10, weight: .bold, design: .rounded)).foregroundStyle(WatchLook.muted)
|
||||
} else {
|
||||
Text(detail).userText(detail).font(.system(size: 9, weight: .bold, design: .rounded)).foregroundStyle(WatchLook.muted).lineLimit(1).minimumScaleFactor(0.6)
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
}
|
||||
.frame(width: 62, height: 62)
|
||||
Text(title)
|
||||
.font(.system(.caption2, design: .rounded).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.watchCard()
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty
|
||||
|
||||
struct EmptyStateView: View {
|
||||
enum Kind { case notSynced, noTrip }
|
||||
let kind: Kind
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
TrekMark()
|
||||
.fill(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
.padding(14)
|
||||
.background(Circle().fill(Color.white.opacity(0.1)))
|
||||
.accessibilityHidden(true)
|
||||
Text(L(kind == .notSynced ? "empty.sync.title" : "empty.trip.title"))
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(L(kind == .notSynced ? "empty.sync.body" : "empty.trip.body"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.containerBackground(WatchLook.tint(Color(hex: 0x3A3A44)), for: .navigation)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,160 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct TREKWatchApp: App {
|
||||
init() {
|
||||
if DemoMode.screen != nil {
|
||||
DemoMode.start()
|
||||
} else {
|
||||
WatchLink.shared.activate()
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
}
|
||||
// Woken in the background to take a snapshot or complication update from
|
||||
// the phone: the task lasts until it is delivered, stored and drawn.
|
||||
.backgroundTask(.watchConnectivity) {
|
||||
await WatchLink.shared.finishPendingDeliveries()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NowKey: EnvironmentKey {
|
||||
static let defaultValue = Date()
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// The minute the screens show, from the root's TimelineView (or the screenshot clock).
|
||||
var trekNow: Date {
|
||||
get { self[NowKey.self] }
|
||||
set { self[NowKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a tap leads from the pages.
|
||||
enum Route: Hashable {
|
||||
case day(Int)
|
||||
case stop(day: Int, stop: Int)
|
||||
case booking(Int)
|
||||
case bookings
|
||||
case days
|
||||
case packing
|
||||
case todos
|
||||
}
|
||||
|
||||
enum Page: Hashable {
|
||||
case now, booking, trip, lists
|
||||
}
|
||||
|
||||
/// The pages under each other, turned with the Digital Crown: today (or the
|
||||
/// countdown before the trip), the next booking, the trip, the lists. A
|
||||
/// minute timeline keeps every countdown current, Always On included.
|
||||
struct RootView: View {
|
||||
private let model = WatchModel.shared
|
||||
@State private var path: [Route] = []
|
||||
@State private var page: Page = .now
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.everyMinute) { context in
|
||||
let now = model.now(context.date)
|
||||
NavigationStack(path: $path) {
|
||||
content(now)
|
||||
.navigationDestination(for: Route.self) { route in
|
||||
destination(route)
|
||||
}
|
||||
}
|
||||
.environment(\.trekNow, now)
|
||||
}
|
||||
.onAppear {
|
||||
openDemoScreen()
|
||||
DemoMode.signalReady()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active && !model.isDemo { WatchLink.shared.requestRefresh() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(_ now: Date) -> some View {
|
||||
if DemoMode.screen == .faces {
|
||||
FacesView(snapshot: model.display, now: now)
|
||||
} else if let snapshot = model.display {
|
||||
if snapshot.trip == nil || snapshot.phase(at: now) == .past {
|
||||
EmptyStateView(kind: .noTrip)
|
||||
} else {
|
||||
pages(snapshot, now)
|
||||
}
|
||||
} else {
|
||||
EmptyStateView(kind: .notSynced)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func pages(_ snapshot: WatchSnapshot, _ now: Date) -> some View {
|
||||
let booking = snapshot.nextBooking(at: now)
|
||||
TabView(selection: $page) {
|
||||
Group {
|
||||
if case .upcoming(let days) = snapshot.phase(at: now) {
|
||||
CountdownPage(snapshot: snapshot, days: days)
|
||||
} else {
|
||||
TodayPage(snapshot: snapshot)
|
||||
}
|
||||
}
|
||||
.tag(Page.now)
|
||||
if let booking {
|
||||
BookingPage(booking: booking, hour12: snapshot.hour12)
|
||||
.tag(Page.booking)
|
||||
}
|
||||
TripPage(snapshot: snapshot)
|
||||
.tag(Page.trip)
|
||||
if snapshot.packing != nil || snapshot.todos != nil {
|
||||
ListsPage(snapshot: snapshot)
|
||||
.tag(Page.lists)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.verticalPage)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func destination(_ route: Route) -> some View {
|
||||
if let snapshot = model.display {
|
||||
switch route {
|
||||
case .day(let index): DayView(snapshot: snapshot, index: index)
|
||||
case .stop(let day, let stop): StopDetailView(snapshot: snapshot, day: day, stop: stop)
|
||||
case .booking(let id): BookingDetailView(snapshot: snapshot, id: id)
|
||||
case .bookings: BookingsListView(snapshot: snapshot)
|
||||
case .days: DaysListView(snapshot: snapshot)
|
||||
case .packing: PackingListView()
|
||||
case .todos: TodoListView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Screenshot mode: opens the screen the launch arguments name.
|
||||
private func openDemoScreen() {
|
||||
guard let screen = DemoMode.screen, let snapshot = model.display else { return }
|
||||
let now = model.now()
|
||||
let today = snapshot.todayIndex(at: now) ?? 0
|
||||
switch screen {
|
||||
case .booking: page = .booking
|
||||
case .trip: page = .trip
|
||||
case .lists: page = .lists
|
||||
case .day: path = [.day(today)]
|
||||
case .days: path = [.days]
|
||||
case .place:
|
||||
let stops = snapshot.days.indices.contains(today) ? snapshot.days[today].stops : []
|
||||
let next = stops.firstIndex { ($0.minutes ?? 0) >= TrekDate.minutesOfDay(now) } ?? 0
|
||||
path = [.stop(day: today, stop: next)]
|
||||
case .bookings: path = [.bookings]
|
||||
case .bookingDetail:
|
||||
if let booking = snapshot.nextBooking(at: now) { path = [.booking(booking.id)] }
|
||||
case .packing: path = [.packing]
|
||||
case .todos: path = [.todos]
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import WatchConnectivity
|
||||
import WidgetKit
|
||||
|
||||
/// The watch's state: the last snapshot from the phone with the ticks it has
|
||||
/// not confirmed yet laid over it. Everything the screens show comes from here.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class WatchModel {
|
||||
static let shared = WatchModel()
|
||||
|
||||
private(set) var snapshot: WatchSnapshot?
|
||||
private(set) var pending: [PendingAction] = []
|
||||
/// Set in screenshot mode (DemoMode.swift): the clock the screens read, and nothing is sent or stored.
|
||||
private(set) var fixedNow: Date?
|
||||
private(set) var isDemo = false
|
||||
|
||||
/// The snapshot as the screens show it.
|
||||
var display: WatchSnapshot? { snapshot?.applying(pending) }
|
||||
|
||||
private init() {
|
||||
snapshot = WatchStore.loadSnapshot()
|
||||
pending = WatchStore.loadPending()
|
||||
}
|
||||
|
||||
func now(_ date: Date = Date()) -> Date { fixedNow ?? date }
|
||||
|
||||
func startDemo(_ snapshot: WatchSnapshot?, now: Date) {
|
||||
isDemo = true
|
||||
fixedNow = now
|
||||
self.snapshot = snapshot
|
||||
pending = []
|
||||
}
|
||||
|
||||
/// A snapshot from the phone: kept, the confirmed ticks dropped, the complications redrawn.
|
||||
func receive(_ data: Data) {
|
||||
guard !isDemo, let incoming = WatchSnapshot.decode(data) else { return }
|
||||
if let current = snapshot, let a = current.generated, let b = incoming.generated, b < a { return }
|
||||
snapshot = incoming
|
||||
WatchStore.saveSnapshot(data)
|
||||
pending = incoming.stillPending(pending)
|
||||
WatchStore.savePending(pending)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
/// Ticks a packing item or to-do: shown at once, sent to the phone, which
|
||||
/// writes it through the app's own offline-first path.
|
||||
func toggle(_ list: PendingAction.List, item: WatchSnapshot.Item) {
|
||||
guard let tripId = display?.trip?.id else { return }
|
||||
let action = PendingAction(id: UUID().uuidString, tripId: tripId, list: list, itemId: item.id, done: !item.done, at: Date())
|
||||
pending.removeAll { $0.list == list && $0.itemId == item.id }
|
||||
pending.append(action)
|
||||
guard !isDemo else { return }
|
||||
WatchStore.savePending(pending)
|
||||
WatchLink.shared.send(action)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
var isWaitingForPhone: Bool { !pending.isEmpty }
|
||||
|
||||
/// WatchConnectivity handed a tick to the phone: it is not sent again.
|
||||
func markSent(_ id: String) {
|
||||
guard let index = pending.firstIndex(where: { $0.id == id }) else { return }
|
||||
pending[index].sent = true
|
||||
WatchStore.savePending(pending)
|
||||
}
|
||||
|
||||
/// Ticks that never reached the phone's queue: not yet handed over, or failed.
|
||||
func unsent(excluding queued: Set<String>) -> [PendingAction] {
|
||||
pending.filter { $0.sent != true && !queued.contains($0.id) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The watch's end of WatchConnectivity: snapshots arrive as the application
|
||||
/// context (the latest one, kept by the system across launches) and as
|
||||
/// complication user info (sent when the phone has budget for it), ticks
|
||||
/// leave as user info, which the system queues until the phone takes them.
|
||||
final class WatchLink: NSObject, WCSessionDelegate {
|
||||
static let shared = WatchLink()
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
if WCSession.default.delegate == nil { WCSession.default.delegate = self }
|
||||
if WCSession.default.activationState != .activated { WCSession.default.activate() }
|
||||
}
|
||||
|
||||
/// For the system's WatchConnectivity background task: activates the session
|
||||
/// and returns only once what the phone sent has been delivered (and the
|
||||
/// snapshot stored and the complications told), or after twenty seconds.
|
||||
func finishPendingDeliveries() async {
|
||||
activate()
|
||||
for _ in 0..<100 {
|
||||
let session = WCSession.default
|
||||
if session.activationState == .activated && !session.hasContentPending { break }
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
// Let the deliveries already queued on the main actor run before the task ends.
|
||||
await MainActor.run {}
|
||||
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) {
|
||||
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).
|
||||
func requestRefresh() {
|
||||
guard WCSession.default.activationState == .activated, WCSession.default.isReachable else { return }
|
||||
WCSession.default.sendMessage(["request": "snapshot"], replyHandler: { reply in
|
||||
if let data = reply["snapshot"] as? Data { Task { @MainActor in WatchModel.shared.receive(data) } }
|
||||
}, errorHandler: nil)
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
guard activationState == .activated else { return }
|
||||
if let data = session.receivedApplicationContext["snapshot"] as? Data {
|
||||
Task { @MainActor in WatchModel.shared.receive(data) }
|
||||
}
|
||||
// Only ticks that never reached the phone's queue go again: those still in the
|
||||
// system's queue are on their way, and those handed over are the phone's now.
|
||||
let queued = Set(session.outstandingUserInfoTransfers.compactMap { ($0.userInfo["action"] as? [String: Any])?["id"] as? String })
|
||||
Task { @MainActor in
|
||||
for action in WatchModel.shared.unsent(excluding: queued) {
|
||||
session.transferUserInfo(["action": action.message])
|
||||
}
|
||||
}
|
||||
requestRefresh()
|
||||
}
|
||||
|
||||
/// A tick reached the phone's queue (or failed, and is sent again on the next activation).
|
||||
func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) {
|
||||
guard error == nil, let id = (userInfoTransfer.userInfo["action"] as? [String: Any])?["id"] as? String else { return }
|
||||
Task { @MainActor in WatchModel.shared.markSent(id) }
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
guard let data = applicationContext["snapshot"] as? Data else { return }
|
||||
Task { @MainActor in WatchModel.shared.receive(data) }
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
guard let data = userInfo["snapshot"] as? Data else { return }
|
||||
Task { @MainActor in WatchModel.shared.receive(data) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "اليوم";
|
||||
"today.next" = "التالي";
|
||||
"today.now" = "الآن";
|
||||
"today.later" = "لاحقًا";
|
||||
"today.done" = "انتهى اليوم";
|
||||
"today.doneBody" = "لا شيء آخر مخطط لليوم.";
|
||||
"today.free" = "يوم حر";
|
||||
"today.freeBody" = "لا شيء مخطط لليوم. استمتع.";
|
||||
"today.plan" = "خطة اليوم";
|
||||
"day.of" = "اليوم %1$d من %2$d";
|
||||
"day.empty" = "لا شيء مخطط";
|
||||
"time.now" = "الآن";
|
||||
"time.today" = "اليوم";
|
||||
"time.tomorrow" = "غدًا";
|
||||
"countdown.title" = "الرحلة القادمة";
|
||||
"booking.next" = "الحجز القادم";
|
||||
"booking.all" = "الحجوزات";
|
||||
"booking.none" = "لا حجوزات قادمة";
|
||||
"booking.confirmed" = "مؤكد";
|
||||
"booking.pending" = "قيد الانتظار";
|
||||
"booking.seat" = "المقعد";
|
||||
"booking.platform" = "الرصيف";
|
||||
"booking.ref" = "رقم الحجز";
|
||||
"booking.checkIn" = "تسجيل الوصول";
|
||||
"booking.checkOut" = "تسجيل المغادرة";
|
||||
"booking.departs" = "المغادرة";
|
||||
"booking.arrives" = "الوصول";
|
||||
"kind.flight" = "رحلة جوية";
|
||||
"kind.train" = "قطار";
|
||||
"kind.bus" = "حافلة";
|
||||
"kind.car" = "سيارة";
|
||||
"kind.cruise" = "عبّارة";
|
||||
"kind.hotel" = "فندق";
|
||||
"kind.restaurant" = "مطعم";
|
||||
"kind.event" = "فعالية";
|
||||
"kind.tour" = "جولة";
|
||||
"kind.other" = "حجز";
|
||||
"trip.title" = "الرحلة";
|
||||
"trip.days" = "الأيام";
|
||||
"place.title" = "المكان";
|
||||
"place.directions" = "الاتجاهات";
|
||||
"lists.title" = "القوائم";
|
||||
"packing.title" = "الأمتعة";
|
||||
"packing.packed" = "محزوم";
|
||||
"packing.packedSection" = "محزومة";
|
||||
"packing.allPacked" = "كل شيء محزوم";
|
||||
"packing.other" = "أخرى";
|
||||
"todos.title" = "المهام";
|
||||
"todos.open" = "مفتوحة";
|
||||
"todos.doneSection" = "منجزة";
|
||||
"todos.allDone" = "كل شيء منجز";
|
||||
"empty.sync.title" = "افتح TREK على iPhone";
|
||||
"empty.sync.body" = "ستظهر رحلتك هنا بعد مزامنة التطبيق على iPhone.";
|
||||
"empty.trip.title" = "لا توجد رحلة مخططة";
|
||||
"empty.trip.body" = "خطط لرحلتك القادمة في TREK وستظهر هنا.";
|
||||
"complication.nextStop.name" = "المحطة التالية";
|
||||
"complication.nextStop.description" = "المحطة التالية في اليوم وموعد بدئها.";
|
||||
"complication.countdown.name" = "العد التنازلي";
|
||||
"complication.countdown.description" = "الوقت المتبقي حتى رحلتك الجوية أو القطار أو تسجيل الوصول أو الرحلة القادمة.";
|
||||
"complication.tripDay.name" = "يوم الرحلة";
|
||||
"complication.tripDay.description" = "في أي يوم من الرحلة أنت.";
|
||||
"complication.noTrip" = "لا رحلة";
|
||||
"a11y.done" = "منجزة";
|
||||
"a11y.open" = "غير منجزة";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Bu gün";
|
||||
"today.next" = "Növbəti";
|
||||
"today.now" = "İndi";
|
||||
"today.later" = "Sonra";
|
||||
"today.done" = "Gün bitdi";
|
||||
"today.doneBody" = "Bu gün üçün başqa plan yoxdur.";
|
||||
"today.free" = "Boş gün";
|
||||
"today.freeBody" = "Bu gün plan yoxdur. Həzz alın.";
|
||||
"today.plan" = "Bu günün planı";
|
||||
"day.of" = "Gün %1$d / %2$d";
|
||||
"day.empty" = "Plan yoxdur";
|
||||
"time.now" = "indi";
|
||||
"time.today" = "Bu gün";
|
||||
"time.tomorrow" = "Sabah";
|
||||
"countdown.title" = "Növbəti səyahət";
|
||||
"booking.next" = "Növbəti rezervasiya";
|
||||
"booking.all" = "Rezervasiyalar";
|
||||
"booking.none" = "Qarşıda rezervasiya yoxdur";
|
||||
"booking.confirmed" = "Təsdiqlənib";
|
||||
"booking.pending" = "Gözləyir";
|
||||
"booking.seat" = "Oturacaq";
|
||||
"booking.platform" = "Platforma";
|
||||
"booking.ref" = "Rezervasiya kodu";
|
||||
"booking.checkIn" = "Qeydiyyat";
|
||||
"booking.checkOut" = "Çıxış";
|
||||
"booking.departs" = "Yola düşmə";
|
||||
"booking.arrives" = "Çatma";
|
||||
"kind.flight" = "Uçuş";
|
||||
"kind.train" = "Qatar";
|
||||
"kind.bus" = "Avtobus";
|
||||
"kind.car" = "Avtomobil";
|
||||
"kind.cruise" = "Bərə";
|
||||
"kind.hotel" = "Otel";
|
||||
"kind.restaurant" = "Restoran";
|
||||
"kind.event" = "Tədbir";
|
||||
"kind.tour" = "Tur";
|
||||
"kind.other" = "Rezervasiya";
|
||||
"trip.title" = "Səyahət";
|
||||
"trip.days" = "Günlər";
|
||||
"place.title" = "Məkan";
|
||||
"place.directions" = "Marşrut";
|
||||
"lists.title" = "Siyahılar";
|
||||
"packing.title" = "Baqaj";
|
||||
"packing.packed" = "yığılıb";
|
||||
"packing.packedSection" = "Yığılıb";
|
||||
"packing.allPacked" = "Hər şey yığılıb";
|
||||
"packing.other" = "Digər";
|
||||
"todos.title" = "Tapşırıqlar";
|
||||
"todos.open" = "açıq";
|
||||
"todos.doneSection" = "Hazırdır";
|
||||
"todos.allDone" = "Hamısı hazırdır";
|
||||
"empty.sync.title" = "TREK-i iPhone-da açın";
|
||||
"empty.sync.body" = "Səyahətiniz iPhone-dakı tətbiq sinxronlaşandan sonra burada görünəcək.";
|
||||
"empty.trip.title" = "Planlaşdırılmış səyahət yoxdur";
|
||||
"empty.trip.body" = "Növbəti səyahətinizi TREK-də planlaşdırın, burada görünəcək.";
|
||||
"complication.nextStop.name" = "Növbəti dayanacaq";
|
||||
"complication.nextStop.description" = "Günün növbəti dayanacağı və başlama vaxtı.";
|
||||
"complication.countdown.name" = "Geri sayım";
|
||||
"complication.countdown.description" = "Növbəti uçuşa, qatara, qeydiyyata və ya səyahətə qalan vaxt.";
|
||||
"complication.tripDay.name" = "Səyahət günü";
|
||||
"complication.tripDay.description" = "Səyahətin neçənci günü olduğu.";
|
||||
"complication.noTrip" = "Səyahət yoxdur";
|
||||
"a11y.done" = "Hazırdır";
|
||||
"a11y.open" = "Hazır deyil";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Avui";
|
||||
"today.next" = "Següent";
|
||||
"today.now" = "Ara";
|
||||
"today.later" = "Més tard";
|
||||
"today.done" = "Dia acabat";
|
||||
"today.doneBody" = "Avui no hi ha res més previst.";
|
||||
"today.free" = "Dia lliure";
|
||||
"today.freeBody" = "Avui no hi ha res previst. Gaudeix-ne.";
|
||||
"today.plan" = "Pla d'avui";
|
||||
"day.of" = "Dia %1$d de %2$d";
|
||||
"day.empty" = "Res previst";
|
||||
"time.now" = "ara";
|
||||
"time.today" = "Avui";
|
||||
"time.tomorrow" = "Demà";
|
||||
"countdown.title" = "Proper viatge";
|
||||
"booking.next" = "Propera reserva";
|
||||
"booking.all" = "Reserves";
|
||||
"booking.none" = "Cap reserva pròxima";
|
||||
"booking.confirmed" = "Confirmada";
|
||||
"booking.pending" = "Pendent";
|
||||
"booking.seat" = "Seient";
|
||||
"booking.platform" = "Andana";
|
||||
"booking.ref" = "Localitzador";
|
||||
"booking.checkIn" = "Entrada";
|
||||
"booking.checkOut" = "Sortida";
|
||||
"booking.departs" = "Surt";
|
||||
"booking.arrives" = "Arriba";
|
||||
"kind.flight" = "Vol";
|
||||
"kind.train" = "Tren";
|
||||
"kind.bus" = "Autobús";
|
||||
"kind.car" = "Cotxe";
|
||||
"kind.cruise" = "Ferri";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restaurant";
|
||||
"kind.event" = "Esdeveniment";
|
||||
"kind.tour" = "Excursió";
|
||||
"kind.other" = "Reserva";
|
||||
"trip.title" = "Viatge";
|
||||
"trip.days" = "Dies";
|
||||
"place.title" = "Lloc";
|
||||
"place.directions" = "Com arribar-hi";
|
||||
"lists.title" = "Llistes";
|
||||
"packing.title" = "Equipatge";
|
||||
"packing.packed" = "a la maleta";
|
||||
"packing.packedSection" = "A la maleta";
|
||||
"packing.allPacked" = "Tot a la maleta";
|
||||
"packing.other" = "Altres";
|
||||
"todos.title" = "Tasques";
|
||||
"todos.open" = "obertes";
|
||||
"todos.doneSection" = "Fetes";
|
||||
"todos.allDone" = "Tot fet";
|
||||
"empty.sync.title" = "Obre TREK a l'iPhone";
|
||||
"empty.sync.body" = "El viatge apareixerà aquí quan l'app de l'iPhone s'hagi sincronitzat.";
|
||||
"empty.trip.title" = "Cap viatge previst";
|
||||
"empty.trip.body" = "Planifica el proper viatge a TREK i apareixerà aquí.";
|
||||
"complication.nextStop.name" = "Propera parada";
|
||||
"complication.nextStop.description" = "La propera parada del dia i quan comença.";
|
||||
"complication.countdown.name" = "Compte enrere";
|
||||
"complication.countdown.description" = "Temps fins al proper vol, tren, entrada a l'hotel o viatge.";
|
||||
"complication.tripDay.name" = "Dia del viatge";
|
||||
"complication.tripDay.description" = "Quin dia del viatge és.";
|
||||
"complication.noTrip" = "Cap viatge";
|
||||
"a11y.done" = "Feta";
|
||||
"a11y.open" = "No feta";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Dnes";
|
||||
"today.next" = "Další";
|
||||
"today.now" = "Teď";
|
||||
"today.later" = "Později";
|
||||
"today.done" = "Den hotový";
|
||||
"today.doneBody" = "Na dnešek už není nic naplánováno.";
|
||||
"today.free" = "Volný den";
|
||||
"today.freeBody" = "Dnes není nic naplánováno. Užijte si to.";
|
||||
"today.plan" = "Dnešní plán";
|
||||
"day.of" = "Den %1$d z %2$d";
|
||||
"day.empty" = "Nic naplánováno";
|
||||
"time.now" = "teď";
|
||||
"time.today" = "Dnes";
|
||||
"time.tomorrow" = "Zítra";
|
||||
"countdown.title" = "Příští cesta";
|
||||
"booking.next" = "Příští rezervace";
|
||||
"booking.all" = "Rezervace";
|
||||
"booking.none" = "Žádné nadcházející rezervace";
|
||||
"booking.confirmed" = "Potvrzeno";
|
||||
"booking.pending" = "Čeká";
|
||||
"booking.seat" = "Sedadlo";
|
||||
"booking.platform" = "Nástupiště";
|
||||
"booking.ref" = "Číslo rezervace";
|
||||
"booking.checkIn" = "Příjezd";
|
||||
"booking.checkOut" = "Odjezd";
|
||||
"booking.departs" = "Odjezd";
|
||||
"booking.arrives" = "Příjezd";
|
||||
"kind.flight" = "Let";
|
||||
"kind.train" = "Vlak";
|
||||
"kind.bus" = "Autobus";
|
||||
"kind.car" = "Auto";
|
||||
"kind.cruise" = "Trajekt";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restaurace";
|
||||
"kind.event" = "Akce";
|
||||
"kind.tour" = "Výlet";
|
||||
"kind.other" = "Rezervace";
|
||||
"trip.title" = "Cesta";
|
||||
"trip.days" = "Dny";
|
||||
"place.title" = "Místo";
|
||||
"place.directions" = "Navigovat";
|
||||
"lists.title" = "Seznamy";
|
||||
"packing.title" = "Balení";
|
||||
"packing.packed" = "zabaleno";
|
||||
"packing.packedSection" = "Zabaleno";
|
||||
"packing.allPacked" = "Vše zabaleno";
|
||||
"packing.other" = "Ostatní";
|
||||
"todos.title" = "Úkoly";
|
||||
"todos.open" = "otevřené";
|
||||
"todos.doneSection" = "Hotovo";
|
||||
"todos.allDone" = "Vše hotovo";
|
||||
"empty.sync.title" = "Otevřete TREK na iPhonu";
|
||||
"empty.sync.body" = "Vaše cesta se tu zobrazí, jakmile se aplikace na iPhonu synchronizuje.";
|
||||
"empty.trip.title" = "Žádná naplánovaná cesta";
|
||||
"empty.trip.body" = "Naplánujte příští cestu v TREK a zobrazí se tady.";
|
||||
"complication.nextStop.name" = "Další zastávka";
|
||||
"complication.nextStop.description" = "Další zastávka dne a kdy začíná.";
|
||||
"complication.countdown.name" = "Odpočet";
|
||||
"complication.countdown.description" = "Čas do dalšího letu, vlaku, ubytování nebo cesty.";
|
||||
"complication.tripDay.name" = "Den cesty";
|
||||
"complication.tripDay.description" = "Kolikátý den cesty je.";
|
||||
"complication.noTrip" = "Žádná cesta";
|
||||
"a11y.done" = "Hotovo";
|
||||
"a11y.open" = "Nehotovo";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Heute";
|
||||
"today.next" = "Als Nächstes";
|
||||
"today.now" = "Jetzt";
|
||||
"today.later" = "Später";
|
||||
"today.done" = "Tag geschafft";
|
||||
"today.doneBody" = "Für heute ist nichts mehr geplant.";
|
||||
"today.free" = "Freier Tag";
|
||||
"today.freeBody" = "Heute ist nichts geplant. Genieß es.";
|
||||
"today.plan" = "Tagesplan";
|
||||
"day.of" = "Tag %1$d von %2$d";
|
||||
"day.empty" = "Nichts geplant";
|
||||
"time.now" = "jetzt";
|
||||
"time.today" = "Heute";
|
||||
"time.tomorrow" = "Morgen";
|
||||
"countdown.title" = "Nächste Reise";
|
||||
"booking.next" = "Nächste Buchung";
|
||||
"booking.all" = "Buchungen";
|
||||
"booking.none" = "Keine anstehenden Buchungen";
|
||||
"booking.confirmed" = "Bestätigt";
|
||||
"booking.pending" = "Ausstehend";
|
||||
"booking.seat" = "Sitz";
|
||||
"booking.platform" = "Gleis";
|
||||
"booking.ref" = "Buchungsnr.";
|
||||
"booking.checkIn" = "Check-in";
|
||||
"booking.checkOut" = "Check-out";
|
||||
"booking.departs" = "Abfahrt";
|
||||
"booking.arrives" = "Ankunft";
|
||||
"kind.flight" = "Flug";
|
||||
"kind.train" = "Zug";
|
||||
"kind.bus" = "Bus";
|
||||
"kind.car" = "Auto";
|
||||
"kind.cruise" = "Fähre";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restaurant";
|
||||
"kind.event" = "Event";
|
||||
"kind.tour" = "Tour";
|
||||
"kind.other" = "Buchung";
|
||||
"trip.title" = "Reise";
|
||||
"trip.days" = "Tage";
|
||||
"place.title" = "Ort";
|
||||
"place.directions" = "Route";
|
||||
"lists.title" = "Listen";
|
||||
"packing.title" = "Packliste";
|
||||
"packing.packed" = "gepackt";
|
||||
"packing.packedSection" = "Gepackt";
|
||||
"packing.allPacked" = "Alles gepackt";
|
||||
"packing.other" = "Sonstiges";
|
||||
"todos.title" = "To-dos";
|
||||
"todos.open" = "offen";
|
||||
"todos.doneSection" = "Erledigt";
|
||||
"todos.allDone" = "Alles erledigt";
|
||||
"empty.sync.title" = "Öffne TREK auf deinem iPhone";
|
||||
"empty.sync.body" = "Deine Reise erscheint hier, sobald die App auf deinem iPhone synchronisiert hat.";
|
||||
"empty.trip.title" = "Keine Reise geplant";
|
||||
"empty.trip.body" = "Plane deine nächste Reise in TREK, dann erscheint sie hier.";
|
||||
"complication.nextStop.name" = "Nächster Stopp";
|
||||
"complication.nextStop.description" = "Der nächste Stopp des Tages und wann er beginnt.";
|
||||
"complication.countdown.name" = "Countdown";
|
||||
"complication.countdown.description" = "Zeit bis zum nächsten Flug, Zug, Check-in oder bis zur Reise.";
|
||||
"complication.tripDay.name" = "Reisetag";
|
||||
"complication.tripDay.description" = "Welcher Tag der Reise heute ist.";
|
||||
"complication.noTrip" = "Keine Reise";
|
||||
"a11y.done" = "Erledigt";
|
||||
"a11y.open" = "Offen";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Σήμερα";
|
||||
"today.next" = "Επόμενο";
|
||||
"today.now" = "Τώρα";
|
||||
"today.later" = "Αργότερα";
|
||||
"today.done" = "Η μέρα τελείωσε";
|
||||
"today.doneBody" = "Τίποτα άλλο για σήμερα.";
|
||||
"today.free" = "Ελεύθερη μέρα";
|
||||
"today.freeBody" = "Τίποτα προγραμματισμένο σήμερα. Απόλαυσέ το.";
|
||||
"today.plan" = "Πρόγραμμα ημέρας";
|
||||
"day.of" = "Ημέρα %1$d από %2$d";
|
||||
"day.empty" = "Τίποτα προγραμματισμένο";
|
||||
"time.now" = "τώρα";
|
||||
"time.today" = "Σήμερα";
|
||||
"time.tomorrow" = "Αύριο";
|
||||
"countdown.title" = "Επόμενο ταξίδι";
|
||||
"booking.next" = "Επόμενη κράτηση";
|
||||
"booking.all" = "Κρατήσεις";
|
||||
"booking.none" = "Καμία επερχόμενη κράτηση";
|
||||
"booking.confirmed" = "Επιβεβαιωμένη";
|
||||
"booking.pending" = "Εκκρεμεί";
|
||||
"booking.seat" = "Θέση";
|
||||
"booking.platform" = "Αποβάθρα";
|
||||
"booking.ref" = "Κωδ. κράτησης";
|
||||
"booking.checkIn" = "Check-in";
|
||||
"booking.checkOut" = "Check-out";
|
||||
"booking.departs" = "Αναχώρηση";
|
||||
"booking.arrives" = "Άφιξη";
|
||||
"kind.flight" = "Πτήση";
|
||||
"kind.train" = "Τρένο";
|
||||
"kind.bus" = "Λεωφορείο";
|
||||
"kind.car" = "Αυτοκίνητο";
|
||||
"kind.cruise" = "Πλοίο";
|
||||
"kind.hotel" = "Ξενοδοχείο";
|
||||
"kind.restaurant" = "Εστιατόριο";
|
||||
"kind.event" = "Εκδήλωση";
|
||||
"kind.tour" = "Περιήγηση";
|
||||
"kind.other" = "Κράτηση";
|
||||
"trip.title" = "Ταξίδι";
|
||||
"trip.days" = "Ημέρες";
|
||||
"place.title" = "Μέρος";
|
||||
"place.directions" = "Οδηγίες";
|
||||
"lists.title" = "Λίστες";
|
||||
"packing.title" = "Βαλίτσα";
|
||||
"packing.packed" = "στη βαλίτσα";
|
||||
"packing.packedSection" = "Στη βαλίτσα";
|
||||
"packing.allPacked" = "Όλα στη βαλίτσα";
|
||||
"packing.other" = "Άλλα";
|
||||
"todos.title" = "Εκκρεμότητες";
|
||||
"todos.open" = "ανοιχτές";
|
||||
"todos.doneSection" = "Ολοκληρώθηκαν";
|
||||
"todos.allDone" = "Όλα έτοιμα";
|
||||
"empty.sync.title" = "Άνοιξε το TREK στο iPhone";
|
||||
"empty.sync.body" = "Το ταξίδι σου εμφανίζεται εδώ μόλις συγχρονιστεί η εφαρμογή στο iPhone.";
|
||||
"empty.trip.title" = "Κανένα ταξίδι";
|
||||
"empty.trip.body" = "Σχεδίασε το επόμενο ταξίδι στο TREK και θα εμφανιστεί εδώ.";
|
||||
"complication.nextStop.name" = "Επόμενη στάση";
|
||||
"complication.nextStop.description" = "Η επόμενη στάση της ημέρας και πότε ξεκινά.";
|
||||
"complication.countdown.name" = "Αντίστροφη μέτρηση";
|
||||
"complication.countdown.description" = "Χρόνος ως την επόμενη πτήση, το τρένο, το check-in ή το ταξίδι.";
|
||||
"complication.tripDay.name" = "Ημέρα ταξιδιού";
|
||||
"complication.tripDay.description" = "Ποια μέρα του ταξιδιού είναι.";
|
||||
"complication.noTrip" = "Χωρίς ταξίδι";
|
||||
"a11y.done" = "Έγινε";
|
||||
"a11y.open" = "Εκκρεμεί";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Today";
|
||||
"today.next" = "Next";
|
||||
"today.now" = "Now";
|
||||
"today.later" = "Later today";
|
||||
"today.done" = "Day done";
|
||||
"today.doneBody" = "Nothing more planned for today.";
|
||||
"today.free" = "Free day";
|
||||
"today.freeBody" = "Nothing planned today. Enjoy it.";
|
||||
"today.plan" = "Today's plan";
|
||||
"day.of" = "Day %1$d of %2$d";
|
||||
"day.empty" = "Nothing planned";
|
||||
"time.now" = "now";
|
||||
"time.today" = "Today";
|
||||
"time.tomorrow" = "Tomorrow";
|
||||
"countdown.title" = "Next trip";
|
||||
"booking.next" = "Next booking";
|
||||
"booking.all" = "Bookings";
|
||||
"booking.none" = "No bookings ahead";
|
||||
"booking.confirmed" = "Confirmed";
|
||||
"booking.pending" = "Pending";
|
||||
"booking.seat" = "Seat";
|
||||
"booking.platform" = "Platform";
|
||||
"booking.ref" = "Booking ref";
|
||||
"booking.checkIn" = "Check-in";
|
||||
"booking.checkOut" = "Check-out";
|
||||
"booking.departs" = "Departs";
|
||||
"booking.arrives" = "Arrives";
|
||||
"kind.flight" = "Flight";
|
||||
"kind.train" = "Train";
|
||||
"kind.bus" = "Bus";
|
||||
"kind.car" = "Car";
|
||||
"kind.cruise" = "Ferry";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restaurant";
|
||||
"kind.event" = "Event";
|
||||
"kind.tour" = "Tour";
|
||||
"kind.other" = "Booking";
|
||||
"trip.title" = "Trip";
|
||||
"trip.days" = "Days";
|
||||
"place.title" = "Place";
|
||||
"place.directions" = "Directions";
|
||||
"lists.title" = "Lists";
|
||||
"packing.title" = "Packing";
|
||||
"packing.packed" = "packed";
|
||||
"packing.packedSection" = "Packed";
|
||||
"packing.allPacked" = "Everything packed";
|
||||
"packing.other" = "Other";
|
||||
"todos.title" = "To-dos";
|
||||
"todos.open" = "open";
|
||||
"todos.doneSection" = "Done";
|
||||
"todos.allDone" = "All done";
|
||||
"empty.sync.title" = "Open TREK on your iPhone";
|
||||
"empty.sync.body" = "Your trip shows up here once the app on your iPhone has synced.";
|
||||
"empty.trip.title" = "No trip planned";
|
||||
"empty.trip.body" = "Plan your next trip in TREK and it shows up here.";
|
||||
"complication.nextStop.name" = "Next stop";
|
||||
"complication.nextStop.description" = "The next stop of the day and when it starts.";
|
||||
"complication.countdown.name" = "Countdown";
|
||||
"complication.countdown.description" = "Time until your next flight, train, check-in or trip.";
|
||||
"complication.tripDay.name" = "Trip day";
|
||||
"complication.tripDay.description" = "Which day of the trip it is.";
|
||||
"complication.noTrip" = "No trip";
|
||||
"a11y.done" = "Done";
|
||||
"a11y.open" = "Not done";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Hoy";
|
||||
"today.next" = "Siguiente";
|
||||
"today.now" = "Ahora";
|
||||
"today.later" = "Más tarde";
|
||||
"today.done" = "Día completado";
|
||||
"today.doneBody" = "No hay nada más previsto para hoy.";
|
||||
"today.free" = "Día libre";
|
||||
"today.freeBody" = "Hoy no hay nada previsto. Disfrútalo.";
|
||||
"today.plan" = "Plan de hoy";
|
||||
"day.of" = "Día %1$d de %2$d";
|
||||
"day.empty" = "Nada previsto";
|
||||
"time.now" = "ahora";
|
||||
"time.today" = "Hoy";
|
||||
"time.tomorrow" = "Mañana";
|
||||
"countdown.title" = "Próximo viaje";
|
||||
"booking.next" = "Próxima reserva";
|
||||
"booking.all" = "Reservas";
|
||||
"booking.none" = "No hay reservas próximas";
|
||||
"booking.confirmed" = "Confirmada";
|
||||
"booking.pending" = "Pendiente";
|
||||
"booking.seat" = "Asiento";
|
||||
"booking.platform" = "Andén";
|
||||
"booking.ref" = "Localizador";
|
||||
"booking.checkIn" = "Entrada";
|
||||
"booking.checkOut" = "Salida";
|
||||
"booking.departs" = "Sale";
|
||||
"booking.arrives" = "Llega";
|
||||
"kind.flight" = "Vuelo";
|
||||
"kind.train" = "Tren";
|
||||
"kind.bus" = "Autobús";
|
||||
"kind.car" = "Coche";
|
||||
"kind.cruise" = "Ferri";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restaurante";
|
||||
"kind.event" = "Evento";
|
||||
"kind.tour" = "Tour";
|
||||
"kind.other" = "Reserva";
|
||||
"trip.title" = "Viaje";
|
||||
"trip.days" = "Días";
|
||||
"place.title" = "Lugar";
|
||||
"place.directions" = "Cómo llegar";
|
||||
"lists.title" = "Listas";
|
||||
"packing.title" = "Equipaje";
|
||||
"packing.packed" = "guardado";
|
||||
"packing.packedSection" = "Guardado";
|
||||
"packing.allPacked" = "Todo guardado";
|
||||
"packing.other" = "Otros";
|
||||
"todos.title" = "Tareas";
|
||||
"todos.open" = "pendientes";
|
||||
"todos.doneSection" = "Hechas";
|
||||
"todos.allDone" = "Todo hecho";
|
||||
"empty.sync.title" = "Abre TREK en tu iPhone";
|
||||
"empty.sync.body" = "Tu viaje aparecerá aquí cuando la app de tu iPhone se haya sincronizado.";
|
||||
"empty.trip.title" = "Ningún viaje previsto";
|
||||
"empty.trip.body" = "Planifica tu próximo viaje en TREK y aparecerá aquí.";
|
||||
"complication.nextStop.name" = "Siguiente parada";
|
||||
"complication.nextStop.description" = "La siguiente parada del día y cuándo empieza.";
|
||||
"complication.countdown.name" = "Cuenta atrás";
|
||||
"complication.countdown.description" = "Tiempo hasta tu próximo vuelo, tren, entrada al hotel o viaje.";
|
||||
"complication.tripDay.name" = "Día del viaje";
|
||||
"complication.tripDay.description" = "En qué día del viaje estás.";
|
||||
"complication.noTrip" = "Sin viaje";
|
||||
"a11y.done" = "Hecha";
|
||||
"a11y.open" = "Pendiente";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Täna";
|
||||
"today.next" = "Järgmine";
|
||||
"today.now" = "Praegu";
|
||||
"today.later" = "Hiljem";
|
||||
"today.done" = "Päev läbi";
|
||||
"today.doneBody" = "Tänaseks pole rohkem midagi plaanis.";
|
||||
"today.free" = "Vaba päev";
|
||||
"today.freeBody" = "Täna pole midagi plaanis. Naudi.";
|
||||
"today.plan" = "Tänane plaan";
|
||||
"day.of" = "Päev %1$d/%2$d";
|
||||
"day.empty" = "Midagi pole plaanis";
|
||||
"time.now" = "praegu";
|
||||
"time.today" = "Täna";
|
||||
"time.tomorrow" = "Homme";
|
||||
"countdown.title" = "Järgmine reis";
|
||||
"booking.next" = "Järgmine broneering";
|
||||
"booking.all" = "Broneeringud";
|
||||
"booking.none" = "Eesootavaid broneeringuid pole";
|
||||
"booking.confirmed" = "Kinnitatud";
|
||||
"booking.pending" = "Ootel";
|
||||
"booking.seat" = "Iste";
|
||||
"booking.platform" = "Platvorm";
|
||||
"booking.ref" = "Broneeringu kood";
|
||||
"booking.checkIn" = "Sisseregistreerimine";
|
||||
"booking.checkOut" = "Väljaregistreerimine";
|
||||
"booking.departs" = "Väljub";
|
||||
"booking.arrives" = "Saabub";
|
||||
"kind.flight" = "Lend";
|
||||
"kind.train" = "Rong";
|
||||
"kind.bus" = "Buss";
|
||||
"kind.car" = "Auto";
|
||||
"kind.cruise" = "Praam";
|
||||
"kind.hotel" = "Hotell";
|
||||
"kind.restaurant" = "Restoran";
|
||||
"kind.event" = "Üritus";
|
||||
"kind.tour" = "Ekskursioon";
|
||||
"kind.other" = "Broneering";
|
||||
"trip.title" = "Reis";
|
||||
"trip.days" = "Päevad";
|
||||
"place.title" = "Koht";
|
||||
"place.directions" = "Teejuhised";
|
||||
"lists.title" = "Loendid";
|
||||
"packing.title" = "Pakkimine";
|
||||
"packing.packed" = "pakitud";
|
||||
"packing.packedSection" = "Pakitud";
|
||||
"packing.allPacked" = "Kõik pakitud";
|
||||
"packing.other" = "Muu";
|
||||
"todos.title" = "Ülesanded";
|
||||
"todos.open" = "avatud";
|
||||
"todos.doneSection" = "Tehtud";
|
||||
"todos.allDone" = "Kõik tehtud";
|
||||
"empty.sync.title" = "Ava TREK oma iPhone'is";
|
||||
"empty.sync.body" = "Sinu reis ilmub siia, kui iPhone'i rakendus on sünkroonitud.";
|
||||
"empty.trip.title" = "Reisi pole plaanitud";
|
||||
"empty.trip.body" = "Planeeri järgmine reis TREKis ja see ilmub siia.";
|
||||
"complication.nextStop.name" = "Järgmine peatus";
|
||||
"complication.nextStop.description" = "Päeva järgmine peatus ja selle algus.";
|
||||
"complication.countdown.name" = "Pöördloendus";
|
||||
"complication.countdown.description" = "Aeg järgmise lennu, rongi, sisseregistreerimise või reisini.";
|
||||
"complication.tripDay.name" = "Reisipäev";
|
||||
"complication.tripDay.description" = "Mitmes reisipäev on.";
|
||||
"complication.noTrip" = "Reisi pole";
|
||||
"a11y.done" = "Tehtud";
|
||||
"a11y.open" = "Tegemata";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Aujourd'hui";
|
||||
"today.next" = "Ensuite";
|
||||
"today.now" = "En cours";
|
||||
"today.later" = "Plus tard";
|
||||
"today.done" = "Journée terminée";
|
||||
"today.doneBody" = "Plus rien de prévu aujourd'hui.";
|
||||
"today.free" = "Journée libre";
|
||||
"today.freeBody" = "Rien de prévu aujourd'hui. Profitez-en.";
|
||||
"today.plan" = "Programme du jour";
|
||||
"day.of" = "Jour %1$d sur %2$d";
|
||||
"day.empty" = "Rien de prévu";
|
||||
"time.now" = "maintenant";
|
||||
"time.today" = "Aujourd'hui";
|
||||
"time.tomorrow" = "Demain";
|
||||
"countdown.title" = "Prochain voyage";
|
||||
"booking.next" = "Prochaine réservation";
|
||||
"booking.all" = "Réservations";
|
||||
"booking.none" = "Aucune réservation à venir";
|
||||
"booking.confirmed" = "Confirmée";
|
||||
"booking.pending" = "En attente";
|
||||
"booking.seat" = "Siège";
|
||||
"booking.platform" = "Voie";
|
||||
"booking.ref" = "Réf. de réservation";
|
||||
"booking.checkIn" = "Arrivée";
|
||||
"booking.checkOut" = "Départ";
|
||||
"booking.departs" = "Départ";
|
||||
"booking.arrives" = "Arrivée";
|
||||
"kind.flight" = "Vol";
|
||||
"kind.train" = "Train";
|
||||
"kind.bus" = "Bus";
|
||||
"kind.car" = "Voiture";
|
||||
"kind.cruise" = "Ferry";
|
||||
"kind.hotel" = "Hôtel";
|
||||
"kind.restaurant" = "Restaurant";
|
||||
"kind.event" = "Événement";
|
||||
"kind.tour" = "Visite";
|
||||
"kind.other" = "Réservation";
|
||||
"trip.title" = "Voyage";
|
||||
"trip.days" = "Jours";
|
||||
"place.title" = "Lieu";
|
||||
"place.directions" = "Itinéraire";
|
||||
"lists.title" = "Listes";
|
||||
"packing.title" = "Bagages";
|
||||
"packing.packed" = "emballés";
|
||||
"packing.packedSection" = "Dans la valise";
|
||||
"packing.allPacked" = "Tout est emballé";
|
||||
"packing.other" = "Autre";
|
||||
"todos.title" = "À faire";
|
||||
"todos.open" = "ouvertes";
|
||||
"todos.doneSection" = "Terminées";
|
||||
"todos.allDone" = "Tout est fait";
|
||||
"empty.sync.title" = "Ouvrez TREK sur votre iPhone";
|
||||
"empty.sync.body" = "Votre voyage apparaît ici dès que l'app de votre iPhone est synchronisée.";
|
||||
"empty.trip.title" = "Aucun voyage prévu";
|
||||
"empty.trip.body" = "Planifiez votre prochain voyage dans TREK et il apparaîtra ici.";
|
||||
"complication.nextStop.name" = "Prochaine étape";
|
||||
"complication.nextStop.description" = "La prochaine étape du jour et son heure de début.";
|
||||
"complication.countdown.name" = "Compte à rebours";
|
||||
"complication.countdown.description" = "Le temps restant avant votre prochain vol, train, arrivée à l'hôtel ou voyage.";
|
||||
"complication.tripDay.name" = "Jour du voyage";
|
||||
"complication.tripDay.description" = "Le jour du voyage où vous en êtes.";
|
||||
"complication.noTrip" = "Aucun voyage";
|
||||
"a11y.done" = "Fait";
|
||||
"a11y.open" = "À faire";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "היום";
|
||||
"today.next" = "הבא";
|
||||
"today.now" = "עכשיו";
|
||||
"today.later" = "בהמשך היום";
|
||||
"today.done" = "היום הסתיים";
|
||||
"today.doneBody" = "אין עוד תוכניות להיום.";
|
||||
"today.free" = "יום חופשי";
|
||||
"today.freeBody" = "אין תוכניות להיום. תיהנו.";
|
||||
"today.plan" = "התוכנית להיום";
|
||||
"day.of" = "יום %1$d מתוך %2$d";
|
||||
"day.empty" = "אין תוכניות";
|
||||
"time.now" = "עכשיו";
|
||||
"time.today" = "היום";
|
||||
"time.tomorrow" = "מחר";
|
||||
"countdown.title" = "הטיול הבא";
|
||||
"booking.next" = "ההזמנה הבאה";
|
||||
"booking.all" = "הזמנות";
|
||||
"booking.none" = "אין הזמנות קרובות";
|
||||
"booking.confirmed" = "מאושרת";
|
||||
"booking.pending" = "ממתינה";
|
||||
"booking.seat" = "מושב";
|
||||
"booking.platform" = "רציף";
|
||||
"booking.ref" = "מספר הזמנה";
|
||||
"booking.checkIn" = "צ'ק-אין";
|
||||
"booking.checkOut" = "צ'ק-אאוט";
|
||||
"booking.departs" = "יציאה";
|
||||
"booking.arrives" = "הגעה";
|
||||
"kind.flight" = "טיסה";
|
||||
"kind.train" = "רכבת";
|
||||
"kind.bus" = "אוטובוס";
|
||||
"kind.car" = "רכב";
|
||||
"kind.cruise" = "מעבורת";
|
||||
"kind.hotel" = "מלון";
|
||||
"kind.restaurant" = "מסעדה";
|
||||
"kind.event" = "אירוע";
|
||||
"kind.tour" = "סיור";
|
||||
"kind.other" = "הזמנה";
|
||||
"trip.title" = "טיול";
|
||||
"trip.days" = "ימים";
|
||||
"place.title" = "מקום";
|
||||
"place.directions" = "הוראות הגעה";
|
||||
"lists.title" = "רשימות";
|
||||
"packing.title" = "אריזה";
|
||||
"packing.packed" = "ארוזים";
|
||||
"packing.packedSection" = "ארוז";
|
||||
"packing.allPacked" = "הכול ארוז";
|
||||
"packing.other" = "אחר";
|
||||
"todos.title" = "משימות";
|
||||
"todos.open" = "פתוחות";
|
||||
"todos.doneSection" = "בוצעו";
|
||||
"todos.allDone" = "הכול בוצע";
|
||||
"empty.sync.title" = "פתחו את TREK ב-iPhone";
|
||||
"empty.sync.body" = "הטיול יופיע כאן אחרי שהאפליקציה ב-iPhone תסתנכרן.";
|
||||
"empty.trip.title" = "אין טיול מתוכנן";
|
||||
"empty.trip.body" = "תכננו את הטיול הבא ב-TREK והוא יופיע כאן.";
|
||||
"complication.nextStop.name" = "העצירה הבאה";
|
||||
"complication.nextStop.description" = "העצירה הבאה של היום ומתי היא מתחילה.";
|
||||
"complication.countdown.name" = "ספירה לאחור";
|
||||
"complication.countdown.description" = "הזמן עד הטיסה, הרכבת, הצ'ק-אין או הטיול הבאים.";
|
||||
"complication.tripDay.name" = "יום בטיול";
|
||||
"complication.tripDay.description" = "באיזה יום של הטיול אתם.";
|
||||
"complication.noTrip" = "אין טיול";
|
||||
"a11y.done" = "בוצע";
|
||||
"a11y.open" = "לא בוצע";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Ma";
|
||||
"today.next" = "Következő";
|
||||
"today.now" = "Most";
|
||||
"today.later" = "Később";
|
||||
"today.done" = "A nap kész";
|
||||
"today.doneBody" = "Mára nincs több program.";
|
||||
"today.free" = "Szabadnap";
|
||||
"today.freeBody" = "Mára nincs program. Élvezd!";
|
||||
"today.plan" = "Mai program";
|
||||
"day.of" = "%1$d. nap / %2$d";
|
||||
"day.empty" = "Nincs program";
|
||||
"time.now" = "most";
|
||||
"time.today" = "Ma";
|
||||
"time.tomorrow" = "Holnap";
|
||||
"countdown.title" = "Következő utazás";
|
||||
"booking.next" = "Következő foglalás";
|
||||
"booking.all" = "Foglalások";
|
||||
"booking.none" = "Nincs közelgő foglalás";
|
||||
"booking.confirmed" = "Megerősítve";
|
||||
"booking.pending" = "Függőben";
|
||||
"booking.seat" = "Ülés";
|
||||
"booking.platform" = "Vágány";
|
||||
"booking.ref" = "Foglalási szám";
|
||||
"booking.checkIn" = "Bejelentkezés";
|
||||
"booking.checkOut" = "Kijelentkezés";
|
||||
"booking.departs" = "Indulás";
|
||||
"booking.arrives" = "Érkezés";
|
||||
"kind.flight" = "Repülés";
|
||||
"kind.train" = "Vonat";
|
||||
"kind.bus" = "Busz";
|
||||
"kind.car" = "Autó";
|
||||
"kind.cruise" = "Komp";
|
||||
"kind.hotel" = "Szálloda";
|
||||
"kind.restaurant" = "Étterem";
|
||||
"kind.event" = "Esemény";
|
||||
"kind.tour" = "Túra";
|
||||
"kind.other" = "Foglalás";
|
||||
"trip.title" = "Utazás";
|
||||
"trip.days" = "Napok";
|
||||
"place.title" = "Hely";
|
||||
"place.directions" = "Útvonal";
|
||||
"lists.title" = "Listák";
|
||||
"packing.title" = "Csomagolás";
|
||||
"packing.packed" = "bepakolva";
|
||||
"packing.packedSection" = "Bepakolva";
|
||||
"packing.allPacked" = "Minden bepakolva";
|
||||
"packing.other" = "Egyéb";
|
||||
"todos.title" = "Teendők";
|
||||
"todos.open" = "nyitott";
|
||||
"todos.doneSection" = "Kész";
|
||||
"todos.allDone" = "Minden kész";
|
||||
"empty.sync.title" = "Nyisd meg a TREK-et az iPhone-on";
|
||||
"empty.sync.body" = "Az utazásod itt jelenik meg, amint az iPhone-os app szinkronizált.";
|
||||
"empty.trip.title" = "Nincs tervezett utazás";
|
||||
"empty.trip.body" = "Tervezd meg a következő utazást a TREK-ben, és itt megjelenik.";
|
||||
"complication.nextStop.name" = "Következő megálló";
|
||||
"complication.nextStop.description" = "A nap következő megállója és a kezdés ideje.";
|
||||
"complication.countdown.name" = "Visszaszámlálás";
|
||||
"complication.countdown.description" = "Idő a következő repülésig, vonatig, bejelentkezésig vagy utazásig.";
|
||||
"complication.tripDay.name" = "Utazási nap";
|
||||
"complication.tripDay.description" = "Hányadik napja tart az utazás.";
|
||||
"complication.noTrip" = "Nincs utazás";
|
||||
"a11y.done" = "Kész";
|
||||
"a11y.open" = "Nincs kész";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Hari ini";
|
||||
"today.next" = "Berikutnya";
|
||||
"today.now" = "Sekarang";
|
||||
"today.later" = "Nanti";
|
||||
"today.done" = "Hari selesai";
|
||||
"today.doneBody" = "Tidak ada rencana lagi untuk hari ini.";
|
||||
"today.free" = "Hari bebas";
|
||||
"today.freeBody" = "Tidak ada rencana hari ini. Nikmati.";
|
||||
"today.plan" = "Rencana hari ini";
|
||||
"day.of" = "Hari %1$d dari %2$d";
|
||||
"day.empty" = "Tidak ada rencana";
|
||||
"time.now" = "sekarang";
|
||||
"time.today" = "Hari ini";
|
||||
"time.tomorrow" = "Besok";
|
||||
"countdown.title" = "Perjalanan berikutnya";
|
||||
"booking.next" = "Pemesanan berikutnya";
|
||||
"booking.all" = "Pemesanan";
|
||||
"booking.none" = "Tidak ada pemesanan mendatang";
|
||||
"booking.confirmed" = "Terkonfirmasi";
|
||||
"booking.pending" = "Menunggu";
|
||||
"booking.seat" = "Kursi";
|
||||
"booking.platform" = "Peron";
|
||||
"booking.ref" = "Kode pemesanan";
|
||||
"booking.checkIn" = "Check-in";
|
||||
"booking.checkOut" = "Check-out";
|
||||
"booking.departs" = "Berangkat";
|
||||
"booking.arrives" = "Tiba";
|
||||
"kind.flight" = "Penerbangan";
|
||||
"kind.train" = "Kereta";
|
||||
"kind.bus" = "Bus";
|
||||
"kind.car" = "Mobil";
|
||||
"kind.cruise" = "Feri";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restoran";
|
||||
"kind.event" = "Acara";
|
||||
"kind.tour" = "Tur";
|
||||
"kind.other" = "Pemesanan";
|
||||
"trip.title" = "Perjalanan";
|
||||
"trip.days" = "Hari";
|
||||
"place.title" = "Tempat";
|
||||
"place.directions" = "Petunjuk arah";
|
||||
"lists.title" = "Daftar";
|
||||
"packing.title" = "Bawaan";
|
||||
"packing.packed" = "dikemas";
|
||||
"packing.packedSection" = "Sudah dikemas";
|
||||
"packing.allPacked" = "Semua sudah dikemas";
|
||||
"packing.other" = "Lainnya";
|
||||
"todos.title" = "Tugas";
|
||||
"todos.open" = "terbuka";
|
||||
"todos.doneSection" = "Selesai";
|
||||
"todos.allDone" = "Semua selesai";
|
||||
"empty.sync.title" = "Buka TREK di iPhone";
|
||||
"empty.sync.body" = "Perjalananmu muncul di sini setelah app di iPhone tersinkron.";
|
||||
"empty.trip.title" = "Belum ada perjalanan";
|
||||
"empty.trip.body" = "Rencanakan perjalanan berikutnya di TREK dan akan muncul di sini.";
|
||||
"complication.nextStop.name" = "Pemberhentian berikutnya";
|
||||
"complication.nextStop.description" = "Pemberhentian berikutnya hari ini dan kapan dimulai.";
|
||||
"complication.countdown.name" = "Hitung mundur";
|
||||
"complication.countdown.description" = "Waktu menuju penerbangan, kereta, check-in, atau perjalanan berikutnya.";
|
||||
"complication.tripDay.name" = "Hari perjalanan";
|
||||
"complication.tripDay.description" = "Hari ke berapa dari perjalanan.";
|
||||
"complication.noTrip" = "Tidak ada perjalanan";
|
||||
"a11y.done" = "Selesai";
|
||||
"a11y.open" = "Belum selesai";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Oggi";
|
||||
"today.next" = "Prossimo";
|
||||
"today.now" = "Ora";
|
||||
"today.later" = "Più tardi";
|
||||
"today.done" = "Giornata finita";
|
||||
"today.doneBody" = "Per oggi non c'è altro in programma.";
|
||||
"today.free" = "Giorno libero";
|
||||
"today.freeBody" = "Oggi niente in programma. Goditelo.";
|
||||
"today.plan" = "Programma di oggi";
|
||||
"day.of" = "Giorno %1$d di %2$d";
|
||||
"day.empty" = "Niente in programma";
|
||||
"time.now" = "ora";
|
||||
"time.today" = "Oggi";
|
||||
"time.tomorrow" = "Domani";
|
||||
"countdown.title" = "Prossimo viaggio";
|
||||
"booking.next" = "Prossima prenotazione";
|
||||
"booking.all" = "Prenotazioni";
|
||||
"booking.none" = "Nessuna prenotazione in arrivo";
|
||||
"booking.confirmed" = "Confermata";
|
||||
"booking.pending" = "In attesa";
|
||||
"booking.seat" = "Posto";
|
||||
"booking.platform" = "Binario";
|
||||
"booking.ref" = "Codice prenotazione";
|
||||
"booking.checkIn" = "Check-in";
|
||||
"booking.checkOut" = "Check-out";
|
||||
"booking.departs" = "Partenza";
|
||||
"booking.arrives" = "Arrivo";
|
||||
"kind.flight" = "Volo";
|
||||
"kind.train" = "Treno";
|
||||
"kind.bus" = "Autobus";
|
||||
"kind.car" = "Auto";
|
||||
"kind.cruise" = "Traghetto";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Ristorante";
|
||||
"kind.event" = "Evento";
|
||||
"kind.tour" = "Tour";
|
||||
"kind.other" = "Prenotazione";
|
||||
"trip.title" = "Viaggio";
|
||||
"trip.days" = "Giorni";
|
||||
"place.title" = "Luogo";
|
||||
"place.directions" = "Indicazioni";
|
||||
"lists.title" = "Liste";
|
||||
"packing.title" = "Bagagli";
|
||||
"packing.packed" = "in valigia";
|
||||
"packing.packedSection" = "In valigia";
|
||||
"packing.allPacked" = "Tutto in valigia";
|
||||
"packing.other" = "Altro";
|
||||
"todos.title" = "Da fare";
|
||||
"todos.open" = "aperte";
|
||||
"todos.doneSection" = "Fatte";
|
||||
"todos.allDone" = "Tutto fatto";
|
||||
"empty.sync.title" = "Apri TREK sul tuo iPhone";
|
||||
"empty.sync.body" = "Il tuo viaggio apparirà qui appena l'app sul tuo iPhone si sarà sincronizzata.";
|
||||
"empty.trip.title" = "Nessun viaggio in programma";
|
||||
"empty.trip.body" = "Pianifica il tuo prossimo viaggio in TREK e apparirà qui.";
|
||||
"complication.nextStop.name" = "Prossima tappa";
|
||||
"complication.nextStop.description" = "La prossima tappa del giorno e quando inizia.";
|
||||
"complication.countdown.name" = "Conto alla rovescia";
|
||||
"complication.countdown.description" = "Il tempo che manca al prossimo volo, treno, check-in o viaggio.";
|
||||
"complication.tripDay.name" = "Giorno di viaggio";
|
||||
"complication.tripDay.description" = "Che giorno del viaggio è oggi.";
|
||||
"complication.noTrip" = "Nessun viaggio";
|
||||
"a11y.done" = "Fatto";
|
||||
"a11y.open" = "Da fare";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "今日";
|
||||
"today.next" = "次";
|
||||
"today.now" = "進行中";
|
||||
"today.later" = "このあと";
|
||||
"today.done" = "今日はおしまい";
|
||||
"today.doneBody" = "今日の予定はもうありません。";
|
||||
"today.free" = "自由な一日";
|
||||
"today.freeBody" = "今日は予定がありません。のんびりどうぞ。";
|
||||
"today.plan" = "今日の予定";
|
||||
"day.of" = "%2$d日中%1$d日目";
|
||||
"day.empty" = "予定なし";
|
||||
"time.now" = "今";
|
||||
"time.today" = "今日";
|
||||
"time.tomorrow" = "明日";
|
||||
"countdown.title" = "次の旅行";
|
||||
"booking.next" = "次の予約";
|
||||
"booking.all" = "予約";
|
||||
"booking.none" = "今後の予約はありません";
|
||||
"booking.confirmed" = "確定";
|
||||
"booking.pending" = "保留中";
|
||||
"booking.seat" = "座席";
|
||||
"booking.platform" = "番線";
|
||||
"booking.ref" = "予約番号";
|
||||
"booking.checkIn" = "チェックイン";
|
||||
"booking.checkOut" = "チェックアウト";
|
||||
"booking.departs" = "出発";
|
||||
"booking.arrives" = "到着";
|
||||
"kind.flight" = "フライト";
|
||||
"kind.train" = "列車";
|
||||
"kind.bus" = "バス";
|
||||
"kind.car" = "車";
|
||||
"kind.cruise" = "フェリー";
|
||||
"kind.hotel" = "ホテル";
|
||||
"kind.restaurant" = "レストラン";
|
||||
"kind.event" = "イベント";
|
||||
"kind.tour" = "ツアー";
|
||||
"kind.other" = "予約";
|
||||
"trip.title" = "旅行";
|
||||
"trip.days" = "日程";
|
||||
"place.title" = "スポット";
|
||||
"place.directions" = "経路";
|
||||
"lists.title" = "リスト";
|
||||
"packing.title" = "持ち物";
|
||||
"packing.packed" = "準備済み";
|
||||
"packing.packedSection" = "準備済み";
|
||||
"packing.allPacked" = "すべて準備完了";
|
||||
"packing.other" = "その他";
|
||||
"todos.title" = "ToDo";
|
||||
"todos.open" = "未完了";
|
||||
"todos.doneSection" = "完了";
|
||||
"todos.allDone" = "すべて完了";
|
||||
"empty.sync.title" = "iPhoneでTREKを開いてください";
|
||||
"empty.sync.body" = "iPhoneのアプリが同期すると、ここに旅行が表示されます。";
|
||||
"empty.trip.title" = "予定している旅行はありません";
|
||||
"empty.trip.body" = "TREKで次の旅行を計画すると、ここに表示されます。";
|
||||
"complication.nextStop.name" = "次の目的地";
|
||||
"complication.nextStop.description" = "今日の次の目的地と開始時刻。";
|
||||
"complication.countdown.name" = "カウントダウン";
|
||||
"complication.countdown.description" = "次のフライト、列車、チェックイン、旅行までの時間。";
|
||||
"complication.tripDay.name" = "旅行の日";
|
||||
"complication.tripDay.description" = "旅行の何日目か。";
|
||||
"complication.noTrip" = "旅行なし";
|
||||
"a11y.done" = "完了";
|
||||
"a11y.open" = "未完了";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "오늘";
|
||||
"today.next" = "다음";
|
||||
"today.now" = "진행 중";
|
||||
"today.later" = "이후";
|
||||
"today.done" = "오늘 일정 끝";
|
||||
"today.doneBody" = "오늘은 더 이상 일정이 없습니다.";
|
||||
"today.free" = "자유로운 날";
|
||||
"today.freeBody" = "오늘은 일정이 없습니다. 즐기세요.";
|
||||
"today.plan" = "오늘 일정";
|
||||
"day.of" = "%2$d일 중 %1$d일째";
|
||||
"day.empty" = "일정 없음";
|
||||
"time.now" = "지금";
|
||||
"time.today" = "오늘";
|
||||
"time.tomorrow" = "내일";
|
||||
"countdown.title" = "다음 여행";
|
||||
"booking.next" = "다음 예약";
|
||||
"booking.all" = "예약";
|
||||
"booking.none" = "예정된 예약 없음";
|
||||
"booking.confirmed" = "확정";
|
||||
"booking.pending" = "대기 중";
|
||||
"booking.seat" = "좌석";
|
||||
"booking.platform" = "승강장";
|
||||
"booking.ref" = "예약 번호";
|
||||
"booking.checkIn" = "체크인";
|
||||
"booking.checkOut" = "체크아웃";
|
||||
"booking.departs" = "출발";
|
||||
"booking.arrives" = "도착";
|
||||
"kind.flight" = "항공편";
|
||||
"kind.train" = "기차";
|
||||
"kind.bus" = "버스";
|
||||
"kind.car" = "자동차";
|
||||
"kind.cruise" = "페리";
|
||||
"kind.hotel" = "호텔";
|
||||
"kind.restaurant" = "레스토랑";
|
||||
"kind.event" = "이벤트";
|
||||
"kind.tour" = "투어";
|
||||
"kind.other" = "예약";
|
||||
"trip.title" = "여행";
|
||||
"trip.days" = "일정";
|
||||
"place.title" = "장소";
|
||||
"place.directions" = "경로";
|
||||
"lists.title" = "목록";
|
||||
"packing.title" = "짐 싸기";
|
||||
"packing.packed" = "챙김";
|
||||
"packing.packedSection" = "챙긴 짐";
|
||||
"packing.allPacked" = "모두 챙김";
|
||||
"packing.other" = "기타";
|
||||
"todos.title" = "할 일";
|
||||
"todos.open" = "남음";
|
||||
"todos.doneSection" = "완료";
|
||||
"todos.allDone" = "모두 완료";
|
||||
"empty.sync.title" = "iPhone에서 TREK을 여세요";
|
||||
"empty.sync.body" = "iPhone의 앱이 동기화되면 여행이 여기에 표시됩니다.";
|
||||
"empty.trip.title" = "계획된 여행 없음";
|
||||
"empty.trip.body" = "TREK에서 다음 여행을 계획하면 여기에 표시됩니다.";
|
||||
"complication.nextStop.name" = "다음 장소";
|
||||
"complication.nextStop.description" = "오늘의 다음 장소와 시작 시간.";
|
||||
"complication.countdown.name" = "카운트다운";
|
||||
"complication.countdown.description" = "다음 항공편, 기차, 체크인 또는 여행까지 남은 시간.";
|
||||
"complication.tripDay.name" = "여행 일차";
|
||||
"complication.tripDay.description" = "여행 며칠째인지 보여 줍니다.";
|
||||
"complication.noTrip" = "여행 없음";
|
||||
"a11y.done" = "완료";
|
||||
"a11y.open" = "미완료";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Vandaag";
|
||||
"today.next" = "Hierna";
|
||||
"today.now" = "Nu";
|
||||
"today.later" = "Later vandaag";
|
||||
"today.done" = "Dag klaar";
|
||||
"today.doneBody" = "Voor vandaag staat er niets meer gepland.";
|
||||
"today.free" = "Vrije dag";
|
||||
"today.freeBody" = "Vandaag staat er niets gepland. Geniet ervan.";
|
||||
"today.plan" = "Dagplanning";
|
||||
"day.of" = "Dag %1$d van %2$d";
|
||||
"day.empty" = "Niets gepland";
|
||||
"time.now" = "nu";
|
||||
"time.today" = "Vandaag";
|
||||
"time.tomorrow" = "Morgen";
|
||||
"countdown.title" = "Volgende reis";
|
||||
"booking.next" = "Volgende boeking";
|
||||
"booking.all" = "Boekingen";
|
||||
"booking.none" = "Geen komende boekingen";
|
||||
"booking.confirmed" = "Bevestigd";
|
||||
"booking.pending" = "In afwachting";
|
||||
"booking.seat" = "Stoel";
|
||||
"booking.platform" = "Spoor";
|
||||
"booking.ref" = "Boekingsnr.";
|
||||
"booking.checkIn" = "Inchecken";
|
||||
"booking.checkOut" = "Uitchecken";
|
||||
"booking.departs" = "Vertrek";
|
||||
"booking.arrives" = "Aankomst";
|
||||
"kind.flight" = "Vlucht";
|
||||
"kind.train" = "Trein";
|
||||
"kind.bus" = "Bus";
|
||||
"kind.car" = "Auto";
|
||||
"kind.cruise" = "Veerboot";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restaurant";
|
||||
"kind.event" = "Evenement";
|
||||
"kind.tour" = "Tour";
|
||||
"kind.other" = "Boeking";
|
||||
"trip.title" = "Reis";
|
||||
"trip.days" = "Dagen";
|
||||
"place.title" = "Plek";
|
||||
"place.directions" = "Routebeschrijving";
|
||||
"lists.title" = "Lijsten";
|
||||
"packing.title" = "Paklijst";
|
||||
"packing.packed" = "ingepakt";
|
||||
"packing.packedSection" = "Ingepakt";
|
||||
"packing.allPacked" = "Alles ingepakt";
|
||||
"packing.other" = "Overig";
|
||||
"todos.title" = "To-do's";
|
||||
"todos.open" = "open";
|
||||
"todos.doneSection" = "Klaar";
|
||||
"todos.allDone" = "Alles klaar";
|
||||
"empty.sync.title" = "Open TREK op je iPhone";
|
||||
"empty.sync.body" = "Je reis verschijnt hier zodra de app op je iPhone is gesynchroniseerd.";
|
||||
"empty.trip.title" = "Geen reis gepland";
|
||||
"empty.trip.body" = "Plan je volgende reis in TREK en hij verschijnt hier.";
|
||||
"complication.nextStop.name" = "Volgende stop";
|
||||
"complication.nextStop.description" = "De volgende stop van de dag en wanneer die begint.";
|
||||
"complication.countdown.name" = "Aftellen";
|
||||
"complication.countdown.description" = "Tijd tot je volgende vlucht, trein, check-in of reis.";
|
||||
"complication.tripDay.name" = "Reisdag";
|
||||
"complication.tripDay.description" = "Welke dag van de reis het is.";
|
||||
"complication.noTrip" = "Geen reis";
|
||||
"a11y.done" = "Klaar";
|
||||
"a11y.open" = "Open";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Dziś";
|
||||
"today.next" = "Dalej";
|
||||
"today.now" = "Teraz";
|
||||
"today.later" = "Później";
|
||||
"today.done" = "Dzień zakończony";
|
||||
"today.doneBody" = "Na dziś nic więcej nie zaplanowano.";
|
||||
"today.free" = "Wolny dzień";
|
||||
"today.freeBody" = "Na dziś nic nie zaplanowano. Odpocznij.";
|
||||
"today.plan" = "Plan dnia";
|
||||
"day.of" = "Dzień %1$d z %2$d";
|
||||
"day.empty" = "Nic nie zaplanowano";
|
||||
"time.now" = "teraz";
|
||||
"time.today" = "Dziś";
|
||||
"time.tomorrow" = "Jutro";
|
||||
"countdown.title" = "Następna podróż";
|
||||
"booking.next" = "Następna rezerwacja";
|
||||
"booking.all" = "Rezerwacje";
|
||||
"booking.none" = "Brak nadchodzących rezerwacji";
|
||||
"booking.confirmed" = "Potwierdzona";
|
||||
"booking.pending" = "Oczekuje";
|
||||
"booking.seat" = "Miejsce";
|
||||
"booking.platform" = "Peron";
|
||||
"booking.ref" = "Nr rezerwacji";
|
||||
"booking.checkIn" = "Zameldowanie";
|
||||
"booking.checkOut" = "Wymeldowanie";
|
||||
"booking.departs" = "Odjazd";
|
||||
"booking.arrives" = "Przyjazd";
|
||||
"kind.flight" = "Lot";
|
||||
"kind.train" = "Pociąg";
|
||||
"kind.bus" = "Autobus";
|
||||
"kind.car" = "Samochód";
|
||||
"kind.cruise" = "Prom";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restauracja";
|
||||
"kind.event" = "Wydarzenie";
|
||||
"kind.tour" = "Wycieczka";
|
||||
"kind.other" = "Rezerwacja";
|
||||
"trip.title" = "Podróż";
|
||||
"trip.days" = "Dni";
|
||||
"place.title" = "Miejsce";
|
||||
"place.directions" = "Wskazówki dojazdu";
|
||||
"lists.title" = "Listy";
|
||||
"packing.title" = "Pakowanie";
|
||||
"packing.packed" = "spakowane";
|
||||
"packing.packedSection" = "Spakowane";
|
||||
"packing.allPacked" = "Wszystko spakowane";
|
||||
"packing.other" = "Inne";
|
||||
"todos.title" = "Zadania";
|
||||
"todos.open" = "otwarte";
|
||||
"todos.doneSection" = "Gotowe";
|
||||
"todos.allDone" = "Wszystko gotowe";
|
||||
"empty.sync.title" = "Otwórz TREK na iPhonie";
|
||||
"empty.sync.body" = "Twoja podróż pojawi się tutaj, gdy aplikacja na iPhonie się zsynchronizuje.";
|
||||
"empty.trip.title" = "Brak zaplanowanej podróży";
|
||||
"empty.trip.body" = "Zaplanuj następną podróż w TREK, a pojawi się tutaj.";
|
||||
"complication.nextStop.name" = "Następny przystanek";
|
||||
"complication.nextStop.description" = "Następny przystanek dnia i godzina rozpoczęcia.";
|
||||
"complication.countdown.name" = "Odliczanie";
|
||||
"complication.countdown.description" = "Czas do następnego lotu, pociągu, zameldowania lub podróży.";
|
||||
"complication.tripDay.name" = "Dzień podróży";
|
||||
"complication.tripDay.description" = "Który to dzień podróży.";
|
||||
"complication.noTrip" = "Brak podróży";
|
||||
"a11y.done" = "Gotowe";
|
||||
"a11y.open" = "Niegotowe";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Hoje";
|
||||
"today.next" = "A seguir";
|
||||
"today.now" = "Agora";
|
||||
"today.later" = "Mais tarde";
|
||||
"today.done" = "Dia concluído";
|
||||
"today.doneBody" = "Nada mais planejado para hoje.";
|
||||
"today.free" = "Dia livre";
|
||||
"today.freeBody" = "Nada planejado para hoje. Aproveite.";
|
||||
"today.plan" = "Plano de hoje";
|
||||
"day.of" = "Dia %1$d de %2$d";
|
||||
"day.empty" = "Nada planejado";
|
||||
"time.now" = "agora";
|
||||
"time.today" = "Hoje";
|
||||
"time.tomorrow" = "Amanhã";
|
||||
"countdown.title" = "Próxima viagem";
|
||||
"booking.next" = "Próxima reserva";
|
||||
"booking.all" = "Reservas";
|
||||
"booking.none" = "Nenhuma reserva à frente";
|
||||
"booking.confirmed" = "Confirmada";
|
||||
"booking.pending" = "Pendente";
|
||||
"booking.seat" = "Assento";
|
||||
"booking.platform" = "Plataforma";
|
||||
"booking.ref" = "Código da reserva";
|
||||
"booking.checkIn" = "Check-in";
|
||||
"booking.checkOut" = "Check-out";
|
||||
"booking.departs" = "Partida";
|
||||
"booking.arrives" = "Chegada";
|
||||
"kind.flight" = "Voo";
|
||||
"kind.train" = "Trem";
|
||||
"kind.bus" = "Ônibus";
|
||||
"kind.car" = "Carro";
|
||||
"kind.cruise" = "Balsa";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restaurante";
|
||||
"kind.event" = "Evento";
|
||||
"kind.tour" = "Passeio";
|
||||
"kind.other" = "Reserva";
|
||||
"trip.title" = "Viagem";
|
||||
"trip.days" = "Dias";
|
||||
"place.title" = "Local";
|
||||
"place.directions" = "Como chegar";
|
||||
"lists.title" = "Listas";
|
||||
"packing.title" = "Bagagem";
|
||||
"packing.packed" = "na mala";
|
||||
"packing.packedSection" = "Na mala";
|
||||
"packing.allPacked" = "Tudo na mala";
|
||||
"packing.other" = "Outros";
|
||||
"todos.title" = "Tarefas";
|
||||
"todos.open" = "abertas";
|
||||
"todos.doneSection" = "Feitas";
|
||||
"todos.allDone" = "Tudo feito";
|
||||
"empty.sync.title" = "Abra o TREK no seu iPhone";
|
||||
"empty.sync.body" = "Sua viagem aparece aqui assim que o app no seu iPhone sincronizar.";
|
||||
"empty.trip.title" = "Nenhuma viagem planejada";
|
||||
"empty.trip.body" = "Planeje sua próxima viagem no TREK e ela aparece aqui.";
|
||||
"complication.nextStop.name" = "Próxima parada";
|
||||
"complication.nextStop.description" = "A próxima parada do dia e quando começa.";
|
||||
"complication.countdown.name" = "Contagem regressiva";
|
||||
"complication.countdown.description" = "Tempo até o próximo voo, trem, check-in ou viagem.";
|
||||
"complication.tripDay.name" = "Dia da viagem";
|
||||
"complication.tripDay.description" = "Em que dia da viagem você está.";
|
||||
"complication.noTrip" = "Sem viagem";
|
||||
"a11y.done" = "Feita";
|
||||
"a11y.open" = "Não feita";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Сегодня";
|
||||
"today.next" = "Далее";
|
||||
"today.now" = "Сейчас";
|
||||
"today.later" = "Позже";
|
||||
"today.done" = "День завершён";
|
||||
"today.doneBody" = "На сегодня больше ничего не запланировано.";
|
||||
"today.free" = "Свободный день";
|
||||
"today.freeBody" = "На сегодня ничего не запланировано. Отдыхайте.";
|
||||
"today.plan" = "План на день";
|
||||
"day.of" = "День %1$d из %2$d";
|
||||
"day.empty" = "Ничего не запланировано";
|
||||
"time.now" = "сейчас";
|
||||
"time.today" = "Сегодня";
|
||||
"time.tomorrow" = "Завтра";
|
||||
"countdown.title" = "Следующая поездка";
|
||||
"booking.next" = "Следующая бронь";
|
||||
"booking.all" = "Брони";
|
||||
"booking.none" = "Предстоящих броней нет";
|
||||
"booking.confirmed" = "Подтверждено";
|
||||
"booking.pending" = "Ожидает";
|
||||
"booking.seat" = "Место";
|
||||
"booking.platform" = "Платформа";
|
||||
"booking.ref" = "Номер брони";
|
||||
"booking.checkIn" = "Заезд";
|
||||
"booking.checkOut" = "Выезд";
|
||||
"booking.departs" = "Отправление";
|
||||
"booking.arrives" = "Прибытие";
|
||||
"kind.flight" = "Рейс";
|
||||
"kind.train" = "Поезд";
|
||||
"kind.bus" = "Автобус";
|
||||
"kind.car" = "Авто";
|
||||
"kind.cruise" = "Паром";
|
||||
"kind.hotel" = "Отель";
|
||||
"kind.restaurant" = "Ресторан";
|
||||
"kind.event" = "Событие";
|
||||
"kind.tour" = "Экскурсия";
|
||||
"kind.other" = "Бронь";
|
||||
"trip.title" = "Поездка";
|
||||
"trip.days" = "Дни";
|
||||
"place.title" = "Место";
|
||||
"place.directions" = "Маршрут";
|
||||
"lists.title" = "Списки";
|
||||
"packing.title" = "Сборы";
|
||||
"packing.packed" = "собрано";
|
||||
"packing.packedSection" = "Собрано";
|
||||
"packing.allPacked" = "Всё собрано";
|
||||
"packing.other" = "Другое";
|
||||
"todos.title" = "Задачи";
|
||||
"todos.open" = "открыто";
|
||||
"todos.doneSection" = "Готово";
|
||||
"todos.allDone" = "Всё готово";
|
||||
"empty.sync.title" = "Откройте TREK на iPhone";
|
||||
"empty.sync.body" = "Поездка появится здесь, как только приложение на iPhone синхронизируется.";
|
||||
"empty.trip.title" = "Поездок не запланировано";
|
||||
"empty.trip.body" = "Запланируйте следующую поездку в TREK, и она появится здесь.";
|
||||
"complication.nextStop.name" = "Следующая точка";
|
||||
"complication.nextStop.description" = "Следующая точка дня и время её начала.";
|
||||
"complication.countdown.name" = "Обратный отсчёт";
|
||||
"complication.countdown.description" = "Время до следующего рейса, поезда, заезда или поездки.";
|
||||
"complication.tripDay.name" = "День поездки";
|
||||
"complication.tripDay.description" = "Какой сейчас день поездки.";
|
||||
"complication.noTrip" = "Нет поездки";
|
||||
"a11y.done" = "Готово";
|
||||
"a11y.open" = "Не готово";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Dnes";
|
||||
"today.next" = "Ďalej";
|
||||
"today.now" = "Teraz";
|
||||
"today.later" = "Neskôr";
|
||||
"today.done" = "Deň hotový";
|
||||
"today.doneBody" = "Na dnes už nie je nič naplánované.";
|
||||
"today.free" = "Voľný deň";
|
||||
"today.freeBody" = "Dnes nie je nič naplánované. Užite si to.";
|
||||
"today.plan" = "Dnešný plán";
|
||||
"day.of" = "Deň %1$d z %2$d";
|
||||
"day.empty" = "Nič naplánované";
|
||||
"time.now" = "teraz";
|
||||
"time.today" = "Dnes";
|
||||
"time.tomorrow" = "Zajtra";
|
||||
"countdown.title" = "Ďalšia cesta";
|
||||
"booking.next" = "Ďalšia rezervácia";
|
||||
"booking.all" = "Rezervácie";
|
||||
"booking.none" = "Žiadne nadchádzajúce rezervácie";
|
||||
"booking.confirmed" = "Potvrdené";
|
||||
"booking.pending" = "Čaká";
|
||||
"booking.seat" = "Sedadlo";
|
||||
"booking.platform" = "Nástupište";
|
||||
"booking.ref" = "Číslo rezervácie";
|
||||
"booking.checkIn" = "Príchod";
|
||||
"booking.checkOut" = "Odchod";
|
||||
"booking.departs" = "Odchod";
|
||||
"booking.arrives" = "Príchod";
|
||||
"kind.flight" = "Let";
|
||||
"kind.train" = "Vlak";
|
||||
"kind.bus" = "Autobus";
|
||||
"kind.car" = "Auto";
|
||||
"kind.cruise" = "Trajekt";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Reštaurácia";
|
||||
"kind.event" = "Podujatie";
|
||||
"kind.tour" = "Výlet";
|
||||
"kind.other" = "Rezervácia";
|
||||
"trip.title" = "Cesta";
|
||||
"trip.days" = "Dni";
|
||||
"place.title" = "Miesto";
|
||||
"place.directions" = "Navigovať";
|
||||
"lists.title" = "Zoznamy";
|
||||
"packing.title" = "Balenie";
|
||||
"packing.packed" = "zbalené";
|
||||
"packing.packedSection" = "Zbalené";
|
||||
"packing.allPacked" = "Všetko zbalené";
|
||||
"packing.other" = "Ostatné";
|
||||
"todos.title" = "Úlohy";
|
||||
"todos.open" = "otvorené";
|
||||
"todos.doneSection" = "Hotové";
|
||||
"todos.allDone" = "Všetko hotové";
|
||||
"empty.sync.title" = "Otvorte TREK v iPhone";
|
||||
"empty.sync.body" = "Vaša cesta sa tu zobrazí, keď sa aplikácia v iPhone zosynchronizuje.";
|
||||
"empty.trip.title" = "Žiadna naplánovaná cesta";
|
||||
"empty.trip.body" = "Naplánujte ďalšiu cestu v TREK a zobrazí sa tu.";
|
||||
"complication.nextStop.name" = "Ďalšia zastávka";
|
||||
"complication.nextStop.description" = "Ďalšia zastávka dňa a kedy začína.";
|
||||
"complication.countdown.name" = "Odpočítavanie";
|
||||
"complication.countdown.description" = "Čas do ďalšieho letu, vlaku, ubytovania alebo cesty.";
|
||||
"complication.tripDay.name" = "Deň cesty";
|
||||
"complication.tripDay.description" = "Koľký deň cesty je.";
|
||||
"complication.noTrip" = "Žiadna cesta";
|
||||
"a11y.done" = "Hotové";
|
||||
"a11y.open" = "Nehotové";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Idag";
|
||||
"today.next" = "Nästa";
|
||||
"today.now" = "Nu";
|
||||
"today.later" = "Senare idag";
|
||||
"today.done" = "Dagen klar";
|
||||
"today.doneBody" = "Inget mer planerat för idag.";
|
||||
"today.free" = "Ledig dag";
|
||||
"today.freeBody" = "Inget planerat idag. Njut av det.";
|
||||
"today.plan" = "Dagens plan";
|
||||
"day.of" = "Dag %1$d av %2$d";
|
||||
"day.empty" = "Inget planerat";
|
||||
"time.now" = "nu";
|
||||
"time.today" = "Idag";
|
||||
"time.tomorrow" = "Imorgon";
|
||||
"countdown.title" = "Nästa resa";
|
||||
"booking.next" = "Nästa bokning";
|
||||
"booking.all" = "Bokningar";
|
||||
"booking.none" = "Inga kommande bokningar";
|
||||
"booking.confirmed" = "Bekräftad";
|
||||
"booking.pending" = "Väntar";
|
||||
"booking.seat" = "Plats";
|
||||
"booking.platform" = "Spår";
|
||||
"booking.ref" = "Bokningsnr";
|
||||
"booking.checkIn" = "Incheckning";
|
||||
"booking.checkOut" = "Utcheckning";
|
||||
"booking.departs" = "Avgår";
|
||||
"booking.arrives" = "Ankommer";
|
||||
"kind.flight" = "Flyg";
|
||||
"kind.train" = "Tåg";
|
||||
"kind.bus" = "Buss";
|
||||
"kind.car" = "Bil";
|
||||
"kind.cruise" = "Färja";
|
||||
"kind.hotel" = "Hotell";
|
||||
"kind.restaurant" = "Restaurang";
|
||||
"kind.event" = "Evenemang";
|
||||
"kind.tour" = "Tur";
|
||||
"kind.other" = "Bokning";
|
||||
"trip.title" = "Resa";
|
||||
"trip.days" = "Dagar";
|
||||
"place.title" = "Plats";
|
||||
"place.directions" = "Vägbeskrivning";
|
||||
"lists.title" = "Listor";
|
||||
"packing.title" = "Packlista";
|
||||
"packing.packed" = "packat";
|
||||
"packing.packedSection" = "Packat";
|
||||
"packing.allPacked" = "Allt packat";
|
||||
"packing.other" = "Övrigt";
|
||||
"todos.title" = "Att göra";
|
||||
"todos.open" = "öppna";
|
||||
"todos.doneSection" = "Klara";
|
||||
"todos.allDone" = "Allt klart";
|
||||
"empty.sync.title" = "Öppna TREK på din iPhone";
|
||||
"empty.sync.body" = "Din resa visas här när appen på din iPhone har synkroniserats.";
|
||||
"empty.trip.title" = "Ingen resa planerad";
|
||||
"empty.trip.body" = "Planera din nästa resa i TREK så visas den här.";
|
||||
"complication.nextStop.name" = "Nästa stopp";
|
||||
"complication.nextStop.description" = "Dagens nästa stopp och när det börjar.";
|
||||
"complication.countdown.name" = "Nedräkning";
|
||||
"complication.countdown.description" = "Tid till nästa flyg, tåg, incheckning eller resa.";
|
||||
"complication.tripDay.name" = "Resdag";
|
||||
"complication.tripDay.description" = "Vilken dag av resan det är.";
|
||||
"complication.noTrip" = "Ingen resa";
|
||||
"a11y.done" = "Klar";
|
||||
"a11y.open" = "Inte klar";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "วันนี้";
|
||||
"today.next" = "ถัดไป";
|
||||
"today.now" = "ตอนนี้";
|
||||
"today.later" = "ภายหลัง";
|
||||
"today.done" = "จบวันแล้ว";
|
||||
"today.doneBody" = "วันนี้ไม่มีแผนอื่นแล้ว";
|
||||
"today.free" = "วันว่าง";
|
||||
"today.freeBody" = "วันนี้ไม่มีแผน พักผ่อนให้สบาย";
|
||||
"today.plan" = "แผนวันนี้";
|
||||
"day.of" = "วันที่ %1$d จาก %2$d";
|
||||
"day.empty" = "ไม่มีแผน";
|
||||
"time.now" = "ตอนนี้";
|
||||
"time.today" = "วันนี้";
|
||||
"time.tomorrow" = "พรุ่งนี้";
|
||||
"countdown.title" = "ทริปถัดไป";
|
||||
"booking.next" = "การจองถัดไป";
|
||||
"booking.all" = "การจอง";
|
||||
"booking.none" = "ไม่มีการจองที่จะถึง";
|
||||
"booking.confirmed" = "ยืนยันแล้ว";
|
||||
"booking.pending" = "รอดำเนินการ";
|
||||
"booking.seat" = "ที่นั่ง";
|
||||
"booking.platform" = "ชานชาลา";
|
||||
"booking.ref" = "รหัสการจอง";
|
||||
"booking.checkIn" = "เช็คอิน";
|
||||
"booking.checkOut" = "เช็คเอาท์";
|
||||
"booking.departs" = "ออกเดินทาง";
|
||||
"booking.arrives" = "ถึง";
|
||||
"kind.flight" = "เที่ยวบิน";
|
||||
"kind.train" = "รถไฟ";
|
||||
"kind.bus" = "รถบัส";
|
||||
"kind.car" = "รถยนต์";
|
||||
"kind.cruise" = "เรือเฟอร์รี่";
|
||||
"kind.hotel" = "โรงแรม";
|
||||
"kind.restaurant" = "ร้านอาหาร";
|
||||
"kind.event" = "อีเวนต์";
|
||||
"kind.tour" = "ทัวร์";
|
||||
"kind.other" = "การจอง";
|
||||
"trip.title" = "ทริป";
|
||||
"trip.days" = "วัน";
|
||||
"place.title" = "สถานที่";
|
||||
"place.directions" = "เส้นทาง";
|
||||
"lists.title" = "รายการ";
|
||||
"packing.title" = "จัดกระเป๋า";
|
||||
"packing.packed" = "จัดแล้ว";
|
||||
"packing.packedSection" = "จัดแล้ว";
|
||||
"packing.allPacked" = "จัดครบแล้ว";
|
||||
"packing.other" = "อื่นๆ";
|
||||
"todos.title" = "สิ่งที่ต้องทำ";
|
||||
"todos.open" = "ค้างอยู่";
|
||||
"todos.doneSection" = "เสร็จแล้ว";
|
||||
"todos.allDone" = "เสร็จหมดแล้ว";
|
||||
"empty.sync.title" = "เปิด TREK บน iPhone";
|
||||
"empty.sync.body" = "ทริปของคุณจะแสดงที่นี่เมื่อแอปบน iPhone ซิงค์แล้ว";
|
||||
"empty.trip.title" = "ยังไม่มีทริป";
|
||||
"empty.trip.body" = "วางแผนทริปถัดไปใน TREK แล้วจะแสดงที่นี่";
|
||||
"complication.nextStop.name" = "จุดแวะถัดไป";
|
||||
"complication.nextStop.description" = "จุดแวะถัดไปของวันและเวลาที่เริ่ม";
|
||||
"complication.countdown.name" = "นับถอยหลัง";
|
||||
"complication.countdown.description" = "เวลาที่เหลือก่อนเที่ยวบิน รถไฟ เช็คอิน หรือทริปถัดไป";
|
||||
"complication.tripDay.name" = "วันของทริป";
|
||||
"complication.tripDay.description" = "วันนี้เป็นวันที่เท่าไรของทริป";
|
||||
"complication.noTrip" = "ไม่มีทริป";
|
||||
"a11y.done" = "เสร็จแล้ว";
|
||||
"a11y.open" = "ยังไม่เสร็จ";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Bugün";
|
||||
"today.next" = "Sıradaki";
|
||||
"today.now" = "Şimdi";
|
||||
"today.later" = "Daha sonra";
|
||||
"today.done" = "Gün tamam";
|
||||
"today.doneBody" = "Bugün için başka plan yok.";
|
||||
"today.free" = "Serbest gün";
|
||||
"today.freeBody" = "Bugün plan yok. Tadını çıkar.";
|
||||
"today.plan" = "Bugünün planı";
|
||||
"day.of" = "Gün %1$d / %2$d";
|
||||
"day.empty" = "Plan yok";
|
||||
"time.now" = "şimdi";
|
||||
"time.today" = "Bugün";
|
||||
"time.tomorrow" = "Yarın";
|
||||
"countdown.title" = "Sonraki seyahat";
|
||||
"booking.next" = "Sonraki rezervasyon";
|
||||
"booking.all" = "Rezervasyonlar";
|
||||
"booking.none" = "Yaklaşan rezervasyon yok";
|
||||
"booking.confirmed" = "Onaylandı";
|
||||
"booking.pending" = "Beklemede";
|
||||
"booking.seat" = "Koltuk";
|
||||
"booking.platform" = "Peron";
|
||||
"booking.ref" = "Rezervasyon no.";
|
||||
"booking.checkIn" = "Giriş";
|
||||
"booking.checkOut" = "Çıkış";
|
||||
"booking.departs" = "Kalkış";
|
||||
"booking.arrives" = "Varış";
|
||||
"kind.flight" = "Uçuş";
|
||||
"kind.train" = "Tren";
|
||||
"kind.bus" = "Otobüs";
|
||||
"kind.car" = "Araba";
|
||||
"kind.cruise" = "Feribot";
|
||||
"kind.hotel" = "Otel";
|
||||
"kind.restaurant" = "Restoran";
|
||||
"kind.event" = "Etkinlik";
|
||||
"kind.tour" = "Tur";
|
||||
"kind.other" = "Rezervasyon";
|
||||
"trip.title" = "Seyahat";
|
||||
"trip.days" = "Günler";
|
||||
"place.title" = "Yer";
|
||||
"place.directions" = "Yol tarifi";
|
||||
"lists.title" = "Listeler";
|
||||
"packing.title" = "Bavul";
|
||||
"packing.packed" = "bavulda";
|
||||
"packing.packedSection" = "Bavulda";
|
||||
"packing.allPacked" = "Her şey bavulda";
|
||||
"packing.other" = "Diğer";
|
||||
"todos.title" = "Yapılacaklar";
|
||||
"todos.open" = "açık";
|
||||
"todos.doneSection" = "Bitti";
|
||||
"todos.allDone" = "Hepsi bitti";
|
||||
"empty.sync.title" = "TREK'i iPhone'unda aç";
|
||||
"empty.sync.body" = "Seyahatin, iPhone'undaki uygulama eşitlendiğinde burada görünür.";
|
||||
"empty.trip.title" = "Planlanmış seyahat yok";
|
||||
"empty.trip.body" = "Sonraki seyahatini TREK'te planla, burada görünsün.";
|
||||
"complication.nextStop.name" = "Sonraki durak";
|
||||
"complication.nextStop.description" = "Günün sonraki durağı ve başlama saati.";
|
||||
"complication.countdown.name" = "Geri sayım";
|
||||
"complication.countdown.description" = "Sonraki uçuşa, trene, otele girişe veya seyahate kalan süre.";
|
||||
"complication.tripDay.name" = "Seyahat günü";
|
||||
"complication.tripDay.description" = "Seyahatin kaçıncı günü olduğu.";
|
||||
"complication.noTrip" = "Seyahat yok";
|
||||
"a11y.done" = "Bitti";
|
||||
"a11y.open" = "Bitmedi";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Сьогодні";
|
||||
"today.next" = "Далі";
|
||||
"today.now" = "Зараз";
|
||||
"today.later" = "Пізніше";
|
||||
"today.done" = "День завершено";
|
||||
"today.doneBody" = "На сьогодні більше нічого не заплановано.";
|
||||
"today.free" = "Вільний день";
|
||||
"today.freeBody" = "На сьогодні нічого не заплановано. Відпочивайте.";
|
||||
"today.plan" = "План на день";
|
||||
"day.of" = "День %1$d з %2$d";
|
||||
"day.empty" = "Нічого не заплановано";
|
||||
"time.now" = "зараз";
|
||||
"time.today" = "Сьогодні";
|
||||
"time.tomorrow" = "Завтра";
|
||||
"countdown.title" = "Наступна подорож";
|
||||
"booking.next" = "Наступне бронювання";
|
||||
"booking.all" = "Бронювання";
|
||||
"booking.none" = "Немає майбутніх бронювань";
|
||||
"booking.confirmed" = "Підтверджено";
|
||||
"booking.pending" = "Очікує";
|
||||
"booking.seat" = "Місце";
|
||||
"booking.platform" = "Платформа";
|
||||
"booking.ref" = "Номер бронювання";
|
||||
"booking.checkIn" = "Заїзд";
|
||||
"booking.checkOut" = "Виїзд";
|
||||
"booking.departs" = "Відправлення";
|
||||
"booking.arrives" = "Прибуття";
|
||||
"kind.flight" = "Рейс";
|
||||
"kind.train" = "Потяг";
|
||||
"kind.bus" = "Автобус";
|
||||
"kind.car" = "Авто";
|
||||
"kind.cruise" = "Пором";
|
||||
"kind.hotel" = "Готель";
|
||||
"kind.restaurant" = "Ресторан";
|
||||
"kind.event" = "Подія";
|
||||
"kind.tour" = "Екскурсія";
|
||||
"kind.other" = "Бронювання";
|
||||
"trip.title" = "Подорож";
|
||||
"trip.days" = "Дні";
|
||||
"place.title" = "Місце";
|
||||
"place.directions" = "Маршрут";
|
||||
"lists.title" = "Списки";
|
||||
"packing.title" = "Пакування";
|
||||
"packing.packed" = "спаковано";
|
||||
"packing.packedSection" = "Спаковано";
|
||||
"packing.allPacked" = "Усе спаковано";
|
||||
"packing.other" = "Інше";
|
||||
"todos.title" = "Завдання";
|
||||
"todos.open" = "відкрито";
|
||||
"todos.doneSection" = "Виконано";
|
||||
"todos.allDone" = "Усе виконано";
|
||||
"empty.sync.title" = "Відкрийте TREK на iPhone";
|
||||
"empty.sync.body" = "Подорож з’явиться тут, щойно застосунок на iPhone синхронізується.";
|
||||
"empty.trip.title" = "Подорожей не заплановано";
|
||||
"empty.trip.body" = "Сплануйте наступну подорож у TREK, і вона з’явиться тут.";
|
||||
"complication.nextStop.name" = "Наступна зупинка";
|
||||
"complication.nextStop.description" = "Наступна зупинка дня і час її початку.";
|
||||
"complication.countdown.name" = "Зворотний відлік";
|
||||
"complication.countdown.description" = "Час до наступного рейсу, потяга, заїзду чи подорожі.";
|
||||
"complication.tripDay.name" = "День подорожі";
|
||||
"complication.tripDay.description" = "Який зараз день подорожі.";
|
||||
"complication.noTrip" = "Немає подорожі";
|
||||
"a11y.done" = "Виконано";
|
||||
"a11y.open" = "Не виконано";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Hôm nay";
|
||||
"today.next" = "Tiếp theo";
|
||||
"today.now" = "Đang diễn ra";
|
||||
"today.later" = "Lát nữa";
|
||||
"today.done" = "Xong ngày";
|
||||
"today.doneBody" = "Hôm nay không còn gì trong kế hoạch.";
|
||||
"today.free" = "Ngày tự do";
|
||||
"today.freeBody" = "Hôm nay không có kế hoạch. Hãy tận hưởng.";
|
||||
"today.plan" = "Kế hoạch hôm nay";
|
||||
"day.of" = "Ngày %1$d/%2$d";
|
||||
"day.empty" = "Chưa có kế hoạch";
|
||||
"time.now" = "bây giờ";
|
||||
"time.today" = "Hôm nay";
|
||||
"time.tomorrow" = "Ngày mai";
|
||||
"countdown.title" = "Chuyến đi tới";
|
||||
"booking.next" = "Đặt chỗ tiếp theo";
|
||||
"booking.all" = "Đặt chỗ";
|
||||
"booking.none" = "Không có đặt chỗ sắp tới";
|
||||
"booking.confirmed" = "Đã xác nhận";
|
||||
"booking.pending" = "Đang chờ";
|
||||
"booking.seat" = "Ghế";
|
||||
"booking.platform" = "Sân ga";
|
||||
"booking.ref" = "Mã đặt chỗ";
|
||||
"booking.checkIn" = "Nhận phòng";
|
||||
"booking.checkOut" = "Trả phòng";
|
||||
"booking.departs" = "Khởi hành";
|
||||
"booking.arrives" = "Đến nơi";
|
||||
"kind.flight" = "Chuyến bay";
|
||||
"kind.train" = "Tàu hỏa";
|
||||
"kind.bus" = "Xe buýt";
|
||||
"kind.car" = "Ô tô";
|
||||
"kind.cruise" = "Phà";
|
||||
"kind.hotel" = "Khách sạn";
|
||||
"kind.restaurant" = "Nhà hàng";
|
||||
"kind.event" = "Sự kiện";
|
||||
"kind.tour" = "Tour";
|
||||
"kind.other" = "Đặt chỗ";
|
||||
"trip.title" = "Chuyến đi";
|
||||
"trip.days" = "Ngày";
|
||||
"place.title" = "Địa điểm";
|
||||
"place.directions" = "Chỉ đường";
|
||||
"lists.title" = "Danh sách";
|
||||
"packing.title" = "Hành lý";
|
||||
"packing.packed" = "đã xếp";
|
||||
"packing.packedSection" = "Đã xếp";
|
||||
"packing.allPacked" = "Đã xếp hết";
|
||||
"packing.other" = "Khác";
|
||||
"todos.title" = "Việc cần làm";
|
||||
"todos.open" = "còn lại";
|
||||
"todos.doneSection" = "Đã xong";
|
||||
"todos.allDone" = "Xong hết";
|
||||
"empty.sync.title" = "Mở TREK trên iPhone";
|
||||
"empty.sync.body" = "Chuyến đi sẽ hiện ở đây khi ứng dụng trên iPhone đồng bộ xong.";
|
||||
"empty.trip.title" = "Chưa có chuyến đi";
|
||||
"empty.trip.body" = "Lên kế hoạch chuyến đi tiếp theo trong TREK, nó sẽ hiện ở đây.";
|
||||
"complication.nextStop.name" = "Điểm dừng tiếp theo";
|
||||
"complication.nextStop.description" = "Điểm dừng tiếp theo trong ngày và giờ bắt đầu.";
|
||||
"complication.countdown.name" = "Đếm ngược";
|
||||
"complication.countdown.description" = "Thời gian đến chuyến bay, tàu, nhận phòng hoặc chuyến đi tiếp theo.";
|
||||
"complication.tripDay.name" = "Ngày của chuyến đi";
|
||||
"complication.tripDay.description" = "Hôm nay là ngày thứ mấy của chuyến đi.";
|
||||
"complication.noTrip" = "Không có chuyến đi";
|
||||
"a11y.done" = "Đã xong";
|
||||
"a11y.open" = "Chưa xong";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "今天";
|
||||
"today.next" = "下一站";
|
||||
"today.now" = "进行中";
|
||||
"today.later" = "稍后";
|
||||
"today.done" = "今天结束了";
|
||||
"today.doneBody" = "今天没有其他安排了。";
|
||||
"today.free" = "空闲日";
|
||||
"today.freeBody" = "今天没有安排,好好享受。";
|
||||
"today.plan" = "今日行程";
|
||||
"day.of" = "第 %1$d 天,共 %2$d 天";
|
||||
"day.empty" = "没有安排";
|
||||
"time.now" = "现在";
|
||||
"time.today" = "今天";
|
||||
"time.tomorrow" = "明天";
|
||||
"countdown.title" = "下次旅行";
|
||||
"booking.next" = "下一个预订";
|
||||
"booking.all" = "预订";
|
||||
"booking.none" = "没有即将到来的预订";
|
||||
"booking.confirmed" = "已确认";
|
||||
"booking.pending" = "待确认";
|
||||
"booking.seat" = "座位";
|
||||
"booking.platform" = "站台";
|
||||
"booking.ref" = "预订号";
|
||||
"booking.checkIn" = "入住";
|
||||
"booking.checkOut" = "退房";
|
||||
"booking.departs" = "出发";
|
||||
"booking.arrives" = "到达";
|
||||
"kind.flight" = "航班";
|
||||
"kind.train" = "火车";
|
||||
"kind.bus" = "巴士";
|
||||
"kind.car" = "汽车";
|
||||
"kind.cruise" = "渡轮";
|
||||
"kind.hotel" = "酒店";
|
||||
"kind.restaurant" = "餐厅";
|
||||
"kind.event" = "活动";
|
||||
"kind.tour" = "游览";
|
||||
"kind.other" = "预订";
|
||||
"trip.title" = "旅行";
|
||||
"trip.days" = "日程";
|
||||
"place.title" = "地点";
|
||||
"place.directions" = "路线";
|
||||
"lists.title" = "清单";
|
||||
"packing.title" = "行李";
|
||||
"packing.packed" = "已装好";
|
||||
"packing.packedSection" = "已装好";
|
||||
"packing.allPacked" = "全部装好了";
|
||||
"packing.other" = "其他";
|
||||
"todos.title" = "待办";
|
||||
"todos.open" = "未完成";
|
||||
"todos.doneSection" = "已完成";
|
||||
"todos.allDone" = "全部完成";
|
||||
"empty.sync.title" = "在 iPhone 上打开 TREK";
|
||||
"empty.sync.body" = "iPhone 上的 App 同步后,你的旅行会显示在这里。";
|
||||
"empty.trip.title" = "没有计划中的旅行";
|
||||
"empty.trip.body" = "在 TREK 中计划下一次旅行,它就会显示在这里。";
|
||||
"complication.nextStop.name" = "下一站";
|
||||
"complication.nextStop.description" = "今天的下一站及开始时间。";
|
||||
"complication.countdown.name" = "倒计时";
|
||||
"complication.countdown.description" = "距下一个航班、火车、入住或旅行的时间。";
|
||||
"complication.tripDay.name" = "旅行第几天";
|
||||
"complication.tripDay.description" = "今天是旅行的第几天。";
|
||||
"complication.noTrip" = "没有旅行";
|
||||
"a11y.done" = "已完成";
|
||||
"a11y.open" = "未完成";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "今天";
|
||||
"today.next" = "下一站";
|
||||
"today.now" = "進行中";
|
||||
"today.later" = "稍後";
|
||||
"today.done" = "今天結束了";
|
||||
"today.doneBody" = "今天沒有其他安排了。";
|
||||
"today.free" = "空閒日";
|
||||
"today.freeBody" = "今天沒有安排,好好享受。";
|
||||
"today.plan" = "今日行程";
|
||||
"day.of" = "第 %1$d 天,共 %2$d 天";
|
||||
"day.empty" = "沒有安排";
|
||||
"time.now" = "現在";
|
||||
"time.today" = "今天";
|
||||
"time.tomorrow" = "明天";
|
||||
"countdown.title" = "下次旅行";
|
||||
"booking.next" = "下一個預訂";
|
||||
"booking.all" = "預訂";
|
||||
"booking.none" = "沒有即將到來的預訂";
|
||||
"booking.confirmed" = "已確認";
|
||||
"booking.pending" = "待確認";
|
||||
"booking.seat" = "座位";
|
||||
"booking.platform" = "月台";
|
||||
"booking.ref" = "訂位代號";
|
||||
"booking.checkIn" = "入住";
|
||||
"booking.checkOut" = "退房";
|
||||
"booking.departs" = "出發";
|
||||
"booking.arrives" = "抵達";
|
||||
"kind.flight" = "航班";
|
||||
"kind.train" = "火車";
|
||||
"kind.bus" = "巴士";
|
||||
"kind.car" = "汽車";
|
||||
"kind.cruise" = "渡輪";
|
||||
"kind.hotel" = "飯店";
|
||||
"kind.restaurant" = "餐廳";
|
||||
"kind.event" = "活動";
|
||||
"kind.tour" = "導覽";
|
||||
"kind.other" = "預訂";
|
||||
"trip.title" = "旅行";
|
||||
"trip.days" = "行程天數";
|
||||
"place.title" = "地點";
|
||||
"place.directions" = "路線";
|
||||
"lists.title" = "清單";
|
||||
"packing.title" = "行李";
|
||||
"packing.packed" = "已裝好";
|
||||
"packing.packedSection" = "已裝好";
|
||||
"packing.allPacked" = "全部裝好了";
|
||||
"packing.other" = "其他";
|
||||
"todos.title" = "待辦";
|
||||
"todos.open" = "未完成";
|
||||
"todos.doneSection" = "已完成";
|
||||
"todos.allDone" = "全部完成";
|
||||
"empty.sync.title" = "在 iPhone 上打開 TREK";
|
||||
"empty.sync.body" = "iPhone 上的 App 同步後,你的旅行會顯示在這裡。";
|
||||
"empty.trip.title" = "沒有計畫中的旅行";
|
||||
"empty.trip.body" = "在 TREK 中計畫下一次旅行,它就會顯示在這裡。";
|
||||
"complication.nextStop.name" = "下一站";
|
||||
"complication.nextStop.description" = "今天的下一站及開始時間。";
|
||||
"complication.countdown.name" = "倒數計時";
|
||||
"complication.countdown.description" = "距下一個航班、火車、入住或旅行的時間。";
|
||||
"complication.tripDay.name" = "旅行第幾天";
|
||||
"complication.tripDay.description" = "今天是旅行的第幾天。";
|
||||
"complication.noTrip" = "沒有旅行";
|
||||
"a11y.done" = "已完成";
|
||||
"a11y.open" = "未完成";
|
||||
@@ -0,0 +1,356 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// The sizes the complications draw for. Their own enum rather than
|
||||
/// WidgetKit's, so the watch app's screenshot mode can lay them out too.
|
||||
enum ComplicationFamily: String, CaseIterable {
|
||||
case circular, rectangular, corner, inline
|
||||
|
||||
var widgetFamily: WidgetFamily {
|
||||
switch self {
|
||||
case .circular: return .accessoryCircular
|
||||
case .rectangular: return .accessoryRectangular
|
||||
case .corner: return .accessoryCorner
|
||||
case .inline: return .accessoryInline
|
||||
}
|
||||
}
|
||||
|
||||
init?(_ family: WidgetFamily) {
|
||||
switch family {
|
||||
case .accessoryCircular: self = .circular
|
||||
case .accessoryRectangular: self = .rectangular
|
||||
case .accessoryCorner: self = .corner
|
||||
case .accessoryInline: self = .inline
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which complication an idle face belongs to: each says something of its own.
|
||||
enum ComplicationKind { case nextStop, countdown, tripDay }
|
||||
|
||||
/// What the complications read from a snapshot at a moment.
|
||||
struct ComplicationState {
|
||||
struct NextStop {
|
||||
var stop: WatchSnapshot.Stop
|
||||
var start: Date?
|
||||
var isNow: Bool
|
||||
}
|
||||
|
||||
struct Countdown {
|
||||
var title: String
|
||||
var detail: String?
|
||||
var symbol: String
|
||||
var color: Color
|
||||
var target: Date
|
||||
}
|
||||
|
||||
var tripTitle: String?
|
||||
var phase: WatchSnapshot.Phase
|
||||
var next: NextStop?
|
||||
var countdown: Countdown?
|
||||
var hour12: Bool
|
||||
|
||||
init(_ snapshot: WatchSnapshot?, now: Date) {
|
||||
tripTitle = snapshot?.trip?.title
|
||||
phase = snapshot?.phase(at: now) ?? .none
|
||||
hour12 = snapshot?.hour12 ?? false
|
||||
let minutes = TrekDate.minutesOfDay(now)
|
||||
if let day = snapshot?.today(at: now) {
|
||||
let current = day.stops.first { ($0.minutes ?? .max) <= minutes && minutes < ($0.endMinutes ?? -1) }
|
||||
let upcoming = day.stops.first { ($0.minutes ?? -1) >= minutes }
|
||||
// A day planned without times still has its places: the first of them, not "day done".
|
||||
let untimed = day.stops.first { $0.minutes == nil }
|
||||
if let stop = current ?? upcoming ?? untimed {
|
||||
next = NextStop(stop: stop, start: stop.minutes.map { TrekDate.at($0, on: now) }, isNow: current != nil)
|
||||
}
|
||||
}
|
||||
// Only bookings with a time: one that lasts the whole day has no moment to count down to.
|
||||
if let booking = snapshot?.bookings.first(where: { !$0.isAllDay && ($0.start ?? .distantPast) > now }), let start = booking.start {
|
||||
let look = Trek.booking(booking.kind)
|
||||
countdown = Countdown(title: booking.route ?? booking.title, detail: booking.route == nil ? nil : booking.carrier,
|
||||
symbol: look.symbol, color: look.color, target: start)
|
||||
} else if let trip = snapshot?.trip, case .upcoming = phase, let start = TrekDate.day(trip.start) {
|
||||
countdown = Countdown(title: trip.title, detail: trip.place, symbol: "suitcase.rolling.fill", color: Trek.info, target: start)
|
||||
}
|
||||
}
|
||||
|
||||
/// The moments within `window` a complication should redraw at: the start
|
||||
/// and end of every stop on every day of the plan in it, every timed
|
||||
/// booking, and each midnight.
|
||||
static func changes(_ snapshot: WatchSnapshot?, from now: Date, window: TimeInterval = 86_400 * 2) -> [Date] {
|
||||
guard let snapshot else { return [] }
|
||||
let horizon = now.addingTimeInterval(window)
|
||||
var dates: [Date] = []
|
||||
for day in snapshot.days {
|
||||
guard let date = day.dateValue, date <= horizon, date >= Calendar.current.startOfDay(for: now) else { continue }
|
||||
for stop in day.stops {
|
||||
if let start = stop.minutes { dates.append(TrekDate.at(start, on: date)) }
|
||||
if let end = stop.endMinutes { dates.append(TrekDate.at(end, on: date)) }
|
||||
}
|
||||
}
|
||||
dates += snapshot.bookings.filter { !$0.isAllDay }.compactMap(\.start)
|
||||
var midnight = Calendar.current.startOfDay(for: now)
|
||||
while let next = Calendar.current.date(byAdding: .day, value: 1, to: midnight), next <= horizon {
|
||||
dates.append(next)
|
||||
midnight = next
|
||||
}
|
||||
return dates.filter { $0 > now && $0 <= horizon }.sorted()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Next stop
|
||||
|
||||
struct NextStopComplication: View {
|
||||
let state: ComplicationState
|
||||
let family: ComplicationFamily
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
if let next = state.next {
|
||||
let time = next.stop.minutes.map { WatchFormat.time(minutes: $0, hour12: state.hour12) }
|
||||
switch family {
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: WatchLook.stopSymbol(next.stop)).widgetAccentable()
|
||||
Text(next.isNow ? L("today.now") : (time ?? L("today.next")))
|
||||
.fontWeight(.semibold)
|
||||
.widgetAccentable()
|
||||
if let start = next.start, !next.isNow, start > now {
|
||||
// Counts down by itself between timeline entries.
|
||||
Text(start, style: .relative).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.font(.system(.footnote, design: .rounded))
|
||||
Text(next.stop.name)
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
if case .running(let day, let total) = state.phase {
|
||||
Text(WatchFormat.dayOf(day, total))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .circular:
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 0) {
|
||||
Image(systemName: WatchLook.stopSymbol(next.stop))
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
Text(time ?? "")
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded).monospacedDigit())
|
||||
.minimumScaleFactor(0.6)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
case .corner:
|
||||
Image(systemName: WatchLook.stopSymbol(next.stop))
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
.widgetLabel { Text([time, next.stop.name].compactMap { $0 }.joined(separator: " ")) }
|
||||
case .inline:
|
||||
Label([time, next.stop.name].compactMap { $0 }.joined(separator: " "), systemImage: WatchLook.stopSymbol(next.stop))
|
||||
}
|
||||
} else {
|
||||
ComplicationIdle(state: state, family: family, kind: .nextStop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Countdown
|
||||
|
||||
struct CountdownComplication: View {
|
||||
let state: ComplicationState
|
||||
let family: ComplicationFamily
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
if let countdown = state.countdown {
|
||||
switch family {
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: countdown.symbol).widgetAccentable()
|
||||
Text(countdown.title).fontWeight(.semibold).lineLimit(1)
|
||||
}
|
||||
.font(.system(.footnote, design: .rounded))
|
||||
Text(countdown.target, style: .relative)
|
||||
.font(.system(.title3, design: .rounded).weight(.bold).monospacedDigit())
|
||||
.widgetAccentable()
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
if let detail = countdown.detail {
|
||||
Text(detail).font(.system(.caption2, design: .rounded)).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .circular:
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 0) {
|
||||
Image(systemName: countdown.symbol).font(.system(size: 14, weight: .semibold)).widgetAccentable()
|
||||
Text(short(countdown.target))
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded).monospacedDigit())
|
||||
.minimumScaleFactor(0.5)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
case .corner:
|
||||
Image(systemName: countdown.symbol)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
.widgetLabel { Text(countdown.target, style: .relative) }
|
||||
case .inline:
|
||||
Label {
|
||||
Text("\(countdown.title) · ") + Text(countdown.target, style: .relative)
|
||||
} icon: {
|
||||
Image(systemName: countdown.symbol)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ComplicationIdle(state: state, family: family, kind: .countdown)
|
||||
}
|
||||
}
|
||||
|
||||
/// The single biggest unit: "2h", "35m", "12d".
|
||||
private func short(_ target: Date) -> String {
|
||||
let formatter = WatchFormat.components()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
formatter.maximumUnitCount = 1
|
||||
let seconds = max(target.timeIntervalSince(now), 60)
|
||||
formatter.allowedUnits = seconds >= 86_400 ? [.day] : seconds >= 3_600 ? [.hour] : [.minute]
|
||||
return formatter.string(from: seconds) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip day
|
||||
|
||||
struct TripDayComplication: View {
|
||||
let state: ComplicationState
|
||||
let family: ComplicationFamily
|
||||
|
||||
var body: some View {
|
||||
switch state.phase {
|
||||
case .running(let day, let total):
|
||||
running(day, total)
|
||||
case .upcoming(let days):
|
||||
upcoming(days)
|
||||
default:
|
||||
ComplicationIdle(state: state, family: family, kind: .tripDay)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func running(_ day: Int, _ total: Int) -> some View {
|
||||
switch family {
|
||||
case .circular:
|
||||
Gauge(value: Double(day), in: 0...Double(max(total, 1))) {
|
||||
Image(systemName: "suitcase.rolling.fill")
|
||||
} currentValueLabel: {
|
||||
Text("\(day)").font(.system(.title3, design: .rounded).weight(.bold))
|
||||
}
|
||||
.gaugeStyle(.accessoryCircularCapacity)
|
||||
.widgetAccentable()
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(WatchFormat.dayOf(day, total))
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.widgetAccentable()
|
||||
Text(state.tripTitle ?? "")
|
||||
.font(.system(.footnote, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
DaySegments(index: day, total: total, color: .white, track: .white.opacity(0.25))
|
||||
.widgetAccentable()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .corner:
|
||||
Text("\(day)")
|
||||
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||
.widgetAccentable()
|
||||
.widgetLabel {
|
||||
Gauge(value: Double(day), in: 0...Double(max(total, 1))) { Text(state.tripTitle ?? "") }
|
||||
}
|
||||
case .inline:
|
||||
Label(WatchFormat.dayOf(day, total), systemImage: "suitcase.rolling.fill")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func upcoming(_ days: Int) -> some View {
|
||||
let unit = WatchFormat.daysUnit(days)
|
||||
switch family {
|
||||
case .circular:
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: -2) {
|
||||
Text("\(days)").font(.system(size: 20, weight: .heavy, design: .rounded)).widgetAccentable()
|
||||
Text(unit).font(.system(size: 9, weight: .semibold, design: .rounded)).lineLimit(1).minimumScaleFactor(0.6)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(L("countdown.title")).font(.system(.caption2, design: .rounded).weight(.semibold)).foregroundStyle(.secondary)
|
||||
Text(state.tripTitle ?? "").font(.system(.headline, design: .rounded)).lineLimit(1)
|
||||
Text("\(days) \(unit)").font(.system(.title3, design: .rounded).weight(.bold)).widgetAccentable()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .corner:
|
||||
Image(systemName: "suitcase.rolling.fill")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
.widgetLabel { Text("\(days) \(unit)") }
|
||||
case .inline:
|
||||
Label("\(state.tripTitle ?? "") · \(days) \(unit)", systemImage: "suitcase.rolling.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing to count down or show: the TREK mark, and a word when there is room.
|
||||
struct ComplicationIdle: View {
|
||||
let state: ComplicationState
|
||||
let family: ComplicationFamily
|
||||
let kind: ComplicationKind
|
||||
|
||||
/// What an idle face says: before the trip how far off it is, while it runs
|
||||
/// "day done" for the next stop and the day of the trip for the rest, and
|
||||
/// "no trip" outside of one.
|
||||
private var text: String {
|
||||
switch state.phase {
|
||||
case .upcoming(let days):
|
||||
return "\(state.tripTitle ?? "") · \(days) \(WatchFormat.daysUnit(days))"
|
||||
case .running(let day, let total):
|
||||
return kind == .nextStop ? L("today.done") : WatchFormat.dayOf(day, total)
|
||||
case .past, .none:
|
||||
return L("complication.noTrip")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .circular:
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
TrekMark().frame(width: 22, height: 22).widgetAccentable()
|
||||
}
|
||||
case .rectangular:
|
||||
HStack(spacing: 8) {
|
||||
TrekMark().frame(width: 26, height: 26).widgetAccentable()
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("TREK").font(.system(.headline, design: .rounded))
|
||||
Text(text).font(.system(.footnote, design: .rounded)).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .corner:
|
||||
TrekMark().frame(width: 20, height: 20).widgetAccentable().widgetLabel { Text(text) }
|
||||
case .inline:
|
||||
Text("TREK · \(text)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import Foundation
|
||||
|
||||
/// A trip to Kyoto, the one the widgets' gallery shows too: what the
|
||||
/// complication gallery draws before the phone sent anything, and what the
|
||||
/// screenshot mode of the watch app runs on (TREKWatch/DemoMode.swift).
|
||||
enum WatchSample {
|
||||
enum Scenario: String {
|
||||
/// Day 3 of 9, early afternoon.
|
||||
case running
|
||||
/// Day 1, the flight out leaves in the afternoon.
|
||||
case travelday
|
||||
/// Twelve days before the trip.
|
||||
case upcoming
|
||||
}
|
||||
|
||||
static func snapshot(_ scenario: Scenario, now: Date = Date()) -> WatchSnapshot {
|
||||
let startOffset: Int
|
||||
switch scenario {
|
||||
case .running: startOffset = -2
|
||||
case .travelday: startOffset = 0
|
||||
case .upcoming: startOffset = 12
|
||||
}
|
||||
let calendar = Calendar.current
|
||||
let iso: (Int) -> String = { TrekDate.iso(calendar.date(byAdding: .day, value: startOffset + $0, to: now) ?? now) }
|
||||
// The flight out leaves in the afternoon, so the screenshot mode can run
|
||||
// every scenario at about one o'clock, the hour the capture sets its clock to.
|
||||
let stops: [[WatchSnapshot.Stop]] = [
|
||||
[
|
||||
stop("LH 716", 15 * 60 + 35, symbol: "airplane", color: nil, booking: "flight", lat: 50.0379, lng: 8.5622, address: "Frankfurt Airport"),
|
||||
],
|
||||
[
|
||||
stop("Shinkansen Nozomi 21", 13 * 60 + 33, symbol: "tram.fill", color: nil, booking: "train", lat: 35.6812, lng: 139.7671, address: "Tokyo Station"),
|
||||
stop("Hotel Kanra Kyoto", 16 * 60 + 30, symbol: "bed.double.fill", color: nil, booking: "hotel", lat: 34.9922, lng: 135.7585, address: "190 Kitamachi, Shimogyo Ward, Kyoto"),
|
||||
],
|
||||
[
|
||||
stop("Fushimi Inari Taisha", 8 * 60 + 30, end: 10 * 60 + 30, symbol: "building.columns.fill", color: "#D6273B", lat: 34.9671, lng: 135.7727, address: "68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto"),
|
||||
stop(t("Nishiki Market"), 11 * 60, end: 12 * 60 + 30, symbol: "basket.fill", color: "#E8A13A", lat: 35.0050, lng: 135.7649, address: "Nishikikoji-dori, Nakagyo Ward, Kyoto"),
|
||||
stop("Kiyomizu-dera", 13 * 60 + 30, end: 15 * 60 + 30, symbol: "leaf.fill", color: "#2FA37A", lat: 34.9949, lng: 135.7850, address: "1-294 Kiyomizu, Higashiyama Ward, Kyoto", note: t("Go up the Sannenzaka steps for the view.")),
|
||||
stop(t("Gion walk"), 16 * 60, symbol: "figure.walk", color: "#4A7DDB", lat: 35.0037, lng: 135.7750, address: "Hanamikoji-dori, Higashiyama Ward, Kyoto"),
|
||||
stop(t("Kaiseki at Kikunoi"), 19 * 60, symbol: "fork.knife", color: nil, booking: "restaurant", lat: 34.9989, lng: 135.7812, address: "459 Shimokawaracho, Higashiyama Ward, Kyoto"),
|
||||
],
|
||||
[
|
||||
stop(t("Arashiyama Bamboo Grove"), 9 * 60, symbol: "tree.fill", color: "#2FA37A", lat: 35.0170, lng: 135.6713, address: "Sagaogurayama Tabuchiyamacho, Kyoto"),
|
||||
stop("Tenryu-ji", 10 * 60 + 30, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0157, lng: 135.6738, address: "68 Sagatenryuji Susukinobabacho, Kyoto"),
|
||||
stop("Kinkaku-ji", 14 * 60, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0394, lng: 135.7292, address: "1 Kinkakujicho, Kita Ward, Kyoto"),
|
||||
],
|
||||
[stop(t("Nara Park"), 10 * 60, symbol: "leaf.fill", color: "#2FA37A", lat: 34.6851, lng: 135.8430, address: "Nara"), stop("Todai-ji", 11 * 60 + 30, symbol: "building.columns.fill", color: "#D6273B", lat: 34.6890, lng: 135.8398, address: "406-1 Zoshicho, Nara")],
|
||||
[stop(t("Philosopher's Path"), 9 * 60 + 30, symbol: "figure.walk", color: "#4A7DDB", lat: 35.0266, lng: 135.7948, address: "Sakyo Ward, Kyoto"), stop("Ginkaku-ji", 11 * 60, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0270, lng: 135.7982, address: "2 Ginkakujicho, Sakyo Ward, Kyoto")],
|
||||
[stop(t("Tea ceremony"), 10 * 60 + 30, symbol: "ticket.fill", color: nil, booking: "event", lat: 35.0036, lng: 135.7788, address: "Camellia Flower, Higashiyama")],
|
||||
[stop(t("Fushimi sake district"), 14 * 60, symbol: "wineglass.fill", color: "#8E6CD9", lat: 34.9318, lng: 135.7601, address: "Fushimi Ward, Kyoto"), stop(t("Pontocho alley"), 18 * 60 + 30, symbol: "fork.knife", color: "#E8A13A", lat: 35.0050, lng: 135.7708, address: "Pontocho, Nakagyo Ward, Kyoto")],
|
||||
[stop(t("Shinkansen to Osaka"), 9 * 60 + 12, symbol: "tram.fill", color: nil, booking: "train", lat: 34.9858, lng: 135.7588, address: "Kyoto Station"), stop("LH 741", 13 * 60 + 25, symbol: "airplane", color: nil, booking: "flight", lat: 34.4320, lng: 135.2304, address: "Kansai International Airport")],
|
||||
]
|
||||
let days = stops.enumerated().map { index, stops in
|
||||
WatchSnapshot.Day(date: iso(index), number: index + 1, title: nil, stops: stops)
|
||||
}
|
||||
let bookings: [WatchSnapshot.Booking] = [
|
||||
WatchSnapshot.Booking(
|
||||
id: 1, title: "LH 716", kind: "flight", confirmed: true, date: iso(0), time: "15:35", endDate: iso(1), endTime: "11:20",
|
||||
zone: nil, endZone: nil, from: .init(name: "Frankfurt", code: "FRA", lat: 50.0379, lng: 8.5622),
|
||||
to: .init(name: "Tokyo Haneda", code: "HND", lat: 35.5494, lng: 139.7798), code: "K7Q2XD",
|
||||
carrier: "Lufthansa LH 716", seat: "42A", platform: nil, location: nil, lat: nil, lng: nil, checkIn: nil, checkOut: nil
|
||||
),
|
||||
WatchSnapshot.Booking(
|
||||
id: 7, title: "Shinkansen Nozomi 21", kind: "train", confirmed: true, date: iso(1), time: "13:33", endDate: iso(1), endTime: "15:48",
|
||||
zone: nil, endZone: nil, from: .init(name: "Tokyo", code: nil, lat: 35.6812, lng: 139.7671), to: .init(name: "Kyoto", code: nil, lat: 34.9858, lng: 135.7588),
|
||||
code: "JR-5521", carrier: "JR Nozomi 21", seat: "7D", platform: "14", location: nil, lat: nil, lng: nil, checkIn: nil, checkOut: nil
|
||||
),
|
||||
WatchSnapshot.Booking(
|
||||
id: 2, title: "Hotel Kanra Kyoto", kind: "hotel", confirmed: true, date: iso(1), time: "16:30", endDate: iso(7), endTime: "11:00",
|
||||
zone: nil, endZone: nil, from: nil, to: nil, code: "HK-208431", carrier: nil, seat: nil, platform: nil,
|
||||
location: "Hotel Kanra Kyoto", lat: 34.9922, lng: 135.7585, checkIn: "15:00", checkOut: "11:00"
|
||||
),
|
||||
WatchSnapshot.Booking(
|
||||
id: 3, title: t("Kaiseki at Kikunoi"), kind: "restaurant", confirmed: false, date: iso(2), time: "19:00", endDate: nil, endTime: nil,
|
||||
zone: nil, endZone: nil, from: nil, to: nil, code: "KK-77", carrier: nil, seat: nil, platform: nil,
|
||||
location: "459 Shimokawaracho, Higashiyama", lat: 34.9989, lng: 135.7812, checkIn: nil, checkOut: nil
|
||||
),
|
||||
WatchSnapshot.Booking(
|
||||
id: 4, title: t("Tea ceremony"), kind: "event", confirmed: true, date: iso(5), time: "10:30", endDate: nil, endTime: nil,
|
||||
zone: nil, endZone: nil, from: nil, to: nil, code: nil, carrier: nil, seat: nil, platform: nil,
|
||||
location: "Camellia Flower", lat: 35.0036, lng: 135.7788, checkIn: nil, checkOut: nil
|
||||
),
|
||||
WatchSnapshot.Booking(
|
||||
id: 5, title: "Shinkansen Hikari 503", kind: "train", confirmed: true, date: iso(8), time: "09:12", endDate: iso(8), endTime: "09:27",
|
||||
zone: nil, endZone: nil, from: .init(name: "Kyoto", code: nil, lat: 34.9858, lng: 135.7588), to: .init(name: "Shin-Osaka", code: nil, lat: 34.7334, lng: 135.5001),
|
||||
code: "E4521", carrier: "JR Hikari 503", seat: "7D", platform: "12", location: nil, lat: nil, lng: nil, checkIn: nil, checkOut: nil
|
||||
),
|
||||
WatchSnapshot.Booking(
|
||||
id: 6, title: "LH 741", kind: "flight", confirmed: true, date: iso(8), time: "13:25", endDate: iso(8), endTime: "19:05",
|
||||
zone: nil, endZone: nil, from: .init(name: "Osaka Kansai", code: "KIX", lat: 34.4320, lng: 135.2304),
|
||||
to: .init(name: "Frankfurt", code: "FRA", lat: 50.0379, lng: 8.5622), code: "K7Q2XD",
|
||||
carrier: "Lufthansa LH 741", seat: "38C", platform: nil, location: nil, lat: nil, lng: nil, checkIn: nil, checkOut: nil
|
||||
),
|
||||
]
|
||||
let packingItems: [(String, String, Bool)] = [
|
||||
(t("Passport"), t("Documents"), true), (t("JR Pass voucher"), t("Documents"), true), (t("Travel insurance"), t("Documents"), true),
|
||||
(t("Rain jacket"), t("Clothes"), false), (t("Walking shoes"), t("Clothes"), true), (t("Warm sweater"), t("Clothes"), false), (t("Socks"), t("Clothes"), true),
|
||||
(t("Camera"), t("Tech"), true), (t("Power bank"), t("Tech"), false), (t("Type A adapter"), t("Tech"), false),
|
||||
(t("Toothbrush"), t("Toiletries"), false), (t("Sunscreen"), t("Toiletries"), true),
|
||||
]
|
||||
var packingList: [WatchSnapshot.Item] = []
|
||||
for (offset, entry) in packingItems.enumerated() {
|
||||
packingList.append(WatchSnapshot.Item(id: 100 + offset, name: entry.0, done: entry.2, group: entry.1))
|
||||
}
|
||||
let packing = WatchSnapshot.Packing(
|
||||
items: packingList.filter { !$0.done } + packingList.filter(\.done),
|
||||
packed: packingList.filter(\.done).count,
|
||||
total: packingList.count
|
||||
)
|
||||
// Due dates in days from now, priority, done.
|
||||
let todoItems: [(String, Int?, Int, Bool)] = [
|
||||
(t("Reserve the Ghibli Museum"), 2, 3, false), (t("Buy a Suica card"), nil, 2, false),
|
||||
(t("Book the tea ceremony"), nil, 1, false), (t("Print the hotel voucher"), nil, 0, false),
|
||||
(t("Order yen"), -1, 0, true), (t("Renew passport"), -20, 0, true),
|
||||
]
|
||||
var todoList: [WatchSnapshot.Item] = []
|
||||
for (offset, entry) in todoItems.enumerated() {
|
||||
let due: String? = entry.1.map { iso($0 - startOffset) }
|
||||
todoList.append(WatchSnapshot.Item(id: 200 + offset, name: entry.0, done: entry.3, group: nil, due: due, priority: entry.2))
|
||||
}
|
||||
let todos = WatchSnapshot.Todos(items: todoList, open: todoItems.filter { !$0.3 }.count, total: todoItems.count)
|
||||
return WatchSnapshot(
|
||||
generatedAt: ISO8601DateFormatter().string(from: now.addingTimeInterval(-4 * 60)),
|
||||
// The clock of the watch's region, as a user's TREK setting would usually match it.
|
||||
hour12: DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: .current)?.contains("a") ?? false,
|
||||
trip: .init(id: 1, title: t("Autumn in Kyoto"), start: iso(0), end: iso(8), place: t("Kyoto, Japan"), cover: nil),
|
||||
days: days,
|
||||
bookings: bookings,
|
||||
weather: .init(temp: 18, high: 21, low: 11, main: scenario == .upcoming ? "Clouds" : "Clear", label: t("Mostly sunny"), place: "Kyoto", rain: 10),
|
||||
packing: packing,
|
||||
todos: todos,
|
||||
applied: []
|
||||
)
|
||||
}
|
||||
|
||||
/// The sample's own texts in German and Russian, so the screenshots in those
|
||||
/// languages show a trip written in them; every other language gets the English.
|
||||
private static let translations: [String: [String: String]] = [
|
||||
"Autumn in Kyoto": ["de": "Herbst in Kyoto", "ru": "Осень в Киото"],
|
||||
"Kyoto, Japan": ["de": "Kyoto, Japan", "ru": "Киото, Япония"],
|
||||
"Nishiki Market": ["de": "Nishiki-Markt", "ru": "Рынок Нисики"],
|
||||
"Gion walk": ["de": "Spaziergang durch Gion", "ru": "Прогулка по Гиону"],
|
||||
"Kaiseki at Kikunoi": ["de": "Kaiseki im Kikunoi", "ru": "Кайсэки в Kikunoi"],
|
||||
"Arashiyama Bamboo Grove": ["de": "Bambuswald Arashiyama", "ru": "Бамбуковая роща Арасиямы"],
|
||||
"Nara Park": ["de": "Nara-Park", "ru": "Парк Нара"],
|
||||
"Philosopher's Path": ["de": "Philosophenweg", "ru": "Тропа философа"],
|
||||
"Tea ceremony": ["de": "Teezeremonie", "ru": "Чайная церемония"],
|
||||
"Fushimi sake district": ["de": "Sake-Viertel Fushimi", "ru": "Саке-квартал Фусими"],
|
||||
"Pontocho alley": ["de": "Gasse Pontocho", "ru": "Улочка Понтотё"],
|
||||
"Shinkansen to Osaka": ["de": "Shinkansen nach Osaka", "ru": "Синкансэн в Осаку"],
|
||||
"Go up the Sannenzaka steps for the view.": ["de": "Für den Ausblick die Sannenzaka-Treppe hinauf.", "ru": "Ради вида поднимитесь по лестнице Саннэндзака."],
|
||||
"Mostly sunny": ["de": "Überwiegend sonnig", "ru": "Преимущественно солнечно"],
|
||||
"Passport": ["de": "Reisepass", "ru": "Паспорт"],
|
||||
"JR Pass voucher": ["de": "JR-Pass-Gutschein", "ru": "Ваучер JR Pass"],
|
||||
"Travel insurance": ["de": "Reiseversicherung", "ru": "Страховка"],
|
||||
"Rain jacket": ["de": "Regenjacke", "ru": "Дождевик"],
|
||||
"Walking shoes": ["de": "Wanderschuhe", "ru": "Удобная обувь"],
|
||||
"Warm sweater": ["de": "Warmer Pullover", "ru": "Тёплый свитер"],
|
||||
"Socks": ["de": "Socken", "ru": "Носки"],
|
||||
"Camera": ["de": "Kamera", "ru": "Фотоаппарат"],
|
||||
"Power bank": ["de": "Powerbank", "ru": "Пауэрбанк"],
|
||||
"Type A adapter": ["de": "Reiseadapter Typ A", "ru": "Переходник типа A"],
|
||||
"Toothbrush": ["de": "Zahnbürste", "ru": "Зубная щётка"],
|
||||
"Sunscreen": ["de": "Sonnencreme", "ru": "Солнцезащитный крем"],
|
||||
"Documents": ["de": "Dokumente", "ru": "Документы"],
|
||||
"Clothes": ["de": "Kleidung", "ru": "Одежда"],
|
||||
"Tech": ["de": "Technik", "ru": "Техника"],
|
||||
"Toiletries": ["de": "Kulturbeutel", "ru": "Гигиена"],
|
||||
"Reserve the Ghibli Museum": ["de": "Ghibli-Museum reservieren", "ru": "Забронировать музей Гибли"],
|
||||
"Buy a Suica card": ["de": "Suica-Karte kaufen", "ru": "Купить карту Suica"],
|
||||
"Book the tea ceremony": ["de": "Teezeremonie buchen", "ru": "Записаться на чайную церемонию"],
|
||||
"Print the hotel voucher": ["de": "Hotelgutschein drucken", "ru": "Распечатать ваучер отеля"],
|
||||
"Order yen": ["de": "Yen bestellen", "ru": "Заказать иены"],
|
||||
"Renew passport": ["de": "Reisepass verlängern", "ru": "Продлить паспорт"],
|
||||
]
|
||||
|
||||
private static func t(_ english: String) -> String {
|
||||
let language = Locale.current.language.languageCode?.identifier ?? "en"
|
||||
return translations[english]?[language] ?? english
|
||||
}
|
||||
|
||||
private static func stop(_ name: String, _ minutes: Int, end: Int? = nil, symbol: String, color: String?, booking: String? = nil,
|
||||
lat: Double, lng: Double, address: String, note: String? = nil) -> WatchSnapshot.Stop {
|
||||
WatchSnapshot.Stop(name: name, minutes: minutes, endMinutes: end, symbol: symbol, color: color, address: address,
|
||||
lat: lat, lng: lng, booking: booking, note: note)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import Foundation
|
||||
|
||||
/// What the iPhone app last sent the watch (client/src/native/watch.ts builds
|
||||
/// it, TrekWatchLink in the app passes it on through WatchConnectivity): the
|
||||
/// trip the dashboard features, in full, so the watch shows it without a
|
||||
/// network or a session of its own. Data only; every text the watch shows of
|
||||
/// its own comes from its Localizable.strings, and it formats times itself.
|
||||
///
|
||||
/// Everything past `v`, `generatedAt` and `trip` is decoded leniently, so a
|
||||
/// phone that is newer or older than this binary still gets through.
|
||||
struct WatchSnapshot: Codable, Equatable {
|
||||
struct Trip: Codable, Equatable {
|
||||
var id: Int
|
||||
var title: String
|
||||
var start: String
|
||||
var end: String
|
||||
var place: String?
|
||||
/// A small base64 JPEG of the cover, when the phone had one that fit.
|
||||
var cover: String?
|
||||
}
|
||||
|
||||
struct Stop: Codable, Equatable, Hashable {
|
||||
var name: String
|
||||
var minutes: Int?
|
||||
var endMinutes: Int?
|
||||
var symbol: String?
|
||||
var color: String?
|
||||
var address: String?
|
||||
var lat: Double?
|
||||
var lng: Double?
|
||||
/// The booking kind when the stop is a booking (flight, hotel, ...).
|
||||
var booking: String?
|
||||
var note: String?
|
||||
}
|
||||
|
||||
struct Day: Codable, Equatable, Hashable {
|
||||
var date: String?
|
||||
var number: Int
|
||||
var title: String?
|
||||
var stops: [Stop]
|
||||
}
|
||||
|
||||
struct Point: Codable, Equatable, Hashable {
|
||||
var name: String
|
||||
var code: String?
|
||||
var lat: Double?
|
||||
var lng: Double?
|
||||
}
|
||||
|
||||
struct Booking: Codable, Equatable, Hashable, Identifiable {
|
||||
var id: Int
|
||||
var title: String
|
||||
var kind: String
|
||||
var confirmed: Bool
|
||||
var date: String?
|
||||
var time: String?
|
||||
var endDate: String?
|
||||
var endTime: String?
|
||||
var zone: String?
|
||||
/// The zone the end time is local to (the arrival airport's), when the booking names one.
|
||||
var endZone: String?
|
||||
var from: Point?
|
||||
var to: Point?
|
||||
var code: String?
|
||||
var carrier: String?
|
||||
var seat: String?
|
||||
var platform: String?
|
||||
var location: String?
|
||||
var lat: Double?
|
||||
var lng: Double?
|
||||
var checkIn: String?
|
||||
var checkOut: String?
|
||||
}
|
||||
|
||||
struct Weather: Codable, Equatable {
|
||||
var temp: Int
|
||||
var high: Int?
|
||||
var low: Int?
|
||||
var main: String
|
||||
var label: String
|
||||
var place: String?
|
||||
var rain: Int?
|
||||
}
|
||||
|
||||
struct Item: Codable, Equatable, Hashable, Identifiable {
|
||||
var id: Int
|
||||
var name: String
|
||||
var done: Bool
|
||||
var group: String?
|
||||
var due: String?
|
||||
var priority: Int?
|
||||
}
|
||||
|
||||
struct Packing: Codable, Equatable {
|
||||
var items: [Item]
|
||||
var packed: Int
|
||||
var total: Int
|
||||
}
|
||||
|
||||
struct Todos: Codable, Equatable {
|
||||
var items: [Item]
|
||||
var open: Int
|
||||
var total: Int
|
||||
}
|
||||
|
||||
var v: Int
|
||||
var generatedAt: String
|
||||
var hour12: Bool
|
||||
var trip: Trip?
|
||||
var days: [Day]
|
||||
var bookings: [Booking]
|
||||
var weather: Weather?
|
||||
var packing: Packing?
|
||||
var todos: Todos?
|
||||
var applied: [String]
|
||||
|
||||
init(v: Int = 1, generatedAt: String, hour12: Bool = false, trip: Trip?, days: [Day] = [], bookings: [Booking] = [],
|
||||
weather: Weather? = nil, packing: Packing? = nil, todos: Todos? = nil, applied: [String] = []) {
|
||||
self.v = v
|
||||
self.generatedAt = generatedAt
|
||||
self.hour12 = hour12
|
||||
self.trip = trip
|
||||
self.days = days
|
||||
self.bookings = bookings
|
||||
self.weather = weather
|
||||
self.packing = packing
|
||||
self.todos = todos
|
||||
self.applied = applied
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
v = (try? c.decode(Int.self, forKey: .v)) ?? 1
|
||||
generatedAt = (try? c.decode(String.self, forKey: .generatedAt)) ?? ""
|
||||
hour12 = (try? c.decode(Bool.self, forKey: .hour12)) ?? false
|
||||
trip = try? c.decodeIfPresent(Trip.self, forKey: .trip)
|
||||
days = (try? c.decode([Day].self, forKey: .days)) ?? []
|
||||
bookings = (try? c.decode([Booking].self, forKey: .bookings)) ?? []
|
||||
weather = try? c.decodeIfPresent(Weather.self, forKey: .weather)
|
||||
packing = try? c.decodeIfPresent(Packing.self, forKey: .packing)
|
||||
todos = try? c.decodeIfPresent(Todos.self, forKey: .todos)
|
||||
applied = (try? c.decode([String].self, forKey: .applied)) ?? []
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> WatchSnapshot? {
|
||||
try? JSONDecoder().decode(WatchSnapshot.self, from: data)
|
||||
}
|
||||
|
||||
var generated: Date? {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter.date(from: generatedAt) ?? ISO8601DateFormatter().date(from: generatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dates
|
||||
|
||||
/// Days and times as the watch reads them: always in the time zone it is in
|
||||
/// now (it may have flown since the last launch), and times of day as wall-clock
|
||||
/// hours and minutes, never as minutes added to midnight, which are an hour off
|
||||
/// on the days the clocks change.
|
||||
enum TrekDate {
|
||||
/// The start of yyyy-MM-dd here.
|
||||
static func day(_ iso: String?) -> Date? {
|
||||
guard let iso, iso.count >= 10 else { return nil }
|
||||
let ymd = iso.prefix(10).split(separator: "-").compactMap { Int($0) }
|
||||
guard ymd.count == 3 else { return nil }
|
||||
return Calendar.current.date(from: DateComponents(year: ymd[0], month: ymd[1], day: ymd[2]))
|
||||
}
|
||||
|
||||
static func iso(_ date: Date) -> String {
|
||||
let parts = Calendar.current.dateComponents([.year, .month, .day], from: date)
|
||||
return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)
|
||||
}
|
||||
|
||||
/// yyyy-MM-dd and HH:mm as a moment, in `zone` when given, else here; midnight without a time.
|
||||
static func moment(_ date: String?, _ time: String?, zone: String? = nil) -> Date? {
|
||||
guard let date, date.count >= 10 else { return nil }
|
||||
let ymd = date.prefix(10).split(separator: "-").compactMap { Int($0) }
|
||||
guard ymd.count == 3 else { return nil }
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = zone.flatMap(TimeZone.init(identifier:)) ?? .current
|
||||
let m = minutes(time) ?? 0
|
||||
return calendar.date(from: DateComponents(year: ymd[0], month: ymd[1], day: ymd[2], hour: m / 60, minute: m % 60))
|
||||
}
|
||||
|
||||
/// "13:05" or "1:05 PM" to minutes after midnight.
|
||||
static func minutes(_ time: String?) -> Int? {
|
||||
guard let time else { return nil }
|
||||
let pieces = time.split(separator: ":")
|
||||
guard pieces.count >= 2, let hour = Int(pieces[0].trimmingCharacters(in: .whitespaces)),
|
||||
let minute = Int(pieces[1].prefix(2)) else { return nil }
|
||||
var h = hour
|
||||
let upper = time.uppercased()
|
||||
if upper.contains("PM"), h < 12 { h += 12 }
|
||||
if upper.contains("AM"), h == 12 { h = 0 }
|
||||
return h * 60 + minute
|
||||
}
|
||||
|
||||
static func daysBetween(_ from: Date, _ to: Date) -> Int {
|
||||
let calendar = Calendar.current
|
||||
return calendar.dateComponents([.day], from: calendar.startOfDay(for: from), to: calendar.startOfDay(for: to)).day ?? 0
|
||||
}
|
||||
|
||||
static func minutesOfDay(_ date: Date) -> Int {
|
||||
let parts = Calendar.current.dateComponents([.hour, .minute], from: date)
|
||||
return (parts.hour ?? 0) * 60 + (parts.minute ?? 0)
|
||||
}
|
||||
|
||||
/// The moment the clock shows `minutes` after midnight on `day`.
|
||||
static func at(_ minutes: Int, on day: Date) -> Date {
|
||||
let m = min(max(minutes, 0), 24 * 60 - 1)
|
||||
return Calendar.current.date(bySettingHour: m / 60, minute: m % 60, second: 0, of: day) ?? day
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - What the screens ask of a snapshot
|
||||
|
||||
extension WatchSnapshot {
|
||||
enum Phase: Equatable {
|
||||
/// The trip starts in this many days (0 = today, before its first day is in the plan).
|
||||
case upcoming(days: Int)
|
||||
/// The trip runs; the 1-based index of today and the number of days.
|
||||
case running(day: Int, of: Int)
|
||||
/// The trip is over.
|
||||
case past
|
||||
case none
|
||||
}
|
||||
|
||||
func phase(at now: Date) -> Phase {
|
||||
guard let trip, let start = TrekDate.day(trip.start) else { return .none }
|
||||
let end = TrekDate.day(trip.end) ?? start
|
||||
let untilStart = TrekDate.daysBetween(now, start)
|
||||
if untilStart > 0 { return .upcoming(days: untilStart) }
|
||||
if TrekDate.daysBetween(now, end) < 0 { return .past }
|
||||
let total = max(days.count, TrekDate.daysBetween(start, end) + 1, 1)
|
||||
let index = (todayIndex(at: now).map { days[$0].number }) ?? (TrekDate.daysBetween(start, now) + 1)
|
||||
return .running(day: min(max(index, 1), total), of: total)
|
||||
}
|
||||
|
||||
/// The position of today in `days`, when the plan has a day with today's date.
|
||||
func todayIndex(at now: Date) -> Int? {
|
||||
let iso = TrekDate.iso(now)
|
||||
return days.firstIndex { $0.date == iso }
|
||||
}
|
||||
|
||||
func today(at now: Date) -> Day? {
|
||||
todayIndex(at: now).map { days[$0] }
|
||||
}
|
||||
|
||||
var tripLength: Int {
|
||||
guard let trip, let start = TrekDate.day(trip.start), let end = TrekDate.day(trip.end) else { return max(days.count, 1) }
|
||||
return max(days.count, TrekDate.daysBetween(start, end) + 1, 1)
|
||||
}
|
||||
|
||||
/// The bookings that have not ended yet, soonest first.
|
||||
func bookingsAhead(at now: Date) -> [Booking] {
|
||||
let today = Calendar.current.startOfDay(for: now)
|
||||
return bookings.filter { booking in
|
||||
if let end = booking.end, booking.endTime != nil || booking.endDate != booking.date {
|
||||
if end >= now { return true }
|
||||
}
|
||||
guard let day = TrekDate.day(booking.date) else { return true }
|
||||
guard let start = booking.start, booking.time != nil else { return day >= today }
|
||||
return start >= now.addingTimeInterval(-30 * 60)
|
||||
}
|
||||
}
|
||||
|
||||
/// The next booking worth a countdown: one not yet started, else a running stay.
|
||||
func nextBooking(at now: Date) -> Booking? {
|
||||
let ahead = bookingsAhead(at: now)
|
||||
return ahead.first { booking in
|
||||
guard let start = booking.start else { return false }
|
||||
return start >= now.addingTimeInterval(-30 * 60)
|
||||
} ?? ahead.first
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchSnapshot.Booking {
|
||||
var start: Date? { TrekDate.moment(date, time, zone: zone) }
|
||||
/// The end in the arrival's zone; a ride without one ends in the zone the watch is in, never the departure's.
|
||||
var end: Date? { TrekDate.moment(endDate ?? date, endTime, zone: endZone ?? (isTransport ? nil : zone)) }
|
||||
|
||||
/// A booking with a day but no time lasts the whole day: there is nothing to count down to.
|
||||
var isAllDay: Bool { time == nil }
|
||||
|
||||
var isTransport: Bool { ["flight", "train", "bus", "car", "cruise"].contains(kind) }
|
||||
|
||||
/// "FRA → HND" from the codes, else the names, for a transport with both ends.
|
||||
var route: String? {
|
||||
guard let from, let to else { return nil }
|
||||
return "\(from.code ?? from.name) → \(to.code ?? to.name)"
|
||||
}
|
||||
|
||||
/// Where Directions goes: the departure for a ride, the place itself for the rest.
|
||||
var destination: (name: String, lat: Double, lng: Double)? {
|
||||
if isTransport, let from, let lat = from.lat, let lng = from.lng { return (from.name, lat, lng) }
|
||||
if let lat, let lng { return (location ?? title, lat, lng) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchSnapshot.Day {
|
||||
var dateValue: Date? { TrekDate.day(date) }
|
||||
}
|
||||
|
||||
extension WatchSnapshot.Stop {
|
||||
var hasLocation: Bool { lat != nil && lng != nil }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// A packing item or to-do ticked on the watch and not yet confirmed by the
|
||||
/// phone. It goes to the phone through WatchConnectivity (guaranteed, queued
|
||||
/// delivery) and stays laid over the snapshot until the phone's next snapshot
|
||||
/// shows it applied, so a tick never flips back while the phone catches up.
|
||||
struct PendingAction: Codable, Equatable, Identifiable {
|
||||
enum List: String, Codable { case packing, todo }
|
||||
|
||||
var id: String
|
||||
var tripId: Int
|
||||
var list: List
|
||||
var itemId: Int
|
||||
var done: Bool
|
||||
var at: Date
|
||||
/// Set once WatchConnectivity has handed the tick to the phone; such a tick
|
||||
/// is never sent again, it only stays laid over the snapshot until confirmed.
|
||||
var sent: Bool?
|
||||
|
||||
/// What the phone receives (parseWatchActions in client/src/native/watch.ts).
|
||||
var message: [String: Any] {
|
||||
["id": id, "tripId": tripId, "list": list.rawValue, "itemId": itemId, "done": done]
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the watch keeps the last snapshot and its pending ticks: the keychain
|
||||
/// group the watch app shares with its complications (no App Group needed,
|
||||
/// the same way the phone shares the widget snapshot). Nothing goes to
|
||||
/// UserDefaults, which the complications could not read anyway.
|
||||
enum WatchStore {
|
||||
private static let service = "com.liketrek.trek.watch"
|
||||
private static let snapshotAccount = "watch.snapshot"
|
||||
private static let pendingAccount = "watch.pending"
|
||||
|
||||
static func loadSnapshot() -> WatchSnapshot? {
|
||||
read(snapshotAccount).flatMap(WatchSnapshot.decode)
|
||||
}
|
||||
|
||||
static func saveSnapshot(_ data: Data) {
|
||||
write(snapshotAccount, data)
|
||||
}
|
||||
|
||||
static func loadPending() -> [PendingAction] {
|
||||
guard let data = read(pendingAccount) else { return [] }
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .secondsSince1970
|
||||
return (try? decoder.decode([PendingAction].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
static func savePending(_ actions: [PendingAction]) {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .secondsSince1970
|
||||
guard let data = try? encoder.encode(actions) else { return }
|
||||
write(pendingAccount, data)
|
||||
}
|
||||
|
||||
private static func query(_ account: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
}
|
||||
|
||||
private static func read(_ account: String) -> Data? {
|
||||
var q = query(account)
|
||||
q[kSecReturnData as String] = true
|
||||
var result: CFTypeRef?
|
||||
return SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess ? result as? Data : nil
|
||||
}
|
||||
|
||||
/// Into the first keychain group of the entitlements, the one the complications read.
|
||||
@discardableResult
|
||||
private static func write(_ account: String, _ data: Data) -> Bool {
|
||||
let q = query(account)
|
||||
let update = [kSecValueData as String: data] as CFDictionary
|
||||
if SecItemUpdate(q as CFDictionary, update) == errSecSuccess { return true }
|
||||
var add = q
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchSnapshot {
|
||||
/// The snapshot with the ticks the phone has not confirmed yet laid over it.
|
||||
func applying(_ pending: [PendingAction]) -> WatchSnapshot {
|
||||
guard !pending.isEmpty, let tripId = trip?.id else { return self }
|
||||
var copy = self
|
||||
for action in pending where action.tripId == tripId {
|
||||
switch action.list {
|
||||
case .packing:
|
||||
if var packing = copy.packing, let index = packing.items.firstIndex(where: { $0.id == action.itemId }) {
|
||||
if packing.items[index].done != action.done {
|
||||
packing.packed += action.done ? 1 : -1
|
||||
packing.items[index].done = action.done
|
||||
}
|
||||
copy.packing = packing
|
||||
}
|
||||
case .todo:
|
||||
if var todos = copy.todos, let index = todos.items.firstIndex(where: { $0.id == action.itemId }) {
|
||||
if todos.items[index].done != action.done {
|
||||
todos.open += action.done ? -1 : 1
|
||||
todos.items[index].done = action.done
|
||||
}
|
||||
copy.todos = todos
|
||||
}
|
||||
}
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
/// The pending ticks still to lay over this snapshot: those the phone has
|
||||
/// not applied, whose item does not show the ticked state yet, and that
|
||||
/// are less than a day old (someone else may have changed it since).
|
||||
func stillPending(_ pending: [PendingAction], now: Date = Date()) -> [PendingAction] {
|
||||
pending.filter { action in
|
||||
if applied.contains(action.id) || now.timeIntervalSince(action.at) > 86_400 { return false }
|
||||
let items = action.list == .packing ? packing?.items : todos?.items
|
||||
guard let item = items?.first(where: { $0.id == action.itemId }) else { return false }
|
||||
return item.done != action.done
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A text from the watch's Localizable.strings.
|
||||
func L(_ key: String) -> String {
|
||||
NSLocalizedString(key, comment: "")
|
||||
}
|
||||
|
||||
/// The watch's own formatting: times in the clock the user picked in TREK,
|
||||
/// days, ranges and countdowns in the watch's language.
|
||||
enum WatchFormat {
|
||||
private static var formatters: [String: DateFormatter] = [:]
|
||||
private static let utc = TimeZone(identifier: "UTC") ?? .current
|
||||
|
||||
/// A formatter for the template in the watch's language, in `zone`
|
||||
/// (the current one unless given), so a flight across zones is followed.
|
||||
private static func formatter(_ template: String, zone: TimeZone = .current) -> DateFormatter {
|
||||
let key = template + "|" + Locale.current.identifier + "|" + zone.identifier
|
||||
if let cached = formatters[key] { return cached }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale.current
|
||||
formatter.timeZone = zone
|
||||
formatter.setLocalizedDateFormatFromTemplate(template)
|
||||
formatters[key] = formatter
|
||||
return formatter
|
||||
}
|
||||
|
||||
static func time(_ date: Date, hour12: Bool) -> String {
|
||||
formatter(hour12 ? "h:mm a" : "HH:mm").string(from: date)
|
||||
}
|
||||
|
||||
/// A time of day from its minutes, with no date involved, so no change of
|
||||
/// the clocks can shift it: the minutes after the reference date, in UTC.
|
||||
static func time(minutes: Int, hour12: Bool) -> String {
|
||||
let m = min(max(minutes, 0), 24 * 60 - 1)
|
||||
return formatter(hour12 ? "h:mm a" : "HH:mm", zone: utc).string(from: Date(timeIntervalSinceReferenceDate: TimeInterval(m * 60)))
|
||||
}
|
||||
|
||||
/// "Tue 18 Nov"
|
||||
static func day(_ date: Date) -> String {
|
||||
formatter("EEEdMMM").string(from: date)
|
||||
}
|
||||
|
||||
/// "Tuesday"
|
||||
static func weekday(_ date: Date) -> String {
|
||||
formatter("EEEE").string(from: date)
|
||||
}
|
||||
|
||||
/// "17 – 25 Nov"
|
||||
static func range(_ start: String, _ end: String) -> String? {
|
||||
guard let from = TrekDate.day(start), let to = TrekDate.day(end) else { return nil }
|
||||
let formatter = DateIntervalFormatter()
|
||||
formatter.dateTemplate = "dMMM"
|
||||
return formatter.string(from: from, to: to)
|
||||
}
|
||||
|
||||
/// "in 25 min", "in 2 hr", "5 min ago": named and plural-correct in every language.
|
||||
static func relative(_ date: Date, now: Date) -> String {
|
||||
if abs(date.timeIntervalSince(now)) < 60 { return L("time.now") }
|
||||
// Against `now`, not the system clock, so the screenshot mode's clock counts too.
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .short
|
||||
formatter.dateTimeStyle = .numeric
|
||||
return formatter.localizedString(for: date, relativeTo: now)
|
||||
}
|
||||
|
||||
/// The time to a moment as a short count: "25 min", "2 h 15 min", "3 d".
|
||||
static func countdown(_ date: Date, now: Date) -> String {
|
||||
let seconds = max(date.timeIntervalSince(now), 0)
|
||||
let formatter = WatchFormat.components()
|
||||
formatter.unitsStyle = .short
|
||||
formatter.maximumUnitCount = 2
|
||||
formatter.allowedUnits = seconds >= 86_400 ? [.day, .hour] : seconds >= 3_600 ? [.hour, .minute] : [.minute]
|
||||
return formatter.string(from: max(seconds, 60)) ?? ""
|
||||
}
|
||||
|
||||
/// The word for a number of days in its right plural form ("days", "Tage",
|
||||
/// "дней", "日"), taken from the system's own phrase so no plural rules
|
||||
/// have to be kept here: the number is formatted and then cut out.
|
||||
static func daysUnit(_ count: Int) -> String {
|
||||
let formatter = WatchFormat.components()
|
||||
formatter.unitsStyle = .full
|
||||
formatter.allowedUnits = [.day]
|
||||
let phrase = formatter.string(from: DateComponents(day: count)) ?? ""
|
||||
let word = phrase.unicodeScalars.filter { !CharacterSet.decimalDigits.contains($0) }
|
||||
return String(String.UnicodeScalarView(word)).trimmingCharacters(in: .whitespacesAndNewlines.union(.punctuationCharacters))
|
||||
}
|
||||
|
||||
/// A duration formatter in the watch's language: it takes its words from
|
||||
/// its calendar's locale, which is not the app's own unless set.
|
||||
static func components() -> DateComponentsFormatter {
|
||||
let formatter = DateComponentsFormatter()
|
||||
var calendar = Calendar.current
|
||||
calendar.locale = Locale.current
|
||||
formatter.calendar = calendar
|
||||
return formatter
|
||||
}
|
||||
|
||||
static func dayOf(_ index: Int, _ total: Int) -> String {
|
||||
String(format: L("day.of"), index, total)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The look
|
||||
|
||||
/// The watch's take on the widgets' look (TrekStyle.swift): black glass instead
|
||||
/// of paper, the trip's own two colours as the light behind its pages, the
|
||||
/// booking kinds' colours behind theirs.
|
||||
enum WatchLook {
|
||||
static let ink = Color.white
|
||||
static let muted = Color.white.opacity(0.62)
|
||||
static let faint = Color.white.opacity(0.38)
|
||||
static let packing = Color(hex: 0x2FA37A)
|
||||
static let todo = Color(hex: 0x4A7DDB)
|
||||
|
||||
/// The lights a trip's pages can have: clear, saturated hues that stay
|
||||
/// clean when they fade into black (no ochre or olive, which turn muddy).
|
||||
static let palette: [(Color, Color)] = [
|
||||
(Color(hex: 0x3D6FD8), Color(hex: 0x1C2E73)),
|
||||
(Color(hex: 0x2BA59A), Color(hex: 0x0E4E5A)),
|
||||
(Color(hex: 0x7A5AE0), Color(hex: 0x2E1F6B)),
|
||||
(Color(hex: 0xD2557A), Color(hex: 0x5C1A3A)),
|
||||
(Color(hex: 0x2E8BC0), Color(hex: 0x113A5C)),
|
||||
(Color(hex: 0xE0703C), Color(hex: 0x5E2414)),
|
||||
(Color(hex: 0x3FA866), Color(hex: 0x134A2C)),
|
||||
]
|
||||
|
||||
/// A trip's own two colours, picked by its id, so they do not change with
|
||||
/// its title or the language it is shown in.
|
||||
static func tripColors(_ trip: WatchSnapshot.Trip?) -> (Color, Color) {
|
||||
palette[(trip?.id ?? 1) % palette.count]
|
||||
}
|
||||
|
||||
/// A trip's page light: its first colour at the top, fading into black.
|
||||
static func tripGradient(_ trip: WatchSnapshot.Trip?) -> LinearGradient {
|
||||
let (first, second) = tripColors(trip)
|
||||
return LinearGradient(colors: [first.opacity(0.85), second.opacity(0.6), .black], startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
|
||||
static func tint(_ color: Color) -> LinearGradient {
|
||||
LinearGradient(colors: [color.opacity(0.75), color.opacity(0.28), .black], startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
|
||||
static func stopColor(_ stop: WatchSnapshot.Stop) -> Color {
|
||||
if let kind = stop.booking { return Trek.booking(kind).color }
|
||||
return Color(css: stop.color) ?? Trek.info
|
||||
}
|
||||
|
||||
static func stopSymbol(_ stop: WatchSnapshot.Stop) -> String {
|
||||
stop.symbol ?? (stop.booking.map { Trek.booking($0).symbol } ?? "mappin")
|
||||
}
|
||||
|
||||
static func kindName(_ kind: String) -> String {
|
||||
L("kind.\(["flight", "train", "bus", "car", "cruise", "hotel", "restaurant", "event", "tour"].contains(kind) ? kind : "other")")
|
||||
}
|
||||
}
|
||||
|
||||
/// The small capitals above a title.
|
||||
struct WatchEyebrow: ViewModifier {
|
||||
var color: Color = WatchLook.muted
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(.system(.caption2, design: .rounded).weight(.bold))
|
||||
.textCase(.uppercase)
|
||||
.tracking(0.4)
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.65)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Text a person wrote (a place, a booking, an item): when it is in Latin
|
||||
/// letters it is set as such, so in a Chinese, Japanese or Korean interface
|
||||
/// it still breaks between words rather than inside one.
|
||||
func userText(_ text: String?) -> some View {
|
||||
let latin = (text ?? "").unicodeScalars.allSatisfy { $0.value < 0x0250 || $0.properties.isWhitespace || $0.properties.generalCategory == .dashPunctuation || $0.properties.generalCategory == .otherPunctuation }
|
||||
return typesettingLanguage(Locale.Language(identifier: "en"), isEnabled: latin)
|
||||
}
|
||||
|
||||
func watchEyebrow(_ color: Color = WatchLook.muted) -> some View { modifier(WatchEyebrow(color: color)) }
|
||||
|
||||
/// A card of glass on watchOS 26, of frosted white before.
|
||||
@ViewBuilder
|
||||
func watchCard(cornerRadius: CGFloat = 18, tint: Color? = nil) -> some View {
|
||||
if #available(watchOS 26.0, iOS 26.0, macOS 26.0, *) {
|
||||
self.glassEffect(tint.map { Glass.regular.tint($0.opacity(0.35)) } ?? .regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
} else {
|
||||
self.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.fill((tint ?? .white).opacity(tint == nil ? 0.12 : 0.28))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stop's category as a tinted disc with its symbol.
|
||||
struct StopGlyph: View {
|
||||
let stop: WatchSnapshot.Stop
|
||||
var size: CGFloat = 26
|
||||
var dimmed = false
|
||||
|
||||
var body: some View {
|
||||
let color = WatchLook.stopColor(stop)
|
||||
Circle()
|
||||
.fill(color.gradient)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Image(systemName: WatchLook.stopSymbol(stop))
|
||||
.font(.system(size: size * 0.46, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
)
|
||||
.opacity(dimmed ? 0.45 : 1)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The TREK mark: four slanted tiles, as on the app icon.
|
||||
struct TrekMark: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
// The icon's tiles in its own 1024 grid, the mark spanning 270...754 by 256...768.
|
||||
let tiles: [[CGPoint]] = [
|
||||
[CGPoint(x: 382, y: 256), CGPoint(x: 621, y: 256), CGPoint(x: 558, y: 494), CGPoint(x: 318, y: 494)],
|
||||
[CGPoint(x: 620, y: 358), CGPoint(x: 754, y: 358), CGPoint(x: 718, y: 494), CGPoint(x: 585, y: 494)],
|
||||
[CGPoint(x: 302, y: 528), CGPoint(x: 438, y: 528), CGPoint(x: 405, y: 664), CGPoint(x: 270, y: 664)],
|
||||
[CGPoint(x: 468, y: 528), CGPoint(x: 706, y: 528), CGPoint(x: 644, y: 768), CGPoint(x: 404, y: 768)],
|
||||
]
|
||||
let minX: CGFloat = 270, minY: CGFloat = 256, span: CGFloat = 512
|
||||
let scale = min(rect.width, rect.height) / span
|
||||
let dx = rect.minX + (rect.width - (754 - minX) * scale) / 2
|
||||
let dy = rect.minY + (rect.height - (768 - minY) * scale) / 2
|
||||
var path = Path()
|
||||
for tile in tiles {
|
||||
let points = tile.map { CGPoint(x: dx + ($0.x - minX) * scale, y: dy + ($0.y - minY) * scale) }
|
||||
path.addLines(points)
|
||||
path.closeSubpath()
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/// A label and a value in a small tile: "SEAT 42A".
|
||||
struct FactTile: View {
|
||||
let label: String
|
||||
let value: String
|
||||
var mono = false
|
||||
/// Laid out at its natural width, so a ViewThatFits can tell when a row of them is too wide.
|
||||
var fits = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(label).watchEyebrow(WatchLook.faint)
|
||||
.fixedSize(horizontal: fits, vertical: false)
|
||||
Text(value)
|
||||
.font(mono ? .system(.body, design: .monospaced).weight(.semibold) : .system(.body, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(WatchLook.ink)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.watchCard(cornerRadius: 12)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>TREK</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// The watch's complications and Smart Stack widgets, drawn from the snapshot
|
||||
/// the watch app keeps (WatchStore, in the keychain group both share): the
|
||||
/// next stop of the day, a countdown to the next departure, the day of the trip.
|
||||
@main
|
||||
struct TREKWatchWidgets: WidgetBundle {
|
||||
var body: some Widget {
|
||||
NextStopWidget()
|
||||
CountdownWidget()
|
||||
TripDayWidget()
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let snapshot: WatchSnapshot?
|
||||
let relevance: TimelineEntryRelevance?
|
||||
|
||||
var state: ComplicationState { ComplicationState(snapshot, now: date) }
|
||||
}
|
||||
|
||||
/// One entry now and one at every moment the face should change (a stop
|
||||
/// starts or ends, a booking departs, midnight), each scored for the Smart
|
||||
/// Stack: highest just before something happens.
|
||||
struct WatchProvider: TimelineProvider {
|
||||
let kind: Kind
|
||||
|
||||
enum Kind { case nextStop, countdown, tripDay }
|
||||
|
||||
func placeholder(in context: Context) -> WatchEntry {
|
||||
WatchEntry(date: Date(), snapshot: WatchSample.snapshot(.running), relevance: nil)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (WatchEntry) -> Void) {
|
||||
let stored = WatchStore.loadSnapshot()
|
||||
completion(WatchEntry(date: Date(), snapshot: context.isPreview && stored == nil ? WatchSample.snapshot(.running) : stored, relevance: nil))
|
||||
}
|
||||
|
||||
/// Entries at every change in the next two days, every quarter hour for two
|
||||
/// hours and every hour after that (for the circular faces' counts; the
|
||||
/// others count by themselves), rebuilt at midnight or after the last entry,
|
||||
/// whichever comes first. Without a trip nothing changes until the app writes
|
||||
/// a new snapshot, which reloads the timelines itself.
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<WatchEntry>) -> Void) {
|
||||
let now = Date()
|
||||
let snapshot = WatchStore.loadSnapshot()?.applying(WatchStore.loadPending())
|
||||
guard snapshot?.trip != nil else {
|
||||
completion(Timeline(entries: [WatchEntry(date: now, snapshot: snapshot, relevance: nil)], policy: .never))
|
||||
return
|
||||
}
|
||||
var moments = ComplicationState.changes(snapshot, from: now)
|
||||
for step in 1...8 { moments.append(now.addingTimeInterval(Double(step) * 15 * 60)) }
|
||||
for hour in 3...24 { moments.append(now.addingTimeInterval(Double(hour) * 3_600)) }
|
||||
let dates = ([now] + moments).sorted().reduce(into: [Date]()) { out, date in
|
||||
if let last = out.last, date.timeIntervalSince(last) < 60 { return }
|
||||
out.append(date)
|
||||
}
|
||||
let entries = dates.prefix(80).map { date in
|
||||
WatchEntry(date: date, snapshot: snapshot, relevance: relevance(snapshot, at: date))
|
||||
}
|
||||
let midnight = Calendar.current.date(byAdding: .day, value: 1, to: Calendar.current.startOfDay(for: now)) ?? now.addingTimeInterval(86_400)
|
||||
let reload = min(midnight, entries.last?.date ?? midnight)
|
||||
completion(Timeline(entries: entries, policy: .after(reload > now ? reload : now.addingTimeInterval(3_600))))
|
||||
}
|
||||
|
||||
private func relevance(_ snapshot: WatchSnapshot?, at date: Date) -> TimelineEntryRelevance? {
|
||||
let state = ComplicationState(snapshot, now: date)
|
||||
switch kind {
|
||||
case .nextStop:
|
||||
guard let next = state.next else { return TimelineEntryRelevance(score: 0) }
|
||||
if next.isNow { return TimelineEntryRelevance(score: 60, duration: 30 * 60) }
|
||||
let minutes = (next.start ?? date).timeIntervalSince(date) / 60
|
||||
return TimelineEntryRelevance(score: minutes <= 45 ? 80 : minutes <= 120 ? 40 : 10)
|
||||
case .countdown:
|
||||
guard let countdown = state.countdown else { return TimelineEntryRelevance(score: 0) }
|
||||
let hours = countdown.target.timeIntervalSince(date) / 3_600
|
||||
return TimelineEntryRelevance(score: hours <= 3 ? 100 : hours <= 24 ? 50 : 5)
|
||||
case .tripDay:
|
||||
if case .running = state.phase { return TimelineEntryRelevance(score: 20) }
|
||||
return TimelineEntryRelevance(score: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let families: [WidgetFamily] = [.accessoryCircular, .accessoryRectangular, .accessoryCorner, .accessoryInline]
|
||||
|
||||
private struct EntryView<Content: View>: View {
|
||||
let entry: WatchEntry
|
||||
@Environment(\.widgetFamily) private var widgetFamily
|
||||
@ViewBuilder let content: (ComplicationState, ComplicationFamily, Date) -> Content
|
||||
|
||||
var body: some View {
|
||||
content(entry.state, ComplicationFamily(widgetFamily) ?? .rectangular, entry.date)
|
||||
.containerBackground(for: .widget) { Color.clear }
|
||||
}
|
||||
}
|
||||
|
||||
struct NextStopWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "com.liketrek.trek.watch.nextStop", provider: WatchProvider(kind: .nextStop)) { entry in
|
||||
EntryView(entry: entry) { state, family, now in NextStopComplication(state: state, family: family, now: now) }
|
||||
}
|
||||
.configurationDisplayName(L("complication.nextStop.name"))
|
||||
.description(L("complication.nextStop.description"))
|
||||
.supportedFamilies(families)
|
||||
}
|
||||
}
|
||||
|
||||
struct CountdownWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "com.liketrek.trek.watch.countdown", provider: WatchProvider(kind: .countdown)) { entry in
|
||||
EntryView(entry: entry) { state, family, now in CountdownComplication(state: state, family: family, now: now) }
|
||||
}
|
||||
.configurationDisplayName(L("complication.countdown.name"))
|
||||
.description(L("complication.countdown.description"))
|
||||
.supportedFamilies(families)
|
||||
}
|
||||
}
|
||||
|
||||
struct TripDayWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "com.liketrek.trek.watch.tripDay", provider: WatchProvider(kind: .tripDay)) { entry in
|
||||
EntryView(entry: entry) { state, family, _ in TripDayComplication(state: state, family: family) }
|
||||
}
|
||||
.configurationDisplayName(L("complication.tripDay.name"))
|
||||
.description(L("complication.tripDay.description"))
|
||||
.supportedFamilies(families)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
# Adds an app extension target to App.xcodeproj and embeds it in the app.
|
||||
# Adds an extension target (or the watch app) to App.xcodeproj and embeds it.
|
||||
#
|
||||
# ruby ios/scripts/add-extension.rb <name> <bundle-id> <type> [extra frameworks...]
|
||||
# ruby ios/scripts/add-extension.rb <name> <bundle-id> <type> [options] [extra frameworks...]
|
||||
#
|
||||
# type is "notification" (Notification Service Extension), "widget" (WidgetKit,
|
||||
# which also carries the Live Activities) or "share" (the share sheet entry). The extension's sources live in
|
||||
# ios/App/<name>/ with an Info.plist and <name>.entitlements next to them. The
|
||||
# which also carries the Live Activities), "share" (the share sheet entry),
|
||||
# "watch" (the Apple Watch app, embedded in the phone app's Watch folder) or
|
||||
# "watchwidget" (the watch's complications, embedded in the watch app). The
|
||||
# target's sources live in ios/App/<name>/ with an Info.plist and
|
||||
# <name>.entitlements next to them; an asset catalog or a privacy manifest
|
||||
# there is a resource. The
|
||||
# project file stays checked in, so the targets show in Xcode like any other;
|
||||
# this script is how they got there, and running it again changes nothing.
|
||||
#
|
||||
# Options:
|
||||
# --with=<path>,... more sources under ios/App compiled into the target
|
||||
# too, each a folder of .swift files or one file
|
||||
# (code shared between targets keeps one reference)
|
||||
# --host=<target> the target a watchwidget is embedded in
|
||||
# --app-file=<path>,... sources added to the phone app itself: the phone's end
|
||||
# of what the new target talks to
|
||||
# --strings-from=<name> the target uses the Localizable.strings of that folder
|
||||
require 'xcodeproj'
|
||||
|
||||
name, bundle_id, type, *frameworks = ARGV
|
||||
abort 'usage: add-extension.rb <name> <bundle-id> <notification|widget|share> [frameworks...]' unless name && bundle_id && type
|
||||
name, bundle_id, type, *rest = ARGV
|
||||
types = %w[notification widget share watch watchwidget]
|
||||
abort "usage: add-extension.rb <name> <bundle-id> <#{types.join('|')}> [options] [frameworks...]" unless name && bundle_id && types.include?(type)
|
||||
options, frameworks = rest.partition { |arg| arg.start_with?('--') }
|
||||
option = ->(key) { options.find { |o| o.start_with?("--#{key}=") }&.split('=', 2)&.last }
|
||||
list = ->(key) { (option.(key) || '').split(',').reject(&:empty?) }
|
||||
watch = %w[watch watchwidget].include?(type)
|
||||
app_dir = File.expand_path('../App', __dir__)
|
||||
|
||||
project_path = File.expand_path('../App/App.xcodeproj', __dir__)
|
||||
project_path = File.join(app_dir, 'App.xcodeproj')
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
app = project.targets.find { |t| t.name == 'App' } or abort 'no App target'
|
||||
|
||||
@@ -21,7 +40,11 @@ if project.targets.any? { |t| t.name == name }
|
||||
exit 0
|
||||
end
|
||||
|
||||
target = project.new_target(:app_extension, name, :ios, '15.0', nil, :swift)
|
||||
target = if type == 'watch'
|
||||
project.new_target(:application, name, :watchos, '11.0', nil, :swift)
|
||||
else
|
||||
project.new_target(:app_extension, name, watch ? :watchos : :ios, watch ? '11.0' : '15.0', nil, :swift)
|
||||
end
|
||||
# new_target links Foundation by a path into one SDK version; it is linked
|
||||
# implicitly anyway, and a pinned SDK path breaks on the next Xcode.
|
||||
target.frameworks_build_phase.files.select { |f| f.display_name.to_s.include?('Foundation') }.each do |f|
|
||||
@@ -33,15 +56,15 @@ end
|
||||
group = project.main_group.find_subpath(name, true)
|
||||
group.set_source_tree('<group>')
|
||||
group.set_path(name)
|
||||
sources = Dir.children(File.expand_path("../App/#{name}", __dir__)).sort
|
||||
sources = Dir.children(File.join(app_dir, name)).sort
|
||||
# Localizations go in as one variant group, so Xcode copies each language's
|
||||
# strings into the extension bundle; JavaScript (Safari's page preprocessing) is
|
||||
# a resource as well.
|
||||
# strings into the extension bundle; JavaScript (Safari's page preprocessing) and
|
||||
# an asset catalog are resources as well.
|
||||
lprojs, files = sources.partition { |file| file.end_with?('.lproj') }
|
||||
files.each do |file|
|
||||
ref = group.new_reference(file)
|
||||
target.add_file_references([ref]) if file.end_with?('.swift')
|
||||
target.add_resources([ref]) if file.end_with?('.js')
|
||||
target.add_resources([ref]) if file.end_with?('.js', '.xcassets', '.xcprivacy')
|
||||
end
|
||||
unless lprojs.empty?
|
||||
variant = group.new_variant_group('Localizable.strings')
|
||||
@@ -55,6 +78,39 @@ unless lprojs.empty?
|
||||
target.add_resources([variant])
|
||||
end
|
||||
|
||||
# A file already in the project (the widgets' style, shared with the watch)
|
||||
# keeps its one reference; one new to it goes in a group named after its folder.
|
||||
reference_for = lambda do |relative|
|
||||
full = File.join(app_dir, relative)
|
||||
project.files.find { |f| f.real_path.to_s == full } || begin
|
||||
dir = File.dirname(relative)
|
||||
parent = project.main_group
|
||||
unless dir == '.'
|
||||
parent = project.main_group.find_subpath(dir, true)
|
||||
parent.set_source_tree('<group>')
|
||||
parent.set_path(dir)
|
||||
end
|
||||
parent.new_reference(File.basename(relative))
|
||||
end
|
||||
end
|
||||
|
||||
list.('with').each do |entry|
|
||||
folder = File.join(app_dir, entry)
|
||||
paths = File.directory?(folder) ? Dir.children(folder).sort.select { |f| f.end_with?('.swift') }.map { |f| "#{entry}/#{f}" } : [entry]
|
||||
target.add_file_references(paths.map { |path| reference_for.(path) })
|
||||
end
|
||||
|
||||
list.('app-file').each do |path|
|
||||
ref = reference_for.(path)
|
||||
app.add_file_references([ref]) unless app.source_build_phase.files_references.include?(ref)
|
||||
end
|
||||
|
||||
if (strings_from = option.('strings-from'))
|
||||
variant = project.main_group.find_subpath(strings_from, false)&.children&.find { |c| c.isa == 'PBXVariantGroup' && c.name == 'Localizable.strings' }
|
||||
abort "no Localizable.strings in #{strings_from}" unless variant
|
||||
target.add_resources([variant])
|
||||
end
|
||||
|
||||
frameworks.each do |framework|
|
||||
ref = project.frameworks_group.new_file("System/Library/Frameworks/#{framework}.framework", :sdk_root)
|
||||
target.frameworks_build_phase.add_file_reference(ref, true)
|
||||
@@ -71,20 +127,46 @@ target.build_configurations.each do |config|
|
||||
s['CODE_SIGN_STYLE'] = 'Automatic'
|
||||
s['DEVELOPMENT_TEAM'] = app_settings['DEVELOPMENT_TEAM'] if app_settings['DEVELOPMENT_TEAM']
|
||||
s['SWIFT_VERSION'] = '5.0'
|
||||
s['TARGETED_DEVICE_FAMILY'] = '1,2'
|
||||
s['IPHONEOS_DEPLOYMENT_TARGET'] = type == 'widget' ? '16.2' : '15.0'
|
||||
s['MARKETING_VERSION'] = app_settings['MARKETING_VERSION'] || '1.0'
|
||||
s['CURRENT_PROJECT_VERSION'] = app_settings['CURRENT_PROJECT_VERSION'] || '1'
|
||||
s['SKIP_INSTALL'] = 'YES'
|
||||
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks', '@executable_path/../../Frameworks']
|
||||
if watch
|
||||
s.delete('IPHONEOS_DEPLOYMENT_TARGET')
|
||||
s['SDKROOT'] = 'watchos'
|
||||
s['TARGETED_DEVICE_FAMILY'] = '4'
|
||||
s['WATCHOS_DEPLOYMENT_TARGET'] = '11.0'
|
||||
if type == 'watch'
|
||||
s['ASSETCATALOG_COMPILER_APPICON_NAME'] = 'AppIcon'
|
||||
s['ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME'] = 'AccentColor'
|
||||
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks']
|
||||
else
|
||||
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks', '@executable_path/../../Frameworks']
|
||||
end
|
||||
else
|
||||
s['TARGETED_DEVICE_FAMILY'] = '1,2'
|
||||
s['IPHONEOS_DEPLOYMENT_TARGET'] = type == 'widget' ? '16.2' : '15.0'
|
||||
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks', '@executable_path/../../Frameworks']
|
||||
end
|
||||
end
|
||||
|
||||
embed = app.copy_files_build_phases.find { |p| p.name == 'Embed Foundation Extensions' } ||
|
||||
app.new_copy_files_build_phase('Embed Foundation Extensions')
|
||||
embed.symbol_dst_subfolder_spec = :plug_ins
|
||||
# The watch app goes into the phone app's Watch folder, every extension into the
|
||||
# PlugIns of its host: the phone app, or the watch app for the complications.
|
||||
if type == 'watch'
|
||||
host = app
|
||||
embed = app.copy_files_build_phases.find { |p| p.name == 'Embed Watch Content' } ||
|
||||
app.new_copy_files_build_phase('Embed Watch Content')
|
||||
embed.symbol_dst_subfolder_spec = :products_directory
|
||||
embed.dst_path = '$(CONTENTS_FOLDER_PATH)/Watch'
|
||||
else
|
||||
host = type == 'watchwidget' ? project.targets.find { |t| t.name == option.('host') } : app
|
||||
abort 'a watchwidget needs --host=<watch target>' unless host
|
||||
embed = host.copy_files_build_phases.find { |p| p.name == 'Embed Foundation Extensions' } ||
|
||||
host.new_copy_files_build_phase('Embed Foundation Extensions')
|
||||
embed.symbol_dst_subfolder_spec = :plug_ins
|
||||
end
|
||||
build_file = embed.add_file_reference(target.product_reference, true)
|
||||
build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }
|
||||
app.add_dependency(target)
|
||||
host.add_dependency(target)
|
||||
|
||||
project.save
|
||||
puts "added #{name} (#{bundle_id})"
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/bin/bash
|
||||
# Captures the watch app's screens from watchOS simulators, in screenshot mode
|
||||
# (client/ios/App/TREKWatch/DemoMode.swift), for review and the App Store:
|
||||
# every screen and its states in English, German and Russian on the newest
|
||||
# Ultra, the largest other watch and the smallest one, and the main screens in
|
||||
# every language on the smallest, where text is tightest.
|
||||
# .github/workflows/native.yml runs it after building the app.
|
||||
#
|
||||
# watch-screens.sh <TREKWatch.app> <output directory>
|
||||
set -euo pipefail
|
||||
|
||||
APP="$1"
|
||||
OUT="$2"
|
||||
BUNDLE=com.liketrek.trek.watchkitapp
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# The newest watchOS runtime, its newest Ultra, the largest other watch and the
|
||||
# smallest one it still runs on.
|
||||
read -r RUNTIME LARGE MEDIUM SMALL < <(xcrun simctl list runtimes -j | python3 -c '
|
||||
import json, re, sys
|
||||
runtimes = [r for r in json.load(sys.stdin)["runtimes"] if r["platform"] == "watchOS" and r["isAvailable"]]
|
||||
runtime = sorted(runtimes, key=lambda r: [int(x) for x in r["version"].split(".")])[-1]
|
||||
names = [t["name"] for t in runtime["supportedDeviceTypes"]]
|
||||
def mm(n):
|
||||
m = re.search(r"(\d+)mm", n); return int(m.group(1)) if m else 0
|
||||
def gen(n):
|
||||
m = re.search(r"(?:Series|Ultra|SE) ?(\d+)?", n); return int(m.group(1) or 1) if m else 0
|
||||
ultra = sorted([n for n in names if "Ultra" in n], key=gen)[-1]
|
||||
rest = sorted([n for n in names if "Ultra" not in n], key=lambda n: (mm(n), gen(n)))
|
||||
print(runtime["identifier"], *[n.replace(" ", "_") for n in (ultra, rest[-1], rest[0])])')
|
||||
LARGE=${LARGE//_/ }
|
||||
MEDIUM=${MEDIUM//_/ }
|
||||
SMALL=${SMALL//_/ }
|
||||
echo "runtime $RUNTIME; large: $LARGE, medium: $MEDIUM, small: $SMALL"
|
||||
|
||||
boot() {
|
||||
local udid
|
||||
udid=$(xcrun simctl create "$1" "$1" "$RUNTIME")
|
||||
xcrun simctl boot "$udid"
|
||||
xcrun simctl bootstatus "$udid" -b >/dev/null
|
||||
xcrun simctl install "$udid" "$APP"
|
||||
echo "$udid"
|
||||
}
|
||||
|
||||
# The watch draws the Mac's clock in its corner, so the Mac goes to a zone in
|
||||
# which it is about one o'clock: the hour the sample trip's screens are made for
|
||||
# (WatchSample.swift), and the clock and the countdowns agree.
|
||||
set_clock() {
|
||||
local offset=$(( (13 - 10#$(date -u +%H) + 36) % 24 - 12 ))
|
||||
local zone
|
||||
if [ "$offset" -ge 0 ]; then zone="Etc/GMT-$offset"; else zone="Etc/GMT+$(( -offset ))"; fi
|
||||
sudo ln -sf "/usr/share/zoneinfo/$zone" /etc/localtime
|
||||
echo "clock: $zone, $(date +%H:%M)"
|
||||
}
|
||||
|
||||
# Waits for the ready file the app writes once the screen has drawn (DemoMode.signalReady).
|
||||
wait_ready() {
|
||||
local udid=$1 token=$2 container
|
||||
for _ in $(seq 1 40); do
|
||||
container=$(xcrun simctl get_app_container "$udid" "$BUNDLE" data 2>/dev/null || true)
|
||||
[ -n "$container" ] && [ -f "$container/Documents/ready-$token" ] && return 0
|
||||
sleep 0.25
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
TOKEN=0
|
||||
# shot <udid> <file> <screen> <scenario> <time or -> <language>: one launch, retried
|
||||
# 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 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
|
||||
local extra=()
|
||||
[ "$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 || { 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")"
|
||||
}
|
||||
|
||||
# name screen scenario clock: every screen, and the states a screen has; a
|
||||
# clock only where the state needs another hour than the real one.
|
||||
SHOTS="
|
||||
today today running -
|
||||
today-travelday today travelday -
|
||||
today-done today running 22:30
|
||||
day day running -
|
||||
place place running -
|
||||
booking booking travelday -
|
||||
booking-dinner booking running -
|
||||
bookings bookings travelday -
|
||||
booking-detail booking-detail travelday -
|
||||
trip trip running -
|
||||
trip-upcoming trip upcoming -
|
||||
days days running -
|
||||
countdown countdown upcoming -
|
||||
lists lists upcoming -
|
||||
packing packing upcoming -
|
||||
todos todos upcoming -
|
||||
empty empty running -
|
||||
nosync nosync running -
|
||||
faces faces running -
|
||||
"
|
||||
MAIN="today booking countdown trip lists packing day"
|
||||
LANGS="ar az ca cs de el en es et fr he hu id it ja ko nl pl pt-BR ru sk sv th tr uk vi zh-Hans zh-Hant"
|
||||
|
||||
# The watch draws its corner clock in its own region's format, not the app's:
|
||||
# English goes first on a 12 hour clock, then the watch is switched to 24
|
||||
# hours, as the other languages read it, and restarted for the clock to follow.
|
||||
use_24_hours() {
|
||||
local udid=$1
|
||||
xcrun simctl spawn "$udid" defaults write -g AppleICUForce24HourTime -bool YES
|
||||
xcrun simctl spawn "$udid" defaults write -g AppleLocale de_DE
|
||||
xcrun simctl shutdown "$udid"
|
||||
xcrun simctl boot "$udid"
|
||||
xcrun simctl bootstatus "$udid" -b >/dev/null
|
||||
}
|
||||
|
||||
EXPECTED=()
|
||||
run() {
|
||||
local size=$1 udid=$2 lang shotname screen scenario clock
|
||||
for lang in en de ru; do
|
||||
[ "$lang" = de ] && use_24_hours "$udid"
|
||||
while read -r shotname screen scenario clock; do
|
||||
[ -n "$shotname" ] || continue
|
||||
EXPECTED+=("$size-$lang-$shotname.png")
|
||||
shot "$udid" "$OUT/$size-$lang-$shotname.png" "$screen" "$scenario" "$clock" "$lang"
|
||||
done <<< "$SHOTS"
|
||||
done
|
||||
if [ "$size" = small ]; then
|
||||
for lang in $LANGS; do
|
||||
case "$lang" in en|de|ru) continue ;; esac
|
||||
while read -r shotname screen scenario clock; do
|
||||
[ -n "$shotname" ] || continue
|
||||
case " $MAIN " in
|
||||
*" $shotname "*)
|
||||
EXPECTED+=("$size-$lang-$shotname.png")
|
||||
shot "$udid" "$OUT/$size-$lang-$shotname.png" "$screen" "$scenario" "$clock" "$lang"
|
||||
;;
|
||||
esac
|
||||
done <<< "$SHOTS"
|
||||
done
|
||||
fi
|
||||
xcrun simctl shutdown "$udid" || true
|
||||
}
|
||||
|
||||
# One watch at a time: launches on several booted simulators at once is what
|
||||
# makes SpringBoard refuse them ("Scene update failed").
|
||||
set_clock
|
||||
run large "$(boot "$LARGE" | tail -1)"
|
||||
run medium "$(boot "$MEDIUM" | tail -1)"
|
||||
run small "$(boot "$SMALL" | tail -1)"
|
||||
printf '%s\n' "large: $LARGE" "medium: $MEDIUM" "small: $SMALL" > "$OUT/devices.txt"
|
||||
|
||||
missing=0
|
||||
for file in "${EXPECTED[@]}"; do
|
||||
[ -s "$OUT/$file" ] || { echo "missing: $file"; missing=$((missing + 1)); }
|
||||
done
|
||||
echo "${#EXPECTED[@]} expected, $missing missing"
|
||||
[ "$missing" -eq 0 ]
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user