From e59af6dcb3648534a14bf7f950c3bf8ad0fb74ce Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 12:46:58 +0100 Subject: [PATCH] feat(native): TREK on Apple Watch, with complications A watchOS app embedded in the iPhone app: today's next stop with its countdown, the next booking with reference, seat and platform, the trip with its days and places (Directions into Apple Maps), and the packing list and to-dos to tick off. The phone sends the snapshot through WatchConnectivity; ticks go back as queued user info. Three complications (next stop, countdown, trip day) in every accessory family, scored for the Smart Stack. 28 languages. The native workflow captures every screen from watchOS simulators in a screenshot mode, on the largest and the smallest watch. --- .github/workflows/native.yml | 75 ++- client/fastlane/Fastfile | 2 + client/ios/App/App.xcodeproj/project.pbxproj | 441 +++++++++++++ client/ios/App/App/AppDelegate.swift | 2 + client/ios/App/App/TrekShellPlugin.swift | 22 +- client/ios/App/App/TrekWatchLink.swift | 155 +++++ .../AccentColor.colorset/Contents.json | 15 + .../AppIcon.appiconset/AppIcon-1024.png | Bin 0 -> 106427 bytes .../AppIcon.appiconset/Contents.json | 14 + .../TREKWatch/Assets.xcassets/Contents.json | 6 + client/ios/App/TREKWatch/DemoMode.swift | 55 ++ client/ios/App/TREKWatch/Details.swift | 498 ++++++++++++++ client/ios/App/TREKWatch/FacesView.swift | 42 ++ client/ios/App/TREKWatch/Info.plist | 30 + client/ios/App/TREKWatch/Pages.swift | 611 ++++++++++++++++++ .../ios/App/TREKWatch/TREKWatch.entitlements | 10 + client/ios/App/TREKWatch/TREKWatchApp.swift | 150 +++++ client/ios/App/TREKWatch/WatchModel.swift | 114 ++++ .../TREKWatch/ar.lproj/Localizable.strings | 65 ++ .../TREKWatch/az.lproj/Localizable.strings | 65 ++ .../TREKWatch/ca.lproj/Localizable.strings | 65 ++ .../TREKWatch/cs.lproj/Localizable.strings | 65 ++ .../TREKWatch/de.lproj/Localizable.strings | 65 ++ .../TREKWatch/el.lproj/Localizable.strings | 65 ++ .../TREKWatch/en.lproj/Localizable.strings | 65 ++ .../TREKWatch/es.lproj/Localizable.strings | 65 ++ .../TREKWatch/et.lproj/Localizable.strings | 65 ++ .../TREKWatch/fr.lproj/Localizable.strings | 65 ++ .../TREKWatch/he.lproj/Localizable.strings | 65 ++ .../TREKWatch/hu.lproj/Localizable.strings | 65 ++ .../TREKWatch/id.lproj/Localizable.strings | 65 ++ .../TREKWatch/it.lproj/Localizable.strings | 65 ++ .../TREKWatch/ja.lproj/Localizable.strings | 65 ++ .../TREKWatch/ko.lproj/Localizable.strings | 65 ++ .../TREKWatch/nl.lproj/Localizable.strings | 65 ++ .../TREKWatch/pl.lproj/Localizable.strings | 65 ++ .../TREKWatch/pt-BR.lproj/Localizable.strings | 65 ++ .../TREKWatch/ru.lproj/Localizable.strings | 65 ++ .../TREKWatch/sk.lproj/Localizable.strings | 65 ++ .../TREKWatch/sv.lproj/Localizable.strings | 65 ++ .../TREKWatch/th.lproj/Localizable.strings | 65 ++ .../TREKWatch/tr.lproj/Localizable.strings | 65 ++ .../TREKWatch/uk.lproj/Localizable.strings | 65 ++ .../TREKWatch/vi.lproj/Localizable.strings | 65 ++ .../zh-Hans.lproj/Localizable.strings | 65 ++ .../zh-Hant.lproj/Localizable.strings | 65 ++ .../App/TREKWatchKit/ComplicationViews.swift | 323 +++++++++ client/ios/App/TREKWatchKit/WatchSample.swift | 131 ++++ .../ios/App/TREKWatchKit/WatchSnapshot.swift | 302 +++++++++ client/ios/App/TREKWatchKit/WatchStore.swift | 122 ++++ client/ios/App/TREKWatchKit/WatchStyle.swift | 215 ++++++ client/ios/App/TREKWatchWidgets/Info.plist | 29 + .../TREKWatchWidgets.entitlements | 10 + .../TREKWatchWidgets/TREKWatchWidgets.swift | 120 ++++ client/ios/scripts/add-extension.rb | 119 +++- client/ios/scripts/watch-screens.sh | 82 +++ 56 files changed, 5493 insertions(+), 22 deletions(-) create mode 100644 client/ios/App/App/TrekWatchLink.swift create mode 100644 client/ios/App/TREKWatch/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 client/ios/App/TREKWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png create mode 100644 client/ios/App/TREKWatch/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 client/ios/App/TREKWatch/Assets.xcassets/Contents.json create mode 100644 client/ios/App/TREKWatch/DemoMode.swift create mode 100644 client/ios/App/TREKWatch/Details.swift create mode 100644 client/ios/App/TREKWatch/FacesView.swift create mode 100644 client/ios/App/TREKWatch/Info.plist create mode 100644 client/ios/App/TREKWatch/Pages.swift create mode 100644 client/ios/App/TREKWatch/TREKWatch.entitlements create mode 100644 client/ios/App/TREKWatch/TREKWatchApp.swift create mode 100644 client/ios/App/TREKWatch/WatchModel.swift create mode 100644 client/ios/App/TREKWatch/ar.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/az.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/ca.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/cs.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/de.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/el.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/en.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/es.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/et.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/fr.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/he.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/hu.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/id.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/it.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/ja.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/ko.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/nl.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/pl.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/pt-BR.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/ru.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/sk.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/sv.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/th.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/tr.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/uk.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/vi.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/zh-Hans.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatch/zh-Hant.lproj/Localizable.strings create mode 100644 client/ios/App/TREKWatchKit/ComplicationViews.swift create mode 100644 client/ios/App/TREKWatchKit/WatchSample.swift create mode 100644 client/ios/App/TREKWatchKit/WatchSnapshot.swift create mode 100644 client/ios/App/TREKWatchKit/WatchStore.swift create mode 100644 client/ios/App/TREKWatchKit/WatchStyle.swift create mode 100644 client/ios/App/TREKWatchWidgets/Info.plist create mode 100644 client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements create mode 100644 client/ios/App/TREKWatchWidgets/TREKWatchWidgets.swift create mode 100644 client/ios/scripts/watch-screens.sh diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index e948921da..18c789755 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -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,8 @@ jobs: working-directory: client run: npm run cap:sync -- ios + # 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,7 +130,6 @@ jobs: -project App.xcodeproj \ -scheme App \ -configuration Debug \ - -sdk iphonesimulator \ -destination 'generic/platform=iOS Simulator' \ CODE_SIGNING_ALLOWED=NO @@ -159,3 +161,72 @@ 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. + watch-screens: + runs-on: macos-15 + timeout-minutes: 60 + 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" + + - name: Make sure a watchOS simulator runtime is there + run: | + xcrun simctl list runtimes | grep -q watchOS || xcodebuild -downloadPlatform watchOS + xcrun simctl list runtimes + + - 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 diff --git a/client/fastlane/Fastfile b/client/fastlane/Fastfile index 6b296e7cf..340195dba 100644 --- a/client/fastlane/Fastfile +++ b/client/fastlane/Fastfile @@ -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 diff --git a/client/ios/App/App.xcodeproj/project.pbxproj b/client/ios/App/App.xcodeproj/project.pbxproj index dab077318..e3257502f 100644 --- a/client/ios/App/App.xcodeproj/project.pbxproj +++ b/client/ios/App/App.xcodeproj/project.pbxproj @@ -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 = ""; }; 037DA05706AE051CEFCA7D75 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 04C0771C8582246A26E0C98E /* PackingContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackingContent.swift; sourceTree = ""; }; + 07C00C53BA5641B009E40EF1 /* zh-Hans */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + 07DF720DA5E63F842526A075 /* vi */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 090D74588DA8436DEC275567 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; 095A4E5D36F6E22070842EC1 /* nl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 0A7C4491A7ABDDBEBE00E5BF /* sk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; 0BA66BF6D1F4FB57EFF31981 /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; + 0CC4BCF06E7719280549FFF1 /* el */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = ""; }; + 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 = ""; }; 114C13FB91B554AE8DC4372B /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = ""; }; + 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekWatchLink.swift; sourceTree = ""; }; 12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; + 1335F1EEB49D744B27A00F31 /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = ""; }; 15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekStyle.swift; sourceTree = ""; }; 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 = ""; }; 1ED347C91E03E51FDF8ABC1A /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; 22A904A2E55946892F488B1A /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = ""; }; + 230D9B2E5898FCEC3EC4FB44 /* pl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; + 23AE662FD3762641A2047F72 /* ja */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 241C37D114DDD53C7E915CB8 /* id */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; 24B175DE91E1E6E850DB0285 /* TodayContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayContent.swift; sourceTree = ""; }; + 2D75538DBBAA6B2366CD8B69 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 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 = ""; }; + 2FD81D4601E9EB8744171B09 /* zh-Hant */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; 3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayLiveActivity.swift; sourceTree = ""; }; 32A62EE30F8AD514D7DB2CAA /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayAttributes.swift; sourceTree = ""; }; + 37FA9D6C0C247EC699CF326C /* TREKWatchWidgets.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWatchWidgets.entitlements; sourceTree = ""; }; 38C632E7EE9C0DF1CA0BF0A0 /* cs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; + 3C3D5E2482AC7D20D2054623 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 3CB565FC61AB5AEA87A3F615 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 3DF4F4305878F949DD09193F /* NotificationService.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; 405B04DFF3A978D1046147C6 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = ""; }; 408F0E543D49BFCCF6B19194 /* es */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 4167040E2C29E44D55580916 /* ru */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 42EBD981456D3493C3947441 /* WatchModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchModel.swift; sourceTree = ""; }; + 467929A8EB65D22F8FA91588 /* ca */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = ""; }; 4D004544572B0E85DE29A7B8 /* AtlasContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtlasContent.swift; sourceTree = ""; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 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 = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; 56050CCA8DE4BEB4680D9E0B /* ShareHandoff.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareHandoff.swift; sourceTree = ""; }; + 57DF93CD1B88A7135481BF03 /* de */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 5C0109EA628FA897B7BFDD3E /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = ""; }; 608F00CBD96957EEA888E50A /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 60EB00F3670B41B898765DF9 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; @@ -143,24 +223,34 @@ 683725459244601B71D92E2D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 6932A417DC4BC05BF07D80E2 /* it */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; + 772816394C94096090C2F8FE /* ar */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; 77A0112CA76E5C1787073AEB /* hu */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; 77C217524DA98A6A92F422F4 /* zh-Hant */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; + 787A9D6B7D9EB7FED319C1B5 /* FacesView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FacesView.swift; sourceTree = ""; }; + 7887877A91D6C782111A8388 /* it */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 7A1E00012C0000000000A001 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; }; 7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekBridgeViewController.swift; sourceTree = ""; }; 7A1E00062C0000000000A006 /* TrekShellPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekShellPlugin.swift; sourceTree = ""; }; 7D5041DDDAFF5B491B053999 /* CollabContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CollabContent.swift; sourceTree = ""; }; 7D525040492AFBC121FD4CC6 /* ar */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; + 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSnapshot.swift; sourceTree = ""; }; 8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripIntent.swift; sourceTree = ""; }; + 849A85B6008760CCD7D9AE6A /* id */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; 84CB5C3B6BD4E1DE1A8BD2B4 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 889DA33D98BC601268861D54 /* fr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 8A0289CB321AE008D8815A19 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWidgetsBundle.swift; sourceTree = ""; }; + 8CBB0E5B9BE05CE82C8DA7F4 /* sv */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; 8D361F7B14AE9F5ACE3BFE78 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = ""; }; + 91F0DD852C0D6D4578449977 /* en */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 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 = ""; }; 9A3FAA9DBC67D12389EEF0CF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 9D673E1909A5B750376ECB41 /* en */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 9DC696E1A1FD3A1A1695F9EA /* SharePage.js */ = {isa = PBXFileReference; includeInIndex = 1; path = SharePage.js; sourceTree = ""; }; @@ -172,35 +262,56 @@ A479036A85683EFF2922D096 /* el */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = ""; }; A5C182FC253CBEE8D5439536 /* ca */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = ""; }; AA02F5CBC22E08415CC1C9BE /* pl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; + AB5322D218E340690F499CA2 /* Pages.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Pages.swift; sourceTree = ""; }; AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodosContent.swift; sourceTree = ""; }; B0450E66BFCF00CC4097330A /* he */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; B680CBF1268D2EE1982B165C /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = ""; }; B7F33B7A5C27C126DF29DCA4 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; + B8850ACF43C79E9D879ACC44 /* WatchStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStore.swift; sourceTree = ""; }; + B991C938B24A275550C15977 /* WatchStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStyle.swift; sourceTree = ""; }; + B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationViews.swift; sourceTree = ""; }; BB2A15008B5664C48B639F94 /* ShareItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareItem.swift; sourceTree = ""; }; BBB2E19CD540AB30B44BECCA /* TREKWidgets.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWidgets.entitlements; sourceTree = ""; }; 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 = ""; }; + BF10C31D56A1C10C6BF62046 /* pt-BR */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + C24CDF14EBF4CF52CBF3DDD0 /* he */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; + C333CDB6CAD048E3F9242222 /* ru */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + C6A6008115A85C9AAE181C5E /* sk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NextTripWidget.swift; sourceTree = ""; }; + C73847E8A007B21B8AD1DCD4 /* tr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; C996A3060A2D20A4B5C9C377 /* ko */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; + C9B6357E3FBBA4D9CDD1C7A3 /* DemoMode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoMode.swift; sourceTree = ""; }; CC077989456FB9DB5F4FF676 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; CD6763B965A87DDB6FCC2D05 /* de */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + CD83ADB6D661D6F7A64CDAB6 /* es */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; CE79157F1170A47CF7FC6FE0 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 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 = ""; }; D84D92C18CC022646692CB9B /* sv */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; + D863C8994C9DAA79D236401C /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = ""; }; DC0C9AB4D77D77A7BC2BA85B /* ja */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; DD341749E47B7DF89BA9A46D /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; + DE0046E6067251CB0D61EC7A /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = ""; }; DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripContent.swift; sourceTree = ""; }; DE8B592FE52EC6FFB589751E /* fr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; E2CFBDBCC474A7A72E053A8E /* uk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 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 = ""; }; + E4AA747C2B20D0CDA0A9538A /* hu */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; E5800E4ECECED54A47CDABAA /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; E6831E849332626BE8D54CEF /* ShareView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = ""; }; E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = ""; }; EAFBDD97043B34E4B64FDA86 /* pt-BR */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + EC8D1292C9FC6430C059960A /* nl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; ECC80E1314B31ED2D2347411 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; ED561D7B7219D23A99FAEB11 /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; EE30A6DF943052C6ADE92D63 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + F406A4A31BD875A880A7CA14 /* Details.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Details.swift; sourceTree = ""; }; + F410423B093EBD2DD11C0FFC /* ko */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; + F4D88ABD8284E0A39E7586BE /* TREKWatchWidgets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWatchWidgets.swift; sourceTree = ""; }; + FB2D9CFF5AC22AEB8CA3D7BD /* cs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; /* 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 = ""; }; @@ -269,6 +397,8 @@ A03A384FE54FAD1ED68E12DC /* NotificationService.appex */, 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */, 694631A1018DCE5678F29930 /* ShareExtension.appex */, + CF4BA0BDDABFBDB24394025D /* TREKWatch.app */, + 0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */, ); name = Products; sourceTree = ""; @@ -289,6 +419,7 @@ 7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */, 2FAD9762203C412B000D30F8 /* config.xml */, 50B271D01FEDC1A000F3C39B /* public */, + 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */, ); path = App; sourceTree = ""; @@ -302,6 +433,7 @@ 6E901288FC198E4D33BE754F /* SwiftUI.framework */, 192E69C7E66646BC39566925 /* ActivityKit.framework */, E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */, + DC1BBE9AA7035B32D3653122 /* watchOS */, ); name = Frameworks; sourceTree = ""; @@ -317,6 +449,44 @@ path = NotificationService; sourceTree = ""; }; + 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 = ""; + }; + 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 = ""; + }; + DC1BBE9AA7035B32D3653122 /* watchOS */ = { + isa = PBXGroup; + children = ( + ); + name = watchOS; + sourceTree = ""; + }; DFD2010504102AC0E682D00F /* ShareExtension */ = { isa = PBXGroup; children = ( @@ -333,6 +503,17 @@ path = ShareExtension; sourceTree = ""; }; + F3ECD357918C4A98B023F65F /* TREKWatchWidgets */ = { + isa = PBXGroup; + children = ( + 2D75538DBBAA6B2366CD8B69 /* Info.plist */, + 37FA9D6C0C247EC699CF326C /* TREKWatchWidgets.entitlements */, + F4D88ABD8284E0A39E7586BE /* TREKWatchWidgets.swift */, + ); + name = TREKWatchWidgets; + path = TREKWatchWidgets; + sourceTree = ""; + }; 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 = ""; }; + 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 = ""; + }; 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 */ diff --git a/client/ios/App/App/AppDelegate.swift b/client/ios/App/App/AppDelegate.swift index 28851fe93..6fd58372c 100644 --- a/client/ios/App/App/AppDelegate.swift +++ b/client/ios/App/App/AppDelegate.swift @@ -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 } diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift index a86b61d82..ec3b9b621 100644 --- a/client/ios/App/App/TrekShellPlugin.swift +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -46,6 +46,8 @@ 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), ] /// What this binary offers the server's UI, raised with every native feature @@ -53,7 +55,7 @@ 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"] + static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "wallet", "watch"] // MARK: - Shared booking documents @@ -65,6 +67,7 @@ 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: [:]) } } // MARK: - Widgets and the travel-day Live Activity @@ -137,6 +140,23 @@ 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 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 diff --git a/client/ios/App/App/TrekWatchLink.swift b/client/ios/App/App/TrekWatchLink.swift new file mode 100644 index 000000000..2ca2512c2 --- /dev/null +++ b/client/ios/App/App/TrekWatchLink.swift @@ -0,0 +1,155 @@ +import CryptoKit +import Foundation +import UIKit +import WatchConnectivity + +/// 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 + + func activate() { + guard WCSession.isSupported() else { return } + WCSession.default.delegate = self + WCSession.default.activate() + } + + // MARK: Snapshots to the watch + + func publish(_ json: Data) { + let data = withSmallCover(json) + queue.async { + UserDefaults.standard.set(data, forKey: self.snapshotKey) + self.send(data) + } + } + + private func send(_ data: Data) { + let session = WCSession.default + guard WCSession.isSupported(), 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 + } + + // 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 + } + + 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] { enqueue(action) } + } + + /// The watch asks for the latest snapshot when it opens while the phone is in reach. + func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + queue.async { + if message["request"] as? String == "snapshot", let data = UserDefaults.standard.data(forKey: self.snapshotKey) { + replyHandler(["snapshot": data]) + } else { + replyHandler([:]) + } + } + } +} diff --git a/client/ios/App/TREKWatch/Assets.xcassets/AccentColor.colorset/Contents.json b/client/ios/App/TREKWatch/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..3fd003751 --- /dev/null +++ b/client/ios/App/TREKWatch/Assets.xcassets/AccentColor.colorset/Contents.json @@ -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 + } +} diff --git a/client/ios/App/TREKWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/client/ios/App/TREKWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..adc348d3e4cac1968fb0f466396d948aea50a208 GIT binary patch literal 106427 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 wl47HW)lt^m@#I0U2}@0Wmuzy today, day, place, booking, bookings, trip, days, +/// packing, todos, lists, countdown, empty, nosync, faces +/// -TrekScenario running, travelday or upcoming (default by screen) +/// -TrekNow the time the screens think it is +enum DemoMode { + enum Screen: String { + case today, day, place, booking, bookings, trip, days, packing, todos, lists, countdown, empty, nosync, faces + } + + static var screen: Screen? { + value(after: "-TrekDemo").flatMap(Screen.init(rawValue:)) + } + + @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: return .travelday + case .countdown, .packing, .lists: return .upcoming + default: return .running + } + } + + private static func clock() -> Date { + let parts = (value(after: "-TrekNow") ?? "13:05").split(separator: ":").compactMap { Int($0) } + let minutes = parts.count == 2 ? parts[0] * 60 + parts[1] : 13 * 60 + 5 + return TrekDate.at(minutes, 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] + } +} diff --git a/client/ios/App/TREKWatch/Details.swift b/client/ios/App/TREKWatch/Details.swift new file mode 100644 index 000000000..6daafe601 --- /dev/null +++ b/client/ios/App/TREKWatch/Details.swift @@ -0,0 +1,498 @@ +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)) + .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?.title ?? "TREK"), for: .navigation) + } + + private func summary(_ day: WatchSnapshot.Day) -> String { + guard let first = day.stops.first else { return L("day.empty") } + return day.stops.count > 1 ? "\(first.name) +\(day.stops.count - 1)" : 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?.title ?? "TREK"), 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, alignment: .trailing) + .padding(.top, 5) + .lineLimit(1) + .minimumScaleFactor(0.7) + VStack(spacing: 0) { + StopGlyph(stop: stop, size: 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) + .font(.system(.footnote, design: .rounded).weight(state == .now ? .bold : .semibold)) + .foregroundStyle(state == .past ? WatchLook.muted : WatchLook.ink) + .lineLimit(2) + if let detail = stop.booking.map(WatchLook.kindName) ?? stop.address?.split(separator: ",").first.map(String.init) { + Text(detail) + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(WatchLook.faint) + .lineLimit(1) + } + } + .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) + .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) + .font(.footnote) + .foregroundStyle(WatchLook.muted) + } + if let note = item.note { + Text(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) + Map(initialPosition: .region(MKCoordinateRegion(center: coordinate, latitudinalMeters: 900, longitudinalMeters: 900)), interactionModes: []) { + Marker(name, coordinate: coordinate).tint(color) + } + .frame(height: 92) + .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) + .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) + .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) + .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")) + } + + 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), + summary: 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), + summary: 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 summary: String? + let doneTitle: String + let allDone: String + let grouped: Bool + let toggle: (WatchSnapshot.Item) -> Void + @State private var ticks = 0 + + var body: some View { + List { + if open.isEmpty { + Label(allDone, systemImage: "checkmark.circle.fill") + .foregroundStyle(color) + .font(.system(.body, design: .rounded).weight(.semibold)) + } + if grouped { + ForEach(groups, id: \.name) { group in + Section { + ForEach(group.items) { row($0) } + } header: { + Text(group.name).watchEyebrow() + } + } + } else { + ForEach(open) { row($0) } + } + if !done.isEmpty { + Section { + ForEach(done) { row($0) } + } header: { + Text(doneTitle).watchEyebrow() + } + } + } + .navigationTitle(title) + .toolbar { + if let summary { + ToolbarItem(placement: .topBarTrailing) { + Text(summary) + .font(.system(.footnote, design: .rounded).weight(.bold).monospacedDigit()) + .foregroundStyle(color) + } + } + } + .sensoryFeedback(.success, trigger: ticks) + .containerBackground(WatchLook.tint(color), for: .navigation) + } + + 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) + .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")) + } +} diff --git a/client/ios/App/TREKWatch/FacesView.swift b/client/ios/App/TREKWatch/FacesView.swift new file mode 100644 index 000000000..21a83a36f --- /dev/null +++ b/client/ios/App/TREKWatch/FacesView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +/// Screenshot mode only (-TrekDemo faces): every complication in every +/// family, on a dark dial, to review them without setting up a watch face. +struct FacesView: View { + let snapshot: WatchSnapshot? + let now: Date + + var body: some View { + let state = ComplicationState(snapshot, now: now) + ScrollView { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + dial(.circular) { NextStopComplication(state: state, family: .circular, now: now) } + dial(.circular) { CountdownComplication(state: state, family: .circular, now: now) } + dial(.circular) { TripDayComplication(state: state, family: .circular) } + } + dial(.rectangular) { NextStopComplication(state: state, family: .rectangular, now: now) } + dial(.rectangular) { CountdownComplication(state: state, family: .rectangular, now: now) } + dial(.rectangular) { TripDayComplication(state: state, family: .rectangular) } + dial(.inline) { NextStopComplication(state: state, family: .inline, now: now) } + dial(.inline) { CountdownComplication(state: state, family: .inline, now: now) } + dial(.inline) { TripDayComplication(state: state, family: .inline) } + } + .foregroundStyle(.white) + .tint(Color(hex: 0xFF9F0A)) + } + } + + private func dial(_ family: ComplicationFamily, @ViewBuilder _ content: () -> Content) -> some View { + let size: CGSize = switch family { + case .circular: CGSize(width: 50, height: 50) + case .rectangular: CGSize(width: 170, height: 64) + case .corner: CGSize(width: 40, height: 40) + case .inline: CGSize(width: 170, height: 18) + } + return content() + .frame(width: size.width, height: size.height) + .clipped() + .overlay(RoundedRectangle(cornerRadius: family == .circular ? 25 : 6).stroke(Color.white.opacity(0.08))) + } +} diff --git a/client/ios/App/TREKWatch/Info.plist b/client/ios/App/TREKWatch/Info.plist new file mode 100644 index 000000000..428e180d6 --- /dev/null +++ b/client/ios/App/TREKWatch/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + TREK + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + WKApplication + + WKCompanionAppBundleIdentifier + com.liketrek.trek + WKRunsIndependentlyOfCompanionApp + + + diff --git a/client/ios/App/TREKWatch/Pages.swift b/client/ios/App/TREKWatch/Pages.swift new file mode 100644 index 000000000..674d4e04c --- /dev/null +++ b/client/ios/App/TREKWatch/Pages.swift @@ -0,0 +1,611 @@ +import SwiftUI + +// 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 } + let focus = currentIndex ?? nextIndex + 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) + } + .buttonStyle(.plain) + DayProgressBar(stops: stops, nowMinutes: nowMinutes) + .padding(.horizontal, 2) + let later = Array(stops.enumerated().filter { $0.offset > focus }.prefix(3)) + if !later.isEmpty && !reduced { + Text(L("today.later")).watchEyebrow().padding(.top, 2) + ForEach(later, id: \.offset) { entry in + NavigationLink(value: Route.stop(day: todayIndex, stop: entry.offset)) { + CompactStopRow(stop: entry.element, hour12: snapshot.hour12) + } + .buttonStyle(.plain) + } + } + } 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") + if let todayIndex { + NavigationLink(value: Route.day(todayIndex)) { + Label(L("today.plan"), systemImage: "list.bullet") + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .watchCard(cornerRadius: 14) + } + .buttonStyle(.plain) + } + } + } + } + .containerBackground(WatchLook.tripGradient(snapshot.trip?.title ?? "TREK"), for: .tabView) + .navigationTitle(L("today.title")) + .toolbar { + if let weather = snapshot.weather { + ToolbarItem(placement: .topBarTrailing) { WeatherBadge(weather: weather) } + } + } + } + + @ViewBuilder + private var header: some View { + if case .running(let index, let total) = snapshot.phase(at: now) { + VStack(alignment: .leading, spacing: 1) { + Text(WatchFormat.dayOf(index, total)).watchEyebrow(.white.opacity(0.85)) + Text(snapshot.trip?.title ?? "") + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundStyle(WatchLook.muted) + .lineLimit(1) + } + .accessibilityElement(children: .combine) + } + } +} + +/// The stop that is on now or comes next, large. +struct FocusCard: View { + let stop: WatchSnapshot.Stop + let isNow: Bool + let hour12: Bool + @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) + if let target = countdownTarget { + Text(WatchFormat.relative(target, now: now)) + .font(.system(.caption, design: .rounded).weight(.bold)) + .foregroundStyle(isNow ? WatchLook.muted : Trek.confirmed) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } + Text(stop.name) + .font(.system(.title3, design: .rounded).weight(.bold)) + .foregroundStyle(WatchLook.ink) + .lineLimit(2) + .minimumScaleFactor(0.75) + if let times = timeRange { + Text(times) + .font(.system(.footnote, design: .rounded).weight(.medium).monospacedDigit()) + .foregroundStyle(WatchLook.muted) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .watchCard() + .accessibilityElement(children: .combine) + } + + /// Next: until it starts. Now: until it ends. + private var countdownTarget: Date? { + let minutes = isNow ? stop.endMinutes : stop.minutes + return minutes.map { TrekDate.at($0, on: now) } + } + + 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) + .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).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)) + 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 ?? "") + .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) + .containerBackground(for: .tabView) { + TripBackdrop(trip: widgetTrip(trip), shade: 0.7) + } + .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) + } + .buttonStyle(.plain) + } + .containerBackground(WatchLook.tint(Trek.booking(booking.kind).color), for: .tabView) + .navigationTitle(L("booking.next")) + } +} + +/// 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 + @Environment(\.trekNow) private var now + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + KindBadge(kind: booking.kind, size: 22) + VStack(alignment: .leading, spacing: 0) { + Text(WatchLook.kindName(booking.kind)).watchEyebrow(.white.opacity(0.85)) + HStack(spacing: 3) { + 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) + } + Spacer(minLength: 0) + } + 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(.footnote, design: .rounded).weight(.medium)) + .foregroundStyle(WatchLook.muted) + .lineLimit(1) + } else { + Text(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) and how long until then. + @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 { + HStack(alignment: .firstTextBaseline, spacing: 6) { + if let clock, let minutes = TrekDate.minutes(clock) { + Text(WatchFormat.time(minutes: minutes, hour12: hour12)) + .font(.system(.title3, design: .rounded).weight(.bold).monospacedDigit()) + } + Text(dayLabel(moment)) + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .foregroundStyle(WatchLook.muted) + Spacer(minLength: 0) + } + if running { + Text(L("booking.checkOut") + " · " + WatchFormat.relative(moment, now: now)) + .font(.system(.caption, design: .rounded).weight(.bold)) + .foregroundStyle(Trek.booking(booking.kind).color) + } else if moment > now { + Text(WatchFormat.relative(moment, now: now)) + .font(.system(.caption, design: .rounded).weight(.bold)) + .foregroundStyle(Trek.booking(booking.kind).color.mix(with: .white, by: 0.35)) + } + } + } + + 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) + } + + @ViewBuilder + private var facts: some View { + let tiles = factTiles + 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 ?? "") + .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) + } + .containerBackground(WatchLook.tripGradient(trip?.title ?? "TREK"), for: .tabView) + .navigationTitle(L("trip.title")) + } +} + +/// 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 { + HStack(spacing: 8) { + Image(systemName: symbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 26, height: 26) + .background(Circle().fill(color.gradient)) + Text(title) + .font(.system(.body, design: .rounded).weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.7) + Spacer(minLength: 4) + if let value { + Text(value) + .font(.system(.footnote, design: .rounded).weight(.semibold).monospacedDigit()) + .foregroundStyle(WatchLook.muted) + } + } + .padding(.horizontal, 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).lineLimit(1) + Spacer(minLength: 0) + } + .font(.system(.footnote, design: .rounded).weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .watchCard(cornerRadius: 12) + } + } + .frame(maxHeight: .infinity) + .containerBackground(WatchLook.tint(WatchLook.packing), for: .tabView) + .navigationTitle(L("lists.title")) + } +} + +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).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) + } +} diff --git a/client/ios/App/TREKWatch/TREKWatch.entitlements b/client/ios/App/TREKWatch/TREKWatch.entitlements new file mode 100644 index 000000000..6b6566872 --- /dev/null +++ b/client/ios/App/TREKWatch/TREKWatch.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.liketrek.trek.shared + + + diff --git a/client/ios/App/TREKWatch/TREKWatchApp.swift b/client/ios/App/TREKWatch/TREKWatchApp.swift new file mode 100644 index 000000000..3c7be0e28 --- /dev/null +++ b/client/ios/App/TREKWatch/TREKWatchApp.swift @@ -0,0 +1,150 @@ +import SwiftUI + +@main +struct TREKWatchApp: App { + init() { + if DemoMode.screen != nil { + DemoMode.start() + } else { + WatchLink.shared.activate() + } + } + + var body: some Scene { + WindowGroup { + RootView() + } + } +} + +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(perform: openDemoScreen) + .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 .packing: path = [.packing] + case .todos: path = [.todos] + default: break + } + } +} diff --git a/client/ios/App/TREKWatch/WatchModel.swift b/client/ios/App/TREKWatch/WatchModel.swift new file mode 100644 index 000000000..34c24de68 --- /dev/null +++ b/client/ios/App/TREKWatch/WatchModel.swift @@ -0,0 +1,114 @@ +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 } +} + +/// 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 } + WCSession.default.delegate = self + WCSession.default.activate() + } + + func send(_ action: PendingAction) { + guard WCSession.default.activationState == .activated else { return } + WCSession.default.transferUserInfo(["action": action.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) } + } + // Ticks queued before an earlier launch ended are still in the system's queue; + // anything only in our own store (sent while not activated) goes again. + let queued = Set(session.outstandingUserInfoTransfers.compactMap { ($0.userInfo["action"] as? [String: Any])?["id"] as? String }) + Task { @MainActor in + for action in WatchModel.shared.pending where !queued.contains(action.id) { + session.transferUserInfo(["action": action.message]) + } + } + requestRefresh() + } + + 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) } + } +} diff --git a/client/ios/App/TREKWatch/ar.lproj/Localizable.strings b/client/ios/App/TREKWatch/ar.lproj/Localizable.strings new file mode 100644 index 000000000..aeaba58c1 --- /dev/null +++ b/client/ios/App/TREKWatch/ar.lproj/Localizable.strings @@ -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" = "غير منجزة"; diff --git a/client/ios/App/TREKWatch/az.lproj/Localizable.strings b/client/ios/App/TREKWatch/az.lproj/Localizable.strings new file mode 100644 index 000000000..ce85c50b0 --- /dev/null +++ b/client/ios/App/TREKWatch/az.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/ca.lproj/Localizable.strings b/client/ios/App/TREKWatch/ca.lproj/Localizable.strings new file mode 100644 index 000000000..b3e6b486d --- /dev/null +++ b/client/ios/App/TREKWatch/ca.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/cs.lproj/Localizable.strings b/client/ios/App/TREKWatch/cs.lproj/Localizable.strings new file mode 100644 index 000000000..380e2cc0d --- /dev/null +++ b/client/ios/App/TREKWatch/cs.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/de.lproj/Localizable.strings b/client/ios/App/TREKWatch/de.lproj/Localizable.strings new file mode 100644 index 000000000..9da8ff3f9 --- /dev/null +++ b/client/ios/App/TREKWatch/de.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/el.lproj/Localizable.strings b/client/ios/App/TREKWatch/el.lproj/Localizable.strings new file mode 100644 index 000000000..848559fe7 --- /dev/null +++ b/client/ios/App/TREKWatch/el.lproj/Localizable.strings @@ -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" = "Εκκρεμεί"; diff --git a/client/ios/App/TREKWatch/en.lproj/Localizable.strings b/client/ios/App/TREKWatch/en.lproj/Localizable.strings new file mode 100644 index 000000000..8b93c400c --- /dev/null +++ b/client/ios/App/TREKWatch/en.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/es.lproj/Localizable.strings b/client/ios/App/TREKWatch/es.lproj/Localizable.strings new file mode 100644 index 000000000..51166722d --- /dev/null +++ b/client/ios/App/TREKWatch/es.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/et.lproj/Localizable.strings b/client/ios/App/TREKWatch/et.lproj/Localizable.strings new file mode 100644 index 000000000..df6bfba25 --- /dev/null +++ b/client/ios/App/TREKWatch/et.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/fr.lproj/Localizable.strings b/client/ios/App/TREKWatch/fr.lproj/Localizable.strings new file mode 100644 index 000000000..abe60f66f --- /dev/null +++ b/client/ios/App/TREKWatch/fr.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/he.lproj/Localizable.strings b/client/ios/App/TREKWatch/he.lproj/Localizable.strings new file mode 100644 index 000000000..afc103b60 --- /dev/null +++ b/client/ios/App/TREKWatch/he.lproj/Localizable.strings @@ -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" = "לא בוצע"; diff --git a/client/ios/App/TREKWatch/hu.lproj/Localizable.strings b/client/ios/App/TREKWatch/hu.lproj/Localizable.strings new file mode 100644 index 000000000..0bff664c1 --- /dev/null +++ b/client/ios/App/TREKWatch/hu.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/id.lproj/Localizable.strings b/client/ios/App/TREKWatch/id.lproj/Localizable.strings new file mode 100644 index 000000000..a2223db0a --- /dev/null +++ b/client/ios/App/TREKWatch/id.lproj/Localizable.strings @@ -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" = "Barang 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"; diff --git a/client/ios/App/TREKWatch/it.lproj/Localizable.strings b/client/ios/App/TREKWatch/it.lproj/Localizable.strings new file mode 100644 index 000000000..2b0f8fff5 --- /dev/null +++ b/client/ios/App/TREKWatch/it.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/ja.lproj/Localizable.strings b/client/ios/App/TREKWatch/ja.lproj/Localizable.strings new file mode 100644 index 000000000..af1968ad7 --- /dev/null +++ b/client/ios/App/TREKWatch/ja.lproj/Localizable.strings @@ -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" = "未完了"; diff --git a/client/ios/App/TREKWatch/ko.lproj/Localizable.strings b/client/ios/App/TREKWatch/ko.lproj/Localizable.strings new file mode 100644 index 000000000..935e3544d --- /dev/null +++ b/client/ios/App/TREKWatch/ko.lproj/Localizable.strings @@ -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" = "미완료"; diff --git a/client/ios/App/TREKWatch/nl.lproj/Localizable.strings b/client/ios/App/TREKWatch/nl.lproj/Localizable.strings new file mode 100644 index 000000000..5965f778b --- /dev/null +++ b/client/ios/App/TREKWatch/nl.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/pl.lproj/Localizable.strings b/client/ios/App/TREKWatch/pl.lproj/Localizable.strings new file mode 100644 index 000000000..9293234cf --- /dev/null +++ b/client/ios/App/TREKWatch/pl.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/pt-BR.lproj/Localizable.strings b/client/ios/App/TREKWatch/pt-BR.lproj/Localizable.strings new file mode 100644 index 000000000..4ae764d6a --- /dev/null +++ b/client/ios/App/TREKWatch/pt-BR.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/ru.lproj/Localizable.strings b/client/ios/App/TREKWatch/ru.lproj/Localizable.strings new file mode 100644 index 000000000..b49a5f47a --- /dev/null +++ b/client/ios/App/TREKWatch/ru.lproj/Localizable.strings @@ -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" = "Не готово"; diff --git a/client/ios/App/TREKWatch/sk.lproj/Localizable.strings b/client/ios/App/TREKWatch/sk.lproj/Localizable.strings new file mode 100644 index 000000000..5071dc37f --- /dev/null +++ b/client/ios/App/TREKWatch/sk.lproj/Localizable.strings @@ -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é"; diff --git a/client/ios/App/TREKWatch/sv.lproj/Localizable.strings b/client/ios/App/TREKWatch/sv.lproj/Localizable.strings new file mode 100644 index 000000000..c6f5d995f --- /dev/null +++ b/client/ios/App/TREKWatch/sv.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/th.lproj/Localizable.strings b/client/ios/App/TREKWatch/th.lproj/Localizable.strings new file mode 100644 index 000000000..fdab5dc53 --- /dev/null +++ b/client/ios/App/TREKWatch/th.lproj/Localizable.strings @@ -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" = "ยังไม่เสร็จ"; diff --git a/client/ios/App/TREKWatch/tr.lproj/Localizable.strings b/client/ios/App/TREKWatch/tr.lproj/Localizable.strings new file mode 100644 index 000000000..0b78cc91b --- /dev/null +++ b/client/ios/App/TREKWatch/tr.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/uk.lproj/Localizable.strings b/client/ios/App/TREKWatch/uk.lproj/Localizable.strings new file mode 100644 index 000000000..276a184b6 --- /dev/null +++ b/client/ios/App/TREKWatch/uk.lproj/Localizable.strings @@ -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" = "Не виконано"; diff --git a/client/ios/App/TREKWatch/vi.lproj/Localizable.strings b/client/ios/App/TREKWatch/vi.lproj/Localizable.strings new file mode 100644 index 000000000..b06f0cc08 --- /dev/null +++ b/client/ios/App/TREKWatch/vi.lproj/Localizable.strings @@ -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"; diff --git a/client/ios/App/TREKWatch/zh-Hans.lproj/Localizable.strings b/client/ios/App/TREKWatch/zh-Hans.lproj/Localizable.strings new file mode 100644 index 000000000..fc55982b9 --- /dev/null +++ b/client/ios/App/TREKWatch/zh-Hans.lproj/Localizable.strings @@ -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" = "未完成"; diff --git a/client/ios/App/TREKWatch/zh-Hant.lproj/Localizable.strings b/client/ios/App/TREKWatch/zh-Hant.lproj/Localizable.strings new file mode 100644 index 000000000..c94170b81 --- /dev/null +++ b/client/ios/App/TREKWatch/zh-Hant.lproj/Localizable.strings @@ -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" = "未完成"; diff --git a/client/ios/App/TREKWatchKit/ComplicationViews.swift b/client/ios/App/TREKWatchKit/ComplicationViews.swift new file mode 100644 index 000000000..2a6ca5b9e --- /dev/null +++ b/client/ios/App/TREKWatchKit/ComplicationViews.swift @@ -0,0 +1,323 @@ +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 + } + } +} + +/// 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 } + if let stop = current ?? upcoming { + next = NextStop(stop: stop, start: stop.minutes.map { TrekDate.at($0, on: now) }, isNow: current != nil) + } + } + if let booking = snapshot?.bookings.first(where: { ($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 a complication should redraw at: each stop's start and end today, each booking's departure. + static func changes(_ snapshot: WatchSnapshot?, from now: Date) -> [Date] { + guard let snapshot else { return [] } + var dates: [Date] = [] + if let day = snapshot.today(at: now) { + for stop in day.stops { + if let start = stop.minutes { dates.append(TrekDate.at(start, on: now)) } + if let end = stop.endMinutes { dates.append(TrekDate.at(end, on: now)) } + } + } + dates += snapshot.bookings.compactMap(\.start) + dates.append(Calendar.current.startOfDay(for: now.addingTimeInterval(86_400))) + return dates.filter { $0 > now && $0 < now.addingTimeInterval(86_400 * 2) }.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 { + Text(WatchFormat.relative(start, now: now)).foregroundStyle(.secondary) + } + } + .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) + } + } +} + +// MARK: - Countdown + +struct CountdownComplication: View { + let state: ComplicationState + let family: ComplicationFamily + let now: Date + + var body: some View { + if let countdown = state.countdown { + let left = WatchFormat.countdown(countdown.target, now: now) + 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(left) + .font(.system(.title3, design: .rounded).weight(.bold).monospacedDigit()) + .widgetAccentable() + .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(left) } + case .inline: + Label("\(countdown.title) · \(left)", systemImage: countdown.symbol) + } + } else { + ComplicationIdle(state: state, family: family) + } + } + + /// The single biggest unit: "2h", "35m", "12d". + private func short(_ target: Date) -> String { + let formatter = DateComponentsFormatter() + 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) + } + } + + @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 + + var body: some View { + let text = state.tripTitle == nil ? L("complication.noTrip") : L("today.done") + 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)") + } + } +} diff --git a/client/ios/App/TREKWatchKit/WatchSample.swift b/client/ios/App/TREKWatchKit/WatchSample.swift new file mode 100644 index 000000000..ba220b6ea --- /dev/null +++ b/client/ios/App/TREKWatchKit/WatchSample.swift @@ -0,0 +1,131 @@ +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 a few hours. + 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) } + let stops: [[WatchSnapshot.Stop]] = [ + [ + stop("LH 716", 13 * 60 + 5, symbol: "airplane", color: nil, booking: "flight", lat: 50.0379, lng: 8.5622, address: "Frankfurt Airport"), + stop("Hotel Kanra Kyoto", 15 * 60, symbol: "bed.double.fill", color: "#8E6CD9", booking: "hotel", lat: 34.9922, lng: 135.7585, address: "190 Kitamachi, Shimogyo Ward, Kyoto"), + ], + [ + stop("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("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("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: "Go up the Sannenzaka steps for the view."), + stop("Gion walk", 16 * 60, symbol: "figure.walk", color: "#4A7DDB", lat: 35.0037, lng: 135.7750, address: "Hanamikoji-dori, Higashiyama Ward, Kyoto"), + stop("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("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("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("Tea ceremony", 10 * 60 + 30, symbol: "ticket.fill", color: nil, booking: "event", lat: 35.0036, lng: 135.7788, address: "Camellia Flower, Higashiyama")], + [stop("Fushimi sake district", 14 * 60, symbol: "wineglass.fill", color: "#8E6CD9", lat: 34.9318, lng: 135.7601, address: "Fushimi Ward, Kyoto")], + [stop("Pontocho alley", 18 * 60 + 30, symbol: "fork.knife", color: "#E8A13A", lat: 35.0050, lng: 135.7708, address: "Pontocho, Nakagyo Ward, Kyoto")], + [stop("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: "13:05", endDate: iso(1), endTime: "08:50", + zone: 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: 2, title: "Hotel Kanra Kyoto", kind: "hotel", confirmed: true, date: iso(0), time: "15:00", endDate: iso(6), endTime: "11:00", + zone: 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: "Kaiseki at Kikunoi", kind: "restaurant", confirmed: false, date: iso(2), time: "19:00", endDate: nil, endTime: nil, + zone: 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: "Tea ceremony", kind: "event", confirmed: true, date: iso(5), time: "10:30", endDate: nil, endTime: nil, + zone: 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, 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, 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)] = [ + ("Passport", "Documents", true), ("JR Pass voucher", "Documents", true), ("Travel insurance", "Documents", true), + ("Rain jacket", "Clothes", false), ("Walking shoes", "Clothes", true), ("Warm sweater", "Clothes", false), ("Socks", "Clothes", true), + ("Camera", "Tech", true), ("Power bank", "Tech", false), ("Type A adapter", "Tech", false), + ("Toothbrush", "Toiletries", false), ("Sunscreen", "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)] = [ + ("Reserve the Ghibli Museum", 2, 3, false), ("Buy a Suica card", nil, 2, false), + ("Book the tea ceremony", nil, 1, false), ("Print the hotel voucher", nil, 0, false), + ("Order yen", -1, 0, true), ("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)), + hour12: false, + trip: .init(id: 1, title: "Autumn in Kyoto", start: iso(0), end: iso(8), place: "Kyoto, Japan", cover: nil), + days: days, + bookings: bookings, + weather: .init(temp: 18, high: 21, low: 11, main: scenario == .upcoming ? "Clouds" : "Clear", label: "Mostly sunny", place: "Kyoto", rain: 10), + packing: packing, + todos: todos, + applied: [] + ) + } + + 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) + } +} diff --git a/client/ios/App/TREKWatchKit/WatchSnapshot.swift b/client/ios/App/TREKWatchKit/WatchSnapshot.swift new file mode 100644 index 000000000..858838813 --- /dev/null +++ b/client/ios/App/TREKWatchKit/WatchSnapshot.swift @@ -0,0 +1,302 @@ +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? + 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 + +enum TrekDate { + private static let isoFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + static func day(_ iso: String?) -> Date? { + guard let iso, iso.count >= 10 else { return nil } + return isoFormatter.date(from: String(iso.prefix(10))) + } + + static func iso(_ date: Date) -> String { + isoFormatter.string(from: date) + } + + /// 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) + } + + /// A moment on `day` at `minutes`. + static func at(_ minutes: Int, on day: Date) -> Date { + Calendar.current.date(byAdding: .minute, value: minutes, to: Calendar.current.startOfDay(for: 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) } + var end: Date? { TrekDate.moment(endDate ?? date, endTime, zone: zone) } + + 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 } +} diff --git a/client/ios/App/TREKWatchKit/WatchStore.swift b/client/ios/App/TREKWatchKit/WatchStore.swift new file mode 100644 index 000000000..ff2b5027b --- /dev/null +++ b/client/ios/App/TREKWatchKit/WatchStore.swift @@ -0,0 +1,122 @@ +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 + + /// 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), with the app's own +/// defaults as the fallback a build without the entitlement still works with. +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) ?? UserDefaults.standard.data(forKey: snapshotAccount)).flatMap(WatchSnapshot.decode) + } + + static func saveSnapshot(_ data: Data) { + if !write(snapshotAccount, data) { UserDefaults.standard.set(data, forKey: snapshotAccount) } + } + + static func loadPending() -> [PendingAction] { + guard let data = read(pendingAccount) ?? UserDefaults.standard.data(forKey: 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 } + if !write(pendingAccount, data) { UserDefaults.standard.set(data, forKey: pendingAccount) } + } + + 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 + } + } +} diff --git a/client/ios/App/TREKWatchKit/WatchStyle.swift b/client/ios/App/TREKWatchKit/WatchStyle.swift new file mode 100644 index 000000000..10625e815 --- /dev/null +++ b/client/ios/App/TREKWatchKit/WatchStyle.swift @@ -0,0 +1,215 @@ +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 func formatter(_ template: String) -> DateFormatter { + let key = template + "|" + Locale.current.identifier + if let cached = formatters[key] { return cached } + let formatter = DateFormatter() + formatter.locale = Locale.current + 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) + } + + static func time(minutes: Int, hour12: Bool, on day: Date = Date()) -> String { + time(TrekDate.at(minutes, on: day), hour12: hour12) + } + + /// "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") } + let style = Date.RelativeFormatStyle(presentation: .numeric, unitsStyle: .abbreviated) + return date.formatted(style.locale(.current)) + } + + /// 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 = DateComponentsFormatter() + formatter.unitsStyle = .abbreviated + 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 = DateComponentsFormatter() + 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)) + } + + 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) + + /// A trip's page light: its first colour at the top, fading into black. + static func tripGradient(_ title: String) -> LinearGradient { + let (first, second) = Trek.tripHues(title) + return LinearGradient(colors: [first.opacity(0.85), second.opacity(0.55), .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) + } +} + +extension View { + 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 + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + Text(label).watchEyebrow(WatchLook.faint) + 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) + } +} diff --git a/client/ios/App/TREKWatchWidgets/Info.plist b/client/ios/App/TREKWatchWidgets/Info.plist new file mode 100644 index 000000000..9cc2043e3 --- /dev/null +++ b/client/ios/App/TREKWatchWidgets/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + TREK + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements b/client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements new file mode 100644 index 000000000..6b6566872 --- /dev/null +++ b/client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.liketrek.trek.shared + + + diff --git a/client/ios/App/TREKWatchWidgets/TREKWatchWidgets.swift b/client/ios/App/TREKWatchWidgets/TREKWatchWidgets.swift new file mode 100644 index 000000000..59adf9ed1 --- /dev/null +++ b/client/ios/App/TREKWatchWidgets/TREKWatchWidgets.swift @@ -0,0 +1,120 @@ +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)) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + let snapshot = WatchStore.loadSnapshot()?.applying(WatchStore.loadPending()) + // Every quarter hour as well, so the counts in the text stay current. + var moments = ComplicationState.changes(snapshot, from: now) + for step in 1...8 { moments.append(now.addingTimeInterval(Double(step) * 15 * 60)) } + 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(40).map { date in + WatchEntry(date: date, snapshot: snapshot, relevance: relevance(snapshot, at: date)) + } + completion(Timeline(entries: entries, policy: .after(dates.last ?? 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: 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) + } +} diff --git a/client/ios/scripts/add-extension.rb b/client/ios/scripts/add-extension.rb index 71ab18891..bc82654a6 100644 --- a/client/ios/scripts/add-extension.rb +++ b/client/ios/scripts/add-extension.rb @@ -1,18 +1,36 @@ -# 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 [extra frameworks...] +# ruby ios/scripts/add-extension.rb [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// with an Info.plist and .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// with an Info.plist and +# .entitlements next to them; an asset catalog 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=,... 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= the target a watchwidget is embedded in +# --app-file=,... sources added to the phone app itself: the phone's end +# of what the new target talks to +# --strings-from= the target uses the Localizable.strings of that folder require 'xcodeproj' -name, bundle_id, type, *frameworks = ARGV -abort 'usage: add-extension.rb [frameworks...]' unless name && bundle_id && type +name, bundle_id, type, *rest = ARGV +types = %w[notification widget share watch watchwidget] +abort "usage: add-extension.rb <#{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 +39,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 +55,15 @@ end group = project.main_group.find_subpath(name, true) group.set_source_tree('') 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') end unless lprojs.empty? variant = group.new_variant_group('Localizable.strings') @@ -55,6 +77,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('') + 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 +126,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=' 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})" diff --git a/client/ios/scripts/watch-screens.sh b/client/ios/scripts/watch-screens.sh new file mode 100644 index 000000000..f10bff879 --- /dev/null +++ b/client/ios/scripts/watch-screens.sh @@ -0,0 +1,82 @@ +#!/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 in English and German on the largest and the smallest watch, +# 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 +set -euo pipefail + +APP="$1" +OUT="$2" +BUNDLE=com.liketrek.trek.watchkitapp +mkdir -p "$OUT" + +RUNTIME=$(xcrun simctl list runtimes -j | python3 -c ' +import json, sys +runtimes = [r for r in json.load(sys.stdin)["runtimes"] if r["platform"] == "watchOS" and r["isAvailable"]] +print(sorted(runtimes, key=lambda r: [int(x) for x in r["version"].split(".")])[-1]["identifier"])') +echo "runtime $RUNTIME" + +# The newest Ultra, and the smallest watch the runtime still supports. +read -r LARGE SMALL < <(xcrun simctl list devicetypes -j | python3 -c ' +import json, re, sys +names = [t["name"] for t in json.load(sys.stdin)["devicetypes"] if t["name"].startswith("Apple Watch")] +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] +small = sorted([n for n in names if "Ultra" not in n], key=lambda n: (mm(n), -gen(n))) +print(ultra.replace(" ", "_"), small[0].replace(" ", "_"))') +LARGE=${LARGE//_/ } +SMALL=${SMALL//_/ } +echo "large: $LARGE, 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 status_bar "$udid" override --time "13:05" >/dev/null 2>&1 || true + xcrun simctl install "$udid" "$APP" + echo "$udid" +} + +# shot [extra launch arguments] +shot() { + local udid=$1 file=$2 screen=$3 lang=$4 + shift 4 + local locale=${lang//-/_} + xcrun simctl launch --terminate-running-process "$udid" "$BUNDLE" \ + -AppleLanguages "($lang)" -AppleLocale "$locale" -TrekDemo "$screen" "$@" >/dev/null + case "$screen" in place) sleep 9 ;; *) sleep 4 ;; esac + xcrun simctl io "$udid" screenshot --type=png "$file" >/dev/null 2>&1 +} + +ALL="today day place booking bookings trip days packing todos lists countdown empty nosync faces" +MAIN="today booking countdown trip lists packing" +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" + +run() { + local size=$1 name=$2 udid + udid=$(boot "$name" | tail -1) + for lang in en de; do + for screen in $ALL; do shot "$udid" "$OUT/$size-$lang-$screen.png" "$screen" "$lang"; done + done + if [ "$size" = small ]; then + for lang in $LANGS; do + [ "$lang" = en ] || [ "$lang" = de ] && continue + for screen in $MAIN; do shot "$udid" "$OUT/$size-$lang-$screen.png" "$screen" "$lang"; done + done + fi + xcrun simctl shutdown "$udid" || true +} + +run large "$LARGE" & +run small "$SMALL" & +wait +echo "$LARGE" > "$OUT/devices.txt" +echo "$SMALL" >> "$OUT/devices.txt" +ls "$OUT" | wc -l