feat(native): home-screen widget and travel-day Live Activity on iOS

A WidgetKit extension (TREKWidgets) with two parts: the next trip on the home
screen, with a countdown before it starts and today's day and next stops
while it runs, and a Live Activity for the travel day on the lock screen and
in the Dynamic Island showing the next timed stop and the day's progress.

The web app builds both from the trips stored offline on the device
(native/widgets.ts) and hands them over through TrekShell
(setWidgetSnapshot, syncTripDay, endTripDay). The texts are already
translated, so the widget needs no localisation of its own. The snapshot
lives in the keychain group the app shares with its extensions, so no App
Group is needed. Taps open com.liketrek.trek://navigate/<path>.

NativeWidgetsSync refreshes on start, on returning to the foreground and every
quarter hour. Both parts are gated behind the widgets / liveActivity
capabilities, so older binaries and the PWA are unaffected.
This commit is contained in:
Maurice
2026-11-06 13:52:52 +01:00
parent c6f91716a4
commit f35b189ca2
48 changed files with 1148 additions and 19 deletions
+1 -1
View File
@@ -103,7 +103,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
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
- uses: actions/setup-node@v6
with:
+1
View File
@@ -24,6 +24,7 @@ PROJECT = "ios/App/App.xcodeproj"
SIGNED_TARGETS = {
"App" => APP_IDENTIFIER,
"NotificationService" => "com.liketrek.trek.notifications",
"TREKWidgets" => "com.liketrek.trek.widgets",
}
platform :ios do
@@ -7,8 +7,14 @@
objects = {
/* Begin PBXBuildFile section */
01FD437981FC32C2C2C5BDCE /* TREKWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */; };
2162CD349AFFDF070C16A58A /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E901288FC198E4D33BE754F /* SwiftUI.framework */; };
2CFE220A73ACB585F3D4CB8F /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2EE43D236E661AA975612A4B /* UserNotifications.framework */; };
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
32797E66C09CF7579AC323FD /* TREKWidgetsBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */; };
3FB1872A25267A8E88FCBE3C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC64330AC19625949E8DC05A /* WidgetKit.framework */; };
492FA24FBB57059B0B5007CE /* TripDayAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */; };
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
@@ -19,9 +25,13 @@
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 */; };
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
A8FDD4DBE3F078BF935CC4B9 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA8802204810ED580DE46F3 /* NotificationService.swift */; };
B4C0FC386F71D849E141C91E /* NextTripWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */; };
B65B7CFC62EEFF443491EA64 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 192E69C7E66646BC39566925 /* ActivityKit.framework */; };
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -32,6 +42,13 @@
remoteGlobalIDString = 9480DFBC2C2A112973DA468A;
remoteInfo = NotificationService;
};
B8BBE7F7A64678D780A7F3D1 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = A3BE5653156025C97CA62C68;
remoteInfo = TREKWidgets;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -42,6 +59,7 @@
dstSubfolderSpec = 13;
files = (
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */,
01FD437981FC32C2C2C5BDCE /* TREKWidgets.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@@ -49,9 +67,12 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
192E69C7E66646BC39566925 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
1E7522315D47F086145DC4B8 /* 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>"; };
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayLiveActivity.swift; sourceTree = "<group>"; };
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayAttributes.swift; sourceTree = "<group>"; };
3DF4F4305878F949DD09193F /* NotificationService.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; 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; };
@@ -61,17 +82,35 @@
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; 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; };
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>"; };
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWidgetsBundle.swift; 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; };
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NextTripWidget.swift; sourceTree = "<group>"; };
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = "<group>"; };
FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
0A579FBACE2CF30B89370F8E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
3FB1872A25267A8E88FCBE3C /* WidgetKit.framework in Frameworks */,
2162CD349AFFDF070C16A58A /* SwiftUI.framework in Frameworks */,
B65B7CFC62EEFF443491EA64 /* ActivityKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -106,6 +145,7 @@
504EC3051FED79650016851F /* Products */,
5198C9768EB9762A60701C92 /* Frameworks */,
74C1FC6022E562372A71EEC9 /* NotificationService */,
FB9226BC82835538BCE3DC0A /* TREKWidgets */,
);
sourceTree = "<group>";
};
@@ -114,6 +154,7 @@
children = (
504EC3041FED79650016851F /* App.app */,
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */,
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -143,6 +184,9 @@
children = (
0FC8613857BD976EB58ECE0B /* iOS */,
2EE43D236E661AA975612A4B /* UserNotifications.framework */,
BC64330AC19625949E8DC05A /* WidgetKit.framework */,
6E901288FC198E4D33BE754F /* SwiftUI.framework */,
192E69C7E66646BC39566925 /* ActivityKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -158,6 +202,21 @@
path = NotificationService;
sourceTree = "<group>";
};
FB9226BC82835538BCE3DC0A /* TREKWidgets */ = {
isa = PBXGroup;
children = (
BE41D7212DCB0C78E00A8462 /* Info.plist */,
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */,
BBB2E19CD540AB30B44BECCA /* TREKWidgets.entitlements */,
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */,
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */,
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */,
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */,
);
name = TREKWidgets;
path = TREKWidgets;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -174,6 +233,7 @@
);
dependencies = (
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */,
4CD7F5AF02CB806E4189489A /* PBXTargetDependency */,
);
name = App;
packageProductDependencies = (
@@ -200,6 +260,23 @@
productReference = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */;
productType = "com.apple.product-type.app-extension";
};
A3BE5653156025C97CA62C68 /* TREKWidgets */ = {
isa = PBXNativeTarget;
buildConfigurationList = 9DE86AC1D6A95987925D7982 /* Build configuration list for PBXNativeTarget "TREKWidgets" */;
buildPhases = (
92D923C146596E50655B451D /* Sources */,
0A579FBACE2CF30B89370F8E /* Frameworks */,
04A03741C8AD60E63D4B11E7 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = TREKWidgets;
productName = TREKWidgets;
productReference = 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -239,11 +316,19 @@
targets = (
504EC3031FED79650016851F /* App */,
9480DFBC2C2A112973DA468A /* NotificationService */,
A3BE5653156025C97CA62C68 /* TREKWidgets */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
04A03741C8AD60E63D4B11E7 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -284,12 +369,31 @@
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */,
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */,
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
92D923C146596E50655B451D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B4C0FC386F71D849E141C91E /* NextTripWidget.swift in Sources */,
32797E66C09CF7579AC323FD /* TREKWidgetsBundle.swift in Sources */,
492FA24FBB57059B0B5007CE /* TripDayAttributes.swift in Sources */,
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */,
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
4CD7F5AF02CB806E4189489A /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = TREKWidgets;
target = A3BE5653156025C97CA62C68 /* TREKWidgets */;
targetProxy = B8BBE7F7A64678D780A7F3D1 /* PBXContainerItemProxy */;
};
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = NotificationService;
@@ -318,6 +422,31 @@
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
3ECF3537A139DB5CAC23E502 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = TREKWidgets/TREKWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
@@ -524,6 +653,32 @@
};
name = Release;
};
C5621F5D75100AAEBB1BD22A /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = TREKWidgets/TREKWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -554,6 +709,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
9DE86AC1D6A95987925D7982 /* Build configuration list for PBXNativeTarget "TREKWidgets" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C5621F5D75100AAEBB1BD22A /* Release */,
3ECF3537A139DB5CAC23E502 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
+2
View File
@@ -71,6 +71,8 @@
<key>NSPhotoLibraryAddUsageDescription</key>
<string>TREK saves photos and exports you download from your trips back to your photo library.</string>
<!-- navigator.geolocation in the WebView ("locate me" on the map). -->
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>TREK uses Face ID to unlock the app when you turn on the app lock.</string>
<key>NSLocationWhenInUseUsageDescription</key>
+95 -15
View File
@@ -4,6 +4,10 @@ import CoreLocation
import CryptoKit
import Security
import UserNotifications
import WidgetKit
#if canImport(ActivityKit)
import ActivityKit
#endif
import LocalAuthentication
import UIKit
import WebKit
@@ -32,6 +36,9 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "takeSharedFile", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "registerPush", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "takeOpenedPush", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setWidgetSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "syncTripDay", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "endTripDay", returnType: CAPPluginReturnPromise),
]
/// What this binary offers the server's UI, raised with every native feature
@@ -39,7 +46,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
static let shellApi = 2
/// The native features by name, so the UI can turn each on by itself.
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush"]
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity"]
// MARK: - Shared booking documents
@@ -52,6 +59,69 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
}
}
// MARK: - Widgets and the travel-day Live Activity
/// Stores what the home-screen widget shows (native/widgets.ts builds it, texts
/// already translated) where the widget extension reads it, and redraws it.
@objc func setWidgetSnapshot(_ call: CAPPluginCall) {
guard let json = call.getString("json"), let data = json.data(using: .utf8) else {
call.reject("json is required")
return
}
TrekSharedKeychain.write("widget.snapshot", data)
WidgetCenter.shared.reloadAllTimelines()
call.resolve()
}
/// Starts the travel day's Live Activity, or updates the one running.
@objc func syncTripDay(_ call: CAPPluginCall) {
guard let title = call.getString("tripTitle"), let path = call.getString("path"),
let dayLabel = call.getString("dayLabel"), let nextName = call.getString("nextName") else {
call.reject("tripTitle, path, dayLabel and nextName are required")
return
}
#if canImport(ActivityKit)
if #available(iOS 16.2, *) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
call.resolve(["running": false])
return
}
let state = TripDayAttributes.ContentState(
dayLabel: dayLabel,
nextName: nextName,
nextTime: call.getString("nextTime"),
done: call.getInt("done") ?? 0,
total: call.getInt("total") ?? 0
)
// Stale at the end of the day, so a forgotten one fades out on its own.
let stale = Calendar.current.startOfDay(for: Date().addingTimeInterval(86_400))
let content = ActivityContent(state: state, staleDate: stale)
if let running = Activity<TripDayAttributes>.activities.first(where: { $0.attributes.path == path }) {
Task { await running.update(content) }
} else {
for other in Activity<TripDayAttributes>.activities {
Task { await other.end(nil, dismissalPolicy: .immediate) }
}
_ = try? Activity.request(attributes: TripDayAttributes(tripTitle: title, path: path), content: content)
}
call.resolve(["running": true])
return
}
#endif
call.resolve(["running": false])
}
@objc func endTripDay(_ call: CAPPluginCall) {
#if canImport(ActivityKit)
if #available(iOS 16.2, *) {
for activity in Activity<TripDayAttributes>.activities {
Task { await activity.end(nil, dismissalPolicy: .immediate) }
}
}
#endif
call.resolve()
}
// MARK: - Push
/// Asks for permission, registers with APNs and answers with a Web Push
@@ -438,32 +508,46 @@ enum TrekPushKeys {
private static let authAccount = "push.auth"
static func loadOrCreate() -> (publicKey: String, auth: String)? {
if let raw = read(privateKeyAccount), let auth = read(authAccount),
if let raw = TrekSharedKeychain.read(privateKeyAccount), let auth = TrekSharedKeychain.read(authAccount),
let key = try? P256.KeyAgreement.PrivateKey(rawRepresentation: raw) {
return (base64url(key.publicKey.x963Representation), base64url(auth))
}
let key = P256.KeyAgreement.PrivateKey()
var auth = Data(count: 16)
let status = auth.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
guard status == errSecSuccess, write(privateKeyAccount, key.rawRepresentation), write(authAccount, auth) else { return nil }
guard status == errSecSuccess,
TrekSharedKeychain.write(privateKeyAccount, key.rawRepresentation),
TrekSharedKeychain.write(authAccount, auth) else { return nil }
return (base64url(key.publicKey.x963Representation), base64url(auth))
}
static func base64url(_ data: Data) -> String {
data.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
/// Items the app shares with its extensions (the notification extension, the
/// widgets) through one keychain group, so no App Group is needed: the push keys
/// and the widget snapshot.
enum TrekSharedKeychain {
static let service = "com.liketrek.trek.push"
private static func query(_ account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.liketrek.trek.push",
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
]
}
/// The team-prefixed group, read from the app's own entitlement at runtime.
private static var accessGroup: String {
/// The team-prefixed shared group, read off a probe item in the default group.
static var accessGroup: String = {
let probe: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "push.groupProbe",
kSecAttrService as String: "com.liketrek.trek.push",
kSecAttrService as String: service,
kSecReturnAttributes as String: true,
]
var result: CFTypeRef?
@@ -482,25 +566,21 @@ enum TrekPushKeys {
return "\(prefix).\(TrekPush.keychainGroup)"
}
return TrekPush.keychainGroup
}
}()
private static func read(_ account: String) -> Data? {
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
}
private static func write(_ account: String, _ data: Data) -> Bool {
@discardableResult
static func write(_ account: String, _ data: Data) -> Bool {
SecItemDelete(query(account) as CFDictionary)
var q = query(account)
q[kSecValueData as String] = data
q[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
return SecItemAdd(q as CFDictionary, nil) == errSecSuccess
}
static func base64url(_ data: Data) -> String {
data.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
+29
View File
@@ -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,118 @@
import SwiftUI
import WidgetKit
/// "Next trip" on the home screen: the trip and how far off it is, or while it
/// runs, today's day and the next stops (medium size).
struct NextTripEntry: TimelineEntry {
let date: Date
let snapshot: WidgetSnapshot?
}
struct NextTripProvider: TimelineProvider {
func placeholder(in context: Context) -> NextTripEntry {
NextTripEntry(date: Date(), snapshot: nil)
}
func getSnapshot(in context: Context, completion: @escaping (NextTripEntry) -> Void) {
completion(NextTripEntry(date: Date(), snapshot: WidgetSnapshot.load()))
}
/// The app reloads the timeline whenever it writes a new snapshot; past
/// midnight the countdown moves on by itself.
func getTimeline(in context: Context, completion: @escaping (Timeline<NextTripEntry>) -> Void) {
let now = Date()
let midnight = Calendar.current.startOfDay(for: now.addingTimeInterval(86_400))
let entry = NextTripEntry(date: now, snapshot: WidgetSnapshot.load())
completion(Timeline(entries: [entry], policy: .after(midnight)))
}
}
struct NextTripWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: NextTripEntry
var body: some View {
content
.widgetURL(entry.snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) })
.modifier(WidgetBackground())
}
@ViewBuilder
private var content: some View {
if let snapshot = entry.snapshot, let trip = snapshot.trip {
VStack(alignment: .leading, spacing: 4) {
Text(snapshot.today == nil ? snapshot.labels.nextTrip : snapshot.labels.today)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(trip.title)
.font(.headline)
.lineLimit(2)
if let today = snapshot.today {
Text(today.dayLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
if family != .systemSmall {
stops(today.stops, nothing: snapshot.labels.nothingPlanned)
}
} else if let days = snapshot.daysUntilStart(from: entry.date), days > 0 {
Spacer(minLength: 0)
Text(days == 1 ? snapshot.labels.tomorrow : snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: String(days)))
.font(.title3.weight(.bold))
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading, spacing: 6) {
Text("TREK").font(.headline)
Text(entry.snapshot?.labels.noTrip ?? "")
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder
private func stops(_ stops: [WidgetSnapshot.Stop], nothing: String) -> some View {
if stops.isEmpty {
Text(nothing).font(.caption).foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(stops.prefix(3).enumerated()), id: \.offset) { _, stop in
HStack(spacing: 6) {
Text(stop.time ?? "")
.font(.caption2.monospacedDigit().weight(.semibold))
.foregroundStyle(.secondary)
.frame(minWidth: 36, alignment: .leading)
Text(stop.name).font(.caption).lineLimit(1)
}
}
}
}
}
}
/// The system background on iOS 17 and later, the plain one before.
struct WidgetBackground: ViewModifier {
func body(content: Content) -> some View {
if #available(iOSApplicationExtension 17.0, *) {
content.containerBackground(.fill.tertiary, for: .widget)
} else {
content.padding()
}
}
}
struct NextTripWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "NextTrip", provider: NextTripProvider()) { entry in
NextTripWidgetView(entry: entry)
}
.configurationDisplayName("TREK")
.description("Your next trip, and today's plan while you travel.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
@@ -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,12 @@
import SwiftUI
import WidgetKit
/// The TREK widgets: the next trip on the home screen, and the travel day as a
/// Live Activity.
@main
struct TREKWidgetsBundle: WidgetBundle {
var body: some Widget {
NextTripWidget()
TripDayLiveActivity()
}
}
@@ -0,0 +1,24 @@
import ActivityKit
import Foundation
/// The Live Activity for a travel day: which trip and day, and the next stop.
/// Compiled into both the app (which starts and updates it, TrekShellPlugin)
/// and the widget extension (which draws it, TripDayLiveActivity).
@available(iOS 16.1, *)
struct TripDayAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
/// "Day 2 of 5", already in the user's language.
var dayLabel: String
/// The next stop, or the label for "all done" once the day's stops are behind.
var nextName: String
/// The next stop's time as the user formats it, if it has one.
var nextTime: String?
/// Stops behind, out of the day's stops.
var done: Int
var total: Int
}
var tripTitle: String
/// The app path a tap opens.
var path: String
}
@@ -0,0 +1,55 @@
import ActivityKit
import SwiftUI
import WidgetKit
/// A travel day on the lock screen and in the Dynamic Island: the next stop and
/// its time, and how much of the day is behind.
struct TripDayLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TripDayAttributes.self) { context in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(context.attributes.tripTitle).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
Spacer()
Text(context.state.dayLabel).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
}
HStack(alignment: .firstTextBaseline) {
Image(systemName: "mappin.circle.fill")
Text(context.state.nextName).font(.headline).lineLimit(1)
Spacer()
if let time = context.state.nextTime {
Text(time).font(.headline.monospacedDigit())
}
}
ProgressView(value: Double(context.state.done), total: Double(max(context.state.total, 1)))
}
.padding()
.widgetURL(WidgetSnapshot.link(context.attributes.path))
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Label(context.state.dayLabel, systemImage: "map").font(.caption)
}
DynamicIslandExpandedRegion(.trailing) {
if let time = context.state.nextTime {
Text(time).font(.caption.monospacedDigit())
}
}
DynamicIslandExpandedRegion(.bottom) {
VStack(alignment: .leading, spacing: 4) {
Text(context.state.nextName).font(.headline).lineLimit(1)
ProgressView(value: Double(context.state.done), total: Double(max(context.state.total, 1)))
}
}
} compactLeading: {
Image(systemName: "mappin.circle.fill")
} compactTrailing: {
Text(context.state.nextTime ?? "\(context.state.done)/\(context.state.total)")
.font(.caption2.monospacedDigit())
} minimal: {
Image(systemName: "mappin.circle.fill")
}
.widgetURL(WidgetSnapshot.link(context.attributes.path))
}
}
}
@@ -0,0 +1,71 @@
import Foundation
import Security
/// What the app last told the widgets (native/widgets.ts writes it through
/// TrekShell.setWidgetSnapshot into the keychain group the app and its
/// extensions share). Texts arrive already translated, so the widget needs no
/// localisation of its own and always speaks the language the app does.
struct WidgetSnapshot: Codable {
struct Trip: Codable {
var title: String
var start: String
var end: String
var path: String
}
struct Stop: Codable, Hashable {
var time: String?
var name: String
}
struct Today: Codable {
var dayLabel: String
var stops: [Stop]
}
struct Labels: Codable {
var nextTrip: String
var today: String
var noTrip: String
/// Contains "{n}".
var inDays: String
var tomorrow: String
var nothingPlanned: String
}
var trip: Trip?
var today: Today?
var labels: Labels
static func load() -> WidgetSnapshot? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.liketrek.trek.push",
kSecAttrAccount as String: "widget.snapshot",
kSecReturnData as String: true,
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
}
/// Whole days from today to the trip's start, in the device's calendar.
func daysUntilStart(from now: Date = Date()) -> Int? {
guard let trip, let start = WidgetSnapshot.day(trip.start) else { return nil }
let calendar = Calendar.current
return calendar.dateComponents([.day], from: calendar.startOfDay(for: now), to: start).day
}
static func day(_ iso: String) -> Date? {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: String(iso.prefix(10)))
}
/// The app link a tap opens: com.liketrek.trek://navigate<path>.
static func link(_ path: String) -> URL? {
URL(string: "com.liketrek.trek://navigate\(path.hasPrefix("/") ? path : "/" + path)")
}
}
+24
View File
@@ -0,0 +1,24 @@
# Compiles a source file of an extension into the App target too.
#
# ruby ios/scripts/share-with-app.rb <extension-group> <file.swift>
#
# For types both sides need: the Live Activity's attributes are started by the
# app and drawn by the widget extension, so both must know them. Running it
# again changes nothing.
require 'xcodeproj'
group_name, file = ARGV
abort 'usage: share-with-app.rb <extension-group> <file.swift>' unless group_name && file
project = Xcodeproj::Project.open(File.expand_path('../App/App.xcodeproj', __dir__))
app = project.targets.find { |t| t.name == 'App' } or abort 'no App target'
group = project.main_group[group_name] or abort "no group #{group_name}"
ref = group.files.find { |f| f.path == file } or abort "no #{file} in #{group_name}"
if app.source_build_phase.files_references.include?(ref)
puts "#{file} is already in App"
else
app.add_file_references([ref])
project.save
puts "#{file} now compiles into App too"
end
+2
View File
@@ -35,6 +35,7 @@ import NewVersionNotice from './components/Layout/NewVersionNotice'
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
import AppLockGate from './components/Layout/AppLockGate'
import NativeImportSheet from './components/Layout/NativeImportSheet'
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel'
// Notice action registrations (side-effect imports):
@@ -448,6 +449,7 @@ export default function App() {
<ErrorBoundary boundaryId="widget:native-update" fallback={null}><NativeUpdateNotice /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:app-lock" fallback={null}><AppLockGate /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:native-import" fallback={null}><NativeImportSheet /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:native-widgets" fallback={null}><NativeWidgetsSync /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
so a mounted boundary keeps the current page on screen instead of flashing
@@ -0,0 +1,70 @@
/**
* NativeWidgetsSync: keeps the iOS home-screen widget 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.
*/
import { App } from '@capacitor/app';
import { useEffect } from 'react';
import { offlineDb } from '../../db/offlineDb';
import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState, widgetsActive } from '../../native/widgets';
import { useSettingsStore } from '../../store/settingsStore';
import { formatTime } from '../../utils/formatters';
import { localDateStr } from '../../vacay/monthGrid';
const REFRESH_MS = 15 * 60 * 1000;
export default function NativeWidgetsSync(): null {
const { t, language } = useTranslation();
const { caps } = useNativeCapabilities();
const timeFormat = useSettingsStore((s) => s.settings.time_format);
useEffect(() => {
if (!widgetsActive()) return;
let cancelled = false;
const sync = async () => {
const now = new Date();
const today = localDateStr(now.getFullYear(), now.getMonth(), now.getDate());
const trips = await offlineDb.trips.toArray();
const trip = pickTrip(trips, today);
const days = trip ? await offlineDb.days.where('trip_id').equals(trip.id).toArray() : [];
const day = trip
? todayOf(
trip,
days,
today,
(n, m) => t('native.widget.dayOf', { n, m }),
(time) => formatTime(time, language, timeFormat)
)
: null;
if (cancelled) return;
const snapshot = buildSnapshot(trip, day, {
nextTrip: t('dashboard.nextTrip'),
today: t('native.widget.today'),
noTrip: t('native.widget.noTrip'),
inDays: t('native.widget.inDays'),
tomorrow: t('native.widget.tomorrow'),
nothingPlanned: t('native.widget.nothingPlanned'),
});
const live = day ? tripDayState(day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone')) : null;
await publishWidgets(snapshot, live);
};
void sync();
const timer = window.setInterval(() => void sync(), REFRESH_MS);
const listener = App.addListener('appStateChange', ({ isActive }) => {
if (isActive) void sync();
});
return () => {
cancelled = true;
window.clearInterval(timer);
void listener.then((handle) => handle.remove());
};
}, [caps, t, language, timeFormat]);
return null;
}
+1 -1
View File
@@ -24,7 +24,7 @@ vi.mock('./capabilities', () => ({
}));
vi.mock('./geolocation', () => ({ installNativeGeolocation: vi.fn() }));
vi.mock('./sharedFiles', () => ({ startSharedFiles: vi.fn(async () => {}) }));
vi.mock('./push', () => ({ startNativePush: vi.fn(async () => {}) }));
vi.mock('./push', () => ({ startNativePush: vi.fn(async () => {}), openPushTarget: vi.fn() }));
vi.mock('../mobile/overlayStack', () => ({ closeTopOverlay: vi.fn(() => false) }));
function backButtonHandler(): (event: { canGoBack: boolean }) => void {
+8 -2
View File
@@ -8,10 +8,10 @@ import { checkAppUpdate, loadNativeCapabilities } from './capabilities';
import { installNativeGeolocation } from './geolocation';
import { keepOfflineShellFresh } from './offlineShell';
import { isNativeApp, isNativeIos } from './platform';
import { startNativePush } from './push';
import { openPushTarget, startNativePush } from './push';
import { reportNativeFailure } from './reportFailure';
import { startSharedFiles } from './sharedFiles';
import { isUnimplemented } from './trekShell';
import { APP_URL_SCHEME, isUnimplemented } from './trekShell';
/** Long enough that the warm-up never competes with the first screen's requests. */
const OFFLINE_SHELL_DELAY_MS = 15_000;
@@ -34,6 +34,12 @@ export function startNativeApp(): void {
// Taps on TREK notifications open the place they point at.
void loadNativeCapabilities().then(() => startNativePush());
// A widget or Live Activity tap opens com.liketrek.trek://navigate/<path>.
void App.addListener('appUrlOpen', ({ url }) => {
const prefix = `${APP_URL_SCHEME}://navigate`;
if (url.startsWith(prefix)) openPushTarget(url.slice(prefix.length) || '/');
});
// A tap on the status bar scrolls the list under it back to the top, as in
// every iOS app (the status-bar plugin fires statusTap on iOS).
window.addEventListener('statusTap', scrollToTop);
+13
View File
@@ -59,6 +59,19 @@ export interface TrekShellPlugin {
registerPush(): Promise<{ endpoint: string; keys: { p256dh: string; auth: string } }>;
/** Feature nativePush: the notification the app was opened from, before the UI ran. */
takeOpenedPush(): Promise<{ url: string | null }>;
/** Feature widgets: what the home-screen widget shows (native/widgets.ts), as JSON. */
setWidgetSnapshot(options: { json: string }): Promise<void>;
/** Feature liveActivity: starts the travel day's Live Activity, or updates the running one. */
syncTripDay(options: {
tripTitle: string;
path: string;
dayLabel: string;
nextName: string;
nextTime?: string;
done: number;
total: number;
}): Promise<{ running: boolean }>;
endTripDay(): Promise<void>;
addListener(
event: 'pushOpened',
listener: (payload: { url: string }) => void
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest';
import type { Day, Trip } from '../types';
vi.mock('./platform', () => ({ isNativeIos: vi.fn(() => true) }));
vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn(() => true) }));
vi.mock('./reportFailure', () => ({ reportNativeFailure: vi.fn() }));
vi.mock('./trekShell', () => ({
TrekShell: {
setWidgetSnapshot: vi.fn(async () => {}),
syncTripDay: vi.fn(async () => ({ running: true })),
endTripDay: vi.fn(async () => {}),
},
}));
import { TrekShell } from './trekShell';
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState } from './widgets';
const trip = (id: number, start: string | null, end: string | null, archived = 0) =>
({ id, title: `Trip ${id}`, start_date: start, end_date: end, is_archived: archived }) as unknown as Trip;
const day = (id: number, n: number, date: string, places: { name: string; time?: string }[]) =>
({
id,
trip_id: 1,
day_number: n,
date,
assignments: places.map((p, i) => ({
id: id * 10 + i,
order_index: i,
place: { name: p.name, place_time: p.time ?? null },
})),
}) as unknown as Day;
const labels = {
nextTrip: 'Next',
today: 'Today',
noTrip: 'None',
inDays: 'in {n} days',
tomorrow: 'Tomorrow',
nothingPlanned: '-',
};
describe('pickTrip', () => {
it('prefers the running trip, then the next upcoming one', () => {
const trips = [
trip(1, '2026-12-01', '2026-12-05'),
trip(2, '2026-11-20', '2026-11-25'),
trip(3, '2026-11-01', '2026-11-10'),
];
expect(pickTrip(trips, '2026-11-06')?.id).toBe(3);
expect(pickTrip(trips, '2026-11-12')?.id).toBe(2);
});
it('skips undated, archived and finished trips', () => {
expect(
pickTrip(
[trip(1, null, null), trip(2, '2026-12-01', '2026-12-02', 1), trip(3, '2026-01-01', '2026-01-02')],
'2026-11-06'
)
).toBeNull();
});
});
describe('todayOf and tripDayState', () => {
const days = [
day(2, 2, '2026-11-07', []),
day(1, 1, '2026-11-06', [{ name: 'Museum', time: '09:00' }, { name: 'Walk' }, { name: 'Dinner', time: '19:30' }]),
];
it('finds today in day order and keeps the plan order', () => {
const today = todayOf(
trip(1, '2026-11-06', '2026-11-07'),
days,
'2026-11-06',
(n, m) => `${n}/${m}`,
(t) => `T${t}`
);
expect(today?.dayLabel).toBe('1/2');
expect(today?.stops.map((s) => s.name)).toEqual(['Museum', 'Walk', 'Dinner']);
expect(today?.stops[0]).toMatchObject({ time: 'T09:00', minutes: 540 });
});
it('is null on a day outside the trip', () => {
expect(
todayOf(
trip(1, '2026-11-06', '2026-11-07'),
days,
'2026-11-09',
() => '',
(t) => t
)
).toBeNull();
});
it('names the next timed stop and counts the ones behind', () => {
const today = todayOf(
trip(1, '2026-11-06', '2026-11-07'),
days,
'2026-11-06',
() => 'D',
(t) => t
)!;
expect(tripDayState(today, 8 * 60, 'done')).toMatchObject({ nextName: 'Museum', done: 0, total: 2 });
expect(tripDayState(today, 12 * 60, 'done')).toMatchObject({ nextName: 'Dinner', nextTime: '19:30', done: 1 });
expect(tripDayState(today, 22 * 60, 'done')).toMatchObject({ nextName: 'done', done: 2, nextTime: undefined });
});
});
describe('publishWidgets', () => {
it('writes the snapshot without the internal minutes and ends the activity on a day without times', async () => {
const snapshot = buildSnapshot(
trip(1, '2026-11-06', '2026-11-07'),
{ tripTitle: 'Trip 1', path: '/trips/1', dayLabel: 'D', stops: [{ name: 'Walk', time: null, minutes: null }] },
labels
);
expect(snapshot.today?.stops[0]).toEqual({ name: 'Walk', time: null });
await publishWidgets(snapshot, {
tripTitle: 'Trip 1',
path: '/trips/1',
dayLabel: 'D',
nextName: 'x',
done: 0,
total: 0,
});
expect(JSON.parse(vi.mocked(TrekShell.setWidgetSnapshot).mock.calls[0][0].json).trip.path).toBe('/trips/1');
expect(TrekShell.endTripDay).toHaveBeenCalled();
expect(TrekShell.syncTripDay).not.toHaveBeenCalled();
});
});
+123
View File
@@ -0,0 +1,123 @@
import type { Day, Trip } from '../types';
import { hasNativeFeature } from './capabilities';
import { isNativeIos } from './platform';
import { reportNativeFailure } from './reportFailure';
import { TrekShell } from './trekShell';
/**
* What the home-screen widget and the travel-day Live Activity show, built from
* the trips this device keeps offline (db/offlineDb) and handed to the app
* (TrekShell.setWidgetSnapshot, syncTripDay). The texts go along translated, so
* the widget speaks the app's language without localising anything itself.
*/
export interface WidgetLabels {
nextTrip: string;
today: string;
noTrip: string;
/** Contains "{n}". */
inDays: string;
tomorrow: string;
nothingPlanned: string;
}
export interface WidgetStop {
time: string | null;
name: string;
/** Minutes after midnight, for picking the next stop; not sent to the widget. */
minutes: number | null;
}
export interface WidgetSnapshot {
trip: { title: string; start: string; end: string; path: string } | null;
today: { dayLabel: string; stops: { time: string | null; name: string }[] } | null;
labels: WidgetLabels;
}
export interface TripDay {
tripTitle: string;
path: string;
dayLabel: string;
stops: WidgetStop[];
}
export function widgetsActive(): boolean {
return isNativeIos() && hasNativeFeature('widgets');
}
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;
}
/**
* The running trip if there is one, else the next to start. Trips without dates
* and trips already over are not candidates.
*/
export function pickTrip(trips: Trip[], today: string): Trip | null {
const dated = trips.filter((t) => t.start_date && t.end_date && !t.is_archived);
const running = dated.find((t) => t.start_date! <= today && t.end_date! >= today);
if (running) return running;
const upcoming = dated
.filter((t) => t.start_date! > today)
.sort((a, b) => a.start_date!.localeCompare(b.start_date!));
return upcoming[0] ?? null;
}
/** Today's day of the trip with its stops in plan order, if the trip runs today. */
export function todayOf(
trip: Trip,
days: Day[],
today: string,
dayLabel: (index: number, total: number) => string,
formatTime: (time: string) => string
): TripDay | null {
const ordered = days.filter((d) => d.trip_id === trip.id).sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0));
const index = ordered.findIndex((d) => (d.date ?? '').slice(0, 10) === today);
if (index < 0) return null;
const day = ordered[index];
const stops = [...(day.assignments ?? [])]
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.filter((a) => a.place?.name)
.map((a) => ({
name: a.place!.name,
time: a.place?.place_time ? formatTime(a.place.place_time.slice(0, 5)) : null,
minutes: minutesOf(a.place?.place_time),
}));
return { tripTitle: trip.title, path: `/trips/${trip.id}`, dayLabel: dayLabel(index + 1, ordered.length), stops };
}
export function buildSnapshot(trip: Trip | null, day: TripDay | null, labels: WidgetLabels): WidgetSnapshot {
return {
trip: trip ? { title: trip.title, start: trip.start_date!, end: trip.end_date!, path: `/trips/${trip.id}` } : null,
today: day ? { dayLabel: day.dayLabel, stops: day.stops.map(({ time, name }) => ({ time, name })) } : null,
labels,
};
}
/** The Live Activity's state: the first stop whose time is still ahead, else the last. */
export function tripDayState(day: TripDay, nowMinutes: number, allDone: string) {
const timed = day.stops.filter((s) => s.minutes != null);
const nextIndex = day.stops.findIndex((s) => s.minutes != null && s.minutes >= nowMinutes);
const done = nextIndex < 0 ? timed.length : day.stops.slice(0, nextIndex).filter((s) => s.minutes != null).length;
const next = nextIndex >= 0 ? day.stops[nextIndex] : null;
return {
tripTitle: day.tripTitle,
path: day.path,
dayLabel: day.dayLabel,
nextName: next?.name ?? allDone,
nextTime: next?.time ?? undefined,
done,
total: timed.length,
};
}
export async function publishWidgets(
snapshot: WidgetSnapshot,
liveDay: ReturnType<typeof tripDayState> | null
): Promise<void> {
if (!widgetsActive()) return;
await TrekShell.setWidgetSnapshot({ json: JSON.stringify(snapshot) }).catch(reportNativeFailure);
if (!hasNativeFeature('liveActivity')) return;
if (liveDay && liveDay.total > 0) await TrekShell.syncTripDay(liveDay).catch(reportNativeFailure);
else await TrekShell.endTripDay().catch(reportNativeFailure);
}
+7
View File
@@ -82,6 +82,13 @@ const native: TranslationStrings = {
'native.lock.settingHint': 'يطلب ذلك عند فتح TREK وبعد نصف دقيقة في الخلفية، ويخفي التطبيق في مبدّل التطبيقات.',
'native.share.title': 'الاستيراد إلى أي رحلة؟',
'native.share.noTrips': 'لا توجد رحلة بعد. أنشئ رحلة أولًا ثم شارك المستند مجددًا.',
'native.widget.dayOf': 'اليوم {n} من {m}',
'native.widget.today': 'اليوم',
'native.widget.noTrip': 'لا توجد رحلة مخططة بعد',
'native.widget.inDays': 'بعد {n} أيام',
'native.widget.tomorrow': 'غدًا',
'native.widget.nothingPlanned': 'لا شيء مخطط لليوم',
'native.widget.dayDone': 'انتهى كل شيء لليوم',
};
export default native;
+7
View File
@@ -84,6 +84,13 @@ const native: TranslationStrings = {
'TREK açılanda və arxa fonda yarım dəqiqədən sonra soruşur, tətbiqi tətbiq dəyişdiricisində gizlədir.',
'native.share.title': 'Hansı səyahətə idxal edilsin?',
'native.share.noTrips': 'Hələ səyahət yoxdur. Əvvəlcə birini yaradın, sonra sənədi yenidən paylaşın.',
'native.widget.dayOf': '{m} gündən {n}-ci gün',
'native.widget.today': 'Bu gün',
'native.widget.noTrip': 'Hələ planlanmış səyahət yoxdur',
'native.widget.inDays': '{n} gün sonra',
'native.widget.tomorrow': 'Sabah',
'native.widget.nothingPlanned': 'Bu gün üçün plan yoxdur',
'native.widget.dayDone': 'Bu gün üçün hər şey bitdi',
};
export default native;
+7
View File
@@ -84,6 +84,13 @@ const native: TranslationStrings = {
'Pede ao abrir o TREK e após meio minuto em segundo plano, e oculta o app no seletor de apps.',
'native.share.title': 'Importar para qual viagem?',
'native.share.noTrips': 'Ainda não há viagens. Crie uma primeiro e compartilhe o documento de novo.',
'native.widget.dayOf': 'Dia {n} de {m}',
'native.widget.today': 'Hoje',
'native.widget.noTrip': 'Nenhuma viagem planejada ainda',
'native.widget.inDays': 'em {n} dias',
'native.widget.tomorrow': 'Amanhã',
'native.widget.nothingPlanned': 'Nada planejado para hoje',
'native.widget.dayDone': 'Tudo feito por hoje',
};
export default native;
+7
View File
@@ -84,6 +84,13 @@ const native: TranslationStrings = {
'Ho demana en obrir TREK i després de mig minut en segon pla, i amaga l’app al selector d’apps.',
'native.share.title': 'A quin viatge vols importar-ho?',
'native.share.noTrips': 'Encara no hi ha cap viatge. Crea’n un i torna a compartir el document.',
'native.widget.dayOf': 'Dia {n} de {m}',
'native.widget.today': 'Avui',
'native.widget.noTrip': 'Encara no hi ha cap viatge planificat',
'native.widget.inDays': 'd’aquí a {n} dies',
'native.widget.tomorrow': 'Demà',
'native.widget.nothingPlanned': 'Res planificat per avui',
'native.widget.dayDone': 'Tot fet per avui',
};
export default native;
+7
View File
@@ -84,6 +84,13 @@ const native: TranslationStrings = {
'Zeptá se při otevření TREK a po půl minutě na pozadí a skryje aplikaci v přepínači aplikací.',
'native.share.title': 'Importovat do které cesty?',
'native.share.noTrips': 'Zatím žádná cesta. Nejdřív nějakou vytvořte a pak dokument sdílejte znovu.',
'native.widget.dayOf': 'Den {n} z {m}',
'native.widget.today': 'Dnes',
'native.widget.noTrip': 'Zatím žádná naplánovaná cesta',
'native.widget.inDays': 'za {n} dní',
'native.widget.tomorrow': 'Zítra',
'native.widget.nothingPlanned': 'Na dnešek nic naplánováno',
'native.widget.dayDone': 'Na dnešek hotovo',
};
export default native;
+7
View File
@@ -85,6 +85,13 @@ const native: TranslationStrings = {
'Fragt beim Öffnen und nach einer halben Minute im Hintergrund nach und verbirgt TREK im App-Umschalter.',
'native.share.title': 'In welchen Trip importieren?',
'native.share.noTrips': 'Noch kein Trip. Lege zuerst einen an und teile das Dokument dann erneut.',
'native.widget.dayOf': 'Tag {n} von {m}',
'native.widget.today': 'Heute',
'native.widget.noTrip': 'Noch keine Reise geplant',
'native.widget.inDays': 'in {n} Tagen',
'native.widget.tomorrow': 'Morgen',
'native.widget.nothingPlanned': 'Für heute nichts geplant',
'native.widget.dayDone': 'Für heute alles geschafft',
};
export default native;
+7
View File
@@ -84,6 +84,13 @@ const native: TranslationStrings = {
'Asks when TREK opens and after half a minute in the background, and hides the app in the app switcher.',
'native.share.title': 'Import into which trip?',
'native.share.noTrips': 'No trip yet. Create one first, then share the document again.',
'native.widget.dayOf': 'Day {n} of {m}',
'native.widget.today': 'Today',
'native.widget.noTrip': 'No trip planned yet',
'native.widget.inDays': 'in {n} days',
'native.widget.tomorrow': 'Tomorrow',
'native.widget.nothingPlanned': 'Nothing planned for today',
'native.widget.dayDone': 'All done for today',
};
export default native;
+7
View File
@@ -85,6 +85,13 @@ const native: TranslationStrings = {
'Lo pide al abrir TREK y tras medio minuto en segundo plano, y oculta la app en el selector de apps.',
'native.share.title': '¿En qué viaje importar?',
'native.share.noTrips': 'Aún no hay viajes. Crea uno primero y vuelve a compartir el documento.',
'native.widget.dayOf': 'Día {n} de {m}',
'native.widget.today': 'Hoy',
'native.widget.noTrip': 'Aún no hay ningún viaje planeado',
'native.widget.inDays': 'en {n} días',
'native.widget.tomorrow': 'Mañana',
'native.widget.nothingPlanned': 'Nada planeado para hoy',
'native.widget.dayDone': 'Todo hecho por hoy',
};
export default native;
+7
View File
@@ -84,6 +84,13 @@ const native: TranslationStrings = {
'Küsib TREK-i avamisel ja pärast poolt minutit taustal ning peidab rakenduse rakenduste vahetajas.',
'native.share.title': 'Millisesse reisi importida?',
'native.share.noTrips': 'Reise veel pole. Loo esmalt üks ja jaga dokumenti uuesti.',
'native.widget.dayOf': 'Päev {n}/{m}',
'native.widget.today': 'Täna',
'native.widget.noTrip': 'Reisi pole veel planeeritud',
'native.widget.inDays': '{n} päeva pärast',
'native.widget.tomorrow': 'Homme',
'native.widget.nothingPlanned': 'Tänaseks pole midagi planeeritud',
'native.widget.dayDone': 'Tänaseks kõik tehtud',
};
export default native;
+7
View File
@@ -85,6 +85,13 @@ const native: TranslationStrings = {
'Le demande à l’ouverture de TREK et après une demi-minute en arrière-plan, et masque l’app dans le sélecteur d’apps.',
'native.share.title': 'Importer dans quel voyage ?',
'native.share.noTrips': 'Pas encore de voyage. Créez-en un, puis partagez à nouveau le document.',
'native.widget.dayOf': 'Jour {n} sur {m}',
'native.widget.today': 'Aujourd’hui',
'native.widget.noTrip': 'Aucun voyage prévu pour l’instant',
'native.widget.inDays': 'dans {n} jours',
'native.widget.tomorrow': 'Demain',
'native.widget.nothingPlanned': 'Rien de prévu aujourd’hui',
'native.widget.dayDone': 'Tout est fait pour aujourd’hui',
};
export default native;
+7
View File
@@ -87,6 +87,13 @@ const native: TranslationStrings = {
'Ζητείται κατά το άνοιγμα του TREK και μετά από μισό λεπτό στο παρασκήνιο, και η εφαρμογή κρύβεται στην εναλλαγή εφαρμογών.',
'native.share.title': 'Εισαγωγή σε ποιο ταξίδι;',
'native.share.noTrips': 'Δεν υπάρχει ακόμα ταξίδι. Δημιουργήστε ένα πρώτα και κοινοποιήστε ξανά το έγγραφο.',
'native.widget.dayOf': 'Ημέρα {n} από {m}',
'native.widget.today': 'Σήμερα',
'native.widget.noTrip': 'Δεν υπάρχει ακόμα προγραμματισμένο ταξίδι',
'native.widget.inDays': 'σε {n} ημέρες',
'native.widget.tomorrow': 'Αύριο',
'native.widget.nothingPlanned': 'Τίποτα προγραμματισμένο για σήμερα',
'native.widget.dayDone': 'Όλα έγιναν για σήμερα',
};
export default native;
+7
View File
@@ -82,6 +82,13 @@ const native: TranslationStrings = {
'native.lock.settingHint': 'מבקש בעת פתיחת TREK ואחרי חצי דקה ברקע, ומסתיר את האפליקציה במחליף האפליקציות.',
'native.share.title': 'לייבא לאיזה טיול?',
'native.share.noTrips': 'אין עדיין טיול. צרו טיול ואז שתפו את המסמך שוב.',
'native.widget.dayOf': 'יום {n} מתוך {m}',
'native.widget.today': 'היום',
'native.widget.noTrip': 'עדיין לא תוכנן טיול',
'native.widget.inDays': 'בעוד {n} ימים',
'native.widget.tomorrow': 'מחר',
'native.widget.nothingPlanned': 'אין תוכניות להיום',
'native.widget.dayDone': 'הכול הושלם להיום',
};
export default native;
+7
View File
@@ -86,6 +86,13 @@ const native: TranslationStrings = {
'Rákérdez a TREK megnyitásakor és fél perc háttérben töltött idő után, és elrejti az alkalmazást az alkalmazásváltóban.',
'native.share.title': 'Melyik utazásba importáljuk?',
'native.share.noTrips': 'Még nincs utazás. Előbb hozz létre egyet, aztán oszd meg újra a dokumentumot.',
'native.widget.dayOf': '{m}/{n}. nap',
'native.widget.today': 'Ma',
'native.widget.noTrip': 'Még nincs tervezett utazás',
'native.widget.inDays': '{n} nap múlva',
'native.widget.tomorrow': 'Holnap',
'native.widget.nothingPlanned': 'Mára nincs semmi tervezve',
'native.widget.dayDone': 'Mára minden kész',
};
export default native;
+7
View File
@@ -85,6 +85,13 @@ const native: TranslationStrings = {
'Meminta saat TREK dibuka dan setelah setengah menit di latar belakang, serta menyembunyikan aplikasi di pengalih aplikasi.',
'native.share.title': 'Impor ke perjalanan mana?',
'native.share.noTrips': 'Belum ada perjalanan. Buat satu dulu, lalu bagikan dokumennya lagi.',
'native.widget.dayOf': 'Hari {n} dari {m}',
'native.widget.today': 'Hari ini',
'native.widget.noTrip': 'Belum ada perjalanan yang direncanakan',
'native.widget.inDays': '{n} hari lagi',
'native.widget.tomorrow': 'Besok',
'native.widget.nothingPlanned': 'Tidak ada rencana hari ini',
'native.widget.dayDone': 'Semua selesai hari ini',
};
export default native;
+7
View File
@@ -85,6 +85,13 @@ const native: TranslationStrings = {
'Lo chiede all’apertura di TREK e dopo mezzo minuto in background, e nasconde l’app nel selettore delle app.',
'native.share.title': 'Importare in quale viaggio?',
'native.share.noTrips': 'Ancora nessun viaggio. Creane uno, poi condividi di nuovo il documento.',
'native.widget.dayOf': 'Giorno {n} di {m}',
'native.widget.today': 'Oggi',
'native.widget.noTrip': 'Nessun viaggio ancora in programma',
'native.widget.inDays': 'tra {n} giorni',
'native.widget.tomorrow': 'Domani',
'native.widget.nothingPlanned': 'Niente in programma oggi',
'native.widget.dayDone': 'Tutto fatto per oggi',
};
export default native;
+7
View File
@@ -85,6 +85,13 @@ const native: TranslationStrings = {
'TREKを開いたときと、バックグラウンドで30秒経過したあとに確認し、アプリスイッチャーでは内容を隠します。',
'native.share.title': 'どの旅行に取り込みますか?',
'native.share.noTrips': 'まだ旅行がありません。先に作成してから、もう一度ドキュメントを共有してください。',
'native.widget.dayOf': '{m}日中{n}日目',
'native.widget.today': '今日',
'native.widget.noTrip': 'まだ予定している旅行はありません',
'native.widget.inDays': 'あと{n}日',
'native.widget.tomorrow': '明日',
'native.widget.nothingPlanned': '今日の予定はありません',
'native.widget.dayDone': '今日の予定はすべて完了',
};
export default native;
+7
View File
@@ -83,6 +83,13 @@ const native: TranslationStrings = {
'TREK을 열 때와 백그라운드에서 30초가 지난 후에 확인하며, 앱 전환기에서는 내용을 숨깁니다.',
'native.share.title': '어느 여행으로 가져올까요?',
'native.share.noTrips': '아직 여행이 없습니다. 먼저 여행을 만든 뒤 문서를 다시 공유하세요.',
'native.widget.dayOf': '{m}일 중 {n}일째',
'native.widget.today': '오늘',
'native.widget.noTrip': '아직 계획된 여행이 없습니다',
'native.widget.inDays': '{n}일 후',
'native.widget.tomorrow': '내일',
'native.widget.nothingPlanned': '오늘은 계획이 없습니다',
'native.widget.dayDone': '오늘 일정 완료',
};
export default native;
+7
View File
@@ -84,6 +84,13 @@ const native: TranslationStrings = {
'Vraagt erom bij het openen van TREK en na een halve minuut op de achtergrond, en verbergt de app in de appkiezer.',
'native.share.title': 'In welke reis importeren?',
'native.share.noTrips': 'Nog geen reis. Maak er eerst een aan en deel het document dan opnieuw.',
'native.widget.dayOf': 'Dag {n} van {m}',
'native.widget.today': 'Vandaag',
'native.widget.noTrip': 'Nog geen reis gepland',
'native.widget.inDays': 'over {n} dagen',
'native.widget.tomorrow': 'Morgen',
'native.widget.nothingPlanned': 'Niets gepland voor vandaag',
'native.widget.dayDone': 'Alles gedaan voor vandaag',
};
export default native;
+7
View File
@@ -84,6 +84,13 @@ const native: TranslationStrings = {
'Pyta przy otwieraniu TREK i po pół minuty w tle oraz ukrywa aplikację w przełączniku aplikacji.',
'native.share.title': 'Do której podróży zaimportować?',
'native.share.noTrips': 'Nie ma jeszcze podróży. Najpierw utwórz podróż, a potem udostępnij dokument ponownie.',
'native.widget.dayOf': 'Dzień {n} z {m}',
'native.widget.today': 'Dziś',
'native.widget.noTrip': 'Nie zaplanowano jeszcze żadnej podróży',
'native.widget.inDays': 'za {n} dni',
'native.widget.tomorrow': 'Jutro',
'native.widget.nothingPlanned': 'Na dziś nic nie zaplanowano',
'native.widget.dayDone': 'Na dziś wszystko zrobione',
};
export default native;
+7
View File
@@ -85,6 +85,13 @@ const native: TranslationStrings = {
'Запрашивается при открытии TREK и через полминуты в фоне, а в переключателе приложений содержимое скрыто.',
'native.share.title': 'В какую поездку импортировать?',
'native.share.noTrips': 'Поездок пока нет. Сначала создайте поездку, затем снова поделитесь документом.',
'native.widget.dayOf': 'День {n} из {m}',
'native.widget.today': 'Сегодня',
'native.widget.noTrip': 'Поездок пока не запланировано',
'native.widget.inDays': 'через {n} дн.',
'native.widget.tomorrow': 'Завтра',
'native.widget.nothingPlanned': 'На сегодня ничего не запланировано',
'native.widget.dayDone': 'На сегодня всё',
};
export default native;
+7
View File
@@ -84,6 +84,13 @@ const native: TranslationStrings = {
'Opýta sa pri otvorení TREK a po pol minúte na pozadí a skryje aplikáciu v prepínači aplikácií.',
'native.share.title': 'Importovať do ktorej cesty?',
'native.share.noTrips': 'Zatiaľ žiadna cesta. Najprv nejakú vytvorte a potom dokument zdieľajte znova.',
'native.widget.dayOf': 'Deň {n} z {m}',
'native.widget.today': 'Dnes',
'native.widget.noTrip': 'Zatiaľ žiadna naplánovaná cesta',
'native.widget.inDays': 'o {n} dní',
'native.widget.tomorrow': 'Zajtra',
'native.widget.nothingPlanned': 'Na dnes nič naplánované',
'native.widget.dayDone': 'Na dnes hotovo',
};
export default native;
+7
View File
@@ -85,6 +85,13 @@ const native: TranslationStrings = {
'Frågar när TREK öppnas och efter en halv minut i bakgrunden, och döljer appen i appväxlaren.',
'native.share.title': 'Importera till vilken resa?',
'native.share.noTrips': 'Ingen resa ännu. Skapa en först och dela sedan dokumentet igen.',
'native.widget.dayOf': 'Dag {n} av {m}',
'native.widget.today': 'I dag',
'native.widget.noTrip': 'Ingen resa planerad ännu',
'native.widget.inDays': 'om {n} dagar',
'native.widget.tomorrow': 'I morgon',
'native.widget.nothingPlanned': 'Inget planerat i dag',
'native.widget.dayDone': 'Allt klart för i dag',
};
export default native;
+7
View File
@@ -82,6 +82,13 @@ const native: TranslationStrings = {
'native.lock.settingHint': 'ถามเมื่อเปิด TREK และหลังจากอยู่เบื้องหลังครึ่งนาที และซ่อนแอปในตัวสลับแอป',
'native.share.title': 'นำเข้าไปยังทริปไหน',
'native.share.noTrips': 'ยังไม่มีทริป สร้างทริปก่อนแล้วแชร์เอกสารอีกครั้ง',
'native.widget.dayOf': 'วันที่ {n} จาก {m}',
'native.widget.today': 'วันนี้',
'native.widget.noTrip': 'ยังไม่มีทริปที่วางแผนไว้',
'native.widget.inDays': 'อีก {n} วัน',
'native.widget.tomorrow': 'พรุ่งนี้',
'native.widget.nothingPlanned': 'วันนี้ไม่มีแผน',
'native.widget.dayDone': 'วันนี้เสร็จหมดแล้ว',
};
export default native;
+7
View File
@@ -85,6 +85,13 @@ const native: TranslationStrings = {
'TREK açılırken ve arka planda yarım dakika sonra sorar, uygulamayı uygulama değiştiricide gizler.',
'native.share.title': 'Hangi geziye aktarılsın?',
'native.share.noTrips': 'Henüz gezi yok. Önce bir gezi oluşturun, sonra belgeyi yeniden paylaşın.',
'native.widget.dayOf': '{m} günün {n}. günü',
'native.widget.today': 'Bugün',
'native.widget.noTrip': 'Henüz planlanmış gezi yok',
'native.widget.inDays': '{n} gün sonra',
'native.widget.tomorrow': 'Yarın',
'native.widget.nothingPlanned': 'Bugün için plan yok',
'native.widget.dayDone': 'Bugünlük her şey tamam',
};
export default native;
+7
View File
@@ -84,6 +84,13 @@ const native: TranslationStrings = {
'Запитує під час відкриття TREK і після пів хвилини у фоні, а в перемикачі застосунків вміст приховано.',
'native.share.title': 'До якої подорожі імпортувати?',
'native.share.noTrips': 'Подорожей ще немає. Спершу створіть подорож, а потім знову поділіться документом.',
'native.widget.dayOf': 'День {n} з {m}',
'native.widget.today': 'Сьогодні',
'native.widget.noTrip': 'Подорожей ще не заплановано',
'native.widget.inDays': 'через {n} дн.',
'native.widget.tomorrow': 'Завтра',
'native.widget.nothingPlanned': 'На сьогодні нічого не заплановано',
'native.widget.dayDone': 'На сьогодні все',
};
export default native;
+7
View File
@@ -85,6 +85,13 @@ const native: TranslationStrings = {
'Hỏi khi mở TREK và sau nửa phút chạy nền, đồng thời ẩn ứng dụng trong trình chuyển ứng dụng.',
'native.share.title': 'Nhập vào chuyến đi nào?',
'native.share.noTrips': 'Chưa có chuyến đi nào. Hãy tạo một chuyến trước rồi chia sẻ lại tài liệu.',
'native.widget.dayOf': 'Ngày {n}/{m}',
'native.widget.today': 'Hôm nay',
'native.widget.noTrip': 'Chưa có chuyến đi nào được lên kế hoạch',
'native.widget.inDays': 'còn {n} ngày',
'native.widget.tomorrow': 'Ngày mai',
'native.widget.nothingPlanned': 'Hôm nay không có kế hoạch',
'native.widget.dayDone': 'Hôm nay đã xong',
};
export default native;
+7
View File
@@ -82,6 +82,13 @@ const native: TranslationStrings = {
'native.lock.settingHint': '開啟 TREK 時以及在背景停留半分鐘後會要求驗證,並在 App 切換器中隱藏內容。',
'native.share.title': '要匯入到哪個行程?',
'native.share.noTrips': '還沒有行程。請先建立一個,然後再次分享該文件。',
'native.widget.dayOf': '第 {n} 天,共 {m} 天',
'native.widget.today': '今天',
'native.widget.noTrip': '還沒有計畫中的行程',
'native.widget.inDays': '{n} 天後',
'native.widget.tomorrow': '明天',
'native.widget.nothingPlanned': '今天沒有安排',
'native.widget.dayDone': '今天的行程已全部完成',
};
export default native;
+7
View File
@@ -82,6 +82,13 @@ const native: TranslationStrings = {
'native.lock.settingHint': '打开 TREK 时以及在后台停留半分钟后会要求验证,并在应用切换器中隐藏内容。',
'native.share.title': '导入到哪个行程?',
'native.share.noTrips': '还没有行程。请先创建一个,然后再次分享该文档。',
'native.widget.dayOf': '第 {n} 天,共 {m} 天',
'native.widget.today': '今天',
'native.widget.noTrip': '还没有计划中的行程',
'native.widget.inDays': '{n} 天后',
'native.widget.tomorrow': '明天',
'native.widget.nothingPlanned': '今天没有安排',
'native.widget.dayDone': '今天的行程已全部完成',
};
export default native;