diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 8c3a94cbc..8ee1272f2 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -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: diff --git a/client/fastlane/Fastfile b/client/fastlane/Fastfile index 4a867a8cf..b477cb0cb 100644 --- a/client/fastlane/Fastfile +++ b/client/fastlane/Fastfile @@ -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 diff --git a/client/ios/App/App.xcodeproj/project.pbxproj b/client/ios/App/App.xcodeproj/project.pbxproj index ea319b7f2..92edde628 100644 --- a/client/ios/App/App.xcodeproj/project.pbxproj +++ b/client/ios/App/App.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; + 3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayLiveActivity.swift; sourceTree = ""; }; + 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayAttributes.swift; sourceTree = ""; }; 3DF4F4305878F949DD09193F /* NotificationService.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -61,17 +82,35 @@ 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 6E901288FC198E4D33BE754F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TREKWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 7A1E00012C0000000000A001 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; }; 7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekBridgeViewController.swift; sourceTree = ""; }; 7A1E00062C0000000000A006 /* TrekShellPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekShellPlugin.swift; sourceTree = ""; }; + 8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWidgetsBundle.swift; sourceTree = ""; }; 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; 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 = ""; }; + 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 = ""; }; + C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NextTripWidget.swift; sourceTree = ""; }; + DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = ""; }; FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; /* 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 = ""; }; @@ -114,6 +154,7 @@ children = ( 504EC3041FED79650016851F /* App.app */, A03A384FE54FAD1ED68E12DC /* NotificationService.appex */, + 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */, ); name = Products; sourceTree = ""; @@ -143,6 +184,9 @@ children = ( 0FC8613857BD976EB58ECE0B /* iOS */, 2EE43D236E661AA975612A4B /* UserNotifications.framework */, + BC64330AC19625949E8DC05A /* WidgetKit.framework */, + 6E901288FC198E4D33BE754F /* SwiftUI.framework */, + 192E69C7E66646BC39566925 /* ActivityKit.framework */, ); name = Frameworks; sourceTree = ""; @@ -158,6 +202,21 @@ path = NotificationService; sourceTree = ""; }; + 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 = ""; + }; /* 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 */ diff --git a/client/ios/App/App/Info.plist b/client/ios/App/App/Info.plist index 8eaff77ba..db57f50f3 100644 --- a/client/ios/App/App/Info.plist +++ b/client/ios/App/App/Info.plist @@ -71,6 +71,8 @@ NSPhotoLibraryAddUsageDescription TREK saves photos and exports you download from your trips back to your photo library. + NSSupportsLiveActivities + NSFaceIDUsageDescription TREK uses Face ID to unlock the app when you turn on the app lock. NSLocationWhenInUseUsageDescription diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift index 7e6c620ed..9a2fcaefe 100644 --- a/client/ios/App/App/TrekShellPlugin.swift +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -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.activities.first(where: { $0.attributes.path == path }) { + Task { await running.update(content) } + } else { + for other in Activity.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.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: "") - } } diff --git a/client/ios/App/TREKWidgets/Info.plist b/client/ios/App/TREKWidgets/Info.plist new file mode 100644 index 000000000..9cc2043e3 --- /dev/null +++ b/client/ios/App/TREKWidgets/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + TREK + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/client/ios/App/TREKWidgets/NextTripWidget.swift b/client/ios/App/TREKWidgets/NextTripWidget.swift new file mode 100644 index 000000000..e77f3615f --- /dev/null +++ b/client/ios/App/TREKWidgets/NextTripWidget.swift @@ -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) -> 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]) + } +} diff --git a/client/ios/App/TREKWidgets/TREKWidgets.entitlements b/client/ios/App/TREKWidgets/TREKWidgets.entitlements new file mode 100644 index 000000000..6b6566872 --- /dev/null +++ b/client/ios/App/TREKWidgets/TREKWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.liketrek.trek.shared + + + diff --git a/client/ios/App/TREKWidgets/TREKWidgetsBundle.swift b/client/ios/App/TREKWidgets/TREKWidgetsBundle.swift new file mode 100644 index 000000000..3db6222e0 --- /dev/null +++ b/client/ios/App/TREKWidgets/TREKWidgetsBundle.swift @@ -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() + } +} diff --git a/client/ios/App/TREKWidgets/TripDayAttributes.swift b/client/ios/App/TREKWidgets/TripDayAttributes.swift new file mode 100644 index 000000000..1fe7076ff --- /dev/null +++ b/client/ios/App/TREKWidgets/TripDayAttributes.swift @@ -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 +} diff --git a/client/ios/App/TREKWidgets/TripDayLiveActivity.swift b/client/ios/App/TREKWidgets/TripDayLiveActivity.swift new file mode 100644 index 000000000..4d8d97a7d --- /dev/null +++ b/client/ios/App/TREKWidgets/TripDayLiveActivity.swift @@ -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)) + } + } +} diff --git a/client/ios/App/TREKWidgets/WidgetSnapshot.swift b/client/ios/App/TREKWidgets/WidgetSnapshot.swift new file mode 100644 index 000000000..54789ee8f --- /dev/null +++ b/client/ios/App/TREKWidgets/WidgetSnapshot.swift @@ -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. + static func link(_ path: String) -> URL? { + URL(string: "com.liketrek.trek://navigate\(path.hasPrefix("/") ? path : "/" + path)") + } +} diff --git a/client/ios/scripts/share-with-app.rb b/client/ios/scripts/share-with-app.rb new file mode 100644 index 000000000..f269d0549 --- /dev/null +++ b/client/ios/scripts/share-with-app.rb @@ -0,0 +1,24 @@ +# Compiles a source file of an extension into the App target too. +# +# ruby ios/scripts/share-with-app.rb +# +# 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 ' 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 diff --git a/client/src/App.tsx b/client/src/App.tsx index 303af78fd..bcf5cad6a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { + {/* One boundary for all route chunks, above 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 diff --git a/client/src/components/Layout/NativeWidgetsSync.tsx b/client/src/components/Layout/NativeWidgetsSync.tsx new file mode 100644 index 000000000..3062c21f6 --- /dev/null +++ b/client/src/components/Layout/NativeWidgetsSync.tsx @@ -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; +} diff --git a/client/src/native/nativeApp.test.ts b/client/src/native/nativeApp.test.ts index 885c05bfa..6f5b531be 100644 --- a/client/src/native/nativeApp.test.ts +++ b/client/src/native/nativeApp.test.ts @@ -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 { diff --git a/client/src/native/nativeApp.ts b/client/src/native/nativeApp.ts index dd50fd6f1..059f1c231 100644 --- a/client/src/native/nativeApp.ts +++ b/client/src/native/nativeApp.ts @@ -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/. + 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); diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts index 3d2d044e7..0229e2b87 100644 --- a/client/src/native/trekShell.ts +++ b/client/src/native/trekShell.ts @@ -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; + /** 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; addListener( event: 'pushOpened', listener: (payload: { url: string }) => void diff --git a/client/src/native/widgets.test.ts b/client/src/native/widgets.test.ts new file mode 100644 index 000000000..e830a53fc --- /dev/null +++ b/client/src/native/widgets.test.ts @@ -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(); + }); +}); diff --git a/client/src/native/widgets.ts b/client/src/native/widgets.ts new file mode 100644 index 000000000..d4453def9 --- /dev/null +++ b/client/src/native/widgets.ts @@ -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 | null +): Promise { + 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); +} diff --git a/shared/src/i18n/ar/native.ts b/shared/src/i18n/ar/native.ts index 0ded8f83f..3f010b3c7 100644 --- a/shared/src/i18n/ar/native.ts +++ b/shared/src/i18n/ar/native.ts @@ -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; diff --git a/shared/src/i18n/az/native.ts b/shared/src/i18n/az/native.ts index 1c5b8b7f5..e341a8638 100644 --- a/shared/src/i18n/az/native.ts +++ b/shared/src/i18n/az/native.ts @@ -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; diff --git a/shared/src/i18n/br/native.ts b/shared/src/i18n/br/native.ts index 7a540640d..1fefd02e2 100644 --- a/shared/src/i18n/br/native.ts +++ b/shared/src/i18n/br/native.ts @@ -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; diff --git a/shared/src/i18n/ca/native.ts b/shared/src/i18n/ca/native.ts index c007e6386..e83fea149 100644 --- a/shared/src/i18n/ca/native.ts +++ b/shared/src/i18n/ca/native.ts @@ -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; diff --git a/shared/src/i18n/cs/native.ts b/shared/src/i18n/cs/native.ts index 73fba6fa8..71e0feaf2 100644 --- a/shared/src/i18n/cs/native.ts +++ b/shared/src/i18n/cs/native.ts @@ -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; diff --git a/shared/src/i18n/de/native.ts b/shared/src/i18n/de/native.ts index 39444941e..2ede2c96e 100644 --- a/shared/src/i18n/de/native.ts +++ b/shared/src/i18n/de/native.ts @@ -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; diff --git a/shared/src/i18n/en/native.ts b/shared/src/i18n/en/native.ts index 35b668677..a18ea2194 100644 --- a/shared/src/i18n/en/native.ts +++ b/shared/src/i18n/en/native.ts @@ -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; diff --git a/shared/src/i18n/es/native.ts b/shared/src/i18n/es/native.ts index d54810e8a..3e362472b 100644 --- a/shared/src/i18n/es/native.ts +++ b/shared/src/i18n/es/native.ts @@ -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; diff --git a/shared/src/i18n/et/native.ts b/shared/src/i18n/et/native.ts index 98ecfb60b..5df69255f 100644 --- a/shared/src/i18n/et/native.ts +++ b/shared/src/i18n/et/native.ts @@ -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; diff --git a/shared/src/i18n/fr/native.ts b/shared/src/i18n/fr/native.ts index 4a4fd48d7..ce7c71376 100644 --- a/shared/src/i18n/fr/native.ts +++ b/shared/src/i18n/fr/native.ts @@ -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; diff --git a/shared/src/i18n/gr/native.ts b/shared/src/i18n/gr/native.ts index f666f08ee..0b814fa2e 100644 --- a/shared/src/i18n/gr/native.ts +++ b/shared/src/i18n/gr/native.ts @@ -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; diff --git a/shared/src/i18n/he/native.ts b/shared/src/i18n/he/native.ts index da74576c8..4d3aacec9 100644 --- a/shared/src/i18n/he/native.ts +++ b/shared/src/i18n/he/native.ts @@ -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; diff --git a/shared/src/i18n/hu/native.ts b/shared/src/i18n/hu/native.ts index 194b29540..6094fecfc 100644 --- a/shared/src/i18n/hu/native.ts +++ b/shared/src/i18n/hu/native.ts @@ -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; diff --git a/shared/src/i18n/id/native.ts b/shared/src/i18n/id/native.ts index 47e658e33..b67667526 100644 --- a/shared/src/i18n/id/native.ts +++ b/shared/src/i18n/id/native.ts @@ -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; diff --git a/shared/src/i18n/it/native.ts b/shared/src/i18n/it/native.ts index 7891b8ec8..6e50651b6 100644 --- a/shared/src/i18n/it/native.ts +++ b/shared/src/i18n/it/native.ts @@ -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; diff --git a/shared/src/i18n/ja/native.ts b/shared/src/i18n/ja/native.ts index 1c87a190d..aa9ba5d2f 100644 --- a/shared/src/i18n/ja/native.ts +++ b/shared/src/i18n/ja/native.ts @@ -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; diff --git a/shared/src/i18n/ko/native.ts b/shared/src/i18n/ko/native.ts index 522ba27aa..5dc34ca2b 100644 --- a/shared/src/i18n/ko/native.ts +++ b/shared/src/i18n/ko/native.ts @@ -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; diff --git a/shared/src/i18n/nl/native.ts b/shared/src/i18n/nl/native.ts index 430a3bbef..0157d5b64 100644 --- a/shared/src/i18n/nl/native.ts +++ b/shared/src/i18n/nl/native.ts @@ -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; diff --git a/shared/src/i18n/pl/native.ts b/shared/src/i18n/pl/native.ts index 8b17f18f1..febfdb2aa 100644 --- a/shared/src/i18n/pl/native.ts +++ b/shared/src/i18n/pl/native.ts @@ -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; diff --git a/shared/src/i18n/ru/native.ts b/shared/src/i18n/ru/native.ts index 976e0a0dd..0e9fbf726 100644 --- a/shared/src/i18n/ru/native.ts +++ b/shared/src/i18n/ru/native.ts @@ -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; diff --git a/shared/src/i18n/sk/native.ts b/shared/src/i18n/sk/native.ts index a140f0352..cc1e2aa81 100644 --- a/shared/src/i18n/sk/native.ts +++ b/shared/src/i18n/sk/native.ts @@ -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; diff --git a/shared/src/i18n/sv/native.ts b/shared/src/i18n/sv/native.ts index fe3787c79..ad4b3bfad 100644 --- a/shared/src/i18n/sv/native.ts +++ b/shared/src/i18n/sv/native.ts @@ -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; diff --git a/shared/src/i18n/th/native.ts b/shared/src/i18n/th/native.ts index 5d11811cb..43969865a 100644 --- a/shared/src/i18n/th/native.ts +++ b/shared/src/i18n/th/native.ts @@ -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; diff --git a/shared/src/i18n/tr/native.ts b/shared/src/i18n/tr/native.ts index deebeaf4e..b73212b1d 100644 --- a/shared/src/i18n/tr/native.ts +++ b/shared/src/i18n/tr/native.ts @@ -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; diff --git a/shared/src/i18n/uk/native.ts b/shared/src/i18n/uk/native.ts index d2bb8f289..f9842f27c 100644 --- a/shared/src/i18n/uk/native.ts +++ b/shared/src/i18n/uk/native.ts @@ -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; diff --git a/shared/src/i18n/vi/native.ts b/shared/src/i18n/vi/native.ts index c8134c429..3d307bcf1 100644 --- a/shared/src/i18n/vi/native.ts +++ b/shared/src/i18n/vi/native.ts @@ -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; diff --git a/shared/src/i18n/zh-TW/native.ts b/shared/src/i18n/zh-TW/native.ts index e781ff8f3..879e28189 100644 --- a/shared/src/i18n/zh-TW/native.ts +++ b/shared/src/i18n/zh-TW/native.ts @@ -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; diff --git a/shared/src/i18n/zh/native.ts b/shared/src/i18n/zh/native.ts index 3e6a8cc2d..07c2e2ee9 100644 --- a/shared/src/i18n/zh/native.ts +++ b/shared/src/i18n/zh/native.ts @@ -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;