mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-10 06:11:02 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02b0454b3d | |||
| 9c1e63e64b | |||
| dacc413c34 | |||
| b8f6a99895 | |||
| db82e04b49 | |||
| 3abc9eacf4 | |||
| 3a0edafdf0 | |||
| 35d2b19f4c | |||
| 6b5b2f614d | |||
| 92f10531d0 | |||
| d5ba4439f2 | |||
| 78a28e2747 | |||
| 71e3ddabed | |||
| e59af6dcb3 | |||
| 9755330113 | |||
| 5ca44453ae | |||
| 4461899910 |
@@ -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,82 @@ 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: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Select Xcode 26
|
||||
run: |
|
||||
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
|
||||
test -n "$XCODE"
|
||||
sudo xcode-select -s "$XCODE"
|
||||
|
||||
# The project resolves the Capacitor packages from node_modules, even for
|
||||
# a target that uses none of them.
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- name: Install
|
||||
run: npm ci --workspace=shared --workspace=client
|
||||
|
||||
- name: Make sure a watchOS simulator runtime is there
|
||||
run: |
|
||||
xcrun simctl list runtimes | grep -q watchOS || xcodebuild -downloadPlatform watchOS
|
||||
xcrun simctl list runtimes
|
||||
|
||||
- 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
|
||||
|
||||
@@ -100,13 +100,13 @@ object TrekTileStore {
|
||||
return null
|
||||
}
|
||||
|
||||
/** HttpURLConnection has already undone any gzip, so no Content-Encoding goes along. */
|
||||
/** Headers that are the request's own; the connection sets the hop-by-hop ones itself. */
|
||||
private val HOP_BY_HOP = setOf("connection", "keep-alive", "transfer-encoding", "te", "upgrade", "host", "content-length", "accept-encoding")
|
||||
|
||||
internal fun forwardedHeaders(headers: Map<String, String>?): Map<String, String> =
|
||||
headers.orEmpty().filterKeys { it.lowercase() !in HOP_BY_HOP }
|
||||
|
||||
/** HttpURLConnection has already undone any gzip, so no Content-Encoding goes along. */
|
||||
private fun response(bytes: ByteArray, type: String): WebResourceResponse {
|
||||
val mime = type.substringBefore(';').trim()
|
||||
val charset = Regex("charset=([^;]+)").find(type)?.groupValues?.get(1)
|
||||
|
||||
@@ -26,6 +26,8 @@ SIGNED_TARGETS = {
|
||||
"NotificationService" => "com.liketrek.trek.notifications",
|
||||
"TREKWidgets" => "com.liketrek.trek.widgets",
|
||||
"ShareExtension" => "com.liketrek.trek.share",
|
||||
"TREKWatch" => "com.liketrek.trek.watchkitapp",
|
||||
"TREKWatchWidgets" => "com.liketrek.trek.watchkitapp.widgets",
|
||||
}
|
||||
|
||||
platform :ios do
|
||||
|
||||
@@ -10,11 +10,19 @@
|
||||
01FD437981FC32C2C2C5BDCE /* TREKWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
0520A7C30D8D3FACCBB784BE /* ShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2A15008B5664C48B639F94 /* ShareItem.swift */; };
|
||||
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */; };
|
||||
0D801738BB09A41A62E9B57F /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
|
||||
1091DAAC693DAE3E1CBB7D69 /* WatchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8850ACF43C79E9D879ACC44 /* WatchStore.swift */; };
|
||||
11B5B80E66AB03C92B0EADBD /* TREKWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CE3377B458EDD77B809F61 /* TREKWatchApp.swift */; };
|
||||
157A7B737E3FFB610A218D47 /* AtlasContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D004544572B0E85DE29A7B8 /* AtlasContent.swift */; };
|
||||
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
|
||||
1F8D58E1B4FD9BB84172527A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
|
||||
2162CD349AFFDF070C16A58A /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E901288FC198E4D33BE754F /* SwiftUI.framework */; };
|
||||
228EA0E0270314581194EC3A /* TREKWatchWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D88ABD8284E0A39E7586BE /* TREKWatchWidgets.swift */; };
|
||||
239FC886F02275957A1D449A /* FacesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 787A9D6B7D9EB7FED319C1B5 /* FacesView.swift */; };
|
||||
2CFE220A73ACB585F3D4CB8F /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2EE43D236E661AA975612A4B /* UserNotifications.framework */; };
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
2FB9B214E9DA5C7A25BE0EA1 /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
|
||||
318BBC653A438E6D1D493F50 /* DemoMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9B6357E3FBBA4D9CDD1C7A3 /* DemoMode.swift */; };
|
||||
32797E66C09CF7579AC323FD /* TREKWidgetsBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */; };
|
||||
3FB1872A25267A8E88FCBE3C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC64330AC19625949E8DC05A /* WidgetKit.framework */; };
|
||||
45B990B86488D5CC2B5222B5 /* WorldGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC751335E0136865E08990 /* WorldGrid.swift */; };
|
||||
@@ -31,30 +39,56 @@
|
||||
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */; };
|
||||
5BBE9EE02A47EC67C31F5E21 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D0A4B2C6C93971E884561CFA /* Localizable.strings */; };
|
||||
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = A030A06726542A2D7F61C54B /* TrekFamily.swift */; };
|
||||
634F4E63DC7C2A0027F0D118 /* Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = F406A4A31BD875A880A7CA14 /* Details.swift */; };
|
||||
661AEED37F1F278677F4F306 /* WatchStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B991C938B24A275550C15977 /* WatchStyle.swift */; };
|
||||
6680B5C572AA37E6E3DFC3A2 /* ComplicationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */; };
|
||||
6EB76B1C6AB4AC78FF021994 /* WatchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8850ACF43C79E9D879ACC44 /* WatchStore.swift */; };
|
||||
7060F9DB820849A45ECE0507 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3C3D5E2482AC7D20D2054623 /* Assets.xcassets */; };
|
||||
7A1E00032C0000000000A003 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */; };
|
||||
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */; };
|
||||
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00062C0000000000A006 /* TrekShellPlugin.swift */; };
|
||||
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
|
||||
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
|
||||
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */; };
|
||||
896952F864B35C647FF6910A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
|
||||
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
|
||||
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
|
||||
91B14997828BEF884E1239B4 /* Pages.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB5322D218E340690F499CA2 /* Pages.swift */; };
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
|
||||
96DBDCE4766AC9F13F100A06 /* SharePage.js in Resources */ = {isa = PBXBuildFile; fileRef = 9DC696E1A1FD3A1A1695F9EA /* SharePage.js */; };
|
||||
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B175DE91E1E6E850DB0285 /* TodayContent.swift */; };
|
||||
A0E8821CA7CF51AF2D6D8422 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 248A72D1C511B4969D89333A /* Localizable.strings */; };
|
||||
A2B048E183B19BB17E264FDE /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED561D7B7219D23A99FAEB11 /* ShareViewController.swift */; };
|
||||
A8FDD4DBE3F078BF935CC4B9 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA8802204810ED580DE46F3 /* NotificationService.swift */; };
|
||||
AAE5F2A3903F1E241C395BB8 /* WatchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42EBD981456D3493C3947441 /* WatchModel.swift */; };
|
||||
AEE032DFFB63C7BEB8AFABBF /* TREKWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = CF4BA0BDDABFBDB24394025D /* TREKWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
B4C0FC386F71D849E141C91E /* NextTripWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */; };
|
||||
B65B7CFC62EEFF443491EA64 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 192E69C7E66646BC39566925 /* ActivityKit.framework */; };
|
||||
B8D1816D155C8D52DFCAE25B /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6831E849332626BE8D54CEF /* ShareView.swift */; };
|
||||
BE2DBB926DB04D6CEEFD8B43 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 694631A1018DCE5678F29930 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
C109B48C5A072AF9BE685BE4 /* ComplicationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */; };
|
||||
C3959ACB55B495F7A86F89A9 /* WatchStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B991C938B24A275550C15977 /* WatchStyle.swift */; };
|
||||
D0983EB4FB8015F96E232919 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
|
||||
D3C699E2258FBA6A11C1DDE4 /* TripIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */; };
|
||||
DE14C398EAFA594CB32ABBF2 /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
|
||||
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */; };
|
||||
EA5EDDD4E5F9BC9BC46C563F /* CollabContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D5041DDDAFF5B491B053999 /* CollabContent.swift */; };
|
||||
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */; };
|
||||
F30AA6CFFE04035E7D6AC546 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
|
||||
F9947489F5B9323E519D3903 /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
|
||||
FEB91385917AC44D5CDAA59C /* PackingContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C0771C8582246A26E0C98E /* PackingContent.swift */; };
|
||||
FECB6034B17C07C879908CEF /* TREKWatchWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
201AA9320B6C5DCBCC3429BF /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 59A840D13C3074A09FCCC7CB;
|
||||
remoteInfo = TREKWatch;
|
||||
};
|
||||
3BB8A38B2B2C8614B24CF2AD /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
@@ -76,9 +110,38 @@
|
||||
remoteGlobalIDString = A3BE5653156025C97CA62C68;
|
||||
remoteInfo = TREKWidgets;
|
||||
};
|
||||
EA68232794738AB11E866FA3 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 5B5EA8FB8F58C6BFA3B77661;
|
||||
remoteInfo = TREKWatchWidgets;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9545786868FF757328C69E1E /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
FECB6034B17C07C879908CEF /* TREKWatchWidgets.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
986D3C6100A1AB6BE603CF1C /* Embed Watch Content */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
AEE032DFFB63C7BEB8AFABBF /* TREKWatch.app in Embed Watch Content */,
|
||||
);
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -95,14 +158,22 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
00B7BB68930D45D35C9AD54A /* az */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
037DA05706AE051CEFCA7D75 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
04C0771C8582246A26E0C98E /* PackingContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackingContent.swift; sourceTree = "<group>"; };
|
||||
07C00C53BA5641B009E40EF1 /* zh-Hans */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
07DF720DA5E63F842526A075 /* vi */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
090D74588DA8436DEC275567 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
095A4E5D36F6E22070842EC1 /* nl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
0A7C4491A7ABDDBEBE00E5BF /* sk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
0BA66BF6D1F4FB57EFF31981 /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
||||
0CC4BCF06E7719280549FFF1 /* el */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TREKWatchWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
10FB55D221F4BA18795F33B5 /* WatchSample.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSample.swift; sourceTree = "<group>"; };
|
||||
114C13FB91B554AE8DC4372B /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekWatchLink.swift; sourceTree = "<group>"; };
|
||||
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1335F1EEB49D744B27A00F31 /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekStyle.swift; sourceTree = "<group>"; };
|
||||
192E69C7E66646BC39566925 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
|
||||
@@ -111,19 +182,27 @@
|
||||
1E7522315D47F086145DC4B8 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
1ED347C91E03E51FDF8ABC1A /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
22A904A2E55946892F488B1A /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
230D9B2E5898FCEC3EC4FB44 /* pl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
23AE662FD3762641A2047F72 /* ja */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
241C37D114DDD53C7E915CB8 /* id */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
24B175DE91E1E6E850DB0285 /* TodayContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayContent.swift; sourceTree = "<group>"; };
|
||||
2D75538DBBAA6B2366CD8B69 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
2EE43D236E661AA975612A4B /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; };
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
2FD81D4601E9EB8744171B09 /* zh-Hant */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayLiveActivity.swift; sourceTree = "<group>"; };
|
||||
32A62EE30F8AD514D7DB2CAA /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayAttributes.swift; sourceTree = "<group>"; };
|
||||
37FA9D6C0C247EC699CF326C /* TREKWatchWidgets.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWatchWidgets.entitlements; sourceTree = "<group>"; };
|
||||
38C632E7EE9C0DF1CA0BF0A0 /* cs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
3C3D5E2482AC7D20D2054623 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
3CB565FC61AB5AEA87A3F615 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
3DF4F4305878F949DD09193F /* NotificationService.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = "<group>"; };
|
||||
405B04DFF3A978D1046147C6 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
408F0E543D49BFCCF6B19194 /* es */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
4167040E2C29E44D55580916 /* ru */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
42EBD981456D3493C3947441 /* WatchModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchModel.swift; sourceTree = "<group>"; };
|
||||
467929A8EB65D22F8FA91588 /* ca */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
4D004544572B0E85DE29A7B8 /* AtlasContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtlasContent.swift; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -134,6 +213,7 @@
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
56050CCA8DE4BEB4680D9E0B /* ShareHandoff.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareHandoff.swift; sourceTree = "<group>"; };
|
||||
57DF93CD1B88A7135481BF03 /* de */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
5C0109EA628FA897B7BFDD3E /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
608F00CBD96957EEA888E50A /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
60EB00F3670B41B898765DF9 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
@@ -143,24 +223,34 @@
|
||||
683725459244601B71D92E2D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
6932A417DC4BC05BF07D80E2 /* it */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
694631A1018DCE5678F29930 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
697E5726770333015DDD444D /* TREKWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWatch.entitlements; sourceTree = "<group>"; };
|
||||
6E901288FC198E4D33BE754F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TREKWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7328217E23761C22814FEC25 /* az */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
772816394C94096090C2F8FE /* ar */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
77A0112CA76E5C1787073AEB /* hu */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
77C217524DA98A6A92F422F4 /* zh-Hant */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
787A9D6B7D9EB7FED319C1B5 /* FacesView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FacesView.swift; sourceTree = "<group>"; };
|
||||
7887877A91D6C782111A8388 /* it */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
7A1E00012C0000000000A001 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekBridgeViewController.swift; sourceTree = "<group>"; };
|
||||
7A1E00062C0000000000A006 /* TrekShellPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekShellPlugin.swift; sourceTree = "<group>"; };
|
||||
7D5041DDDAFF5B491B053999 /* CollabContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CollabContent.swift; sourceTree = "<group>"; };
|
||||
7D525040492AFBC121FD4CC6 /* ar */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSnapshot.swift; sourceTree = "<group>"; };
|
||||
8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripIntent.swift; sourceTree = "<group>"; };
|
||||
849A85B6008760CCD7D9AE6A /* id */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
84CB5C3B6BD4E1DE1A8BD2B4 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
889DA33D98BC601268861D54 /* fr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8A0289CB321AE008D8815A19 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWidgetsBundle.swift; sourceTree = "<group>"; };
|
||||
8CBB0E5B9BE05CE82C8DA7F4 /* sv */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8D361F7B14AE9F5ACE3BFE78 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
91F0DD852C0D6D4578449977 /* en */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
96CE3377B458EDD77B809F61 /* TREKWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWatchApp.swift; sourceTree = "<group>"; };
|
||||
9A3FAA9DBC67D12389EEF0CF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9D673E1909A5B750376ECB41 /* en */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9DC696E1A1FD3A1A1695F9EA /* SharePage.js */ = {isa = PBXFileReference; includeInIndex = 1; path = SharePage.js; sourceTree = "<group>"; };
|
||||
@@ -172,35 +262,56 @@
|
||||
A479036A85683EFF2922D096 /* el */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
A5C182FC253CBEE8D5439536 /* ca */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
AA02F5CBC22E08415CC1C9BE /* pl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
AB5322D218E340690F499CA2 /* Pages.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Pages.swift; sourceTree = "<group>"; };
|
||||
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodosContent.swift; sourceTree = "<group>"; };
|
||||
B0450E66BFCF00CC4097330A /* he */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B680CBF1268D2EE1982B165C /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B7F33B7A5C27C126DF29DCA4 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B8850ACF43C79E9D879ACC44 /* WatchStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStore.swift; sourceTree = "<group>"; };
|
||||
B991C938B24A275550C15977 /* WatchStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStyle.swift; sourceTree = "<group>"; };
|
||||
B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationViews.swift; sourceTree = "<group>"; };
|
||||
BB2A15008B5664C48B639F94 /* ShareItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareItem.swift; sourceTree = "<group>"; };
|
||||
BBB2E19CD540AB30B44BECCA /* TREKWidgets.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWidgets.entitlements; sourceTree = "<group>"; };
|
||||
BC64330AC19625949E8DC05A /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
BE41D7212DCB0C78E00A8462 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
BF10C31D56A1C10C6BF62046 /* pt-BR */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
C24CDF14EBF4CF52CBF3DDD0 /* he */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C333CDB6CAD048E3F9242222 /* ru */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C6A6008115A85C9AAE181C5E /* sk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NextTripWidget.swift; sourceTree = "<group>"; };
|
||||
C73847E8A007B21B8AD1DCD4 /* tr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C996A3060A2D20A4B5C9C377 /* ko */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C9B6357E3FBBA4D9CDD1C7A3 /* DemoMode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoMode.swift; sourceTree = "<group>"; };
|
||||
CC077989456FB9DB5F4FF676 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
CD6763B965A87DDB6FCC2D05 /* de */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
CD83ADB6D661D6F7A64CDAB6 /* es */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
CE79157F1170A47CF7FC6FE0 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
CF4BA0BDDABFBDB24394025D /* TREKWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TREKWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D0AC751335E0136865E08990 /* WorldGrid.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorldGrid.swift; sourceTree = "<group>"; };
|
||||
D84D92C18CC022646692CB9B /* sv */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
D863C8994C9DAA79D236401C /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = "<group>"; };
|
||||
DC0C9AB4D77D77A7BC2BA85B /* ja */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
DD341749E47B7DF89BA9A46D /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
DE0046E6067251CB0D61EC7A /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripContent.swift; sourceTree = "<group>"; };
|
||||
DE8B592FE52EC6FFB589751E /* fr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E2CFBDBCC474A7A72E053A8E /* uk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; };
|
||||
E47D5FF1A217D5F77B3C7548 /* uk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E4AA747C2B20D0CDA0A9538A /* hu */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E5800E4ECECED54A47CDABAA /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E6831E849332626BE8D54CEF /* ShareView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
|
||||
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = "<group>"; };
|
||||
EAFBDD97043B34E4B64FDA86 /* pt-BR */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
EC8D1292C9FC6430C059960A /* nl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
ECC80E1314B31ED2D2347411 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
ED561D7B7219D23A99FAEB11 /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
EE30A6DF943052C6ADE92D63 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
F406A4A31BD875A880A7CA14 /* Details.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Details.swift; sourceTree = "<group>"; };
|
||||
F410423B093EBD2DD11C0FFC /* ko */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
F4D88ABD8284E0A39E7586BE /* TREKWatchWidgets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWatchWidgets.swift; sourceTree = "<group>"; };
|
||||
FB2D9CFF5AC22AEB8CA3D7BD /* cs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -223,6 +334,20 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
622430061CE31239E1D64FB8 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
69112BCF6652B92DFA2AE6BB /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C9AA4ADC0D397C620A35B6A3 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -259,6 +384,9 @@
|
||||
74C1FC6022E562372A71EEC9 /* NotificationService */,
|
||||
FB9226BC82835538BCE3DC0A /* TREKWidgets */,
|
||||
DFD2010504102AC0E682D00F /* ShareExtension */,
|
||||
A5731FDC6B43DF41413C28A6 /* TREKWatch */,
|
||||
83BCA52D4B145E66DC1B0111 /* TREKWatchKit */,
|
||||
F3ECD357918C4A98B023F65F /* TREKWatchWidgets */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -269,6 +397,8 @@
|
||||
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */,
|
||||
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */,
|
||||
694631A1018DCE5678F29930 /* ShareExtension.appex */,
|
||||
CF4BA0BDDABFBDB24394025D /* TREKWatch.app */,
|
||||
0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -289,6 +419,7 @@
|
||||
7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
@@ -302,6 +433,7 @@
|
||||
6E901288FC198E4D33BE754F /* SwiftUI.framework */,
|
||||
192E69C7E66646BC39566925 /* ActivityKit.framework */,
|
||||
E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */,
|
||||
DC1BBE9AA7035B32D3653122 /* watchOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -317,6 +449,44 @@
|
||||
path = NotificationService;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
83BCA52D4B145E66DC1B0111 /* TREKWatchKit */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */,
|
||||
10FB55D221F4BA18795F33B5 /* WatchSample.swift */,
|
||||
842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */,
|
||||
B8850ACF43C79E9D879ACC44 /* WatchStore.swift */,
|
||||
B991C938B24A275550C15977 /* WatchStyle.swift */,
|
||||
);
|
||||
name = TREKWatchKit;
|
||||
path = TREKWatchKit;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A5731FDC6B43DF41413C28A6 /* TREKWatch */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3C3D5E2482AC7D20D2054623 /* Assets.xcassets */,
|
||||
C9B6357E3FBBA4D9CDD1C7A3 /* DemoMode.swift */,
|
||||
F406A4A31BD875A880A7CA14 /* Details.swift */,
|
||||
787A9D6B7D9EB7FED319C1B5 /* FacesView.swift */,
|
||||
D863C8994C9DAA79D236401C /* Info.plist */,
|
||||
AB5322D218E340690F499CA2 /* Pages.swift */,
|
||||
697E5726770333015DDD444D /* TREKWatch.entitlements */,
|
||||
96CE3377B458EDD77B809F61 /* TREKWatchApp.swift */,
|
||||
42EBD981456D3493C3947441 /* WatchModel.swift */,
|
||||
3A6054202DCC03D83C2C9602 /* Localizable.strings */,
|
||||
);
|
||||
name = TREKWatch;
|
||||
path = TREKWatch;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DC1BBE9AA7035B32D3653122 /* watchOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = watchOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DFD2010504102AC0E682D00F /* ShareExtension */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -333,6 +503,17 @@
|
||||
path = ShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F3ECD357918C4A98B023F65F /* TREKWatchWidgets */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D75538DBBAA6B2366CD8B69 /* Info.plist */,
|
||||
37FA9D6C0C247EC699CF326C /* TREKWatchWidgets.entitlements */,
|
||||
F4D88ABD8284E0A39E7586BE /* TREKWatchWidgets.swift */,
|
||||
);
|
||||
name = TREKWatchWidgets;
|
||||
path = TREKWatchWidgets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FB9226BC82835538BCE3DC0A /* TREKWidgets */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -388,6 +569,7 @@
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */,
|
||||
986D3C6100A1AB6BE603CF1C /* Embed Watch Content */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -395,6 +577,7 @@
|
||||
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */,
|
||||
4CD7F5AF02CB806E4189489A /* PBXTargetDependency */,
|
||||
855AE76937B942D62DFFD7E2 /* PBXTargetDependency */,
|
||||
DC6A321F5C3C7D5EF0973677 /* PBXTargetDependency */,
|
||||
);
|
||||
name = App;
|
||||
packageProductDependencies = (
|
||||
@@ -404,6 +587,42 @@
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
59A840D13C3074A09FCCC7CB /* TREKWatch */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = DF9244A5B1D6A7560CB92D4E /* Build configuration list for PBXNativeTarget "TREKWatch" */;
|
||||
buildPhases = (
|
||||
B3023E0FD7245E6465981AC5 /* Sources */,
|
||||
622430061CE31239E1D64FB8 /* Frameworks */,
|
||||
E46DCC12600D7B9D1381FBCE /* Resources */,
|
||||
9545786868FF757328C69E1E /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
5D0E9608BF309D8A27F5C4B6 /* PBXTargetDependency */,
|
||||
);
|
||||
name = TREKWatch;
|
||||
productName = TREKWatch;
|
||||
productReference = CF4BA0BDDABFBDB24394025D /* TREKWatch.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
5B5EA8FB8F58C6BFA3B77661 /* TREKWatchWidgets */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = C24DF86369B92FC86F735AEE /* Build configuration list for PBXNativeTarget "TREKWatchWidgets" */;
|
||||
buildPhases = (
|
||||
068381E1E877C97A3390DE57 /* Sources */,
|
||||
69112BCF6652B92DFA2AE6BB /* Frameworks */,
|
||||
F36545081C7270E279639BF4 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = TREKWatchWidgets;
|
||||
productName = TREKWatchWidgets;
|
||||
productReference = 0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
9480DFBC2C2A112973DA468A /* NotificationService */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 4E3E5FA93A15425251E22D48 /* Build configuration list for PBXNativeTarget "NotificationService" */;
|
||||
@@ -506,6 +725,8 @@
|
||||
9480DFBC2C2A112973DA468A /* NotificationService */,
|
||||
A3BE5653156025C97CA62C68 /* TREKWidgets */,
|
||||
01A63CD6CFBC1463CAA8FFF0 /* ShareExtension */,
|
||||
59A840D13C3074A09FCCC7CB /* TREKWatch */,
|
||||
5B5EA8FB8F58C6BFA3B77661 /* TREKWatchWidgets */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -540,6 +761,15 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E46DCC12600D7B9D1381FBCE /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7060F9DB820849A45ECE0507 /* Assets.xcassets in Resources */,
|
||||
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EB430D4F409D7961AF5603C9 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -549,9 +779,32 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F36545081C7270E279639BF4 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F30AA6CFFE04035E7D6AC546 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
068381E1E877C97A3390DE57 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
228EA0E0270314581194EC3A /* TREKWatchWidgets.swift in Sources */,
|
||||
C109B48C5A072AF9BE685BE4 /* ComplicationViews.swift in Sources */,
|
||||
896952F864B35C647FF6910A /* WatchSample.swift in Sources */,
|
||||
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */,
|
||||
6EB76B1C6AB4AC78FF021994 /* WatchStore.swift in Sources */,
|
||||
661AEED37F1F278677F4F306 /* WatchStyle.swift in Sources */,
|
||||
0D801738BB09A41A62E9B57F /* TrekStyle.swift in Sources */,
|
||||
DE14C398EAFA594CB32ABBF2 /* WidgetSnapshot.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1EA0B85A2E70AC56F83C09C9 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -569,6 +822,7 @@
|
||||
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */,
|
||||
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
|
||||
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
|
||||
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -595,6 +849,26 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B3023E0FD7245E6465981AC5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
318BBC653A438E6D1D493F50 /* DemoMode.swift in Sources */,
|
||||
634F4E63DC7C2A0027F0D118 /* Details.swift in Sources */,
|
||||
239FC886F02275957A1D449A /* FacesView.swift in Sources */,
|
||||
91B14997828BEF884E1239B4 /* Pages.swift in Sources */,
|
||||
11B5B80E66AB03C92B0EADBD /* TREKWatchApp.swift in Sources */,
|
||||
AAE5F2A3903F1E241C395BB8 /* WatchModel.swift in Sources */,
|
||||
6680B5C572AA37E6E3DFC3A2 /* ComplicationViews.swift in Sources */,
|
||||
1F8D58E1B4FD9BB84172527A /* WatchSample.swift in Sources */,
|
||||
D0983EB4FB8015F96E232919 /* WatchSnapshot.swift in Sources */,
|
||||
1091DAAC693DAE3E1CBB7D69 /* WatchStore.swift in Sources */,
|
||||
C3959ACB55B495F7A86F89A9 /* WatchStyle.swift in Sources */,
|
||||
2FB9B214E9DA5C7A25BE0EA1 /* TrekStyle.swift in Sources */,
|
||||
F9947489F5B9323E519D3903 /* WidgetSnapshot.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E0B13DA0CF9C1103058FE869 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -615,12 +889,24 @@
|
||||
target = A3BE5653156025C97CA62C68 /* TREKWidgets */;
|
||||
targetProxy = B8BBE7F7A64678D780A7F3D1 /* PBXContainerItemProxy */;
|
||||
};
|
||||
5D0E9608BF309D8A27F5C4B6 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = TREKWatchWidgets;
|
||||
target = 5B5EA8FB8F58C6BFA3B77661 /* TREKWatchWidgets */;
|
||||
targetProxy = EA68232794738AB11E866FA3 /* PBXContainerItemProxy */;
|
||||
};
|
||||
855AE76937B942D62DFFD7E2 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = ShareExtension;
|
||||
target = 01A63CD6CFBC1463CAA8FFF0 /* ShareExtension */;
|
||||
targetProxy = 64B91219C04CABCE204C7CCC /* PBXContainerItemProxy */;
|
||||
};
|
||||
DC6A321F5C3C7D5EF0973677 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = TREKWatch;
|
||||
target = 59A840D13C3074A09FCCC7CB /* TREKWatch */;
|
||||
targetProxy = 201AA9320B6C5DCBCC3429BF /* PBXContainerItemProxy */;
|
||||
};
|
||||
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = NotificationService;
|
||||
@@ -665,6 +951,41 @@
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3A6054202DCC03D83C2C9602 /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
772816394C94096090C2F8FE /* ar */,
|
||||
00B7BB68930D45D35C9AD54A /* az */,
|
||||
467929A8EB65D22F8FA91588 /* ca */,
|
||||
FB2D9CFF5AC22AEB8CA3D7BD /* cs */,
|
||||
57DF93CD1B88A7135481BF03 /* de */,
|
||||
0CC4BCF06E7719280549FFF1 /* el */,
|
||||
91F0DD852C0D6D4578449977 /* en */,
|
||||
CD83ADB6D661D6F7A64CDAB6 /* es */,
|
||||
DE0046E6067251CB0D61EC7A /* et */,
|
||||
889DA33D98BC601268861D54 /* fr */,
|
||||
C24CDF14EBF4CF52CBF3DDD0 /* he */,
|
||||
E4AA747C2B20D0CDA0A9538A /* hu */,
|
||||
849A85B6008760CCD7D9AE6A /* id */,
|
||||
7887877A91D6C782111A8388 /* it */,
|
||||
23AE662FD3762641A2047F72 /* ja */,
|
||||
F410423B093EBD2DD11C0FFC /* ko */,
|
||||
EC8D1292C9FC6430C059960A /* nl */,
|
||||
230D9B2E5898FCEC3EC4FB44 /* pl */,
|
||||
BF10C31D56A1C10C6BF62046 /* pt-BR */,
|
||||
C333CDB6CAD048E3F9242222 /* ru */,
|
||||
C6A6008115A85C9AAE181C5E /* sk */,
|
||||
8CBB0E5B9BE05CE82C8DA7F4 /* sv */,
|
||||
1335F1EEB49D744B27A00F31 /* th */,
|
||||
C73847E8A007B21B8AD1DCD4 /* tr */,
|
||||
E47D5FF1A217D5F77B3C7548 /* uk */,
|
||||
07DF720DA5E63F842526A075 /* vi */,
|
||||
07C00C53BA5641B009E40EF1 /* zh-Hans */,
|
||||
2FD81D4601E9EB8744171B09 /* zh-Hant */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
@@ -719,6 +1040,33 @@
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
1F7D993B412B63E918648BA1 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = TREKWatch/TREKWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWatch/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
31CC9E868E52D06AD12E83D6 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -950,6 +1298,32 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
57D289EF9468E358528F5FBB /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = TREKWatch/TREKWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWatch/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
686FA31AEC6FE5BA3EF95E99 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -1001,6 +1375,30 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
C04A1F63D9298FA52E6C727E /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = TREKWatchWidgets/TREKWatchWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWatchWidgets/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
C5621F5D75100AAEBB1BD22A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -1027,6 +1425,31 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
DF4816365A7D101A9EA8A0C7 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = TREKWatchWidgets/TREKWatchWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWatchWidgets/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -1075,6 +1498,24 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
C24DF86369B92FC86F735AEE /* Build configuration list for PBXNativeTarget "TREKWatchWidgets" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
DF4816365A7D101A9EA8A0C7 /* Release */,
|
||||
C04A1F63D9298FA52E6C727E /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
DF9244A5B1D6A7560CB92D4E /* Build configuration list for PBXNativeTarget "TREKWatch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1F7D993B412B63E918648BA1 /* Release */,
|
||||
57D289EF9468E358528F5FBB /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
|
||||
@@ -10,6 +10,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Taps on a TREK notification, and banners while the app is open.
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
// Early, so ticks the watch sent while the app was closed arrive with this launch.
|
||||
TrekWatchLink.shared.activate()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -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,11 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
|
||||
static let shellApi = 2
|
||||
/// The native features by name, so the UI can turn each on by itself.
|
||||
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "wallet"]
|
||||
/// "watch" only where WatchConnectivity is (an iPhone, not an iPad), so the UI never builds a snapshot for nothing.
|
||||
static var features: [String] {
|
||||
["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "wallet"]
|
||||
+ (TrekWatchLink.isAvailable ? ["watch"] : [])
|
||||
}
|
||||
|
||||
// MARK: - Shared booking documents
|
||||
|
||||
@@ -65,6 +71,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 +144,27 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
// MARK: - Apple Watch
|
||||
|
||||
/// Passes what the watch app shows (native/watch.ts builds it) on to the watch.
|
||||
@objc func setWatchSnapshot(_ call: CAPPluginCall) {
|
||||
guard TrekWatchLink.isAvailable else {
|
||||
call.resolve()
|
||||
return
|
||||
}
|
||||
guard let json = call.getString("json"), let data = json.data(using: .utf8) else {
|
||||
call.reject("json is required")
|
||||
return
|
||||
}
|
||||
TrekWatchLink.shared.publish(data)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
/// The packing items and to-dos ticked on the watch since the last call, oldest first.
|
||||
@objc func takeWatchActions(_ call: CAPPluginCall) {
|
||||
call.resolve(["actions": TrekWatchLink.shared.takeActions()])
|
||||
}
|
||||
|
||||
// MARK: - Push
|
||||
|
||||
/// Asks for permission, registers with APNs and answers with a Web Push
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
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
|
||||
|
||||
/// False on an iPad (and anything else without a paired watch): then this is
|
||||
/// a no-op throughout, and the UI is not offered the feature at all.
|
||||
static var isAvailable: Bool { WCSession.isSupported() }
|
||||
|
||||
func activate() {
|
||||
guard Self.isAvailable else { return }
|
||||
WCSession.default.delegate = self
|
||||
WCSession.default.activate()
|
||||
}
|
||||
|
||||
// MARK: Snapshots to the watch
|
||||
|
||||
func publish(_ json: Data) {
|
||||
guard Self.isAvailable else { return }
|
||||
let data = withSmallCover(json)
|
||||
queue.async {
|
||||
UserDefaults.standard.set(data, forKey: self.snapshotKey)
|
||||
self.send(data)
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ data: Data) {
|
||||
guard Self.isAvailable else { return }
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return }
|
||||
try? session.updateApplicationContext(["snapshot": data])
|
||||
let digest = complicationDigest(data)
|
||||
if session.isComplicationEnabled, session.remainingComplicationUserInfoTransfers > 0,
|
||||
digest != UserDefaults.standard.string(forKey: complicationKey) {
|
||||
session.transferCurrentComplicationUserInfo(["snapshot": data])
|
||||
UserDefaults.standard.set(digest, forKey: complicationKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the complications show of a snapshot: everything but when it was built and the applied ticks.
|
||||
private func complicationDigest(_ data: Data) -> String {
|
||||
guard var object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { return "" }
|
||||
object.removeValue(forKey: "generatedAt")
|
||||
object.removeValue(forKey: "applied")
|
||||
object.removeValue(forKey: "packing")
|
||||
object.removeValue(forKey: "todos")
|
||||
let canonical = (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])) ?? Data()
|
||||
return SHA256.hash(data: canonical).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// The trip's cover scaled down to a thumbnail the watch can draw, or dropped when it would not fit.
|
||||
private func withSmallCover(_ json: Data) -> Data {
|
||||
guard var object = (try? JSONSerialization.jsonObject(with: json)) as? [String: Any],
|
||||
var trip = object["trip"] as? [String: Any], let cover = trip["cover"] as? String else { return json }
|
||||
trip.removeValue(forKey: "cover")
|
||||
if let data = Data(base64Encoded: cover), let image = UIImage(data: data) {
|
||||
let width: CGFloat = 220
|
||||
let size = CGSize(width: width, height: (width * image.size.height / max(image.size.width, 1)).rounded())
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1
|
||||
let small = UIGraphicsImageRenderer(size: size, format: format).image { _ in image.draw(in: CGRect(origin: .zero, size: size)) }
|
||||
if let jpeg = small.jpegData(compressionQuality: 0.6), jpeg.count < 24_000 {
|
||||
trip["cover"] = jpeg.base64EncodedString()
|
||||
}
|
||||
}
|
||||
object["trip"] = trip
|
||||
guard let result = try? JSONSerialization.data(withJSONObject: object) else { return json }
|
||||
if result.count > maxBytes, trip["cover"] != nil {
|
||||
trip.removeValue(forKey: "cover")
|
||||
object["trip"] = trip
|
||||
return (try? JSONSerialization.data(withJSONObject: object)) ?? json
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 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([:])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"colors": [
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": { "alpha": "1.000", "blue": "1.000", "green": "1.000", "red": "1.000" }
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "AppIcon-1024.png",
|
||||
"idiom": "universal",
|
||||
"platform": "watchos",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
/// The screenshot mode: started with launch arguments, the app runs on the
|
||||
/// sample trip at a fixed time and opens on one screen, so the CI can capture
|
||||
/// every screen in every size and language from a simulator
|
||||
/// (.github/workflows/native.yml, watch-screens). Nothing is sent or stored.
|
||||
///
|
||||
/// -TrekDemo <screen> today, day, place, booking, booking-detail, bookings,
|
||||
/// trip, days, packing, todos, lists, countdown, empty,
|
||||
/// nosync, faces
|
||||
/// -TrekScenario <name> running, travelday or upcoming (default by screen)
|
||||
/// -TrekReady <token> once the screen has drawn, writes Documents/ready-<token>
|
||||
/// in the app's container, which the script waits for
|
||||
/// -TrekNow <HH:mm> the time the screens think it is; without it the
|
||||
/// real time, which the capture script moves to the
|
||||
/// hour it wants with the app's time zone (TZ), so the
|
||||
/// clock in the corner agrees with the screen
|
||||
enum DemoMode {
|
||||
enum Screen: String {
|
||||
case today, day, place, booking, bookings, trip, days, packing, todos, lists, countdown, empty, nosync, faces
|
||||
case bookingDetail = "booking-detail"
|
||||
}
|
||||
|
||||
static var screen: Screen? {
|
||||
value(after: "-TrekDemo").flatMap(Screen.init(rawValue:))
|
||||
}
|
||||
|
||||
/// Tells the capture script the screen is up: a moment after it appeared,
|
||||
/// so the navigation to it and the first layout have settled.
|
||||
static func signalReady() {
|
||||
guard let token = value(after: "-TrekReady"),
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
||||
try? FileManager.default.createDirectory(at: documents, withIntermediateDirectories: true)
|
||||
try? Data().write(to: documents.appendingPathComponent("ready-\(token)"))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func start() {
|
||||
guard let screen else { return }
|
||||
let now = clock()
|
||||
let scenario = value(after: "-TrekScenario").flatMap(WatchSample.Scenario.init(rawValue:)) ?? defaultScenario(screen)
|
||||
switch screen {
|
||||
case .nosync:
|
||||
WatchModel.shared.startDemo(nil, now: now)
|
||||
case .empty:
|
||||
WatchModel.shared.startDemo(WatchSnapshot(generatedAt: ISO8601DateFormatter().string(from: now), trip: nil), now: now)
|
||||
default:
|
||||
WatchModel.shared.startDemo(WatchSample.snapshot(scenario, now: now), now: now)
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultScenario(_ screen: Screen) -> WatchSample.Scenario {
|
||||
switch screen {
|
||||
case .booking, .bookings, .bookingDetail: return .travelday
|
||||
case .countdown, .packing, .lists: return .upcoming
|
||||
default: return .running
|
||||
}
|
||||
}
|
||||
|
||||
private static func clock() -> Date {
|
||||
guard let parts = value(after: "-TrekNow")?.split(separator: ":").compactMap({ Int($0) }), parts.count == 2 else { return Date() }
|
||||
return TrekDate.at(parts[0] * 60 + parts[1], on: Date())
|
||||
}
|
||||
|
||||
private static func value(after flag: String) -> String? {
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
guard let index = args.firstIndex(of: flag), index + 1 < args.count else { return nil }
|
||||
return args[index + 1]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import MapKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Days
|
||||
|
||||
/// Every day of the trip, today marked, each opening its plan.
|
||||
struct DaysListView: View {
|
||||
let snapshot: WatchSnapshot
|
||||
@Environment(\.trekNow) private var now
|
||||
|
||||
var body: some View {
|
||||
let today = snapshot.todayIndex(at: now)
|
||||
List {
|
||||
ForEach(Array(snapshot.days.enumerated()), id: \.offset) { index, day in
|
||||
NavigationLink(value: Route.day(index)) {
|
||||
HStack(spacing: 10) {
|
||||
Text("\(day.number)")
|
||||
.font(.system(.headline, design: .rounded).weight(.heavy).monospacedDigit())
|
||||
.foregroundStyle(index == today ? Color.black : WatchLook.ink)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Circle().fill(index == today ? Color.white : Color.white.opacity(0.12)))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(day.title ?? day.dateValue.map { WatchFormat.day($0) } ?? WatchFormat.dayOf(day.number, snapshot.days.count))
|
||||
.font(.system(.body, design: .rounded).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(summary(day)).userText(summary(day))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.opacity(isPast(day) ? 0.55 : 1)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L("trip.days"))
|
||||
.containerBackground(WatchLook.tripGradient(snapshot.trip), for: .navigation)
|
||||
}
|
||||
|
||||
private func summary(_ day: WatchSnapshot.Day) -> String {
|
||||
guard let first = day.stops.first else { return L("day.empty") }
|
||||
return first.booking.map { "\(WatchLook.kindName($0)) \(first.name)" } ?? first.name
|
||||
}
|
||||
|
||||
private func isPast(_ day: WatchSnapshot.Day) -> Bool {
|
||||
guard let date = day.dateValue else { return false }
|
||||
return TrekDate.daysBetween(now, date) < 0
|
||||
}
|
||||
}
|
||||
|
||||
/// One day's plan as a timeline: times on the leading edge, the line between
|
||||
/// the stops filled up to now, the current stop lit. The crown scrolls it.
|
||||
struct DayView: View {
|
||||
let snapshot: WatchSnapshot
|
||||
let index: Int
|
||||
@Environment(\.trekNow) private var now
|
||||
|
||||
var body: some View {
|
||||
let day = snapshot.days.indices.contains(index) ? snapshot.days[index] : nil
|
||||
let isToday = day?.date == TrekDate.iso(now)
|
||||
let nowMinutes = isToday ? TrekDate.minutesOfDay(now) : (isPastDay(day) ? 24 * 60 : -1)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(WatchFormat.dayOf(day?.number ?? index + 1, max(snapshot.tripLength, snapshot.days.count)))
|
||||
.watchEyebrow(.white.opacity(0.85))
|
||||
if let date = day?.dateValue {
|
||||
Text(WatchFormat.day(date))
|
||||
.font(.system(.headline, design: .rounded))
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
let stops = day?.stops ?? []
|
||||
if stops.isEmpty {
|
||||
Text(L("day.empty"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
ForEach(Array(stops.enumerated()), id: \.offset) { offset, stop in
|
||||
NavigationLink(value: Route.stop(day: index, stop: offset)) {
|
||||
TimelineRow(
|
||||
stop: stop,
|
||||
hour12: snapshot.hour12,
|
||||
state: state(stop, next: offset + 1 < stops.count ? stops[offset + 1] : nil, nowMinutes: nowMinutes),
|
||||
isLast: offset == stops.count - 1
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(day?.dateValue.map { WatchFormat.weekday($0) } ?? L("trip.days"))
|
||||
.containerBackground(WatchLook.tripGradient(snapshot.trip), for: .navigation)
|
||||
}
|
||||
|
||||
private func isPastDay(_ day: WatchSnapshot.Day?) -> Bool {
|
||||
guard let date = day?.dateValue else { return false }
|
||||
return TrekDate.daysBetween(now, date) < 0
|
||||
}
|
||||
|
||||
private func state(_ stop: WatchSnapshot.Stop, next: WatchSnapshot.Stop?, nowMinutes: Int) -> TimelineRow.State {
|
||||
guard let start = stop.minutes else { return .ahead }
|
||||
let end = stop.endMinutes ?? next?.minutes ?? start + 60
|
||||
if nowMinutes >= end { return .past }
|
||||
if nowMinutes >= start { return .now }
|
||||
return .ahead
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineRow: View {
|
||||
enum State { case past, now, ahead }
|
||||
let stop: WatchSnapshot.Stop
|
||||
let hour12: Bool
|
||||
let state: State
|
||||
let isLast: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text(stop.minutes.map { WatchFormat.time(minutes: $0, hour12: hour12) } ?? "")
|
||||
.font(.system(.caption2, design: .rounded).weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(state == .now ? Trek.confirmed : WatchLook.muted)
|
||||
.frame(width: (hour12 ? 50 : 36) - (WatchScreen.compact ? 4 : 0), alignment: .trailing)
|
||||
.padding(.top, 5)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
VStack(spacing: 0) {
|
||||
StopGlyph(stop: stop, size: WatchScreen.compact ? 18 : 22, dimmed: state == .past)
|
||||
.overlay(Circle().stroke(Trek.confirmed, lineWidth: state == .now ? 2 : 0).padding(-3))
|
||||
if !isLast {
|
||||
Rectangle()
|
||||
.fill(state == .ahead ? Color.white.opacity(0.18) : WatchLook.stopColor(stop).opacity(0.7))
|
||||
.frame(width: 2)
|
||||
.frame(minHeight: 14, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(stop.name).userText(stop.name)
|
||||
.font(.system(.footnote, design: .rounded).weight(state == .now ? .bold : .semibold))
|
||||
.foregroundStyle(state == .past ? WatchLook.muted : WatchLook.ink)
|
||||
.lineLimit(2)
|
||||
// The street, whole: the first part of the address, on up to two lines.
|
||||
if let detail = stop.booking.map(WatchLook.kindName) ?? stop.address?.split(separator: ",").first.map(String.init) {
|
||||
Text(detail).userText(detail)
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(WatchLook.faint)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(.top, 3)
|
||||
.padding(.bottom, 10)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - A stop
|
||||
|
||||
/// A stop of the plan: where it is on the map, when, the note, and Directions into Apple Maps.
|
||||
struct StopDetailView: View {
|
||||
let snapshot: WatchSnapshot
|
||||
let day: Int
|
||||
let stop: Int
|
||||
|
||||
var body: some View {
|
||||
let item = snapshot.days.indices.contains(day) && snapshot.days[day].stops.indices.contains(stop) ? snapshot.days[day].stops[stop] : nil
|
||||
ScrollView {
|
||||
if let item {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let lat = item.lat, let lng = item.lng {
|
||||
MiniMap(name: item.name, lat: lat, lng: lng, color: WatchLook.stopColor(item))
|
||||
}
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
StopGlyph(stop: item, size: 26)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(item.name).userText(item.name)
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.lineLimit(3)
|
||||
if let start = item.minutes {
|
||||
let from = WatchFormat.time(minutes: start, hour12: snapshot.hour12)
|
||||
Text(item.endMinutes.map { "\(from) – \(WatchFormat.time(minutes: $0, hour12: snapshot.hour12))" } ?? from)
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let address = item.address {
|
||||
Text(address).userText(address)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
if let note = item.note {
|
||||
Text(note).userText(note)
|
||||
.font(.footnote)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.watchCard(cornerRadius: 12)
|
||||
}
|
||||
if let lat = item.lat, let lng = item.lng {
|
||||
DirectionsButton(name: item.name, lat: lat, lng: lng)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(item?.booking.map(WatchLook.kindName) ?? L("place.title"))
|
||||
.containerBackground(WatchLook.tint(item.map(WatchLook.stopColor) ?? Trek.info), for: .navigation)
|
||||
}
|
||||
}
|
||||
|
||||
/// A small map of one spot, with its pin.
|
||||
struct MiniMap: View {
|
||||
let name: String
|
||||
let lat: Double
|
||||
let lng: Double
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
||||
// From above, without street names or points of interest, so the stop's
|
||||
// own pin is what stands out; Apple's attribution stays in its corner.
|
||||
Map(initialPosition: .region(MKCoordinateRegion(center: coordinate, latitudinalMeters: 1100, longitudinalMeters: 1100)), interactionModes: []) {
|
||||
Annotation(name, coordinate: coordinate, anchor: .center) {
|
||||
Circle()
|
||||
.fill(color.gradient)
|
||||
.frame(width: 22, height: 22)
|
||||
.overlay(Circle().stroke(.white, lineWidth: 2.5))
|
||||
.shadow(color: .black.opacity(0.4), radius: 3, y: 1)
|
||||
}
|
||||
.annotationTitles(.hidden)
|
||||
}
|
||||
.mapStyle(.standard(elevation: .flat, emphasis: .muted, pointsOfInterest: .excludingAll, showsTraffic: false))
|
||||
.frame(height: 118)
|
||||
// A soft fade over the top edge only, where labels would be cut; the
|
||||
// attribution sits at the bottom and stays as MapKit draws it.
|
||||
.overlay(alignment: .top) {
|
||||
LinearGradient(colors: [.black.opacity(0.55), .clear], startPoint: .top, endPoint: .bottom)
|
||||
.frame(height: 26)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens Apple Maps with directions to a spot.
|
||||
struct DirectionsButton: View {
|
||||
let name: String
|
||||
let lat: Double
|
||||
let lng: Double
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
let item = MKMapItem(placemark: MKPlacemark(coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lng)))
|
||||
item.name = name
|
||||
_ = item.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault])
|
||||
} label: {
|
||||
Label(L("place.directions"), systemImage: "arrow.triangle.turn.up.right.diamond.fill")
|
||||
.font(.system(.body, design: .rounded).weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.tint(Trek.info)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bookings
|
||||
|
||||
struct BookingDetailView: View {
|
||||
let snapshot: WatchSnapshot
|
||||
let id: Int
|
||||
|
||||
var body: some View {
|
||||
let booking = snapshot.bookings.first { $0.id == id }
|
||||
ScrollView {
|
||||
if let booking {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
BookingSummary(booking: booking, hour12: snapshot.hour12)
|
||||
if let from = booking.from, let to = booking.to {
|
||||
LegRow(label: L("booking.departs"), point: from, date: booking.date, time: booking.time, hour12: snapshot.hour12)
|
||||
LegRow(label: L("booking.arrives"), point: to, date: booking.endDate, time: booking.endTime, hour12: snapshot.hour12)
|
||||
} else if booking.kind == "hotel", let checkOut = TrekDate.minutes(booking.checkOut ?? booking.endTime) {
|
||||
FactTile(label: L("booking.checkOut"), value: [TrekDate.day(booking.endDate).map { WatchFormat.day($0) }, WatchFormat.time(minutes: checkOut, hour12: snapshot.hour12)].compactMap { $0 }.joined(separator: " "))
|
||||
}
|
||||
if let location = booking.location, booking.route == nil {
|
||||
Text(location).userText(location)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
if let target = booking.destination {
|
||||
DirectionsButton(name: target.name, lat: target.lat, lng: target.lng)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(booking.map { WatchLook.kindName($0.kind) } ?? "")
|
||||
.containerBackground(WatchLook.tint(Trek.booking(booking?.kind ?? "other").color), for: .navigation)
|
||||
}
|
||||
}
|
||||
|
||||
/// One end of a ride: the place, its code, the day and time.
|
||||
struct LegRow: View {
|
||||
let label: String
|
||||
let point: WatchSnapshot.Point
|
||||
let date: String?
|
||||
let time: String?
|
||||
let hour12: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(label).watchEyebrow(WatchLook.faint)
|
||||
Text(point.name).userText(point.name)
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 1) {
|
||||
if let minutes = TrekDate.minutes(time) {
|
||||
Text(WatchFormat.time(minutes: minutes, hour12: hour12))
|
||||
.font(.system(.footnote, design: .rounded).weight(.bold).monospacedDigit())
|
||||
}
|
||||
if let day = TrekDate.day(date) {
|
||||
Text(WatchFormat.day(day))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.watchCard(cornerRadius: 12)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
struct BookingsListView: View {
|
||||
let snapshot: WatchSnapshot
|
||||
@Environment(\.trekNow) private var now
|
||||
|
||||
var body: some View {
|
||||
let bookings = snapshot.bookingsAhead(at: now)
|
||||
List {
|
||||
if bookings.isEmpty {
|
||||
Text(L("booking.none")).foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
ForEach(bookings) { booking in
|
||||
NavigationLink(value: Route.booking(booking.id)) {
|
||||
HStack(spacing: 8) {
|
||||
KindBadge(kind: booking.kind, size: 24)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(booking.route ?? booking.title).userText((booking.route ?? booking.title))
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(when(booking))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L("booking.all"))
|
||||
.containerBackground(WatchLook.tripGradient(snapshot.trip), for: .navigation)
|
||||
}
|
||||
|
||||
private func when(_ booking: WatchSnapshot.Booking) -> String {
|
||||
guard let day = TrekDate.day(booking.date) else { return WatchLook.kindName(booking.kind) }
|
||||
let days = TrekDate.daysBetween(now, day)
|
||||
let label = days == 0 ? L("time.today") : days == 1 ? L("time.tomorrow") : WatchFormat.day(day)
|
||||
guard let minutes = TrekDate.minutes(booking.time) else { return label }
|
||||
return "\(label) \(WatchFormat.time(minutes: minutes, hour12: snapshot.hour12))"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The lists
|
||||
|
||||
/// The packing list: what is still to pack, by category, ticked off with a tap.
|
||||
struct PackingListView: View {
|
||||
private let model = WatchModel.shared
|
||||
|
||||
var body: some View {
|
||||
let packing = model.display?.packing
|
||||
let items = packing?.items ?? []
|
||||
ChecklistView(
|
||||
title: L("packing.title"),
|
||||
color: WatchLook.packing,
|
||||
open: items.filter { !$0.done },
|
||||
done: items.filter(\.done),
|
||||
progress: packing.map { ($0.packed, $0.total) },
|
||||
doneTitle: L("packing.packedSection"),
|
||||
allDone: L("packing.allPacked"),
|
||||
grouped: true
|
||||
) { model.toggle(.packing, item: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
struct TodoListView: View {
|
||||
private let model = WatchModel.shared
|
||||
|
||||
var body: some View {
|
||||
let todos = model.display?.todos
|
||||
let items = todos?.items ?? []
|
||||
ChecklistView(
|
||||
title: L("todos.title"),
|
||||
color: WatchLook.todo,
|
||||
open: items.filter { !$0.done },
|
||||
done: items.filter(\.done),
|
||||
progress: todos.map { ($0.total - $0.open, $0.total) },
|
||||
doneTitle: L("todos.doneSection"),
|
||||
allDone: L("todos.allDone"),
|
||||
grouped: false
|
||||
) { model.toggle(.todo, item: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A list to tick off: the open items (grouped by category when asked), then the done ones, dimmed.
|
||||
struct ChecklistView: View {
|
||||
let title: String
|
||||
let color: Color
|
||||
let open: [WatchSnapshot.Item]
|
||||
let done: [WatchSnapshot.Item]
|
||||
let progress: (done: Int, total: Int)?
|
||||
let doneTitle: String
|
||||
let allDone: String
|
||||
let grouped: Bool
|
||||
let toggle: (WatchSnapshot.Item) -> Void
|
||||
@State private var ticks = 0
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// The progress sits in the first section's header, so the list starts right under it.
|
||||
if open.isEmpty {
|
||||
Section {
|
||||
Label(allDone, systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(color)
|
||||
.font(.system(.body, design: .rounded).weight(.semibold))
|
||||
} header: {
|
||||
header(nil)
|
||||
}
|
||||
} else if grouped {
|
||||
ForEach(Array(groups.enumerated()), id: \.element.name) { index, group in
|
||||
Section {
|
||||
ForEach(group.items) { row($0) }
|
||||
} header: {
|
||||
if index == 0 { header(group.name) } else { Text(group.name).watchEyebrow() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ForEach(open) { row($0) }
|
||||
} header: {
|
||||
header(nil)
|
||||
}
|
||||
}
|
||||
if !done.isEmpty {
|
||||
Section {
|
||||
ForEach(done) { row($0) }
|
||||
} header: {
|
||||
Text(doneTitle).watchEyebrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listSectionSpacing(.compact)
|
||||
.navigationTitle(title)
|
||||
.sensoryFeedback(.success, trigger: ticks)
|
||||
.containerBackground(WatchLook.tint(color), for: .navigation)
|
||||
}
|
||||
|
||||
/// The bar with its count, and under it the first group's name when there is one.
|
||||
@ViewBuilder
|
||||
private func header(_ group: String?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let progress {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView(value: Double(progress.done), total: Double(max(progress.total, 1)))
|
||||
.tint(color)
|
||||
Text("\(progress.done)/\(progress.total)")
|
||||
.font(.system(.footnote, design: .rounded).weight(.bold).monospacedDigit())
|
||||
.foregroundStyle(color)
|
||||
.fixedSize()
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
if let group { Text(group).watchEyebrow() }
|
||||
}
|
||||
.textCase(nil)
|
||||
}
|
||||
|
||||
private var groups: [(name: String, items: [WatchSnapshot.Item])] {
|
||||
var order: [String] = []
|
||||
var byName: [String: [WatchSnapshot.Item]] = [:]
|
||||
for item in open {
|
||||
let name = item.group ?? L("packing.other")
|
||||
if byName[name] == nil { order.append(name) }
|
||||
byName[name, default: []].append(item)
|
||||
}
|
||||
return order.map { ($0, byName[$0] ?? []) }
|
||||
}
|
||||
|
||||
private func row(_ item: WatchSnapshot.Item) -> some View {
|
||||
Button {
|
||||
ticks += 1
|
||||
withAnimation(.snappy) { toggle(item) }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: item.done ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(item.done ? color : WatchLook.faint)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(item.name).userText(item.name)
|
||||
.font(.system(.body, design: .rounded).weight(.medium))
|
||||
.strikethrough(item.done, color: WatchLook.faint)
|
||||
.foregroundStyle(item.done ? WatchLook.muted : WatchLook.ink)
|
||||
.lineLimit(2)
|
||||
if !item.done, let due = TrekDate.day(item.due) {
|
||||
HStack(spacing: 3) {
|
||||
if let priority = item.priority, let tint = Trek.priority(priority) {
|
||||
Circle().fill(tint).frame(width: 5, height: 5)
|
||||
}
|
||||
Text(WatchFormat.day(due))
|
||||
}
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
} else if !item.done, let priority = item.priority, let tint = Trek.priority(priority) {
|
||||
Circle().fill(tint).frame(width: 5, height: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityValue(L(item.done ? "a11y.done" : "a11y.open"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Screenshot mode only (-TrekDemo faces): every complication in every family
|
||||
/// it ships in, at the sizes a watch face gives them on this watch, to review
|
||||
/// them without setting up a face. Circular and corner ones three to a row,
|
||||
/// rectangular and inline ones across the screen's width.
|
||||
struct FacesView: View {
|
||||
let snapshot: WatchSnapshot?
|
||||
let now: Date
|
||||
/// A circular complication's diameter, from the screen: 42 pt on 40 mm, 50 on 49 mm.
|
||||
@State private var dial: CGFloat = 44
|
||||
|
||||
var body: some View {
|
||||
let state = ComplicationState(snapshot, now: now)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
round { NextStopComplication(state: state, family: .circular, now: now) }
|
||||
round { CountdownComplication(state: state, family: .circular, now: now) }
|
||||
round { TripDayComplication(state: state, family: .circular) }
|
||||
}
|
||||
Divider().opacity(0.3)
|
||||
HStack(spacing: 8) {
|
||||
round { NextStopComplication(state: state, family: .corner, now: now) }
|
||||
round { CountdownComplication(state: state, family: .corner, now: now) }
|
||||
round { TripDayComplication(state: state, family: .corner) }
|
||||
}
|
||||
Divider().opacity(0.3)
|
||||
wide(56) { NextStopComplication(state: state, family: .rectangular, now: now) }
|
||||
wide(56) { CountdownComplication(state: state, family: .rectangular, now: now) }
|
||||
wide(56) { TripDayComplication(state: state, family: .rectangular) }
|
||||
Divider().opacity(0.3)
|
||||
wide(18) { NextStopComplication(state: state, family: .inline, now: now) }
|
||||
wide(18) { CountdownComplication(state: state, family: .inline, now: now) }
|
||||
wide(18) { TripDayComplication(state: state, family: .inline) }
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.tint(Color(hex: 0xFF9F0A))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in
|
||||
dial = min(50, max(38, (width - 16) / 3))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func round<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
||||
content()
|
||||
.frame(width: dial, height: dial)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private func wide<Content: View>(_ height: CGFloat, @ViewBuilder _ content: () -> Content) -> some View {
|
||||
content()
|
||||
.frame(maxWidth: .infinity, minHeight: height, maxHeight: height, alignment: .leading)
|
||||
.padding(.horizontal, 6)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.06)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>TREK</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>com.liketrek.trek</string>
|
||||
<key>WKRunsIndependentlyOfCompanionApp</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,728 @@
|
||||
import SwiftUI
|
||||
import WatchKit
|
||||
|
||||
/// The 40 and 41 mm cases, where a line less decides whether a screen ends clean.
|
||||
enum WatchScreen {
|
||||
static let compact = WKInterfaceDevice.current().screenBounds.width < 180
|
||||
}
|
||||
|
||||
// MARK: - Today
|
||||
|
||||
/// The trip day at a glance: what is on now or next, how long until it, how
|
||||
/// far the day has come and what follows. A tap opens the stop.
|
||||
struct TodayPage: View {
|
||||
let snapshot: WatchSnapshot
|
||||
@Environment(\.trekNow) private var now
|
||||
@Environment(\.isLuminanceReduced) private var reduced
|
||||
|
||||
var body: some View {
|
||||
let todayIndex = snapshot.todayIndex(at: now)
|
||||
let day = todayIndex.map { snapshot.days[$0] }
|
||||
let stops = day?.stops ?? []
|
||||
let nowMinutes = TrekDate.minutesOfDay(now)
|
||||
let currentIndex = stops.firstIndex { stop in
|
||||
guard let start = stop.minutes, let end = stop.endMinutes else { return false }
|
||||
return start <= nowMinutes && nowMinutes < end
|
||||
}
|
||||
let nextIndex = stops.firstIndex { ($0.minutes ?? -1) >= nowMinutes }
|
||||
// A day planned without times still has its places: the first of them,
|
||||
// and "day done" only once every stop has a time and it is behind.
|
||||
let focus = currentIndex ?? nextIndex ?? stops.firstIndex { $0.minutes == nil }
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
header
|
||||
if let focus, let todayIndex {
|
||||
NavigationLink(value: Route.stop(day: todayIndex, stop: focus)) {
|
||||
FocusCard(
|
||||
stop: stops[focus],
|
||||
isNow: focus == currentIndex,
|
||||
hour12: snapshot.hour12,
|
||||
then: reduced ? nil : stops.dropFirst(focus + 1).first
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
DayProgressBar(stops: stops, nowMinutes: nowMinutes)
|
||||
.padding(.horizontal, 2)
|
||||
} else if stops.isEmpty {
|
||||
DoneCard(title: L("today.free"), detail: L("today.freeBody"), symbol: "sun.max.fill")
|
||||
} else {
|
||||
DoneCard(title: L("today.done"), detail: L("today.doneBody"), symbol: "checkmark.circle.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentMargins(.horizontal, 5, for: .scrollContent)
|
||||
.containerBackground(WatchLook.tripGradient(snapshot.trip), for: .tabView)
|
||||
.toolbar {
|
||||
if let weather = snapshot.weather {
|
||||
ToolbarItem(placement: .topBarTrailing) { WeatherBadge(weather: weather) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The day and the trip; a tap opens the whole day's timeline.
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
if case .running(let index, let total) = snapshot.phase(at: now), let todayIndex = snapshot.todayIndex(at: now) {
|
||||
NavigationLink(value: Route.day(todayIndex)) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 3) {
|
||||
Text(WatchFormat.dayOf(index, total)).watchEyebrow(.white.opacity(0.85))
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.system(size: 9, weight: .heavy))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
Text(snapshot.trip?.title ?? "").userText((snapshot.trip?.title ?? ""))
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityHint(L("today.plan"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stop that is on now or comes next, large.
|
||||
struct FocusCard: View {
|
||||
let stop: WatchSnapshot.Stop
|
||||
let isNow: Bool
|
||||
let hour12: Bool
|
||||
/// The stop after it, in a line at the foot.
|
||||
var then: WatchSnapshot.Stop?
|
||||
@Environment(\.trekNow) private var now
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
StopGlyph(stop: stop, size: 24)
|
||||
Text(L(isNow ? "today.now" : "today.next"))
|
||||
.watchEyebrow(isNow ? Trek.confirmed : WatchLook.muted)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Text(stop.name).userText(stop.name)
|
||||
.font(.system(WatchScreen.compact ? .headline : .title3, design: .rounded).weight(.bold))
|
||||
.foregroundStyle(WatchLook.ink)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.75)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
if let times = timeRange {
|
||||
Text(times)
|
||||
.font(.system(.footnote, design: .rounded).weight(.medium).monospacedDigit())
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
Spacer(minLength: 2)
|
||||
if let target = countdownTarget, target > now {
|
||||
Text(WatchFormat.relative(target, now: now))
|
||||
.font(.system(.footnote, design: .rounded).weight(.bold))
|
||||
.foregroundStyle(isNow ? WatchLook.muted : Trek.confirmed)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
}
|
||||
if let then {
|
||||
Divider().overlay(Color.white.opacity(0.12)).padding(.vertical, 2)
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "arrow.turn.down.right")
|
||||
.foregroundStyle(WatchLook.faint)
|
||||
if let minutes = then.minutes {
|
||||
Text(WatchFormat.time(minutes: minutes, hour12: hour12)).monospacedDigit()
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
Text(then.name).userText(then.name)
|
||||
.foregroundStyle(WatchLook.ink.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.font(.system(.caption, design: .rounded).weight(.semibold))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(L("today.later")): \(then.name)")
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.watchCard()
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
/// Next: until it starts. Now: until it ends.
|
||||
private var countdownTarget: Date? {
|
||||
guard !isNow, let minutes = stop.minutes else { return nil }
|
||||
let start = TrekDate.at(minutes, on: now)
|
||||
return start.timeIntervalSince(now) >= 60 ? start : nil
|
||||
}
|
||||
|
||||
private var timeRange: String? {
|
||||
guard let start = stop.minutes else { return nil }
|
||||
let from = WatchFormat.time(minutes: start, hour12: hour12)
|
||||
guard let end = stop.endMinutes else { return from }
|
||||
return "\(from) – \(WatchFormat.time(minutes: end, hour12: hour12))"
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's timed stops as segments, those behind filled.
|
||||
struct DayProgressBar: View {
|
||||
let stops: [WatchSnapshot.Stop]
|
||||
let nowMinutes: Int
|
||||
|
||||
var body: some View {
|
||||
let timed = stops.filter { $0.minutes != nil }
|
||||
if timed.count > 1 {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(Array(timed.enumerated()), id: \.offset) { _, stop in
|
||||
Capsule()
|
||||
.fill((stop.minutes ?? 0) < nowMinutes ? WatchLook.stopColor(stop) : Color.white.opacity(0.18))
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CompactStopRow: View {
|
||||
let stop: WatchSnapshot.Stop
|
||||
let hour12: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
StopGlyph(stop: stop, size: 20)
|
||||
Text(stop.name).userText(stop.name)
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(WatchLook.ink)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if let minutes = stop.minutes {
|
||||
Text(WatchFormat.time(minutes: minutes, hour12: hour12))
|
||||
.font(.system(.caption2, design: .rounded).weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
struct DoneCard: View {
|
||||
let title: String
|
||||
let detail: String
|
||||
let symbol: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Image(systemName: symbol)
|
||||
.font(.title2)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.foregroundStyle(Trek.confirmed)
|
||||
Text(title).font(.system(.headline, design: .rounded)).foregroundStyle(WatchLook.ink)
|
||||
Text(detail).userText(detail).font(.footnote).foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.watchCard()
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
struct WeatherBadge: View {
|
||||
let weather: WatchSnapshot.Weather
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: Trek.weatherSymbol(weather.main)).symbolRenderingMode(.multicolor)
|
||||
Text("\(weather.temp)°").monospacedDigit()
|
||||
}
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||
.accessibilityLabel("\(weather.label), \(weather.temp)°")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Before the trip
|
||||
|
||||
/// The days to the trip, big, over its cover or its own colours.
|
||||
struct CountdownPage: View {
|
||||
let snapshot: WatchSnapshot
|
||||
let days: Int
|
||||
@ScaledMetric(relativeTo: .largeTitle) private var numberSize: CGFloat = 54
|
||||
|
||||
var body: some View {
|
||||
let trip = snapshot.trip
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(L("countdown.title")).watchEyebrow(.white.opacity(0.85))
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 0)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(days)")
|
||||
.font(.system(size: numberSize, weight: .heavy, design: .rounded).monospacedDigit())
|
||||
.minimumScaleFactor(0.5)
|
||||
Text(WatchFormat.daysUnit(days))
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
Text(trip?.title ?? "").userText((trip?.title ?? ""))
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
HStack(spacing: 4) {
|
||||
if let range = trip.flatMap({ WatchFormat.range($0.start, $0.end) }) {
|
||||
Text(range)
|
||||
}
|
||||
if let place = trip?.place?.split(separator: ",").first {
|
||||
Text("·")
|
||||
Text(place)
|
||||
}
|
||||
}
|
||||
.font(.system(.caption2, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
.lineLimit(1)
|
||||
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: .white)
|
||||
.frame(height: 20)
|
||||
.padding(.top, 6)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.accessibilityElement(children: .combine)
|
||||
.padding(.horizontal, 5)
|
||||
.containerBackground(for: .tabView) {
|
||||
if trip?.cover != nil {
|
||||
TripBackdrop(trip: widgetTrip(trip), shade: 0.7)
|
||||
} else {
|
||||
let (first, second) = WatchLook.tripColors(trip)
|
||||
ZStack {
|
||||
LinearGradient(colors: [first, second, .black], startPoint: .topLeading, endPoint: .bottom)
|
||||
ContourLines(seed: UInt64(trip?.id ?? 1) % 997, rings: 11)
|
||||
.stroke(Color.white.opacity(0.12), lineWidth: 0.9)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
if let weather = snapshot.weather {
|
||||
ToolbarItem(placement: .topBarTrailing) { WeatherBadge(weather: weather) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func widgetTrip(_ trip: WatchSnapshot.Trip?) -> WidgetSnapshot.Trip? {
|
||||
trip.map { WidgetSnapshot.Trip(title: $0.title, start: $0.start, end: $0.end, path: "", place: $0.place, cover: $0.cover) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The next booking
|
||||
|
||||
/// The next flight, train, hotel or table: where to, when, the countdown, and
|
||||
/// what is asked for at the gate or the desk.
|
||||
struct BookingPage: View {
|
||||
let booking: WatchSnapshot.Booking
|
||||
let hour12: Bool
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
NavigationLink(value: Route.booking(booking.id)) {
|
||||
BookingSummary(booking: booking, hour12: hour12, compactFacts: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.contentMargins(.horizontal, 5, for: .scrollContent)
|
||||
.containerBackground(WatchLook.tint(Trek.booking(booking.kind).color), for: .tabView)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/// The head of a booking, shared by its page and its detail.
|
||||
struct BookingSummary: View {
|
||||
let booking: WatchSnapshot.Booking
|
||||
let hour12: Bool
|
||||
var showFacts = true
|
||||
/// The page's single row of facts rather than all of them.
|
||||
var compactFacts = false
|
||||
@Environment(\.trekNow) private var now
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: WatchScreen.compact ? 4 : 6) {
|
||||
// The kind and the status in one line ("FLIGHT · Confirmed"), the status under it when that is too long.
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 5) {
|
||||
KindBadge(kind: booking.kind, size: 20)
|
||||
kindLabel.fixedSize()
|
||||
status.fixedSize()
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
KindBadge(kind: booking.kind, size: 22)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
kindLabel
|
||||
status
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
if let route = booking.route {
|
||||
Text(route)
|
||||
.font(.system(.title2, design: .rounded).weight(.heavy))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.55)
|
||||
Text(booking.carrier ?? booking.title)
|
||||
.font(.system(WatchScreen.compact ? .caption : .footnote, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text(booking.title).userText(booking.title)
|
||||
.font(.system(.title3, design: .rounded).weight(.bold))
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
timing
|
||||
if showFacts { facts }
|
||||
}
|
||||
.foregroundStyle(WatchLook.ink)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
/// The time it starts (or, for a stay under way, ends), its day, and how long until then, in one line.
|
||||
@ViewBuilder
|
||||
private var timing: some View {
|
||||
let running = booking.kind == "hotel" && (booking.start ?? .distantFuture) < now
|
||||
let moment = running ? booking.end : booking.start
|
||||
let clock = running ? booking.checkOut ?? booking.endTime : booking.time
|
||||
if let moment {
|
||||
// The day goes first when the line is too short for all three.
|
||||
let label = running ? L("booking.checkOut") : dayLabel(moment)
|
||||
// A booking with no time lasts the day: its day, but no countdown to midnight.
|
||||
let counts = running ? clock != nil : !booking.isAllDay
|
||||
ViewThatFits(in: .horizontal) {
|
||||
timeLine(clock: clock, label: label, moment: moment, pill: counts)
|
||||
timeLine(clock: clock, label: nil, moment: moment, pill: counts)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
timeLine(clock: clock, label: label, moment: moment, pill: false)
|
||||
if counts { countdownPill(moment) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func timeLine(clock: String?, label: String?, moment: Date, pill: Bool) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 5) {
|
||||
if let clock, let minutes = TrekDate.minutes(clock) {
|
||||
Text(WatchFormat.time(minutes: minutes, hour12: hour12))
|
||||
.font(.system(.title3, design: .rounded).weight(.bold).monospacedDigit())
|
||||
.fixedSize()
|
||||
}
|
||||
if let label {
|
||||
Text(label)
|
||||
.font(.system(.caption2, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.fixedSize()
|
||||
}
|
||||
if pill {
|
||||
Spacer(minLength: 4)
|
||||
countdownPill(moment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func countdownPill(_ moment: Date) -> some View {
|
||||
if moment > now {
|
||||
Text(WatchFormat.relative(moment, now: now))
|
||||
.font(.system(.caption2, design: .rounded).weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.fixedSize()
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(Trek.booking(booking.kind).color.opacity(0.85)))
|
||||
}
|
||||
}
|
||||
|
||||
private var kindLabel: some View {
|
||||
Text(WatchLook.kindName(booking.kind))
|
||||
.font(.system(.caption2, design: .rounded).weight(.bold))
|
||||
.textCase(.uppercase)
|
||||
.tracking(0.4)
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
private var status: some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(booking.confirmed ? Trek.confirmed : Trek.pending).frame(width: 5, height: 5)
|
||||
Text(L(booking.confirmed ? "booking.confirmed" : "booking.pending"))
|
||||
.font(.system(.caption2, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private var factTiles: [(label: String, value: String)] {
|
||||
var tiles: [(label: String, value: String)] = []
|
||||
if let seat = booking.seat { tiles.append((L("booking.seat"), seat)) }
|
||||
if let platform = booking.platform { tiles.append((L("booking.platform"), platform)) }
|
||||
if booking.kind == "hotel", let checkIn = TrekDate.minutes(booking.checkIn) {
|
||||
tiles.append((L("booking.checkIn"), WatchFormat.time(minutes: checkIn, hour12: hour12)))
|
||||
}
|
||||
return tiles
|
||||
}
|
||||
|
||||
private func dayLabel(_ date: Date) -> String {
|
||||
let days = TrekDate.daysBetween(now, date)
|
||||
if days == 0 { return L("time.today") }
|
||||
if days == 1 { return L("time.tomorrow") }
|
||||
return WatchFormat.day(date)
|
||||
}
|
||||
|
||||
/// On the page one row, the reference and the seat; in the detail all of them.
|
||||
@ViewBuilder
|
||||
private var facts: some View {
|
||||
let tiles = factTiles
|
||||
if compactFacts {
|
||||
let row = [booking.code.map { (L("booking.ref"), $0, true) }, tiles.first.map { ($0.label, $0.value, false) }].compactMap { $0 }
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(row, id: \.0) { FactTile(label: $0.0, value: $0.1, mono: $0.2, fits: true) }
|
||||
}
|
||||
VStack(spacing: 6) {
|
||||
ForEach(row, id: \.0) { FactTile(label: $0.0, value: $0.1, mono: $0.2) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
factsInFull(tiles)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func factsInFull(_ tiles: [(label: String, value: String)]) -> some View {
|
||||
if !tiles.isEmpty {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(tiles.prefix(2), id: \.label) { tile in
|
||||
FactTile(label: tile.label, value: tile.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let code = booking.code {
|
||||
FactTile(label: L("booking.ref"), value: code, mono: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The trip
|
||||
|
||||
/// The trip as a whole: how far along, and the ways into its days, bookings and lists.
|
||||
struct TripPage: View {
|
||||
let snapshot: WatchSnapshot
|
||||
@Environment(\.trekNow) private var now
|
||||
|
||||
var body: some View {
|
||||
let trip = snapshot.trip
|
||||
let total = snapshot.tripLength
|
||||
let index: Int = {
|
||||
if case .running(let day, _) = snapshot.phase(at: now) { return day }
|
||||
return 0
|
||||
}()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
ProgressRing(value: Double(index) / Double(max(total, 1)), lineWidth: 5, colors: [.white.opacity(0.7), .white], trackColor: .white.opacity(0.2)) {
|
||||
VStack(spacing: -2) {
|
||||
Text(index > 0 ? "\(index)" : "\(total)")
|
||||
.font(.system(.headline, design: .rounded).weight(.heavy).monospacedDigit())
|
||||
Text(index > 0 ? "/\(total)" : WatchFormat.daysUnit(total))
|
||||
.font(.system(size: 9, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
.frame(width: 46, height: 46)
|
||||
.accessibilityLabel(index > 0 ? WatchFormat.dayOf(index, total) : "\(total) \(WatchFormat.daysUnit(total))")
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(trip?.title ?? "").userText((trip?.title ?? ""))
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
if let range = trip.flatMap({ WatchFormat.range($0.start, $0.end) }) {
|
||||
Text(range)
|
||||
.font(.system(.caption2, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
NavigationLink(value: Route.days) {
|
||||
MenuRow(symbol: "calendar", color: Trek.info, title: L("trip.days"), value: "\(total)")
|
||||
}
|
||||
if !snapshot.bookings.isEmpty {
|
||||
NavigationLink(value: Route.bookings) {
|
||||
MenuRow(symbol: "ticket.fill", color: Trek.booking("event").color, title: L("booking.all"), value: "\(snapshot.bookingsAhead(at: now).count)")
|
||||
}
|
||||
}
|
||||
if let packing = snapshot.packing {
|
||||
NavigationLink(value: Route.packing) {
|
||||
MenuRow(symbol: "suitcase.rolling.fill", color: WatchLook.packing, title: L("packing.title"), value: "\(packing.packed)/\(packing.total)")
|
||||
}
|
||||
}
|
||||
if let todos = snapshot.todos {
|
||||
NavigationLink(value: Route.todos) {
|
||||
MenuRow(symbol: "checklist", color: WatchLook.todo, title: L("todos.title"), value: "\(todos.open)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(WatchLook.ink)
|
||||
}
|
||||
.contentMargins(.horizontal, 5, for: .scrollContent)
|
||||
.containerBackground(WatchLook.tripGradient(trip), for: .tabView)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/// A way into a part of the trip: its symbol, its name, a count.
|
||||
struct MenuRow: View {
|
||||
let symbol: String
|
||||
let color: Color
|
||||
let title: String
|
||||
let value: String?
|
||||
|
||||
var body: some View {
|
||||
let compact = WatchScreen.compact
|
||||
HStack(spacing: compact ? 6 : 8) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: compact ? 11 : 13, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: compact ? 22 : 26, height: compact ? 22 : 26)
|
||||
.background(Circle().fill(color.gradient))
|
||||
Text(title)
|
||||
.font(.system(compact ? .callout : .body, design: .rounded).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.65)
|
||||
.allowsTightening(true)
|
||||
Spacer(minLength: 2)
|
||||
if let value {
|
||||
Text(value)
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, WatchScreen.compact ? 8 : 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.watchCard(cornerRadius: 16)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The lists
|
||||
|
||||
/// The packing list and the to-dos as two rings, each opening its list.
|
||||
struct ListsPage: View {
|
||||
let snapshot: WatchSnapshot
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
if let packing = snapshot.packing {
|
||||
NavigationLink(value: Route.packing) {
|
||||
ListRing(value: Double(packing.packed) / Double(max(packing.total, 1)), color: WatchLook.packing,
|
||||
number: "\(packing.packed)", of: "/\(packing.total)", title: L("packing.title"), detail: L("packing.packed"))
|
||||
}
|
||||
}
|
||||
if let todos = snapshot.todos {
|
||||
NavigationLink(value: Route.todos) {
|
||||
ListRing(value: Double(todos.total - todos.open) / Double(max(todos.total, 1)), color: WatchLook.todo,
|
||||
number: "\(todos.open)", of: nil, title: L("todos.title"), detail: L("todos.open"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if let next = snapshot.packing?.items.first(where: { !$0.done }) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "circle").foregroundStyle(WatchLook.packing)
|
||||
Text(next.name).userText(next.name).lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.system(.footnote, design: .rounded).weight(.medium))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.watchCard(cornerRadius: 12)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.horizontal, 5)
|
||||
.containerBackground(WatchLook.tint(WatchLook.packing), for: .tabView)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct ListRing: View {
|
||||
let value: Double
|
||||
let color: Color
|
||||
let number: String
|
||||
let of: String?
|
||||
let title: String
|
||||
let detail: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 5) {
|
||||
ProgressRing(value: value, lineWidth: 6, colors: [color.opacity(0.7), color], trackColor: color.opacity(0.22)) {
|
||||
VStack(spacing: -3) {
|
||||
Text(number).font(.system(.title3, design: .rounded).weight(.heavy).monospacedDigit())
|
||||
if let of {
|
||||
Text(of).font(.system(size: 10, weight: .bold, design: .rounded)).foregroundStyle(WatchLook.muted)
|
||||
} else {
|
||||
Text(detail).userText(detail).font(.system(size: 9, weight: .bold, design: .rounded)).foregroundStyle(WatchLook.muted).lineLimit(1).minimumScaleFactor(0.6)
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
}
|
||||
.frame(width: 62, height: 62)
|
||||
Text(title)
|
||||
.font(.system(.caption2, design: .rounded).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.watchCard()
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty
|
||||
|
||||
struct EmptyStateView: View {
|
||||
enum Kind { case notSynced, noTrip }
|
||||
let kind: Kind
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
TrekMark()
|
||||
.fill(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
.padding(14)
|
||||
.background(Circle().fill(Color.white.opacity(0.1)))
|
||||
.accessibilityHidden(true)
|
||||
Text(L(kind == .notSynced ? "empty.sync.title" : "empty.trip.title"))
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(L(kind == .notSynced ? "empty.sync.body" : "empty.trip.body"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(WatchLook.muted)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.containerBackground(WatchLook.tint(Color(hex: 0x3A3A44)), for: .navigation)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,160 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct TREKWatchApp: App {
|
||||
init() {
|
||||
if DemoMode.screen != nil {
|
||||
DemoMode.start()
|
||||
} else {
|
||||
WatchLink.shared.activate()
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
}
|
||||
// Woken in the background to take a snapshot or complication update from
|
||||
// the phone: the task lasts until it is delivered, stored and drawn.
|
||||
.backgroundTask(.watchConnectivity) {
|
||||
await WatchLink.shared.finishPendingDeliveries()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NowKey: EnvironmentKey {
|
||||
static let defaultValue = Date()
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// The minute the screens show, from the root's TimelineView (or the screenshot clock).
|
||||
var trekNow: Date {
|
||||
get { self[NowKey.self] }
|
||||
set { self[NowKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a tap leads from the pages.
|
||||
enum Route: Hashable {
|
||||
case day(Int)
|
||||
case stop(day: Int, stop: Int)
|
||||
case booking(Int)
|
||||
case bookings
|
||||
case days
|
||||
case packing
|
||||
case todos
|
||||
}
|
||||
|
||||
enum Page: Hashable {
|
||||
case now, booking, trip, lists
|
||||
}
|
||||
|
||||
/// The pages under each other, turned with the Digital Crown: today (or the
|
||||
/// countdown before the trip), the next booking, the trip, the lists. A
|
||||
/// minute timeline keeps every countdown current, Always On included.
|
||||
struct RootView: View {
|
||||
private let model = WatchModel.shared
|
||||
@State private var path: [Route] = []
|
||||
@State private var page: Page = .now
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.everyMinute) { context in
|
||||
let now = model.now(context.date)
|
||||
NavigationStack(path: $path) {
|
||||
content(now)
|
||||
.navigationDestination(for: Route.self) { route in
|
||||
destination(route)
|
||||
}
|
||||
}
|
||||
.environment(\.trekNow, now)
|
||||
}
|
||||
.onAppear {
|
||||
openDemoScreen()
|
||||
DemoMode.signalReady()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active && !model.isDemo { WatchLink.shared.requestRefresh() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(_ now: Date) -> some View {
|
||||
if DemoMode.screen == .faces {
|
||||
FacesView(snapshot: model.display, now: now)
|
||||
} else if let snapshot = model.display {
|
||||
if snapshot.trip == nil || snapshot.phase(at: now) == .past {
|
||||
EmptyStateView(kind: .noTrip)
|
||||
} else {
|
||||
pages(snapshot, now)
|
||||
}
|
||||
} else {
|
||||
EmptyStateView(kind: .notSynced)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func pages(_ snapshot: WatchSnapshot, _ now: Date) -> some View {
|
||||
let booking = snapshot.nextBooking(at: now)
|
||||
TabView(selection: $page) {
|
||||
Group {
|
||||
if case .upcoming(let days) = snapshot.phase(at: now) {
|
||||
CountdownPage(snapshot: snapshot, days: days)
|
||||
} else {
|
||||
TodayPage(snapshot: snapshot)
|
||||
}
|
||||
}
|
||||
.tag(Page.now)
|
||||
if let booking {
|
||||
BookingPage(booking: booking, hour12: snapshot.hour12)
|
||||
.tag(Page.booking)
|
||||
}
|
||||
TripPage(snapshot: snapshot)
|
||||
.tag(Page.trip)
|
||||
if snapshot.packing != nil || snapshot.todos != nil {
|
||||
ListsPage(snapshot: snapshot)
|
||||
.tag(Page.lists)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.verticalPage)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func destination(_ route: Route) -> some View {
|
||||
if let snapshot = model.display {
|
||||
switch route {
|
||||
case .day(let index): DayView(snapshot: snapshot, index: index)
|
||||
case .stop(let day, let stop): StopDetailView(snapshot: snapshot, day: day, stop: stop)
|
||||
case .booking(let id): BookingDetailView(snapshot: snapshot, id: id)
|
||||
case .bookings: BookingsListView(snapshot: snapshot)
|
||||
case .days: DaysListView(snapshot: snapshot)
|
||||
case .packing: PackingListView()
|
||||
case .todos: TodoListView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Screenshot mode: opens the screen the launch arguments name.
|
||||
private func openDemoScreen() {
|
||||
guard let screen = DemoMode.screen, let snapshot = model.display else { return }
|
||||
let now = model.now()
|
||||
let today = snapshot.todayIndex(at: now) ?? 0
|
||||
switch screen {
|
||||
case .booking: page = .booking
|
||||
case .trip: page = .trip
|
||||
case .lists: page = .lists
|
||||
case .day: path = [.day(today)]
|
||||
case .days: path = [.days]
|
||||
case .place:
|
||||
let stops = snapshot.days.indices.contains(today) ? snapshot.days[today].stops : []
|
||||
let next = stops.firstIndex { ($0.minutes ?? 0) >= TrekDate.minutesOfDay(now) } ?? 0
|
||||
path = [.stop(day: today, stop: next)]
|
||||
case .bookings: path = [.bookings]
|
||||
case .bookingDetail:
|
||||
if let booking = snapshot.nextBooking(at: now) { path = [.booking(booking.id)] }
|
||||
case .packing: path = [.packing]
|
||||
case .todos: path = [.todos]
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import WatchConnectivity
|
||||
import WidgetKit
|
||||
|
||||
/// The watch's state: the last snapshot from the phone with the ticks it has
|
||||
/// not confirmed yet laid over it. Everything the screens show comes from here.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class WatchModel {
|
||||
static let shared = WatchModel()
|
||||
|
||||
private(set) var snapshot: WatchSnapshot?
|
||||
private(set) var pending: [PendingAction] = []
|
||||
/// Set in screenshot mode (DemoMode.swift): the clock the screens read, and nothing is sent or stored.
|
||||
private(set) var fixedNow: Date?
|
||||
private(set) var isDemo = false
|
||||
|
||||
/// The snapshot as the screens show it.
|
||||
var display: WatchSnapshot? { snapshot?.applying(pending) }
|
||||
|
||||
private init() {
|
||||
snapshot = WatchStore.loadSnapshot()
|
||||
pending = WatchStore.loadPending()
|
||||
}
|
||||
|
||||
func now(_ date: Date = Date()) -> Date { fixedNow ?? date }
|
||||
|
||||
func startDemo(_ snapshot: WatchSnapshot?, now: Date) {
|
||||
isDemo = true
|
||||
fixedNow = now
|
||||
self.snapshot = snapshot
|
||||
pending = []
|
||||
}
|
||||
|
||||
/// A snapshot from the phone: kept, the confirmed ticks dropped, the complications redrawn.
|
||||
func receive(_ data: Data) {
|
||||
guard !isDemo, let incoming = WatchSnapshot.decode(data) else { return }
|
||||
if let current = snapshot, let a = current.generated, let b = incoming.generated, b < a { return }
|
||||
snapshot = incoming
|
||||
WatchStore.saveSnapshot(data)
|
||||
pending = incoming.stillPending(pending)
|
||||
WatchStore.savePending(pending)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
/// Ticks a packing item or to-do: shown at once, sent to the phone, which
|
||||
/// writes it through the app's own offline-first path.
|
||||
func toggle(_ list: PendingAction.List, item: WatchSnapshot.Item) {
|
||||
guard let tripId = display?.trip?.id else { return }
|
||||
let action = PendingAction(id: UUID().uuidString, tripId: tripId, list: list, itemId: item.id, done: !item.done, at: Date())
|
||||
pending.removeAll { $0.list == list && $0.itemId == item.id }
|
||||
pending.append(action)
|
||||
guard !isDemo else { return }
|
||||
WatchStore.savePending(pending)
|
||||
WatchLink.shared.send(action)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
var isWaitingForPhone: Bool { !pending.isEmpty }
|
||||
|
||||
/// WatchConnectivity handed a tick to the phone: it is not sent again.
|
||||
func markSent(_ id: String) {
|
||||
guard let index = pending.firstIndex(where: { $0.id == id }) else { return }
|
||||
pending[index].sent = true
|
||||
WatchStore.savePending(pending)
|
||||
}
|
||||
|
||||
/// Ticks that never reached the phone's queue: not yet handed over, or failed.
|
||||
func unsent(excluding queued: Set<String>) -> [PendingAction] {
|
||||
pending.filter { $0.sent != true && !queued.contains($0.id) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The watch's end of WatchConnectivity: snapshots arrive as the application
|
||||
/// context (the latest one, kept by the system across launches) and as
|
||||
/// complication user info (sent when the phone has budget for it), ticks
|
||||
/// leave as user info, which the system queues until the phone takes them.
|
||||
final class WatchLink: NSObject, WCSessionDelegate {
|
||||
static let shared = WatchLink()
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
if WCSession.default.delegate == nil { WCSession.default.delegate = self }
|
||||
if WCSession.default.activationState != .activated { WCSession.default.activate() }
|
||||
}
|
||||
|
||||
/// For the system's WatchConnectivity background task: activates the session
|
||||
/// and returns only once what the phone sent has been delivered (and the
|
||||
/// snapshot stored and the complications told), or after twenty seconds.
|
||||
func finishPendingDeliveries() async {
|
||||
activate()
|
||||
for _ in 0..<100 {
|
||||
let session = WCSession.default
|
||||
if session.activationState == .activated && !session.hasContentPending { break }
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
// Let the deliveries already queued on the main actor run before the task ends.
|
||||
await MainActor.run {}
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
// Only ticks that never reached the phone's queue go again: those still in the
|
||||
// system's queue are on their way, and those handed over are the phone's now.
|
||||
let queued = Set(session.outstandingUserInfoTransfers.compactMap { ($0.userInfo["action"] as? [String: Any])?["id"] as? String })
|
||||
Task { @MainActor in
|
||||
for action in WatchModel.shared.unsent(excluding: queued) {
|
||||
session.transferUserInfo(["action": action.message])
|
||||
}
|
||||
}
|
||||
requestRefresh()
|
||||
}
|
||||
|
||||
/// A tick reached the phone's queue (or failed, and is sent again on the next activation).
|
||||
func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) {
|
||||
guard error == nil, let id = (userInfoTransfer.userInfo["action"] as? [String: Any])?["id"] as? String else { return }
|
||||
Task { @MainActor in WatchModel.shared.markSent(id) }
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
guard let data = applicationContext["snapshot"] as? Data else { return }
|
||||
Task { @MainActor in WatchModel.shared.receive(data) }
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
guard let data = userInfo["snapshot"] as? Data else { return }
|
||||
Task { @MainActor in WatchModel.shared.receive(data) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "اليوم";
|
||||
"today.next" = "التالي";
|
||||
"today.now" = "الآن";
|
||||
"today.later" = "لاحقًا";
|
||||
"today.done" = "انتهى اليوم";
|
||||
"today.doneBody" = "لا شيء آخر مخطط لليوم.";
|
||||
"today.free" = "يوم حر";
|
||||
"today.freeBody" = "لا شيء مخطط لليوم. استمتع.";
|
||||
"today.plan" = "خطة اليوم";
|
||||
"day.of" = "اليوم %1$d من %2$d";
|
||||
"day.empty" = "لا شيء مخطط";
|
||||
"time.now" = "الآن";
|
||||
"time.today" = "اليوم";
|
||||
"time.tomorrow" = "غدًا";
|
||||
"countdown.title" = "الرحلة القادمة";
|
||||
"booking.next" = "الحجز القادم";
|
||||
"booking.all" = "الحجوزات";
|
||||
"booking.none" = "لا حجوزات قادمة";
|
||||
"booking.confirmed" = "مؤكد";
|
||||
"booking.pending" = "قيد الانتظار";
|
||||
"booking.seat" = "المقعد";
|
||||
"booking.platform" = "الرصيف";
|
||||
"booking.ref" = "رقم الحجز";
|
||||
"booking.checkIn" = "تسجيل الوصول";
|
||||
"booking.checkOut" = "تسجيل المغادرة";
|
||||
"booking.departs" = "المغادرة";
|
||||
"booking.arrives" = "الوصول";
|
||||
"kind.flight" = "رحلة جوية";
|
||||
"kind.train" = "قطار";
|
||||
"kind.bus" = "حافلة";
|
||||
"kind.car" = "سيارة";
|
||||
"kind.cruise" = "عبّارة";
|
||||
"kind.hotel" = "فندق";
|
||||
"kind.restaurant" = "مطعم";
|
||||
"kind.event" = "فعالية";
|
||||
"kind.tour" = "جولة";
|
||||
"kind.other" = "حجز";
|
||||
"trip.title" = "الرحلة";
|
||||
"trip.days" = "الأيام";
|
||||
"place.title" = "المكان";
|
||||
"place.directions" = "الاتجاهات";
|
||||
"lists.title" = "القوائم";
|
||||
"packing.title" = "الأمتعة";
|
||||
"packing.packed" = "محزوم";
|
||||
"packing.packedSection" = "محزومة";
|
||||
"packing.allPacked" = "كل شيء محزوم";
|
||||
"packing.other" = "أخرى";
|
||||
"todos.title" = "المهام";
|
||||
"todos.open" = "مفتوحة";
|
||||
"todos.doneSection" = "منجزة";
|
||||
"todos.allDone" = "كل شيء منجز";
|
||||
"empty.sync.title" = "افتح TREK على iPhone";
|
||||
"empty.sync.body" = "ستظهر رحلتك هنا بعد مزامنة التطبيق على iPhone.";
|
||||
"empty.trip.title" = "لا توجد رحلة مخططة";
|
||||
"empty.trip.body" = "خطط لرحلتك القادمة في TREK وستظهر هنا.";
|
||||
"complication.nextStop.name" = "المحطة التالية";
|
||||
"complication.nextStop.description" = "المحطة التالية في اليوم وموعد بدئها.";
|
||||
"complication.countdown.name" = "العد التنازلي";
|
||||
"complication.countdown.description" = "الوقت المتبقي حتى رحلتك الجوية أو القطار أو تسجيل الوصول أو الرحلة القادمة.";
|
||||
"complication.tripDay.name" = "يوم الرحلة";
|
||||
"complication.tripDay.description" = "في أي يوم من الرحلة أنت.";
|
||||
"complication.noTrip" = "لا رحلة";
|
||||
"a11y.done" = "منجزة";
|
||||
"a11y.open" = "غير منجزة";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Bu gün";
|
||||
"today.next" = "Növbəti";
|
||||
"today.now" = "İndi";
|
||||
"today.later" = "Sonra";
|
||||
"today.done" = "Gün bitdi";
|
||||
"today.doneBody" = "Bu gün üçün başqa plan yoxdur.";
|
||||
"today.free" = "Boş gün";
|
||||
"today.freeBody" = "Bu gün plan yoxdur. Həzz alın.";
|
||||
"today.plan" = "Bu günün planı";
|
||||
"day.of" = "Gün %1$d / %2$d";
|
||||
"day.empty" = "Plan yoxdur";
|
||||
"time.now" = "indi";
|
||||
"time.today" = "Bu gün";
|
||||
"time.tomorrow" = "Sabah";
|
||||
"countdown.title" = "Növbəti səyahət";
|
||||
"booking.next" = "Növbəti rezervasiya";
|
||||
"booking.all" = "Rezervasiyalar";
|
||||
"booking.none" = "Qarşıda rezervasiya yoxdur";
|
||||
"booking.confirmed" = "Təsdiqlənib";
|
||||
"booking.pending" = "Gözləyir";
|
||||
"booking.seat" = "Oturacaq";
|
||||
"booking.platform" = "Platforma";
|
||||
"booking.ref" = "Rezervasiya kodu";
|
||||
"booking.checkIn" = "Qeydiyyat";
|
||||
"booking.checkOut" = "Çıxış";
|
||||
"booking.departs" = "Yola düşmə";
|
||||
"booking.arrives" = "Çatma";
|
||||
"kind.flight" = "Uçuş";
|
||||
"kind.train" = "Qatar";
|
||||
"kind.bus" = "Avtobus";
|
||||
"kind.car" = "Avtomobil";
|
||||
"kind.cruise" = "Bərə";
|
||||
"kind.hotel" = "Otel";
|
||||
"kind.restaurant" = "Restoran";
|
||||
"kind.event" = "Tədbir";
|
||||
"kind.tour" = "Tur";
|
||||
"kind.other" = "Rezervasiya";
|
||||
"trip.title" = "Səyahət";
|
||||
"trip.days" = "Günlər";
|
||||
"place.title" = "Məkan";
|
||||
"place.directions" = "Marşrut";
|
||||
"lists.title" = "Siyahılar";
|
||||
"packing.title" = "Baqaj";
|
||||
"packing.packed" = "yığılıb";
|
||||
"packing.packedSection" = "Yığılıb";
|
||||
"packing.allPacked" = "Hər şey yığılıb";
|
||||
"packing.other" = "Digər";
|
||||
"todos.title" = "Tapşırıqlar";
|
||||
"todos.open" = "açıq";
|
||||
"todos.doneSection" = "Hazırdır";
|
||||
"todos.allDone" = "Hamısı hazırdır";
|
||||
"empty.sync.title" = "TREK-i iPhone-da açın";
|
||||
"empty.sync.body" = "Səyahətiniz iPhone-dakı tətbiq sinxronlaşandan sonra burada görünəcək.";
|
||||
"empty.trip.title" = "Planlaşdırılmış səyahət yoxdur";
|
||||
"empty.trip.body" = "Növbəti səyahətinizi TREK-də planlaşdırın, burada görünəcək.";
|
||||
"complication.nextStop.name" = "Növbəti dayanacaq";
|
||||
"complication.nextStop.description" = "Günün növbəti dayanacağı və başlama vaxtı.";
|
||||
"complication.countdown.name" = "Geri sayım";
|
||||
"complication.countdown.description" = "Növbəti uçuşa, qatara, qeydiyyata və ya səyahətə qalan vaxt.";
|
||||
"complication.tripDay.name" = "Səyahət günü";
|
||||
"complication.tripDay.description" = "Səyahətin neçənci günü olduğu.";
|
||||
"complication.noTrip" = "Səyahət yoxdur";
|
||||
"a11y.done" = "Hazırdır";
|
||||
"a11y.open" = "Hazır deyil";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Avui";
|
||||
"today.next" = "Següent";
|
||||
"today.now" = "Ara";
|
||||
"today.later" = "Més tard";
|
||||
"today.done" = "Dia acabat";
|
||||
"today.doneBody" = "Avui no hi ha res més previst.";
|
||||
"today.free" = "Dia lliure";
|
||||
"today.freeBody" = "Avui no hi ha res previst. Gaudeix-ne.";
|
||||
"today.plan" = "Pla d'avui";
|
||||
"day.of" = "Dia %1$d de %2$d";
|
||||
"day.empty" = "Res previst";
|
||||
"time.now" = "ara";
|
||||
"time.today" = "Avui";
|
||||
"time.tomorrow" = "Demà";
|
||||
"countdown.title" = "Proper viatge";
|
||||
"booking.next" = "Propera reserva";
|
||||
"booking.all" = "Reserves";
|
||||
"booking.none" = "Cap reserva pròxima";
|
||||
"booking.confirmed" = "Confirmada";
|
||||
"booking.pending" = "Pendent";
|
||||
"booking.seat" = "Seient";
|
||||
"booking.platform" = "Andana";
|
||||
"booking.ref" = "Localitzador";
|
||||
"booking.checkIn" = "Entrada";
|
||||
"booking.checkOut" = "Sortida";
|
||||
"booking.departs" = "Surt";
|
||||
"booking.arrives" = "Arriba";
|
||||
"kind.flight" = "Vol";
|
||||
"kind.train" = "Tren";
|
||||
"kind.bus" = "Autobús";
|
||||
"kind.car" = "Cotxe";
|
||||
"kind.cruise" = "Ferri";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restaurant";
|
||||
"kind.event" = "Esdeveniment";
|
||||
"kind.tour" = "Excursió";
|
||||
"kind.other" = "Reserva";
|
||||
"trip.title" = "Viatge";
|
||||
"trip.days" = "Dies";
|
||||
"place.title" = "Lloc";
|
||||
"place.directions" = "Com arribar-hi";
|
||||
"lists.title" = "Llistes";
|
||||
"packing.title" = "Equipatge";
|
||||
"packing.packed" = "a la maleta";
|
||||
"packing.packedSection" = "A la maleta";
|
||||
"packing.allPacked" = "Tot a la maleta";
|
||||
"packing.other" = "Altres";
|
||||
"todos.title" = "Tasques";
|
||||
"todos.open" = "obertes";
|
||||
"todos.doneSection" = "Fetes";
|
||||
"todos.allDone" = "Tot fet";
|
||||
"empty.sync.title" = "Obre TREK a l'iPhone";
|
||||
"empty.sync.body" = "El viatge apareixerà aquí quan l'app de l'iPhone s'hagi sincronitzat.";
|
||||
"empty.trip.title" = "Cap viatge previst";
|
||||
"empty.trip.body" = "Planifica el proper viatge a TREK i apareixerà aquí.";
|
||||
"complication.nextStop.name" = "Propera parada";
|
||||
"complication.nextStop.description" = "La propera parada del dia i quan comença.";
|
||||
"complication.countdown.name" = "Compte enrere";
|
||||
"complication.countdown.description" = "Temps fins al proper vol, tren, entrada a l'hotel o viatge.";
|
||||
"complication.tripDay.name" = "Dia del viatge";
|
||||
"complication.tripDay.description" = "Quin dia del viatge és.";
|
||||
"complication.noTrip" = "Cap viatge";
|
||||
"a11y.done" = "Feta";
|
||||
"a11y.open" = "No feta";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Dnes";
|
||||
"today.next" = "Další";
|
||||
"today.now" = "Teď";
|
||||
"today.later" = "Později";
|
||||
"today.done" = "Den hotový";
|
||||
"today.doneBody" = "Na dnešek už není nic naplánováno.";
|
||||
"today.free" = "Volný den";
|
||||
"today.freeBody" = "Dnes není nic naplánováno. Užijte si to.";
|
||||
"today.plan" = "Dnešní plán";
|
||||
"day.of" = "Den %1$d z %2$d";
|
||||
"day.empty" = "Nic naplánováno";
|
||||
"time.now" = "teď";
|
||||
"time.today" = "Dnes";
|
||||
"time.tomorrow" = "Zítra";
|
||||
"countdown.title" = "Příští cesta";
|
||||
"booking.next" = "Příští rezervace";
|
||||
"booking.all" = "Rezervace";
|
||||
"booking.none" = "Žádné nadcházející rezervace";
|
||||
"booking.confirmed" = "Potvrzeno";
|
||||
"booking.pending" = "Čeká";
|
||||
"booking.seat" = "Sedadlo";
|
||||
"booking.platform" = "Nástupiště";
|
||||
"booking.ref" = "Číslo rezervace";
|
||||
"booking.checkIn" = "Příjezd";
|
||||
"booking.checkOut" = "Odjezd";
|
||||
"booking.departs" = "Odjezd";
|
||||
"booking.arrives" = "Příjezd";
|
||||
"kind.flight" = "Let";
|
||||
"kind.train" = "Vlak";
|
||||
"kind.bus" = "Autobus";
|
||||
"kind.car" = "Auto";
|
||||
"kind.cruise" = "Trajekt";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restaurace";
|
||||
"kind.event" = "Akce";
|
||||
"kind.tour" = "Výlet";
|
||||
"kind.other" = "Rezervace";
|
||||
"trip.title" = "Cesta";
|
||||
"trip.days" = "Dny";
|
||||
"place.title" = "Místo";
|
||||
"place.directions" = "Navigovat";
|
||||
"lists.title" = "Seznamy";
|
||||
"packing.title" = "Balení";
|
||||
"packing.packed" = "zabaleno";
|
||||
"packing.packedSection" = "Zabaleno";
|
||||
"packing.allPacked" = "Vše zabaleno";
|
||||
"packing.other" = "Ostatní";
|
||||
"todos.title" = "Úkoly";
|
||||
"todos.open" = "otevřené";
|
||||
"todos.doneSection" = "Hotovo";
|
||||
"todos.allDone" = "Vše hotovo";
|
||||
"empty.sync.title" = "Otevřete TREK na iPhonu";
|
||||
"empty.sync.body" = "Vaše cesta se tu zobrazí, jakmile se aplikace na iPhonu synchronizuje.";
|
||||
"empty.trip.title" = "Žádná naplánovaná cesta";
|
||||
"empty.trip.body" = "Naplánujte příští cestu v TREK a zobrazí se tady.";
|
||||
"complication.nextStop.name" = "Další zastávka";
|
||||
"complication.nextStop.description" = "Další zastávka dne a kdy začíná.";
|
||||
"complication.countdown.name" = "Odpočet";
|
||||
"complication.countdown.description" = "Čas do dalšího letu, vlaku, ubytování nebo cesty.";
|
||||
"complication.tripDay.name" = "Den cesty";
|
||||
"complication.tripDay.description" = "Kolikátý den cesty je.";
|
||||
"complication.noTrip" = "Žádná cesta";
|
||||
"a11y.done" = "Hotovo";
|
||||
"a11y.open" = "Nehotovo";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Heute";
|
||||
"today.next" = "Als Nächstes";
|
||||
"today.now" = "Jetzt";
|
||||
"today.later" = "Später";
|
||||
"today.done" = "Tag geschafft";
|
||||
"today.doneBody" = "Für heute ist nichts mehr geplant.";
|
||||
"today.free" = "Freier Tag";
|
||||
"today.freeBody" = "Heute ist nichts geplant. Genieß es.";
|
||||
"today.plan" = "Tagesplan";
|
||||
"day.of" = "Tag %1$d von %2$d";
|
||||
"day.empty" = "Nichts geplant";
|
||||
"time.now" = "jetzt";
|
||||
"time.today" = "Heute";
|
||||
"time.tomorrow" = "Morgen";
|
||||
"countdown.title" = "Nächste Reise";
|
||||
"booking.next" = "Nächste Buchung";
|
||||
"booking.all" = "Buchungen";
|
||||
"booking.none" = "Keine anstehenden Buchungen";
|
||||
"booking.confirmed" = "Bestätigt";
|
||||
"booking.pending" = "Ausstehend";
|
||||
"booking.seat" = "Sitz";
|
||||
"booking.platform" = "Gleis";
|
||||
"booking.ref" = "Buchungsnr.";
|
||||
"booking.checkIn" = "Check-in";
|
||||
"booking.checkOut" = "Check-out";
|
||||
"booking.departs" = "Abfahrt";
|
||||
"booking.arrives" = "Ankunft";
|
||||
"kind.flight" = "Flug";
|
||||
"kind.train" = "Zug";
|
||||
"kind.bus" = "Bus";
|
||||
"kind.car" = "Auto";
|
||||
"kind.cruise" = "Fähre";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restaurant";
|
||||
"kind.event" = "Event";
|
||||
"kind.tour" = "Tour";
|
||||
"kind.other" = "Buchung";
|
||||
"trip.title" = "Reise";
|
||||
"trip.days" = "Tage";
|
||||
"place.title" = "Ort";
|
||||
"place.directions" = "Route";
|
||||
"lists.title" = "Listen";
|
||||
"packing.title" = "Packliste";
|
||||
"packing.packed" = "gepackt";
|
||||
"packing.packedSection" = "Gepackt";
|
||||
"packing.allPacked" = "Alles gepackt";
|
||||
"packing.other" = "Sonstiges";
|
||||
"todos.title" = "To-dos";
|
||||
"todos.open" = "offen";
|
||||
"todos.doneSection" = "Erledigt";
|
||||
"todos.allDone" = "Alles erledigt";
|
||||
"empty.sync.title" = "Öffne TREK auf deinem iPhone";
|
||||
"empty.sync.body" = "Deine Reise erscheint hier, sobald die App auf deinem iPhone synchronisiert hat.";
|
||||
"empty.trip.title" = "Keine Reise geplant";
|
||||
"empty.trip.body" = "Plane deine nächste Reise in TREK, dann erscheint sie hier.";
|
||||
"complication.nextStop.name" = "Nächster Stopp";
|
||||
"complication.nextStop.description" = "Der nächste Stopp des Tages und wann er beginnt.";
|
||||
"complication.countdown.name" = "Countdown";
|
||||
"complication.countdown.description" = "Zeit bis zum nächsten Flug, Zug, Check-in oder bis zur Reise.";
|
||||
"complication.tripDay.name" = "Reisetag";
|
||||
"complication.tripDay.description" = "Welcher Tag der Reise heute ist.";
|
||||
"complication.noTrip" = "Keine Reise";
|
||||
"a11y.done" = "Erledigt";
|
||||
"a11y.open" = "Offen";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Σήμερα";
|
||||
"today.next" = "Επόμενο";
|
||||
"today.now" = "Τώρα";
|
||||
"today.later" = "Αργότερα";
|
||||
"today.done" = "Η μέρα τελείωσε";
|
||||
"today.doneBody" = "Τίποτα άλλο για σήμερα.";
|
||||
"today.free" = "Ελεύθερη μέρα";
|
||||
"today.freeBody" = "Τίποτα προγραμματισμένο σήμερα. Απόλαυσέ το.";
|
||||
"today.plan" = "Πρόγραμμα ημέρας";
|
||||
"day.of" = "Ημέρα %1$d από %2$d";
|
||||
"day.empty" = "Τίποτα προγραμματισμένο";
|
||||
"time.now" = "τώρα";
|
||||
"time.today" = "Σήμερα";
|
||||
"time.tomorrow" = "Αύριο";
|
||||
"countdown.title" = "Επόμενο ταξίδι";
|
||||
"booking.next" = "Επόμενη κράτηση";
|
||||
"booking.all" = "Κρατήσεις";
|
||||
"booking.none" = "Καμία επερχόμενη κράτηση";
|
||||
"booking.confirmed" = "Επιβεβαιωμένη";
|
||||
"booking.pending" = "Εκκρεμεί";
|
||||
"booking.seat" = "Θέση";
|
||||
"booking.platform" = "Αποβάθρα";
|
||||
"booking.ref" = "Κωδ. κράτησης";
|
||||
"booking.checkIn" = "Check-in";
|
||||
"booking.checkOut" = "Check-out";
|
||||
"booking.departs" = "Αναχώρηση";
|
||||
"booking.arrives" = "Άφιξη";
|
||||
"kind.flight" = "Πτήση";
|
||||
"kind.train" = "Τρένο";
|
||||
"kind.bus" = "Λεωφορείο";
|
||||
"kind.car" = "Αυτοκίνητο";
|
||||
"kind.cruise" = "Πλοίο";
|
||||
"kind.hotel" = "Ξενοδοχείο";
|
||||
"kind.restaurant" = "Εστιατόριο";
|
||||
"kind.event" = "Εκδήλωση";
|
||||
"kind.tour" = "Περιήγηση";
|
||||
"kind.other" = "Κράτηση";
|
||||
"trip.title" = "Ταξίδι";
|
||||
"trip.days" = "Ημέρες";
|
||||
"place.title" = "Μέρος";
|
||||
"place.directions" = "Οδηγίες";
|
||||
"lists.title" = "Λίστες";
|
||||
"packing.title" = "Βαλίτσα";
|
||||
"packing.packed" = "στη βαλίτσα";
|
||||
"packing.packedSection" = "Στη βαλίτσα";
|
||||
"packing.allPacked" = "Όλα στη βαλίτσα";
|
||||
"packing.other" = "Άλλα";
|
||||
"todos.title" = "Εκκρεμότητες";
|
||||
"todos.open" = "ανοιχτές";
|
||||
"todos.doneSection" = "Ολοκληρώθηκαν";
|
||||
"todos.allDone" = "Όλα έτοιμα";
|
||||
"empty.sync.title" = "Άνοιξε το TREK στο iPhone";
|
||||
"empty.sync.body" = "Το ταξίδι σου εμφανίζεται εδώ μόλις συγχρονιστεί η εφαρμογή στο iPhone.";
|
||||
"empty.trip.title" = "Κανένα ταξίδι";
|
||||
"empty.trip.body" = "Σχεδίασε το επόμενο ταξίδι στο TREK και θα εμφανιστεί εδώ.";
|
||||
"complication.nextStop.name" = "Επόμενη στάση";
|
||||
"complication.nextStop.description" = "Η επόμενη στάση της ημέρας και πότε ξεκινά.";
|
||||
"complication.countdown.name" = "Αντίστροφη μέτρηση";
|
||||
"complication.countdown.description" = "Χρόνος ως την επόμενη πτήση, το τρένο, το check-in ή το ταξίδι.";
|
||||
"complication.tripDay.name" = "Ημέρα ταξιδιού";
|
||||
"complication.tripDay.description" = "Ποια μέρα του ταξιδιού είναι.";
|
||||
"complication.noTrip" = "Χωρίς ταξίδι";
|
||||
"a11y.done" = "Έγινε";
|
||||
"a11y.open" = "Εκκρεμεί";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Today";
|
||||
"today.next" = "Next";
|
||||
"today.now" = "Now";
|
||||
"today.later" = "Later today";
|
||||
"today.done" = "Day done";
|
||||
"today.doneBody" = "Nothing more planned for today.";
|
||||
"today.free" = "Free day";
|
||||
"today.freeBody" = "Nothing planned today. Enjoy it.";
|
||||
"today.plan" = "Today's plan";
|
||||
"day.of" = "Day %1$d of %2$d";
|
||||
"day.empty" = "Nothing planned";
|
||||
"time.now" = "now";
|
||||
"time.today" = "Today";
|
||||
"time.tomorrow" = "Tomorrow";
|
||||
"countdown.title" = "Next trip";
|
||||
"booking.next" = "Next booking";
|
||||
"booking.all" = "Bookings";
|
||||
"booking.none" = "No bookings ahead";
|
||||
"booking.confirmed" = "Confirmed";
|
||||
"booking.pending" = "Pending";
|
||||
"booking.seat" = "Seat";
|
||||
"booking.platform" = "Platform";
|
||||
"booking.ref" = "Booking ref";
|
||||
"booking.checkIn" = "Check-in";
|
||||
"booking.checkOut" = "Check-out";
|
||||
"booking.departs" = "Departs";
|
||||
"booking.arrives" = "Arrives";
|
||||
"kind.flight" = "Flight";
|
||||
"kind.train" = "Train";
|
||||
"kind.bus" = "Bus";
|
||||
"kind.car" = "Car";
|
||||
"kind.cruise" = "Ferry";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restaurant";
|
||||
"kind.event" = "Event";
|
||||
"kind.tour" = "Tour";
|
||||
"kind.other" = "Booking";
|
||||
"trip.title" = "Trip";
|
||||
"trip.days" = "Days";
|
||||
"place.title" = "Place";
|
||||
"place.directions" = "Directions";
|
||||
"lists.title" = "Lists";
|
||||
"packing.title" = "Packing";
|
||||
"packing.packed" = "packed";
|
||||
"packing.packedSection" = "Packed";
|
||||
"packing.allPacked" = "Everything packed";
|
||||
"packing.other" = "Other";
|
||||
"todos.title" = "To-dos";
|
||||
"todos.open" = "open";
|
||||
"todos.doneSection" = "Done";
|
||||
"todos.allDone" = "All done";
|
||||
"empty.sync.title" = "Open TREK on your iPhone";
|
||||
"empty.sync.body" = "Your trip shows up here once the app on your iPhone has synced.";
|
||||
"empty.trip.title" = "No trip planned";
|
||||
"empty.trip.body" = "Plan your next trip in TREK and it shows up here.";
|
||||
"complication.nextStop.name" = "Next stop";
|
||||
"complication.nextStop.description" = "The next stop of the day and when it starts.";
|
||||
"complication.countdown.name" = "Countdown";
|
||||
"complication.countdown.description" = "Time until your next flight, train, check-in or trip.";
|
||||
"complication.tripDay.name" = "Trip day";
|
||||
"complication.tripDay.description" = "Which day of the trip it is.";
|
||||
"complication.noTrip" = "No trip";
|
||||
"a11y.done" = "Done";
|
||||
"a11y.open" = "Not done";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Hoy";
|
||||
"today.next" = "Siguiente";
|
||||
"today.now" = "Ahora";
|
||||
"today.later" = "Más tarde";
|
||||
"today.done" = "Día completado";
|
||||
"today.doneBody" = "No hay nada más previsto para hoy.";
|
||||
"today.free" = "Día libre";
|
||||
"today.freeBody" = "Hoy no hay nada previsto. Disfrútalo.";
|
||||
"today.plan" = "Plan de hoy";
|
||||
"day.of" = "Día %1$d de %2$d";
|
||||
"day.empty" = "Nada previsto";
|
||||
"time.now" = "ahora";
|
||||
"time.today" = "Hoy";
|
||||
"time.tomorrow" = "Mañana";
|
||||
"countdown.title" = "Próximo viaje";
|
||||
"booking.next" = "Próxima reserva";
|
||||
"booking.all" = "Reservas";
|
||||
"booking.none" = "No hay reservas próximas";
|
||||
"booking.confirmed" = "Confirmada";
|
||||
"booking.pending" = "Pendiente";
|
||||
"booking.seat" = "Asiento";
|
||||
"booking.platform" = "Andén";
|
||||
"booking.ref" = "Localizador";
|
||||
"booking.checkIn" = "Entrada";
|
||||
"booking.checkOut" = "Salida";
|
||||
"booking.departs" = "Sale";
|
||||
"booking.arrives" = "Llega";
|
||||
"kind.flight" = "Vuelo";
|
||||
"kind.train" = "Tren";
|
||||
"kind.bus" = "Autobús";
|
||||
"kind.car" = "Coche";
|
||||
"kind.cruise" = "Ferri";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restaurante";
|
||||
"kind.event" = "Evento";
|
||||
"kind.tour" = "Tour";
|
||||
"kind.other" = "Reserva";
|
||||
"trip.title" = "Viaje";
|
||||
"trip.days" = "Días";
|
||||
"place.title" = "Lugar";
|
||||
"place.directions" = "Cómo llegar";
|
||||
"lists.title" = "Listas";
|
||||
"packing.title" = "Equipaje";
|
||||
"packing.packed" = "guardado";
|
||||
"packing.packedSection" = "Guardado";
|
||||
"packing.allPacked" = "Todo guardado";
|
||||
"packing.other" = "Otros";
|
||||
"todos.title" = "Tareas";
|
||||
"todos.open" = "pendientes";
|
||||
"todos.doneSection" = "Hechas";
|
||||
"todos.allDone" = "Todo hecho";
|
||||
"empty.sync.title" = "Abre TREK en tu iPhone";
|
||||
"empty.sync.body" = "Tu viaje aparecerá aquí cuando la app de tu iPhone se haya sincronizado.";
|
||||
"empty.trip.title" = "Ningún viaje previsto";
|
||||
"empty.trip.body" = "Planifica tu próximo viaje en TREK y aparecerá aquí.";
|
||||
"complication.nextStop.name" = "Siguiente parada";
|
||||
"complication.nextStop.description" = "La siguiente parada del día y cuándo empieza.";
|
||||
"complication.countdown.name" = "Cuenta atrás";
|
||||
"complication.countdown.description" = "Tiempo hasta tu próximo vuelo, tren, entrada al hotel o viaje.";
|
||||
"complication.tripDay.name" = "Día del viaje";
|
||||
"complication.tripDay.description" = "En qué día del viaje estás.";
|
||||
"complication.noTrip" = "Sin viaje";
|
||||
"a11y.done" = "Hecha";
|
||||
"a11y.open" = "Pendiente";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Täna";
|
||||
"today.next" = "Järgmine";
|
||||
"today.now" = "Praegu";
|
||||
"today.later" = "Hiljem";
|
||||
"today.done" = "Päev läbi";
|
||||
"today.doneBody" = "Tänaseks pole rohkem midagi plaanis.";
|
||||
"today.free" = "Vaba päev";
|
||||
"today.freeBody" = "Täna pole midagi plaanis. Naudi.";
|
||||
"today.plan" = "Tänane plaan";
|
||||
"day.of" = "Päev %1$d/%2$d";
|
||||
"day.empty" = "Midagi pole plaanis";
|
||||
"time.now" = "praegu";
|
||||
"time.today" = "Täna";
|
||||
"time.tomorrow" = "Homme";
|
||||
"countdown.title" = "Järgmine reis";
|
||||
"booking.next" = "Järgmine broneering";
|
||||
"booking.all" = "Broneeringud";
|
||||
"booking.none" = "Eesootavaid broneeringuid pole";
|
||||
"booking.confirmed" = "Kinnitatud";
|
||||
"booking.pending" = "Ootel";
|
||||
"booking.seat" = "Iste";
|
||||
"booking.platform" = "Platvorm";
|
||||
"booking.ref" = "Broneeringu kood";
|
||||
"booking.checkIn" = "Sisseregistreerimine";
|
||||
"booking.checkOut" = "Väljaregistreerimine";
|
||||
"booking.departs" = "Väljub";
|
||||
"booking.arrives" = "Saabub";
|
||||
"kind.flight" = "Lend";
|
||||
"kind.train" = "Rong";
|
||||
"kind.bus" = "Buss";
|
||||
"kind.car" = "Auto";
|
||||
"kind.cruise" = "Praam";
|
||||
"kind.hotel" = "Hotell";
|
||||
"kind.restaurant" = "Restoran";
|
||||
"kind.event" = "Üritus";
|
||||
"kind.tour" = "Ekskursioon";
|
||||
"kind.other" = "Broneering";
|
||||
"trip.title" = "Reis";
|
||||
"trip.days" = "Päevad";
|
||||
"place.title" = "Koht";
|
||||
"place.directions" = "Teejuhised";
|
||||
"lists.title" = "Loendid";
|
||||
"packing.title" = "Pakkimine";
|
||||
"packing.packed" = "pakitud";
|
||||
"packing.packedSection" = "Pakitud";
|
||||
"packing.allPacked" = "Kõik pakitud";
|
||||
"packing.other" = "Muu";
|
||||
"todos.title" = "Ülesanded";
|
||||
"todos.open" = "avatud";
|
||||
"todos.doneSection" = "Tehtud";
|
||||
"todos.allDone" = "Kõik tehtud";
|
||||
"empty.sync.title" = "Ava TREK oma iPhone'is";
|
||||
"empty.sync.body" = "Sinu reis ilmub siia, kui iPhone'i rakendus on sünkroonitud.";
|
||||
"empty.trip.title" = "Reisi pole plaanitud";
|
||||
"empty.trip.body" = "Planeeri järgmine reis TREKis ja see ilmub siia.";
|
||||
"complication.nextStop.name" = "Järgmine peatus";
|
||||
"complication.nextStop.description" = "Päeva järgmine peatus ja selle algus.";
|
||||
"complication.countdown.name" = "Pöördloendus";
|
||||
"complication.countdown.description" = "Aeg järgmise lennu, rongi, sisseregistreerimise või reisini.";
|
||||
"complication.tripDay.name" = "Reisipäev";
|
||||
"complication.tripDay.description" = "Mitmes reisipäev on.";
|
||||
"complication.noTrip" = "Reisi pole";
|
||||
"a11y.done" = "Tehtud";
|
||||
"a11y.open" = "Tegemata";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Aujourd'hui";
|
||||
"today.next" = "Ensuite";
|
||||
"today.now" = "En cours";
|
||||
"today.later" = "Plus tard";
|
||||
"today.done" = "Journée terminée";
|
||||
"today.doneBody" = "Plus rien de prévu aujourd'hui.";
|
||||
"today.free" = "Journée libre";
|
||||
"today.freeBody" = "Rien de prévu aujourd'hui. Profitez-en.";
|
||||
"today.plan" = "Programme du jour";
|
||||
"day.of" = "Jour %1$d sur %2$d";
|
||||
"day.empty" = "Rien de prévu";
|
||||
"time.now" = "maintenant";
|
||||
"time.today" = "Aujourd'hui";
|
||||
"time.tomorrow" = "Demain";
|
||||
"countdown.title" = "Prochain voyage";
|
||||
"booking.next" = "Prochaine réservation";
|
||||
"booking.all" = "Réservations";
|
||||
"booking.none" = "Aucune réservation à venir";
|
||||
"booking.confirmed" = "Confirmée";
|
||||
"booking.pending" = "En attente";
|
||||
"booking.seat" = "Siège";
|
||||
"booking.platform" = "Voie";
|
||||
"booking.ref" = "Réf. de réservation";
|
||||
"booking.checkIn" = "Arrivée";
|
||||
"booking.checkOut" = "Départ";
|
||||
"booking.departs" = "Départ";
|
||||
"booking.arrives" = "Arrivée";
|
||||
"kind.flight" = "Vol";
|
||||
"kind.train" = "Train";
|
||||
"kind.bus" = "Bus";
|
||||
"kind.car" = "Voiture";
|
||||
"kind.cruise" = "Ferry";
|
||||
"kind.hotel" = "Hôtel";
|
||||
"kind.restaurant" = "Restaurant";
|
||||
"kind.event" = "Événement";
|
||||
"kind.tour" = "Visite";
|
||||
"kind.other" = "Réservation";
|
||||
"trip.title" = "Voyage";
|
||||
"trip.days" = "Jours";
|
||||
"place.title" = "Lieu";
|
||||
"place.directions" = "Itinéraire";
|
||||
"lists.title" = "Listes";
|
||||
"packing.title" = "Bagages";
|
||||
"packing.packed" = "emballés";
|
||||
"packing.packedSection" = "Dans la valise";
|
||||
"packing.allPacked" = "Tout est emballé";
|
||||
"packing.other" = "Autre";
|
||||
"todos.title" = "À faire";
|
||||
"todos.open" = "ouvertes";
|
||||
"todos.doneSection" = "Terminées";
|
||||
"todos.allDone" = "Tout est fait";
|
||||
"empty.sync.title" = "Ouvrez TREK sur votre iPhone";
|
||||
"empty.sync.body" = "Votre voyage apparaît ici dès que l'app de votre iPhone est synchronisée.";
|
||||
"empty.trip.title" = "Aucun voyage prévu";
|
||||
"empty.trip.body" = "Planifiez votre prochain voyage dans TREK et il apparaîtra ici.";
|
||||
"complication.nextStop.name" = "Prochaine étape";
|
||||
"complication.nextStop.description" = "La prochaine étape du jour et son heure de début.";
|
||||
"complication.countdown.name" = "Compte à rebours";
|
||||
"complication.countdown.description" = "Le temps restant avant votre prochain vol, train, arrivée à l'hôtel ou voyage.";
|
||||
"complication.tripDay.name" = "Jour du voyage";
|
||||
"complication.tripDay.description" = "Le jour du voyage où vous en êtes.";
|
||||
"complication.noTrip" = "Aucun voyage";
|
||||
"a11y.done" = "Fait";
|
||||
"a11y.open" = "À faire";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "היום";
|
||||
"today.next" = "הבא";
|
||||
"today.now" = "עכשיו";
|
||||
"today.later" = "בהמשך היום";
|
||||
"today.done" = "היום הסתיים";
|
||||
"today.doneBody" = "אין עוד תוכניות להיום.";
|
||||
"today.free" = "יום חופשי";
|
||||
"today.freeBody" = "אין תוכניות להיום. תיהנו.";
|
||||
"today.plan" = "התוכנית להיום";
|
||||
"day.of" = "יום %1$d מתוך %2$d";
|
||||
"day.empty" = "אין תוכניות";
|
||||
"time.now" = "עכשיו";
|
||||
"time.today" = "היום";
|
||||
"time.tomorrow" = "מחר";
|
||||
"countdown.title" = "הטיול הבא";
|
||||
"booking.next" = "ההזמנה הבאה";
|
||||
"booking.all" = "הזמנות";
|
||||
"booking.none" = "אין הזמנות קרובות";
|
||||
"booking.confirmed" = "מאושרת";
|
||||
"booking.pending" = "ממתינה";
|
||||
"booking.seat" = "מושב";
|
||||
"booking.platform" = "רציף";
|
||||
"booking.ref" = "מספר הזמנה";
|
||||
"booking.checkIn" = "צ'ק-אין";
|
||||
"booking.checkOut" = "צ'ק-אאוט";
|
||||
"booking.departs" = "יציאה";
|
||||
"booking.arrives" = "הגעה";
|
||||
"kind.flight" = "טיסה";
|
||||
"kind.train" = "רכבת";
|
||||
"kind.bus" = "אוטובוס";
|
||||
"kind.car" = "רכב";
|
||||
"kind.cruise" = "מעבורת";
|
||||
"kind.hotel" = "מלון";
|
||||
"kind.restaurant" = "מסעדה";
|
||||
"kind.event" = "אירוע";
|
||||
"kind.tour" = "סיור";
|
||||
"kind.other" = "הזמנה";
|
||||
"trip.title" = "טיול";
|
||||
"trip.days" = "ימים";
|
||||
"place.title" = "מקום";
|
||||
"place.directions" = "הוראות הגעה";
|
||||
"lists.title" = "רשימות";
|
||||
"packing.title" = "אריזה";
|
||||
"packing.packed" = "ארוזים";
|
||||
"packing.packedSection" = "ארוז";
|
||||
"packing.allPacked" = "הכול ארוז";
|
||||
"packing.other" = "אחר";
|
||||
"todos.title" = "משימות";
|
||||
"todos.open" = "פתוחות";
|
||||
"todos.doneSection" = "בוצעו";
|
||||
"todos.allDone" = "הכול בוצע";
|
||||
"empty.sync.title" = "פתחו את TREK ב-iPhone";
|
||||
"empty.sync.body" = "הטיול יופיע כאן אחרי שהאפליקציה ב-iPhone תסתנכרן.";
|
||||
"empty.trip.title" = "אין טיול מתוכנן";
|
||||
"empty.trip.body" = "תכננו את הטיול הבא ב-TREK והוא יופיע כאן.";
|
||||
"complication.nextStop.name" = "העצירה הבאה";
|
||||
"complication.nextStop.description" = "העצירה הבאה של היום ומתי היא מתחילה.";
|
||||
"complication.countdown.name" = "ספירה לאחור";
|
||||
"complication.countdown.description" = "הזמן עד הטיסה, הרכבת, הצ'ק-אין או הטיול הבאים.";
|
||||
"complication.tripDay.name" = "יום בטיול";
|
||||
"complication.tripDay.description" = "באיזה יום של הטיול אתם.";
|
||||
"complication.noTrip" = "אין טיול";
|
||||
"a11y.done" = "בוצע";
|
||||
"a11y.open" = "לא בוצע";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Ma";
|
||||
"today.next" = "Következő";
|
||||
"today.now" = "Most";
|
||||
"today.later" = "Később";
|
||||
"today.done" = "A nap kész";
|
||||
"today.doneBody" = "Mára nincs több program.";
|
||||
"today.free" = "Szabadnap";
|
||||
"today.freeBody" = "Mára nincs program. Élvezd!";
|
||||
"today.plan" = "Mai program";
|
||||
"day.of" = "%1$d. nap / %2$d";
|
||||
"day.empty" = "Nincs program";
|
||||
"time.now" = "most";
|
||||
"time.today" = "Ma";
|
||||
"time.tomorrow" = "Holnap";
|
||||
"countdown.title" = "Következő utazás";
|
||||
"booking.next" = "Következő foglalás";
|
||||
"booking.all" = "Foglalások";
|
||||
"booking.none" = "Nincs közelgő foglalás";
|
||||
"booking.confirmed" = "Megerősítve";
|
||||
"booking.pending" = "Függőben";
|
||||
"booking.seat" = "Ülés";
|
||||
"booking.platform" = "Vágány";
|
||||
"booking.ref" = "Foglalási szám";
|
||||
"booking.checkIn" = "Bejelentkezés";
|
||||
"booking.checkOut" = "Kijelentkezés";
|
||||
"booking.departs" = "Indulás";
|
||||
"booking.arrives" = "Érkezés";
|
||||
"kind.flight" = "Repülés";
|
||||
"kind.train" = "Vonat";
|
||||
"kind.bus" = "Busz";
|
||||
"kind.car" = "Autó";
|
||||
"kind.cruise" = "Komp";
|
||||
"kind.hotel" = "Szálloda";
|
||||
"kind.restaurant" = "Étterem";
|
||||
"kind.event" = "Esemény";
|
||||
"kind.tour" = "Túra";
|
||||
"kind.other" = "Foglalás";
|
||||
"trip.title" = "Utazás";
|
||||
"trip.days" = "Napok";
|
||||
"place.title" = "Hely";
|
||||
"place.directions" = "Útvonal";
|
||||
"lists.title" = "Listák";
|
||||
"packing.title" = "Csomagolás";
|
||||
"packing.packed" = "bepakolva";
|
||||
"packing.packedSection" = "Bepakolva";
|
||||
"packing.allPacked" = "Minden bepakolva";
|
||||
"packing.other" = "Egyéb";
|
||||
"todos.title" = "Teendők";
|
||||
"todos.open" = "nyitott";
|
||||
"todos.doneSection" = "Kész";
|
||||
"todos.allDone" = "Minden kész";
|
||||
"empty.sync.title" = "Nyisd meg a TREK-et az iPhone-on";
|
||||
"empty.sync.body" = "Az utazásod itt jelenik meg, amint az iPhone-os app szinkronizált.";
|
||||
"empty.trip.title" = "Nincs tervezett utazás";
|
||||
"empty.trip.body" = "Tervezd meg a következő utazást a TREK-ben, és itt megjelenik.";
|
||||
"complication.nextStop.name" = "Következő megálló";
|
||||
"complication.nextStop.description" = "A nap következő megállója és a kezdés ideje.";
|
||||
"complication.countdown.name" = "Visszaszámlálás";
|
||||
"complication.countdown.description" = "Idő a következő repülésig, vonatig, bejelentkezésig vagy utazásig.";
|
||||
"complication.tripDay.name" = "Utazási nap";
|
||||
"complication.tripDay.description" = "Hányadik napja tart az utazás.";
|
||||
"complication.noTrip" = "Nincs utazás";
|
||||
"a11y.done" = "Kész";
|
||||
"a11y.open" = "Nincs kész";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Hari ini";
|
||||
"today.next" = "Berikutnya";
|
||||
"today.now" = "Sekarang";
|
||||
"today.later" = "Nanti";
|
||||
"today.done" = "Hari selesai";
|
||||
"today.doneBody" = "Tidak ada rencana lagi untuk hari ini.";
|
||||
"today.free" = "Hari bebas";
|
||||
"today.freeBody" = "Tidak ada rencana hari ini. Nikmati.";
|
||||
"today.plan" = "Rencana hari ini";
|
||||
"day.of" = "Hari %1$d dari %2$d";
|
||||
"day.empty" = "Tidak ada rencana";
|
||||
"time.now" = "sekarang";
|
||||
"time.today" = "Hari ini";
|
||||
"time.tomorrow" = "Besok";
|
||||
"countdown.title" = "Perjalanan berikutnya";
|
||||
"booking.next" = "Pemesanan berikutnya";
|
||||
"booking.all" = "Pemesanan";
|
||||
"booking.none" = "Tidak ada pemesanan mendatang";
|
||||
"booking.confirmed" = "Terkonfirmasi";
|
||||
"booking.pending" = "Menunggu";
|
||||
"booking.seat" = "Kursi";
|
||||
"booking.platform" = "Peron";
|
||||
"booking.ref" = "Kode pemesanan";
|
||||
"booking.checkIn" = "Check-in";
|
||||
"booking.checkOut" = "Check-out";
|
||||
"booking.departs" = "Berangkat";
|
||||
"booking.arrives" = "Tiba";
|
||||
"kind.flight" = "Penerbangan";
|
||||
"kind.train" = "Kereta";
|
||||
"kind.bus" = "Bus";
|
||||
"kind.car" = "Mobil";
|
||||
"kind.cruise" = "Feri";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restoran";
|
||||
"kind.event" = "Acara";
|
||||
"kind.tour" = "Tur";
|
||||
"kind.other" = "Pemesanan";
|
||||
"trip.title" = "Perjalanan";
|
||||
"trip.days" = "Hari";
|
||||
"place.title" = "Tempat";
|
||||
"place.directions" = "Petunjuk arah";
|
||||
"lists.title" = "Daftar";
|
||||
"packing.title" = "Bawaan";
|
||||
"packing.packed" = "dikemas";
|
||||
"packing.packedSection" = "Sudah dikemas";
|
||||
"packing.allPacked" = "Semua sudah dikemas";
|
||||
"packing.other" = "Lainnya";
|
||||
"todos.title" = "Tugas";
|
||||
"todos.open" = "terbuka";
|
||||
"todos.doneSection" = "Selesai";
|
||||
"todos.allDone" = "Semua selesai";
|
||||
"empty.sync.title" = "Buka TREK di iPhone";
|
||||
"empty.sync.body" = "Perjalananmu muncul di sini setelah app di iPhone tersinkron.";
|
||||
"empty.trip.title" = "Belum ada perjalanan";
|
||||
"empty.trip.body" = "Rencanakan perjalanan berikutnya di TREK dan akan muncul di sini.";
|
||||
"complication.nextStop.name" = "Pemberhentian berikutnya";
|
||||
"complication.nextStop.description" = "Pemberhentian berikutnya hari ini dan kapan dimulai.";
|
||||
"complication.countdown.name" = "Hitung mundur";
|
||||
"complication.countdown.description" = "Waktu menuju penerbangan, kereta, check-in, atau perjalanan berikutnya.";
|
||||
"complication.tripDay.name" = "Hari perjalanan";
|
||||
"complication.tripDay.description" = "Hari ke berapa dari perjalanan.";
|
||||
"complication.noTrip" = "Tidak ada perjalanan";
|
||||
"a11y.done" = "Selesai";
|
||||
"a11y.open" = "Belum selesai";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Oggi";
|
||||
"today.next" = "Prossimo";
|
||||
"today.now" = "Ora";
|
||||
"today.later" = "Più tardi";
|
||||
"today.done" = "Giornata finita";
|
||||
"today.doneBody" = "Per oggi non c'è altro in programma.";
|
||||
"today.free" = "Giorno libero";
|
||||
"today.freeBody" = "Oggi niente in programma. Goditelo.";
|
||||
"today.plan" = "Programma di oggi";
|
||||
"day.of" = "Giorno %1$d di %2$d";
|
||||
"day.empty" = "Niente in programma";
|
||||
"time.now" = "ora";
|
||||
"time.today" = "Oggi";
|
||||
"time.tomorrow" = "Domani";
|
||||
"countdown.title" = "Prossimo viaggio";
|
||||
"booking.next" = "Prossima prenotazione";
|
||||
"booking.all" = "Prenotazioni";
|
||||
"booking.none" = "Nessuna prenotazione in arrivo";
|
||||
"booking.confirmed" = "Confermata";
|
||||
"booking.pending" = "In attesa";
|
||||
"booking.seat" = "Posto";
|
||||
"booking.platform" = "Binario";
|
||||
"booking.ref" = "Codice prenotazione";
|
||||
"booking.checkIn" = "Check-in";
|
||||
"booking.checkOut" = "Check-out";
|
||||
"booking.departs" = "Partenza";
|
||||
"booking.arrives" = "Arrivo";
|
||||
"kind.flight" = "Volo";
|
||||
"kind.train" = "Treno";
|
||||
"kind.bus" = "Autobus";
|
||||
"kind.car" = "Auto";
|
||||
"kind.cruise" = "Traghetto";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Ristorante";
|
||||
"kind.event" = "Evento";
|
||||
"kind.tour" = "Tour";
|
||||
"kind.other" = "Prenotazione";
|
||||
"trip.title" = "Viaggio";
|
||||
"trip.days" = "Giorni";
|
||||
"place.title" = "Luogo";
|
||||
"place.directions" = "Indicazioni";
|
||||
"lists.title" = "Liste";
|
||||
"packing.title" = "Bagagli";
|
||||
"packing.packed" = "in valigia";
|
||||
"packing.packedSection" = "In valigia";
|
||||
"packing.allPacked" = "Tutto in valigia";
|
||||
"packing.other" = "Altro";
|
||||
"todos.title" = "Da fare";
|
||||
"todos.open" = "aperte";
|
||||
"todos.doneSection" = "Fatte";
|
||||
"todos.allDone" = "Tutto fatto";
|
||||
"empty.sync.title" = "Apri TREK sul tuo iPhone";
|
||||
"empty.sync.body" = "Il tuo viaggio apparirà qui appena l'app sul tuo iPhone si sarà sincronizzata.";
|
||||
"empty.trip.title" = "Nessun viaggio in programma";
|
||||
"empty.trip.body" = "Pianifica il tuo prossimo viaggio in TREK e apparirà qui.";
|
||||
"complication.nextStop.name" = "Prossima tappa";
|
||||
"complication.nextStop.description" = "La prossima tappa del giorno e quando inizia.";
|
||||
"complication.countdown.name" = "Conto alla rovescia";
|
||||
"complication.countdown.description" = "Il tempo che manca al prossimo volo, treno, check-in o viaggio.";
|
||||
"complication.tripDay.name" = "Giorno di viaggio";
|
||||
"complication.tripDay.description" = "Che giorno del viaggio è oggi.";
|
||||
"complication.noTrip" = "Nessun viaggio";
|
||||
"a11y.done" = "Fatto";
|
||||
"a11y.open" = "Da fare";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "今日";
|
||||
"today.next" = "次";
|
||||
"today.now" = "進行中";
|
||||
"today.later" = "このあと";
|
||||
"today.done" = "今日はおしまい";
|
||||
"today.doneBody" = "今日の予定はもうありません。";
|
||||
"today.free" = "自由な一日";
|
||||
"today.freeBody" = "今日は予定がありません。のんびりどうぞ。";
|
||||
"today.plan" = "今日の予定";
|
||||
"day.of" = "%2$d日中%1$d日目";
|
||||
"day.empty" = "予定なし";
|
||||
"time.now" = "今";
|
||||
"time.today" = "今日";
|
||||
"time.tomorrow" = "明日";
|
||||
"countdown.title" = "次の旅行";
|
||||
"booking.next" = "次の予約";
|
||||
"booking.all" = "予約";
|
||||
"booking.none" = "今後の予約はありません";
|
||||
"booking.confirmed" = "確定";
|
||||
"booking.pending" = "保留中";
|
||||
"booking.seat" = "座席";
|
||||
"booking.platform" = "番線";
|
||||
"booking.ref" = "予約番号";
|
||||
"booking.checkIn" = "チェックイン";
|
||||
"booking.checkOut" = "チェックアウト";
|
||||
"booking.departs" = "出発";
|
||||
"booking.arrives" = "到着";
|
||||
"kind.flight" = "フライト";
|
||||
"kind.train" = "列車";
|
||||
"kind.bus" = "バス";
|
||||
"kind.car" = "車";
|
||||
"kind.cruise" = "フェリー";
|
||||
"kind.hotel" = "ホテル";
|
||||
"kind.restaurant" = "レストラン";
|
||||
"kind.event" = "イベント";
|
||||
"kind.tour" = "ツアー";
|
||||
"kind.other" = "予約";
|
||||
"trip.title" = "旅行";
|
||||
"trip.days" = "日程";
|
||||
"place.title" = "スポット";
|
||||
"place.directions" = "経路";
|
||||
"lists.title" = "リスト";
|
||||
"packing.title" = "持ち物";
|
||||
"packing.packed" = "準備済み";
|
||||
"packing.packedSection" = "準備済み";
|
||||
"packing.allPacked" = "すべて準備完了";
|
||||
"packing.other" = "その他";
|
||||
"todos.title" = "ToDo";
|
||||
"todos.open" = "未完了";
|
||||
"todos.doneSection" = "完了";
|
||||
"todos.allDone" = "すべて完了";
|
||||
"empty.sync.title" = "iPhoneでTREKを開いてください";
|
||||
"empty.sync.body" = "iPhoneのアプリが同期すると、ここに旅行が表示されます。";
|
||||
"empty.trip.title" = "予定している旅行はありません";
|
||||
"empty.trip.body" = "TREKで次の旅行を計画すると、ここに表示されます。";
|
||||
"complication.nextStop.name" = "次の目的地";
|
||||
"complication.nextStop.description" = "今日の次の目的地と開始時刻。";
|
||||
"complication.countdown.name" = "カウントダウン";
|
||||
"complication.countdown.description" = "次のフライト、列車、チェックイン、旅行までの時間。";
|
||||
"complication.tripDay.name" = "旅行の日";
|
||||
"complication.tripDay.description" = "旅行の何日目か。";
|
||||
"complication.noTrip" = "旅行なし";
|
||||
"a11y.done" = "完了";
|
||||
"a11y.open" = "未完了";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "오늘";
|
||||
"today.next" = "다음";
|
||||
"today.now" = "진행 중";
|
||||
"today.later" = "이후";
|
||||
"today.done" = "오늘 일정 끝";
|
||||
"today.doneBody" = "오늘은 더 이상 일정이 없습니다.";
|
||||
"today.free" = "자유로운 날";
|
||||
"today.freeBody" = "오늘은 일정이 없습니다. 즐기세요.";
|
||||
"today.plan" = "오늘 일정";
|
||||
"day.of" = "%2$d일 중 %1$d일째";
|
||||
"day.empty" = "일정 없음";
|
||||
"time.now" = "지금";
|
||||
"time.today" = "오늘";
|
||||
"time.tomorrow" = "내일";
|
||||
"countdown.title" = "다음 여행";
|
||||
"booking.next" = "다음 예약";
|
||||
"booking.all" = "예약";
|
||||
"booking.none" = "예정된 예약 없음";
|
||||
"booking.confirmed" = "확정";
|
||||
"booking.pending" = "대기 중";
|
||||
"booking.seat" = "좌석";
|
||||
"booking.platform" = "승강장";
|
||||
"booking.ref" = "예약 번호";
|
||||
"booking.checkIn" = "체크인";
|
||||
"booking.checkOut" = "체크아웃";
|
||||
"booking.departs" = "출발";
|
||||
"booking.arrives" = "도착";
|
||||
"kind.flight" = "항공편";
|
||||
"kind.train" = "기차";
|
||||
"kind.bus" = "버스";
|
||||
"kind.car" = "자동차";
|
||||
"kind.cruise" = "페리";
|
||||
"kind.hotel" = "호텔";
|
||||
"kind.restaurant" = "레스토랑";
|
||||
"kind.event" = "이벤트";
|
||||
"kind.tour" = "투어";
|
||||
"kind.other" = "예약";
|
||||
"trip.title" = "여행";
|
||||
"trip.days" = "일정";
|
||||
"place.title" = "장소";
|
||||
"place.directions" = "경로";
|
||||
"lists.title" = "목록";
|
||||
"packing.title" = "짐 싸기";
|
||||
"packing.packed" = "챙김";
|
||||
"packing.packedSection" = "챙긴 짐";
|
||||
"packing.allPacked" = "모두 챙김";
|
||||
"packing.other" = "기타";
|
||||
"todos.title" = "할 일";
|
||||
"todos.open" = "남음";
|
||||
"todos.doneSection" = "완료";
|
||||
"todos.allDone" = "모두 완료";
|
||||
"empty.sync.title" = "iPhone에서 TREK을 여세요";
|
||||
"empty.sync.body" = "iPhone의 앱이 동기화되면 여행이 여기에 표시됩니다.";
|
||||
"empty.trip.title" = "계획된 여행 없음";
|
||||
"empty.trip.body" = "TREK에서 다음 여행을 계획하면 여기에 표시됩니다.";
|
||||
"complication.nextStop.name" = "다음 장소";
|
||||
"complication.nextStop.description" = "오늘의 다음 장소와 시작 시간.";
|
||||
"complication.countdown.name" = "카운트다운";
|
||||
"complication.countdown.description" = "다음 항공편, 기차, 체크인 또는 여행까지 남은 시간.";
|
||||
"complication.tripDay.name" = "여행 일차";
|
||||
"complication.tripDay.description" = "여행 며칠째인지 보여 줍니다.";
|
||||
"complication.noTrip" = "여행 없음";
|
||||
"a11y.done" = "완료";
|
||||
"a11y.open" = "미완료";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Vandaag";
|
||||
"today.next" = "Hierna";
|
||||
"today.now" = "Nu";
|
||||
"today.later" = "Later vandaag";
|
||||
"today.done" = "Dag klaar";
|
||||
"today.doneBody" = "Voor vandaag staat er niets meer gepland.";
|
||||
"today.free" = "Vrije dag";
|
||||
"today.freeBody" = "Vandaag staat er niets gepland. Geniet ervan.";
|
||||
"today.plan" = "Dagplanning";
|
||||
"day.of" = "Dag %1$d van %2$d";
|
||||
"day.empty" = "Niets gepland";
|
||||
"time.now" = "nu";
|
||||
"time.today" = "Vandaag";
|
||||
"time.tomorrow" = "Morgen";
|
||||
"countdown.title" = "Volgende reis";
|
||||
"booking.next" = "Volgende boeking";
|
||||
"booking.all" = "Boekingen";
|
||||
"booking.none" = "Geen komende boekingen";
|
||||
"booking.confirmed" = "Bevestigd";
|
||||
"booking.pending" = "In afwachting";
|
||||
"booking.seat" = "Stoel";
|
||||
"booking.platform" = "Spoor";
|
||||
"booking.ref" = "Boekingsnr.";
|
||||
"booking.checkIn" = "Inchecken";
|
||||
"booking.checkOut" = "Uitchecken";
|
||||
"booking.departs" = "Vertrek";
|
||||
"booking.arrives" = "Aankomst";
|
||||
"kind.flight" = "Vlucht";
|
||||
"kind.train" = "Trein";
|
||||
"kind.bus" = "Bus";
|
||||
"kind.car" = "Auto";
|
||||
"kind.cruise" = "Veerboot";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restaurant";
|
||||
"kind.event" = "Evenement";
|
||||
"kind.tour" = "Tour";
|
||||
"kind.other" = "Boeking";
|
||||
"trip.title" = "Reis";
|
||||
"trip.days" = "Dagen";
|
||||
"place.title" = "Plek";
|
||||
"place.directions" = "Routebeschrijving";
|
||||
"lists.title" = "Lijsten";
|
||||
"packing.title" = "Paklijst";
|
||||
"packing.packed" = "ingepakt";
|
||||
"packing.packedSection" = "Ingepakt";
|
||||
"packing.allPacked" = "Alles ingepakt";
|
||||
"packing.other" = "Overig";
|
||||
"todos.title" = "To-do's";
|
||||
"todos.open" = "open";
|
||||
"todos.doneSection" = "Klaar";
|
||||
"todos.allDone" = "Alles klaar";
|
||||
"empty.sync.title" = "Open TREK op je iPhone";
|
||||
"empty.sync.body" = "Je reis verschijnt hier zodra de app op je iPhone is gesynchroniseerd.";
|
||||
"empty.trip.title" = "Geen reis gepland";
|
||||
"empty.trip.body" = "Plan je volgende reis in TREK en hij verschijnt hier.";
|
||||
"complication.nextStop.name" = "Volgende stop";
|
||||
"complication.nextStop.description" = "De volgende stop van de dag en wanneer die begint.";
|
||||
"complication.countdown.name" = "Aftellen";
|
||||
"complication.countdown.description" = "Tijd tot je volgende vlucht, trein, check-in of reis.";
|
||||
"complication.tripDay.name" = "Reisdag";
|
||||
"complication.tripDay.description" = "Welke dag van de reis het is.";
|
||||
"complication.noTrip" = "Geen reis";
|
||||
"a11y.done" = "Klaar";
|
||||
"a11y.open" = "Open";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Dziś";
|
||||
"today.next" = "Dalej";
|
||||
"today.now" = "Teraz";
|
||||
"today.later" = "Później";
|
||||
"today.done" = "Dzień zakończony";
|
||||
"today.doneBody" = "Na dziś nic więcej nie zaplanowano.";
|
||||
"today.free" = "Wolny dzień";
|
||||
"today.freeBody" = "Na dziś nic nie zaplanowano. Odpocznij.";
|
||||
"today.plan" = "Plan dnia";
|
||||
"day.of" = "Dzień %1$d z %2$d";
|
||||
"day.empty" = "Nic nie zaplanowano";
|
||||
"time.now" = "teraz";
|
||||
"time.today" = "Dziś";
|
||||
"time.tomorrow" = "Jutro";
|
||||
"countdown.title" = "Następna podróż";
|
||||
"booking.next" = "Następna rezerwacja";
|
||||
"booking.all" = "Rezerwacje";
|
||||
"booking.none" = "Brak nadchodzących rezerwacji";
|
||||
"booking.confirmed" = "Potwierdzona";
|
||||
"booking.pending" = "Oczekuje";
|
||||
"booking.seat" = "Miejsce";
|
||||
"booking.platform" = "Peron";
|
||||
"booking.ref" = "Nr rezerwacji";
|
||||
"booking.checkIn" = "Zameldowanie";
|
||||
"booking.checkOut" = "Wymeldowanie";
|
||||
"booking.departs" = "Odjazd";
|
||||
"booking.arrives" = "Przyjazd";
|
||||
"kind.flight" = "Lot";
|
||||
"kind.train" = "Pociąg";
|
||||
"kind.bus" = "Autobus";
|
||||
"kind.car" = "Samochód";
|
||||
"kind.cruise" = "Prom";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restauracja";
|
||||
"kind.event" = "Wydarzenie";
|
||||
"kind.tour" = "Wycieczka";
|
||||
"kind.other" = "Rezerwacja";
|
||||
"trip.title" = "Podróż";
|
||||
"trip.days" = "Dni";
|
||||
"place.title" = "Miejsce";
|
||||
"place.directions" = "Wskazówki dojazdu";
|
||||
"lists.title" = "Listy";
|
||||
"packing.title" = "Pakowanie";
|
||||
"packing.packed" = "spakowane";
|
||||
"packing.packedSection" = "Spakowane";
|
||||
"packing.allPacked" = "Wszystko spakowane";
|
||||
"packing.other" = "Inne";
|
||||
"todos.title" = "Zadania";
|
||||
"todos.open" = "otwarte";
|
||||
"todos.doneSection" = "Gotowe";
|
||||
"todos.allDone" = "Wszystko gotowe";
|
||||
"empty.sync.title" = "Otwórz TREK na iPhonie";
|
||||
"empty.sync.body" = "Twoja podróż pojawi się tutaj, gdy aplikacja na iPhonie się zsynchronizuje.";
|
||||
"empty.trip.title" = "Brak zaplanowanej podróży";
|
||||
"empty.trip.body" = "Zaplanuj następną podróż w TREK, a pojawi się tutaj.";
|
||||
"complication.nextStop.name" = "Następny przystanek";
|
||||
"complication.nextStop.description" = "Następny przystanek dnia i godzina rozpoczęcia.";
|
||||
"complication.countdown.name" = "Odliczanie";
|
||||
"complication.countdown.description" = "Czas do następnego lotu, pociągu, zameldowania lub podróży.";
|
||||
"complication.tripDay.name" = "Dzień podróży";
|
||||
"complication.tripDay.description" = "Który to dzień podróży.";
|
||||
"complication.noTrip" = "Brak podróży";
|
||||
"a11y.done" = "Gotowe";
|
||||
"a11y.open" = "Niegotowe";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Hoje";
|
||||
"today.next" = "A seguir";
|
||||
"today.now" = "Agora";
|
||||
"today.later" = "Mais tarde";
|
||||
"today.done" = "Dia concluído";
|
||||
"today.doneBody" = "Nada mais planejado para hoje.";
|
||||
"today.free" = "Dia livre";
|
||||
"today.freeBody" = "Nada planejado para hoje. Aproveite.";
|
||||
"today.plan" = "Plano de hoje";
|
||||
"day.of" = "Dia %1$d de %2$d";
|
||||
"day.empty" = "Nada planejado";
|
||||
"time.now" = "agora";
|
||||
"time.today" = "Hoje";
|
||||
"time.tomorrow" = "Amanhã";
|
||||
"countdown.title" = "Próxima viagem";
|
||||
"booking.next" = "Próxima reserva";
|
||||
"booking.all" = "Reservas";
|
||||
"booking.none" = "Nenhuma reserva à frente";
|
||||
"booking.confirmed" = "Confirmada";
|
||||
"booking.pending" = "Pendente";
|
||||
"booking.seat" = "Assento";
|
||||
"booking.platform" = "Plataforma";
|
||||
"booking.ref" = "Código da reserva";
|
||||
"booking.checkIn" = "Check-in";
|
||||
"booking.checkOut" = "Check-out";
|
||||
"booking.departs" = "Partida";
|
||||
"booking.arrives" = "Chegada";
|
||||
"kind.flight" = "Voo";
|
||||
"kind.train" = "Trem";
|
||||
"kind.bus" = "Ônibus";
|
||||
"kind.car" = "Carro";
|
||||
"kind.cruise" = "Balsa";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Restaurante";
|
||||
"kind.event" = "Evento";
|
||||
"kind.tour" = "Passeio";
|
||||
"kind.other" = "Reserva";
|
||||
"trip.title" = "Viagem";
|
||||
"trip.days" = "Dias";
|
||||
"place.title" = "Local";
|
||||
"place.directions" = "Como chegar";
|
||||
"lists.title" = "Listas";
|
||||
"packing.title" = "Bagagem";
|
||||
"packing.packed" = "na mala";
|
||||
"packing.packedSection" = "Na mala";
|
||||
"packing.allPacked" = "Tudo na mala";
|
||||
"packing.other" = "Outros";
|
||||
"todos.title" = "Tarefas";
|
||||
"todos.open" = "abertas";
|
||||
"todos.doneSection" = "Feitas";
|
||||
"todos.allDone" = "Tudo feito";
|
||||
"empty.sync.title" = "Abra o TREK no seu iPhone";
|
||||
"empty.sync.body" = "Sua viagem aparece aqui assim que o app no seu iPhone sincronizar.";
|
||||
"empty.trip.title" = "Nenhuma viagem planejada";
|
||||
"empty.trip.body" = "Planeje sua próxima viagem no TREK e ela aparece aqui.";
|
||||
"complication.nextStop.name" = "Próxima parada";
|
||||
"complication.nextStop.description" = "A próxima parada do dia e quando começa.";
|
||||
"complication.countdown.name" = "Contagem regressiva";
|
||||
"complication.countdown.description" = "Tempo até o próximo voo, trem, check-in ou viagem.";
|
||||
"complication.tripDay.name" = "Dia da viagem";
|
||||
"complication.tripDay.description" = "Em que dia da viagem você está.";
|
||||
"complication.noTrip" = "Sem viagem";
|
||||
"a11y.done" = "Feita";
|
||||
"a11y.open" = "Não feita";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Сегодня";
|
||||
"today.next" = "Далее";
|
||||
"today.now" = "Сейчас";
|
||||
"today.later" = "Позже";
|
||||
"today.done" = "День завершён";
|
||||
"today.doneBody" = "На сегодня больше ничего не запланировано.";
|
||||
"today.free" = "Свободный день";
|
||||
"today.freeBody" = "На сегодня ничего не запланировано. Отдыхайте.";
|
||||
"today.plan" = "План на день";
|
||||
"day.of" = "День %1$d из %2$d";
|
||||
"day.empty" = "Ничего не запланировано";
|
||||
"time.now" = "сейчас";
|
||||
"time.today" = "Сегодня";
|
||||
"time.tomorrow" = "Завтра";
|
||||
"countdown.title" = "Следующая поездка";
|
||||
"booking.next" = "Следующая бронь";
|
||||
"booking.all" = "Брони";
|
||||
"booking.none" = "Предстоящих броней нет";
|
||||
"booking.confirmed" = "Подтверждено";
|
||||
"booking.pending" = "Ожидает";
|
||||
"booking.seat" = "Место";
|
||||
"booking.platform" = "Платформа";
|
||||
"booking.ref" = "Номер брони";
|
||||
"booking.checkIn" = "Заезд";
|
||||
"booking.checkOut" = "Выезд";
|
||||
"booking.departs" = "Отправление";
|
||||
"booking.arrives" = "Прибытие";
|
||||
"kind.flight" = "Рейс";
|
||||
"kind.train" = "Поезд";
|
||||
"kind.bus" = "Автобус";
|
||||
"kind.car" = "Авто";
|
||||
"kind.cruise" = "Паром";
|
||||
"kind.hotel" = "Отель";
|
||||
"kind.restaurant" = "Ресторан";
|
||||
"kind.event" = "Событие";
|
||||
"kind.tour" = "Экскурсия";
|
||||
"kind.other" = "Бронь";
|
||||
"trip.title" = "Поездка";
|
||||
"trip.days" = "Дни";
|
||||
"place.title" = "Место";
|
||||
"place.directions" = "Маршрут";
|
||||
"lists.title" = "Списки";
|
||||
"packing.title" = "Сборы";
|
||||
"packing.packed" = "собрано";
|
||||
"packing.packedSection" = "Собрано";
|
||||
"packing.allPacked" = "Всё собрано";
|
||||
"packing.other" = "Другое";
|
||||
"todos.title" = "Задачи";
|
||||
"todos.open" = "открыто";
|
||||
"todos.doneSection" = "Готово";
|
||||
"todos.allDone" = "Всё готово";
|
||||
"empty.sync.title" = "Откройте TREK на iPhone";
|
||||
"empty.sync.body" = "Поездка появится здесь, как только приложение на iPhone синхронизируется.";
|
||||
"empty.trip.title" = "Поездок не запланировано";
|
||||
"empty.trip.body" = "Запланируйте следующую поездку в TREK, и она появится здесь.";
|
||||
"complication.nextStop.name" = "Следующая точка";
|
||||
"complication.nextStop.description" = "Следующая точка дня и время её начала.";
|
||||
"complication.countdown.name" = "Обратный отсчёт";
|
||||
"complication.countdown.description" = "Время до следующего рейса, поезда, заезда или поездки.";
|
||||
"complication.tripDay.name" = "День поездки";
|
||||
"complication.tripDay.description" = "Какой сейчас день поездки.";
|
||||
"complication.noTrip" = "Нет поездки";
|
||||
"a11y.done" = "Готово";
|
||||
"a11y.open" = "Не готово";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Dnes";
|
||||
"today.next" = "Ďalej";
|
||||
"today.now" = "Teraz";
|
||||
"today.later" = "Neskôr";
|
||||
"today.done" = "Deň hotový";
|
||||
"today.doneBody" = "Na dnes už nie je nič naplánované.";
|
||||
"today.free" = "Voľný deň";
|
||||
"today.freeBody" = "Dnes nie je nič naplánované. Užite si to.";
|
||||
"today.plan" = "Dnešný plán";
|
||||
"day.of" = "Deň %1$d z %2$d";
|
||||
"day.empty" = "Nič naplánované";
|
||||
"time.now" = "teraz";
|
||||
"time.today" = "Dnes";
|
||||
"time.tomorrow" = "Zajtra";
|
||||
"countdown.title" = "Ďalšia cesta";
|
||||
"booking.next" = "Ďalšia rezervácia";
|
||||
"booking.all" = "Rezervácie";
|
||||
"booking.none" = "Žiadne nadchádzajúce rezervácie";
|
||||
"booking.confirmed" = "Potvrdené";
|
||||
"booking.pending" = "Čaká";
|
||||
"booking.seat" = "Sedadlo";
|
||||
"booking.platform" = "Nástupište";
|
||||
"booking.ref" = "Číslo rezervácie";
|
||||
"booking.checkIn" = "Príchod";
|
||||
"booking.checkOut" = "Odchod";
|
||||
"booking.departs" = "Odchod";
|
||||
"booking.arrives" = "Príchod";
|
||||
"kind.flight" = "Let";
|
||||
"kind.train" = "Vlak";
|
||||
"kind.bus" = "Autobus";
|
||||
"kind.car" = "Auto";
|
||||
"kind.cruise" = "Trajekt";
|
||||
"kind.hotel" = "Hotel";
|
||||
"kind.restaurant" = "Reštaurácia";
|
||||
"kind.event" = "Podujatie";
|
||||
"kind.tour" = "Výlet";
|
||||
"kind.other" = "Rezervácia";
|
||||
"trip.title" = "Cesta";
|
||||
"trip.days" = "Dni";
|
||||
"place.title" = "Miesto";
|
||||
"place.directions" = "Navigovať";
|
||||
"lists.title" = "Zoznamy";
|
||||
"packing.title" = "Balenie";
|
||||
"packing.packed" = "zbalené";
|
||||
"packing.packedSection" = "Zbalené";
|
||||
"packing.allPacked" = "Všetko zbalené";
|
||||
"packing.other" = "Ostatné";
|
||||
"todos.title" = "Úlohy";
|
||||
"todos.open" = "otvorené";
|
||||
"todos.doneSection" = "Hotové";
|
||||
"todos.allDone" = "Všetko hotové";
|
||||
"empty.sync.title" = "Otvorte TREK v iPhone";
|
||||
"empty.sync.body" = "Vaša cesta sa tu zobrazí, keď sa aplikácia v iPhone zosynchronizuje.";
|
||||
"empty.trip.title" = "Žiadna naplánovaná cesta";
|
||||
"empty.trip.body" = "Naplánujte ďalšiu cestu v TREK a zobrazí sa tu.";
|
||||
"complication.nextStop.name" = "Ďalšia zastávka";
|
||||
"complication.nextStop.description" = "Ďalšia zastávka dňa a kedy začína.";
|
||||
"complication.countdown.name" = "Odpočítavanie";
|
||||
"complication.countdown.description" = "Čas do ďalšieho letu, vlaku, ubytovania alebo cesty.";
|
||||
"complication.tripDay.name" = "Deň cesty";
|
||||
"complication.tripDay.description" = "Koľký deň cesty je.";
|
||||
"complication.noTrip" = "Žiadna cesta";
|
||||
"a11y.done" = "Hotové";
|
||||
"a11y.open" = "Nehotové";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Idag";
|
||||
"today.next" = "Nästa";
|
||||
"today.now" = "Nu";
|
||||
"today.later" = "Senare idag";
|
||||
"today.done" = "Dagen klar";
|
||||
"today.doneBody" = "Inget mer planerat för idag.";
|
||||
"today.free" = "Ledig dag";
|
||||
"today.freeBody" = "Inget planerat idag. Njut av det.";
|
||||
"today.plan" = "Dagens plan";
|
||||
"day.of" = "Dag %1$d av %2$d";
|
||||
"day.empty" = "Inget planerat";
|
||||
"time.now" = "nu";
|
||||
"time.today" = "Idag";
|
||||
"time.tomorrow" = "Imorgon";
|
||||
"countdown.title" = "Nästa resa";
|
||||
"booking.next" = "Nästa bokning";
|
||||
"booking.all" = "Bokningar";
|
||||
"booking.none" = "Inga kommande bokningar";
|
||||
"booking.confirmed" = "Bekräftad";
|
||||
"booking.pending" = "Väntar";
|
||||
"booking.seat" = "Plats";
|
||||
"booking.platform" = "Spår";
|
||||
"booking.ref" = "Bokningsnr";
|
||||
"booking.checkIn" = "Incheckning";
|
||||
"booking.checkOut" = "Utcheckning";
|
||||
"booking.departs" = "Avgår";
|
||||
"booking.arrives" = "Ankommer";
|
||||
"kind.flight" = "Flyg";
|
||||
"kind.train" = "Tåg";
|
||||
"kind.bus" = "Buss";
|
||||
"kind.car" = "Bil";
|
||||
"kind.cruise" = "Färja";
|
||||
"kind.hotel" = "Hotell";
|
||||
"kind.restaurant" = "Restaurang";
|
||||
"kind.event" = "Evenemang";
|
||||
"kind.tour" = "Tur";
|
||||
"kind.other" = "Bokning";
|
||||
"trip.title" = "Resa";
|
||||
"trip.days" = "Dagar";
|
||||
"place.title" = "Plats";
|
||||
"place.directions" = "Vägbeskrivning";
|
||||
"lists.title" = "Listor";
|
||||
"packing.title" = "Packlista";
|
||||
"packing.packed" = "packat";
|
||||
"packing.packedSection" = "Packat";
|
||||
"packing.allPacked" = "Allt packat";
|
||||
"packing.other" = "Övrigt";
|
||||
"todos.title" = "Att göra";
|
||||
"todos.open" = "öppna";
|
||||
"todos.doneSection" = "Klara";
|
||||
"todos.allDone" = "Allt klart";
|
||||
"empty.sync.title" = "Öppna TREK på din iPhone";
|
||||
"empty.sync.body" = "Din resa visas här när appen på din iPhone har synkroniserats.";
|
||||
"empty.trip.title" = "Ingen resa planerad";
|
||||
"empty.trip.body" = "Planera din nästa resa i TREK så visas den här.";
|
||||
"complication.nextStop.name" = "Nästa stopp";
|
||||
"complication.nextStop.description" = "Dagens nästa stopp och när det börjar.";
|
||||
"complication.countdown.name" = "Nedräkning";
|
||||
"complication.countdown.description" = "Tid till nästa flyg, tåg, incheckning eller resa.";
|
||||
"complication.tripDay.name" = "Resdag";
|
||||
"complication.tripDay.description" = "Vilken dag av resan det är.";
|
||||
"complication.noTrip" = "Ingen resa";
|
||||
"a11y.done" = "Klar";
|
||||
"a11y.open" = "Inte klar";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "วันนี้";
|
||||
"today.next" = "ถัดไป";
|
||||
"today.now" = "ตอนนี้";
|
||||
"today.later" = "ภายหลัง";
|
||||
"today.done" = "จบวันแล้ว";
|
||||
"today.doneBody" = "วันนี้ไม่มีแผนอื่นแล้ว";
|
||||
"today.free" = "วันว่าง";
|
||||
"today.freeBody" = "วันนี้ไม่มีแผน พักผ่อนให้สบาย";
|
||||
"today.plan" = "แผนวันนี้";
|
||||
"day.of" = "วันที่ %1$d จาก %2$d";
|
||||
"day.empty" = "ไม่มีแผน";
|
||||
"time.now" = "ตอนนี้";
|
||||
"time.today" = "วันนี้";
|
||||
"time.tomorrow" = "พรุ่งนี้";
|
||||
"countdown.title" = "ทริปถัดไป";
|
||||
"booking.next" = "การจองถัดไป";
|
||||
"booking.all" = "การจอง";
|
||||
"booking.none" = "ไม่มีการจองที่จะถึง";
|
||||
"booking.confirmed" = "ยืนยันแล้ว";
|
||||
"booking.pending" = "รอดำเนินการ";
|
||||
"booking.seat" = "ที่นั่ง";
|
||||
"booking.platform" = "ชานชาลา";
|
||||
"booking.ref" = "รหัสการจอง";
|
||||
"booking.checkIn" = "เช็คอิน";
|
||||
"booking.checkOut" = "เช็คเอาท์";
|
||||
"booking.departs" = "ออกเดินทาง";
|
||||
"booking.arrives" = "ถึง";
|
||||
"kind.flight" = "เที่ยวบิน";
|
||||
"kind.train" = "รถไฟ";
|
||||
"kind.bus" = "รถบัส";
|
||||
"kind.car" = "รถยนต์";
|
||||
"kind.cruise" = "เรือเฟอร์รี่";
|
||||
"kind.hotel" = "โรงแรม";
|
||||
"kind.restaurant" = "ร้านอาหาร";
|
||||
"kind.event" = "อีเวนต์";
|
||||
"kind.tour" = "ทัวร์";
|
||||
"kind.other" = "การจอง";
|
||||
"trip.title" = "ทริป";
|
||||
"trip.days" = "วัน";
|
||||
"place.title" = "สถานที่";
|
||||
"place.directions" = "เส้นทาง";
|
||||
"lists.title" = "รายการ";
|
||||
"packing.title" = "จัดกระเป๋า";
|
||||
"packing.packed" = "จัดแล้ว";
|
||||
"packing.packedSection" = "จัดแล้ว";
|
||||
"packing.allPacked" = "จัดครบแล้ว";
|
||||
"packing.other" = "อื่นๆ";
|
||||
"todos.title" = "สิ่งที่ต้องทำ";
|
||||
"todos.open" = "ค้างอยู่";
|
||||
"todos.doneSection" = "เสร็จแล้ว";
|
||||
"todos.allDone" = "เสร็จหมดแล้ว";
|
||||
"empty.sync.title" = "เปิด TREK บน iPhone";
|
||||
"empty.sync.body" = "ทริปของคุณจะแสดงที่นี่เมื่อแอปบน iPhone ซิงค์แล้ว";
|
||||
"empty.trip.title" = "ยังไม่มีทริป";
|
||||
"empty.trip.body" = "วางแผนทริปถัดไปใน TREK แล้วจะแสดงที่นี่";
|
||||
"complication.nextStop.name" = "จุดแวะถัดไป";
|
||||
"complication.nextStop.description" = "จุดแวะถัดไปของวันและเวลาที่เริ่ม";
|
||||
"complication.countdown.name" = "นับถอยหลัง";
|
||||
"complication.countdown.description" = "เวลาที่เหลือก่อนเที่ยวบิน รถไฟ เช็คอิน หรือทริปถัดไป";
|
||||
"complication.tripDay.name" = "วันของทริป";
|
||||
"complication.tripDay.description" = "วันนี้เป็นวันที่เท่าไรของทริป";
|
||||
"complication.noTrip" = "ไม่มีทริป";
|
||||
"a11y.done" = "เสร็จแล้ว";
|
||||
"a11y.open" = "ยังไม่เสร็จ";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Bugün";
|
||||
"today.next" = "Sıradaki";
|
||||
"today.now" = "Şimdi";
|
||||
"today.later" = "Daha sonra";
|
||||
"today.done" = "Gün tamam";
|
||||
"today.doneBody" = "Bugün için başka plan yok.";
|
||||
"today.free" = "Serbest gün";
|
||||
"today.freeBody" = "Bugün plan yok. Tadını çıkar.";
|
||||
"today.plan" = "Bugünün planı";
|
||||
"day.of" = "Gün %1$d / %2$d";
|
||||
"day.empty" = "Plan yok";
|
||||
"time.now" = "şimdi";
|
||||
"time.today" = "Bugün";
|
||||
"time.tomorrow" = "Yarın";
|
||||
"countdown.title" = "Sonraki seyahat";
|
||||
"booking.next" = "Sonraki rezervasyon";
|
||||
"booking.all" = "Rezervasyonlar";
|
||||
"booking.none" = "Yaklaşan rezervasyon yok";
|
||||
"booking.confirmed" = "Onaylandı";
|
||||
"booking.pending" = "Beklemede";
|
||||
"booking.seat" = "Koltuk";
|
||||
"booking.platform" = "Peron";
|
||||
"booking.ref" = "Rezervasyon no.";
|
||||
"booking.checkIn" = "Giriş";
|
||||
"booking.checkOut" = "Çıkış";
|
||||
"booking.departs" = "Kalkış";
|
||||
"booking.arrives" = "Varış";
|
||||
"kind.flight" = "Uçuş";
|
||||
"kind.train" = "Tren";
|
||||
"kind.bus" = "Otobüs";
|
||||
"kind.car" = "Araba";
|
||||
"kind.cruise" = "Feribot";
|
||||
"kind.hotel" = "Otel";
|
||||
"kind.restaurant" = "Restoran";
|
||||
"kind.event" = "Etkinlik";
|
||||
"kind.tour" = "Tur";
|
||||
"kind.other" = "Rezervasyon";
|
||||
"trip.title" = "Seyahat";
|
||||
"trip.days" = "Günler";
|
||||
"place.title" = "Yer";
|
||||
"place.directions" = "Yol tarifi";
|
||||
"lists.title" = "Listeler";
|
||||
"packing.title" = "Bavul";
|
||||
"packing.packed" = "bavulda";
|
||||
"packing.packedSection" = "Bavulda";
|
||||
"packing.allPacked" = "Her şey bavulda";
|
||||
"packing.other" = "Diğer";
|
||||
"todos.title" = "Yapılacaklar";
|
||||
"todos.open" = "açık";
|
||||
"todos.doneSection" = "Bitti";
|
||||
"todos.allDone" = "Hepsi bitti";
|
||||
"empty.sync.title" = "TREK'i iPhone'unda aç";
|
||||
"empty.sync.body" = "Seyahatin, iPhone'undaki uygulama eşitlendiğinde burada görünür.";
|
||||
"empty.trip.title" = "Planlanmış seyahat yok";
|
||||
"empty.trip.body" = "Sonraki seyahatini TREK'te planla, burada görünsün.";
|
||||
"complication.nextStop.name" = "Sonraki durak";
|
||||
"complication.nextStop.description" = "Günün sonraki durağı ve başlama saati.";
|
||||
"complication.countdown.name" = "Geri sayım";
|
||||
"complication.countdown.description" = "Sonraki uçuşa, trene, otele girişe veya seyahate kalan süre.";
|
||||
"complication.tripDay.name" = "Seyahat günü";
|
||||
"complication.tripDay.description" = "Seyahatin kaçıncı günü olduğu.";
|
||||
"complication.noTrip" = "Seyahat yok";
|
||||
"a11y.done" = "Bitti";
|
||||
"a11y.open" = "Bitmedi";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Сьогодні";
|
||||
"today.next" = "Далі";
|
||||
"today.now" = "Зараз";
|
||||
"today.later" = "Пізніше";
|
||||
"today.done" = "День завершено";
|
||||
"today.doneBody" = "На сьогодні більше нічого не заплановано.";
|
||||
"today.free" = "Вільний день";
|
||||
"today.freeBody" = "На сьогодні нічого не заплановано. Відпочивайте.";
|
||||
"today.plan" = "План на день";
|
||||
"day.of" = "День %1$d з %2$d";
|
||||
"day.empty" = "Нічого не заплановано";
|
||||
"time.now" = "зараз";
|
||||
"time.today" = "Сьогодні";
|
||||
"time.tomorrow" = "Завтра";
|
||||
"countdown.title" = "Наступна подорож";
|
||||
"booking.next" = "Наступне бронювання";
|
||||
"booking.all" = "Бронювання";
|
||||
"booking.none" = "Немає майбутніх бронювань";
|
||||
"booking.confirmed" = "Підтверджено";
|
||||
"booking.pending" = "Очікує";
|
||||
"booking.seat" = "Місце";
|
||||
"booking.platform" = "Платформа";
|
||||
"booking.ref" = "Номер бронювання";
|
||||
"booking.checkIn" = "Заїзд";
|
||||
"booking.checkOut" = "Виїзд";
|
||||
"booking.departs" = "Відправлення";
|
||||
"booking.arrives" = "Прибуття";
|
||||
"kind.flight" = "Рейс";
|
||||
"kind.train" = "Потяг";
|
||||
"kind.bus" = "Автобус";
|
||||
"kind.car" = "Авто";
|
||||
"kind.cruise" = "Пором";
|
||||
"kind.hotel" = "Готель";
|
||||
"kind.restaurant" = "Ресторан";
|
||||
"kind.event" = "Подія";
|
||||
"kind.tour" = "Екскурсія";
|
||||
"kind.other" = "Бронювання";
|
||||
"trip.title" = "Подорож";
|
||||
"trip.days" = "Дні";
|
||||
"place.title" = "Місце";
|
||||
"place.directions" = "Маршрут";
|
||||
"lists.title" = "Списки";
|
||||
"packing.title" = "Пакування";
|
||||
"packing.packed" = "спаковано";
|
||||
"packing.packedSection" = "Спаковано";
|
||||
"packing.allPacked" = "Усе спаковано";
|
||||
"packing.other" = "Інше";
|
||||
"todos.title" = "Завдання";
|
||||
"todos.open" = "відкрито";
|
||||
"todos.doneSection" = "Виконано";
|
||||
"todos.allDone" = "Усе виконано";
|
||||
"empty.sync.title" = "Відкрийте TREK на iPhone";
|
||||
"empty.sync.body" = "Подорож з’явиться тут, щойно застосунок на iPhone синхронізується.";
|
||||
"empty.trip.title" = "Подорожей не заплановано";
|
||||
"empty.trip.body" = "Сплануйте наступну подорож у TREK, і вона з’явиться тут.";
|
||||
"complication.nextStop.name" = "Наступна зупинка";
|
||||
"complication.nextStop.description" = "Наступна зупинка дня і час її початку.";
|
||||
"complication.countdown.name" = "Зворотний відлік";
|
||||
"complication.countdown.description" = "Час до наступного рейсу, потяга, заїзду чи подорожі.";
|
||||
"complication.tripDay.name" = "День подорожі";
|
||||
"complication.tripDay.description" = "Який зараз день подорожі.";
|
||||
"complication.noTrip" = "Немає подорожі";
|
||||
"a11y.done" = "Виконано";
|
||||
"a11y.open" = "Не виконано";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "Hôm nay";
|
||||
"today.next" = "Tiếp theo";
|
||||
"today.now" = "Đang diễn ra";
|
||||
"today.later" = "Lát nữa";
|
||||
"today.done" = "Xong ngày";
|
||||
"today.doneBody" = "Hôm nay không còn gì trong kế hoạch.";
|
||||
"today.free" = "Ngày tự do";
|
||||
"today.freeBody" = "Hôm nay không có kế hoạch. Hãy tận hưởng.";
|
||||
"today.plan" = "Kế hoạch hôm nay";
|
||||
"day.of" = "Ngày %1$d/%2$d";
|
||||
"day.empty" = "Chưa có kế hoạch";
|
||||
"time.now" = "bây giờ";
|
||||
"time.today" = "Hôm nay";
|
||||
"time.tomorrow" = "Ngày mai";
|
||||
"countdown.title" = "Chuyến đi tới";
|
||||
"booking.next" = "Đặt chỗ tiếp theo";
|
||||
"booking.all" = "Đặt chỗ";
|
||||
"booking.none" = "Không có đặt chỗ sắp tới";
|
||||
"booking.confirmed" = "Đã xác nhận";
|
||||
"booking.pending" = "Đang chờ";
|
||||
"booking.seat" = "Ghế";
|
||||
"booking.platform" = "Sân ga";
|
||||
"booking.ref" = "Mã đặt chỗ";
|
||||
"booking.checkIn" = "Nhận phòng";
|
||||
"booking.checkOut" = "Trả phòng";
|
||||
"booking.departs" = "Khởi hành";
|
||||
"booking.arrives" = "Đến nơi";
|
||||
"kind.flight" = "Chuyến bay";
|
||||
"kind.train" = "Tàu hỏa";
|
||||
"kind.bus" = "Xe buýt";
|
||||
"kind.car" = "Ô tô";
|
||||
"kind.cruise" = "Phà";
|
||||
"kind.hotel" = "Khách sạn";
|
||||
"kind.restaurant" = "Nhà hàng";
|
||||
"kind.event" = "Sự kiện";
|
||||
"kind.tour" = "Tour";
|
||||
"kind.other" = "Đặt chỗ";
|
||||
"trip.title" = "Chuyến đi";
|
||||
"trip.days" = "Ngày";
|
||||
"place.title" = "Địa điểm";
|
||||
"place.directions" = "Chỉ đường";
|
||||
"lists.title" = "Danh sách";
|
||||
"packing.title" = "Hành lý";
|
||||
"packing.packed" = "đã xếp";
|
||||
"packing.packedSection" = "Đã xếp";
|
||||
"packing.allPacked" = "Đã xếp hết";
|
||||
"packing.other" = "Khác";
|
||||
"todos.title" = "Việc cần làm";
|
||||
"todos.open" = "còn lại";
|
||||
"todos.doneSection" = "Đã xong";
|
||||
"todos.allDone" = "Xong hết";
|
||||
"empty.sync.title" = "Mở TREK trên iPhone";
|
||||
"empty.sync.body" = "Chuyến đi sẽ hiện ở đây khi ứng dụng trên iPhone đồng bộ xong.";
|
||||
"empty.trip.title" = "Chưa có chuyến đi";
|
||||
"empty.trip.body" = "Lên kế hoạch chuyến đi tiếp theo trong TREK, nó sẽ hiện ở đây.";
|
||||
"complication.nextStop.name" = "Điểm dừng tiếp theo";
|
||||
"complication.nextStop.description" = "Điểm dừng tiếp theo trong ngày và giờ bắt đầu.";
|
||||
"complication.countdown.name" = "Đếm ngược";
|
||||
"complication.countdown.description" = "Thời gian đến chuyến bay, tàu, nhận phòng hoặc chuyến đi tiếp theo.";
|
||||
"complication.tripDay.name" = "Ngày của chuyến đi";
|
||||
"complication.tripDay.description" = "Hôm nay là ngày thứ mấy của chuyến đi.";
|
||||
"complication.noTrip" = "Không có chuyến đi";
|
||||
"a11y.done" = "Đã xong";
|
||||
"a11y.open" = "Chưa xong";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "今天";
|
||||
"today.next" = "下一站";
|
||||
"today.now" = "进行中";
|
||||
"today.later" = "稍后";
|
||||
"today.done" = "今天结束了";
|
||||
"today.doneBody" = "今天没有其他安排了。";
|
||||
"today.free" = "空闲日";
|
||||
"today.freeBody" = "今天没有安排,好好享受。";
|
||||
"today.plan" = "今日行程";
|
||||
"day.of" = "第 %1$d 天,共 %2$d 天";
|
||||
"day.empty" = "没有安排";
|
||||
"time.now" = "现在";
|
||||
"time.today" = "今天";
|
||||
"time.tomorrow" = "明天";
|
||||
"countdown.title" = "下次旅行";
|
||||
"booking.next" = "下一个预订";
|
||||
"booking.all" = "预订";
|
||||
"booking.none" = "没有即将到来的预订";
|
||||
"booking.confirmed" = "已确认";
|
||||
"booking.pending" = "待确认";
|
||||
"booking.seat" = "座位";
|
||||
"booking.platform" = "站台";
|
||||
"booking.ref" = "预订号";
|
||||
"booking.checkIn" = "入住";
|
||||
"booking.checkOut" = "退房";
|
||||
"booking.departs" = "出发";
|
||||
"booking.arrives" = "到达";
|
||||
"kind.flight" = "航班";
|
||||
"kind.train" = "火车";
|
||||
"kind.bus" = "巴士";
|
||||
"kind.car" = "汽车";
|
||||
"kind.cruise" = "渡轮";
|
||||
"kind.hotel" = "酒店";
|
||||
"kind.restaurant" = "餐厅";
|
||||
"kind.event" = "活动";
|
||||
"kind.tour" = "游览";
|
||||
"kind.other" = "预订";
|
||||
"trip.title" = "旅行";
|
||||
"trip.days" = "日程";
|
||||
"place.title" = "地点";
|
||||
"place.directions" = "路线";
|
||||
"lists.title" = "清单";
|
||||
"packing.title" = "行李";
|
||||
"packing.packed" = "已装好";
|
||||
"packing.packedSection" = "已装好";
|
||||
"packing.allPacked" = "全部装好了";
|
||||
"packing.other" = "其他";
|
||||
"todos.title" = "待办";
|
||||
"todos.open" = "未完成";
|
||||
"todos.doneSection" = "已完成";
|
||||
"todos.allDone" = "全部完成";
|
||||
"empty.sync.title" = "在 iPhone 上打开 TREK";
|
||||
"empty.sync.body" = "iPhone 上的 App 同步后,你的旅行会显示在这里。";
|
||||
"empty.trip.title" = "没有计划中的旅行";
|
||||
"empty.trip.body" = "在 TREK 中计划下一次旅行,它就会显示在这里。";
|
||||
"complication.nextStop.name" = "下一站";
|
||||
"complication.nextStop.description" = "今天的下一站及开始时间。";
|
||||
"complication.countdown.name" = "倒计时";
|
||||
"complication.countdown.description" = "距下一个航班、火车、入住或旅行的时间。";
|
||||
"complication.tripDay.name" = "旅行第几天";
|
||||
"complication.tripDay.description" = "今天是旅行的第几天。";
|
||||
"complication.noTrip" = "没有旅行";
|
||||
"a11y.done" = "已完成";
|
||||
"a11y.open" = "未完成";
|
||||
@@ -0,0 +1,65 @@
|
||||
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
|
||||
"today.title" = "今天";
|
||||
"today.next" = "下一站";
|
||||
"today.now" = "進行中";
|
||||
"today.later" = "稍後";
|
||||
"today.done" = "今天結束了";
|
||||
"today.doneBody" = "今天沒有其他安排了。";
|
||||
"today.free" = "空閒日";
|
||||
"today.freeBody" = "今天沒有安排,好好享受。";
|
||||
"today.plan" = "今日行程";
|
||||
"day.of" = "第 %1$d 天,共 %2$d 天";
|
||||
"day.empty" = "沒有安排";
|
||||
"time.now" = "現在";
|
||||
"time.today" = "今天";
|
||||
"time.tomorrow" = "明天";
|
||||
"countdown.title" = "下次旅行";
|
||||
"booking.next" = "下一個預訂";
|
||||
"booking.all" = "預訂";
|
||||
"booking.none" = "沒有即將到來的預訂";
|
||||
"booking.confirmed" = "已確認";
|
||||
"booking.pending" = "待確認";
|
||||
"booking.seat" = "座位";
|
||||
"booking.platform" = "月台";
|
||||
"booking.ref" = "訂位代號";
|
||||
"booking.checkIn" = "入住";
|
||||
"booking.checkOut" = "退房";
|
||||
"booking.departs" = "出發";
|
||||
"booking.arrives" = "抵達";
|
||||
"kind.flight" = "航班";
|
||||
"kind.train" = "火車";
|
||||
"kind.bus" = "巴士";
|
||||
"kind.car" = "汽車";
|
||||
"kind.cruise" = "渡輪";
|
||||
"kind.hotel" = "飯店";
|
||||
"kind.restaurant" = "餐廳";
|
||||
"kind.event" = "活動";
|
||||
"kind.tour" = "導覽";
|
||||
"kind.other" = "預訂";
|
||||
"trip.title" = "旅行";
|
||||
"trip.days" = "行程天數";
|
||||
"place.title" = "地點";
|
||||
"place.directions" = "路線";
|
||||
"lists.title" = "清單";
|
||||
"packing.title" = "行李";
|
||||
"packing.packed" = "已裝好";
|
||||
"packing.packedSection" = "已裝好";
|
||||
"packing.allPacked" = "全部裝好了";
|
||||
"packing.other" = "其他";
|
||||
"todos.title" = "待辦";
|
||||
"todos.open" = "未完成";
|
||||
"todos.doneSection" = "已完成";
|
||||
"todos.allDone" = "全部完成";
|
||||
"empty.sync.title" = "在 iPhone 上打開 TREK";
|
||||
"empty.sync.body" = "iPhone 上的 App 同步後,你的旅行會顯示在這裡。";
|
||||
"empty.trip.title" = "沒有計畫中的旅行";
|
||||
"empty.trip.body" = "在 TREK 中計畫下一次旅行,它就會顯示在這裡。";
|
||||
"complication.nextStop.name" = "下一站";
|
||||
"complication.nextStop.description" = "今天的下一站及開始時間。";
|
||||
"complication.countdown.name" = "倒數計時";
|
||||
"complication.countdown.description" = "距下一個航班、火車、入住或旅行的時間。";
|
||||
"complication.tripDay.name" = "旅行第幾天";
|
||||
"complication.tripDay.description" = "今天是旅行的第幾天。";
|
||||
"complication.noTrip" = "沒有旅行";
|
||||
"a11y.done" = "已完成";
|
||||
"a11y.open" = "未完成";
|
||||
@@ -0,0 +1,356 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// The sizes the complications draw for. Their own enum rather than
|
||||
/// WidgetKit's, so the watch app's screenshot mode can lay them out too.
|
||||
enum ComplicationFamily: String, CaseIterable {
|
||||
case circular, rectangular, corner, inline
|
||||
|
||||
var widgetFamily: WidgetFamily {
|
||||
switch self {
|
||||
case .circular: return .accessoryCircular
|
||||
case .rectangular: return .accessoryRectangular
|
||||
case .corner: return .accessoryCorner
|
||||
case .inline: return .accessoryInline
|
||||
}
|
||||
}
|
||||
|
||||
init?(_ family: WidgetFamily) {
|
||||
switch family {
|
||||
case .accessoryCircular: self = .circular
|
||||
case .accessoryRectangular: self = .rectangular
|
||||
case .accessoryCorner: self = .corner
|
||||
case .accessoryInline: self = .inline
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which complication an idle face belongs to: each says something of its own.
|
||||
enum ComplicationKind { case nextStop, countdown, tripDay }
|
||||
|
||||
/// What the complications read from a snapshot at a moment.
|
||||
struct ComplicationState {
|
||||
struct NextStop {
|
||||
var stop: WatchSnapshot.Stop
|
||||
var start: Date?
|
||||
var isNow: Bool
|
||||
}
|
||||
|
||||
struct Countdown {
|
||||
var title: String
|
||||
var detail: String?
|
||||
var symbol: String
|
||||
var color: Color
|
||||
var target: Date
|
||||
}
|
||||
|
||||
var tripTitle: String?
|
||||
var phase: WatchSnapshot.Phase
|
||||
var next: NextStop?
|
||||
var countdown: Countdown?
|
||||
var hour12: Bool
|
||||
|
||||
init(_ snapshot: WatchSnapshot?, now: Date) {
|
||||
tripTitle = snapshot?.trip?.title
|
||||
phase = snapshot?.phase(at: now) ?? .none
|
||||
hour12 = snapshot?.hour12 ?? false
|
||||
let minutes = TrekDate.minutesOfDay(now)
|
||||
if let day = snapshot?.today(at: now) {
|
||||
let current = day.stops.first { ($0.minutes ?? .max) <= minutes && minutes < ($0.endMinutes ?? -1) }
|
||||
let upcoming = day.stops.first { ($0.minutes ?? -1) >= minutes }
|
||||
// A day planned without times still has its places: the first of them, not "day done".
|
||||
let untimed = day.stops.first { $0.minutes == nil }
|
||||
if let stop = current ?? upcoming ?? untimed {
|
||||
next = NextStop(stop: stop, start: stop.minutes.map { TrekDate.at($0, on: now) }, isNow: current != nil)
|
||||
}
|
||||
}
|
||||
// Only bookings with a time: one that lasts the whole day has no moment to count down to.
|
||||
if let booking = snapshot?.bookings.first(where: { !$0.isAllDay && ($0.start ?? .distantPast) > now }), let start = booking.start {
|
||||
let look = Trek.booking(booking.kind)
|
||||
countdown = Countdown(title: booking.route ?? booking.title, detail: booking.route == nil ? nil : booking.carrier,
|
||||
symbol: look.symbol, color: look.color, target: start)
|
||||
} else if let trip = snapshot?.trip, case .upcoming = phase, let start = TrekDate.day(trip.start) {
|
||||
countdown = Countdown(title: trip.title, detail: trip.place, symbol: "suitcase.rolling.fill", color: Trek.info, target: start)
|
||||
}
|
||||
}
|
||||
|
||||
/// The moments within `window` a complication should redraw at: the start
|
||||
/// and end of every stop on every day of the plan in it, every timed
|
||||
/// booking, and each midnight.
|
||||
static func changes(_ snapshot: WatchSnapshot?, from now: Date, window: TimeInterval = 86_400 * 2) -> [Date] {
|
||||
guard let snapshot else { return [] }
|
||||
let horizon = now.addingTimeInterval(window)
|
||||
var dates: [Date] = []
|
||||
for day in snapshot.days {
|
||||
guard let date = day.dateValue, date <= horizon, date >= Calendar.current.startOfDay(for: now) else { continue }
|
||||
for stop in day.stops {
|
||||
if let start = stop.minutes { dates.append(TrekDate.at(start, on: date)) }
|
||||
if let end = stop.endMinutes { dates.append(TrekDate.at(end, on: date)) }
|
||||
}
|
||||
}
|
||||
dates += snapshot.bookings.filter { !$0.isAllDay }.compactMap(\.start)
|
||||
var midnight = Calendar.current.startOfDay(for: now)
|
||||
while let next = Calendar.current.date(byAdding: .day, value: 1, to: midnight), next <= horizon {
|
||||
dates.append(next)
|
||||
midnight = next
|
||||
}
|
||||
return dates.filter { $0 > now && $0 <= horizon }.sorted()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Next stop
|
||||
|
||||
struct NextStopComplication: View {
|
||||
let state: ComplicationState
|
||||
let family: ComplicationFamily
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
if let next = state.next {
|
||||
let time = next.stop.minutes.map { WatchFormat.time(minutes: $0, hour12: state.hour12) }
|
||||
switch family {
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: WatchLook.stopSymbol(next.stop)).widgetAccentable()
|
||||
Text(next.isNow ? L("today.now") : (time ?? L("today.next")))
|
||||
.fontWeight(.semibold)
|
||||
.widgetAccentable()
|
||||
if let start = next.start, !next.isNow, start > now {
|
||||
// Counts down by itself between timeline entries.
|
||||
Text(start, style: .relative).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.font(.system(.footnote, design: .rounded))
|
||||
Text(next.stop.name)
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
if case .running(let day, let total) = state.phase {
|
||||
Text(WatchFormat.dayOf(day, total))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .circular:
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 0) {
|
||||
Image(systemName: WatchLook.stopSymbol(next.stop))
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
Text(time ?? "")
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded).monospacedDigit())
|
||||
.minimumScaleFactor(0.6)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
case .corner:
|
||||
Image(systemName: WatchLook.stopSymbol(next.stop))
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
.widgetLabel { Text([time, next.stop.name].compactMap { $0 }.joined(separator: " ")) }
|
||||
case .inline:
|
||||
Label([time, next.stop.name].compactMap { $0 }.joined(separator: " "), systemImage: WatchLook.stopSymbol(next.stop))
|
||||
}
|
||||
} else {
|
||||
ComplicationIdle(state: state, family: family, kind: .nextStop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Countdown
|
||||
|
||||
struct CountdownComplication: View {
|
||||
let state: ComplicationState
|
||||
let family: ComplicationFamily
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
if let countdown = state.countdown {
|
||||
switch family {
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: countdown.symbol).widgetAccentable()
|
||||
Text(countdown.title).fontWeight(.semibold).lineLimit(1)
|
||||
}
|
||||
.font(.system(.footnote, design: .rounded))
|
||||
Text(countdown.target, style: .relative)
|
||||
.font(.system(.title3, design: .rounded).weight(.bold).monospacedDigit())
|
||||
.widgetAccentable()
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
if let detail = countdown.detail {
|
||||
Text(detail).font(.system(.caption2, design: .rounded)).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .circular:
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 0) {
|
||||
Image(systemName: countdown.symbol).font(.system(size: 14, weight: .semibold)).widgetAccentable()
|
||||
Text(short(countdown.target))
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded).monospacedDigit())
|
||||
.minimumScaleFactor(0.5)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
case .corner:
|
||||
Image(systemName: countdown.symbol)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
.widgetLabel { Text(countdown.target, style: .relative) }
|
||||
case .inline:
|
||||
Label {
|
||||
Text("\(countdown.title) · ") + Text(countdown.target, style: .relative)
|
||||
} icon: {
|
||||
Image(systemName: countdown.symbol)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ComplicationIdle(state: state, family: family, kind: .countdown)
|
||||
}
|
||||
}
|
||||
|
||||
/// The single biggest unit: "2h", "35m", "12d".
|
||||
private func short(_ target: Date) -> String {
|
||||
let formatter = WatchFormat.components()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
formatter.maximumUnitCount = 1
|
||||
let seconds = max(target.timeIntervalSince(now), 60)
|
||||
formatter.allowedUnits = seconds >= 86_400 ? [.day] : seconds >= 3_600 ? [.hour] : [.minute]
|
||||
return formatter.string(from: seconds) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip day
|
||||
|
||||
struct TripDayComplication: View {
|
||||
let state: ComplicationState
|
||||
let family: ComplicationFamily
|
||||
|
||||
var body: some View {
|
||||
switch state.phase {
|
||||
case .running(let day, let total):
|
||||
running(day, total)
|
||||
case .upcoming(let days):
|
||||
upcoming(days)
|
||||
default:
|
||||
ComplicationIdle(state: state, family: family, kind: .tripDay)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func running(_ day: Int, _ total: Int) -> some View {
|
||||
switch family {
|
||||
case .circular:
|
||||
Gauge(value: Double(day), in: 0...Double(max(total, 1))) {
|
||||
Image(systemName: "suitcase.rolling.fill")
|
||||
} currentValueLabel: {
|
||||
Text("\(day)").font(.system(.title3, design: .rounded).weight(.bold))
|
||||
}
|
||||
.gaugeStyle(.accessoryCircularCapacity)
|
||||
.widgetAccentable()
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(WatchFormat.dayOf(day, total))
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.widgetAccentable()
|
||||
Text(state.tripTitle ?? "")
|
||||
.font(.system(.footnote, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
DaySegments(index: day, total: total, color: .white, track: .white.opacity(0.25))
|
||||
.widgetAccentable()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .corner:
|
||||
Text("\(day)")
|
||||
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||
.widgetAccentable()
|
||||
.widgetLabel {
|
||||
Gauge(value: Double(day), in: 0...Double(max(total, 1))) { Text(state.tripTitle ?? "") }
|
||||
}
|
||||
case .inline:
|
||||
Label(WatchFormat.dayOf(day, total), systemImage: "suitcase.rolling.fill")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func upcoming(_ days: Int) -> some View {
|
||||
let unit = WatchFormat.daysUnit(days)
|
||||
switch family {
|
||||
case .circular:
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: -2) {
|
||||
Text("\(days)").font(.system(size: 20, weight: .heavy, design: .rounded)).widgetAccentable()
|
||||
Text(unit).font(.system(size: 9, weight: .semibold, design: .rounded)).lineLimit(1).minimumScaleFactor(0.6)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(L("countdown.title")).font(.system(.caption2, design: .rounded).weight(.semibold)).foregroundStyle(.secondary)
|
||||
Text(state.tripTitle ?? "").font(.system(.headline, design: .rounded)).lineLimit(1)
|
||||
Text("\(days) \(unit)").font(.system(.title3, design: .rounded).weight(.bold)).widgetAccentable()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .corner:
|
||||
Image(systemName: "suitcase.rolling.fill")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
.widgetLabel { Text("\(days) \(unit)") }
|
||||
case .inline:
|
||||
Label("\(state.tripTitle ?? "") · \(days) \(unit)", systemImage: "suitcase.rolling.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing to count down or show: the TREK mark, and a word when there is room.
|
||||
struct ComplicationIdle: View {
|
||||
let state: ComplicationState
|
||||
let family: ComplicationFamily
|
||||
let kind: ComplicationKind
|
||||
|
||||
/// What an idle face says: before the trip how far off it is, while it runs
|
||||
/// "day done" for the next stop and the day of the trip for the rest, and
|
||||
/// "no trip" outside of one.
|
||||
private var text: String {
|
||||
switch state.phase {
|
||||
case .upcoming(let days):
|
||||
return "\(state.tripTitle ?? "") · \(days) \(WatchFormat.daysUnit(days))"
|
||||
case .running(let day, let total):
|
||||
return kind == .nextStop ? L("today.done") : WatchFormat.dayOf(day, total)
|
||||
case .past, .none:
|
||||
return L("complication.noTrip")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .circular:
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
TrekMark().frame(width: 22, height: 22).widgetAccentable()
|
||||
}
|
||||
case .rectangular:
|
||||
HStack(spacing: 8) {
|
||||
TrekMark().frame(width: 26, height: 26).widgetAccentable()
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("TREK").font(.system(.headline, design: .rounded))
|
||||
Text(text).font(.system(.footnote, design: .rounded)).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .corner:
|
||||
TrekMark().frame(width: 20, height: 20).widgetAccentable().widgetLabel { Text(text) }
|
||||
case .inline:
|
||||
Text("TREK · \(text)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import Foundation
|
||||
|
||||
/// A trip to Kyoto, the one the widgets' gallery shows too: what the
|
||||
/// complication gallery draws before the phone sent anything, and what the
|
||||
/// screenshot mode of the watch app runs on (TREKWatch/DemoMode.swift).
|
||||
enum WatchSample {
|
||||
enum Scenario: String {
|
||||
/// Day 3 of 9, early afternoon.
|
||||
case running
|
||||
/// Day 1, the flight out leaves in the afternoon.
|
||||
case travelday
|
||||
/// Twelve days before the trip.
|
||||
case upcoming
|
||||
}
|
||||
|
||||
static func snapshot(_ scenario: Scenario, now: Date = Date()) -> WatchSnapshot {
|
||||
let startOffset: Int
|
||||
switch scenario {
|
||||
case .running: startOffset = -2
|
||||
case .travelday: startOffset = 0
|
||||
case .upcoming: startOffset = 12
|
||||
}
|
||||
let calendar = Calendar.current
|
||||
let iso: (Int) -> String = { TrekDate.iso(calendar.date(byAdding: .day, value: startOffset + $0, to: now) ?? now) }
|
||||
// The flight out leaves in the afternoon, so the screenshot mode can run
|
||||
// every scenario at about one o'clock, the hour the capture sets its clock to.
|
||||
let stops: [[WatchSnapshot.Stop]] = [
|
||||
[
|
||||
stop("LH 716", 15 * 60 + 35, symbol: "airplane", color: nil, booking: "flight", lat: 50.0379, lng: 8.5622, address: "Frankfurt Airport"),
|
||||
],
|
||||
[
|
||||
stop("Shinkansen Nozomi 21", 13 * 60 + 33, symbol: "tram.fill", color: nil, booking: "train", lat: 35.6812, lng: 139.7671, address: "Tokyo Station"),
|
||||
stop("Hotel Kanra Kyoto", 16 * 60 + 30, symbol: "bed.double.fill", color: nil, booking: "hotel", lat: 34.9922, lng: 135.7585, address: "190 Kitamachi, Shimogyo Ward, Kyoto"),
|
||||
],
|
||||
[
|
||||
stop("Fushimi Inari Taisha", 8 * 60 + 30, end: 10 * 60 + 30, symbol: "building.columns.fill", color: "#D6273B", lat: 34.9671, lng: 135.7727, address: "68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto"),
|
||||
stop(t("Nishiki Market"), 11 * 60, end: 12 * 60 + 30, symbol: "basket.fill", color: "#E8A13A", lat: 35.0050, lng: 135.7649, address: "Nishikikoji-dori, Nakagyo Ward, Kyoto"),
|
||||
stop("Kiyomizu-dera", 13 * 60 + 30, end: 15 * 60 + 30, symbol: "leaf.fill", color: "#2FA37A", lat: 34.9949, lng: 135.7850, address: "1-294 Kiyomizu, Higashiyama Ward, Kyoto", note: t("Go up the Sannenzaka steps for the view.")),
|
||||
stop(t("Gion walk"), 16 * 60, symbol: "figure.walk", color: "#4A7DDB", lat: 35.0037, lng: 135.7750, address: "Hanamikoji-dori, Higashiyama Ward, Kyoto"),
|
||||
stop(t("Kaiseki at Kikunoi"), 19 * 60, symbol: "fork.knife", color: nil, booking: "restaurant", lat: 34.9989, lng: 135.7812, address: "459 Shimokawaracho, Higashiyama Ward, Kyoto"),
|
||||
],
|
||||
[
|
||||
stop(t("Arashiyama Bamboo Grove"), 9 * 60, symbol: "tree.fill", color: "#2FA37A", lat: 35.0170, lng: 135.6713, address: "Sagaogurayama Tabuchiyamacho, Kyoto"),
|
||||
stop("Tenryu-ji", 10 * 60 + 30, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0157, lng: 135.6738, address: "68 Sagatenryuji Susukinobabacho, Kyoto"),
|
||||
stop("Kinkaku-ji", 14 * 60, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0394, lng: 135.7292, address: "1 Kinkakujicho, Kita Ward, Kyoto"),
|
||||
],
|
||||
[stop(t("Nara Park"), 10 * 60, symbol: "leaf.fill", color: "#2FA37A", lat: 34.6851, lng: 135.8430, address: "Nara"), stop("Todai-ji", 11 * 60 + 30, symbol: "building.columns.fill", color: "#D6273B", lat: 34.6890, lng: 135.8398, address: "406-1 Zoshicho, Nara")],
|
||||
[stop(t("Philosopher's Path"), 9 * 60 + 30, symbol: "figure.walk", color: "#4A7DDB", lat: 35.0266, lng: 135.7948, address: "Sakyo Ward, Kyoto"), stop("Ginkaku-ji", 11 * 60, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0270, lng: 135.7982, address: "2 Ginkakujicho, Sakyo Ward, Kyoto")],
|
||||
[stop(t("Tea ceremony"), 10 * 60 + 30, symbol: "ticket.fill", color: nil, booking: "event", lat: 35.0036, lng: 135.7788, address: "Camellia Flower, Higashiyama")],
|
||||
[stop(t("Fushimi sake district"), 14 * 60, symbol: "wineglass.fill", color: "#8E6CD9", lat: 34.9318, lng: 135.7601, address: "Fushimi Ward, Kyoto"), stop(t("Pontocho alley"), 18 * 60 + 30, symbol: "fork.knife", color: "#E8A13A", lat: 35.0050, lng: 135.7708, address: "Pontocho, Nakagyo Ward, Kyoto")],
|
||||
[stop(t("Shinkansen to Osaka"), 9 * 60 + 12, symbol: "tram.fill", color: nil, booking: "train", lat: 34.9858, lng: 135.7588, address: "Kyoto Station"), stop("LH 741", 13 * 60 + 25, symbol: "airplane", color: nil, booking: "flight", lat: 34.4320, lng: 135.2304, address: "Kansai International Airport")],
|
||||
]
|
||||
let days = stops.enumerated().map { index, stops in
|
||||
WatchSnapshot.Day(date: iso(index), number: index + 1, title: nil, stops: stops)
|
||||
}
|
||||
let bookings: [WatchSnapshot.Booking] = [
|
||||
WatchSnapshot.Booking(
|
||||
id: 1, title: "LH 716", kind: "flight", confirmed: true, date: iso(0), time: "15:35", endDate: iso(1), endTime: "11:20",
|
||||
zone: nil, endZone: nil, from: .init(name: "Frankfurt", code: "FRA", lat: 50.0379, lng: 8.5622),
|
||||
to: .init(name: "Tokyo Haneda", code: "HND", lat: 35.5494, lng: 139.7798), code: "K7Q2XD",
|
||||
carrier: "Lufthansa LH 716", seat: "42A", platform: nil, location: nil, lat: nil, lng: nil, checkIn: nil, checkOut: nil
|
||||
),
|
||||
WatchSnapshot.Booking(
|
||||
id: 7, title: "Shinkansen Nozomi 21", kind: "train", confirmed: true, date: iso(1), time: "13:33", endDate: iso(1), endTime: "15:48",
|
||||
zone: nil, endZone: nil, from: .init(name: "Tokyo", code: nil, lat: 35.6812, lng: 139.7671), to: .init(name: "Kyoto", code: nil, lat: 34.9858, lng: 135.7588),
|
||||
code: "JR-5521", carrier: "JR Nozomi 21", seat: "7D", platform: "14", location: nil, lat: nil, lng: nil, checkIn: nil, checkOut: nil
|
||||
),
|
||||
WatchSnapshot.Booking(
|
||||
id: 2, title: "Hotel Kanra Kyoto", kind: "hotel", confirmed: true, date: iso(1), time: "16:30", endDate: iso(7), endTime: "11:00",
|
||||
zone: nil, endZone: nil, from: nil, to: nil, code: "HK-208431", carrier: nil, seat: nil, platform: nil,
|
||||
location: "Hotel Kanra Kyoto", lat: 34.9922, lng: 135.7585, checkIn: "15:00", checkOut: "11:00"
|
||||
),
|
||||
WatchSnapshot.Booking(
|
||||
id: 3, title: t("Kaiseki at Kikunoi"), kind: "restaurant", confirmed: false, date: iso(2), time: "19:00", endDate: nil, endTime: nil,
|
||||
zone: nil, endZone: nil, from: nil, to: nil, code: "KK-77", carrier: nil, seat: nil, platform: nil,
|
||||
location: "459 Shimokawaracho, Higashiyama", lat: 34.9989, lng: 135.7812, checkIn: nil, checkOut: nil
|
||||
),
|
||||
WatchSnapshot.Booking(
|
||||
id: 4, title: t("Tea ceremony"), kind: "event", confirmed: true, date: iso(5), time: "10:30", endDate: nil, endTime: nil,
|
||||
zone: nil, endZone: nil, from: nil, to: nil, code: nil, carrier: nil, seat: nil, platform: nil,
|
||||
location: "Camellia Flower", lat: 35.0036, lng: 135.7788, checkIn: nil, checkOut: nil
|
||||
),
|
||||
WatchSnapshot.Booking(
|
||||
id: 5, title: "Shinkansen Hikari 503", kind: "train", confirmed: true, date: iso(8), time: "09:12", endDate: iso(8), endTime: "09:27",
|
||||
zone: nil, endZone: nil, from: .init(name: "Kyoto", code: nil, lat: 34.9858, lng: 135.7588), to: .init(name: "Shin-Osaka", code: nil, lat: 34.7334, lng: 135.5001),
|
||||
code: "E4521", carrier: "JR Hikari 503", seat: "7D", platform: "12", location: nil, lat: nil, lng: nil, checkIn: nil, checkOut: nil
|
||||
),
|
||||
WatchSnapshot.Booking(
|
||||
id: 6, title: "LH 741", kind: "flight", confirmed: true, date: iso(8), time: "13:25", endDate: iso(8), endTime: "19:05",
|
||||
zone: nil, endZone: nil, from: .init(name: "Osaka Kansai", code: "KIX", lat: 34.4320, lng: 135.2304),
|
||||
to: .init(name: "Frankfurt", code: "FRA", lat: 50.0379, lng: 8.5622), code: "K7Q2XD",
|
||||
carrier: "Lufthansa LH 741", seat: "38C", platform: nil, location: nil, lat: nil, lng: nil, checkIn: nil, checkOut: nil
|
||||
),
|
||||
]
|
||||
let packingItems: [(String, String, Bool)] = [
|
||||
(t("Passport"), t("Documents"), true), (t("JR Pass voucher"), t("Documents"), true), (t("Travel insurance"), t("Documents"), true),
|
||||
(t("Rain jacket"), t("Clothes"), false), (t("Walking shoes"), t("Clothes"), true), (t("Warm sweater"), t("Clothes"), false), (t("Socks"), t("Clothes"), true),
|
||||
(t("Camera"), t("Tech"), true), (t("Power bank"), t("Tech"), false), (t("Type A adapter"), t("Tech"), false),
|
||||
(t("Toothbrush"), t("Toiletries"), false), (t("Sunscreen"), t("Toiletries"), true),
|
||||
]
|
||||
var packingList: [WatchSnapshot.Item] = []
|
||||
for (offset, entry) in packingItems.enumerated() {
|
||||
packingList.append(WatchSnapshot.Item(id: 100 + offset, name: entry.0, done: entry.2, group: entry.1))
|
||||
}
|
||||
let packing = WatchSnapshot.Packing(
|
||||
items: packingList.filter { !$0.done } + packingList.filter(\.done),
|
||||
packed: packingList.filter(\.done).count,
|
||||
total: packingList.count
|
||||
)
|
||||
// Due dates in days from now, priority, done.
|
||||
let todoItems: [(String, Int?, Int, Bool)] = [
|
||||
(t("Reserve the Ghibli Museum"), 2, 3, false), (t("Buy a Suica card"), nil, 2, false),
|
||||
(t("Book the tea ceremony"), nil, 1, false), (t("Print the hotel voucher"), nil, 0, false),
|
||||
(t("Order yen"), -1, 0, true), (t("Renew passport"), -20, 0, true),
|
||||
]
|
||||
var todoList: [WatchSnapshot.Item] = []
|
||||
for (offset, entry) in todoItems.enumerated() {
|
||||
let due: String? = entry.1.map { iso($0 - startOffset) }
|
||||
todoList.append(WatchSnapshot.Item(id: 200 + offset, name: entry.0, done: entry.3, group: nil, due: due, priority: entry.2))
|
||||
}
|
||||
let todos = WatchSnapshot.Todos(items: todoList, open: todoItems.filter { !$0.3 }.count, total: todoItems.count)
|
||||
return WatchSnapshot(
|
||||
generatedAt: ISO8601DateFormatter().string(from: now.addingTimeInterval(-4 * 60)),
|
||||
// The clock of the watch's region, as a user's TREK setting would usually match it.
|
||||
hour12: DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: .current)?.contains("a") ?? false,
|
||||
trip: .init(id: 1, title: t("Autumn in Kyoto"), start: iso(0), end: iso(8), place: t("Kyoto, Japan"), cover: nil),
|
||||
days: days,
|
||||
bookings: bookings,
|
||||
weather: .init(temp: 18, high: 21, low: 11, main: scenario == .upcoming ? "Clouds" : "Clear", label: t("Mostly sunny"), place: "Kyoto", rain: 10),
|
||||
packing: packing,
|
||||
todos: todos,
|
||||
applied: []
|
||||
)
|
||||
}
|
||||
|
||||
/// The sample's own texts in German and Russian, so the screenshots in those
|
||||
/// languages show a trip written in them; every other language gets the English.
|
||||
private static let translations: [String: [String: String]] = [
|
||||
"Autumn in Kyoto": ["de": "Herbst in Kyoto", "ru": "Осень в Киото"],
|
||||
"Kyoto, Japan": ["de": "Kyoto, Japan", "ru": "Киото, Япония"],
|
||||
"Nishiki Market": ["de": "Nishiki-Markt", "ru": "Рынок Нисики"],
|
||||
"Gion walk": ["de": "Spaziergang durch Gion", "ru": "Прогулка по Гиону"],
|
||||
"Kaiseki at Kikunoi": ["de": "Kaiseki im Kikunoi", "ru": "Кайсэки в Kikunoi"],
|
||||
"Arashiyama Bamboo Grove": ["de": "Bambuswald Arashiyama", "ru": "Бамбуковая роща Арасиямы"],
|
||||
"Nara Park": ["de": "Nara-Park", "ru": "Парк Нара"],
|
||||
"Philosopher's Path": ["de": "Philosophenweg", "ru": "Тропа философа"],
|
||||
"Tea ceremony": ["de": "Teezeremonie", "ru": "Чайная церемония"],
|
||||
"Fushimi sake district": ["de": "Sake-Viertel Fushimi", "ru": "Саке-квартал Фусими"],
|
||||
"Pontocho alley": ["de": "Gasse Pontocho", "ru": "Улочка Понтотё"],
|
||||
"Shinkansen to Osaka": ["de": "Shinkansen nach Osaka", "ru": "Синкансэн в Осаку"],
|
||||
"Go up the Sannenzaka steps for the view.": ["de": "Für den Ausblick die Sannenzaka-Treppe hinauf.", "ru": "Ради вида поднимитесь по лестнице Саннэндзака."],
|
||||
"Mostly sunny": ["de": "Überwiegend sonnig", "ru": "Преимущественно солнечно"],
|
||||
"Passport": ["de": "Reisepass", "ru": "Паспорт"],
|
||||
"JR Pass voucher": ["de": "JR-Pass-Gutschein", "ru": "Ваучер JR Pass"],
|
||||
"Travel insurance": ["de": "Reiseversicherung", "ru": "Страховка"],
|
||||
"Rain jacket": ["de": "Regenjacke", "ru": "Дождевик"],
|
||||
"Walking shoes": ["de": "Wanderschuhe", "ru": "Удобная обувь"],
|
||||
"Warm sweater": ["de": "Warmer Pullover", "ru": "Тёплый свитер"],
|
||||
"Socks": ["de": "Socken", "ru": "Носки"],
|
||||
"Camera": ["de": "Kamera", "ru": "Фотоаппарат"],
|
||||
"Power bank": ["de": "Powerbank", "ru": "Пауэрбанк"],
|
||||
"Type A adapter": ["de": "Reiseadapter Typ A", "ru": "Переходник типа A"],
|
||||
"Toothbrush": ["de": "Zahnbürste", "ru": "Зубная щётка"],
|
||||
"Sunscreen": ["de": "Sonnencreme", "ru": "Солнцезащитный крем"],
|
||||
"Documents": ["de": "Dokumente", "ru": "Документы"],
|
||||
"Clothes": ["de": "Kleidung", "ru": "Одежда"],
|
||||
"Tech": ["de": "Technik", "ru": "Техника"],
|
||||
"Toiletries": ["de": "Kulturbeutel", "ru": "Гигиена"],
|
||||
"Reserve the Ghibli Museum": ["de": "Ghibli-Museum reservieren", "ru": "Забронировать музей Гибли"],
|
||||
"Buy a Suica card": ["de": "Suica-Karte kaufen", "ru": "Купить карту Suica"],
|
||||
"Book the tea ceremony": ["de": "Teezeremonie buchen", "ru": "Записаться на чайную церемонию"],
|
||||
"Print the hotel voucher": ["de": "Hotelgutschein drucken", "ru": "Распечатать ваучер отеля"],
|
||||
"Order yen": ["de": "Yen bestellen", "ru": "Заказать иены"],
|
||||
"Renew passport": ["de": "Reisepass verlängern", "ru": "Продлить паспорт"],
|
||||
]
|
||||
|
||||
private static func t(_ english: String) -> String {
|
||||
let language = Locale.current.language.languageCode?.identifier ?? "en"
|
||||
return translations[english]?[language] ?? english
|
||||
}
|
||||
|
||||
private static func stop(_ name: String, _ minutes: Int, end: Int? = nil, symbol: String, color: String?, booking: String? = nil,
|
||||
lat: Double, lng: Double, address: String, note: String? = nil) -> WatchSnapshot.Stop {
|
||||
WatchSnapshot.Stop(name: name, minutes: minutes, endMinutes: end, symbol: symbol, color: color, address: address,
|
||||
lat: lat, lng: lng, booking: booking, note: note)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import Foundation
|
||||
|
||||
/// What the iPhone app last sent the watch (client/src/native/watch.ts builds
|
||||
/// it, TrekWatchLink in the app passes it on through WatchConnectivity): the
|
||||
/// trip the dashboard features, in full, so the watch shows it without a
|
||||
/// network or a session of its own. Data only; every text the watch shows of
|
||||
/// its own comes from its Localizable.strings, and it formats times itself.
|
||||
///
|
||||
/// Everything past `v`, `generatedAt` and `trip` is decoded leniently, so a
|
||||
/// phone that is newer or older than this binary still gets through.
|
||||
struct WatchSnapshot: Codable, Equatable {
|
||||
struct Trip: Codable, Equatable {
|
||||
var id: Int
|
||||
var title: String
|
||||
var start: String
|
||||
var end: String
|
||||
var place: String?
|
||||
/// A small base64 JPEG of the cover, when the phone had one that fit.
|
||||
var cover: String?
|
||||
}
|
||||
|
||||
struct Stop: Codable, Equatable, Hashable {
|
||||
var name: String
|
||||
var minutes: Int?
|
||||
var endMinutes: Int?
|
||||
var symbol: String?
|
||||
var color: String?
|
||||
var address: String?
|
||||
var lat: Double?
|
||||
var lng: Double?
|
||||
/// The booking kind when the stop is a booking (flight, hotel, ...).
|
||||
var booking: String?
|
||||
var note: String?
|
||||
}
|
||||
|
||||
struct Day: Codable, Equatable, Hashable {
|
||||
var date: String?
|
||||
var number: Int
|
||||
var title: String?
|
||||
var stops: [Stop]
|
||||
}
|
||||
|
||||
struct Point: Codable, Equatable, Hashable {
|
||||
var name: String
|
||||
var code: String?
|
||||
var lat: Double?
|
||||
var lng: Double?
|
||||
}
|
||||
|
||||
struct Booking: Codable, Equatable, Hashable, Identifiable {
|
||||
var id: Int
|
||||
var title: String
|
||||
var kind: String
|
||||
var confirmed: Bool
|
||||
var date: String?
|
||||
var time: String?
|
||||
var endDate: String?
|
||||
var endTime: String?
|
||||
var zone: String?
|
||||
/// The zone the end time is local to (the arrival airport's), when the booking names one.
|
||||
var endZone: String?
|
||||
var from: Point?
|
||||
var to: Point?
|
||||
var code: String?
|
||||
var carrier: String?
|
||||
var seat: String?
|
||||
var platform: String?
|
||||
var location: String?
|
||||
var lat: Double?
|
||||
var lng: Double?
|
||||
var checkIn: String?
|
||||
var checkOut: String?
|
||||
}
|
||||
|
||||
struct Weather: Codable, Equatable {
|
||||
var temp: Int
|
||||
var high: Int?
|
||||
var low: Int?
|
||||
var main: String
|
||||
var label: String
|
||||
var place: String?
|
||||
var rain: Int?
|
||||
}
|
||||
|
||||
struct Item: Codable, Equatable, Hashable, Identifiable {
|
||||
var id: Int
|
||||
var name: String
|
||||
var done: Bool
|
||||
var group: String?
|
||||
var due: String?
|
||||
var priority: Int?
|
||||
}
|
||||
|
||||
struct Packing: Codable, Equatable {
|
||||
var items: [Item]
|
||||
var packed: Int
|
||||
var total: Int
|
||||
}
|
||||
|
||||
struct Todos: Codable, Equatable {
|
||||
var items: [Item]
|
||||
var open: Int
|
||||
var total: Int
|
||||
}
|
||||
|
||||
var v: Int
|
||||
var generatedAt: String
|
||||
var hour12: Bool
|
||||
var trip: Trip?
|
||||
var days: [Day]
|
||||
var bookings: [Booking]
|
||||
var weather: Weather?
|
||||
var packing: Packing?
|
||||
var todos: Todos?
|
||||
var applied: [String]
|
||||
|
||||
init(v: Int = 1, generatedAt: String, hour12: Bool = false, trip: Trip?, days: [Day] = [], bookings: [Booking] = [],
|
||||
weather: Weather? = nil, packing: Packing? = nil, todos: Todos? = nil, applied: [String] = []) {
|
||||
self.v = v
|
||||
self.generatedAt = generatedAt
|
||||
self.hour12 = hour12
|
||||
self.trip = trip
|
||||
self.days = days
|
||||
self.bookings = bookings
|
||||
self.weather = weather
|
||||
self.packing = packing
|
||||
self.todos = todos
|
||||
self.applied = applied
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
v = (try? c.decode(Int.self, forKey: .v)) ?? 1
|
||||
generatedAt = (try? c.decode(String.self, forKey: .generatedAt)) ?? ""
|
||||
hour12 = (try? c.decode(Bool.self, forKey: .hour12)) ?? false
|
||||
trip = try? c.decodeIfPresent(Trip.self, forKey: .trip)
|
||||
days = (try? c.decode([Day].self, forKey: .days)) ?? []
|
||||
bookings = (try? c.decode([Booking].self, forKey: .bookings)) ?? []
|
||||
weather = try? c.decodeIfPresent(Weather.self, forKey: .weather)
|
||||
packing = try? c.decodeIfPresent(Packing.self, forKey: .packing)
|
||||
todos = try? c.decodeIfPresent(Todos.self, forKey: .todos)
|
||||
applied = (try? c.decode([String].self, forKey: .applied)) ?? []
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> WatchSnapshot? {
|
||||
try? JSONDecoder().decode(WatchSnapshot.self, from: data)
|
||||
}
|
||||
|
||||
var generated: Date? {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter.date(from: generatedAt) ?? ISO8601DateFormatter().date(from: generatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dates
|
||||
|
||||
/// Days and times as the watch reads them: always in the time zone it is in
|
||||
/// now (it may have flown since the last launch), and times of day as wall-clock
|
||||
/// hours and minutes, never as minutes added to midnight, which are an hour off
|
||||
/// on the days the clocks change.
|
||||
enum TrekDate {
|
||||
/// The start of yyyy-MM-dd here.
|
||||
static func day(_ iso: String?) -> Date? {
|
||||
guard let iso, iso.count >= 10 else { return nil }
|
||||
let ymd = iso.prefix(10).split(separator: "-").compactMap { Int($0) }
|
||||
guard ymd.count == 3 else { return nil }
|
||||
return Calendar.current.date(from: DateComponents(year: ymd[0], month: ymd[1], day: ymd[2]))
|
||||
}
|
||||
|
||||
static func iso(_ date: Date) -> String {
|
||||
let parts = Calendar.current.dateComponents([.year, .month, .day], from: date)
|
||||
return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)
|
||||
}
|
||||
|
||||
/// yyyy-MM-dd and HH:mm as a moment, in `zone` when given, else here; midnight without a time.
|
||||
static func moment(_ date: String?, _ time: String?, zone: String? = nil) -> Date? {
|
||||
guard let date, date.count >= 10 else { return nil }
|
||||
let ymd = date.prefix(10).split(separator: "-").compactMap { Int($0) }
|
||||
guard ymd.count == 3 else { return nil }
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = zone.flatMap(TimeZone.init(identifier:)) ?? .current
|
||||
let m = minutes(time) ?? 0
|
||||
return calendar.date(from: DateComponents(year: ymd[0], month: ymd[1], day: ymd[2], hour: m / 60, minute: m % 60))
|
||||
}
|
||||
|
||||
/// "13:05" or "1:05 PM" to minutes after midnight.
|
||||
static func minutes(_ time: String?) -> Int? {
|
||||
guard let time else { return nil }
|
||||
let pieces = time.split(separator: ":")
|
||||
guard pieces.count >= 2, let hour = Int(pieces[0].trimmingCharacters(in: .whitespaces)),
|
||||
let minute = Int(pieces[1].prefix(2)) else { return nil }
|
||||
var h = hour
|
||||
let upper = time.uppercased()
|
||||
if upper.contains("PM"), h < 12 { h += 12 }
|
||||
if upper.contains("AM"), h == 12 { h = 0 }
|
||||
return h * 60 + minute
|
||||
}
|
||||
|
||||
static func daysBetween(_ from: Date, _ to: Date) -> Int {
|
||||
let calendar = Calendar.current
|
||||
return calendar.dateComponents([.day], from: calendar.startOfDay(for: from), to: calendar.startOfDay(for: to)).day ?? 0
|
||||
}
|
||||
|
||||
static func minutesOfDay(_ date: Date) -> Int {
|
||||
let parts = Calendar.current.dateComponents([.hour, .minute], from: date)
|
||||
return (parts.hour ?? 0) * 60 + (parts.minute ?? 0)
|
||||
}
|
||||
|
||||
/// The moment the clock shows `minutes` after midnight on `day`.
|
||||
static func at(_ minutes: Int, on day: Date) -> Date {
|
||||
let m = min(max(minutes, 0), 24 * 60 - 1)
|
||||
return Calendar.current.date(bySettingHour: m / 60, minute: m % 60, second: 0, of: day) ?? day
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - What the screens ask of a snapshot
|
||||
|
||||
extension WatchSnapshot {
|
||||
enum Phase: Equatable {
|
||||
/// The trip starts in this many days (0 = today, before its first day is in the plan).
|
||||
case upcoming(days: Int)
|
||||
/// The trip runs; the 1-based index of today and the number of days.
|
||||
case running(day: Int, of: Int)
|
||||
/// The trip is over.
|
||||
case past
|
||||
case none
|
||||
}
|
||||
|
||||
func phase(at now: Date) -> Phase {
|
||||
guard let trip, let start = TrekDate.day(trip.start) else { return .none }
|
||||
let end = TrekDate.day(trip.end) ?? start
|
||||
let untilStart = TrekDate.daysBetween(now, start)
|
||||
if untilStart > 0 { return .upcoming(days: untilStart) }
|
||||
if TrekDate.daysBetween(now, end) < 0 { return .past }
|
||||
let total = max(days.count, TrekDate.daysBetween(start, end) + 1, 1)
|
||||
let index = (todayIndex(at: now).map { days[$0].number }) ?? (TrekDate.daysBetween(start, now) + 1)
|
||||
return .running(day: min(max(index, 1), total), of: total)
|
||||
}
|
||||
|
||||
/// The position of today in `days`, when the plan has a day with today's date.
|
||||
func todayIndex(at now: Date) -> Int? {
|
||||
let iso = TrekDate.iso(now)
|
||||
return days.firstIndex { $0.date == iso }
|
||||
}
|
||||
|
||||
func today(at now: Date) -> Day? {
|
||||
todayIndex(at: now).map { days[$0] }
|
||||
}
|
||||
|
||||
var tripLength: Int {
|
||||
guard let trip, let start = TrekDate.day(trip.start), let end = TrekDate.day(trip.end) else { return max(days.count, 1) }
|
||||
return max(days.count, TrekDate.daysBetween(start, end) + 1, 1)
|
||||
}
|
||||
|
||||
/// The bookings that have not ended yet, soonest first.
|
||||
func bookingsAhead(at now: Date) -> [Booking] {
|
||||
let today = Calendar.current.startOfDay(for: now)
|
||||
return bookings.filter { booking in
|
||||
if let end = booking.end, booking.endTime != nil || booking.endDate != booking.date {
|
||||
if end >= now { return true }
|
||||
}
|
||||
guard let day = TrekDate.day(booking.date) else { return true }
|
||||
guard let start = booking.start, booking.time != nil else { return day >= today }
|
||||
return start >= now.addingTimeInterval(-30 * 60)
|
||||
}
|
||||
}
|
||||
|
||||
/// The next booking worth a countdown: one not yet started, else a running stay.
|
||||
func nextBooking(at now: Date) -> Booking? {
|
||||
let ahead = bookingsAhead(at: now)
|
||||
return ahead.first { booking in
|
||||
guard let start = booking.start else { return false }
|
||||
return start >= now.addingTimeInterval(-30 * 60)
|
||||
} ?? ahead.first
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchSnapshot.Booking {
|
||||
var start: Date? { TrekDate.moment(date, time, zone: zone) }
|
||||
/// The end in the arrival's zone; a ride without one ends in the zone the watch is in, never the departure's.
|
||||
var end: Date? { TrekDate.moment(endDate ?? date, endTime, zone: endZone ?? (isTransport ? nil : zone)) }
|
||||
|
||||
/// A booking with a day but no time lasts the whole day: there is nothing to count down to.
|
||||
var isAllDay: Bool { time == nil }
|
||||
|
||||
var isTransport: Bool { ["flight", "train", "bus", "car", "cruise"].contains(kind) }
|
||||
|
||||
/// "FRA → HND" from the codes, else the names, for a transport with both ends.
|
||||
var route: String? {
|
||||
guard let from, let to else { return nil }
|
||||
return "\(from.code ?? from.name) → \(to.code ?? to.name)"
|
||||
}
|
||||
|
||||
/// Where Directions goes: the departure for a ride, the place itself for the rest.
|
||||
var destination: (name: String, lat: Double, lng: Double)? {
|
||||
if isTransport, let from, let lat = from.lat, let lng = from.lng { return (from.name, lat, lng) }
|
||||
if let lat, let lng { return (location ?? title, lat, lng) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchSnapshot.Day {
|
||||
var dateValue: Date? { TrekDate.day(date) }
|
||||
}
|
||||
|
||||
extension WatchSnapshot.Stop {
|
||||
var hasLocation: Bool { lat != nil && lng != nil }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// A packing item or to-do ticked on the watch and not yet confirmed by the
|
||||
/// phone. It goes to the phone through WatchConnectivity (guaranteed, queued
|
||||
/// delivery) and stays laid over the snapshot until the phone's next snapshot
|
||||
/// shows it applied, so a tick never flips back while the phone catches up.
|
||||
struct PendingAction: Codable, Equatable, Identifiable {
|
||||
enum List: String, Codable { case packing, todo }
|
||||
|
||||
var id: String
|
||||
var tripId: Int
|
||||
var list: List
|
||||
var itemId: Int
|
||||
var done: Bool
|
||||
var at: Date
|
||||
/// Set once WatchConnectivity has handed the tick to the phone; such a tick
|
||||
/// is never sent again, it only stays laid over the snapshot until confirmed.
|
||||
var sent: Bool?
|
||||
|
||||
/// What the phone receives (parseWatchActions in client/src/native/watch.ts).
|
||||
var message: [String: Any] {
|
||||
["id": id, "tripId": tripId, "list": list.rawValue, "itemId": itemId, "done": done]
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the watch keeps the last snapshot and its pending ticks: the keychain
|
||||
/// group the watch app shares with its complications (no App Group needed,
|
||||
/// the same way the phone shares the widget snapshot). Nothing goes to
|
||||
/// UserDefaults, which the complications could not read anyway.
|
||||
enum WatchStore {
|
||||
private static let service = "com.liketrek.trek.watch"
|
||||
private static let snapshotAccount = "watch.snapshot"
|
||||
private static let pendingAccount = "watch.pending"
|
||||
|
||||
static func loadSnapshot() -> WatchSnapshot? {
|
||||
read(snapshotAccount).flatMap(WatchSnapshot.decode)
|
||||
}
|
||||
|
||||
static func saveSnapshot(_ data: Data) {
|
||||
write(snapshotAccount, data)
|
||||
}
|
||||
|
||||
static func loadPending() -> [PendingAction] {
|
||||
guard let data = read(pendingAccount) else { return [] }
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .secondsSince1970
|
||||
return (try? decoder.decode([PendingAction].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
static func savePending(_ actions: [PendingAction]) {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .secondsSince1970
|
||||
guard let data = try? encoder.encode(actions) else { return }
|
||||
write(pendingAccount, data)
|
||||
}
|
||||
|
||||
private static func query(_ account: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
}
|
||||
|
||||
private static func read(_ account: String) -> Data? {
|
||||
var q = query(account)
|
||||
q[kSecReturnData as String] = true
|
||||
var result: CFTypeRef?
|
||||
return SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess ? result as? Data : nil
|
||||
}
|
||||
|
||||
/// Into the first keychain group of the entitlements, the one the complications read.
|
||||
@discardableResult
|
||||
private static func write(_ account: String, _ data: Data) -> Bool {
|
||||
let q = query(account)
|
||||
let update = [kSecValueData as String: data] as CFDictionary
|
||||
if SecItemUpdate(q as CFDictionary, update) == errSecSuccess { return true }
|
||||
var add = q
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchSnapshot {
|
||||
/// The snapshot with the ticks the phone has not confirmed yet laid over it.
|
||||
func applying(_ pending: [PendingAction]) -> WatchSnapshot {
|
||||
guard !pending.isEmpty, let tripId = trip?.id else { return self }
|
||||
var copy = self
|
||||
for action in pending where action.tripId == tripId {
|
||||
switch action.list {
|
||||
case .packing:
|
||||
if var packing = copy.packing, let index = packing.items.firstIndex(where: { $0.id == action.itemId }) {
|
||||
if packing.items[index].done != action.done {
|
||||
packing.packed += action.done ? 1 : -1
|
||||
packing.items[index].done = action.done
|
||||
}
|
||||
copy.packing = packing
|
||||
}
|
||||
case .todo:
|
||||
if var todos = copy.todos, let index = todos.items.firstIndex(where: { $0.id == action.itemId }) {
|
||||
if todos.items[index].done != action.done {
|
||||
todos.open += action.done ? -1 : 1
|
||||
todos.items[index].done = action.done
|
||||
}
|
||||
copy.todos = todos
|
||||
}
|
||||
}
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
/// The pending ticks still to lay over this snapshot: those the phone has
|
||||
/// not applied, whose item does not show the ticked state yet, and that
|
||||
/// are less than a day old (someone else may have changed it since).
|
||||
func stillPending(_ pending: [PendingAction], now: Date = Date()) -> [PendingAction] {
|
||||
pending.filter { action in
|
||||
if applied.contains(action.id) || now.timeIntervalSince(action.at) > 86_400 { return false }
|
||||
let items = action.list == .packing ? packing?.items : todos?.items
|
||||
guard let item = items?.first(where: { $0.id == action.itemId }) else { return false }
|
||||
return item.done != action.done
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A text from the watch's Localizable.strings.
|
||||
func L(_ key: String) -> String {
|
||||
NSLocalizedString(key, comment: "")
|
||||
}
|
||||
|
||||
/// The watch's own formatting: times in the clock the user picked in TREK,
|
||||
/// days, ranges and countdowns in the watch's language.
|
||||
enum WatchFormat {
|
||||
private static var formatters: [String: DateFormatter] = [:]
|
||||
private static let utc = TimeZone(identifier: "UTC") ?? .current
|
||||
|
||||
/// A formatter for the template in the watch's language, in `zone`
|
||||
/// (the current one unless given), so a flight across zones is followed.
|
||||
private static func formatter(_ template: String, zone: TimeZone = .current) -> DateFormatter {
|
||||
let key = template + "|" + Locale.current.identifier + "|" + zone.identifier
|
||||
if let cached = formatters[key] { return cached }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale.current
|
||||
formatter.timeZone = zone
|
||||
formatter.setLocalizedDateFormatFromTemplate(template)
|
||||
formatters[key] = formatter
|
||||
return formatter
|
||||
}
|
||||
|
||||
static func time(_ date: Date, hour12: Bool) -> String {
|
||||
formatter(hour12 ? "h:mm a" : "HH:mm").string(from: date)
|
||||
}
|
||||
|
||||
/// A time of day from its minutes, with no date involved, so no change of
|
||||
/// the clocks can shift it: the minutes after the reference date, in UTC.
|
||||
static func time(minutes: Int, hour12: Bool) -> String {
|
||||
let m = min(max(minutes, 0), 24 * 60 - 1)
|
||||
return formatter(hour12 ? "h:mm a" : "HH:mm", zone: utc).string(from: Date(timeIntervalSinceReferenceDate: TimeInterval(m * 60)))
|
||||
}
|
||||
|
||||
/// "Tue 18 Nov"
|
||||
static func day(_ date: Date) -> String {
|
||||
formatter("EEEdMMM").string(from: date)
|
||||
}
|
||||
|
||||
/// "Tuesday"
|
||||
static func weekday(_ date: Date) -> String {
|
||||
formatter("EEEE").string(from: date)
|
||||
}
|
||||
|
||||
/// "17 – 25 Nov"
|
||||
static func range(_ start: String, _ end: String) -> String? {
|
||||
guard let from = TrekDate.day(start), let to = TrekDate.day(end) else { return nil }
|
||||
let formatter = DateIntervalFormatter()
|
||||
formatter.dateTemplate = "dMMM"
|
||||
return formatter.string(from: from, to: to)
|
||||
}
|
||||
|
||||
/// "in 25 min", "in 2 hr", "5 min ago": named and plural-correct in every language.
|
||||
static func relative(_ date: Date, now: Date) -> String {
|
||||
if abs(date.timeIntervalSince(now)) < 60 { return L("time.now") }
|
||||
// Against `now`, not the system clock, so the screenshot mode's clock counts too.
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .short
|
||||
formatter.dateTimeStyle = .numeric
|
||||
return formatter.localizedString(for: date, relativeTo: now)
|
||||
}
|
||||
|
||||
/// The time to a moment as a short count: "25 min", "2 h 15 min", "3 d".
|
||||
static func countdown(_ date: Date, now: Date) -> String {
|
||||
let seconds = max(date.timeIntervalSince(now), 0)
|
||||
let formatter = WatchFormat.components()
|
||||
formatter.unitsStyle = .short
|
||||
formatter.maximumUnitCount = 2
|
||||
formatter.allowedUnits = seconds >= 86_400 ? [.day, .hour] : seconds >= 3_600 ? [.hour, .minute] : [.minute]
|
||||
return formatter.string(from: max(seconds, 60)) ?? ""
|
||||
}
|
||||
|
||||
/// The word for a number of days in its right plural form ("days", "Tage",
|
||||
/// "дней", "日"), taken from the system's own phrase so no plural rules
|
||||
/// have to be kept here: the number is formatted and then cut out.
|
||||
static func daysUnit(_ count: Int) -> String {
|
||||
let formatter = WatchFormat.components()
|
||||
formatter.unitsStyle = .full
|
||||
formatter.allowedUnits = [.day]
|
||||
let phrase = formatter.string(from: DateComponents(day: count)) ?? ""
|
||||
let word = phrase.unicodeScalars.filter { !CharacterSet.decimalDigits.contains($0) }
|
||||
return String(String.UnicodeScalarView(word)).trimmingCharacters(in: .whitespacesAndNewlines.union(.punctuationCharacters))
|
||||
}
|
||||
|
||||
/// A duration formatter in the watch's language: it takes its words from
|
||||
/// its calendar's locale, which is not the app's own unless set.
|
||||
static func components() -> DateComponentsFormatter {
|
||||
let formatter = DateComponentsFormatter()
|
||||
var calendar = Calendar.current
|
||||
calendar.locale = Locale.current
|
||||
formatter.calendar = calendar
|
||||
return formatter
|
||||
}
|
||||
|
||||
static func dayOf(_ index: Int, _ total: Int) -> String {
|
||||
String(format: L("day.of"), index, total)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The look
|
||||
|
||||
/// The watch's take on the widgets' look (TrekStyle.swift): black glass instead
|
||||
/// of paper, the trip's own two colours as the light behind its pages, the
|
||||
/// booking kinds' colours behind theirs.
|
||||
enum WatchLook {
|
||||
static let ink = Color.white
|
||||
static let muted = Color.white.opacity(0.62)
|
||||
static let faint = Color.white.opacity(0.38)
|
||||
static let packing = Color(hex: 0x2FA37A)
|
||||
static let todo = Color(hex: 0x4A7DDB)
|
||||
|
||||
/// The lights a trip's pages can have: clear, saturated hues that stay
|
||||
/// clean when they fade into black (no ochre or olive, which turn muddy).
|
||||
static let palette: [(Color, Color)] = [
|
||||
(Color(hex: 0x3D6FD8), Color(hex: 0x1C2E73)),
|
||||
(Color(hex: 0x2BA59A), Color(hex: 0x0E4E5A)),
|
||||
(Color(hex: 0x7A5AE0), Color(hex: 0x2E1F6B)),
|
||||
(Color(hex: 0xD2557A), Color(hex: 0x5C1A3A)),
|
||||
(Color(hex: 0x2E8BC0), Color(hex: 0x113A5C)),
|
||||
(Color(hex: 0xE0703C), Color(hex: 0x5E2414)),
|
||||
(Color(hex: 0x3FA866), Color(hex: 0x134A2C)),
|
||||
]
|
||||
|
||||
/// A trip's own two colours, picked by its id, so they do not change with
|
||||
/// its title or the language it is shown in.
|
||||
static func tripColors(_ trip: WatchSnapshot.Trip?) -> (Color, Color) {
|
||||
palette[(trip?.id ?? 1) % palette.count]
|
||||
}
|
||||
|
||||
/// A trip's page light: its first colour at the top, fading into black.
|
||||
static func tripGradient(_ trip: WatchSnapshot.Trip?) -> LinearGradient {
|
||||
let (first, second) = tripColors(trip)
|
||||
return LinearGradient(colors: [first.opacity(0.85), second.opacity(0.6), .black], startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
|
||||
static func tint(_ color: Color) -> LinearGradient {
|
||||
LinearGradient(colors: [color.opacity(0.75), color.opacity(0.28), .black], startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
|
||||
static func stopColor(_ stop: WatchSnapshot.Stop) -> Color {
|
||||
if let kind = stop.booking { return Trek.booking(kind).color }
|
||||
return Color(css: stop.color) ?? Trek.info
|
||||
}
|
||||
|
||||
static func stopSymbol(_ stop: WatchSnapshot.Stop) -> String {
|
||||
stop.symbol ?? (stop.booking.map { Trek.booking($0).symbol } ?? "mappin")
|
||||
}
|
||||
|
||||
static func kindName(_ kind: String) -> String {
|
||||
L("kind.\(["flight", "train", "bus", "car", "cruise", "hotel", "restaurant", "event", "tour"].contains(kind) ? kind : "other")")
|
||||
}
|
||||
}
|
||||
|
||||
/// The small capitals above a title.
|
||||
struct WatchEyebrow: ViewModifier {
|
||||
var color: Color = WatchLook.muted
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(.system(.caption2, design: .rounded).weight(.bold))
|
||||
.textCase(.uppercase)
|
||||
.tracking(0.4)
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.65)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Text a person wrote (a place, a booking, an item): when it is in Latin
|
||||
/// letters it is set as such, so in a Chinese, Japanese or Korean interface
|
||||
/// it still breaks between words rather than inside one.
|
||||
func userText(_ text: String?) -> some View {
|
||||
let latin = (text ?? "").unicodeScalars.allSatisfy { $0.value < 0x0250 || $0.properties.isWhitespace || $0.properties.generalCategory == .dashPunctuation || $0.properties.generalCategory == .otherPunctuation }
|
||||
return typesettingLanguage(Locale.Language(identifier: "en"), isEnabled: latin)
|
||||
}
|
||||
|
||||
func watchEyebrow(_ color: Color = WatchLook.muted) -> some View { modifier(WatchEyebrow(color: color)) }
|
||||
|
||||
/// A card of glass on watchOS 26, of frosted white before.
|
||||
@ViewBuilder
|
||||
func watchCard(cornerRadius: CGFloat = 18, tint: Color? = nil) -> some View {
|
||||
if #available(watchOS 26.0, iOS 26.0, macOS 26.0, *) {
|
||||
self.glassEffect(tint.map { Glass.regular.tint($0.opacity(0.35)) } ?? .regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
} else {
|
||||
self.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.fill((tint ?? .white).opacity(tint == nil ? 0.12 : 0.28))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stop's category as a tinted disc with its symbol.
|
||||
struct StopGlyph: View {
|
||||
let stop: WatchSnapshot.Stop
|
||||
var size: CGFloat = 26
|
||||
var dimmed = false
|
||||
|
||||
var body: some View {
|
||||
let color = WatchLook.stopColor(stop)
|
||||
Circle()
|
||||
.fill(color.gradient)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Image(systemName: WatchLook.stopSymbol(stop))
|
||||
.font(.system(size: size * 0.46, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
)
|
||||
.opacity(dimmed ? 0.45 : 1)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The TREK mark: four slanted tiles, as on the app icon.
|
||||
struct TrekMark: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
// The icon's tiles in its own 1024 grid, the mark spanning 270...754 by 256...768.
|
||||
let tiles: [[CGPoint]] = [
|
||||
[CGPoint(x: 382, y: 256), CGPoint(x: 621, y: 256), CGPoint(x: 558, y: 494), CGPoint(x: 318, y: 494)],
|
||||
[CGPoint(x: 620, y: 358), CGPoint(x: 754, y: 358), CGPoint(x: 718, y: 494), CGPoint(x: 585, y: 494)],
|
||||
[CGPoint(x: 302, y: 528), CGPoint(x: 438, y: 528), CGPoint(x: 405, y: 664), CGPoint(x: 270, y: 664)],
|
||||
[CGPoint(x: 468, y: 528), CGPoint(x: 706, y: 528), CGPoint(x: 644, y: 768), CGPoint(x: 404, y: 768)],
|
||||
]
|
||||
let minX: CGFloat = 270, minY: CGFloat = 256, span: CGFloat = 512
|
||||
let scale = min(rect.width, rect.height) / span
|
||||
let dx = rect.minX + (rect.width - (754 - minX) * scale) / 2
|
||||
let dy = rect.minY + (rect.height - (768 - minY) * scale) / 2
|
||||
var path = Path()
|
||||
for tile in tiles {
|
||||
let points = tile.map { CGPoint(x: dx + ($0.x - minX) * scale, y: dy + ($0.y - minY) * scale) }
|
||||
path.addLines(points)
|
||||
path.closeSubpath()
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/// A label and a value in a small tile: "SEAT 42A".
|
||||
struct FactTile: View {
|
||||
let label: String
|
||||
let value: String
|
||||
var mono = false
|
||||
/// Laid out at its natural width, so a ViewThatFits can tell when a row of them is too wide.
|
||||
var fits = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(label).watchEyebrow(WatchLook.faint)
|
||||
.fixedSize(horizontal: fits, vertical: false)
|
||||
Text(value)
|
||||
.font(mono ? .system(.body, design: .monospaced).weight(.semibold) : .system(.body, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(WatchLook.ink)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.watchCard(cornerRadius: 12)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>TREK</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// The watch's complications and Smart Stack widgets, drawn from the snapshot
|
||||
/// the watch app keeps (WatchStore, in the keychain group both share): the
|
||||
/// next stop of the day, a countdown to the next departure, the day of the trip.
|
||||
@main
|
||||
struct TREKWatchWidgets: WidgetBundle {
|
||||
var body: some Widget {
|
||||
NextStopWidget()
|
||||
CountdownWidget()
|
||||
TripDayWidget()
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let snapshot: WatchSnapshot?
|
||||
let relevance: TimelineEntryRelevance?
|
||||
|
||||
var state: ComplicationState { ComplicationState(snapshot, now: date) }
|
||||
}
|
||||
|
||||
/// One entry now and one at every moment the face should change (a stop
|
||||
/// starts or ends, a booking departs, midnight), each scored for the Smart
|
||||
/// Stack: highest just before something happens.
|
||||
struct WatchProvider: TimelineProvider {
|
||||
let kind: Kind
|
||||
|
||||
enum Kind { case nextStop, countdown, tripDay }
|
||||
|
||||
func placeholder(in context: Context) -> WatchEntry {
|
||||
WatchEntry(date: Date(), snapshot: WatchSample.snapshot(.running), relevance: nil)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (WatchEntry) -> Void) {
|
||||
let stored = WatchStore.loadSnapshot()
|
||||
completion(WatchEntry(date: Date(), snapshot: context.isPreview && stored == nil ? WatchSample.snapshot(.running) : stored, relevance: nil))
|
||||
}
|
||||
|
||||
/// Entries at every change in the next two days, every quarter hour for two
|
||||
/// hours and every hour after that (for the circular faces' counts; the
|
||||
/// others count by themselves), rebuilt at midnight or after the last entry,
|
||||
/// whichever comes first. Without a trip nothing changes until the app writes
|
||||
/// a new snapshot, which reloads the timelines itself.
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<WatchEntry>) -> Void) {
|
||||
let now = Date()
|
||||
let snapshot = WatchStore.loadSnapshot()?.applying(WatchStore.loadPending())
|
||||
guard snapshot?.trip != nil else {
|
||||
completion(Timeline(entries: [WatchEntry(date: now, snapshot: snapshot, relevance: nil)], policy: .never))
|
||||
return
|
||||
}
|
||||
var moments = ComplicationState.changes(snapshot, from: now)
|
||||
for step in 1...8 { moments.append(now.addingTimeInterval(Double(step) * 15 * 60)) }
|
||||
for hour in 3...24 { moments.append(now.addingTimeInterval(Double(hour) * 3_600)) }
|
||||
let dates = ([now] + moments).sorted().reduce(into: [Date]()) { out, date in
|
||||
if let last = out.last, date.timeIntervalSince(last) < 60 { return }
|
||||
out.append(date)
|
||||
}
|
||||
let entries = dates.prefix(80).map { date in
|
||||
WatchEntry(date: date, snapshot: snapshot, relevance: relevance(snapshot, at: date))
|
||||
}
|
||||
let midnight = Calendar.current.date(byAdding: .day, value: 1, to: Calendar.current.startOfDay(for: now)) ?? now.addingTimeInterval(86_400)
|
||||
let reload = min(midnight, entries.last?.date ?? midnight)
|
||||
completion(Timeline(entries: entries, policy: .after(reload > now ? reload : now.addingTimeInterval(3_600))))
|
||||
}
|
||||
|
||||
private func relevance(_ snapshot: WatchSnapshot?, at date: Date) -> TimelineEntryRelevance? {
|
||||
let state = ComplicationState(snapshot, now: date)
|
||||
switch kind {
|
||||
case .nextStop:
|
||||
guard let next = state.next else { return TimelineEntryRelevance(score: 0) }
|
||||
if next.isNow { return TimelineEntryRelevance(score: 60, duration: 30 * 60) }
|
||||
let minutes = (next.start ?? date).timeIntervalSince(date) / 60
|
||||
return TimelineEntryRelevance(score: minutes <= 45 ? 80 : minutes <= 120 ? 40 : 10)
|
||||
case .countdown:
|
||||
guard let countdown = state.countdown else { return TimelineEntryRelevance(score: 0) }
|
||||
let hours = countdown.target.timeIntervalSince(date) / 3_600
|
||||
return TimelineEntryRelevance(score: hours <= 3 ? 100 : hours <= 24 ? 50 : 5)
|
||||
case .tripDay:
|
||||
if case .running = state.phase { return TimelineEntryRelevance(score: 20) }
|
||||
return TimelineEntryRelevance(score: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let families: [WidgetFamily] = [.accessoryCircular, .accessoryRectangular, .accessoryCorner, .accessoryInline]
|
||||
|
||||
private struct EntryView<Content: View>: View {
|
||||
let entry: WatchEntry
|
||||
@Environment(\.widgetFamily) private var widgetFamily
|
||||
@ViewBuilder let content: (ComplicationState, ComplicationFamily, Date) -> Content
|
||||
|
||||
var body: some View {
|
||||
content(entry.state, ComplicationFamily(widgetFamily) ?? .rectangular, entry.date)
|
||||
.containerBackground(for: .widget) { Color.clear }
|
||||
}
|
||||
}
|
||||
|
||||
struct NextStopWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "com.liketrek.trek.watch.nextStop", provider: WatchProvider(kind: .nextStop)) { entry in
|
||||
EntryView(entry: entry) { state, family, now in NextStopComplication(state: state, family: family, now: now) }
|
||||
}
|
||||
.configurationDisplayName(L("complication.nextStop.name"))
|
||||
.description(L("complication.nextStop.description"))
|
||||
.supportedFamilies(families)
|
||||
}
|
||||
}
|
||||
|
||||
struct CountdownWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "com.liketrek.trek.watch.countdown", provider: WatchProvider(kind: .countdown)) { entry in
|
||||
EntryView(entry: entry) { state, family, now in CountdownComplication(state: state, family: family, now: now) }
|
||||
}
|
||||
.configurationDisplayName(L("complication.countdown.name"))
|
||||
.description(L("complication.countdown.description"))
|
||||
.supportedFamilies(families)
|
||||
}
|
||||
}
|
||||
|
||||
struct TripDayWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "com.liketrek.trek.watch.tripDay", provider: WatchProvider(kind: .tripDay)) { entry in
|
||||
EntryView(entry: entry) { state, family, _ in TripDayComplication(state: state, family: family) }
|
||||
}
|
||||
.configurationDisplayName(L("complication.tripDay.name"))
|
||||
.description(L("complication.tripDay.description"))
|
||||
.supportedFamilies(families)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,37 @@
|
||||
# Adds an app extension target to App.xcodeproj and embeds it in the app.
|
||||
# Adds an extension target (or the watch app) to App.xcodeproj and embeds it.
|
||||
#
|
||||
# ruby ios/scripts/add-extension.rb <name> <bundle-id> <type> [extra frameworks...]
|
||||
# ruby ios/scripts/add-extension.rb <name> <bundle-id> <type> [options] [extra frameworks...]
|
||||
#
|
||||
# type is "notification" (Notification Service Extension), "widget" (WidgetKit,
|
||||
# which also carries the Live Activities) or "share" (the share sheet entry). The extension's sources live in
|
||||
# ios/App/<name>/ with an Info.plist and <name>.entitlements next to them. The
|
||||
# which also carries the Live Activities), "share" (the share sheet entry),
|
||||
# "watch" (the Apple Watch app, embedded in the phone app's Watch folder) or
|
||||
# "watchwidget" (the watch's complications, embedded in the watch app). The
|
||||
# target's sources live in ios/App/<name>/ with an Info.plist and
|
||||
# <name>.entitlements next to them; an asset catalog or a privacy manifest
|
||||
# there is a resource. The
|
||||
# project file stays checked in, so the targets show in Xcode like any other;
|
||||
# this script is how they got there, and running it again changes nothing.
|
||||
#
|
||||
# Options:
|
||||
# --with=<path>,... more sources under ios/App compiled into the target
|
||||
# too, each a folder of .swift files or one file
|
||||
# (code shared between targets keeps one reference)
|
||||
# --host=<target> the target a watchwidget is embedded in
|
||||
# --app-file=<path>,... sources added to the phone app itself: the phone's end
|
||||
# of what the new target talks to
|
||||
# --strings-from=<name> the target uses the Localizable.strings of that folder
|
||||
require 'xcodeproj'
|
||||
|
||||
name, bundle_id, type, *frameworks = ARGV
|
||||
abort 'usage: add-extension.rb <name> <bundle-id> <notification|widget|share> [frameworks...]' unless name && bundle_id && type
|
||||
name, bundle_id, type, *rest = ARGV
|
||||
types = %w[notification widget share watch watchwidget]
|
||||
abort "usage: add-extension.rb <name> <bundle-id> <#{types.join('|')}> [options] [frameworks...]" unless name && bundle_id && types.include?(type)
|
||||
options, frameworks = rest.partition { |arg| arg.start_with?('--') }
|
||||
option = ->(key) { options.find { |o| o.start_with?("--#{key}=") }&.split('=', 2)&.last }
|
||||
list = ->(key) { (option.(key) || '').split(',').reject(&:empty?) }
|
||||
watch = %w[watch watchwidget].include?(type)
|
||||
app_dir = File.expand_path('../App', __dir__)
|
||||
|
||||
project_path = File.expand_path('../App/App.xcodeproj', __dir__)
|
||||
project_path = File.join(app_dir, 'App.xcodeproj')
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
app = project.targets.find { |t| t.name == 'App' } or abort 'no App target'
|
||||
|
||||
@@ -21,7 +40,11 @@ if project.targets.any? { |t| t.name == name }
|
||||
exit 0
|
||||
end
|
||||
|
||||
target = project.new_target(:app_extension, name, :ios, '15.0', nil, :swift)
|
||||
target = if type == 'watch'
|
||||
project.new_target(:application, name, :watchos, '11.0', nil, :swift)
|
||||
else
|
||||
project.new_target(:app_extension, name, watch ? :watchos : :ios, watch ? '11.0' : '15.0', nil, :swift)
|
||||
end
|
||||
# new_target links Foundation by a path into one SDK version; it is linked
|
||||
# implicitly anyway, and a pinned SDK path breaks on the next Xcode.
|
||||
target.frameworks_build_phase.files.select { |f| f.display_name.to_s.include?('Foundation') }.each do |f|
|
||||
@@ -33,15 +56,15 @@ end
|
||||
group = project.main_group.find_subpath(name, true)
|
||||
group.set_source_tree('<group>')
|
||||
group.set_path(name)
|
||||
sources = Dir.children(File.expand_path("../App/#{name}", __dir__)).sort
|
||||
sources = Dir.children(File.join(app_dir, name)).sort
|
||||
# Localizations go in as one variant group, so Xcode copies each language's
|
||||
# strings into the extension bundle; JavaScript (Safari's page preprocessing) is
|
||||
# a resource as well.
|
||||
# strings into the extension bundle; JavaScript (Safari's page preprocessing) and
|
||||
# an asset catalog are resources as well.
|
||||
lprojs, files = sources.partition { |file| file.end_with?('.lproj') }
|
||||
files.each do |file|
|
||||
ref = group.new_reference(file)
|
||||
target.add_file_references([ref]) if file.end_with?('.swift')
|
||||
target.add_resources([ref]) if file.end_with?('.js')
|
||||
target.add_resources([ref]) if file.end_with?('.js', '.xcassets', '.xcprivacy')
|
||||
end
|
||||
unless lprojs.empty?
|
||||
variant = group.new_variant_group('Localizable.strings')
|
||||
@@ -55,6 +78,39 @@ unless lprojs.empty?
|
||||
target.add_resources([variant])
|
||||
end
|
||||
|
||||
# A file already in the project (the widgets' style, shared with the watch)
|
||||
# keeps its one reference; one new to it goes in a group named after its folder.
|
||||
reference_for = lambda do |relative|
|
||||
full = File.join(app_dir, relative)
|
||||
project.files.find { |f| f.real_path.to_s == full } || begin
|
||||
dir = File.dirname(relative)
|
||||
parent = project.main_group
|
||||
unless dir == '.'
|
||||
parent = project.main_group.find_subpath(dir, true)
|
||||
parent.set_source_tree('<group>')
|
||||
parent.set_path(dir)
|
||||
end
|
||||
parent.new_reference(File.basename(relative))
|
||||
end
|
||||
end
|
||||
|
||||
list.('with').each do |entry|
|
||||
folder = File.join(app_dir, entry)
|
||||
paths = File.directory?(folder) ? Dir.children(folder).sort.select { |f| f.end_with?('.swift') }.map { |f| "#{entry}/#{f}" } : [entry]
|
||||
target.add_file_references(paths.map { |path| reference_for.(path) })
|
||||
end
|
||||
|
||||
list.('app-file').each do |path|
|
||||
ref = reference_for.(path)
|
||||
app.add_file_references([ref]) unless app.source_build_phase.files_references.include?(ref)
|
||||
end
|
||||
|
||||
if (strings_from = option.('strings-from'))
|
||||
variant = project.main_group.find_subpath(strings_from, false)&.children&.find { |c| c.isa == 'PBXVariantGroup' && c.name == 'Localizable.strings' }
|
||||
abort "no Localizable.strings in #{strings_from}" unless variant
|
||||
target.add_resources([variant])
|
||||
end
|
||||
|
||||
frameworks.each do |framework|
|
||||
ref = project.frameworks_group.new_file("System/Library/Frameworks/#{framework}.framework", :sdk_root)
|
||||
target.frameworks_build_phase.add_file_reference(ref, true)
|
||||
@@ -71,20 +127,46 @@ target.build_configurations.each do |config|
|
||||
s['CODE_SIGN_STYLE'] = 'Automatic'
|
||||
s['DEVELOPMENT_TEAM'] = app_settings['DEVELOPMENT_TEAM'] if app_settings['DEVELOPMENT_TEAM']
|
||||
s['SWIFT_VERSION'] = '5.0'
|
||||
s['TARGETED_DEVICE_FAMILY'] = '1,2'
|
||||
s['IPHONEOS_DEPLOYMENT_TARGET'] = type == 'widget' ? '16.2' : '15.0'
|
||||
s['MARKETING_VERSION'] = app_settings['MARKETING_VERSION'] || '1.0'
|
||||
s['CURRENT_PROJECT_VERSION'] = app_settings['CURRENT_PROJECT_VERSION'] || '1'
|
||||
s['SKIP_INSTALL'] = 'YES'
|
||||
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks', '@executable_path/../../Frameworks']
|
||||
if watch
|
||||
s.delete('IPHONEOS_DEPLOYMENT_TARGET')
|
||||
s['SDKROOT'] = 'watchos'
|
||||
s['TARGETED_DEVICE_FAMILY'] = '4'
|
||||
s['WATCHOS_DEPLOYMENT_TARGET'] = '11.0'
|
||||
if type == 'watch'
|
||||
s['ASSETCATALOG_COMPILER_APPICON_NAME'] = 'AppIcon'
|
||||
s['ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME'] = 'AccentColor'
|
||||
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks']
|
||||
else
|
||||
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks', '@executable_path/../../Frameworks']
|
||||
end
|
||||
else
|
||||
s['TARGETED_DEVICE_FAMILY'] = '1,2'
|
||||
s['IPHONEOS_DEPLOYMENT_TARGET'] = type == 'widget' ? '16.2' : '15.0'
|
||||
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks', '@executable_path/../../Frameworks']
|
||||
end
|
||||
end
|
||||
|
||||
embed = app.copy_files_build_phases.find { |p| p.name == 'Embed Foundation Extensions' } ||
|
||||
app.new_copy_files_build_phase('Embed Foundation Extensions')
|
||||
embed.symbol_dst_subfolder_spec = :plug_ins
|
||||
# The watch app goes into the phone app's Watch folder, every extension into the
|
||||
# PlugIns of its host: the phone app, or the watch app for the complications.
|
||||
if type == 'watch'
|
||||
host = app
|
||||
embed = app.copy_files_build_phases.find { |p| p.name == 'Embed Watch Content' } ||
|
||||
app.new_copy_files_build_phase('Embed Watch Content')
|
||||
embed.symbol_dst_subfolder_spec = :products_directory
|
||||
embed.dst_path = '$(CONTENTS_FOLDER_PATH)/Watch'
|
||||
else
|
||||
host = type == 'watchwidget' ? project.targets.find { |t| t.name == option.('host') } : app
|
||||
abort 'a watchwidget needs --host=<watch target>' unless host
|
||||
embed = host.copy_files_build_phases.find { |p| p.name == 'Embed Foundation Extensions' } ||
|
||||
host.new_copy_files_build_phase('Embed Foundation Extensions')
|
||||
embed.symbol_dst_subfolder_spec = :plug_ins
|
||||
end
|
||||
build_file = embed.add_file_reference(target.product_reference, true)
|
||||
build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }
|
||||
app.add_dependency(target)
|
||||
host.add_dependency(target)
|
||||
|
||||
project.save
|
||||
puts "added #{name} (#{bundle_id})"
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/bin/bash
|
||||
# Captures the watch app's screens from watchOS simulators, in screenshot mode
|
||||
# (client/ios/App/TREKWatch/DemoMode.swift), for review and the App Store:
|
||||
# every screen and its states in English, German and Russian on the newest
|
||||
# Ultra, the largest other watch and the smallest one, and the main screens in
|
||||
# every language on the smallest, where text is tightest.
|
||||
# .github/workflows/native.yml runs it after building the app.
|
||||
#
|
||||
# watch-screens.sh <TREKWatch.app> <output directory>
|
||||
set -euo pipefail
|
||||
|
||||
APP="$1"
|
||||
OUT="$2"
|
||||
BUNDLE=com.liketrek.trek.watchkitapp
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# The newest watchOS runtime, its newest Ultra, the largest other watch and the
|
||||
# smallest one it still runs on.
|
||||
read -r RUNTIME LARGE MEDIUM SMALL < <(xcrun simctl list runtimes -j | python3 -c '
|
||||
import json, re, sys
|
||||
runtimes = [r for r in json.load(sys.stdin)["runtimes"] if r["platform"] == "watchOS" and r["isAvailable"]]
|
||||
runtime = sorted(runtimes, key=lambda r: [int(x) for x in r["version"].split(".")])[-1]
|
||||
names = [t["name"] for t in runtime["supportedDeviceTypes"]]
|
||||
def mm(n):
|
||||
m = re.search(r"(\d+)mm", n); return int(m.group(1)) if m else 0
|
||||
def gen(n):
|
||||
m = re.search(r"(?:Series|Ultra|SE) ?(\d+)?", n); return int(m.group(1) or 1) if m else 0
|
||||
ultra = sorted([n for n in names if "Ultra" in n], key=gen)[-1]
|
||||
rest = sorted([n for n in names if "Ultra" not in n], key=lambda n: (mm(n), gen(n)))
|
||||
print(runtime["identifier"], *[n.replace(" ", "_") for n in (ultra, rest[-1], rest[0])])')
|
||||
LARGE=${LARGE//_/ }
|
||||
MEDIUM=${MEDIUM//_/ }
|
||||
SMALL=${SMALL//_/ }
|
||||
echo "runtime $RUNTIME; large: $LARGE, medium: $MEDIUM, small: $SMALL"
|
||||
|
||||
boot() {
|
||||
local udid
|
||||
udid=$(xcrun simctl create "$1" "$1" "$RUNTIME")
|
||||
xcrun simctl boot "$udid"
|
||||
xcrun simctl bootstatus "$udid" -b >/dev/null
|
||||
xcrun simctl install "$udid" "$APP"
|
||||
echo "$udid"
|
||||
}
|
||||
|
||||
# The watch draws the Mac's clock in its corner, so the Mac goes to a zone in
|
||||
# which it is about one o'clock: the hour the sample trip's screens are made for
|
||||
# (WatchSample.swift), and the clock and the countdowns agree.
|
||||
set_clock() {
|
||||
local offset=$(( (13 - 10#$(date -u +%H) + 36) % 24 - 12 ))
|
||||
local zone
|
||||
if [ "$offset" -ge 0 ]; then zone="Etc/GMT-$offset"; else zone="Etc/GMT+$(( -offset ))"; fi
|
||||
sudo ln -sf "/usr/share/zoneinfo/$zone" /etc/localtime
|
||||
echo "clock: $zone, $(date +%H:%M)"
|
||||
}
|
||||
|
||||
# Waits for the ready file the app writes once the screen has drawn (DemoMode.signalReady).
|
||||
wait_ready() {
|
||||
local udid=$1 token=$2 container
|
||||
for _ in $(seq 1 40); do
|
||||
container=$(xcrun simctl get_app_container "$udid" "$BUNDLE" data 2>/dev/null || true)
|
||||
[ -n "$container" ] && [ -f "$container/Documents/ready-$token" ] && return 0
|
||||
sleep 0.25
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
TOKEN=0
|
||||
# shot <udid> <file> <screen> <scenario> <time or -> <language>: one launch, retried
|
||||
# after 2, 5 and 10 seconds when the simulator refuses it.
|
||||
shot() {
|
||||
local udid=$1 file=$2 screen=$3 scenario=$4 clock=$5 lang=$6
|
||||
local locale=${lang//-/_} attempt
|
||||
for attempt in 0 2 5 10; do
|
||||
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s"; sleep "$attempt"; }
|
||||
TOKEN=$((TOKEN + 1))
|
||||
xcrun simctl terminate "$udid" "$BUNDLE" >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
local extra=()
|
||||
[ "$clock" = - ] || extra=(-TrekNow "$clock")
|
||||
xcrun simctl launch "$udid" "$BUNDLE" \
|
||||
-AppleLanguages "($lang)" -AppleLocale "$locale" -TrekDemo "$screen" -TrekScenario "$scenario" \
|
||||
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || continue
|
||||
wait_ready "$udid" "$TOKEN" || continue
|
||||
# The map's tiles come over the network after the screen is up.
|
||||
[ "$screen" = place ] && sleep 6
|
||||
xcrun simctl io "$udid" screenshot --type=png "$file" >/dev/null 2>&1 && return 0
|
||||
done
|
||||
echo "gave up on $(basename "$file")"
|
||||
}
|
||||
|
||||
# name screen scenario clock: every screen, and the states a screen has; a
|
||||
# clock only where the state needs another hour than the real one.
|
||||
SHOTS="
|
||||
today today running -
|
||||
today-travelday today travelday -
|
||||
today-done today running 22:30
|
||||
day day running -
|
||||
place place running -
|
||||
booking booking travelday -
|
||||
booking-dinner booking running -
|
||||
bookings bookings travelday -
|
||||
booking-detail booking-detail travelday -
|
||||
trip trip running -
|
||||
trip-upcoming trip upcoming -
|
||||
days days running -
|
||||
countdown countdown upcoming -
|
||||
lists lists upcoming -
|
||||
packing packing upcoming -
|
||||
todos todos upcoming -
|
||||
empty empty running -
|
||||
nosync nosync running -
|
||||
faces faces running -
|
||||
"
|
||||
MAIN="today booking countdown trip lists packing day"
|
||||
LANGS="ar az ca cs de el en es et fr he hu id it ja ko nl pl pt-BR ru sk sv th tr uk vi zh-Hans zh-Hant"
|
||||
|
||||
# The watch draws its corner clock in its own region's format, not the app's:
|
||||
# English goes first on a 12 hour clock, then the watch is switched to 24
|
||||
# hours, as the other languages read it, and restarted for the clock to follow.
|
||||
use_24_hours() {
|
||||
local udid=$1
|
||||
xcrun simctl spawn "$udid" defaults write -g AppleICUForce24HourTime -bool YES
|
||||
xcrun simctl spawn "$udid" defaults write -g AppleLocale de_DE
|
||||
xcrun simctl shutdown "$udid"
|
||||
xcrun simctl boot "$udid"
|
||||
xcrun simctl bootstatus "$udid" -b >/dev/null
|
||||
}
|
||||
|
||||
EXPECTED=()
|
||||
run() {
|
||||
local size=$1 udid=$2 lang shotname screen scenario clock
|
||||
for lang in en de ru; do
|
||||
[ "$lang" = de ] && use_24_hours "$udid"
|
||||
while read -r shotname screen scenario clock; do
|
||||
[ -n "$shotname" ] || continue
|
||||
EXPECTED+=("$size-$lang-$shotname.png")
|
||||
shot "$udid" "$OUT/$size-$lang-$shotname.png" "$screen" "$scenario" "$clock" "$lang"
|
||||
done <<< "$SHOTS"
|
||||
done
|
||||
if [ "$size" = small ]; then
|
||||
for lang in $LANGS; do
|
||||
case "$lang" in en|de|ru) continue ;; esac
|
||||
while read -r shotname screen scenario clock; do
|
||||
[ -n "$shotname" ] || continue
|
||||
case " $MAIN " in
|
||||
*" $shotname "*)
|
||||
EXPECTED+=("$size-$lang-$shotname.png")
|
||||
shot "$udid" "$OUT/$size-$lang-$shotname.png" "$screen" "$scenario" "$clock" "$lang"
|
||||
;;
|
||||
esac
|
||||
done <<< "$SHOTS"
|
||||
done
|
||||
fi
|
||||
xcrun simctl shutdown "$udid" || true
|
||||
}
|
||||
|
||||
# One watch at a time: launches on several booted simulators at once is what
|
||||
# makes SpringBoard refuse them ("Scene update failed").
|
||||
set_clock
|
||||
run large "$(boot "$LARGE" | tail -1)"
|
||||
run medium "$(boot "$MEDIUM" | tail -1)"
|
||||
run small "$(boot "$SMALL" | tail -1)"
|
||||
printf '%s\n' "large: $LARGE" "medium: $MEDIUM" "small: $SMALL" > "$OUT/devices.txt"
|
||||
|
||||
missing=0
|
||||
for file in "${EXPECTED[@]}"; do
|
||||
[ -s "$OUT/$file" ] || { echo "missing: $file"; missing=$((missing + 1)); }
|
||||
done
|
||||
echo "${#EXPECTED[@]} expected, $missing missing"
|
||||
[ "$missing" -eq 0 ]
|
||||
@@ -2,14 +2,18 @@
|
||||
* NativeWidgetsSync: keeps the iOS widgets and the travel-day Live Activity in
|
||||
* step with the trips stored on this device (native/widgets.ts). Runs at start,
|
||||
* whenever the app comes back to the foreground and every quarter hour while it
|
||||
* is open, so the next stop moves on through the day. Renders nothing; outside
|
||||
* an iOS binary with the widgets it does nothing.
|
||||
* is open, so the next stop moves on through the day. The paired Apple Watch
|
||||
* gets the hero trip in full alongside (native/watch.ts), and what is ticked
|
||||
* off on the watch comes back here and goes through the trip store's toggles.
|
||||
* Renders nothing; outside an iOS binary with the widgets it does nothing.
|
||||
*/
|
||||
import { App } from '@capacitor/app';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { offlineDb } from '../../db/offlineDb';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
import { TrekShell } from '../../native/trekShell';
|
||||
import { applyWatchActions, buildWatchSnapshot, publishWatch, takeWatchActions, watchActive } from '../../native/watch';
|
||||
import { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
|
||||
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
|
||||
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
|
||||
@@ -27,6 +31,7 @@ import {
|
||||
} from '../../native/widgets';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Day, Place, TranslationFn, Trip } from '../../types';
|
||||
import { formatTime } from '../../utils/formatters';
|
||||
import { localDateStr } from '../../vacay/monthGrid';
|
||||
@@ -118,6 +123,7 @@ async function tripExtras(trip: Trip, days: Day[], { today, language, unit, time
|
||||
const dayDates = new Map(days.map((d) => [d.id, (d.date ?? '').slice(0, 10)]));
|
||||
return {
|
||||
reservations,
|
||||
lists: { places, packing, todos },
|
||||
extras: {
|
||||
place,
|
||||
cover,
|
||||
@@ -138,6 +144,8 @@ export default function NativeWidgetsSync(): null {
|
||||
const { caps } = useNativeCapabilities();
|
||||
const timeFormat = useSettingsStore((s) => s.settings.time_format);
|
||||
const unit = useSettingsStore((s) => s.settings.temperature_unit);
|
||||
/** The ids of the watch's ticks applied in this session, which the watch drops from its pending ones. */
|
||||
const applied = useRef<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetsActive()) return;
|
||||
@@ -151,7 +159,29 @@ export default function NativeWidgetsSync(): null {
|
||||
? todayOf(trip, days, context.today, (n, m) => t('native.widget.dayOf', { n, m }), context.time)
|
||||
: null;
|
||||
const day = plain ? withBookings(plain, extra?.reservations ?? [], context.today, context.time) : null;
|
||||
return { day, snapshot: buildSnapshot(trip, day, labels(t), { ...extra?.extras, atlas }) };
|
||||
return { day, days, extra, snapshot: buildSnapshot(trip, day, labels(t), { ...extra?.extras, atlas }) };
|
||||
};
|
||||
|
||||
/** The hero trip in full for the watch, with the ticks from it applied so far. */
|
||||
const publishToWatch = async (trip: Trip | null, main: Awaited<ReturnType<typeof tripSnapshot>>, today: string) => {
|
||||
if (!watchActive()) return;
|
||||
const lists = main.extra?.lists;
|
||||
await publishWatch(
|
||||
buildWatchSnapshot({
|
||||
trip,
|
||||
days: main.days,
|
||||
places: lists?.places ?? [],
|
||||
reservations: main.extra?.reservations ?? [],
|
||||
packing: lists?.packing ?? [],
|
||||
todos: lists?.todos ?? [],
|
||||
today,
|
||||
hour12: timeFormat === '12h',
|
||||
place: main.extra?.extras.place,
|
||||
cover: main.extra?.extras.cover,
|
||||
weather: main.extra?.extras.weather,
|
||||
applied: applied.current,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const sync = async () => {
|
||||
@@ -180,17 +210,30 @@ export default function NativeWidgetsSync(): null {
|
||||
? tripDayState(main.day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone'))
|
||||
: null;
|
||||
await publishWidgets(main.snapshot, live, choices);
|
||||
await publishToWatch(hero, main, today);
|
||||
};
|
||||
|
||||
void sync();
|
||||
/** The ticks made on the watch, written like a tap in the app, then the watch told. */
|
||||
const drainWatch = async () => {
|
||||
const actions = await takeWatchActions();
|
||||
if (cancelled || actions.length === 0) return;
|
||||
const { togglePackingItem, toggleTodoItem } = useTripStore.getState();
|
||||
const ids = await applyWatchActions(actions, { packing: togglePackingItem, todo: toggleTodoItem });
|
||||
applied.current = [...applied.current, ...ids].slice(-50);
|
||||
await sync();
|
||||
};
|
||||
|
||||
void sync().then(drainWatch);
|
||||
const timer = window.setInterval(() => void sync(), REFRESH_MS);
|
||||
const listener = App.addListener('appStateChange', ({ isActive }) => {
|
||||
if (isActive) void sync();
|
||||
if (isActive) void sync().then(drainWatch);
|
||||
});
|
||||
const watchListener = watchActive() ? TrekShell.addListener('watchAction', () => void drainWatch()) : null;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
void listener.then((handle) => handle.remove());
|
||||
void watchListener?.then((handle) => handle.remove());
|
||||
};
|
||||
}, [caps, t, language, timeFormat, unit]);
|
||||
|
||||
|
||||
@@ -174,7 +174,8 @@ describe('native client on Android (Google Play Billing)', () => {
|
||||
shell.storeRestore.mockResolvedValue({});
|
||||
expect(await nativeIapClient.restore()).toBeNull();
|
||||
shell.storeFinish.mockResolvedValue(undefined);
|
||||
await nativeIapClient.finish(purchase!);
|
||||
if (!purchase) throw new Error('expected the restored purchase');
|
||||
await nativeIapClient.finish(purchase);
|
||||
expect(shell.storeFinish).toHaveBeenCalledWith({ transactionId: 'token-2' });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isNativeAndroid, isNativeIos } from './platform';
|
||||
import { reportNativeFailure } from './reportFailure';
|
||||
import { TrekShell, type StoreRestoreResult } from './trekShell';
|
||||
|
||||
/**
|
||||
@@ -185,7 +186,10 @@ export const nativeIapClient: IapClient = {
|
||||
},
|
||||
onTransaction(listener) {
|
||||
if (nativeStore()?.platform !== 'google') return;
|
||||
void TrekShell.addListener('storeTransaction', () => listener()).catch(() => {});
|
||||
void TrekShell.addListener('storeTransaction', () => listener()).catch((err: unknown) => {
|
||||
// Without the listener a renewal that clears later is still picked up by the next restore; nothing to undo.
|
||||
reportNativeFailure(err);
|
||||
});
|
||||
},
|
||||
async restore() {
|
||||
try {
|
||||
|
||||
@@ -98,6 +98,12 @@ export interface TrekShellPlugin {
|
||||
addToWallet(options: { data: string }): Promise<{ result: 'added' | 'opened' | 'cancelled' }>;
|
||||
/** iOS, feature storeKit: the newest active subscription under `prefix` on this Apple ID, if any. */
|
||||
storeRestore(options: { prefix: string }): Promise<{ jws?: string; transactionId?: string }>;
|
||||
/** Feature watch: what the Apple Watch app shows (native/watch.ts), as JSON, sent on through WatchConnectivity. */
|
||||
setWatchSnapshot(options: { json: string }): Promise<void>;
|
||||
/** Feature watch: the ticks the watch sent since the last call, oldest first (parsed by parseWatchActions). */
|
||||
takeWatchActions(): Promise<{ actions: unknown[] }>;
|
||||
/** Feature watch: the watch sent a tick; takeWatchActions has it. */
|
||||
addListener(event: 'watchAction', listener: () => void): Promise<{ remove: () => Promise<void> }>;
|
||||
addListener(
|
||||
event: 'pushOpened',
|
||||
listener: (payload: { url: string }) => void
|
||||
@@ -151,7 +157,8 @@ interface NativeWindowLayout {
|
||||
orientation?: 'vertical' | 'horizontal';
|
||||
separating?: boolean;
|
||||
occluding?: boolean;
|
||||
hinge?: { left: number; top: number; right: number; bottom: number };
|
||||
/** The fold's rectangle on the screen, in CSS pixels: geometry, not text sides. */
|
||||
hinge?: Pick<DOMRectReadOnly, 'left' | 'top' | 'right' | 'bottom'>;
|
||||
}
|
||||
|
||||
/** A position, or an error shaped like GeolocationPositionError (1 denied, 2 unavailable). */
|
||||
|
||||
@@ -81,6 +81,11 @@ function setGoogleState(next: typeof googleState): void {
|
||||
for (const listener of googleListeners) listener();
|
||||
}
|
||||
|
||||
/** Read through a call, so a check after setGoogleState() sees the new state rather than a narrowed one. */
|
||||
function googleSaidYes(): boolean {
|
||||
return googleState === 'yes';
|
||||
}
|
||||
|
||||
export async function loadGoogleWalletAvailability(tripId: number): Promise<boolean> {
|
||||
if (!canAddToGoogleWallet()) return false;
|
||||
if (googleState === 'yes' || googleState === 'no') return googleState === 'yes';
|
||||
@@ -94,18 +99,15 @@ export async function loadGoogleWalletAvailability(tripId: number): Promise<bool
|
||||
// Offline: ask again next time.
|
||||
setGoogleState('unknown');
|
||||
}
|
||||
return googleState === 'yes';
|
||||
return googleSaidYes();
|
||||
}
|
||||
|
||||
/** True once this server has said Google Wallet works; asks it the first time a booking shows. */
|
||||
export function useGoogleWalletAvailable(tripId: number): boolean {
|
||||
const available = useSyncExternalStore(
|
||||
(listener) => {
|
||||
googleListeners.add(listener);
|
||||
return () => googleListeners.delete(listener);
|
||||
},
|
||||
() => googleState === 'yes'
|
||||
);
|
||||
const available = useSyncExternalStore((listener) => {
|
||||
googleListeners.add(listener);
|
||||
return () => googleListeners.delete(listener);
|
||||
}, googleSaidYes);
|
||||
useEffect(() => {
|
||||
void loadGoogleWalletAvailability(tripId);
|
||||
}, [tripId]);
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Day, PackingItem, Place, Reservation, TodoItem, Trip } from '../types';
|
||||
|
||||
vi.mock('./platform', () => ({ isNativeIos: vi.fn(() => true) }));
|
||||
vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn((feature: string) => feature === 'watch') }));
|
||||
vi.mock('./reportFailure', () => ({ reportNativeFailure: vi.fn() }));
|
||||
vi.mock('./trekShell', () => ({
|
||||
TrekShell: {
|
||||
setWatchSnapshot: vi.fn(async () => {}),
|
||||
takeWatchActions: vi.fn(async () => ({ actions: [] as unknown[] })),
|
||||
},
|
||||
}));
|
||||
|
||||
import { hasNativeFeature } from './capabilities';
|
||||
import { reportNativeFailure } from './reportFailure';
|
||||
import { TrekShell } from './trekShell';
|
||||
import {
|
||||
applyWatchActions,
|
||||
buildWatchSnapshot,
|
||||
fitWatchSnapshot,
|
||||
parseWatchActions,
|
||||
publishWatch,
|
||||
takeWatchActions,
|
||||
watchBooking,
|
||||
watchDays,
|
||||
watchPacking,
|
||||
watchTodos,
|
||||
type WatchSnapshot,
|
||||
} from './watch';
|
||||
import { upcomingReservations } from './widgetExtras';
|
||||
|
||||
const trip = { id: 7, title: 'Autumn in Kyoto', start_date: '2026-11-17', end_date: '2026-11-25' } as unknown as Trip;
|
||||
|
||||
const day = (id: number, n: number, date: string | null, stops: Record<string, unknown>[]) =>
|
||||
({
|
||||
id,
|
||||
trip_id: 7,
|
||||
day_number: n,
|
||||
date,
|
||||
assignments: stops.map((place, i) => ({ id: id * 10 + i, order_index: i, place })),
|
||||
}) as unknown as Day;
|
||||
|
||||
const reservation = (fields: Partial<Reservation> & { id: number; title: string; type: string }): Reservation =>
|
||||
({ trip_id: 7, status: 'confirmed', ...fields }) as Reservation;
|
||||
|
||||
const flight = reservation({
|
||||
id: 1,
|
||||
title: 'LH 716',
|
||||
type: 'flight',
|
||||
reservation_time: '2026-11-17T13:05',
|
||||
reservation_end_time: '2026-11-18T08:50',
|
||||
confirmation_number: ' K7Q2XD ',
|
||||
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH 716', seat: '42A' }),
|
||||
endpoints: [
|
||||
{
|
||||
role: 'to',
|
||||
sequence: 1,
|
||||
name: 'Tokyo Haneda',
|
||||
code: 'HND',
|
||||
lat: 35.5494,
|
||||
lng: 139.7798,
|
||||
timezone: 'Asia/Tokyo',
|
||||
local_time: null,
|
||||
local_date: null,
|
||||
},
|
||||
{
|
||||
role: 'from',
|
||||
sequence: 0,
|
||||
name: 'Frankfurt',
|
||||
code: 'FRA',
|
||||
lat: 50.0379,
|
||||
lng: 8.5622,
|
||||
timezone: 'Europe/Berlin',
|
||||
local_time: null,
|
||||
local_date: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const hotel = reservation({
|
||||
id: 2,
|
||||
title: 'Hotel Kanra Kyoto',
|
||||
type: 'hotel',
|
||||
day_id: 2,
|
||||
end_day_id: 3,
|
||||
accommodation_place_id: 50,
|
||||
accommodation_name: 'Hotel Kanra Kyoto',
|
||||
metadata: JSON.stringify({ check_in_time: '15:00', check_out_time: '11:00' }),
|
||||
});
|
||||
|
||||
const places = [{ id: 50, lat: 34.99, lng: 135.76, address: 'Kyoto' }] as unknown as Place[];
|
||||
|
||||
const days = [
|
||||
day(1, 1, '2026-11-17', []),
|
||||
day(2, 2, '2026-11-18', [
|
||||
{
|
||||
name: 'Fushimi Inari',
|
||||
place_time: '08:30',
|
||||
end_time: '10:30',
|
||||
lat: 34.967146,
|
||||
lng: 135.772673,
|
||||
address: '68 Fukakusa',
|
||||
category: { color: '#D6273B', icon: 'Landmark' },
|
||||
},
|
||||
{ name: 'Nishiki Market', place_time: null, lat: null, lng: null },
|
||||
]),
|
||||
day(3, 3, '2026-11-19', []),
|
||||
];
|
||||
|
||||
describe('watchDays', () => {
|
||||
it('lists the days in order with their stops, coordinates and the timed bookings of their date', () => {
|
||||
const bookings = [
|
||||
watchBooking(flight, { day: '2026-11-17', time: '13:05' }, { dayDates: new Map(), places: new Map() }),
|
||||
];
|
||||
const out = watchDays([days[2], days[0], days[1]], bookings);
|
||||
expect(out.map((d) => d.number)).toEqual([1, 2, 3]);
|
||||
expect(out[0].stops).toEqual([
|
||||
{
|
||||
name: 'LH 716',
|
||||
minutes: 785,
|
||||
symbol: 'airplane',
|
||||
booking: 'flight',
|
||||
address: 'Frankfurt',
|
||||
lat: 50.0379,
|
||||
lng: 8.5622,
|
||||
},
|
||||
]);
|
||||
expect(out[1].stops[0]).toEqual({
|
||||
name: 'Fushimi Inari',
|
||||
minutes: 510,
|
||||
endMinutes: 630,
|
||||
symbol: 'building.columns.fill',
|
||||
color: '#D6273B',
|
||||
address: '68 Fukakusa',
|
||||
lat: 34.96715,
|
||||
lng: 135.77267,
|
||||
});
|
||||
expect(out[1].stops[1]).toEqual({ name: 'Nishiki Market' });
|
||||
});
|
||||
|
||||
it('prefers the stop time set on the day over the place time', () => {
|
||||
const custom = day(4, 1, '2026-11-20', []);
|
||||
custom.assignments = [
|
||||
{ id: 1, order_index: 0, assignment_time: '09:15', place: { name: 'Gion', place_time: '18:00' } },
|
||||
] as unknown as Day['assignments'];
|
||||
expect(watchDays([custom], [])[0].stops[0].minutes).toBe(555);
|
||||
});
|
||||
});
|
||||
|
||||
describe('watchBooking', () => {
|
||||
it('carries the reference, seat, carrier, both airports and the departure zone', () => {
|
||||
const b = watchBooking(flight, { day: '2026-11-17', time: '13:05' }, { dayDates: new Map(), places: new Map() });
|
||||
expect(b).toMatchObject({
|
||||
id: 1,
|
||||
kind: 'flight',
|
||||
confirmed: true,
|
||||
date: '2026-11-17',
|
||||
time: '13:05',
|
||||
endDate: '2026-11-18',
|
||||
endTime: '08:50',
|
||||
zone: 'Europe/Berlin',
|
||||
endZone: 'Asia/Tokyo',
|
||||
code: 'K7Q2XD',
|
||||
carrier: 'Lufthansa LH 716',
|
||||
seat: '42A',
|
||||
from: { name: 'Frankfurt', code: 'FRA' },
|
||||
to: { name: 'Tokyo Haneda', code: 'HND' },
|
||||
});
|
||||
});
|
||||
|
||||
it('gives a stay its check-in, check-out, dates from its days and the hotel as the place to go', () => {
|
||||
const dayDates = new Map([
|
||||
[2, '2026-11-18'],
|
||||
[3, '2026-11-19'],
|
||||
]);
|
||||
const b = watchBooking(hotel, { day: '2026-11-18', time: null }, { dayDates, places: new Map([[50, places[0]]]) });
|
||||
expect(b).toMatchObject({
|
||||
kind: 'hotel',
|
||||
time: '15:00',
|
||||
endDate: '2026-11-19',
|
||||
endTime: '11:00',
|
||||
checkIn: '15:00',
|
||||
checkOut: '11:00',
|
||||
location: 'Hotel Kanra Kyoto',
|
||||
lat: 34.99,
|
||||
lng: 135.76,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('upcomingReservations', () => {
|
||||
it('keeps a stay that is running when asked to', () => {
|
||||
const dayDates = new Map([
|
||||
[2, '2026-11-18'],
|
||||
[3, '2026-11-19'],
|
||||
]);
|
||||
expect(upcomingReservations([hotel], { dayDates, today: '2026-11-19' })).toEqual([]);
|
||||
expect(upcomingReservations([hotel], { dayDates, today: '2026-11-19', running: true })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the arrival zone', () => {
|
||||
it('falls back to the arrival timezone in the metadata and is left out without one', () => {
|
||||
const noEndpoints = reservation({
|
||||
id: 9,
|
||||
title: 'LH 717',
|
||||
type: 'flight',
|
||||
reservation_time: '2026-11-25T10:00',
|
||||
metadata: JSON.stringify({ departure_timezone: 'Asia/Tokyo', arrival_timezone: 'Europe/Berlin' }),
|
||||
});
|
||||
const b = watchBooking(
|
||||
noEndpoints,
|
||||
{ day: '2026-11-25', time: '10:00' },
|
||||
{ dayDates: new Map(), places: new Map() }
|
||||
);
|
||||
expect(b).toMatchObject({ zone: 'Asia/Tokyo', endZone: 'Europe/Berlin' });
|
||||
const plain = watchBooking(hotel, { day: '2026-11-18', time: null }, { dayDates: new Map(), places: new Map() });
|
||||
expect(plain).not.toHaveProperty('endZone');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the lists', () => {
|
||||
it('puts the packing still to do first and counts what is packed', () => {
|
||||
const items = [
|
||||
{ id: 1, name: 'Passport', checked: 1, category: 'Documents', sort_order: 0 },
|
||||
{ id: 2, name: 'Rain jacket', checked: 0, category: 'Clothes', sort_order: 1 },
|
||||
] as PackingItem[];
|
||||
expect(watchPacking(items)).toEqual({
|
||||
items: [
|
||||
{ id: 2, name: 'Rain jacket', done: false, group: 'Clothes' },
|
||||
{ id: 1, name: 'Passport', done: true, group: 'Documents' },
|
||||
],
|
||||
packed: 1,
|
||||
total: 2,
|
||||
});
|
||||
expect(watchPacking([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('orders the to-dos by done, due date, then priority', () => {
|
||||
const todo = (id: number, fields: Partial<TodoItem>) =>
|
||||
({
|
||||
id,
|
||||
name: `T${id}`,
|
||||
checked: 0,
|
||||
due_date: null,
|
||||
priority: 0,
|
||||
sort_order: id,
|
||||
category: null,
|
||||
...fields,
|
||||
}) as TodoItem;
|
||||
const out = watchTodos([
|
||||
todo(1, { checked: 1 }),
|
||||
todo(2, {}),
|
||||
todo(3, { due_date: '2026-11-15', priority: 1 }),
|
||||
todo(4, { priority: 3 }),
|
||||
todo(5, { due_date: '2026-11-10' }),
|
||||
]);
|
||||
expect(out?.items.map((i) => i.id)).toEqual([5, 3, 4, 2, 1]);
|
||||
expect(out?.items[1]).toEqual({ id: 3, name: 'T3', done: false, due: '2026-11-15', priority: 1 });
|
||||
expect(out?.open).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWatchSnapshot', () => {
|
||||
const src = {
|
||||
trip,
|
||||
days,
|
||||
places,
|
||||
reservations: [
|
||||
flight,
|
||||
hotel,
|
||||
reservation({ id: 3, title: 'Old', type: 'tour', reservation_time: '2026-11-01T10:00' }),
|
||||
],
|
||||
packing: [],
|
||||
todos: [],
|
||||
today: '2026-11-07',
|
||||
hour12: false,
|
||||
place: 'Kyoto, Japan',
|
||||
applied: ['a1'],
|
||||
now: new Date('2026-11-07T10:00:00Z'),
|
||||
};
|
||||
|
||||
it('carries the trip, its days, the bookings ahead and the applied ticks', () => {
|
||||
const snapshot = buildWatchSnapshot(src);
|
||||
expect(snapshot.v).toBe(1);
|
||||
expect(snapshot.generatedAt).toBe('2026-11-07T10:00:00.000Z');
|
||||
expect(snapshot.trip).toEqual({
|
||||
id: 7,
|
||||
title: 'Autumn in Kyoto',
|
||||
start: '2026-11-17',
|
||||
end: '2026-11-25',
|
||||
place: 'Kyoto, Japan',
|
||||
});
|
||||
expect(snapshot.bookings.map((b) => b.id)).toEqual([1, 2]);
|
||||
expect(snapshot.days).toHaveLength(3);
|
||||
expect(snapshot.applied).toEqual(['a1']);
|
||||
expect(snapshot).not.toHaveProperty('packing');
|
||||
});
|
||||
|
||||
it('is an empty snapshot without a trip', () => {
|
||||
expect(buildWatchSnapshot({ ...src, trip: null })).toMatchObject({ trip: null, days: [], bookings: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fitWatchSnapshot', () => {
|
||||
const big = (): WatchSnapshot => ({
|
||||
v: 1,
|
||||
generatedAt: '',
|
||||
hour12: false,
|
||||
trip: { id: 1, title: 'T', start: '', end: '', cover: 'x'.repeat(20_000) },
|
||||
days: [],
|
||||
bookings: [],
|
||||
packing: {
|
||||
items: Array.from({ length: 400 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `Item number ${i} with a long name`,
|
||||
done: i % 2 === 0,
|
||||
})),
|
||||
packed: 200,
|
||||
total: 400,
|
||||
},
|
||||
applied: [],
|
||||
});
|
||||
|
||||
it('leaves a small snapshot alone', () => {
|
||||
const small = { ...big(), packing: undefined, trip: null };
|
||||
expect(fitWatchSnapshot(small)).toBe(small);
|
||||
});
|
||||
|
||||
it('drops the cover, then the done items, then halves the lists until it fits', () => {
|
||||
const fitted = fitWatchSnapshot(big(), 6_000);
|
||||
expect(fitted.trip?.cover).toBeUndefined();
|
||||
expect(fitted.packing?.items.every((i) => !i.done)).toBe(true);
|
||||
expect(JSON.stringify(fitted).length).toBeLessThanOrEqual(6_000);
|
||||
expect(fitted.packing?.total).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the watch actions', () => {
|
||||
it('keeps only well-formed ticks', () => {
|
||||
const good = { id: 'u1', tripId: 7, list: 'packing', itemId: 3, done: true };
|
||||
expect(
|
||||
parseWatchActions([good, { ...good, list: 'budget' }, { ...good, itemId: '3' }, null, 'x', { ...good, id: '' }])
|
||||
).toEqual([good]);
|
||||
expect(parseWatchActions({})).toEqual([]);
|
||||
});
|
||||
|
||||
it('applies them in order through the toggles and answers their ids, failed ones included', async () => {
|
||||
const calls: string[] = [];
|
||||
const toggles = {
|
||||
packing: vi.fn(async (trip: number, id: number, done: boolean) => {
|
||||
calls.push(`p${trip}:${id}:${done}`);
|
||||
}),
|
||||
todo: vi.fn(async () => {
|
||||
calls.push('t');
|
||||
throw new Error('offline');
|
||||
}),
|
||||
};
|
||||
const ids = await applyWatchActions(
|
||||
[
|
||||
{ id: 'a', tripId: 7, list: 'packing', itemId: 1, done: true },
|
||||
{ id: 'b', tripId: 7, list: 'todo', itemId: 2, done: false },
|
||||
],
|
||||
toggles
|
||||
);
|
||||
expect(calls).toEqual(['p7:1:true', 't']);
|
||||
expect(ids).toEqual(['a', 'b']);
|
||||
expect(reportNativeFailure).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('takes them from the app, and nothing on a binary without the watch', async () => {
|
||||
vi.mocked(TrekShell.takeWatchActions).mockResolvedValueOnce({
|
||||
actions: [{ id: 'z', tripId: 1, list: 'todo', itemId: 9, done: true }, { bad: 1 }],
|
||||
});
|
||||
expect(await takeWatchActions()).toEqual([{ id: 'z', tripId: 1, list: 'todo', itemId: 9, done: true }]);
|
||||
vi.mocked(hasNativeFeature).mockReturnValueOnce(false);
|
||||
expect(await takeWatchActions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishWatch', () => {
|
||||
it('hands the fitted snapshot to the app', async () => {
|
||||
const snapshot = buildWatchSnapshot({
|
||||
trip: null,
|
||||
days: [],
|
||||
places: [],
|
||||
reservations: [],
|
||||
packing: [],
|
||||
todos: [],
|
||||
today: '2026-11-07',
|
||||
hour12: true,
|
||||
now: new Date('2026-11-07T10:00:00Z'),
|
||||
});
|
||||
await publishWatch(snapshot);
|
||||
expect(TrekShell.setWatchSnapshot).toHaveBeenCalledWith({ json: JSON.stringify(snapshot) });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
import type { Day, PackingItem, Place, Reservation, TodoItem, Trip } from '../types';
|
||||
import { parseReservationMetadata } from '../utils/flightLegs';
|
||||
import { splitReservationDateTime } from '../utils/formatters';
|
||||
import { hasNativeFeature } from './capabilities';
|
||||
import { isNativeIos } from './platform';
|
||||
import { reportNativeFailure } from './reportFailure';
|
||||
import { TrekShell } from './trekShell';
|
||||
import { bookingKind, stopLook, upcomingReservations } from './widgetExtras';
|
||||
import { RESERVATION_SYMBOLS, mergeByTime, minutesOf, type WidgetWeather } from './widgets';
|
||||
|
||||
/**
|
||||
* What the Apple Watch app shows (client/ios/App/TREKWatch), built from the
|
||||
* trips this device keeps offline, like the widgets' snapshot, and handed to
|
||||
* the app (TrekShell.setWatchSnapshot), which passes it on through
|
||||
* WatchConnectivity. The watch has no session of its own, so this carries the
|
||||
* whole trip it needs: every day with its stops and their coordinates, the
|
||||
* bookings with their reference, seat and platform, the packing list and the
|
||||
* to-dos with their ids. It carries data only; the watch says everything in its
|
||||
* own languages and formats the times itself.
|
||||
*
|
||||
* What the watch ticks off comes back as WatchActions (parseWatchActions) and
|
||||
* goes through the trip store's toggles, i.e. the repos and the mutation queue,
|
||||
* exactly as a tap in the app would. The Swift side of the shape is
|
||||
* client/ios/App/TREKWatchKit/WatchSnapshot.swift.
|
||||
*/
|
||||
|
||||
interface WatchStop {
|
||||
name: string;
|
||||
/** Minutes after midnight, and the end when the stop has one. */
|
||||
minutes?: number;
|
||||
endMinutes?: number;
|
||||
symbol?: string;
|
||||
color?: string;
|
||||
address?: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
/** Set for a booking on the day's timeline: its kind (flight, hotel, ...). */
|
||||
booking?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface WatchDay {
|
||||
/** yyyy-MM-dd; missing on a day without a date. */
|
||||
date?: string;
|
||||
/** 1-based position in the trip. */
|
||||
number: number;
|
||||
title?: string;
|
||||
stops: WatchStop[];
|
||||
}
|
||||
|
||||
interface WatchPoint {
|
||||
name: string;
|
||||
code?: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
}
|
||||
|
||||
interface WatchBooking {
|
||||
id: number;
|
||||
title: string;
|
||||
/** flight, train, bus, car, cruise, hotel, restaurant, event, tour, other */
|
||||
kind: string;
|
||||
confirmed: boolean;
|
||||
/** yyyy-MM-dd and HH:mm (24 hours) of the start, and of the end. */
|
||||
date?: string;
|
||||
time?: string;
|
||||
endDate?: string;
|
||||
endTime?: string;
|
||||
/** The IANA zone the start is local to, when the booking names one (a departure airport). */
|
||||
zone?: string;
|
||||
/** The zone the end is local to (the arrival airport), so a long flight lands at the right hour. */
|
||||
endZone?: string;
|
||||
from?: WatchPoint;
|
||||
to?: WatchPoint;
|
||||
/** The booking reference. */
|
||||
code?: string;
|
||||
/** "Lufthansa LH 716", "ICE 597". */
|
||||
carrier?: string;
|
||||
seat?: string;
|
||||
platform?: string;
|
||||
/** Where it is: the hotel, the restaurant's address. */
|
||||
location?: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
/** A stay's check-in and check-out times, HH:mm. */
|
||||
checkIn?: string;
|
||||
checkOut?: string;
|
||||
}
|
||||
|
||||
interface WatchItem {
|
||||
id: number;
|
||||
name: string;
|
||||
done: boolean;
|
||||
/** The packing category or to-do category. */
|
||||
group?: string;
|
||||
/** yyyy-MM-dd */
|
||||
due?: string;
|
||||
/** 0 none ... 3 high. */
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface WatchSnapshot {
|
||||
v: 1;
|
||||
/** When the phone built it, ISO 8601. */
|
||||
generatedAt: string;
|
||||
/** The user's clock in TREK's settings. */
|
||||
hour12: boolean;
|
||||
trip: {
|
||||
id: number;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
place?: string;
|
||||
/** The cover as a base64 JPEG; the app scales it down for the watch. */
|
||||
cover?: string;
|
||||
} | null;
|
||||
days: WatchDay[];
|
||||
bookings: WatchBooking[];
|
||||
weather?: WidgetWeather;
|
||||
packing?: { items: WatchItem[]; packed: number; total: number };
|
||||
todos?: { items: WatchItem[]; open: number; total: number };
|
||||
/** The ids of the watch's own actions this phone has applied, newest last. */
|
||||
applied: string[];
|
||||
}
|
||||
|
||||
/** A tick from the watch: one packing item or to-do set done or not. */
|
||||
interface WatchAction {
|
||||
id: string;
|
||||
tripId: number;
|
||||
list: 'packing' | 'todo';
|
||||
itemId: number;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
const MAX_DAYS = 45;
|
||||
const MAX_STOPS = 24;
|
||||
const MAX_BOOKINGS = 12;
|
||||
const MAX_NAME = 80;
|
||||
|
||||
const text = (value: string | null | undefined, max = MAX_NAME): string | undefined => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed.slice(0, max) : undefined;
|
||||
};
|
||||
|
||||
const coordinate = (value: number | null | undefined): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? Math.round(value * 1e5) / 1e5 : undefined;
|
||||
|
||||
/** Leaves out what is undefined, so the JSON the watch gets stays small. */
|
||||
function compact<T extends object>(value: T): T {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T;
|
||||
}
|
||||
|
||||
function placeStop(a: NonNullable<Day['assignments']>[number]): WatchStop | null {
|
||||
const place = a.place;
|
||||
if (!place?.name) return null;
|
||||
const start = minutesOf(a.assignment_time ?? place.place_time);
|
||||
const end = minutesOf(a.assignment_end_time ?? place.end_time);
|
||||
const look = stopLook(place.category);
|
||||
return compact({
|
||||
name: place.name.slice(0, MAX_NAME),
|
||||
minutes: start ?? undefined,
|
||||
endMinutes: start != null && end != null && end > start ? end : undefined,
|
||||
symbol: look.symbol ?? undefined,
|
||||
color: look.color ?? undefined,
|
||||
address: text(place.address, 120),
|
||||
lat: coordinate(place.lat),
|
||||
lng: coordinate(place.lng),
|
||||
note: text(a.notes, 140),
|
||||
});
|
||||
}
|
||||
|
||||
/** The trip's days in order, each with its stops and the timed bookings of its date merged in by time. */
|
||||
export function watchDays(days: Day[], bookings: WatchBooking[]): WatchDay[] {
|
||||
return [...days]
|
||||
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
.slice(0, MAX_DAYS)
|
||||
.map((day, index) => {
|
||||
const date = day.date ? day.date.slice(0, 10) : undefined;
|
||||
const stops = [...(day.assignments ?? [])]
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.map(placeStop)
|
||||
.filter((s): s is WatchStop => s !== null);
|
||||
const booked = date
|
||||
? bookings
|
||||
.filter((b) => b.date === date && b.time)
|
||||
.map((b) =>
|
||||
compact<WatchStop>({
|
||||
name: b.title,
|
||||
minutes: minutesOf(b.time) ?? undefined,
|
||||
symbol: RESERVATION_SYMBOLS[b.kind] ?? 'bookmark.fill',
|
||||
booking: b.kind,
|
||||
address: b.from?.name ?? b.location,
|
||||
lat: b.from?.lat ?? b.lat,
|
||||
lng: b.from?.lng ?? b.lng,
|
||||
})
|
||||
)
|
||||
: [];
|
||||
return compact({
|
||||
date,
|
||||
number: index + 1,
|
||||
title: text(day.title),
|
||||
stops: mergeByTime(stops, booked).slice(0, MAX_STOPS),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function point(e: NonNullable<Reservation['endpoints']>[number] | undefined): WatchPoint | undefined {
|
||||
if (!e) return undefined;
|
||||
return compact({
|
||||
name: e.name.slice(0, MAX_NAME),
|
||||
code: text(e.code, 8),
|
||||
lat: coordinate(e.lat),
|
||||
lng: coordinate(e.lng),
|
||||
});
|
||||
}
|
||||
|
||||
/** "Lufthansa LH 716" from the flight's airline and number, "ICE 597" from a train's. */
|
||||
function carrierOf(meta: Record<string, unknown>): string | undefined {
|
||||
const str = (key: string) => (typeof meta[key] === 'string' ? (meta[key] as string).trim() : '');
|
||||
const number = str('flight_number') || str('train_number');
|
||||
const joined = [str('airline'), number].filter(Boolean).join(' ');
|
||||
return text(joined, 40);
|
||||
}
|
||||
|
||||
type PlaceLookup = Map<number, Pick<Place, 'lat' | 'lng' | 'address'>>;
|
||||
|
||||
const metaText = (meta: Record<string, unknown>, key: string, max = 20) =>
|
||||
typeof meta[key] === 'string' ? text(meta[key] as string, max) : undefined;
|
||||
|
||||
/** Where a booking is: its hotel or place with coordinates, else what its location field says. */
|
||||
function whereOf(r: Reservation, places: PlaceLookup): Pick<WatchBooking, 'location' | 'lat' | 'lng'> {
|
||||
const placeId = r.accommodation_place_id ?? r.place_id;
|
||||
const place = placeId == null ? undefined : places.get(placeId);
|
||||
const named = r.accommodation_name || r.location || r.place_name;
|
||||
return { location: text(named || place?.address), lat: coordinate(place?.lat), lng: coordinate(place?.lng) };
|
||||
}
|
||||
|
||||
/** When a booking starts and ends; a stay without times of its own takes its check-in and check-out. */
|
||||
function timesOf(
|
||||
r: Reservation,
|
||||
start: { day: string | null; time: string | null },
|
||||
meta: Record<string, unknown>,
|
||||
dayDates: Map<number, string>
|
||||
): Pick<WatchBooking, 'date' | 'time' | 'endDate' | 'endTime'> {
|
||||
const end = splitReservationDateTime(r.reservation_end_time);
|
||||
const endDay = r.end_day_id == null ? undefined : dayDates.get(r.end_day_id);
|
||||
return {
|
||||
date: start.day ?? undefined,
|
||||
time: start.time ?? metaText(meta, 'check_in_time'),
|
||||
endDate: end.date ?? endDay,
|
||||
endTime: end.time ?? metaText(meta, 'check_out_time'),
|
||||
};
|
||||
}
|
||||
|
||||
/** A booking with what the watch shows of it: the reference, seat, platform, both ends and where to go. */
|
||||
export function watchBooking(
|
||||
r: Reservation,
|
||||
start: { day: string | null; time: string | null },
|
||||
{ dayDates, places }: { dayDates: Map<number, string>; places: PlaceLookup }
|
||||
): WatchBooking {
|
||||
const meta = parseReservationMetadata(r);
|
||||
const legs = [...(r.endpoints ?? [])].sort((a, b) => a.sequence - b.sequence);
|
||||
const from = legs.find((e) => e.role === 'from');
|
||||
const to = [...legs].reverse().find((e) => e.role === 'to');
|
||||
return compact({
|
||||
id: r.id,
|
||||
title: r.title.slice(0, MAX_NAME),
|
||||
kind: bookingKind(r.type),
|
||||
confirmed: r.status === 'confirmed',
|
||||
...timesOf(r, start, meta, dayDates),
|
||||
zone: text(from?.timezone || metaText(meta, 'departure_timezone', 40), 40),
|
||||
endZone: text(to?.timezone || metaText(meta, 'arrival_timezone', 40), 40),
|
||||
from: point(from),
|
||||
to: point(to),
|
||||
code: text(r.confirmation_number, 40),
|
||||
carrier: carrierOf(meta),
|
||||
seat: metaText(meta, 'seat'),
|
||||
platform: metaText(meta, 'platform'),
|
||||
...whereOf(r, places),
|
||||
checkIn: metaText(meta, 'check_in_time'),
|
||||
checkOut: metaText(meta, 'check_out_time'),
|
||||
});
|
||||
}
|
||||
|
||||
/** The packing list, items still to pack first, each group in the list's order. */
|
||||
export function watchPacking(items: PackingItem[]): WatchSnapshot['packing'] {
|
||||
if (items.length === 0) return undefined;
|
||||
const sorted = [...items].sort((a, b) => Number(!!a.checked) - Number(!!b.checked) || a.sort_order - b.sort_order);
|
||||
return {
|
||||
items: sorted.map((i) =>
|
||||
compact({ id: i.id, name: i.name.slice(0, MAX_NAME), done: !!i.checked, group: text(i.category, 40) })
|
||||
),
|
||||
packed: items.filter((i) => i.checked).length,
|
||||
total: items.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The to-dos, open ones first by due date (undated last) and priority, then the done ones. */
|
||||
export function watchTodos(items: TodoItem[]): WatchSnapshot['todos'] {
|
||||
if (items.length === 0) return undefined;
|
||||
const rank = (i: TodoItem) => [i.checked ? 1 : 0, i.due_date ? 0 : 1] as const;
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
const [doneA, undatedA] = rank(a);
|
||||
const [doneB, undatedB] = rank(b);
|
||||
if (doneA !== doneB) return doneA - doneB;
|
||||
if (undatedA !== undatedB) return undatedA - undatedB;
|
||||
if (a.due_date && b.due_date && a.due_date !== b.due_date) return a.due_date.localeCompare(b.due_date);
|
||||
return (b.priority ?? 0) - (a.priority ?? 0) || (a.sort_order ?? 0) - (b.sort_order ?? 0);
|
||||
});
|
||||
return {
|
||||
items: sorted.map((i) =>
|
||||
compact({
|
||||
id: i.id,
|
||||
name: i.name.slice(0, MAX_NAME),
|
||||
done: !!i.checked,
|
||||
group: text(i.category, 40),
|
||||
due: i.due_date ? i.due_date.slice(0, 10) : undefined,
|
||||
priority: i.priority ? Math.max(0, Math.min(3, i.priority)) : undefined,
|
||||
})
|
||||
),
|
||||
open: items.filter((i) => !i.checked).length,
|
||||
total: items.length,
|
||||
};
|
||||
}
|
||||
|
||||
interface WatchSources {
|
||||
trip: Trip | null;
|
||||
days: Day[];
|
||||
places: Place[];
|
||||
reservations: Reservation[];
|
||||
packing: PackingItem[];
|
||||
todos: TodoItem[];
|
||||
today: string;
|
||||
hour12: boolean;
|
||||
place?: string | null;
|
||||
cover?: string | null;
|
||||
weather?: WidgetWeather | null;
|
||||
applied?: string[];
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export function buildWatchSnapshot(src: WatchSources): WatchSnapshot {
|
||||
const { trip } = src;
|
||||
const base = {
|
||||
v: 1 as const,
|
||||
generatedAt: (src.now ?? new Date()).toISOString(),
|
||||
hour12: src.hour12,
|
||||
applied: src.applied ?? [],
|
||||
};
|
||||
if (!trip) return { ...base, trip: null, days: [], bookings: [] };
|
||||
const dayDates = new Map(src.days.map((d) => [d.id, (d.date ?? '').slice(0, 10)]));
|
||||
const places = new Map(src.places.map((p) => [p.id, p]));
|
||||
const dated = upcomingReservations(src.reservations, { dayDates, today: src.today, running: true });
|
||||
const bookings = dated.map(({ r, day, time }) => watchBooking(r, { day, time }, { dayDates, places }));
|
||||
// The days get every booking of their date; the list only the ones ahead.
|
||||
const all = upcomingReservations(src.reservations, { dayDates, today: '0000-00-00' }).map(({ r, day, time }) =>
|
||||
watchBooking(r, { day, time }, { dayDates, places })
|
||||
);
|
||||
return compact({
|
||||
...base,
|
||||
trip: compact({
|
||||
id: trip.id,
|
||||
title: trip.title,
|
||||
start: trip.start_date ?? '',
|
||||
end: trip.end_date ?? '',
|
||||
place: text(src.place),
|
||||
cover: src.cover ?? undefined,
|
||||
}),
|
||||
days: watchDays(src.days, all),
|
||||
bookings: bookings.slice(0, MAX_BOOKINGS),
|
||||
weather: src.weather ?? undefined,
|
||||
packing: watchPacking(src.packing),
|
||||
todos: watchTodos(src.todos),
|
||||
});
|
||||
}
|
||||
|
||||
const bytes = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)).length;
|
||||
|
||||
/**
|
||||
* The snapshot cut down to `max` bytes of JSON, for WatchConnectivity's
|
||||
* payload limit: first the cover goes (the app adds a small one back itself
|
||||
* when it fits), then the done to-dos and packed items, then the lists are
|
||||
* halved, then the days far from today.
|
||||
*/
|
||||
export function fitWatchSnapshot(snapshot: WatchSnapshot, max = 48_000): WatchSnapshot {
|
||||
let fitted = snapshot;
|
||||
const steps: ((s: WatchSnapshot) => WatchSnapshot)[] = [
|
||||
(s) => (s.trip?.cover ? { ...s, trip: { ...s.trip, cover: undefined } } : s),
|
||||
(s) => ({
|
||||
...s,
|
||||
packing: s.packing && { ...s.packing, items: s.packing.items.filter((i) => !i.done) },
|
||||
todos: s.todos && { ...s.todos, items: s.todos.items.filter((i) => !i.done) },
|
||||
}),
|
||||
...[0, 1, 2, 3].map(() => (s: WatchSnapshot) => ({
|
||||
...s,
|
||||
packing: s.packing && { ...s.packing, items: s.packing.items.slice(0, Math.ceil(s.packing.items.length / 2)) },
|
||||
todos: s.todos && { ...s.todos, items: s.todos.items.slice(0, Math.ceil(s.todos.items.length / 2)) },
|
||||
})),
|
||||
(s) => ({
|
||||
...s,
|
||||
days: s.days.map((d) => ({ ...d, stops: d.stops.map((stop) => ({ ...stop, note: undefined })) })),
|
||||
}),
|
||||
(s) => ({ ...s, days: s.days.slice(0, 14) }),
|
||||
];
|
||||
for (const step of steps) {
|
||||
if (bytes(fitted) <= max) break;
|
||||
fitted = step(fitted);
|
||||
}
|
||||
return fitted;
|
||||
}
|
||||
|
||||
export function watchActive(): boolean {
|
||||
return isNativeIos() && hasNativeFeature('watch');
|
||||
}
|
||||
|
||||
export async function publishWatch(snapshot: WatchSnapshot): Promise<void> {
|
||||
if (!watchActive()) return;
|
||||
await TrekShell.setWatchSnapshot({ json: JSON.stringify(fitWatchSnapshot(snapshot)) }).catch(reportNativeFailure);
|
||||
}
|
||||
|
||||
/** The actions the watch queued, as the app hands them over; anything malformed is dropped. */
|
||||
export function parseWatchActions(raw: unknown): WatchAction[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.flatMap((entry): WatchAction[] => {
|
||||
if (typeof entry !== 'object' || entry === null) return [];
|
||||
const { id, tripId, list, itemId, done } = entry as Record<string, unknown>;
|
||||
const valid =
|
||||
typeof id === 'string' &&
|
||||
id.length > 0 &&
|
||||
id.length <= 64 &&
|
||||
Number.isInteger(tripId) &&
|
||||
(list === 'packing' || list === 'todo') &&
|
||||
Number.isInteger(itemId) &&
|
||||
typeof done === 'boolean';
|
||||
return valid ? [{ id, tripId: tripId as number, list, itemId: itemId as number, done }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
interface WatchToggles {
|
||||
packing: (tripId: number, id: number, done: boolean) => Promise<void>;
|
||||
todo: (tripId: number, id: number, done: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the watch's ticks in the order they were made, through the given
|
||||
* toggles (the trip store's, which write through the repos and the mutation
|
||||
* queue). Answers the ids it applied; a tick whose write failed is applied too,
|
||||
* as the toggle has already rolled it back and told the user.
|
||||
*/
|
||||
export async function applyWatchActions(actions: WatchAction[], toggles: WatchToggles): Promise<string[]> {
|
||||
const applied: string[] = [];
|
||||
for (const action of actions) {
|
||||
try {
|
||||
await toggles[action.list](action.tripId, action.itemId, action.done);
|
||||
} catch (err: unknown) {
|
||||
reportNativeFailure(err);
|
||||
}
|
||||
applied.push(action.id);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/** Takes the ticks the watch sent while the UI was not running, and with them the ones just arriving. */
|
||||
export async function takeWatchActions(): Promise<WatchAction[]> {
|
||||
if (!watchActive()) return [];
|
||||
try {
|
||||
const { actions } = await TrekShell.takeWatchActions();
|
||||
return parseWatchActions(actions);
|
||||
} catch (err: unknown) {
|
||||
reportNativeFailure(err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -137,10 +137,49 @@ function bookingDetail(r: Reservation): string | null {
|
||||
/** Transport bookings open on the trip's transports tab, the rest on its bookings tab. */
|
||||
const TRANSPORT_KINDS = new Set(['flight', 'train', 'bus', 'car', 'cruise']);
|
||||
|
||||
/** A booking with the day it falls on and its start time, as the widgets order them. */
|
||||
interface DatedReservation {
|
||||
r: Reservation;
|
||||
/** yyyy-MM-dd: its own date, else the date of the day it is placed on. */
|
||||
day: string | null;
|
||||
/** HH:mm, 24 hours. */
|
||||
time: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip's bookings from today on, soonest first: those without a date at
|
||||
* the end, cancelled ones and drafts an import still waits on left out.
|
||||
* the end, cancelled ones and drafts an import still waits on left out. With
|
||||
* `running`, a booking that started before today but ends today or later (the
|
||||
* hotel the trip sleeps in) stays in.
|
||||
*/
|
||||
export function upcomingReservations(
|
||||
reservations: Reservation[],
|
||||
{ dayDates, today, running = false }: { dayDates: Map<number, string>; today: string; running?: boolean }
|
||||
): DatedReservation[] {
|
||||
const dayOf = (date: string | null, dayId: number | null | undefined) =>
|
||||
date ?? (dayId != null ? (dayDates.get(dayId) ?? null) : null);
|
||||
return reservations
|
||||
.filter((r) => r.status !== 'cancelled' && (r.ingest_state ?? 'live') === 'live')
|
||||
.map((r) => {
|
||||
const { date, time } = splitReservationDateTime(r.reservation_time);
|
||||
const day = dayOf(date, r.day_id);
|
||||
const end = running ? dayOf(splitReservationDateTime(r.reservation_end_time).date, r.end_day_id) : null;
|
||||
return { r, day, time, end };
|
||||
})
|
||||
.filter(({ day, end }) => day == null || day >= today || (end != null && end >= today))
|
||||
.sort((a, b) => {
|
||||
if (a.day !== b.day) return a.day == null ? 1 : b.day == null ? -1 : a.day.localeCompare(b.day);
|
||||
return (a.time ?? '99:99').localeCompare(b.time ?? '99:99');
|
||||
})
|
||||
.map(({ r, day, time }) => ({ r, day, time }));
|
||||
}
|
||||
|
||||
/** The kind a booking type is drawn as. */
|
||||
export function bookingKind(type: string): string {
|
||||
return KINDS[type] ?? 'other';
|
||||
}
|
||||
|
||||
/** The trip's bookings from today on, soonest first, as the widgets show them. */
|
||||
export function bookingsAhead(
|
||||
reservations: Reservation[],
|
||||
{
|
||||
@@ -158,21 +197,10 @@ export function bookingsAhead(
|
||||
limit?: number;
|
||||
}
|
||||
): WidgetBooking[] {
|
||||
return reservations
|
||||
.filter((r) => r.status !== 'cancelled' && (r.ingest_state ?? 'live') === 'live')
|
||||
.map((r) => {
|
||||
const { date, time } = splitReservationDateTime(r.reservation_time);
|
||||
const day = date ?? (r.day_id != null ? (dayDates.get(r.day_id) ?? null) : null);
|
||||
return { r, day, time };
|
||||
})
|
||||
.filter(({ day }) => day == null || day >= today)
|
||||
.sort((a, b) => {
|
||||
if (a.day !== b.day) return a.day == null ? 1 : b.day == null ? -1 : a.day.localeCompare(b.day);
|
||||
return (a.time ?? '99:99').localeCompare(b.time ?? '99:99');
|
||||
})
|
||||
return upcomingReservations(reservations, { dayDates, today })
|
||||
.slice(0, limit)
|
||||
.map(({ r, day, time }) => {
|
||||
const kind = KINDS[r.type] ?? 'other';
|
||||
const kind = bookingKind(r.type);
|
||||
return {
|
||||
title: r.title,
|
||||
kind,
|
||||
|
||||
@@ -130,7 +130,7 @@ export function widgetsActive(): boolean {
|
||||
return isNativeApp() && hasNativeFeature('widgets');
|
||||
}
|
||||
|
||||
function minutesOf(time: string | null | undefined): number | null {
|
||||
export function minutesOf(time: string | null | undefined): number | null {
|
||||
const m = /^(\d{1,2}):(\d{2})/.exec(time ?? '');
|
||||
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
|
||||
}
|
||||
@@ -156,7 +156,7 @@ export function widgetTripChoices(trips: Trip[], today: string, limit = 6): Trip
|
||||
}
|
||||
|
||||
/** The symbols of the booking kinds a day's timeline shows next to its stops. */
|
||||
const RESERVATION_SYMBOLS: Record<string, string> = {
|
||||
export const RESERVATION_SYMBOLS: Record<string, string> = {
|
||||
flight: 'airplane',
|
||||
train: 'tram.fill',
|
||||
bus: 'bus.fill',
|
||||
@@ -170,7 +170,7 @@ const RESERVATION_SYMBOLS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Puts each timed booking before the first stop that comes later; untimed stops keep their place. */
|
||||
function mergeByTime(stops: WidgetStop[], booked: WidgetStop[]): WidgetStop[] {
|
||||
export function mergeByTime<T extends { minutes?: number | null }>(stops: T[], booked: T[]): T[] {
|
||||
const out = [...stops];
|
||||
for (const b of [...booked].sort((x, y) => (x.minutes ?? 0) - (y.minutes ?? 0))) {
|
||||
const at = out.findIndex((s) => s.minutes != null && s.minutes > (b.minutes ?? 0));
|
||||
|
||||
Reference in New Issue
Block a user