mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-10 06:11:02 +00:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.1
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "5.1.0"
|
||||
appVersion: "5.1.1"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,56 @@
|
||||
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 */; };
|
||||
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 +110,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 +158,22 @@
|
||||
/* 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>"; };
|
||||
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 +182,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 +213,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 +223,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 +262,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 +334,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 +384,9 @@
|
||||
74C1FC6022E562372A71EEC9 /* NotificationService */,
|
||||
FB9226BC82835538BCE3DC0A /* TREKWidgets */,
|
||||
DFD2010504102AC0E682D00F /* ShareExtension */,
|
||||
A5731FDC6B43DF41413C28A6 /* TREKWatch */,
|
||||
83BCA52D4B145E66DC1B0111 /* TREKWatchKit */,
|
||||
F3ECD357918C4A98B023F65F /* TREKWatchWidgets */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -269,6 +397,8 @@
|
||||
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */,
|
||||
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */,
|
||||
694631A1018DCE5678F29930 /* ShareExtension.appex */,
|
||||
CF4BA0BDDABFBDB24394025D /* TREKWatch.app */,
|
||||
0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -289,6 +419,7 @@
|
||||
7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
@@ -302,6 +433,7 @@
|
||||
6E901288FC198E4D33BE754F /* SwiftUI.framework */,
|
||||
192E69C7E66646BC39566925 /* ActivityKit.framework */,
|
||||
E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */,
|
||||
DC1BBE9AA7035B32D3653122 /* watchOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -317,6 +449,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 +503,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 +569,7 @@
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */,
|
||||
986D3C6100A1AB6BE603CF1C /* Embed Watch Content */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -395,6 +577,7 @@
|
||||
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */,
|
||||
4CD7F5AF02CB806E4189489A /* PBXTargetDependency */,
|
||||
855AE76937B942D62DFFD7E2 /* PBXTargetDependency */,
|
||||
DC6A321F5C3C7D5EF0973677 /* PBXTargetDependency */,
|
||||
);
|
||||
name = App;
|
||||
packageProductDependencies = (
|
||||
@@ -404,6 +587,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 +725,8 @@
|
||||
9480DFBC2C2A112973DA468A /* NotificationService */,
|
||||
A3BE5653156025C97CA62C68 /* TREKWidgets */,
|
||||
01A63CD6CFBC1463CAA8FFF0 /* ShareExtension */,
|
||||
59A840D13C3074A09FCCC7CB /* TREKWatch */,
|
||||
5B5EA8FB8F58C6BFA3B77661 /* TREKWatchWidgets */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -540,6 +761,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 +779,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 +822,7 @@
|
||||
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */,
|
||||
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
|
||||
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
|
||||
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -595,6 +849,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 +889,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 +951,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 +1040,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 +1298,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 +1375,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 +1425,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 +1498,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>
|
||||
|
||||
@@ -110,6 +110,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)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,87 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
// the page follows the finger and an open sheet closes first.
|
||||
webView?.allowsBackForwardNavigationGestures = false
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// The remembered server and the offline copy of its start page.
|
||||
|
||||
@@ -46,6 +46,9 @@ 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),
|
||||
]
|
||||
|
||||
/// What this binary offers the server's UI, raised with every native feature
|
||||
@@ -53,7 +56,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"]
|
||||
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 +81,23 @@ 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])
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +170,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
URLSession.shared.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
|
||||
}
|
||||
}
|
||||
@@ -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.
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared/i18n'
|
||||
import { SUPPORTED_LANGUAGE_CODES, isRtlLanguage } from '@trek/shared/i18n'
|
||||
import type { TranslationStrings } from '@trek/shared/i18n'
|
||||
|
||||
// Only the native strings of every locale, not the whole UI: this page ships
|
||||
@@ -32,3 +32,8 @@ export function translator(code: string) {
|
||||
: key
|
||||
}
|
||||
}
|
||||
|
||||
/** Arabic and Hebrew run right to left. */
|
||||
export function directionOf(code: string): 'rtl' | 'ltr' {
|
||||
return isRtlLanguage(code) ? 'rtl' : 'ltr'
|
||||
}
|
||||
|
||||
+114
-59
@@ -5,11 +5,12 @@ import { StatusBar, Style } from '@capacitor/status-bar';
|
||||
import logoUrl from '../public/icons/icon-white.svg';
|
||||
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
|
||||
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
|
||||
import { opensSoon, refundedBeforeOpening } from '../src/native/cloudOrders';
|
||||
import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap';
|
||||
import { CLOUD_TERMS_URL, CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL, STORE_TERMS_URL } from '../src/native/links';
|
||||
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
|
||||
import { TrekShell } from '../src/native/trekShell';
|
||||
import { pickLocale, translator } from './i18n';
|
||||
import { directionOf, pickLocale, translator } from './i18n';
|
||||
import './shell.css';
|
||||
|
||||
/**
|
||||
@@ -24,7 +25,7 @@ const locale = pickLocale(navigator.languages?.length ? navigator.languages : [n
|
||||
const onPlay = Capacitor.getPlatform() === 'android';
|
||||
const t = translator(locale);
|
||||
document.documentElement.lang = locale;
|
||||
document.documentElement.dir = locale === 'ar' ? 'rtl' : 'ltr';
|
||||
document.documentElement.dir = directionOf(locale);
|
||||
|
||||
const root = document.getElementById('shell')!;
|
||||
const PROBE_TIMEOUT_MS = 8000;
|
||||
@@ -128,6 +129,11 @@ function show(screen: HTMLElement, opts: { back?: boolean } = {}): void {
|
||||
if (opts.back) screen.classList.add('screen-back');
|
||||
const previous = current;
|
||||
if (previous) {
|
||||
// The leaving screen is lifted out of the flow where it stands, so it fades
|
||||
// from its own place rather than jumping to the top of a centred layout.
|
||||
previous.style.top = `${previous.offsetTop}px`;
|
||||
previous.style.left = `${previous.offsetLeft}px`;
|
||||
previous.style.width = `${previous.offsetWidth}px`;
|
||||
previous.classList.remove('screen-enter');
|
||||
previous.classList.add('screen-leave');
|
||||
if (opts.back) previous.classList.add('screen-back');
|
||||
@@ -144,6 +150,15 @@ function externalLink(text: string, href: string): HTMLAnchorElement {
|
||||
function footLinks(...extra: Node[]): HTMLElement {
|
||||
return el('div', { className: 'foot-links' }, ...extra, externalLink(t('native.privacy'), PRIVACY_URL));
|
||||
}
|
||||
/**
|
||||
* A screen's two halves: what it is (brand, title, explanation) and what to do
|
||||
* on it (the choices, the form). Stacked in one column they read as before; on
|
||||
* a phone or a foldable on its side the screen is too short for one column, so
|
||||
* the halves stand side by side there (shell.css, `.col`).
|
||||
*/
|
||||
function columns(about: Node[], act: Node[]): HTMLElement[] {
|
||||
return [el('div', { className: 'col col-about' }, ...about), el('div', { className: 'col col-act' }, ...act)];
|
||||
}
|
||||
function topbar(onBack: () => void): HTMLElement {
|
||||
const back = el('button', { type: 'button', className: 'back' }, icon('M15 18l-6-6 6-6', 18), t('native.back'));
|
||||
back.addEventListener('click', () => {
|
||||
@@ -202,13 +217,14 @@ function showWelcome(opts: { back?: boolean } = {}): void {
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
hero,
|
||||
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
|
||||
el('p', { className: 'sub' }, t('native.welcome.sub')),
|
||||
cloud,
|
||||
connect,
|
||||
demo,
|
||||
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
|
||||
...columns(
|
||||
[
|
||||
hero,
|
||||
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
|
||||
el('p', { className: 'sub' }, t('native.welcome.sub')),
|
||||
],
|
||||
[cloud, connect, demo, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -225,6 +241,8 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
});
|
||||
input.setAttribute('inputmode', 'url');
|
||||
input.setAttribute('autocomplete', 'url');
|
||||
// An address reads left to right in every language, Arabic too.
|
||||
input.dir = 'ltr';
|
||||
// The on-screen keyboard's return key submits the address; it reads "Go", not a newline.
|
||||
input.setAttribute('enterkeyhint', 'go');
|
||||
input.setAttribute('aria-label', t('native.connect.title'));
|
||||
@@ -266,11 +284,14 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.connect.title')),
|
||||
el('p', { className: 'sub' }, t('native.connect.hint')),
|
||||
form,
|
||||
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
|
||||
...columns(
|
||||
[
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.connect.title')),
|
||||
el('p', { className: 'sub' }, t('native.connect.hint')),
|
||||
],
|
||||
[form, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -288,7 +309,13 @@ function feature(path: string, text: string): HTMLElement {
|
||||
}
|
||||
|
||||
function showCloud(opts: { back?: boolean } = {}): void {
|
||||
const cta = el('button', { type: 'button', className: 'btn' }, t('native.cloud.cta'), icon(I.chevron, 20, 'chev'));
|
||||
// The label centred, the chevron at the far edge: it points on, not at the word.
|
||||
const cta = el(
|
||||
'button',
|
||||
{ type: 'button', className: 'btn btn-go' },
|
||||
el('span', { className: 'btn-label' }, t('native.cloud.cta')),
|
||||
icon(I.chevron, 20, 'chev')
|
||||
);
|
||||
cta.addEventListener('click', () => {
|
||||
void tap();
|
||||
showPlans();
|
||||
@@ -304,25 +331,32 @@ function showCloud(opts: { back?: boolean } = {}): void {
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
el(
|
||||
'div',
|
||||
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title') },
|
||||
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
|
||||
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
|
||||
el('span', { className: 'cloud-tag' }, 'cloud')
|
||||
),
|
||||
el('p', { className: 'sub' }, t('native.cloud.sub')),
|
||||
el(
|
||||
'div',
|
||||
{ className: 'features' },
|
||||
feature(I.globe, t('native.cloud.f1')),
|
||||
feature(I.bolt, t('native.cloud.f2')),
|
||||
feature(I.shield, t('native.cloud.f3'))
|
||||
),
|
||||
cta,
|
||||
have,
|
||||
footLinks()
|
||||
...columns(
|
||||
[
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
// The lockup is a logo: "trek" then "cloud" in every language, never mirrored.
|
||||
el(
|
||||
'div',
|
||||
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title'), dir: 'ltr' },
|
||||
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
|
||||
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
|
||||
el('span', { className: 'cloud-tag' }, 'cloud')
|
||||
),
|
||||
el('p', { className: 'sub' }, t('native.cloud.sub')),
|
||||
],
|
||||
[
|
||||
el(
|
||||
'div',
|
||||
{ className: 'features' },
|
||||
feature(I.globe, t('native.cloud.f1')),
|
||||
feature(I.bolt, t('native.cloud.f2')),
|
||||
feature(I.shield, t('native.cloud.f3'))
|
||||
),
|
||||
cta,
|
||||
have,
|
||||
footLinks(),
|
||||
]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -396,12 +430,14 @@ function offerFor(offer: Offer, plan: CloudPlan, interval: 'month' | 'year'): {
|
||||
|
||||
async function loadOffer(list: CloudPlans): Promise<Offer> {
|
||||
const available = await iapReady;
|
||||
// Closed: a store purchase would be refused at the cloud, so only a test build may buy.
|
||||
const opensSoon = available && list.closed && !(await iap.isSandbox().catch(() => false));
|
||||
const canBuy = available && !opensSoon;
|
||||
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon };
|
||||
// Closed: a real purchase is refused at the cloud, so only a test build may buy on iOS;
|
||||
// Android keeps the button for the licence testers (see cloudOrders.ts).
|
||||
const sandbox = list.closed && !onPlay ? await iap.isSandbox().catch(() => false) : false;
|
||||
const soon = opensSoon({ available, closed: list.closed, sandbox, onPlay });
|
||||
const canBuy = available && !soon;
|
||||
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon: soon };
|
||||
const ids = list.plans.flatMap((p) => [productIdFor(p.id, 'month'), productIdFor(p.id, 'year')]);
|
||||
return { canBuy, storePrices: await iap.prices(ids), opensSoon };
|
||||
return { canBuy, storePrices: await iap.prices(ids), opensSoon: soon };
|
||||
}
|
||||
|
||||
function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HTMLElement {
|
||||
@@ -427,7 +463,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HT
|
||||
{ className: 'plan-top' },
|
||||
el('span', { className: 'plan-name' }, plan.name),
|
||||
// No price yet (orders not open): the plan shows without one rather than with an empty one.
|
||||
...(label ? [el('span', { className: 'plan-price' }, label, el('small', {}, per))] : [])
|
||||
// The price is figures and a currency sign: one left-to-right run, so Arabic
|
||||
// keeps "5,99 €" whole, with the period standing apart beside it.
|
||||
...(label ? [el('span', { className: 'plan-price' }, el('bdi', { dir: 'ltr' }, label), el('small', {}, per))] : [])
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
@@ -491,7 +529,10 @@ function planFooter(offer: Offer, plans: CloudPlan[]): Node[] {
|
||||
void notify(NotificationType.Error);
|
||||
restore.disabled = false;
|
||||
status.className = 'hint bad';
|
||||
status.textContent = t('native.plans.restoreFailed');
|
||||
// Bought before the opening: refused, unacknowledged, and refunded by Google Play.
|
||||
status.textContent = refundedBeforeOpening(redeemed.code, onPlay)
|
||||
? t('native.claim.closedRefund')
|
||||
: t('native.plans.restoreFailed');
|
||||
});
|
||||
|
||||
// A payment that was pending (cash, some carrier billing) and has cleared since:
|
||||
@@ -578,12 +619,15 @@ function showPlans(opts: { back?: boolean } = {}): void {
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
topbar(() => showCloud({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.plans.title')),
|
||||
el('p', { className: 'sub' }, t('native.plans.sub')),
|
||||
seg,
|
||||
content,
|
||||
footer
|
||||
...columns(
|
||||
[
|
||||
topbar(() => showCloud({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.plans.title')),
|
||||
el('p', { className: 'sub' }, t('native.plans.sub')),
|
||||
seg,
|
||||
],
|
||||
[content, footer]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -661,9 +705,10 @@ function showClaim(
|
||||
slugInput.setAttribute('autocomplete', 'off');
|
||||
slugInput.setAttribute('maxlength', '40');
|
||||
slugInput.setAttribute('aria-label', t('native.claim.addressLabel'));
|
||||
// "name.liketrek.com" is one address, left to right in every language.
|
||||
const slugField = el(
|
||||
'div',
|
||||
{ className: 'field slug-field' },
|
||||
{ className: 'field slug-field', dir: 'ltr' },
|
||||
slugInput,
|
||||
el('span', { className: 'slug-suffix' }, cloudSuffix())
|
||||
);
|
||||
@@ -680,6 +725,8 @@ function showClaim(
|
||||
emailInput.setAttribute('autocomplete', 'email');
|
||||
emailInput.setAttribute('enterkeyhint', 'go');
|
||||
emailInput.setAttribute('aria-label', t('native.claim.emailLabel'));
|
||||
emailInput.dir = 'ltr';
|
||||
emailInput.classList.add('ltr-value');
|
||||
const emailField = el('div', { className: 'field' }, emailInput);
|
||||
|
||||
const submit = el('button', { type: 'submit', className: 'btn' }, t('native.claim.subscribe', { price }));
|
||||
@@ -797,7 +844,9 @@ function showClaim(
|
||||
slugNote.textContent = t('native.claim.taken');
|
||||
slugNote.className = 'hint bad';
|
||||
} else {
|
||||
error.textContent = t('native.claim.redeemFailed');
|
||||
error.textContent = refundedBeforeOpening(redeemed.code, onPlay)
|
||||
? t('native.claim.closedRefund')
|
||||
: t('native.claim.redeemFailed');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -805,10 +854,14 @@ function showClaim(
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
topbar(() => showPlans({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.claim.title')),
|
||||
el('p', { className: 'sub' }, t('native.claim.sub')),
|
||||
form
|
||||
...columns(
|
||||
[
|
||||
topbar(() => showPlans({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.claim.title')),
|
||||
el('p', { className: 'sub' }, t('native.claim.sub')),
|
||||
],
|
||||
[form]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -933,12 +986,14 @@ function showUnreachable(server: string): void {
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
|
||||
el('h1', { className: 'title-sm' }, t('native.offline.title')),
|
||||
el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })),
|
||||
retry,
|
||||
change,
|
||||
footLinks()
|
||||
...columns(
|
||||
[
|
||||
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
|
||||
el('h1', { className: 'title-sm' }, t('native.offline.title')),
|
||||
el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })),
|
||||
],
|
||||
[retry, change, footLinks()]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+324
-41
@@ -38,6 +38,45 @@
|
||||
font-display: swap;
|
||||
src: url('./fonts/museomoderno-latin-600.woff2') format('woff2');
|
||||
}
|
||||
/*
|
||||
* Poppins and MuseoModerno have no Cyrillic, so Russian and Ukrainian fell back
|
||||
* to the system's plain sans. Each family gets a Cyrillic-only stand-in under its
|
||||
* own name, picked for the same build: Montserrat for the geometric text face,
|
||||
* Comfortaa for the rounded headline. The unicode-range keeps Latin on TREK's
|
||||
* own faces and loads these only when a Cyrillic letter is on screen.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./fonts/montserrat-cyrillic-500.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/montserrat-cyrillic-600.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/montserrat-cyrillic-700.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'MuseoModerno';
|
||||
font-style: normal;
|
||||
font-weight: 600 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/comfortaa-cyrillic-700.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* Italic, subset to the letters of "cloud" — the wordmark's badge, nothing else. */
|
||||
@font-face {
|
||||
font-family: 'MuseoModerno';
|
||||
@@ -64,6 +103,19 @@
|
||||
/* The hairline the ambient grid is drawn in, and a travel-dusk wash for depth. */
|
||||
--grid: rgba(255, 255, 255, 0.055);
|
||||
--wash-1: rgba(129, 140, 248, 0.12);
|
||||
/* Mixes with transparent, spelled out per scheme: older Android web views (Chrome 113)
|
||||
resolved color-mix() towards transparent as opaque and drew solid halos and rings. */
|
||||
--accent-ink-a16: rgba(9, 9, 11, 0.16);
|
||||
--accent-ink-a35: rgba(9, 9, 11, 0.35);
|
||||
--danger-a16: rgba(248, 113, 113, 0.16);
|
||||
--ink-a22: rgba(250, 250, 250, 0.22);
|
||||
--ink-a3: rgba(250, 250, 250, 0.03);
|
||||
--ink-a4: rgba(250, 250, 250, 0.04);
|
||||
--ink-a9: rgba(250, 250, 250, 0.09);
|
||||
--ok-a16: rgba(74, 222, 128, 0.16);
|
||||
--ok-a22: rgba(74, 222, 128, 0.22);
|
||||
--wash-1-a85: rgba(129, 140, 248, 0.102);
|
||||
--wash-1-a90: rgba(129, 140, 248, 0.108);
|
||||
--wash-2: rgba(244, 114, 182, 0.08);
|
||||
--veil-top: rgba(9, 9, 11, 0.4);
|
||||
--veil-mid: rgba(9, 9, 11, 0.72);
|
||||
@@ -71,8 +123,13 @@
|
||||
--radius: 16px;
|
||||
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
/* 1 left to right, -1 in Arabic: a screen that comes next slides in from the trailing side. */
|
||||
--flow: 1;
|
||||
color-scheme: dark;
|
||||
}
|
||||
:root[dir='rtl'] {
|
||||
--flow: -1;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
@@ -91,6 +148,17 @@
|
||||
--ok: #16a34a;
|
||||
--grid: rgba(17, 24, 39, 0.075);
|
||||
--wash-1: rgba(79, 70, 229, 0.1);
|
||||
--accent-ink-a16: rgba(255, 255, 255, 0.16);
|
||||
--accent-ink-a35: rgba(255, 255, 255, 0.35);
|
||||
--danger-a16: rgba(220, 38, 38, 0.16);
|
||||
--ink-a22: rgba(17, 24, 39, 0.22);
|
||||
--ink-a3: rgba(17, 24, 39, 0.03);
|
||||
--ink-a4: rgba(17, 24, 39, 0.04);
|
||||
--ink-a9: rgba(17, 24, 39, 0.09);
|
||||
--ok-a16: rgba(22, 163, 74, 0.16);
|
||||
--ok-a22: rgba(22, 163, 74, 0.22);
|
||||
--wash-1-a85: rgba(79, 70, 229, 0.085);
|
||||
--wash-1-a90: rgba(79, 70, 229, 0.09);
|
||||
--wash-2: rgba(219, 39, 119, 0.07);
|
||||
--veil-top: rgba(255, 255, 255, 0.12);
|
||||
--veil-mid: rgba(255, 255, 255, 0.74);
|
||||
@@ -167,6 +235,18 @@ body::after {
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
/* In Arabic the photo holds the leading (right) side and the grid the other. */
|
||||
:root[dir='rtl'] body::after {
|
||||
background-position:
|
||||
0 0,
|
||||
right 28%;
|
||||
-webkit-mask-image: linear-gradient(270deg, #000 0%, #000 40%, transparent 80%);
|
||||
mask-image: linear-gradient(270deg, #000 0%, #000 40%, transparent 80%);
|
||||
}
|
||||
:root[dir='rtl'] body::before {
|
||||
-webkit-mask-image: radial-gradient(135% 95% at 0% 0%, #000 34%, transparent 80%);
|
||||
mask-image: radial-gradient(135% 95% at 0% 0%, #000 34%, transparent 80%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
@@ -175,11 +255,12 @@ body::after {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
max-width: 440px;
|
||||
margin: 0 auto;
|
||||
padding: calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 24px);
|
||||
overflow: hidden;
|
||||
/* Clip only the sideways slide: a screen taller than the window scrolls the
|
||||
page instead (and a sticky column needs an ancestor that does not scroll). */
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* ── Screen transitions ──────────────────────────────────────────────────── */
|
||||
@@ -187,14 +268,22 @@ body::after {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
/* Centred while it fits; taller than the window it starts at the top and the
|
||||
page scrolls, rather than overflowing both edges as justify-content would. */
|
||||
margin-block: auto;
|
||||
}
|
||||
/* A screen's two halves (main.ts, columns()): one column unless the layout asks. */
|
||||
.col {
|
||||
display: contents;
|
||||
}
|
||||
.screen-enter {
|
||||
animation: screenIn 0.5s var(--ease) both;
|
||||
}
|
||||
/* Lifted out where it stood: main.ts sets its top, left and width. */
|
||||
.screen-leave {
|
||||
animation: screenOut 0.28s var(--ease-in) both;
|
||||
position: absolute;
|
||||
inset: calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 24px);
|
||||
margin: 0;
|
||||
}
|
||||
.screen-back.screen-enter {
|
||||
animation-name: screenInBack;
|
||||
@@ -205,7 +294,7 @@ body::after {
|
||||
@keyframes screenIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(26px);
|
||||
transform: translateX(calc(26px * var(--flow)));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -215,13 +304,13 @@ body::after {
|
||||
@keyframes screenOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-22px);
|
||||
transform: translateX(calc(-22px * var(--flow)));
|
||||
}
|
||||
}
|
||||
@keyframes screenInBack {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-26px);
|
||||
transform: translateX(calc(-26px * var(--flow)));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -231,14 +320,40 @@ body::after {
|
||||
@keyframes screenOutBack {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(22px);
|
||||
transform: translateX(calc(22px * var(--flow)));
|
||||
}
|
||||
}
|
||||
|
||||
/* Children stagger in, so a screen assembles rather than snaps. */
|
||||
.screen-enter > * {
|
||||
/* Children stagger in, so a screen assembles rather than snaps. Inside the two
|
||||
halves the explanation comes first and the choices follow. */
|
||||
.screen-enter > :not(.col),
|
||||
.screen-enter > .col > * {
|
||||
animation: riseIn 0.55s var(--ease) both;
|
||||
}
|
||||
.screen-enter > .col > :nth-child(1) {
|
||||
animation-delay: 0.02s;
|
||||
}
|
||||
.screen-enter > .col > :nth-child(2) {
|
||||
animation-delay: 0.07s;
|
||||
}
|
||||
.screen-enter > .col > :nth-child(3) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
.screen-enter > .col > :nth-child(4) {
|
||||
animation-delay: 0.17s;
|
||||
}
|
||||
.screen-enter > .col-act > :nth-child(1) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.screen-enter > .col-act > :nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.screen-enter > .col-act > :nth-child(3) {
|
||||
animation-delay: 0.25s;
|
||||
}
|
||||
.screen-enter > .col-act > :nth-child(4) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.screen-enter > *:nth-child(1) {
|
||||
animation-delay: 0.02s;
|
||||
}
|
||||
@@ -307,16 +422,16 @@ body::after {
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
animation: floaty 6s ease-in-out infinite;
|
||||
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
|
||||
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
|
||||
filter: drop-shadow(0 14px 36px var(--wash-1-a90))
|
||||
drop-shadow(0 6px 16px var(--ink-a22));
|
||||
}
|
||||
|
||||
/* The full TREK wordmark on the welcome screen, in the variant that reads on the scheme. */
|
||||
.hero-logo {
|
||||
display: block;
|
||||
margin-bottom: 22px;
|
||||
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
|
||||
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
|
||||
filter: drop-shadow(0 14px 36px var(--wash-1-a90))
|
||||
drop-shadow(0 6px 16px var(--ink-a22));
|
||||
animation: logoRise 0.7s var(--ease) both;
|
||||
}
|
||||
.hero-logo img {
|
||||
@@ -364,6 +479,7 @@ body::after {
|
||||
margin: 0;
|
||||
/* Honour the newline in the headline string (e.g. "Deine Reisen.\nDein Plan."). */
|
||||
white-space: pre-line;
|
||||
text-wrap: balance;
|
||||
}
|
||||
/* The welcome headline as the brand says it: MuseoModerno, lower case. */
|
||||
.brand-title {
|
||||
@@ -380,6 +496,8 @@ body::after {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
/* Left to right inside (dir="ltr"), yet on the screen's start edge in Arabic. */
|
||||
align-self: flex-start;
|
||||
}
|
||||
.cloud-wordmark img {
|
||||
display: block;
|
||||
@@ -414,6 +532,7 @@ body::after {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.sub {
|
||||
color: var(--muted);
|
||||
@@ -421,6 +540,8 @@ body::after {
|
||||
line-height: 1.55;
|
||||
font-size: 0.9rem;
|
||||
overflow-wrap: anywhere;
|
||||
/* No lone last word ("AI.") on a line of its own. */
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
@@ -428,9 +549,10 @@ body::after {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 1.02rem;
|
||||
padding: 16px 18px;
|
||||
padding: 16px 22px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
/* A pill, as the app's buttons are. */
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
cursor: pointer;
|
||||
@@ -455,13 +577,18 @@ body::after {
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
/* A two-line choice (welcome) is a card rather than a button: the app's card radius. */
|
||||
.btn:has(.btn-row) {
|
||||
border-radius: 22px;
|
||||
padding-inline: 18px;
|
||||
}
|
||||
.btn-ghost {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
border-color: var(--edge);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.22),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
inset 0 0 0 1px var(--ink-a4);
|
||||
}
|
||||
.btn-ghost:active {
|
||||
background: var(--surface-2);
|
||||
@@ -471,6 +598,8 @@ body::after {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
/* A subtitle that wraps keeps to the start edge under its title. */
|
||||
text-align: start;
|
||||
}
|
||||
.btn-row b {
|
||||
font-weight: 650;
|
||||
@@ -481,9 +610,20 @@ body::after {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.btn .chev {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
opacity: 0.5;
|
||||
}
|
||||
/* A centred label with the chevron at the far edge, the label optically centred. */
|
||||
.btn-go .btn-label {
|
||||
flex: 1;
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
/* Chevrons point along the reading direction. */
|
||||
:root[dir='rtl'] .chev,
|
||||
:root[dir='rtl'] .back svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.linkbtn {
|
||||
font: inherit;
|
||||
@@ -507,7 +647,7 @@ body::after {
|
||||
margin-top: 2px;
|
||||
padding: 9px 18px;
|
||||
min-height: 40px;
|
||||
border-radius: var(--radius);
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--edge);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
@@ -565,15 +705,15 @@ a.linkbtn {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
padding: 13px 16px;
|
||||
border-radius: var(--radius);
|
||||
padding: 13px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--edge);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.18),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
inset 0 1px 0 var(--ink-a4);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.25s;
|
||||
@@ -585,11 +725,11 @@ a.linkbtn {
|
||||
}
|
||||
.field input:focus {
|
||||
border-color: var(--edge-strong);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
|
||||
box-shadow: 0 0 0 4px var(--ink-a9);
|
||||
}
|
||||
.field.bad input {
|
||||
border-color: var(--danger);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
box-shadow: 0 0 0 4px var(--danger-a16);
|
||||
animation: shake 0.3s;
|
||||
}
|
||||
@keyframes shake {
|
||||
@@ -617,7 +757,7 @@ a.linkbtn {
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid color-mix(in srgb, var(--accent-ink) 35%, transparent);
|
||||
border: 2px solid var(--accent-ink-a35);
|
||||
border-top-color: var(--accent-ink);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
@@ -672,7 +812,7 @@ a.linkbtn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--ok) 16%, transparent);
|
||||
background: var(--ok-a16);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 8px;
|
||||
@@ -707,7 +847,8 @@ a.linkbtn {
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 0 2px -8px;
|
||||
margin-block: 0 2px;
|
||||
margin-inline-start: -8px;
|
||||
}
|
||||
.back {
|
||||
font: inherit;
|
||||
@@ -741,7 +882,7 @@ a.linkbtn {
|
||||
padding: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 13px;
|
||||
border-radius: 999px;
|
||||
align-self: stretch;
|
||||
}
|
||||
.segmented button {
|
||||
@@ -751,7 +892,7 @@ a.linkbtn {
|
||||
font-size: 0.95rem;
|
||||
padding: 9px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
@@ -772,11 +913,11 @@ a.linkbtn {
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ok) 22%, transparent);
|
||||
background: var(--ok-a22);
|
||||
color: var(--ok);
|
||||
}
|
||||
.segmented button[aria-pressed='true'] .save {
|
||||
background: color-mix(in srgb, var(--accent-ink) 16%, transparent);
|
||||
background: var(--accent-ink-a16);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
@@ -792,7 +933,7 @@ a.linkbtn {
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
0 2px 10px -4px rgba(0, 0, 0, 0.3),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--ink) 3%, transparent);
|
||||
inset 0 0 0 1px var(--ink-a3);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s,
|
||||
@@ -800,7 +941,7 @@ a.linkbtn {
|
||||
}
|
||||
.plan.featured {
|
||||
box-shadow:
|
||||
0 10px 30px -10px color-mix(in srgb, var(--wash-1) 85%, transparent),
|
||||
0 10px 30px -10px var(--wash-1-a85),
|
||||
0 2px 8px -3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.plan:active {
|
||||
@@ -829,7 +970,7 @@ a.linkbtn {
|
||||
font-weight: 500;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-left: 7px;
|
||||
margin-inline-start: 7px;
|
||||
}
|
||||
.plan-feats {
|
||||
margin: 10px 0 14px;
|
||||
@@ -902,39 +1043,45 @@ a.linkbtn {
|
||||
.slug-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 16px;
|
||||
border-radius: var(--radius);
|
||||
padding-inline-end: 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--edge);
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.18),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
inset 0 1px 0 var(--ink-a4);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.25s;
|
||||
}
|
||||
.slug-field .slug-input {
|
||||
.slug-field .slug-input,
|
||||
.slug-field .slug-input:focus {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
/* The ring is the whole field's (focus-within below), not a second one inside it. */
|
||||
box-shadow: none;
|
||||
}
|
||||
/* An email is typed left to right, but its Arabic placeholder reads from the right. */
|
||||
:root[dir='rtl'] .field input.ltr-value:placeholder-shown {
|
||||
direction: rtl;
|
||||
}
|
||||
.slug-field:focus-within {
|
||||
border-color: var(--edge-strong);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
|
||||
box-shadow: 0 0 0 4px var(--ink-a9);
|
||||
}
|
||||
.slug-field.bad {
|
||||
border-color: var(--danger);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
box-shadow: 0 0 0 4px var(--danger-a16);
|
||||
animation: shake 0.3s;
|
||||
}
|
||||
.slug-suffix {
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
padding-left: 4px;
|
||||
padding-inline-start: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
.hint {
|
||||
@@ -976,3 +1123,139 @@ a.linkbtn {
|
||||
border-color: var(--edge-strong);
|
||||
border-top-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── A large screen ──────────────────────────────────────────────────────────
|
||||
On its side (an iPad, a foldable unfolded) the photo becomes a panel of its
|
||||
own on the leading half, full height, and the column of choices stands
|
||||
centred in the other half, the way a split sign-in page uses the width.
|
||||
Upright, the photo band grows with the screen instead of stopping at a
|
||||
phone's 440px. Logical sides, so Arabic mirrors it. */
|
||||
@media (min-width: 840px) and (min-height: 600px) and (orientation: landscape) {
|
||||
body::after,
|
||||
:root[dir='rtl'] body::after {
|
||||
inset: 16px auto 16px 16px;
|
||||
inset-block: 16px;
|
||||
inset-inline: 16px auto;
|
||||
width: calc(50% - 24px);
|
||||
height: auto;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(180deg, transparent 50%, rgba(0, 0, 0, 0.32) 100%),
|
||||
url('./img/kyoto.webp') 30% 45% / cover no-repeat;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
box-shadow: 0 30px 70px -30px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
body::before {
|
||||
inset: 0;
|
||||
inset-inline-start: 50%;
|
||||
height: auto;
|
||||
}
|
||||
.shell {
|
||||
max-width: none;
|
||||
width: 50%;
|
||||
margin-inline: 50% 0;
|
||||
padding-inline: max(32px, calc(25vw - 220px));
|
||||
}
|
||||
}
|
||||
/* With the whole half of a 13" iPad on its side, the plans stand two by two
|
||||
rather than in a column to scroll through. */
|
||||
@media (min-width: 1200px) and (min-height: 600px) and (orientation: landscape) {
|
||||
.shell:has(.plans) {
|
||||
padding-inline: max(32px, calc(25vw - 320px));
|
||||
}
|
||||
.plans {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
/* An iPad upright, a foldable unfolded, half of an iPad in Split View: the photo
|
||||
spans the whole width as a band across the top and fades out only downwards,
|
||||
and the choices stand under it in a wider column than a phone's, centred in
|
||||
the room the band leaves, so the page reads as one composition rather than a
|
||||
phone screen at the top of the glass. The plans take two columns. */
|
||||
@media (min-width: 600px) and (min-height: 800px) and (orientation: portrait) {
|
||||
body::before,
|
||||
body::after {
|
||||
height: 56vh;
|
||||
}
|
||||
body::after,
|
||||
:root[dir='rtl'] body::after {
|
||||
background-position:
|
||||
0 0,
|
||||
center 30%;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
.shell {
|
||||
max-width: 560px;
|
||||
padding-top: calc(env(safe-area-inset-top) + 28vh);
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 6vh);
|
||||
}
|
||||
.shell:has(.plans) {
|
||||
max-width: 760px;
|
||||
padding-top: calc(env(safe-area-inset-top) + 16vh);
|
||||
}
|
||||
.plans {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── A short screen on its side ──────────────────────────────────────────────
|
||||
A phone, or a foldable folded, turned landscape: one column of choices is
|
||||
taller than the glass, and the second choice or the main button ended up
|
||||
below the edge. The two halves of a screen stand side by side instead, what
|
||||
it is on the leading side and what to do on the other, top-aligned under the
|
||||
status bar. The leading half stays put while a long list beside it (the
|
||||
plans) scrolls, so Back is always in reach. */
|
||||
@media (orientation: landscape) and (max-height: 500px) {
|
||||
.shell {
|
||||
max-width: 880px;
|
||||
padding-top: calc(env(safe-area-inset-top) + 16px);
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 16px);
|
||||
padding-inline: max(28px, env(safe-area-inset-left)) max(28px, env(safe-area-inset-right));
|
||||
}
|
||||
.screen:has(> .col) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
column-gap: 40px;
|
||||
align-items: center;
|
||||
}
|
||||
/* The plans run longer than the window: both halves start at the top. */
|
||||
.screen:has(.plans) {
|
||||
align-items: start;
|
||||
}
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.col-about {
|
||||
position: sticky;
|
||||
top: calc(env(safe-area-inset-top) + 16px);
|
||||
}
|
||||
.hero-logo {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.hero-logo img {
|
||||
height: 46px;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.btn {
|
||||
min-height: 50px;
|
||||
padding-block: 12px;
|
||||
}
|
||||
.features {
|
||||
margin: 0;
|
||||
}
|
||||
.feature {
|
||||
padding: 7px 0;
|
||||
}
|
||||
.foot-links {
|
||||
margin-top: 0;
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "5.1.0",
|
||||
"version": "5.1.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 375,
|
||||
"@typescript-eslint/no-misused-promises": 847,
|
||||
"@typescript-eslint/no-misused-promises": 845,
|
||||
"no-console": 88,
|
||||
"complexity": 218,
|
||||
"@typescript-eslint/no-explicit-any": 144,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 489,
|
||||
"@typescript-eslint/no-non-null-assertion": 1912,
|
||||
"@typescript-eslint/no-non-null-assertion": 1911,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -282,11 +282,17 @@
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx", "src/mobile/screens/trip/tabs/MCostsTab.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Costs/budget ledger: useCostsLedger, useSettlementForm, useExpenseLinks",
|
||||
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts", "src/components/Budget/useExpenseLinks.ts"],
|
||||
"feature": "Costs/budget ledger: useCostsLedger, useExpenseLinks",
|
||||
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useExpenseLinks.ts"],
|
||||
"desktop": ["src/components/Budget/CostsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MCostsTab.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Costs/budget settle-up payment: useSettlementForm",
|
||||
"hooks": ["src/components/Budget/useSettlementForm.ts"],
|
||||
"desktop": ["src/components/Budget/CostsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MCostsPayment.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Create/edit trip form",
|
||||
"hooks": ["src/components/Trips/tripFormModel.ts", "src/components/Trips/useTripCoverPicker.ts"],
|
||||
@@ -663,6 +669,12 @@
|
||||
"desktop": ["src/components/Roadtrip/RoadtripStopPopup.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Road-trip Google Maps route import",
|
||||
"hooks": ["src/components/Roadtrip/useGoogleRouteImport.ts"],
|
||||
"desktop": ["src/components/Roadtrip/GoogleRouteImport.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtRouteImportSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Road-trip stay length draft",
|
||||
"hooks": ["src/components/Roadtrip/useStayDraft.ts"],
|
||||
@@ -822,6 +834,12 @@
|
||||
"desktop": ["src/components/shared/Toast.tsx"],
|
||||
"mobile": ["src/mobile/components/MToastHost.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Tour editor workspace",
|
||||
"hooks": ["src/pages/tripPlanner/useTripPlannerPage.ts"],
|
||||
"desktop": ["src/pages/TripPlannerPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tours/MTourWorkspace.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Tours list filter",
|
||||
"hooks": ["src/components/Tours/tourPresentation.ts"],
|
||||
@@ -1002,11 +1020,13 @@
|
||||
"src/mobile/screens/collections/MCollInvites.tsx": "Markup only; the invites and accept and decline come through props from useCollections via MCollections.",
|
||||
"src/mobile/screens/collections/MCollLabelsSheet.tsx": "Phone label sheet; create, update, delete and assign come through props from useCollections.",
|
||||
"src/mobile/screens/collections/MCollLinksEditor.tsx": "Markup only; link rows and the change handler come through props from the collection sheets.",
|
||||
"src/mobile/screens/collections/MCollListNav.tsx": "Markup only; the collections' lists as the header dropdown or as the column beside the places on a wide window, with the lists and the selection handed in through props from useCollections.",
|
||||
"src/mobile/screens/collections/MCollListPickerSheet.tsx": "Phone list picker sheet; the move or copy action comes through onPick from useCollections.",
|
||||
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
|
||||
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
|
||||
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
|
||||
"src/mobile/screens/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.",
|
||||
"src/mobile/screens/journey/MJourneyFeed.tsx": "Markup only; the journey's entries as a feed beside the map on a wide window, with the entries, the active one and the tap handed in through props from useJourneyDetail and useJourneyCarouselSync.",
|
||||
"src/mobile/screens/settings/MAppLockCard.tsx": "Phone only: the app lock exists only in the native app, so there is no desktop setting to share with.",
|
||||
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
|
||||
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
|
||||
@@ -1018,6 +1038,9 @@
|
||||
"src/mobile/screens/settings/MSettingsUi.tsx": "Phone only settings UI primitives (card, row, input, button, segments, hint).",
|
||||
"src/mobile/screens/settings/MTileCacheUsage.tsx": "Phone only: the native tile cache size and clear action, which the browser build has no counterpart for.",
|
||||
"src/mobile/screens/settings/MWebPushCard.tsx": "Already shares hooks/useWebPush with components/Settings/WebPushCard.tsx.",
|
||||
"src/mobile/screens/trip/tabHeadSlot.ts": "Phone shell only; the place in the shell's top row the tours tab and the journal fill with their own switches. The desktop heads its panels itself.",
|
||||
"src/mobile/screens/trip/MTripRail.tsx": "Phone shell only; the trip's sections on the leading edge of a wide window in place of the dock, with the tabs and handlers MTripShell hands in.",
|
||||
"src/mobile/screens/trip/MTripShellParts.tsx": "Markup only; small pieces of MTripShell's top controls (segment pill, compact toggle, document sync button), with the state and handlers MTripShell hands in.",
|
||||
"src/mobile/screens/trip/dockTabs.ts": "Phone only helper that picks which trip tabs get a seat in the mobile dock.",
|
||||
"src/mobile/screens/trip/lib/dayNotes.ts": "Phone only helper that splits a leading time off a day note for the plan timeline.",
|
||||
"src/mobile/screens/trip/map/MMapArea.tsx": "Phone map stage framing; already shares usePoiExplore and useMergedMapPois with the desktop trip planner.",
|
||||
@@ -1035,8 +1058,10 @@
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": "Markup only; its corridor state comes through props from useMRtCorridor.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": "Phone corridor search sheet over useMRtCorridor and the planner's shared corridor state.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": "Read only figures sheet that already shares useHotelBookends, dayWindow and parseAvoid with RoadtripLimitsCard.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx": "Large screen form inside MRtInfoSheet over the planner's saveRoadtripLimit, the desktop dialog's own save, and the shared dayWindow, parseAvoid and rangeFromSpec.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtTrackSheet.tsx": "Large screen sheet over the planner's followTrack, the same hook RoadtripTrackModal runs on; the phone has no entry to it.",
|
||||
"src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.",
|
||||
"src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": "Phone stage screen adapter over the shared roadtripRowModel plus a live clock tick.",
|
||||
"src/mobile/screens/trip/roadtrip/useMRtAlternatives.ts": "Tap to preview, confirm to write layer over the planner's shared route alternatives.",
|
||||
@@ -1053,10 +1078,13 @@
|
||||
"src/mobile/screens/trip/sheets/PlTimeFields.tsx": "Phone time fields that already share timeCollisions with the desktop PlaceFormModal.",
|
||||
"src/mobile/screens/trip/sheets/tourSheetOpen.ts": "Phone only context for MTourDetailShell: whether the tour sheet is open and the name it carries.",
|
||||
"src/mobile/screens/trip/tabs/MCollabTab.tsx": "Phone collab sub tab router that reuses the desktop CollabLinks component.",
|
||||
"src/mobile/screens/trip/tabs/MFilesBoard.tsx": "Markup only; the trip's files as tiles and the picked file's preview on a wide window, with the files and their actions handed in through props from MFilesTab.",
|
||||
"src/mobile/screens/trip/tabs/MListsTab.tsx": "Phone router between the packing and to-do bodies, with no logic of its own.",
|
||||
"src/mobile/screens/trip/tabs/MReceiptScanButton.tsx": "Already shares useReceiptScan and ReceiptScanModal with the desktop CostsPanel.",
|
||||
"src/mobile/screens/trip/tabs/MReservationCardParts.tsx": "Markup only: the title, actions, date, files and delete sheet parts both phone booking tabs draw.",
|
||||
"src/mobile/screens/trip/tabs/MTodoSidebar.tsx": "Markup only; the to-do filters as a pane beside the tasks on a wide window, with the filter state and the counts handed in through props from useTodoView.",
|
||||
"src/mobile/screens/trip/tabs/MTripTabPanel.tsx": "Phone tab router that mounts the per tab screens and plugin frames, with no logic of its own.",
|
||||
"src/mobile/screens/trip/tabs/collabPanes.ts": "Phone only; decides whether the collab tab shows the chat beside a board on a wide window, a layout question of the touch shell that the desktop planner does not have.",
|
||||
"src/mobile/screens/trip/tabs/listsModel.ts": "Priority colour and label constants only the phone to-do list and task sheet use.",
|
||||
"src/mobile/screens/trip/tabs/tabChrome.tsx": "Phone tab chrome primitives (scroller, section headers, fields, traveler chips, plugin slots).",
|
||||
"src/mobile/screens/trip/tabs/tabModel.ts": "Phone tab prop type and status colour tokens only the phone tabs use.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"admin.ts": 112,
|
||||
"atlas.ts": 9,
|
||||
"backup.ts": 10,
|
||||
"budget.ts": 36,
|
||||
"budget.ts": 35,
|
||||
"categories.ts": 1,
|
||||
"collab.ts": 9,
|
||||
"collection.ts": 35,
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
"components/Map/DawarichTrailPill.tsx": 1,
|
||||
"components/Map/LocationButton.tsx": 1,
|
||||
"components/Map/MapTogglePill.tsx": 1,
|
||||
"components/Map/MapView.tsx": 6,
|
||||
"components/Map/MapViewGL.tsx": 8,
|
||||
"components/Map/MapView.tsx": 5,
|
||||
"components/Map/MapViewGL.tsx": 7,
|
||||
"components/Map/NightPauseTooltip.tsx": 1,
|
||||
"components/Map/PlaceHoverCard.tsx": 1,
|
||||
"components/Map/PoiCategoryPill.tsx": 1,
|
||||
@@ -34,6 +34,7 @@
|
||||
"components/Map/locationMarkerMapbox.ts": 2,
|
||||
"components/Map/nightPauseMarker.ts": 4,
|
||||
"components/Map/ratingBadge.ts": 1,
|
||||
"components/Map/switcherRow.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 7,
|
||||
"components/PDF/tripMapImage.ts": 1,
|
||||
"components/Packing/PackingListPanelBagCard.tsx": 1,
|
||||
@@ -79,15 +80,10 @@
|
||||
"hooks/useAnchoredPosition.ts": 3,
|
||||
"hooks/useElementRect.ts": 3,
|
||||
"mobile/components/MSegmented.tsx": 1,
|
||||
"mobile/components/MSheet.tsx": 1,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 1,
|
||||
"mobile/screens/journey/MJourneyDetail.tsx": 3,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 1,
|
||||
"mobile/screens/trip/MTripShell.tsx": 3,
|
||||
"mobile/screens/trip/map/MMapArea.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 1,
|
||||
"mobile/screens/trip/tabs/MFileLightbox.tsx": 2,
|
||||
"mobile/screens/vacay/MVacay.tsx": 1,
|
||||
"mobile/screens/vacay/MVacayMonth.tsx": 1,
|
||||
"pages/AtlasPage.tsx": 2,
|
||||
"pages/JourneyDetailPage.tsx": 1,
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2730,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1214,
|
||||
"src/components/Map/MapView.tsx": 1200,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2127,
|
||||
"src/components/Map/MapViewGL.tsx": 2126,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
||||
@@ -20,7 +20,7 @@
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4028,
|
||||
"src/pages/LoginPage.tsx": 1347,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
"src/pages/atlas/useAtlas.ts": 1210,
|
||||
"src/pages/atlas/useAtlas.ts": 1207,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 1918
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"components/Journey/JourneyDetailPageProviderPicker.tsx": 163,
|
||||
"components/Journey/JourneyDetailPageScrollTrigger.tsx": 4,
|
||||
"components/Journey/JourneyDetailPageVerdictSection.tsx": 51,
|
||||
"components/Journey/JourneyEntryCover.tsx": 15,
|
||||
"components/Journey/JourneyEntryCover.tsx": 13,
|
||||
"components/Journey/JourneyMap.tsx": 3,
|
||||
"components/Journey/JourneyMapGL.tsx": 5,
|
||||
"components/Journey/MobileEntryView.tsx": 94,
|
||||
@@ -60,8 +60,8 @@
|
||||
"components/Map/LocationButton.tsx": 1,
|
||||
"components/Map/MapPluginMarkers.tsx": 2,
|
||||
"components/Map/MapTogglePill.tsx": 1,
|
||||
"components/Map/MapView.tsx": 8,
|
||||
"components/Map/MapViewGL.tsx": 11,
|
||||
"components/Map/MapView.tsx": 7,
|
||||
"components/Map/MapViewGL.tsx": 10,
|
||||
"components/Map/NightPauseTooltip.tsx": 1,
|
||||
"components/Map/PlaceHoverCard.tsx": 1,
|
||||
"components/Map/ReservationOverlay.tsx": 5,
|
||||
@@ -71,6 +71,7 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Map/switcherRow.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 51,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
@@ -146,7 +147,7 @@
|
||||
"mobile/components/MStorageTestResult.tsx": 3,
|
||||
"mobile/components/MToastHost.tsx": 2,
|
||||
"mobile/components/MUserPickerList.tsx": 1,
|
||||
"mobile/screens/admin/MAdmin.tsx": 9,
|
||||
"mobile/screens/admin/MAdmin.tsx": 8,
|
||||
"mobile/screens/admin/MAdminAddonManager.tsx": 9,
|
||||
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
|
||||
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
|
||||
@@ -181,9 +182,9 @@
|
||||
"mobile/screens/collections/MCollShareSheet.tsx": 14,
|
||||
"mobile/screens/collections/MCollSheetKit.tsx": 5,
|
||||
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
|
||||
"mobile/screens/collections/MCollections.tsx": 18,
|
||||
"mobile/screens/collections/MCollections.tsx": 14,
|
||||
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 31,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 30,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 12,
|
||||
"mobile/screens/dashboard/MUserMenu.tsx": 7,
|
||||
"mobile/screens/journey/MJourney.tsx": 10,
|
||||
@@ -202,7 +203,7 @@
|
||||
"mobile/screens/settings/MPhotoProvidersSection.tsx": 2,
|
||||
"mobile/screens/settings/MScopeGroupPicker.tsx": 5,
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 1,
|
||||
"mobile/screens/settings/MSettings.tsx": 2,
|
||||
"mobile/screens/settings/MSettings.tsx": 1,
|
||||
"mobile/screens/settings/MSettingsAbout.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 20,
|
||||
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
|
||||
@@ -214,7 +215,7 @@
|
||||
"mobile/screens/settings/MSettingsUi.tsx": 11,
|
||||
"mobile/screens/settings/MWebPushCard.tsx": 1,
|
||||
"mobile/screens/trip/MTripLoadingSplash.tsx": 2,
|
||||
"mobile/screens/trip/MTripShell.tsx": 9,
|
||||
"mobile/screens/trip/MTripShell.tsx": 7,
|
||||
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
|
||||
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 2,
|
||||
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
|
||||
@@ -262,7 +263,7 @@
|
||||
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
|
||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
|
||||
"mobile/screens/trip/tabs/MCollabTab.tsx": 1,
|
||||
"mobile/screens/trip/tabs/MCostsTab.tsx": 71,
|
||||
"mobile/screens/trip/tabs/MCostsTab.tsx": 70,
|
||||
"mobile/screens/trip/tabs/MDocSyncSheet.tsx": 43,
|
||||
"mobile/screens/trip/tabs/MFileLightbox.tsx": 12,
|
||||
"mobile/screens/trip/tabs/MFileLinkSheet.tsx": 2,
|
||||
|
||||
@@ -36,6 +36,7 @@ import NewVersionNotice from './components/Layout/NewVersionNotice'
|
||||
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
|
||||
import AppLockGate from './components/Layout/AppLockGate'
|
||||
import NativeImportSheet from './mobile/components/NativeImportSheet'
|
||||
import MPushPrompt from './mobile/components/MPushPrompt'
|
||||
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
import HelpPanel from './components/Help/HelpPanel'
|
||||
@@ -453,6 +454,7 @@ export default function App() {
|
||||
<ErrorBoundary boundaryId="widget:native-update" fallback={null}><NativeUpdateNotice /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:app-lock" fallback={null}><AppLockGate /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:native-import" fallback={null}><NativeImportSheet /></ErrorBoundary>
|
||||
{!hideAuthedWidgets && isPhone && <ErrorBoundary boundaryId="widget:push-prompt" fallback={null}><MPushPrompt /></ErrorBoundary>}
|
||||
<ErrorBoundary boundaryId="widget:native-widgets" fallback={null}><NativeWidgetsSync /></ErrorBoundary>
|
||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||
across navigations. react-router runs location updates inside a transition,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-COLLAB-CHATDATA-001 to FE-COLLAB-CHATDATA-016: the trip chat logic behind the desktop
|
||||
// FE-COLLAB-CHATDATA-001 to FE-COLLAB-CHATDATA-018: the trip chat logic behind the desktop
|
||||
// Collab panel (quiet older-page errors, scroll on remote delete, upload progress) and the
|
||||
// phone's chat tab (toasted older-page errors, sending gated on the edit permission).
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
|
||||
import { createElement, type ChangeEvent } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { collabApi } from '../../api/client';
|
||||
@@ -285,3 +285,80 @@ describe('useCollabChatData', () => {
|
||||
expect(toast.error).toHaveBeenCalledWith('common.error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCollabChatData on a box that changes size', () => {
|
||||
// The observer reports when the test says the list's box has changed.
|
||||
let report: () => void = () => {};
|
||||
let observed: Element[] = [];
|
||||
class Observer {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
report = () => callback([], this as unknown as ResizeObserver);
|
||||
}
|
||||
observe(el: Element) {
|
||||
observed.push(el);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The list as both views draw it: only once the messages are in, so at mount there
|
||||
* is no element yet. Its sizes are the test's to set, since jsdom lays nothing out.
|
||||
*/
|
||||
function ChatList({ scrollHeight }: { scrollHeight: () => number }) {
|
||||
const chat = useCollabChatData({
|
||||
tripId: 1,
|
||||
t,
|
||||
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
});
|
||||
const setList = (el: HTMLDivElement | null) => {
|
||||
if (el && !Object.getOwnPropertyDescriptor(el, 'scrollHeight')) {
|
||||
let scrollTop = 0;
|
||||
Object.defineProperty(el, 'scrollHeight', { configurable: true, get: scrollHeight });
|
||||
Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => 400 });
|
||||
Object.defineProperty(el, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (v: number) => {
|
||||
scrollTop = v;
|
||||
},
|
||||
});
|
||||
el.scrollTo = () => {};
|
||||
}
|
||||
(chat.scrollRef as { current: HTMLDivElement | null }).current = el;
|
||||
};
|
||||
if (chat.loading || chat.messages.length === 0) return createElement('p', null, 'loading');
|
||||
return createElement('div', { 'data-testid': 'list', ref: setList, onScroll: chat.checkAtBottom });
|
||||
}
|
||||
|
||||
async function mountList(height: number) {
|
||||
let scrollHeight = height;
|
||||
render(createElement(ChatList, { scrollHeight: () => scrollHeight }));
|
||||
const list = await screen.findByTestId('list');
|
||||
return { list, grow: (to: number) => (scrollHeight = to) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observed = [];
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('FE-COLLAB-CHATDATA-017: a list at its newest message stays there when a rotation or a fold resizes it', async () => {
|
||||
vi.stubGlobal('ResizeObserver', Observer);
|
||||
const { list, grow } = await mountList(400);
|
||||
expect(observed).toContain(list);
|
||||
grow(900);
|
||||
report();
|
||||
expect(list.scrollTop).toBe(900);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-CHATDATA-018: a list read further up keeps its place', async () => {
|
||||
vi.stubGlobal('ResizeObserver', Observer);
|
||||
const { list, grow } = await mountList(2000);
|
||||
list.scrollTop = 200;
|
||||
fireEvent.scroll(list);
|
||||
grow(2600);
|
||||
report();
|
||||
expect(list.scrollTop).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,22 @@ export function useCollabChatData({
|
||||
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
|
||||
}, []);
|
||||
|
||||
// A list that sat at its newest message stays there when its box changes size (a
|
||||
// rotation, a fold, a pane opening beside it): the messages wrap anew and the list
|
||||
// grows, which left the newest ones below the composer. Both views draw the list
|
||||
// only once there are messages to show, so the observer attaches when it appears,
|
||||
// not at mount, when there is nothing yet to observe.
|
||||
const listShown = !loading && messages.length > 0;
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!listShown || !el || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (isAtBottom.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [listShown]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
@@ -77,8 +77,9 @@ describe('JournalBody', () => {
|
||||
const { container } = render(<JournalBody text={'# Big\n\n### Small'} />);
|
||||
expect(container.querySelector('h1')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('h3')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Big').tagName).toBe('P');
|
||||
expect(screen.getByText('Small').tagName).toBe('P');
|
||||
// Each paragraph's text sits in an isolate (<bdi>) so it keeps its own direction.
|
||||
expect(screen.getByText('Big').parentElement?.tagName).toBe('P');
|
||||
expect(screen.getByText('Small').parentElement?.tagName).toBe('P');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-012: keeps a setext underline as text instead of a heading', () => {
|
||||
|
||||
@@ -15,13 +15,14 @@ export default function JournalBody({ text, dark }: Props) {
|
||||
lineHeight: 1.6,
|
||||
color: 'inherit',
|
||||
}}>
|
||||
{/* Each paragraph in its own direction (bdi): a Latin story in the Arabic UI keeps its full stop at its end. */}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={{
|
||||
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
p: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote style={{
|
||||
borderInlineStart: `3px solid var(--journal-accent)`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-COVER-001 to FE-JRN-COVER-010
|
||||
// FE-JRN-COVER-001 to FE-JRN-COVER-011
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -133,4 +133,25 @@ describe('JourneyEntryCover', () => {
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
|
||||
expect(container.querySelector('svg.lucide-play')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-011: only the phone draws a card without a photo in the deep day colour, opaque even as a suggestion', () => {
|
||||
const entry = buildEntry({ photos: [], type: 'skeleton' })
|
||||
const phone = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} tone="mobile" />)
|
||||
const phoneCard = phone.getByRole('button')
|
||||
// d9, the deep wash (jsdom spells the colour out as rgba)
|
||||
expect(phoneCard.style.backgroundImage).toMatch(/rgba\(99, 102, 241, 0\.85/)
|
||||
expect(phoneCard.className).not.toContain('opacity-90')
|
||||
expect(phone.container.querySelector('svg.lucide-map-pin')?.getAttribute('class')).toContain('-mt-6')
|
||||
phone.unmount()
|
||||
|
||||
// The desktop and shared timelines keep their faint wash and the day-coloured pin.
|
||||
const desk = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
|
||||
const deskCard = desk.getByRole('button')
|
||||
// 2e, the faint one
|
||||
expect(deskCard.style.backgroundImage).toMatch(/rgba\(99, 102, 241, 0\.18/)
|
||||
expect(deskCard.className).toContain('opacity-90')
|
||||
const pin = desk.container.querySelector('svg.lucide-map-pin') as SVGElement
|
||||
expect(pin.getAttribute('class')).not.toContain('-mt-6')
|
||||
expect(pin.style.color).not.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user