mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-09 13:51:02 +00:00
Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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}"
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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);
|
||||
|
||||
@@ -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,73 @@ 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));
|
||||
}
|
||||
|
||||
/** 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,16 @@
|
||||
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.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Used while the WebView shows the user's server. When that server cannot be
|
||||
@@ -18,10 +23,43 @@ import com.getcapacitor.BridgeWebViewClient;
|
||||
class TrekWebViewClient extends BridgeWebViewClient {
|
||||
|
||||
private final Runnable onUnreachable;
|
||||
private final Set<String> signInHosts;
|
||||
|
||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable) {
|
||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) {
|
||||
super(bridge);
|
||||
this.onUnreachable = onUnreachable;
|
||||
this.signInHosts = 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;
|
||||
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);
|
||||
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 +72,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 +106,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")))
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,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.
|
||||
private func guardBridgeMessages() {
|
||||
guard !TrekServerStore.signInHosts.isEmpty,
|
||||
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,56 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")
|
||||
@@ -310,6 +453,75 @@ 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
|
||||
}
|
||||
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 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 +532,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.
Binary file not shown.
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