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 000000000..adc348d3e Binary files /dev/null and b/client/ios/App/TREKWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/client/ios/App/TREKWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/client/ios/App/TREKWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..3e58c9f43 --- /dev/null +++ b/client/ios/App/TREKWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "AppIcon-1024.png", + "idiom": "universal", + "platform": "watchos", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/client/ios/App/TREKWatch/Assets.xcassets/Contents.json b/client/ios/App/TREKWatch/Assets.xcassets/Contents.json new file mode 100644 index 000000000..74d6a722c --- /dev/null +++ b/client/ios/App/TREKWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/client/ios/App/TREKWatch/DemoMode.swift b/client/ios/App/TREKWatch/DemoMode.swift new file mode 100644 index 000000000..7dc58928a --- /dev/null +++ b/client/ios/App/TREKWatch/DemoMode.swift @@ -0,0 +1,55 @@ +import Foundation + +/// The screenshot mode: started with launch arguments, the app runs on the +/// sample trip at a fixed time and opens on one screen, so the CI can capture +/// every screen in every size and language from a simulator +/// (.github/workflows/native.yml, watch-screens). Nothing is sent or stored. +/// +/// -TrekDemo 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