Compare commits

..

28 Commits

Author SHA1 Message Date
Maurice 792e55b2ca style(native): format the in-app purchase files and strings 2026-11-07 11:25:17 +01:00
Maurice 5462bce9c3 feat(native): TREK Cloud subscriptions through the App Store
The plan picker sent buyers to the website, which the App Store does not allow.
It now sells through StoreKit 2 in the TrekShell plugin (feature storeKit):
App Store price strings in the buyer's currency, the purchase sheet, the signed
transaction redeemed at the control plane, and the transaction finished only
once the instance exists. A purchase whose redeem failed is redeemed again on
retry, never bought twice.

Restore purchases, the auto-renewal terms and the links to the terms of use and
the privacy policy sit under the plans. Where no store purchase is possible
(Android, an older binary) the picker shows the plans with no way to buy and no
pointer to the web.
2026-11-07 11:25:17 +01:00
Maurice ede54d10cd test(managed): the mock factory brings its own imports
vi.mock is hoisted above the file's imports, so the managed-tab mock could not
use the icons and JSX imported at the top (ReferenceError before initialization).
2026-11-07 11:25:10 +01:00
Maurice e7a99db03b fix(privacy): the PDF print view uses the bundled Poppins, not Google Fonts
The trip PDF's print view linked Poppins from fonts.googleapis.com, so printing
a trip sent the reader's IP address to Google, the case German courts treated as
a GDPR breach (LG München I, 3 O 17493/20). It now declares the Poppins files
TREK already ships under /fonts, and the content security policy no longer
allows fonts.googleapis.com or fonts.gstatic.com, so no font can be fetched
from Google again by accident.
2026-11-07 11:22:53 +01:00
Maurice e5a119b6bd feat(managed): an install's own admin tabs on the phone too
The mobile admin never read managedAdminTabs, so a tab an install attaches
there (managed/index.tsx) existed on the desktop only. The phone's section
picker now lists them after the built-in sections, as the desktop sidebar does,
and shows `mobileElement` when a tab brings a phone layout of its own, else its
desktop `element`. TREK itself attaches nothing; the seam stays empty.
2026-11-07 10:47:59 +01:00
Maurice 315db3fa5a style(demo): format the phone welcome 2026-11-07 10:41:25 +01:00
Maurice 19ef38a377 fix(demo): the phone welcome scales with the text size and is paired with the desktop one
MDemoWelcome used raw rem sizes, which ignore the text size setting and broke
theme:lint, and was missing from feature-pairs.json, which broke lint:pairs.
2026-11-07 10:05:23 +01:00
Maurice 7797a9a562 fix(demo): the phone welcome lives with the phone dashboard, not under components
components/ may not import from mobile/ (lint:layers). The phone dashboard
mounts the sheet itself, as it already mounted the welcome, and the desktop
dialog no longer asks which shell it is in.
2026-11-07 00:33:16 +01:00
Maurice 55e4bfa11e fix(demo): the phone welcome sheet is opaque, so the dashboard does not show through its list 2026-11-07 00:22:04 +01:00
Maurice ad0fa41bb5 Revert "fix(demo): the phone welcome sheet is opaque, so the dashboard does not show through its list"
This reverts commit 7c91a609da.
2026-11-07 00:21:29 +01:00
Maurice 7c91a609da fix(demo): the phone welcome sheet is opaque, so the dashboard does not show through its list 2026-11-07 00:20:06 +01:00
Maurice 545eb650ca fix(demo): the welcome is a sheet on a phone, not the desktop dialog made narrow
On a phone the head took half the screen, the list scrolled in a small window
and the start button broke over two lines next to the links. The phone now gets
a bottom sheet of the phone shell: a small head with the countdown, the same
content as a list, a full-width start button with the two links under it. The
photo credits open in its place and back, as on the desktop.
2026-11-07 00:15:14 +01:00
Maurice 43f86f4559 test(booking-import): the per-leg seat tests pass the type check of the tests
An object literal with airplaneSeat inside the legs() call failed the excess
property check of tsconfig.tests.json; the nodes are built as constants first,
as the other seat tests do.
2026-11-07 00:12:56 +01:00
Maurice 25f2cd8192 fix(booking-import): every leg of a connecting flight keeps its own seat
The grouped flight wrote a seat only on the booking itself, from the first
segment. Each leg in metadata.legs now carries the seat of its own segment,
which the planner and the PDF already show per leg.
2026-11-06 23:50:53 +01:00
aaronspruit 7ae9198a43 fix(booking-import): read the seat that KItinerary gives
KItinerary writes a flight seat as airplaneSeat and a ticket seat as
reservedTicket.ticketedSeat.seatNumber, the schema.org fields, and it
removes seat and seatNumber from a script result. segmentDetail read only
seat and seatNumber, so an imported booking lost its seat.

segmentDetail now also reads airplaneSeat, and then the ticket seat when
no other key has a value. seat and seatNumber stay first, so a booking
that already has a seat gets the same result.
2026-11-06 23:49:31 +01:00
Maurice 8f3a8bd670 fix(ci): the carousel tests at the 90 ms settle, the search type, the hook back under its line limit
- The carousel and timeline tests step by the new 90 ms settle
- The journey search keeps its suggestion type through the fallback
- nearestCardIndex leaves useJourneyCarouselSync, which had grown past its
  line limit; WidgetBag is not exported; the test baseline drops by the
  non-null assertions a test no longer needs
2026-11-06 22:51:15 +01:00
Maurice e0c3642874 fix(journey): the location search suggests as you type, like the planner
The journal searched the full text only, which for a hotel in Osaka listed
the Japanese names the index holds. It now asks the planner's typed-ahead
search first (usePlaceSuggestions, the user's language, one billed session
per search), shows each place with where it is, and takes its point from the
details once picked; the full search still answers when there are no
suggestions. (#3003)
2026-11-06 22:28:37 +01:00
Maurice 25091dbd60 fix(mobile): switches keep their knob inside the track at every text size
The track was w-10, which is rem and followed the text size setting, while
the knob and its travel are px; with a smaller text size the knob hung over
the end of the track. The track is 40 px now.
2026-11-06 21:57:10 +01:00
Maurice fe7847fc08 feat(native): pick the trip a widget shows in Edit Widget
Widgets followed the dashboard's trip only, which is the one under way, so a
packing list or to-dos for a trip still being planned could not sit on the
home screen. The trip, today, bookings, to-dos, packing and group widgets now
take a trip in Edit Widget (an AppIntent with the trips running or ahead,
Automatic by default, which keeps following the dashboard); the world map
needs none.

- The app writes a snapshot per trip running or ahead (at most six, the
  dashboard's order) and the list to pick from, under their own keys
  (feature widgetTrips, so an older binary never mistakes one for the
  dashboard's); bags, chat and polls are kept ten minutes, covers per trip
- The widget extension targets iOS 17, which configurable widgets need; the
  apps are not released, so no installed widget goes away
- The choices and their texts in the 28 languages of the gallery
2026-11-06 21:41:21 +01:00
Maurice 77b9252577 fix(journey): the card under the finger grows while swiping, and the zoom buttons really go on touch screens
- The entry carousel draws the card at the centre larger while the swipe is
  still moving (focusIndex, a transform), and the active card and the map
  follow 90 ms after it stops instead of 150; the grow takes 200 ms
- The journey map's zoom buttons set their display inline, which beat the
  class that hides them on touch screens; the display is a class now (#3001)
- The packing widget data no longer carries a colour literal
2026-11-06 21:30:27 +01:00
Maurice 3364c0d131 chore(native): the lock screen mockup without the preview's boxes, the photo kept to the screen 2026-11-06 21:03:27 +01:00
Maurice deabaf7398 chore(native): home and lock screen mockups of the widgets in the preview renderer 2026-11-06 21:03:27 +01:00
Maurice 9b780d6b7e fix(native): whole initials in the group avatars, a smooth glow across the world map 2026-11-06 21:03:26 +01:00
Maurice 93727084ec feat(native): packing, world map and travel group widgets
- Packing: a ring of what is in the bags, each bag against its weight limit,
  the items still missing in their bag's colour, a nudge when departure is
  two days or less away
- World map: the world as dots from the Atlas country shapes
  (WorldGrid.swift, generated by WidgetPreviews/make-world-grid.py), the
  visited countries glowing, the latest one ringed, continents as bars and
  the latest countries with their flags; it needs no trip
- Travel group: who comes along as avatars, the latest chat messages as
  bubbles and the open poll with its votes as bars
- The to-dos widget is about to-dos only; the packing ring moved out

The app sends the bags, the Atlas countries (an hour's cache) and the chat
and poll with the snapshot; all optional, as before.
2026-11-06 21:03:26 +01:00
Maurice ed178f3b5e fix(journey): the shared journey map reaches the screen edge, and desktop readers get the day picker
- On a phone the shared journey's map stopped at the safe area, so Safari's
  toolbar sat over a bare strip; it now runs to the edge (#3002)
- The shared journey on desktop gains the journal's day picker, every day and
  its entries one click away (#2998)
2026-11-06 20:52:07 +01:00
Maurice f46f6f38fc fix(journey): no zoom buttons on the journey map on touch screens
On a phone they sat under the bottom bar, and a finger zooms with a pinch
anyway; devices with a mouse or trackpad keep them.
2026-11-06 20:45:15 +01:00
Maurice ae082c1fd0 fix(native): the widgets show the trip the dashboard features
With several trips running at once, the widgets took whichever came first
on the device, so an old test trip spanning today pushed out the journey
under way, and the today widget counted its days. The dashboard's choice
moves to utils/heroTrip.ts (running first, earliest start, else the next to
come) and the widgets ask the same function, so the home screen always
shows the trip the app opens on.
2026-11-06 20:25:41 +01:00
Maurice 6b8eb939c3 fix(journey): the entry carousel no longer jolts when a swipe lands
The active card grew its box from 136 to 164 px once the swipe had settled,
which moved every card after it and made the snap pull the strip a second
time. Every card now keeps the same box and the active one is drawn larger
with a transform, so nothing moves; the strips keep the room with a wider gap
and top padding. Where the browser reports scrollend, the card grows the
moment the snap lands instead of a beat later.

TestFlight builds now carry the release tag's message as What to Test.
2026-11-06 20:12:26 +01:00
114 changed files with 3976 additions and 244 deletions
+13
View File
@@ -161,6 +161,19 @@ jobs:
# development profile, which needs a registered device), so client/fastlane/Fastfile
# imports the stored distribution certificate and creates the App Store profile
# through the API key.
# The tag's message, past its subject line, is what testers read under
# "What to Test": one short line per change worth trying.
- name: TestFlight notes from the tag
if: startsWith(github.ref, 'refs/tags/')
run: |
git fetch --force --depth=1 origin "+${GITHUB_REF}:${GITHUB_REF}"
{
echo 'TESTFLIGHT_NOTES<<TREK_NOTES_END'
# A signed tag carries its signature at the end of the message.
git for-each-ref "$GITHUB_REF" --format='%(contents:body)' | sed '/-----BEGIN .*SIGNATURE-----/,$d'
echo 'TREK_NOTES_END'
} >> "$GITHUB_ENV"
- name: Build and upload to TestFlight
working-directory: client
env:
+2 -2
View File
@@ -150,9 +150,9 @@ jobs:
working-directory: client/ios/WidgetPreviews
run: |
mkdir -p Sources/WidgetPreviews/Widgets
cp ../App/TREKWidgets/WidgetSnapshot.swift ../App/TREKWidgets/TrekStyle.swift ../App/TREKWidgets/TrekFamily.swift \
cp ../App/TREKWidgets/WidgetSnapshot.swift ../App/TREKWidgets/TrekStyle.swift ../App/TREKWidgets/TrekFamily.swift ../App/TREKWidgets/WorldGrid.swift \
../App/TREKWidgets/*Content.swift Sources/WidgetPreviews/Widgets/
swift run -c release WidgetPreviews out
swift run -c release WidgetPreviews out "$PWD/../App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png"
- uses: actions/upload-artifact@v6
with:
+5 -1
View File
@@ -103,9 +103,13 @@ platform :ios do
# Upload to App Store Connect; it shows up in TestFlight after Apple finishes processing.
# Internal testing needs no App Review, so nothing here submits the app for review.
# "What to Test" comes from the release tag's message (native-release.yml). Apple only
# takes it once the build is processed, so with notes the lane waits for that.
notes = ENV.fetch("TESTFLIGHT_NOTES", "").strip
upload_to_testflight(
api_key: key,
skip_waiting_for_build_processing: true,
changelog: notes.empty? ? nil : notes,
skip_waiting_for_build_processing: notes.empty?,
skip_submission: true,
)
end
+22 -2
View File
@@ -26,6 +26,11 @@
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 */; };
D3C699E2258FBA6A11C1DDE4 /* TripIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */; };
157A7B737E3FFB610A218D47 /* AtlasContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D004544572B0E85DE29A7B8 /* AtlasContent.swift */; };
FEB91385917AC44D5CDAA59C /* PackingContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C0771C8582246A26E0C98E /* PackingContent.swift */; };
EA5EDDD4E5F9BC9BC46C563F /* CollabContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D5041DDDAFF5B491B053999 /* CollabContent.swift */; };
45B990B86488D5CC2B5222B5 /* WorldGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC751335E0136865E08990 /* WorldGrid.swift */; };
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = A030A06726542A2D7F61C54B /* TrekFamily.swift */; };
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
@@ -104,6 +109,11 @@
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>"; };
8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripIntent.swift; sourceTree = "<group>"; };
4D004544572B0E85DE29A7B8 /* AtlasContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtlasContent.swift; sourceTree = "<group>"; };
04C0771C8582246A26E0C98E /* PackingContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackingContent.swift; sourceTree = "<group>"; };
7D5041DDDAFF5B491B053999 /* CollabContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CollabContent.swift; sourceTree = "<group>"; };
D0AC751335E0136865E08990 /* WorldGrid.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorldGrid.swift; sourceTree = "<group>"; };
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekStyle.swift; sourceTree = "<group>"; };
A030A06726542A2D7F61C54B /* TrekFamily.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekFamily.swift; sourceTree = "<group>"; };
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripContent.swift; sourceTree = "<group>"; };
@@ -254,6 +264,11 @@
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */,
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */,
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */,
8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */,
4D004544572B0E85DE29A7B8 /* AtlasContent.swift */,
04C0771C8582246A26E0C98E /* PackingContent.swift */,
7D5041DDDAFF5B491B053999 /* CollabContent.swift */,
D0AC751335E0136865E08990 /* WorldGrid.swift */,
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */,
A030A06726542A2D7F61C54B /* TrekFamily.swift */,
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */,
@@ -458,6 +473,11 @@
492FA24FBB57059B0B5007CE /* TripDayAttributes.swift in Sources */,
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */,
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */,
D3C699E2258FBA6A11C1DDE4 /* TripIntent.swift in Sources */,
157A7B737E3FFB610A218D47 /* AtlasContent.swift in Sources */,
FEB91385917AC44D5CDAA59C /* PackingContent.swift in Sources */,
EA5EDDD4E5F9BC9BC46C563F /* CollabContent.swift in Sources */,
45B990B86488D5CC2B5222B5 /* WorldGrid.swift in Sources */,
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */,
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */,
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */,
@@ -548,7 +568,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -779,7 +799,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
+143 -2
View File
@@ -3,6 +3,7 @@ import Capacitor
import CoreLocation
import CryptoKit
import Security
import StoreKit
import UserNotifications
import WidgetKit
#if canImport(ActivityKit)
@@ -39,6 +40,10 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "setWidgetSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "syncTripDay", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "endTripDay", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storeProducts", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storePurchase", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise),
]
/// What this binary offers the server's UI, raised with every native feature
@@ -46,7 +51,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
static let shellApi = 2
/// The native features by name, so the UI can turn each on by itself.
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity"]
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit"]
// MARK: - Shared booking documents
@@ -57,18 +62,26 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
TrekPush.shared.onOpen = { [weak self] url in
DispatchQueue.main.async { self?.notifyListeners("pushOpened", data: ["url": url]) }
}
TrekStore.watchUpdates()
}
// 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.
/// `key` (feature widgetTrips) stores one trip's snapshot ("trip.<id>") or the
/// list of trips a widget can be set to ("trips"); without it, the dashboard's.
@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)
let key = call.getString("key") ?? "snapshot"
guard key == "snapshot" || key == "trips" || key.range(of: "^trip\\.[0-9]{1,12}$", options: .regularExpression) != nil else {
call.reject("key must be snapshot, trips or trip.<id>")
return
}
TrekSharedKeychain.write("widget.\(key)", data)
WidgetCenter.shared.reloadAllTimelines()
call.resolve()
}
@@ -368,6 +381,134 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
/// When-In-Use permission asked the first time.
// MARK: - In-app purchase
extension TrekShellPlugin {
/// Feature storeKit: the App Store's own price strings for the given products,
/// already in the buyer's currency and format. Products the store does not
/// know are left out, so the shell offers only what can really be bought.
@objc func storeProducts(_ call: CAPPluginCall) {
let ids = call.getArray("ids", String.self) ?? []
Task {
guard AppStore.canMakePayments, let products = try? await Product.products(for: ids) else {
call.resolve(["products": []])
return
}
call.resolve(["products": products.map { ["id": $0.id, "displayPrice": $0.displayPrice] }])
}
}
/// Feature storeKit: runs the App Store purchase sheet. Resolves with `kind`
/// bought (with the signed transaction the control plane verifies), cancelled,
/// pending (Ask to Buy, a payment the bank still has to approve) or failed.
/// The transaction stays unfinished until storeFinish, so a purchase whose
/// instance was never set up is delivered again rather than lost.
@objc func storePurchase(_ call: CAPPluginCall) {
guard let id = call.getString("productId") else {
call.reject("productId is required")
return
}
Task {
do {
guard let product = try await Product.products(for: [id]).first else {
call.resolve(["kind": "failed"])
return
}
switch try await product.purchase() {
case .success(let verification):
guard case .verified(let transaction) = verification else {
call.resolve(["kind": "failed"])
return
}
call.resolve([
"kind": "bought",
"jws": verification.jwsRepresentation,
"transactionId": String(transaction.id),
])
case .userCancelled:
call.resolve(["kind": "cancelled"])
case .pending:
call.resolve(["kind": "pending"])
@unknown default:
call.resolve(["kind": "failed"])
}
} catch {
call.resolve(["kind": "failed"])
}
}
}
/// Feature storeKit: marks a purchase as delivered, once its instance exists.
@objc func storeFinish(_ call: CAPPluginCall) {
guard let raw = call.getString("transactionId"), let id = UInt64(raw) else {
call.reject("transactionId is required")
return
}
Task {
await TrekStore.finish(id)
call.resolve()
}
}
/// Feature storeKit: the newest active subscription on this Apple ID among
/// products starting with `prefix`, after syncing with the App Store. Resolves
/// without `jws` when there is none.
@objc func storeRestore(_ call: CAPPluginCall) {
let prefix = call.getString("prefix") ?? "cloud."
Task {
// Asks for the Apple ID password when needed; a cancel still leaves the local entitlements to check.
try? await AppStore.sync()
guard let found = await TrekStore.currentEntitlement(prefix: prefix) else {
call.resolve([:])
return
}
call.resolve(["jws": found.jws, "transactionId": String(found.id)])
}
}
}
enum TrekStore {
private static var updates: Task<Void, Never>?
/// Listens for transactions that arrive outside a purchase sheet. Renewals are
/// finished at once: the control plane learns of them from Apple's server
/// notifications. A first purchase that lands here (Ask to Buy approved later,
/// a purchase interrupted by the app closing) stays unfinished for the shell
/// to redeem through a restore.
static func watchUpdates() {
guard updates == nil else { return }
updates = Task.detached {
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
if transaction.originalID != transaction.id {
await transaction.finish()
}
}
}
}
static func finish(_ id: UInt64) async {
for await result in Transaction.unfinished {
if case .verified(let transaction) = result, transaction.id == id {
await transaction.finish()
}
}
}
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64)? {
var newest: (date: Date, jws: String, id: UInt64)?
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result,
transaction.productID.hasPrefix(prefix),
transaction.revocationDate == nil else { continue }
if newest == nil || transaction.purchaseDate > newest!.date {
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id)
}
}
return newest.map { ($0.jws, $0.id) }
}
}
final class TrekLocation: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private let emit: ([String: Any]) -> Void
@@ -0,0 +1,253 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
/// The Atlas on the home screen: the world as dots, the visited countries
/// glowing, the latest one ringed, and how many there are.
struct AtlasContent: View {
let snapshot: WidgetSnapshot?
let family: TrekFamily
@Environment(\.colorScheme) private var scheme
var body: some View {
let atlas = snapshot?.atlas
let labels = snapshot?.labels
let visited = Set(atlas?.codes ?? [])
let latest = atlas?.recent.first?.code
switch family {
case .circular:
ZStack {
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
Circle().trim(from: 0, to: min(1, Double(atlas?.count ?? 0) / 195))
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: -2) {
Image(systemName: "globe.europe.africa.fill").font(.system(size: 9, weight: .bold))
Text("\(atlas?.count ?? 0)").font(Trek.number(19, .heavy))
}
}
.padding(3)
case .rectangular:
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "globe.europe.africa.fill").font(.system(size: 11, weight: .bold))
Text("\(atlas?.count ?? 0) \(labels?.countries ?? "")").font(.system(size: 14, weight: .heavy))
}
DotWorld(visited: visited, latest: latest, accent: [.white], rest: Color.white.opacity(0.3), dot: 1.3)
.frame(height: 34)
}
.frame(maxWidth: .infinity, alignment: .leading)
case .inline:
Text("\(Image(systemName: "globe.europe.africa.fill")) \(atlas?.count ?? 0) \(labels?.countries ?? "")")
case .small:
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
}
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
Spacer(minLength: 6)
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 1.35)
.frame(height: 64)
}
case .medium:
VStack(alignment: .leading, spacing: 8) {
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 2.2)
.frame(maxHeight: .infinity)
HStack(alignment: .lastTextBaseline, spacing: 6) {
Text("\(atlas?.count ?? 0)").font(Trek.number(30, .heavy)).foregroundStyle(ink)
VStack(alignment: .leading, spacing: 0) {
Text(labels?.countries ?? "").font(.system(size: 12, weight: .bold)).foregroundStyle(ink)
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 10.5, weight: .semibold)).foregroundStyle(muted)
}
Spacer(minLength: 4)
if let recent = atlas?.recent.first {
CountryPill(country: recent, highlight: true)
}
}
}
default:
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .lastTextBaseline, spacing: 6) {
Text("\(atlas?.count ?? 0)").font(Trek.number(40, .heavy)).foregroundStyle(ink)
VStack(alignment: .leading, spacing: 0) {
Text(labels?.countries ?? "").font(.system(size: 14, weight: .heavy)).foregroundStyle(ink)
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
}
Spacer(minLength: 4)
if let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) }
if let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) }
}
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 2.2)
.frame(height: 150)
ContinentBars(continents: atlas?.continents ?? [], scheme: scheme)
HStack(spacing: 6) {
ForEach(Array((atlas?.recent ?? []).prefix(3).enumerated()), id: \.offset) { index, country in
CountryPill(country: country, highlight: index == 0)
}
Spacer(minLength: 0)
}
}
}
}
private var ink: Color { Trek.ink(scheme) }
private var muted: Color { Trek.muted(scheme) }
private var glow: [Color] { [Color(hex: 0x3DDC97), Color(hex: 0x4A7DDB)] }
private var rest: Color { scheme == .dark ? Color.white.opacity(0.13) : Color(hex: 0x101013).opacity(0.11) }
/// "12 % of the world", of the 195 countries there are.
private func share(_ count: Int, _ labels: WidgetSnapshot.Labels?) -> String {
let percent = Int((Double(count) / 195 * 100).rounded())
return "\(percent) % \(labels?.world ?? "")"
}
}
/// The world map as a grid of dots (WorldGrid): visited countries in the
/// accent from west to east, the rest faint, the latest one ringed.
struct DotWorld: View {
let visited: Set<String>
let latest: String?
let accent: [Color]
let rest: Color
/// The dot radius at a 364 pt wide map; scaled with the width.
var dot: CGFloat = 2
var body: some View {
Canvas { context, size in
let columns = CGFloat(WorldGrid.columns)
let rows = CGFloat(WorldGrid.rows.count)
let step = min(size.width / columns, size.height / rows)
let originX = (size.width - step * columns) / 2
let originY = (size.height - step * rows) / 2
let radius = max(0.6, step * 0.36 * dot / 2)
var ringed: CGPoint?
var ringCount = 0
var ringSum = CGPoint.zero
for row in 0..<WorldGrid.rows.count {
for column in 0..<WorldGrid.columns {
guard let code = WorldGrid.code(row: row, column: column) else { continue }
let center = CGPoint(x: originX + (CGFloat(column) + 0.5) * step, y: originY + (CGFloat(row) + 0.5) * step)
let rect = CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2)
if visited.contains(code) {
let t = CGFloat(column) / columns
context.fill(Path(ellipseIn: rect.insetBy(dx: -radius * 0.25, dy: -radius * 0.25)), with: .color(blend(t)))
if code == latest {
ringSum.x += center.x
ringSum.y += center.y
ringCount += 1
}
} else {
context.fill(Path(ellipseIn: rect), with: .color(rest))
}
}
}
if ringCount > 0 { ringed = CGPoint(x: ringSum.x / CGFloat(ringCount), y: ringSum.y / CGFloat(ringCount)) }
if let ringed {
let color = accent.last ?? .white
for (scale, alpha) in [(5.5, 0.18), (3.6, 0.35)] as [(CGFloat, Double)] {
let r = step * scale / 2
context.stroke(
Path(ellipseIn: CGRect(x: ringed.x - r, y: ringed.y - r, width: r * 2, height: r * 2)),
with: .color(color.opacity(alpha)), lineWidth: max(1, step * 0.22)
)
}
let core = step * 0.55
context.fill(Path(ellipseIn: CGRect(x: ringed.x - core, y: ringed.y - core, width: core * 2, height: core * 2)), with: .color(.white))
}
}
}
/// The accent at a point from west (0) to east (1), blended between its colours.
private func blend(_ t: CGFloat) -> Color {
guard accent.count > 1, let from = rgb(accent[0]), let to = rgb(accent[accent.count - 1]) else { return accent.first ?? .white }
let k = Double(max(0, min(1, t)))
return Color(.sRGB, red: from.0 + (to.0 - from.0) * k, green: from.1 + (to.1 - from.1) * k, blue: from.2 + (to.2 - from.2) * k)
}
private func rgb(_ color: Color) -> (Double, Double, Double)? {
#if canImport(UIKit)
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
guard UIColor(color).getRed(&r, green: &g, blue: &b, alpha: &a) else { return nil }
return (Double(r), Double(g), Double(b))
#elseif canImport(AppKit)
guard let c = NSColor(color).usingColorSpace(.sRGB) else { return nil }
return (Double(c.redComponent), Double(c.greenComponent), Double(c.blueComponent))
#else
return nil
#endif
}
}
/// A country with its flag, the latest one tinted.
struct CountryPill: View {
let country: WidgetSnapshot.Atlas.Country
var highlight = false
@Environment(\.colorScheme) private var scheme
var body: some View {
HStack(spacing: 5) {
Text(flag(country.code)).font(.system(size: 13))
Text(country.name).font(.system(size: 11.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
}
.padding(.horizontal, 9)
.padding(.vertical, 4)
.background(Capsule().fill(highlight ? Color(hex: 0x3DDC97).opacity(scheme == .dark ? 0.2 : 0.16) : Trek.card(scheme)))
.overlay(Capsule().stroke(highlight ? Color(hex: 0x3DDC97).opacity(0.45) : Trek.cardBorder(scheme), lineWidth: 0.7))
}
private func flag(_ code: String) -> String {
code.uppercased().unicodeScalars.compactMap { UnicodeScalar(127_397 + $0.value) }.map { String($0) }.joined()
}
}
/// The continents as bars, longest first.
struct ContinentBars: View {
let continents: [WidgetSnapshot.Atlas.Continent]
let scheme: ColorScheme
var body: some View {
let top = continents.prefix(4)
let most = max(top.map(\.count).max() ?? 1, 1)
VStack(spacing: 6) {
ForEach(Array(top.enumerated()), id: \.offset) { index, continent in
HStack(spacing: 8) {
Text(continent.name).font(.system(size: 11, weight: .bold)).foregroundStyle(Trek.ink(scheme))
.frame(width: 92, alignment: .leading).lineLimit(1)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Trek.track(scheme))
Capsule()
.fill(LinearGradient(colors: [Color(hex: 0x3DDC97), Color(hex: 0x4A7DDB)], startPoint: .leading, endPoint: .trailing))
.frame(width: max(6, geo.size.width * CGFloat(continent.count) / CGFloat(most)))
.opacity(1 - Double(index) * 0.15)
}
}
.frame(height: 6)
Text("\(continent.count)").font(Trek.number(11, .heavy)).foregroundStyle(Trek.muted(scheme)).frame(width: 20, alignment: .trailing)
}
}
}
}
}
/// A figure with its symbol, for the large atlas header.
private struct Stat: View {
let value: String
let symbol: String
let scheme: ColorScheme
var body: some View {
HStack(spacing: 3) {
Image(systemName: symbol).font(.system(size: 10, weight: .bold))
Text(value).font(Trek.number(12, .heavy))
}
.foregroundStyle(Trek.muted(scheme))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Trek.card(scheme)))
}
}
@@ -0,0 +1,207 @@
import SwiftUI
/// The travel group: who comes along, the latest messages in the trip chat
/// as bubbles, and the open poll with its votes as bars.
struct CollabContent: View {
let snapshot: WidgetSnapshot?
let family: TrekFamily
@Environment(\.colorScheme) private var scheme
var body: some View {
let collab = snapshot?.collab
let labels = snapshot?.labels
if let collab, let labels {
switch family {
case .small:
VStack(alignment: .leading, spacing: 0) {
Text(labels.group ?? "").trekEyebrow(scheme)
AvatarStack(members: collab.members, size: 30, scheme: scheme).padding(.top, 8)
Spacer(minLength: 0)
if let poll = collab.poll {
Label(labels.poll ?? "", systemImage: "chart.bar.fill")
.font(.system(size: 10.5, weight: .heavy))
.foregroundStyle(Trek.info)
Text(poll.question).font(.system(size: 13.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
} else if let last = collab.messages.last {
Text(last.author).font(.system(size: 10.5, weight: .heavy)).foregroundStyle(Trek.muted(scheme))
Text(last.text).font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(3)
} else {
Text(labels.noMessages ?? "").font(.system(size: 12.5, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
}
}
case .medium:
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(snapshot?.trip?.title ?? labels.group ?? "").trekEyebrow(scheme)
Spacer(minLength: 4)
AvatarStack(members: collab.members, size: 22, scheme: scheme)
}
bubbles(Array(collab.messages.suffix(2)), labels)
Spacer(minLength: 0)
}
case .large:
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 2) {
Text(labels.group ?? "").trekEyebrow(scheme)
if let trip = snapshot?.trip {
Text(trip.title).font(.system(size: 16, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
}
}
Spacer(minLength: 4)
AvatarStack(members: collab.members, size: 28, scheme: scheme)
}
bubbles(Array(collab.messages.suffix(3)), labels)
Spacer(minLength: 0)
if let poll = collab.poll { PollCard(poll: poll, labels: labels, scheme: scheme) }
}
default:
accessory(collab, labels)
}
} else {
VStack(alignment: .leading, spacing: 6) {
Text(labels?.group ?? "").trekEyebrow(scheme)
Spacer(minLength: 0)
Image(systemName: "person.3.fill").font(.system(size: 28)).foregroundStyle(Trek.muted(scheme).opacity(0.5))
Text(labels?.noMessages ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
}
@ViewBuilder
private func bubbles(_ messages: [WidgetSnapshot.Collab.Message], _ labels: WidgetSnapshot.Labels) -> some View {
if messages.isEmpty {
Text(labels.noMessages ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
} else {
VStack(spacing: 6) {
ForEach(Array(messages.enumerated()), id: \.offset) { _, message in
ChatBubble(message: message, scheme: scheme)
}
}
}
}
@ViewBuilder
private func accessory(_ collab: WidgetSnapshot.Collab, _ labels: WidgetSnapshot.Labels) -> some View {
if let last = collab.messages.last {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: "bubble.left.and.bubble.right.fill").font(.system(size: 11, weight: .bold))
Text(last.author).font(.system(size: 12, weight: .bold)).lineLimit(1)
}
Text(last.text).font(.system(size: 13, weight: .semibold)).lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Text(labels.noMessages ?? "").font(.system(size: 12, weight: .medium)).frame(maxWidth: .infinity, alignment: .leading)
}
}
}
extension Trek {
/// A colour of a person's own, from their name.
static func personColor(_ name: String) -> Color {
let palette: [UInt32] = [0x4A7DDB, 0x2FA37A, 0xE8A13A, 0x8E6CD9, 0xD6408B, 0x2BA6A6, 0xD6673B, 0x5A6CDE]
return Color(hex: palette[Int(seed(name) % UInt64(palette.count))])
}
}
/// Overlapping initials in each person's colour, with a count for the rest.
struct AvatarStack: View {
let members: [WidgetSnapshot.Collab.Member]
let size: CGFloat
let scheme: ColorScheme
var body: some View {
HStack(spacing: -size * 0.18) {
ForEach(Array(members.prefix(4).enumerated()), id: \.offset) { _, member in
Circle()
.fill(LinearGradient(colors: [Trek.personColor(member.name), Trek.personColor(member.name).opacity(0.75)], startPoint: .top, endPoint: .bottom))
.frame(width: size, height: size)
.overlay(Text(member.initials).font(.system(size: size * 0.34, weight: .heavy)).minimumScaleFactor(0.7).lineLimit(1).foregroundStyle(.white).padding(.horizontal, size * 0.1))
.overlay(Circle().stroke(scheme == .dark ? Color(hex: 0x111115) : Color(hex: 0xF4F2EE), lineWidth: 2))
}
if members.count > 4 {
Circle()
.fill(Trek.card(scheme))
.frame(width: size, height: size)
.overlay(Text("+\(members.count - 4)").font(.system(size: size * 0.34, weight: .heavy)).foregroundStyle(Trek.muted(scheme)))
.overlay(Circle().stroke(scheme == .dark ? Color(hex: 0x111115) : Color(hex: 0xF4F2EE), lineWidth: 2))
}
}
}
}
/// A chat message: the others on the left with their initials, mine on the right in ink.
struct ChatBubble: View {
let message: WidgetSnapshot.Collab.Message
let scheme: ColorScheme
var body: some View {
HStack(alignment: .bottom, spacing: 6) {
if message.mine { Spacer(minLength: 36) }
if !message.mine {
Circle()
.fill(Trek.personColor(message.author))
.frame(width: 20, height: 20)
.overlay(Text(message.initials).font(.system(size: 8, weight: .heavy)).foregroundStyle(.white))
}
Text(message.text)
.font(.system(size: 12.5, weight: .medium))
.foregroundStyle(message.mine ? (scheme == .dark ? Color(hex: 0x101013) : .white) : Trek.ink(scheme))
.lineLimit(2)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(message.mine ? Trek.ink(scheme) : Trek.card(scheme))
)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(message.mine ? Color.clear : Trek.cardBorder(scheme), lineWidth: 0.7)
)
if !message.mine { Spacer(minLength: 36) }
}
}
}
/// The open poll: the question and each option as a bar of its votes.
struct PollCard: View {
let poll: WidgetSnapshot.Collab.Poll
let labels: WidgetSnapshot.Labels
let scheme: ColorScheme
var body: some View {
let most = max(poll.options.map(\.votes).max() ?? 1, 1)
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 5) {
Image(systemName: "chart.bar.fill").font(.system(size: 10, weight: .bold)).foregroundStyle(Trek.info)
Text(labels.poll ?? "").font(.system(size: 10.5, weight: .heavy)).textCase(.uppercase).foregroundStyle(Trek.info)
Spacer(minLength: 0)
Text("\(poll.voters) \(labels.votes ?? "")").font(.system(size: 10.5, weight: .bold)).foregroundStyle(Trek.muted(scheme))
}
Text(poll.question).font(.system(size: 13.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
ForEach(Array(poll.options.prefix(3).enumerated()), id: \.offset) { _, option in
let leading = option.votes == most && option.votes > 0
ZStack(alignment: .leading) {
GeometryReader { geo in
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(leading ? Trek.info.opacity(scheme == .dark ? 0.35 : 0.22) : Trek.track(scheme))
.frame(width: max(8, geo.size.width * CGFloat(option.votes) / CGFloat(most)))
}
HStack {
Text(option.label).font(.system(size: 12, weight: leading ? .heavy : .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
Spacer(minLength: 4)
Text("\(option.votes)").font(Trek.number(12, .heavy)).foregroundStyle(Trek.muted(scheme))
}
.padding(.horizontal, 9)
}
.frame(height: 24)
}
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 15, style: .continuous).fill(Trek.card(scheme)))
.overlay(RoundedRectangle(cornerRadius: 15, style: .continuous).stroke(Trek.cardBorder(scheme), lineWidth: 0.7))
}
}
@@ -1,7 +1,8 @@
import SwiftUI
import WidgetKit
/// The home-screen and lock-screen widgets; their gallery names and
/// The home-screen and lock-screen widgets; all but the Atlas show one trip,
/// the dashboard's or the one picked in Edit Widget (TripIntent.swift). Their gallery names and
/// descriptions are in <language>.lproj/Localizable.strings. Each reads the snapshot the app
/// last wrote (WidgetSnapshot) and draws it with the views in *Content.swift,
/// which know nothing of WidgetKit so the preview renderer can draw them too.
@@ -27,7 +28,10 @@ struct TrekProvider: TimelineProvider {
/// the next stop move on without the app; the app reloads the timeline
/// whenever it writes a new snapshot.
func getTimeline(in context: Context, completion: @escaping (Timeline<TrekEntry>) -> Void) {
let snapshot = WidgetSnapshot.load()
completion(TrekProvider.timeline(WidgetSnapshot.load()))
}
static func timeline(_ snapshot: WidgetSnapshot?) -> Timeline<TrekEntry> {
let now = Date()
let calendar = Calendar.current
let minute = calendar.component(.minute, from: now)
@@ -38,7 +42,7 @@ struct TrekProvider: TimelineProvider {
entries.append(TrekEntry(date: calendar.date(bySetting: .second, value: 0, of: date) ?? date, snapshot: snapshot))
}
}
completion(Timeline(entries: entries, policy: .atEnd))
return Timeline(entries: entries, policy: .atEnd)
}
}
@@ -83,7 +87,7 @@ struct TrekContainer<Content: View, Background: View>: View {
struct NextTripWidget: Widget {
var body: some WidgetConfiguration {
// The kind stays "NextTrip" so widgets placed before the redesign keep working.
StaticConfiguration(kind: "NextTrip", provider: TrekProvider()) { entry in
AppIntentConfiguration(kind: "NextTrip", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TripWidgetView(entry: entry)
}
.configurationDisplayName("widget.trip.name")
@@ -110,7 +114,7 @@ struct TripWidgetView: View {
struct TodayWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "TripToday", provider: TrekProvider()) { entry in
AppIntentConfiguration(kind: "TripToday", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TrekContainer(link: entry.snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) }) {
TodayFamilyView(entry: entry)
} background: {
@@ -133,7 +137,7 @@ private struct TodayFamilyView: View {
struct BookingsWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "TripBookings", provider: TrekProvider()) { entry in
AppIntentConfiguration(kind: "TripBookings", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TrekContainer(link: entry.snapshot?.bookings?.first.flatMap { WidgetSnapshot.link($0.path) }) {
BookingsFamilyView(entry: entry)
} background: {
@@ -156,7 +160,7 @@ private struct BookingsFamilyView: View {
struct TodosWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "TripTodos", provider: TrekProvider()) { entry in
AppIntentConfiguration(kind: "TripTodos", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TrekContainer(link: entry.snapshot?.todos.flatMap { WidgetSnapshot.link($0.path) }) {
TodosFamilyView(entry: entry)
} background: {
@@ -174,3 +178,72 @@ private struct TodosFamilyView: View {
let entry: TrekEntry
var body: some View { TodosContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
}
// MARK: - Packing
struct PackingWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: "TripPacking", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TrekContainer(link: entry.snapshot?.packing.flatMap { WidgetSnapshot.link($0.path) }) {
PackingFamilyView(entry: entry)
} background: {
TrekPaper()
}
}
.configurationDisplayName("widget.packing.name")
.description("widget.packing.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular])
}
}
private struct PackingFamilyView: View {
@Environment(\.widgetFamily) private var family
let entry: TrekEntry
var body: some View { PackingContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
}
// MARK: - Atlas
struct AtlasWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "TripAtlas", provider: TrekProvider()) { entry in
TrekContainer(link: WidgetSnapshot.link("/atlas")) {
AtlasFamilyView(entry: entry)
} background: {
TrekPaper(contours: false)
}
}
.configurationDisplayName("widget.atlas.name")
.description("widget.atlas.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
private struct AtlasFamilyView: View {
@Environment(\.widgetFamily) private var family
let entry: TrekEntry
var body: some View { AtlasContent(snapshot: entry.snapshot, family: TrekFamily(family)) }
}
// MARK: - Travel group
struct CollabWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: "TripCollab", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TrekContainer(link: entry.snapshot?.collab.flatMap { WidgetSnapshot.link($0.path) }) {
CollabFamilyView(entry: entry)
} background: {
TrekPaper()
}
}
.configurationDisplayName("widget.collab.name")
.description("widget.collab.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular])
}
}
private struct CollabFamilyView: View {
@Environment(\.widgetFamily) private var family
let entry: TrekEntry
var body: some View { CollabContent(snapshot: entry.snapshot, family: TrekFamily(family)) }
}
@@ -0,0 +1,209 @@
import SwiftUI
/// The packing list: how much is in the bags, each bag against its weight
/// limit, and what is still missing, with a nudge when departure is close.
struct PackingContent: View {
let snapshot: WidgetSnapshot?
let family: TrekFamily
let now: Date
@Environment(\.colorScheme) private var scheme
var body: some View {
let packing = snapshot?.packing
let labels = snapshot?.labels
if family.isAccessory {
accessory(packing, labels)
} else if let packing, let labels, packing.total > 0 {
let missing = packing.total - packing.packed
switch family {
case .small:
VStack(alignment: .leading, spacing: 0) {
header(labels)
Spacer(minLength: 0)
HStack {
Spacer(minLength: 0)
ring(packing, size: 88)
Spacer(minLength: 0)
}
Spacer(minLength: 0)
Text("\(missing) \(labels.missing ?? "")")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Trek.muted(scheme))
.lineLimit(1)
}
case .medium:
HStack(spacing: 14) {
ring(packing, size: 104)
VStack(alignment: .leading, spacing: 6) {
header(labels)
missingList(Array((packing.missing ?? []).prefix(4)))
Spacer(minLength: 0)
}
}
default:
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 14) {
ring(packing, size: 86)
VStack(alignment: .leading, spacing: 3) {
header(labels)
if let trip = snapshot?.trip {
Text(trip.title).font(.system(size: 15, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
}
Text("\(missing) \(labels.missing ?? "")")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Trek.muted(scheme))
}
Spacer(minLength: 0)
}
VStack(spacing: 7) {
ForEach(Array((packing.bags ?? []).prefix(3).enumerated()), id: \.offset) { _, bag in
BagCard(bag: bag, scheme: scheme)
}
}
missingGrid(Array((packing.missing ?? []).prefix(8)))
Spacer(minLength: 0)
}
}
} else {
VStack(alignment: .leading, spacing: 6) {
header(labels)
Spacer(minLength: 0)
Image(systemName: "suitcase.rolling.fill").font(.system(size: 32)).foregroundStyle(Trek.muted(scheme).opacity(0.5))
Text(labels?.noTodos ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
}
/// The days to departure while it is two or fewer away.
private var departureSoon: Int? {
guard snapshot?.today == nil, let days = snapshot?.daysUntilStart(from: now), (0...2).contains(days) else { return nil }
return days
}
private func header(_ labels: WidgetSnapshot.Labels?) -> some View {
HStack(spacing: 6) {
Text(labels?.packing ?? "").trekEyebrow(scheme)
Spacer(minLength: 0)
if let days = departureSoon, let labels {
Text(days == 0 ? (labels.todayShort ?? labels.today) : days == 1 ? labels.tomorrow : labels.inDays.replacingOccurrences(of: "{n}", with: "\(days)"))
.font(.system(size: 10, weight: .heavy))
.foregroundStyle(.white)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(Capsule().fill(Trek.pending))
}
}
}
private func ring(_ packing: WidgetSnapshot.Packing, size: CGFloat) -> some View {
let share = Double(packing.packed) / Double(max(packing.total, 1))
return ProgressRing(value: share, lineWidth: size * 0.1, colors: [Color(hex: 0x2FA37A), Color(hex: 0x7BC96F)], trackColor: Trek.track(scheme)) {
VStack(spacing: -1) {
Image(systemName: "suitcase.rolling.fill").font(.system(size: size * 0.16, weight: .semibold)).foregroundStyle(Trek.confirmed)
Text("\(Int((share * 100).rounded()))%").font(Trek.number(size * 0.22, .heavy)).foregroundStyle(Trek.ink(scheme))
}
}
.frame(width: size, height: size)
}
private func missingList(_ items: [WidgetSnapshot.Packing.Item]) -> some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.stroke(Color(css: item.color) ?? Trek.faint(scheme), lineWidth: 1.7)
.frame(width: 13, height: 13)
Text(item.name).font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
}
}
}
}
private func missingGrid(_ items: [WidgetSnapshot.Packing.Item]) -> some View {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], alignment: .leading, spacing: 7) {
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
HStack(spacing: 7) {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.stroke(Color(css: item.color) ?? Trek.faint(scheme), lineWidth: 1.6)
.frame(width: 12, height: 12)
Text(item.name).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
}
}
}
}
@ViewBuilder
private func accessory(_ packing: WidgetSnapshot.Packing?, _ labels: WidgetSnapshot.Labels?) -> some View {
let total = packing?.total ?? 0
let packed = packing?.packed ?? 0
switch family {
case .circular:
ZStack {
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
Circle().trim(from: 0, to: total > 0 ? Double(packed) / Double(total) : 0)
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: -2) {
Image(systemName: "suitcase.rolling.fill").font(.system(size: 9, weight: .bold))
Text("\(total - packed)").font(Trek.number(18, .heavy))
}
}
.padding(3)
default:
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: "suitcase.rolling.fill").font(.system(size: 11, weight: .bold))
Text("\(labels?.packing ?? "") \(packed)/\(total)").font(.system(size: 12, weight: .bold))
}
ForEach(Array((packing?.missing ?? []).prefix(2).enumerated()), id: \.offset) { _, item in
Text("\(Image(systemName: "square")) \(item.name)").font(.system(size: 12, weight: .medium)).lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
/// A bag in its colour: how full, and its weight against the limit.
struct BagCard: View {
let bag: WidgetSnapshot.Packing.Bag
let scheme: ColorScheme
var body: some View {
let color = Color(css: bag.color) ?? Trek.info
let share = bag.limit.map { Double(bag.grams) / Double(max($0, 1)) }
HStack(spacing: 10) {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(LinearGradient(colors: [color, color.opacity(0.75)], startPoint: .top, endPoint: .bottom))
.frame(width: 30, height: 30)
.overlay(Image(systemName: "bag.fill").font(.system(size: 13, weight: .semibold)).foregroundStyle(.white))
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(bag.name).font(.system(size: 12.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
Spacer(minLength: 4)
Text(weight(bag)).font(Trek.number(11.5, .heavy)).foregroundStyle((share ?? 0) > 1 ? Trek.danger : Trek.muted(scheme))
}
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Trek.track(scheme))
Capsule()
.fill((share ?? 0) > 1 ? Trek.danger : color)
.frame(width: max(5, geo.size.width * CGFloat(min(share ?? Double(bag.packed) / Double(max(bag.total, 1)), 1))))
}
}
.frame(height: 5)
}
}
.padding(8)
.background(RoundedRectangle(cornerRadius: 13, style: .continuous).fill(Trek.card(scheme)))
.overlay(RoundedRectangle(cornerRadius: 13, style: .continuous).stroke(Trek.cardBorder(scheme), lineWidth: 0.7))
}
/// "14.2 / 23 kg", or the packed count when the bag has no limit.
private func weight(_ bag: WidgetSnapshot.Packing.Bag) -> String {
let kg = { (grams: Int) in String(format: "%.1f", Double(grams) / 1000) }
if let limit = bag.limit { return "\(kg(bag.grams)) / \(kg(limit)) kg" }
return "\(bag.packed)/\(bag.total)"
}
}
@@ -1,8 +1,9 @@
import SwiftUI
import WidgetKit
/// The TREK widgets: the trip, today's plan, the bookings ahead and the
/// to-dos on the home and lock screen, and the travel day as a Live Activity.
/// The TREK widgets: the trip, today's plan, the bookings ahead, the to-dos,
/// the packing list, the Atlas and the travel group on the home and lock
/// screen, and the travel day as a Live Activity.
@main
struct TREKWidgetsBundle: WidgetBundle {
var body: some Widget {
@@ -10,6 +11,9 @@ struct TREKWidgetsBundle: WidgetBundle {
TodayWidget()
BookingsWidget()
TodosWidget()
PackingWidget()
AtlasWidget()
CollabWidget()
TripDayLiveActivity()
}
}
+3 -13
View File
@@ -13,7 +13,7 @@ struct TodosContent: View {
let labels = snapshot?.labels
if family.isAccessory {
accessory(todos, labels)
} else if let labels, let todos, todos.total > 0 || snapshot?.packing != nil {
} else if let labels, let todos, todos.total > 0 {
switch family {
case .small: small(todos, labels)
case .medium:
@@ -31,7 +31,6 @@ struct TodosContent: View {
rings(todos, labels, size: 96)
VStack(alignment: .leading, spacing: 8) {
header(labels, todos)
if let packing = snapshot?.packing { PackingBar(packing: packing, labels: labels) }
}
}
Divider().overlay(Trek.track(scheme)).padding(.vertical, 10)
@@ -75,18 +74,12 @@ struct TodosContent: View {
}
}
/// The to-dos as the outer ring and the packing list as the inner one.
/// The share of the to-dos done as a ring, the open ones in the middle.
private func rings(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels, size: CGFloat) -> some View {
let done = todos.total > 0 ? Double(todos.total - todos.open) / Double(todos.total) : 1
let packing = snapshot?.packing
let packed = packing.map { $0.total > 0 ? Double($0.packed) / Double($0.total) : 1 }
let width = size * 0.085
let width = size * 0.1
return ZStack {
ProgressRing(value: done, lineWidth: width, colors: [Color(hex: 0x4A7DDB), Color(hex: 0x6A5BE0)], trackColor: Trek.track(scheme)) { EmptyView() }
if let packed {
ProgressRing(value: packed, lineWidth: width, colors: [Color(hex: 0x2FA37A), Color(hex: 0x7BC96F)], trackColor: Trek.track(scheme)) { EmptyView() }
.padding(width * 1.45)
}
VStack(spacing: -2) {
Text("\(todos.open)").font(Trek.number(size * 0.3, .heavy)).foregroundStyle(Trek.ink(scheme))
Image(systemName: "checklist").font(.system(size: size * 0.11, weight: .bold)).foregroundStyle(Trek.muted(scheme))
@@ -99,9 +92,6 @@ struct TodosContent: View {
private func legend(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels) -> some View {
HStack(spacing: 8) {
LegendPill(color: Color(hex: 0x5A6CDE), symbol: "checklist", text: labels.todos ?? "", count: "\(todos.total - todos.open)/\(todos.total)")
if let packing = snapshot?.packing {
LegendPill(color: Trek.confirmed, symbol: "suitcase.rolling.fill", text: labels.packing ?? "", count: "\(packing.packed)/\(packing.total)")
}
Spacer(minLength: 0)
}
}
+52 -2
View File
@@ -33,7 +33,7 @@ extension WidgetSnapshot {
return formatter.string(from: calendar.date(byAdding: .day, value: offset, to: now) ?? now)
}
let start = running ? -2 : 12
return WidgetSnapshot(
var snapshot = WidgetSnapshot(
trip: Trip(
title: "Autumn in Kyoto", start: iso(start), end: iso(start + 8), path: "/trips/1",
place: "Kyoto, Japan", cover: nil, dayCount: 9, placeCount: 34, memberCount: 3
@@ -76,9 +76,57 @@ extension WidgetSnapshot {
days: "days", dayOf: "Day {n} of {m}", bookings: "Coming up", noBookings: "No bookings ahead",
todos: "To-dos", noTodos: "All done", packing: "Packing", packed: "packed",
confirmed: "Confirmed", pending: "Pending", todayShort: "Today", tomorrowShort: "Tomorrow",
now: "Now", places: "places", travellers: "travellers", allDone: "Day done", openTrek: "Open TREK"
now: "Now", places: "places", travellers: "travellers", allDone: "Day done", openTrek: "Open TREK",
missing: "still to pack", countries: "countries", world: "of the world", group: "Travel group",
poll: "Poll", votes: "votes", noMessages: "No messages yet", departure: "Departure"
)
)
snapshot.packing?.bags = [
.init(name: "Suitcase", color: "#4A7DDB", grams: 14_200, limit: 23_000, packed: 15, total: 19),
.init(name: "Daypack", color: "#2FA37A", grams: 4_100, limit: 8_000, packed: 8, total: 12),
]
snapshot.packing?.missing = [
.init(name: "Rain jacket", category: "Clothes", color: "#4A7DDB"),
.init(name: "Power bank", category: "Tech", color: "#2FA37A"),
.init(name: "Passport", category: "Documents", color: "#2FA37A"),
.init(name: "Sandals", category: "Shoes", color: "#4A7DDB"),
.init(name: "Travel adapter", category: "Tech", color: "#2FA37A"),
.init(name: "Sunscreen", category: "Toiletries", color: nil),
.init(name: "Earplugs", category: nil, color: nil),
.init(name: "Book", category: nil, color: nil),
]
snapshot.atlas = Atlas(
count: 23,
codes: ["DE", "AT", "CH", "FR", "IT", "ES", "PT", "NL", "BE", "DK", "NO", "SE", "GB", "IE", "GR", "HR",
"JP", "TH", "VN", "US", "CA", "MA", "AU"],
recent: [
.init(code: "JP", name: "Japan"), .init(code: "NO", name: "Norway"),
.init(code: "PT", name: "Portugal"), .init(code: "MA", name: "Morocco"),
],
continents: [
.init(name: "Europe", count: 16), .init(name: "Asia", count: 3), .init(name: "North America", count: 2),
.init(name: "Africa", count: 1), .init(name: "Oceania", count: 1),
],
trips: 41, days: 318
)
snapshot.collab = Collab(
members: [
.init(name: "Maurice", initials: "MA"), .init(name: "Julien", initials: "JU"),
.init(name: "Elmo", initials: "EL"), .init(name: "Sara", initials: "SA"),
],
messages: [
.init(author: "Julien", initials: "JU", text: "Got the JR Pass vouchers, all four of us", at: nil, mine: false),
.init(author: "Sara", initials: "SA", text: "Can we push dinner to 20:00? Train is late", at: nil, mine: false),
.init(author: "Maurice", initials: "MA", text: "Sure, I moved the table at Pontocho", at: nil, mine: true),
],
poll: .init(
question: "Day trip on Saturday?",
options: [.init(label: "Nara", votes: 3), .init(label: "Uji", votes: 1), .init(label: "Kurama", votes: 0)],
voters: 4
),
path: "/trips/1?tab=collab"
)
return snapshot
}
/// An empty state: no trip at all.
@@ -90,6 +138,8 @@ extension WidgetSnapshot {
snapshot.bookings = []
snapshot.todos = Todos(open: 0, total: 0, items: [], path: "/")
snapshot.packing = nil
snapshot.atlas = Atlas(count: 0, codes: [], recent: [], continents: [])
snapshot.collab = nil
return snapshot
}
}
@@ -0,0 +1,72 @@
import AppIntents
import WidgetKit
/// The trip a widget shows, picked in "Edit Widget": the dashboard's trip by
/// default ("Automatic"), or any trip the app listed in widget.trips.
struct TripChoice: AppEntity {
static var typeDisplayRepresentation: TypeDisplayRepresentation = "widget.intent.trip"
static var defaultQuery = TripChoiceQuery()
/// The id of the automatic choice, which follows the dashboard's trip.
static let automaticID = "auto"
var id: String
var title: String
var dates: String?
var displayRepresentation: DisplayRepresentation {
if id == TripChoice.automaticID {
return DisplayRepresentation(title: "widget.intent.auto", subtitle: "widget.intent.autoHint")
}
return DisplayRepresentation(title: "\(title)", subtitle: dates.map { "\($0)" })
}
static var automatic: TripChoice { TripChoice(id: automaticID, title: "", dates: nil) }
}
struct TripChoiceQuery: EntityQuery {
func entities(for identifiers: [TripChoice.ID]) async throws -> [TripChoice] {
all().filter { identifiers.contains($0.id) }
}
func suggestedEntities() async throws -> [TripChoice] {
all()
}
func defaultResult() async -> TripChoice? {
.automatic
}
/// Automatic first, then the trips the app sent, soonest first.
private func all() -> [TripChoice] {
[.automatic] + WidgetSnapshot.tripIndex().map {
TripChoice(id: String($0.id), title: $0.title, dates: RelativeDay.range($0.start, $0.end))
}
}
}
struct SelectTripIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "widget.intent.title"
static var description = IntentDescription("widget.intent.description")
@Parameter(title: "widget.intent.trip")
var trip: TripChoice?
}
/// Like TrekProvider, for the widgets that show one trip: the snapshot of the
/// trip picked for the widget, or the dashboard's.
struct TrekTripProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> TrekEntry {
TrekEntry(date: Date(), snapshot: .sample(running: false))
}
func snapshot(for configuration: SelectTripIntent, in context: Context) async -> TrekEntry {
let stored = WidgetSnapshot.load(trip: configuration.trip?.id)
let snapshot: WidgetSnapshot? = (stored?.trip != nil || !context.isPreview) ? stored : WidgetSnapshot.sample(running: false)
return TrekEntry(date: Date(), snapshot: snapshot)
}
func timeline(for configuration: SelectTripIntent, in context: Context) async -> Timeline<TrekEntry> {
TrekProvider.timeline(WidgetSnapshot.load(trip: configuration.trip?.id))
}
}
+112 -3
View File
@@ -85,6 +85,79 @@ struct WidgetSnapshot: Codable {
var packed: Int
var total: Int
var path: String
var bags: [Bag]?
var missing: [Item]?
struct Bag: Codable, Hashable {
var name: String
/// #rrggbb
var color: String
var grams: Int
/// The bag's weight limit in grams, if it has one.
var limit: Int?
var packed: Int
var total: Int
}
struct Item: Codable, Hashable {
var name: String
var category: String?
/// The colour of the bag it goes in.
var color: String?
}
}
/// The countries of the Atlas, independent of any trip.
struct Atlas: Codable {
var count: Int
/// ISO 3166-1 alpha-2 codes.
var codes: [String]
var recent: [Country]
var continents: [Continent]
var trips: Int?
var days: Int?
struct Country: Codable, Hashable {
var code: String
var name: String
}
struct Continent: Codable, Hashable {
var name: String
var count: Int
}
}
/// Who travels along, the trip chat and its open poll.
struct Collab: Codable {
var members: [Member]
var messages: [Message]
var poll: Poll?
var path: String
struct Member: Codable, Hashable {
var name: String
var initials: String
}
struct Message: Codable, Hashable {
var author: String
var initials: String
var text: String
var at: String?
var mine: Bool
}
struct Poll: Codable {
var question: String
var options: [Option]
var voters: Int
struct Option: Codable, Hashable {
var label: String
var votes: Int
}
}
}
struct Labels: Codable {
@@ -112,6 +185,14 @@ struct WidgetSnapshot: Codable {
var travellers: String?
var allDone: String?
var openTrek: String?
var missing: String?
var countries: String?
var world: String?
var group: String?
var poll: String?
var votes: String?
var noMessages: String?
var departure: String?
}
var trip: Trip?
@@ -120,18 +201,46 @@ struct WidgetSnapshot: Codable {
var bookings: [Booking]?
var todos: Todos?
var packing: Packing?
var atlas: Atlas?
var collab: Collab?
var labels: Labels
/// The dashboard's snapshot.
static func load() -> WidgetSnapshot? {
read("widget.snapshot").flatMap { try? JSONDecoder().decode(WidgetSnapshot.self, from: $0) }
}
/// The snapshot of the trip a widget is set to, the dashboard's for none or
/// "auto", and the dashboard's too while the app has not written that trip yet.
static func load(trip id: String?) -> WidgetSnapshot? {
guard let id, id != "auto", let data = read("widget.trip.\(id)"),
var snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else { return load() }
if snapshot.atlas == nil { snapshot.atlas = load()?.atlas }
return snapshot
}
/// A trip a widget can be set to, as the app lists them.
struct TripRef: Codable {
var id: Int
var title: String
var start: String
var end: String
}
static func tripIndex() -> [TripRef] {
read("widget.trips").flatMap { try? JSONDecoder().decode([TripRef].self, from: $0) } ?? []
}
private static func read(_ account: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.liketrek.trek.push",
kSecAttrAccount as String: "widget.snapshot",
kSecAttrAccount as String: account,
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)
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }
return result as? Data
}
/// Whole days from today to the trip's start, in the device's calendar.
@@ -0,0 +1,53 @@
// Generated by client/ios/WidgetPreviews/make-world-grid.py from
// server/assets/atlas/admin0.geojson.gz; do not edit by hand.
import Foundation
/// The world as 76 by 34 dots from 80° north to 58° south,
/// each the ISO code of the country under it, ".." for sea (977 land dots).
enum WorldGrid {
static let columns = 76
static let rows: [String] = [
"........................CACACA..CACACACACACA..GLGLGLGLGLGLGLGLGLGLGL............NONONO..................RU..........RURURU..............................",
"......................CACACACACA..CACACACACA........GLGLGLGLGLGLGLGL..............................RURU....RURURURURURURURURURURURURU..RURURU............",
"......USUSUSUSUSCACACACACACACACACACACACACACACACA....GLGLGLGLGLGLGL................NONONORURU....RURU..RURURURURURURURURURURURURURURURURURURURURURURURURU",
"RURU..USUSUSUSUSCACACACACACACACACACACACACACACACACA....GLGLGLGL....ISIS..........NOSESEFIRURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURU",
"......USUSUSUSUSCACACACACACACACACACACA....CACACACA....GLGL....................NONOSEFIFIRURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURU",
"........USUS......USCACACACACACACACACACA..CACACACA........................GB..NOSESELVLVRURURURURURURURURURURURURURURURURURURURURURURURU......RURU......",
"......US............CACACACACACACACACACACACACACACACA....................IEGBGBNLDEPLPLBYBYRURURURURURUKZKZRURURURURURURURURURURURURURURURU....RU........",
"......................CACACACACACACACACACACACACACACACA....................FRFRDEDECZPLUAUAUARURUKZKZKZKZKZKZKZKZRUMNMNMNMNRUMNCNCNRURURURU..............",
"........................USUSUSUSUSUSUSUSCACACAUSCA........................FRFRITITHRROROMDUARURURUKZKZKZKZKZKZCNCNMNMNMNMNMNCNCNCNCNRU..................",
"......................USUSUSUSUSUSUSUSUSUSUSUS..........................ESESES..ITITALBGTRTRTRGEAZTMTMUZKZKGKGCNCNCNCNCNCNCNCNCNCNCN..JPJP..............",
"........................USUSUSUSUSUSUSUSUSUS............................ESESDZDZ....GRTRTRTRTRIRIRIRIRTMTJTJCNCNCNCNCNCNCNCNCNCNKPKR..JP................",
"..........................USUSUSUSUSUSUSUSUS............................MAMADZDZTN..LY....LBIQIQIRIRIRAFAFPKINCNCNCNCNCNCNCNCNCNKRJPJP..................",
"..........................MXMXMXMXUSUS..US............................MADZDZDZDZLYLYLYEGEGSASAIQIRIRIRPKPKININNPCNCNCNCNCNCNCNCN........................",
"............................MXMXMXUS....US..........................EHEHMRDZDZDZLYLYLYEGEGSASASASAAEIRPKPKINININININMMCNCNCNCNCN........................",
"..............................MXMXMX..MXCUCUBS......................MRMRMRMLDZDZNETDLYSDSDSDSASASASAOM..INININININMMMMLAVNCN............................",
"................................MXMXMXMX..JM........................MRMRMRMLMLNENETDTDSDSDSDERYEYEOM......INININ....MMTHVN....PH........................",
"....................................GTSVNI....VE....................SNSNMLBFNENENGTDTDSDSDSDETYEYE........ININ......MMTHKH....PH........................",
"........................................CRPACOVEVEVE..................SLCIGHBJNGNGTDCFSSSSETETSOSO..........IN....IN....VN......PH......................",
"..........................................COCOCOVEGYSR..................CICI..NGCMCFCFCDSSKEETSOSO..................IDMY....MY..ID......................",
"..........................................ECCOBRBRBRBRBR........................GACGCDCDUGKEKESO......................ID..IDIDID........................",
"..........................................ECPEBRBRBRBRBRBRBR....................CGCDCDCDBITZKE........................IDIDIDIDID..IDIDIDPG..PG..........",
"..........................................PEBRBRBRBRBRBRBRBRBR..................AOAOCDCDTZTZTZ..........................IDIDID........IDPGPGPG..........",
"..........................................PEPEBOBRBRBRBRBRBRBR....................AOAOCDZMTZMZKM..................................AUAU....PG............",
"............................................PEBOBOBRBRBRBRBR....................AOAOAOZMMZMZMZMGMG..............................AUAUAU..AU..............",
"..............................................CLBOPYBRBRBRBR....................NANABWBWZWMZ..MGMG............................AUAUAUAUAUAUAU....FR......",
"..............................................CLARPYBRBRBR........................NABWBWZAMZ..MG............................AUAUAUAUAUAUAUAU............",
"..............................................ARARARBRBR..........................NAZAZAZA..................................AUAUAUAUAUAUAUAUAU..........",
"..............................................ARARARUY............................ZAZAZAZA..................................AUAUAUAUAUAUAUAUAU..........",
"............................................CLARARARUY..............................ZA......................................AU........AUAUAU............",
"............................................CLARARAR....................................................................................AU............NZ",
"............................................CLARAR......................................................................................AU........NZNZ..",
"............................................CLAR........................................................................................................",
"............................................CLAR........................................................................................................",
"..............................................CL........................................................................................................",
]
/// The country code of a dot, nil for sea.
static func code(row: Int, column: Int) -> String? {
let line = rows[row]
let start = line.index(line.startIndex, offsetBy: column * 2)
let code = String(line[start..<line.index(start, offsetBy: 2)])
return code == ".." ? nil : code
}
}
@@ -7,3 +7,14 @@
"widget.bookings.description" = "رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها.";
"widget.todos.name" = "المهام";
"widget.todos.description" = "ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة.";
"widget.packing.name" = "قائمة الأمتعة";
"widget.packing.description" = "مدى امتلاء الحقائب ووزنها وما الذي ما زال ناقصًا.";
"widget.atlas.name" = "خريطة العالم";
"widget.atlas.description" = "الدول التي زرتها مضيئة على الخريطة.";
"widget.collab.name" = "مجموعة السفر";
"widget.collab.description" = "من يرافقك وأحدث الرسائل والتصويت المفتوح.";
"widget.intent.title" = "اختر الرحلة";
"widget.intent.description" = "الرحلة التي تعرضها هذه الأداة.";
"widget.intent.trip" = "الرحلة";
"widget.intent.auto" = "تلقائي";
"widget.intent.autoHint" = "نفس رحلة لوحة التحكم";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Növbəti uçuşlar, qatarlar, otellər və restoranlar, nə vaxt olduqları ilə.";
"widget.todos.name" = "Tapşırıqlar";
"widget.todos.description" = "Səyahət üçün hələ nəyin qaldığı və yığışmağın nə yerdə olduğu.";
"widget.packing.name" = "Yük siyahısı";
"widget.packing.description" = "Çantaların doluluğu, çəkisi və hələ nəyin çatışmadığı.";
"widget.atlas.name" = "Dünya xəritəsi";
"widget.atlas.description" = "Olduğun ölkələr xəritədə parlayır.";
"widget.collab.name" = "Səyahət qrupu";
"widget.collab.description" = "Kim gəlir, son mesajlar və açıq səsvermə.";
"widget.intent.title" = "Səyahəti seç";
"widget.intent.description" = "Bu vidcetin göstərdiyi səyahət.";
"widget.intent.trip" = "Səyahət";
"widget.intent.auto" = "Avtomatik";
"widget.intent.autoHint" = "İdarə panelindəki səyahət";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Els teus propers vols, trens, hotels i taules, i quan toquen.";
"widget.todos.name" = "Tasques";
"widget.todos.description" = "El que encara queda per al viatge i com va l'equipatge.";
"widget.packing.name" = "Equipatge";
"widget.packing.description" = "Com de plenes són les maletes, el pes i el que encara falta.";
"widget.atlas.name" = "Mapa del món";
"widget.atlas.description" = "Els països on has estat, brillant al mapa.";
"widget.collab.name" = "Grup de viatge";
"widget.collab.description" = "Qui ve, els darrers missatges i la votació oberta.";
"widget.intent.title" = "Tria el viatge";
"widget.intent.description" = "El viatge que mostra aquest giny.";
"widget.intent.trip" = "Viatge";
"widget.intent.auto" = "Automàtic";
"widget.intent.autoHint" = "El mateix viatge que el tauler";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají.";
"widget.todos.name" = "Úkoly";
"widget.todos.description" = "Co ještě zbývá k cestě a jak daleko je balení.";
"widget.packing.name" = "Balení";
"widget.packing.description" = "Jak plná jsou zavazadla, jejich váha a co ještě chybí.";
"widget.atlas.name" = "Mapa světa";
"widget.atlas.description" = "Země, kde jsi byl, zářící na mapě.";
"widget.collab.name" = "Cestovní skupina";
"widget.collab.description" = "Kdo jede s tebou, nejnovější zprávy a otevřené hlasování.";
"widget.intent.title" = "Vybrat cestu";
"widget.intent.description" = "Cesta, kterou tento widget zobrazuje.";
"widget.intent.trip" = "Cesta";
"widget.intent.auto" = "Automaticky";
"widget.intent.autoHint" = "Stejná cesta jako na nástěnce";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Deine nächsten Flüge, Züge, Hotels und Tische, und wann sie anstehen.";
"widget.todos.name" = "To-dos";
"widget.todos.description" = "Was für die Reise noch offen ist und wie weit das Packen ist.";
"widget.packing.name" = "Packliste";
"widget.packing.description" = "Wie voll die Taschen sind, ihr Gewicht und was noch fehlt.";
"widget.atlas.name" = "Weltkarte";
"widget.atlas.description" = "Die Länder, in denen du warst, leuchtend auf der Karte.";
"widget.collab.name" = "Reisegruppe";
"widget.collab.description" = "Wer mitreist, die neuesten Nachrichten und die offene Abstimmung.";
"widget.intent.title" = "Reise wählen";
"widget.intent.description" = "Die Reise, die dieses Widget zeigt.";
"widget.intent.trip" = "Reise";
"widget.intent.auto" = "Automatisch";
"widget.intent.autoHint" = "Dieselbe Reise wie im Dashboard";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι.";
"widget.todos.name" = "Εκκρεμότητες";
"widget.todos.description" = "Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα.";
"widget.packing.name" = "Αποσκευές";
"widget.packing.description" = "Πόσο γεμάτες είναι οι τσάντες, το βάρος τους και τι λείπει ακόμα.";
"widget.atlas.name" = "Παγκόσμιος χάρτης";
"widget.atlas.description" = "Οι χώρες που έχεις επισκεφθεί, φωτεινές στον χάρτη.";
"widget.collab.name" = "Ομάδα ταξιδιού";
"widget.collab.description" = "Ποιος έρχεται, τα τελευταία μηνύματα και η ανοιχτή ψηφοφορία.";
"widget.intent.title" = "Επιλογή ταξιδιού";
"widget.intent.description" = "Το ταξίδι που δείχνει αυτό το widget.";
"widget.intent.trip" = "Ταξίδι";
"widget.intent.auto" = "Αυτόματα";
"widget.intent.autoHint" = "Το ίδιο ταξίδι με τον πίνακα";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Your next flights, trains, hotels and tables, and when they are due.";
"widget.todos.name" = "To-dos";
"widget.todos.description" = "What is still open for the trip, and how far the packing has come.";
"widget.packing.name" = "Packing";
"widget.packing.description" = "How full the bags are, their weight, and what is still missing.";
"widget.atlas.name" = "World map";
"widget.atlas.description" = "The countries you have been to, glowing on the map.";
"widget.collab.name" = "Travel group";
"widget.collab.description" = "Who comes along, the latest messages and the open poll.";
"widget.intent.title" = "Choose trip";
"widget.intent.description" = "The trip this widget shows.";
"widget.intent.trip" = "Trip";
"widget.intent.auto" = "Automatic";
"widget.intent.autoHint" = "Same trip as the dashboard";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan.";
"widget.todos.name" = "Tareas";
"widget.todos.description" = "Lo que aún queda para el viaje y cómo va la maleta.";
"widget.packing.name" = "Equipaje";
"widget.packing.description" = "Lo llenas que van las maletas, su peso y lo que aún falta.";
"widget.atlas.name" = "Mapa del mundo";
"widget.atlas.description" = "Los países donde has estado, brillando en el mapa.";
"widget.collab.name" = "Grupo de viaje";
"widget.collab.description" = "Quién viene, los últimos mensajes y la votación abierta.";
"widget.intent.title" = "Elegir viaje";
"widget.intent.description" = "El viaje que muestra este widget.";
"widget.intent.trip" = "Viaje";
"widget.intent.auto" = "Automático";
"widget.intent.autoHint" = "El mismo viaje que el panel";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on.";
"widget.todos.name" = "Ülesanded";
"widget.todos.description" = "Mis reisi jaoks veel tegemata on ja kui kaugel pakkimine on.";
"widget.packing.name" = "Pakkimine";
"widget.packing.description" = "Kui täis kotid on, nende kaal ja mis veel puudu on.";
"widget.atlas.name" = "Maailmakaart";
"widget.atlas.description" = "Riigid, kus oled käinud, kaardil helendamas.";
"widget.collab.name" = "Reisiseltskond";
"widget.collab.description" = "Kes tuleb kaasa, viimased sõnumid ja avatud hääletus.";
"widget.intent.title" = "Vali reis";
"widget.intent.description" = "Reis, mida see vidin näitab.";
"widget.intent.trip" = "Reis";
"widget.intent.auto" = "Automaatne";
"widget.intent.autoHint" = "Sama reis mis töölaual";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Vos prochains vols, trains, hôtels et tables, et quand ils arrivent.";
"widget.todos.name" = "À faire";
"widget.todos.description" = "Ce qui reste à faire pour le voyage et où en sont les bagages.";
"widget.packing.name" = "Bagages";
"widget.packing.description" = "Le remplissage des sacs, leur poids et ce qui manque encore.";
"widget.atlas.name" = "Carte du monde";
"widget.atlas.description" = "Les pays où vous êtes allé, illuminés sur la carte.";
"widget.collab.name" = "Groupe de voyage";
"widget.collab.description" = "Qui vient, les derniers messages et le sondage ouvert.";
"widget.intent.title" = "Choisir le voyage";
"widget.intent.description" = "Le voyage affiché par ce widget.";
"widget.intent.trip" = "Voyage";
"widget.intent.auto" = "Automatique";
"widget.intent.autoHint" = "Le même voyage que le tableau de bord";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "הטיסות, הרכבות, המלונות והשולחנות הבאים שלך, ומתי הם.";
"widget.todos.name" = "משימות";
"widget.todos.description" = "מה עוד פתוח לטיול וכמה התקדמה האריזה.";
"widget.packing.name" = "אריזה";
"widget.packing.description" = "כמה מלאים התיקים, המשקל שלהם ומה עוד חסר.";
"widget.atlas.name" = "מפת העולם";
"widget.atlas.description" = "המדינות שביקרת בהן, זוהרות על המפה.";
"widget.collab.name" = "קבוצת הטיול";
"widget.collab.description" = "מי מצטרף, ההודעות האחרונות והסקר הפתוח.";
"widget.intent.title" = "בחירת טיול";
"widget.intent.description" = "הטיול שהווידג׳ט הזה מציג.";
"widget.intent.trip" = "טיול";
"widget.intent.auto" = "אוטומטי";
"widget.intent.autoHint" = "אותו טיול כמו בלוח הבקרה";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "A következő járataid, vonataid, szállásaid és asztalfoglalásaid, és hogy mikor esedékesek.";
"widget.todos.name" = "Teendők";
"widget.todos.description" = "Ami még hátravan az utazáshoz, és hol tart a csomagolás.";
"widget.packing.name" = "Csomagolás";
"widget.packing.description" = "Mennyire telik a táska, a súlya, és mi hiányzik még.";
"widget.atlas.name" = "Világtérkép";
"widget.atlas.description" = "Az országok, ahol jártál, világítva a térképen.";
"widget.collab.name" = "Útitársak";
"widget.collab.description" = "Ki jön veled, a legújabb üzenetek és a nyitott szavazás.";
"widget.intent.title" = "Utazás választása";
"widget.intent.description" = "Az utazás, amelyet ez a widget mutat.";
"widget.intent.trip" = "Utazás";
"widget.intent.auto" = "Automatikus";
"widget.intent.autoHint" = "Ugyanaz az utazás, mint az irányítópulton";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Penerbangan, kereta, hotel, dan meja berikutnya, beserta waktunya.";
"widget.todos.name" = "Tugas";
"widget.todos.description" = "Yang masih tersisa untuk perjalanan dan sejauh mana persiapan barang.";
"widget.packing.name" = "Barang bawaan";
"widget.packing.description" = "Seberapa penuh tas, beratnya, dan yang masih kurang.";
"widget.atlas.name" = "Peta dunia";
"widget.atlas.description" = "Negara yang pernah kamu kunjungi, bersinar di peta.";
"widget.collab.name" = "Rombongan";
"widget.collab.description" = "Siapa yang ikut, pesan terbaru, dan polling yang terbuka.";
"widget.intent.title" = "Pilih perjalanan";
"widget.intent.description" = "Perjalanan yang ditampilkan widget ini.";
"widget.intent.trip" = "Perjalanan";
"widget.intent.auto" = "Otomatis";
"widget.intent.autoHint" = "Perjalanan yang sama dengan dasbor";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "I tuoi prossimi voli, treni, hotel e tavoli, e quando sono.";
"widget.todos.name" = "Da fare";
"widget.todos.description" = "Cosa manca ancora per il viaggio e a che punto è la valigia.";
"widget.packing.name" = "Bagagli";
"widget.packing.description" = "Quanto sono piene le borse, il loro peso e cosa manca ancora.";
"widget.atlas.name" = "Mappa del mondo";
"widget.atlas.description" = "I paesi in cui sei stato, illuminati sulla mappa.";
"widget.collab.name" = "Gruppo di viaggio";
"widget.collab.description" = "Chi viene, gli ultimi messaggi e il sondaggio aperto.";
"widget.intent.title" = "Scegli viaggio";
"widget.intent.description" = "Il viaggio mostrato da questo widget.";
"widget.intent.trip" = "Viaggio";
"widget.intent.auto" = "Automatico";
"widget.intent.autoHint" = "Lo stesso viaggio della dashboard";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "次のフライト、列車、ホテル、レストランとその日時。";
"widget.todos.name" = "やること";
"widget.todos.description" = "旅行のために残っていることと、荷造りの進み具合。";
"widget.packing.name" = "持ち物";
"widget.packing.description" = "バッグの詰め具合と重さ、まだ足りないもの。";
"widget.atlas.name" = "世界地図";
"widget.atlas.description" = "訪れた国が地図上で光ります。";
"widget.collab.name" = "旅行グループ";
"widget.collab.description" = "一緒に行くメンバー、最新のメッセージ、受付中の投票。";
"widget.intent.title" = "旅行を選択";
"widget.intent.description" = "このウィジェットに表示する旅行。";
"widget.intent.trip" = "旅行";
"widget.intent.auto" = "自動";
"widget.intent.autoHint" = "ダッシュボードと同じ旅行";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "다가오는 항공편, 기차, 호텔, 식당과 그 시간.";
"widget.todos.name" = "할 일";
"widget.todos.description" = "여행을 위해 남은 일과 짐 싸기 진행 상황.";
"widget.packing.name" = "짐 싸기";
"widget.packing.description" = "가방이 얼마나 찼는지, 무게, 아직 빠진 것.";
"widget.atlas.name" = "세계 지도";
"widget.atlas.description" = "다녀온 나라가 지도 위에서 빛납니다.";
"widget.collab.name" = "여행 그룹";
"widget.collab.description" = "함께 가는 사람, 최근 메시지, 진행 중인 투표.";
"widget.intent.title" = "여행 선택";
"widget.intent.description" = "이 위젯에 표시할 여행.";
"widget.intent.trip" = "여행";
"widget.intent.auto" = "자동";
"widget.intent.autoHint" = "대시보드와 같은 여행";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Je volgende vluchten, treinen, hotels en tafels, en wanneer ze zijn.";
"widget.todos.name" = "Taken";
"widget.todos.description" = "Wat er nog openstaat voor de reis en hoe ver het inpakken is.";
"widget.packing.name" = "Bagage";
"widget.packing.description" = "Hoe vol de tassen zijn, hun gewicht en wat er nog mist.";
"widget.atlas.name" = "Wereldkaart";
"widget.atlas.description" = "De landen waar je bent geweest, oplichtend op de kaart.";
"widget.collab.name" = "Reisgezelschap";
"widget.collab.description" = "Wie er meegaat, de nieuwste berichten en de open peiling.";
"widget.intent.title" = "Reis kiezen";
"widget.intent.description" = "De reis die deze widget toont.";
"widget.intent.trip" = "Reis";
"widget.intent.auto" = "Automatisch";
"widget.intent.autoHint" = "Dezelfde reis als het dashboard";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Twoje najbliższe loty, pociągi, hotele i stoliki oraz ich terminy.";
"widget.todos.name" = "Zadania";
"widget.todos.description" = "Co jeszcze zostało do zrobienia przed podróżą i jak idzie pakowanie.";
"widget.packing.name" = "Pakowanie";
"widget.packing.description" = "Jak pełne są torby, ich waga i czego jeszcze brakuje.";
"widget.atlas.name" = "Mapa świata";
"widget.atlas.description" = "Kraje, w których byłeś, świecące na mapie.";
"widget.collab.name" = "Grupa podróży";
"widget.collab.description" = "Kto jedzie, najnowsze wiadomości i otwarta ankieta.";
"widget.intent.title" = "Wybierz podróż";
"widget.intent.description" = "Podróż, którą pokazuje ten widżet.";
"widget.intent.trip" = "Podróż";
"widget.intent.auto" = "Automatycznie";
"widget.intent.autoHint" = "Ta sama podróż co na pulpicie";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Seus próximos voos, trens, hotéis e mesas, e quando acontecem.";
"widget.todos.name" = "Tarefas";
"widget.todos.description" = "O que ainda falta para a viagem e quanto da mala já está pronto.";
"widget.packing.name" = "Bagagem";
"widget.packing.description" = "Quão cheias estão as malas, o peso e o que ainda falta.";
"widget.atlas.name" = "Mapa-múndi";
"widget.atlas.description" = "Os países em que você esteve, brilhando no mapa.";
"widget.collab.name" = "Grupo de viagem";
"widget.collab.description" = "Quem vai junto, as mensagens recentes e a votação aberta.";
"widget.intent.title" = "Escolher viagem";
"widget.intent.description" = "A viagem que este widget mostra.";
"widget.intent.trip" = "Viagem";
"widget.intent.auto" = "Automático";
"widget.intent.autoHint" = "A mesma viagem do painel";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Ближайшие рейсы, поезда, отели и столики и когда они.";
"widget.todos.name" = "Задачи";
"widget.todos.description" = "Что ещё осталось сделать к поездке и как идут сборы.";
"widget.packing.name" = "Сборы";
"widget.packing.description" = "Насколько заполнены сумки, их вес и чего ещё не хватает.";
"widget.atlas.name" = "Карта мира";
"widget.atlas.description" = "Страны, где вы были, светятся на карте.";
"widget.collab.name" = "Группа поездки";
"widget.collab.description" = "Кто едет, последние сообщения и открытый опрос.";
"widget.intent.title" = "Выбрать поездку";
"widget.intent.description" = "Поездка, которую показывает этот виджет.";
"widget.intent.trip" = "Поездка";
"widget.intent.auto" = "Автоматически";
"widget.intent.autoHint" = "Та же поездка, что и на главной";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Tvoje ďalšie lety, vlaky, hotely a stoly a kedy ťa čakajú.";
"widget.todos.name" = "Úlohy";
"widget.todos.description" = "Čo ešte zostáva na cestu a ako ďaleko je balenie.";
"widget.packing.name" = "Balenie";
"widget.packing.description" = "Aké plné sú tašky, ich váha a čo ešte chýba.";
"widget.atlas.name" = "Mapa sveta";
"widget.atlas.description" = "Krajiny, kde si bol, žiariace na mape.";
"widget.collab.name" = "Cestovná skupina";
"widget.collab.description" = "Kto ide s tebou, najnovšie správy a otvorené hlasovanie.";
"widget.intent.title" = "Vybrať cestu";
"widget.intent.description" = "Cesta, ktorú zobrazuje tento widget.";
"widget.intent.trip" = "Cesta";
"widget.intent.auto" = "Automaticky";
"widget.intent.autoHint" = "Rovnaká cesta ako na nástenke";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Dina nästa flyg, tåg, hotell och bord, och när de är.";
"widget.todos.name" = "Att göra";
"widget.todos.description" = "Vad som återstår inför resan och hur långt packningen har kommit.";
"widget.packing.name" = "Packning";
"widget.packing.description" = "Hur fulla väskorna är, deras vikt och vad som fortfarande saknas.";
"widget.atlas.name" = "Världskarta";
"widget.atlas.description" = "Länderna du har besökt, lysande på kartan.";
"widget.collab.name" = "Resesällskap";
"widget.collab.description" = "Vem som följer med, de senaste meddelandena och den öppna omröstningen.";
"widget.intent.title" = "Välj resa";
"widget.intent.description" = "Resan som den här widgeten visar.";
"widget.intent.trip" = "Resa";
"widget.intent.auto" = "Automatiskt";
"widget.intent.autoHint" = "Samma resa som på startsidan";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "เที่ยวบิน รถไฟ โรงแรม และร้านอาหารถัดไปของคุณ และเวลาของแต่ละรายการ";
"widget.todos.name" = "สิ่งที่ต้องทำ";
"widget.todos.description" = "สิ่งที่ยังค้างอยู่สำหรับทริป และความคืบหน้าการจัดกระเป๋า";
"widget.packing.name" = "จัดกระเป๋า";
"widget.packing.description" = "กระเป๋าเต็มแค่ไหน น้ำหนัก และอะไรที่ยังขาด";
"widget.atlas.name" = "แผนที่โลก";
"widget.atlas.description" = "ประเทศที่คุณเคยไป ส่องแสงบนแผนที่";
"widget.collab.name" = "กลุ่มเดินทาง";
"widget.collab.description" = "ใครไปด้วย ข้อความล่าสุด และโหวตที่เปิดอยู่";
"widget.intent.title" = "เลือกทริป";
"widget.intent.description" = "ทริปที่วิดเจ็ตนี้แสดง";
"widget.intent.trip" = "ทริป";
"widget.intent.auto" = "อัตโนมัติ";
"widget.intent.autoHint" = "ทริปเดียวกับแดชบอร์ด";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Sıradaki uçuşların, trenlerin, otellerin ve masaların, ne zaman oldukları ile.";
"widget.todos.name" = "Yapılacaklar";
"widget.todos.description" = "Seyahat için hâlâ açık olanlar ve bavulun ne durumda olduğu.";
"widget.packing.name" = "Bavul";
"widget.packing.description" = "Çantaların ne kadar dolu olduğu, ağırlıkları ve eksikler.";
"widget.atlas.name" = "Dünya haritası";
"widget.atlas.description" = "Bulunduğun ülkeler haritada parlıyor.";
"widget.collab.name" = "Seyahat grubu";
"widget.collab.description" = "Kimler geliyor, son mesajlar ve açık anket.";
"widget.intent.title" = "Seyahat seç";
"widget.intent.description" = "Bu widget'ın gösterdiği seyahat.";
"widget.intent.trip" = "Seyahat";
"widget.intent.auto" = "Otomatik";
"widget.intent.autoHint" = "Paneldeki seyahatin aynısı";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Найближчі рейси, поїзди, готелі й столики та коли вони.";
"widget.todos.name" = "Завдання";
"widget.todos.description" = "Що ще лишилося зробити до подорожі і як просувається пакування.";
"widget.packing.name" = "Пакування";
"widget.packing.description" = "Наскільки заповнені сумки, їхня вага і чого ще бракує.";
"widget.atlas.name" = "Мапа світу";
"widget.atlas.description" = "Країни, де ви були, світяться на мапі.";
"widget.collab.name" = "Група подорожі";
"widget.collab.description" = "Хто їде, останні повідомлення і відкрите опитування.";
"widget.intent.title" = "Вибрати подорож";
"widget.intent.description" = "Подорож, яку показує цей віджет.";
"widget.intent.trip" = "Подорож";
"widget.intent.auto" = "Автоматично";
"widget.intent.autoHint" = "Та сама подорож, що й на панелі";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "Các chuyến bay, tàu, khách sạn và bàn sắp tới của bạn, cùng thời gian.";
"widget.todos.name" = "Việc cần làm";
"widget.todos.description" = "Những gì còn lại cho chuyến đi và việc soạn hành lý đã tới đâu.";
"widget.packing.name" = "Hành lý";
"widget.packing.description" = "Túi đã đầy đến đâu, cân nặng và những gì còn thiếu.";
"widget.atlas.name" = "Bản đồ thế giới";
"widget.atlas.description" = "Những quốc gia bạn đã đến, sáng lên trên bản đồ.";
"widget.collab.name" = "Nhóm du lịch";
"widget.collab.description" = "Ai đi cùng, tin nhắn mới nhất và cuộc bình chọn đang mở.";
"widget.intent.title" = "Chọn chuyến đi";
"widget.intent.description" = "Chuyến đi mà tiện ích này hiển thị.";
"widget.intent.trip" = "Chuyến đi";
"widget.intent.auto" = "Tự động";
"widget.intent.autoHint" = "Cùng chuyến đi với bảng điều khiển";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "你接下来的航班、火车、酒店和餐厅,以及时间。";
"widget.todos.name" = "待办";
"widget.todos.description" = "旅行还剩哪些事没做,以及行李收拾到哪一步。";
"widget.packing.name" = "行李";
"widget.packing.description" = "包装得多满、重量多少,以及还缺什么。";
"widget.atlas.name" = "世界地图";
"widget.atlas.description" = "你去过的国家在地图上亮起。";
"widget.collab.name" = "旅行小组";
"widget.collab.description" = "谁一起去、最新消息和进行中的投票。";
"widget.intent.title" = "选择旅行";
"widget.intent.description" = "此小组件显示的旅行。";
"widget.intent.trip" = "旅行";
"widget.intent.auto" = "自动";
"widget.intent.autoHint" = "与仪表板相同的旅行";
@@ -7,3 +7,14 @@
"widget.bookings.description" = "你接下來的航班、火車、飯店與餐廳,以及時間。";
"widget.todos.name" = "待辦";
"widget.todos.description" = "旅行還剩哪些事沒做,以及行李整理到哪一步。";
"widget.packing.name" = "行李";
"widget.packing.description" = "包包裝得多滿、重量多少,以及還缺什麼。";
"widget.atlas.name" = "世界地圖";
"widget.atlas.description" = "你去過的國家在地圖上亮起。";
"widget.collab.name" = "旅行小組";
"widget.collab.description" = "誰一起去、最新訊息與進行中的投票。";
"widget.intent.title" = "選擇旅行";
"widget.intent.description" = "此小工具顯示的旅行。";
"widget.intent.trip" = "旅行";
"widget.intent.auto" = "自動";
"widget.intent.autoHint" = "與儀表板相同的旅行";
@@ -0,0 +1,388 @@
import AppKit
import SwiftUI
/// Home and lock screens of an iPhone filled with the widgets, at the size of
/// an iPhone 16 Pro (402 by 874 points), for showing what the widgets look like
/// where they live. The app icon comes from the asset catalogue.
enum Mockups {
@MainActor
static func render(to out: URL, iconPath: String?, now: Date) throws {
let icon = iconPath.flatMap { NSImage(contentsOfFile: $0) }.map { Image(nsImage: $0) }
var upcoming = WidgetSnapshot.sample(running: false, now: now)
upcoming.trip?.cover = Render.sampleCover()
var running = WidgetSnapshot.sample(running: true, now: now)
running.trip?.cover = upcoming.trip?.cover
let pages: [(String, ColorScheme, AnyView)] = [
("1-travelling", .dark, AnyView(PhonePage(wallpaper: .night, icon: icon, page: 0) {
WidgetFrame(kind: .trip, family: .large, snapshot: running, now: now)
WidgetFrame(kind: .today, family: .medium, snapshot: running, now: now)
AppRow(icon: icon, apps: [.maps, .photos, .camera, .weather])
})),
("2-countdown", .light, AnyView(PhonePage(wallpaper: .dawn, icon: icon, page: 1) {
WidgetFrame(kind: .trip, family: .medium, snapshot: upcoming, now: now)
HStack(spacing: 24) {
WidgetFrame(kind: .bookings, family: .small, snapshot: upcoming, now: now)
WidgetFrame(kind: .packing, family: .small, snapshot: upcoming, now: now)
}
WidgetFrame(kind: .todos, family: .medium, snapshot: upcoming, now: now)
AppRow(icon: icon, apps: [.calendar, .trek, .mail, .notes])
})),
("3-explorer", .dark, AnyView(PhonePage(wallpaper: .ocean, icon: icon, page: 2) {
WidgetFrame(kind: .atlas, family: .large, snapshot: upcoming, now: now)
WidgetFrame(kind: .collab, family: .medium, snapshot: running, now: now)
AppRow(icon: icon, apps: [.photos, .trek, .music, .settings])
})),
("4-getting-ready", .light, AnyView(PhonePage(wallpaper: .sand, icon: icon, page: 3) {
WidgetFrame(kind: .packing, family: .large, snapshot: upcoming, now: now)
HStack(spacing: 24) {
WidgetFrame(kind: .todos, family: .small, snapshot: upcoming, now: now)
WidgetFrame(kind: .atlas, family: .small, snapshot: upcoming, now: now)
}
AppRow(icon: icon, apps: [.wallet, .trek, .maps, .clock])
})),
("5-on-the-road", .dark, AnyView(PhonePage(wallpaper: .night, icon: icon, page: 4) {
HStack(spacing: 24) {
WidgetFrame(kind: .trip, family: .small, snapshot: running, now: now)
WidgetFrame(kind: .collab, family: .small, snapshot: running, now: now)
}
WidgetFrame(kind: .bookings, family: .medium, snapshot: running, now: now)
HStack(spacing: 24) {
WidgetFrame(kind: .bookings, family: .small, snapshot: running, now: now)
WidgetFrame(kind: .atlas, family: .small, snapshot: running, now: now)
}
AppRow(icon: icon, apps: [.maps, .trek, .camera, .weather])
})),
("6-lock-screen", .dark, AnyView(LockPage(snapshot: running, now: now))),
]
for (name, scheme, view) in pages {
try Render.write(
view.environment(\.colorScheme, scheme).environment(\.trekStaticPreview, true),
to: out.appendingPathComponent("mockup-\(name).png")
)
}
}
}
// MARK: - The phone
enum Wallpaper {
case night, dawn, ocean, sand
@ViewBuilder var view: some View {
switch self {
case .night:
ZStack {
LinearGradient(colors: [Color(hex: 0x0E1230), Color(hex: 0x1B1340), Color(hex: 0x05060C)], startPoint: .top, endPoint: .bottom)
RadialGradient(colors: [Color(hex: 0x6A3FD8).opacity(0.75), .clear], center: UnitPoint(x: 0.15, y: 0.25), startRadius: 0, endRadius: 360)
RadialGradient(colors: [Color(hex: 0x1E8FD8).opacity(0.6), .clear], center: UnitPoint(x: 0.9, y: 0.7), startRadius: 0, endRadius: 380)
}
case .dawn:
ZStack {
LinearGradient(colors: [Color(hex: 0xF6D5C3), Color(hex: 0xE9B8C9), Color(hex: 0xB9C6EE)], startPoint: .top, endPoint: .bottom)
RadialGradient(colors: [Color(hex: 0xFFE2A8).opacity(0.9), .clear], center: UnitPoint(x: 0.8, y: 0.15), startRadius: 0, endRadius: 320)
RadialGradient(colors: [Color(hex: 0x8FA8F0).opacity(0.7), .clear], center: UnitPoint(x: 0.1, y: 0.85), startRadius: 0, endRadius: 360)
}
case .ocean:
ZStack {
LinearGradient(colors: [Color(hex: 0x03263A), Color(hex: 0x064D5C), Color(hex: 0x021018)], startPoint: .topLeading, endPoint: .bottomTrailing)
RadialGradient(colors: [Color(hex: 0x22C7A9).opacity(0.55), .clear], center: UnitPoint(x: 0.85, y: 0.2), startRadius: 0, endRadius: 340)
RadialGradient(colors: [Color(hex: 0x2E5BD8).opacity(0.55), .clear], center: UnitPoint(x: 0.1, y: 0.75), startRadius: 0, endRadius: 360)
}
case .sand:
ZStack {
LinearGradient(colors: [Color(hex: 0xEFE6D8), Color(hex: 0xE3D2BC), Color(hex: 0xCFD9D4)], startPoint: .top, endPoint: .bottom)
RadialGradient(colors: [Color(hex: 0xF4B183).opacity(0.65), .clear], center: UnitPoint(x: 0.2, y: 0.2), startRadius: 0, endRadius: 330)
RadialGradient(colors: [Color(hex: 0x9CC5B8).opacity(0.7), .clear], center: UnitPoint(x: 0.9, y: 0.8), startRadius: 0, endRadius: 360)
}
}
}
}
/// One page of the home screen: status bar, the widgets and apps, the page
/// dots and the dock, on the wallpaper, with the screen's rounded corners.
struct PhonePage<Content: View>: View {
let wallpaper: Wallpaper
let icon: Image?
let page: Int
@ViewBuilder var content: () -> Content
var body: some View {
ZStack(alignment: .top) {
wallpaper.view
VStack(spacing: 0) {
StatusBar(dark: wallpaper == .night || wallpaper == .ocean)
VStack(spacing: 22) { content() }
.padding(.top, 14)
Spacer(minLength: 0)
PageDots(count: 5, current: page)
.padding(.bottom, 12)
Dock(icon: icon)
.padding(.horizontal, 12)
.padding(.bottom, 14)
}
}
.frame(width: 402, height: 874)
.clipShape(RoundedRectangle(cornerRadius: 56, style: .continuous))
.padding(30)
.background(Color(hex: 0x111214))
}
}
struct StatusBar: View {
let dark: Bool
var body: some View {
HStack {
Text("9:41").font(.system(size: 17, weight: .semibold))
.frame(width: 120)
Spacer()
Capsule().fill(.black).frame(width: 124, height: 36)
Spacer()
HStack(spacing: 6) {
Image(systemName: "cellularbars")
Image(systemName: "wifi")
Image(systemName: "battery.100percent")
}
.font(.system(size: 15, weight: .semibold))
.frame(width: 120)
}
.foregroundStyle(dark ? .white : .black)
.padding(.top, 12)
.frame(height: 58)
}
}
struct PageDots: View {
let count: Int
let current: Int
var body: some View {
HStack(spacing: 8) {
ForEach(0..<count, id: \.self) { index in
Circle().fill(Color.white.opacity(index == current ? 0.95 : 0.4)).frame(width: 7, height: 7)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(Capsule().fill(Color.black.opacity(0.18)))
}
}
/// A generic app, drawn as a symbol on a gradient, never a real app's icon.
enum MockApp {
case trek, maps, photos, camera, weather, calendar, mail, notes, music, settings, wallet, clock, phone, messages, browser
var name: String {
switch self {
case .trek: return "TREK"
case .maps: return "Maps"
case .photos: return "Photos"
case .camera: return "Camera"
case .weather: return "Weather"
case .calendar: return "Calendar"
case .mail: return "Mail"
case .notes: return "Notes"
case .music: return "Music"
case .settings: return "Settings"
case .wallet: return "Wallet"
case .clock: return "Clock"
case .phone: return "Phone"
case .messages: return "Messages"
case .browser: return "Browser"
}
}
var look: (String, [Color], Color) {
switch self {
case .trek: return ("map.fill", [Color(hex: 0x202024), Color(hex: 0x0C0C0F)], .white)
case .maps: return ("map", [Color(hex: 0x6FD08C), Color(hex: 0x2FA37A)], .white)
case .photos: return ("camera.macro", [Color(hex: 0xFFFFFF), Color(hex: 0xF2F2F2)], Color(hex: 0xE8A13A))
case .camera: return ("camera.fill", [Color(hex: 0xC8C8CC), Color(hex: 0x8E8E93)], Color(hex: 0x2B2B2E))
case .weather: return ("cloud.sun.fill", [Color(hex: 0x5AB3F5), Color(hex: 0x2E6FD8)], .white)
case .calendar: return ("calendar", [Color(hex: 0xFFFFFF), Color(hex: 0xF4F4F4)], Color(hex: 0xE5484D))
case .mail: return ("envelope.fill", [Color(hex: 0x56B4FF), Color(hex: 0x1D7AF2)], .white)
case .notes: return ("note.text", [Color(hex: 0xFFE27A), Color(hex: 0xF5C542)], Color(hex: 0x5A4A12))
case .music: return ("music.note", [Color(hex: 0xFF6B81), Color(hex: 0xF0304F)], .white)
case .settings: return ("gearshape.fill", [Color(hex: 0xA6A6AB), Color(hex: 0x6E6E73)], .white)
case .wallet: return ("wallet.pass.fill", [Color(hex: 0x2B2B2E), Color(hex: 0x111113)], Color(hex: 0x7BC96F))
case .clock: return ("clock.fill", [Color(hex: 0x2B2B2E), Color(hex: 0x111113)], .white)
case .phone: return ("phone.fill", [Color(hex: 0x6FE38A), Color(hex: 0x34C759)], .white)
case .messages: return ("message.fill", [Color(hex: 0x6FE38A), Color(hex: 0x34C759)], .white)
case .browser: return ("safari.fill", [Color(hex: 0x56B4FF), Color(hex: 0x1D7AF2)], .white)
}
}
}
struct AppIcon: View {
let app: MockApp
let icon: Image?
var label = true
var body: some View {
VStack(spacing: 5) {
Group {
if app == .trek, let icon {
icon.resizable().scaledToFill()
} else {
let (symbol, colors, ink) = app.look
LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
.overlay(Image(systemName: symbol).font(.system(size: 28, weight: .medium)).foregroundStyle(ink))
}
}
.frame(width: 64, height: 64)
.clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
.shadow(color: .black.opacity(0.18), radius: 3, y: 1)
if label {
Text(app.name).font(.system(size: 12, weight: .medium)).foregroundStyle(.white).shadow(color: .black.opacity(0.35), radius: 2)
}
}
.frame(width: 80)
}
}
struct AppRow: View {
let icon: Image?
let apps: [MockApp]
var body: some View {
HStack(spacing: 13) {
ForEach(Array(apps.enumerated()), id: \.offset) { _, app in AppIcon(app: app, icon: icon) }
}
}
}
struct Dock: View {
let icon: Image?
var body: some View {
HStack(spacing: 13) {
ForEach([MockApp.phone, .browser, .messages, .trek], id: \.name) { app in
AppIcon(app: app, icon: icon, label: false)
}
}
.padding(.vertical, 14)
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: 38, style: .continuous).fill(Color.white.opacity(0.22)))
.overlay(RoundedRectangle(cornerRadius: 38, style: .continuous).stroke(Color.white.opacity(0.25), lineWidth: 0.6))
}
}
// MARK: - The lock screen
struct LockPage: View {
let snapshot: WidgetSnapshot
let now: Date
var body: some View {
VStack(spacing: 0) {
StatusBar(dark: true)
Image(systemName: "lock.fill").font(.system(size: 18, weight: .semibold)).foregroundStyle(.white).padding(.top, 6)
LockAccessory(kind: .trip, family: .inline, snapshot: snapshot, now: now)
.padding(.top, 10)
Text("9:41")
.font(.system(size: 104, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.92))
.padding(.top, -6)
HStack(spacing: 14) {
LockAccessory(kind: .trip, family: .circular, snapshot: snapshot, now: now)
LockAccessory(kind: .packing, family: .circular, snapshot: snapshot, now: now)
LockAccessory(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
}
.padding(.top, 4)
Spacer(minLength: 0)
LiveActivityMock(snapshot: snapshot, now: now)
.padding(.horizontal, 12)
HStack {
LockButton(symbol: "flashlight.off.fill")
Spacer()
LockButton(symbol: "camera.fill")
}
.padding(.horizontal, 46)
.padding(.top, 22)
Capsule().fill(.white).frame(width: 140, height: 5).padding(.top, 20).padding(.bottom, 10)
}
.frame(width: 402, height: 874)
// Behind, not inside: a photo filled into a stack would widen it past the screen.
.background(TripBackdrop(trip: snapshot.trip, shade: 0.35).frame(width: 402, height: 874).clipped())
.clipShape(RoundedRectangle(cornerRadius: 56, style: .continuous))
.padding(30)
.background(Color(hex: 0x111214))
}
}
private struct LockButton: View {
let symbol: String
var body: some View {
Circle().fill(Color.black.opacity(0.35)).frame(width: 50, height: 50)
.overlay(Image(systemName: symbol).font(.system(size: 19)).foregroundStyle(.white))
}
}
/// The travel day's Live Activity as the lock screen shows it (the real one is
/// TripDayLiveActivity, which needs ActivityKit).
struct LiveActivityMock: View {
let snapshot: WidgetSnapshot
let now: Date
var body: some View {
let today = snapshot.today
let minutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
let stops = today?.stops ?? []
let next = stops.first { ($0.minutes ?? -1) >= minutes } ?? stops.last
let done = stops.filter { ($0.minutes ?? Int.max) < minutes }.count
HStack(spacing: 14) {
ZStack {
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
Circle().trim(from: 0, to: stops.isEmpty ? 0 : Double(done) / Double(stops.count))
.stroke(Trek.confirmed, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white)
}
.frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(snapshot.trip?.title ?? "").font(.system(size: 11, weight: .bold)).tracking(0.5).textCase(.uppercase).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
Spacer(minLength: 4)
Text(today?.dayLabel ?? "").font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65))
}
HStack(alignment: .firstTextBaseline) {
Text(next?.name ?? "").font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
Spacer(minLength: 4)
Text(next?.time ?? "").font(Trek.number(18, .heavy)).foregroundStyle(.white)
}
DaySegments(index: done + 1, total: max(stops.count, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
.padding(.top, 3)
}
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 26, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.78)))
}
}
/// A lock-screen accessory as the system draws it: white on the wallpaper,
/// the circular ones on a soft disc, no box of their own.
struct LockAccessory: View {
let kind: WidgetKind
let family: TrekFamily
let snapshot: WidgetSnapshot
let now: Date
var body: some View {
let size = family.previewSize
Group {
switch kind {
case .trip: TripContent(snapshot: snapshot, family: family, now: now)
case .bookings: BookingsContent(snapshot: snapshot, family: family, now: now)
case .todos: TodosContent(snapshot: snapshot, family: family, now: now)
case .packing: PackingContent(snapshot: snapshot, family: family, now: now)
case .atlas: AtlasContent(snapshot: snapshot, family: family)
default: EmptyView()
}
}
.foregroundStyle(.white)
.frame(width: size.width, height: size.height)
.background(Circle().fill(Color.white.opacity(family == .circular ? 0.16 : 0)))
}
}
@@ -39,6 +39,10 @@ struct Render {
}
}
}
let showcase = NewWidgets(snapshot: states[0].1, now: now)
.environment(\.colorScheme, scheme)
.environment(\.trekStaticPreview, true)
try write(showcase, to: out.appendingPathComponent("new-widgets-\(schemeName).png"))
for (stateName, snapshot) in states where stateName != "empty" {
let sheet = HomeScreen(snapshot: snapshot, now: now)
.environment(\.colorScheme, scheme)
@@ -48,6 +52,8 @@ struct Render {
let lock = LockScreen(snapshots: states.prefix(3).map(\.1), now: now).environment(\.colorScheme, scheme)
try write(lock, to: out.appendingPathComponent("lock-\(schemeName).png"))
}
let args = Array(CommandLine.arguments.dropFirst())
try Mockups.render(to: out, iconPath: args.count > 1 ? args[1] : nil, now: now)
print("wrote \(try FileManager.default.contentsOfDirectory(atPath: out.path).count) images to \(out.path)")
}
@@ -85,7 +91,7 @@ struct Render {
}
enum WidgetKind: String, CaseIterable {
case trip, today, bookings, todos
case trip, today, bookings, todos, packing, atlas, collab
var families: [TrekFamily] {
switch self {
@@ -93,6 +99,9 @@ enum WidgetKind: String, CaseIterable {
case .today: return [.medium, .large]
case .bookings: return [.small, .medium, .large, .rectangular, .inline]
case .todos: return [.small, .medium, .large, .circular, .rectangular]
case .packing: return [.small, .medium, .large, .circular, .rectangular]
case .atlas: return [.small, .medium, .large, .circular, .rectangular, .inline]
case .collab: return [.small, .medium, .large, .rectangular]
}
}
}
@@ -132,6 +141,9 @@ struct WidgetFrame: View {
case .today: TodayContent(snapshot: snapshot, family: family, now: now)
case .bookings: BookingsContent(snapshot: snapshot, family: family, now: now)
case .todos: TodosContent(snapshot: snapshot, family: family, now: now)
case .packing: PackingContent(snapshot: snapshot, family: family, now: now)
case .atlas: AtlasContent(snapshot: snapshot, family: family)
case .collab: CollabContent(snapshot: snapshot, family: family)
}
}
@@ -140,7 +152,7 @@ struct WidgetFrame: View {
if kind == .trip && snapshot.trip != nil {
TripBackdrop(trip: snapshot.trip)
} else {
TrekPaper()
TrekPaper(contours: kind != .atlas)
}
}
}
@@ -224,3 +236,37 @@ struct Ridge: Shape {
return path
}
}
/// The packing, atlas and group widgets side by side, the sheet to judge them by.
struct NewWidgets: View {
let snapshot: WidgetSnapshot
let now: Date
@Environment(\.colorScheme) private var scheme
var body: some View {
VStack(spacing: 22) {
HStack(spacing: 22) {
WidgetFrame(kind: .packing, family: .small, snapshot: snapshot, now: now)
WidgetFrame(kind: .atlas, family: .small, snapshot: snapshot, now: now)
}
WidgetFrame(kind: .atlas, family: .medium, snapshot: snapshot, now: now)
WidgetFrame(kind: .packing, family: .medium, snapshot: snapshot, now: now)
WidgetFrame(kind: .collab, family: .medium, snapshot: snapshot, now: now)
HStack(spacing: 22) {
WidgetFrame(kind: .collab, family: .small, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .small, snapshot: snapshot, now: now)
}
WidgetFrame(kind: .atlas, family: .large, snapshot: snapshot, now: now)
WidgetFrame(kind: .packing, family: .large, snapshot: snapshot, now: now)
WidgetFrame(kind: .collab, family: .large, snapshot: snapshot, now: now)
}
.padding(.vertical, 60)
.padding(.horizontal, 24)
.background(
LinearGradient(
colors: scheme == .dark ? [Color(hex: 0x1D2440), Color(hex: 0x0B0F1E)] : [Color(hex: 0xC9D8F0), Color(hex: 0xF3E3D3)],
startPoint: .top, endPoint: .bottom
)
)
}
}
@@ -0,0 +1,93 @@
"""Writes client/ios/App/TREKWidgets/WorldGrid.swift: the world as a grid of dots,
each with the ISO code of the country under it, for the atlas widget.
Run from the repo root after the country shapes change:
python client/ios/WidgetPreviews/make-world-grid.py
Reads server/assets/atlas/admin0.geojson.gz, the same shapes the Atlas page draws.
"""
import gzip
import json
COLS, ROWS = 76, 34
LAT_TOP, LAT_BOTTOM = 80.0, -58.0 # Antarctica and the far north stay off the map
SOURCE = 'server/assets/atlas/admin0.geojson.gz'
TARGET = 'client/ios/App/TREKWidgets/WorldGrid.swift'
def rings(geometry):
if geometry['type'] == 'Polygon':
yield geometry['coordinates']
elif geometry['type'] == 'MultiPolygon':
yield from geometry['coordinates']
def inside(ring, x, y):
hit = False
j = len(ring) - 1
for i in range(len(ring)):
xi, yi = ring[i][0], ring[i][1]
xj, yj = ring[j][0], ring[j][1]
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / ((yj - yi) or 1e-12) + xi:
hit = not hit
j = i
return hit
def in_polygon(polygon, x, y):
return inside(polygon[0], x, y) and not any(inside(hole, x, y) for hole in polygon[1:])
features = []
for feature in json.load(gzip.open(SOURCE))['features']:
code = feature['properties'].get('ISO_A2') or ''
if len(code) != 2 or not code.isalpha():
continue
for polygon in rings(feature['geometry']):
xs = [p[0] for p in polygon[0]]
ys = [p[1] for p in polygon[0]]
features.append((code, (min(xs), min(ys), max(xs), max(ys)), polygon))
# Each cell is sampled at five points, so a narrow country still claims its dot.
offsets = [(0.5, 0.5), (0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)]
cell_w = 360.0 / COLS
cell_h = (LAT_TOP - LAT_BOTTOM) / ROWS
rows = []
for r in range(ROWS):
row = []
for c in range(COLS):
votes = {}
for ox, oy in offsets:
x = -180 + (c + ox) * cell_w
y = LAT_TOP - (r + oy) * cell_h
for code, (x0, y0, x1, y1), polygon in features:
if x0 <= x <= x1 and y0 <= y <= y1 and in_polygon(polygon, x, y):
votes[code] = votes.get(code, 0) + 1
break
row.append(max(votes, key=votes.get) if votes else '..')
rows.append(''.join(row))
land = sum(len(r) // 2 - r.count('..') for r in rows)
body = '\n'.join(f' "{r}",' for r in rows)
swift = f'''// Generated by client/ios/WidgetPreviews/make-world-grid.py from
// server/assets/atlas/admin0.geojson.gz; do not edit by hand.
import Foundation
/// The world as {COLS} by {ROWS} dots from {LAT_TOP:g}° north to {-LAT_BOTTOM:g}° south,
/// each the ISO code of the country under it, ".." for sea ({land} land dots).
enum WorldGrid {{
static let columns = {COLS}
static let rows: [String] = [
{body}
]
/// The country code of a dot, nil for sea.
static func code(row: Int, column: Int) -> String? {{
let line = rows[row]
let start = line.index(line.startIndex, offsetBy: column * 2)
let code = String(line[start..<line.index(start, offsetBy: 2)])
return code == ".." ? nil : code
}}
}}
'''
open(TARGET, 'w', encoding='utf8', newline='\n').write(swift)
print('land dots', land)
+127 -43
View File
@@ -5,8 +5,8 @@ import { StatusBar, Style } from '@capacitor/status-bar';
import logoUrl from '../public/icons/icon-white.svg';
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
import { getIapClient, productIdFor, type Purchase } from '../src/native/iap';
import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL } from '../src/native/links';
import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap';
import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL, STORE_TERMS_URL } from '../src/native/links';
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
import { TrekShell } from '../src/native/trekShell';
import { pickLocale, translator } from './i18n';
@@ -28,8 +28,12 @@ const root = document.getElementById('shell')!;
const PROBE_TIMEOUT_MS = 8000;
const iap = getIapClient();
/** Whether this build can take a payment in-app; decided once at start. Until then, and in a store build without the purchase plugin, the plan picker finishes on the web. */
let iapAvailable = false;
/**
* Whether this build can take a payment in-app, asked once at start. Without it
* the plan picker shows the plans but offers no way to buy: the store rules
* allow no link or button that sends a buyer to the website instead.
*/
const iapReady: Promise<boolean> = iap.isAvailable().catch(() => false);
/** The subdomain suffix a cloud instance sits on, from the cloud address: `liketrek.com` → `.liketrek.com`. */
function cloudSuffix(): string {
@@ -359,34 +363,54 @@ async function fetchPlans(): Promise<CloudPlan[] | null> {
return null;
}
function subscribe(plan: CloudPlan, interval: 'month' | 'year'): void {
void tap(ImpactStyle.Medium);
// In-app purchase when this build can take one; otherwise finish on the web, so
// nothing half-wired faces a customer until the store products exist.
if (iapAvailable) {
showClaim(plan, interval);
} else {
window.open(`${CLOUD_URL}/order?plan=${encodeURIComponent(plan.id)}`, '_blank', 'noopener');
}
/**
* What the plan picker can sell. With the store, a plan is offered at the price
* string the App Store gives for its product, in the buyer's currency, and only
* when the store knows that product. The fake (development, preview) sells
* every plan at the plan list's price. Without either, nothing is for sale.
*/
interface Offer {
canBuy: boolean;
storePrices: Map<string, string> | null;
}
function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean): HTMLElement {
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
function offerFor(offer: Offer, plan: CloudPlan, interval: 'month' | 'year'): { label: string; buyable: boolean } {
const cents = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
const storePrice = offer.storePrices?.get(productIdFor(plan.id, interval));
if (offer.storePrices) return { label: storePrice ?? money(cents), buyable: offer.canBuy && !!storePrice };
return { label: money(cents), buyable: offer.canBuy };
}
async function loadOffer(plans: CloudPlan[]): Promise<Offer> {
const canBuy = await iapReady;
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null };
const ids = plans.flatMap((p) => [productIdFor(p.id, 'month'), productIdFor(p.id, 'year')]);
return { canBuy, storePrices: await iap.prices(ids) };
}
function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HTMLElement {
const { label, buyable } = offerFor(offer, plan, interval);
const per = interval === 'year' ? t('native.plans.perYear') : t('native.plans.perMonth');
const feat = (text: string) => el('span', {}, icon(I.check, 16), text);
// An absent feature (e.g. a plan with no AI documents): a muted dash, not a
// green tick, so "none" never reads as a perk.
const featOff = (text: string) => el('span', { className: 'off' }, icon('M5 12h14', 16), text);
const sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe'));
sub.addEventListener('click', () => subscribe(plan, interval));
let sub: HTMLElement | null = null;
if (buyable) {
sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe'));
sub.addEventListener('click', () => {
void tap(ImpactStyle.Medium);
showClaim(plan, interval, label);
});
}
return el(
'div',
{ className: featured ? 'plan featured' : 'plan' },
{ className: 'plan' },
el(
'div',
{ className: 'plan-top' },
el('span', { className: 'plan-name' }, plan.name),
el('span', { className: 'plan-price' }, money(price), el('small', {}, per))
el('span', { className: 'plan-price' }, label, el('small', {}, per))
),
el(
'div',
@@ -397,15 +421,65 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean
? feat(t('native.plans.aiDocs', { n: String(plan.aiDocuments) }))
: featOff(t('native.plans.noAi'))
),
sub
...(sub ? [sub] : [])
);
}
/**
* Below the plans. With a store: how the subscription renews, the terms and the
* privacy policy, and a way to restore a subscription this Apple ID already
* holds (a reinstall, a new phone). Without one: only that buying in the app is
* not available, and no pointer elsewhere.
*/
function planFooter(offer: Offer): Node[] {
if (!offer.canBuy) return [el('p', { className: 'note' }, t('native.plans.soon'))];
const status = el('p', { className: 'hint', role: 'status' });
const restore = el('button', { type: 'button', className: 'btn btn-ghost' }, t('native.plans.restore'));
restore.addEventListener('click', async () => {
void tap();
restore.disabled = true;
status.className = 'hint';
status.textContent = t('native.plans.restoring');
const purchase = await iap.restore();
if (!purchase) {
restore.disabled = false;
status.textContent = t('native.plans.restoreNone');
return;
}
// A restore finds the instance the purchase was first redeemed for; address and email are not asked again.
const redeemed = await redeemPurchase('', '', purchase);
if (redeemed.ok && redeemed.instanceUrl) {
await iap.finish(purchase);
showProvisioning(redeemed.instanceUrl);
return;
}
void notify(NotificationType.Error);
restore.disabled = false;
status.className = 'hint bad';
status.textContent = t('native.plans.restoreFailed');
});
return [
restore,
status,
el('p', { className: 'note' }, t('native.plans.legal')),
el(
'div',
{ className: 'foot-links' },
externalLink(t('native.plans.terms'), STORE_TERMS_URL),
externalLink(t('native.privacy'), PRIVACY_URL)
),
];
}
function showPlans(opts: { back?: boolean } = {}): void {
let interval: 'month' | 'year' = 'month';
let plans: CloudPlan[] | null = null;
let offer: Offer = { canBuy: false, storePrices: null };
const content = el('div', { className: 'loading' }, el('span', { className: 'spinner' }));
const footer = el('div', { className: 'plans-foot' });
const monthBtn = el('button', { type: 'button' }, t('native.plans.monthly'));
const yearBtn = el('button', { type: 'button' }, t('native.plans.yearly'));
@@ -419,7 +493,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
const list = plans;
if (!list) return;
content.className = 'plans';
content.replaceChildren(...list.map((p) => planCard(p, interval, false)));
content.replaceChildren(...list.map((p) => planCard(p, interval, offer)));
};
const choose = (next: 'month' | 'year') => {
if (interval === next) return;
@@ -433,9 +507,12 @@ function showPlans(opts: { back?: boolean } = {}): void {
paintSeg();
const load = async () => {
plans = await fetchPlans();
if (plans) {
const list = await fetchPlans();
if (list) {
offer = await loadOffer(list);
plans = list;
renderList();
footer.replaceChildren(...planFooter(offer));
return;
}
content.className = '';
@@ -458,7 +535,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
el('p', { className: 'sub' }, t('native.plans.sub')),
seg,
content,
el('p', { className: 'note' }, t('native.plans.soon'))
footer
),
opts
);
@@ -507,8 +584,9 @@ async function redeemPurchase(slug: string, email: string, purchase: Purchase):
}
}
function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: boolean } = {}): void {
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
function showClaim(plan: CloudPlan, interval: 'month' | 'year', price: string, opts: { back?: boolean } = {}): void {
/** A purchase that went through but whose redeem did not: a retry redeems it again instead of buying twice. */
let bought: Purchase | null = null;
const slugInput = el('input', {
type: 'text',
@@ -546,7 +624,7 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
const submit = el(
'button',
{ type: 'submit', className: 'btn' },
t('native.claim.subscribe', { price: money(price) })
t('native.claim.subscribe', { price })
);
const error = el('p', { className: 'error', role: 'alert' });
const form = el(
@@ -617,24 +695,33 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking'));
const restore = () => {
lock(false);
submit.replaceChildren(t('native.claim.subscribe', { price: money(price) }));
submit.replaceChildren(t('native.claim.subscribe', { price }));
};
const outcome = await iap.purchase(productIdFor(plan.id, interval));
if (outcome.kind === 'cancelled') {
restore();
error.textContent = t('native.claim.cancelled');
return;
}
if (outcome.kind === 'failed') {
void notify(NotificationType.Error);
restore();
error.textContent = t('native.claim.failed');
return;
if (!bought) {
const outcome = await iap.purchase(productIdFor(plan.id, interval));
if (outcome.kind === 'cancelled') {
restore();
error.textContent = t('native.claim.cancelled');
return;
}
if (outcome.kind === 'pending') {
restore();
error.textContent = t('native.claim.pending');
return;
}
if (outcome.kind === 'failed') {
void notify(NotificationType.Error);
restore();
error.textContent = t('native.claim.failed');
return;
}
bought = outcome.purchase;
}
const redeemed = await redeemPurchase(slug, email, outcome.purchase);
const redeemed = await redeemPurchase(slug, email, bought);
if (redeemed.ok && redeemed.instanceUrl) {
await iap.finish(bought);
showProvisioning(redeemed.instanceUrl);
return;
}
@@ -808,9 +895,6 @@ async function paintStatusBar(): Promise<void> {
async function start(): Promise<void> {
void paintStatusBar();
iap.isAvailable().then((ok) => {
iapAvailable = ok;
});
let url: string | null;
try {
({ url } = await TrekShell.getServer());
+14
View File
@@ -881,6 +881,20 @@ a.linkbtn {
margin: 2px 0 0;
line-height: 1.45;
}
/* Under the plans: restore, the renewal terms, the legal links. */
.plans-foot {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 4px;
}
.plans-foot .hint {
text-align: center;
margin: 0;
}
.plans-foot .hint:empty {
display: none;
}
/* ── Claim: address + email, and the provisioning wait ───────────────────── */
/* One rounded field holding the address input and its fixed ".liketrek.com"
+1 -1
View File
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1913,
"@typescript-eslint/no-non-null-assertion": 1912,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+6
View File
@@ -669,6 +669,12 @@
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
},
{
"feature": "Demo welcome",
"hooks": ["src/components/Demo/useDemoWelcome.ts"],
"desktop": ["src/components/Demo/DemoWelcome.tsx"],
"mobile": ["src/mobile/screens/dashboard/MDemoWelcome.tsx"]
},
{
"feature": "Settings / Demo account locks",
"hooks": ["src/components/Demo/useDemoLock.ts"],
@@ -84,10 +84,12 @@ describe('JourneyEntryCover', () => {
const { container, rerender } = render(
<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
)
expect(container.querySelector('button')!.className).toContain('w-[136px]')
expect(container.querySelector('button')!.className).toContain('scale-100')
rerender(<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive onClick={() => {}} />)
expect(container.querySelector('button')!.className).toContain('w-[164px]')
// Drawn larger, not laid out larger: the box stays, so nothing beside it moves.
expect(container.querySelector('button')?.className).toContain('scale-[1.18]')
expect(container.querySelector('button')?.className).toContain('w-[136px]')
})
it('FE-JRN-COVER-007: a shared journey serves its photos through the share token', () => {
@@ -103,8 +103,13 @@ export default function JourneyEntryCover({
// The whole card is the photo, so its own ring is what separates it from the
// map behind. A suggestion is drawn as an outline rather than a solid thing:
// it is somewhere you planned to be, not somewhere you have written about.
className={`relative flex-none overflow-hidden rounded-[18px] text-start transition-[width,height] duration-150 ${
isActive ? 'h-[180px] w-[164px] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'h-[152px] w-[136px] shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
//
// Every card takes the same room and the active one is only drawn larger:
// growing its box moved every card after it once a swipe had landed, and the
// snap then pulled the strip again, which read as a jolt (carousels keep the
// room for it with their gap and top padding).
className={`relative h-[152px] w-[136px] flex-none origin-bottom overflow-hidden rounded-[18px] text-start transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
isActive ? 'z-[1] scale-[1.18] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'scale-100 shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
} ${src ? '' : emptyGround} ${isSuggestion ? 'opacity-90' : ''}`}
// backgroundImage, not background: the shorthand would drop the opaque colour
// the class supplies and leave the map showing through a card that is only a
+7 -1
View File
@@ -691,7 +691,13 @@ function JourneyMap(
ref={containerRef}
style={{ width: '100%', height: '100%' }}
/>
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
{/* A finger zooms with a pinch, and on a phone these sat under the bottom bar (#3001). */}
<div
// The display lives in the classes, not the style: an inline display beat the
// hidden class and left the buttons on phones.
className="flex flex-col gap-1 [@media(pointer:coarse)]:hidden"
style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400 }}
>
<button type="button" onClick={() => zoom(true)} style={zoomButtonStyle(dark)}>+</button>
<button type="button" onClick={() => zoom(false)} style={zoomButtonStyle(dark)}>−</button>
</div>
@@ -150,7 +150,7 @@ describe('MobileMapTimeline', () => {
await userEvent.click(segments[1])
const { cards } = carouselOf(container)
expect(cards[1].querySelector('button')!.className).toContain('w-[164px]')
expect(cards[1].querySelector('button')!.className).toContain('scale-[1.18]')
})
it('FE-COMP-JMAPTL-003: the first entry starts out active and drives the map highlight', () => {
@@ -285,13 +285,14 @@ describe('MobileMapTimeline', () => {
carousel.getBoundingClientRect = () => fakeRect(0, 300)
cards.forEach((c, i) => { c.getBoundingClientRect = () => fakeRect(i === 2 ? 30 : -400, 240) })
// The strip counts as landed 90 ms after its last scroll event.
fireEvent.scroll(carousel)
act(() => { vi.advanceTimersByTime(100) })
act(() => { vi.advanceTimersByTime(60) })
fireEvent.scroll(carousel)
act(() => { vi.advanceTimersByTime(100) })
act(() => { vi.advanceTimersByTime(60) })
expect(lastMapProps.current.activeMarkerId).toBe('1')
act(() => { vi.advanceTimersByTime(100) })
act(() => { vi.advanceTimersByTime(40) })
expect(lastMapProps.current.activeMarkerId).toBe('3')
} finally {
vi.useRealTimers()
@@ -69,7 +69,7 @@ export default function MobileMapTimeline({
controlsTop,
}: Props) {
const {
mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays,
mapRef, carouselRef, cardRefs, activeIndex, focusIndex, setActiveIndex, scrubberDays,
syncMapToCard: syncMapToCarousel, handleMarkerClick, handleCardTap, openEntryById, jumpToDay,
} = useJourneyCarouselSync({ entries, mapEntries, onOpenEntry: onEntryClick })
// The delayed initial focus reads both through refs so it always works off
@@ -108,8 +108,8 @@ export default function MobileMapTimeline({
if (entries.length === 0) {
return (
<div
className="fixed inset-x-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
className="fixed inset-x-0 bottom-0 z-10"
style={{ top: 'var(--nav-h, 0px)' }}
>
<JourneyLayeredMap
ref={mapRef}
@@ -147,8 +147,10 @@ export default function MobileMapTimeline({
return (
<div
className="fixed inset-x-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
// Down to the very edge: Safari draws its toolbar over the page, and a map
// that stopped at the safe area left a bare strip under it (#3002).
className="fixed inset-x-0 bottom-0 z-10"
style={{ top: 'var(--nav-h, 0px)' }}
>
{/* Full-screen map */}
<JourneyLayeredMap
@@ -187,7 +189,7 @@ export default function MobileMapTimeline({
/>
<div
ref={carouselRef}
className="flex items-end gap-[10px] overflow-x-auto px-4 pb-3 pt-1"
className="flex items-end gap-[22px] overflow-x-auto px-6 pb-3 pt-[30px]"
style={{
scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch',
@@ -205,7 +207,7 @@ export default function MobileMapTimeline({
<JourneyEntryCover
entry={entry}
dayColor={dayColorOf(scrubberDays, entry.entry_date)}
isActive={i === activeIndex}
isActive={i === focusIndex}
onClick={() => handleCardTap(entry, i)}
showMood={showMood}
showWeather={showWeather}
@@ -127,9 +127,9 @@ describe('useJourneyCarouselSync', () => {
});
carousel.dispatchEvent(new Event('scroll'));
act(() => vi.advanceTimersByTime(100));
act(() => vi.advanceTimersByTime(60));
carousel.dispatchEvent(new Event('scroll'));
act(() => vi.advanceTimersByTime(149));
act(() => vi.advanceTimersByTime(89));
expect(hook.result.current.activeIndex).toBe(0);
act(() => vi.advanceTimersByTime(1));
// Centre 250: card 1 spans 200 to 300.
@@ -170,7 +170,7 @@ describe('useJourneyCarouselSync', () => {
Object.defineProperty(carousel, 'scrollLeft', { value: scrollLeft, configurable: true });
carousel.dispatchEvent(new Event('scroll'));
act(() => {
vi.advanceTimersByTime(150);
vi.advanceTimersByTime(90);
});
};
@@ -19,6 +19,42 @@ export interface JourneyCarouselSyncOptions<E extends CarouselEntry> {
syncMapOnTap?: boolean;
}
/**
* The card closest to the strip's horizontal centre. More stable than
* IntersectionObserver thresholds when the active card can drift toward the
* viewport edge with proximity snapping.
*/
function nearestCardIndex(el: HTMLDivElement | null, cards: Map<number, HTMLDivElement>): number | null {
if (!el) return null;
const center = el.getBoundingClientRect().left + el.clientWidth / 2;
let bestIdx = 0;
let bestDist = Infinity;
cards.forEach((node, idx) => {
const r = node.getBoundingClientRect();
const d = Math.abs(r.left + r.width / 2 - center);
if (d < bestDist) {
bestDist = d;
bestIdx = idx;
}
});
// The first and the last card can never reach the centre, so a carousel scrolled
// as far as it goes means the card at that end: without this, opening on the
// latest day settled on the one before it (#2851). Measured from the start in
// either direction: in a right-to-left layout scrollLeft runs negative.
const travel = el.scrollWidth - el.clientWidth;
const scrolled = Math.abs(el.scrollLeft);
if (travel > 1 && scrolled >= travel - 1) bestIdx = cards.size - 1;
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
return bestIdx;
}
/**
* How long the strip has to be still before the swipe counts as landed: long
* enough to see past the gaps between scroll events of a momentum swipe, short
* enough that the map follows the card without a noticeable wait.
*/
const SETTLE_MS = 90;
/**
* The card carousel over the journey map, behind both the phone journey screen and the
* narrow desktop and public timeline: the active card is the one nearest the centre
@@ -35,6 +71,10 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
const carouselRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const [activeIndex, setActiveIndex] = useState(0);
// The card nearest the centre while a swipe is still moving: drawn larger at
// once (a transform, so nothing reflows), while the active card and the map
// only follow once the strip settles. Null when not swiping.
const [liveIndex, setLiveIndex] = useState<number | null>(null);
const scrubberDays = useMemo(() => journeyDays(entries), [entries]);
@@ -54,36 +94,17 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
[entries, mapEntries]
);
// Pick the card closest to the horizontal centre. More stable than
// IntersectionObserver thresholds when the active card can drift toward the
// viewport edge with proximity snapping.
const nearestCard = useCallback(() => nearestCardIndex(carouselRef.current, cardRefs.current), []);
const pickNearestCard = useCallback(() => {
const el = carouselRef.current;
if (!el) return;
const center = el.getBoundingClientRect().left + el.clientWidth / 2;
let bestIdx = 0;
let bestDist = Infinity;
cardRefs.current.forEach((node, idx) => {
const r = node.getBoundingClientRect();
const d = Math.abs(r.left + r.width / 2 - center);
if (d < bestDist) {
bestDist = d;
bestIdx = idx;
}
});
// The first and the last card can never reach the centre, so a carousel scrolled
// as far as it goes means the card at that end: without this, opening on the
// latest day settled on the one before it (#2851). Measured from the start in
// either direction: in a right-to-left layout scrollLeft runs negative.
const travel = el.scrollWidth - el.clientWidth;
const scrolled = Math.abs(el.scrollLeft);
if (travel > 1 && scrolled >= travel - 1) bestIdx = cardRefs.current.size - 1;
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
const bestIdx = nearestCard();
if (bestIdx == null) return;
setLiveIndex(null);
setActiveIndex((prev) => {
if (prev !== bestIdx) syncMapToCard(bestIdx);
return bestIdx;
});
}, [syncMapToCard]);
}, [nearestCard, syncMapToCard]);
// Defer every state update until scrolling settles: updating the active card
// mid swipe resizes the cards and reflows the layout on every frame.
@@ -91,16 +112,33 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
const el = carouselRef.current;
if (!el || entries.length === 0) return;
let settleTimer: number | null = null;
let frame: number | null = null;
const onScroll = () => {
if (frame == null) {
frame = window.requestAnimationFrame(() => {
frame = null;
setLiveIndex(nearestCard());
});
}
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(pickNearestCard, 150);
settleTimer = window.setTimeout(pickNearestCard, SETTLE_MS);
};
// Where the browser says when the snap has landed, the card grows right then
// instead of a beat later; the timer stays for the ones that do not.
const onScrollEnd = () => {
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = null;
pickNearestCard();
};
el.addEventListener('scroll', onScroll, { passive: true });
el.addEventListener('scrollend', onScrollEnd, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
el.removeEventListener('scrollend', onScrollEnd);
if (settleTimer != null) window.clearTimeout(settleTimer);
if (frame != null) window.cancelAnimationFrame(frame);
};
}, [entries.length, pickNearestCard]);
}, [entries.length, pickNearestCard, nearestCard]);
const scrollCardIntoCenter = useCallback((idx: number) => {
cardRefs.current.get(idx)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
@@ -159,6 +197,8 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
carouselRef,
cardRefs,
activeIndex,
/** The card to draw as the active one: the one at the centre while swiping, else the active card. */
focusIndex: liveIndex ?? activeIndex,
setActiveIndex,
scrubberDays,
syncMapToCard,
@@ -281,6 +281,8 @@ describe('useJourneyEntryForm', () => {
vi.useFakeTimers();
const places = Array.from({ length: 8 }, (_, i) => ({ name: `P${i}`, lat: String(i), lng: '1' }));
const search = vi.spyOn(mapsApi, 'search').mockResolvedValue({ places } as never);
// No suggestions: the full search answers.
vi.spyOn(mapsApi, 'autocomplete').mockResolvedValue({ suggestions: [], source: 'none' } as never);
const { result } = setup();
act(() => result.current.searchLocation('R'));
expect(result.current.showLocationResults).toBe(true);
@@ -306,6 +308,35 @@ describe('useJourneyEntryForm', () => {
expect(result.current.showLocationResults).toBe(false);
});
it('FE-JRN-ENTRYFORM-013b: suggestions come first, as in the planner, and a pick fetches its point (#3003)', async () => {
vi.useFakeTimers();
const suggest = vi.spyOn(mapsApi, 'autocomplete').mockResolvedValue({
suggestions: [{ placeId: 'g1', mainText: 'Hotel Keihan Temmabashi', secondaryText: 'Osaka, Japan' }],
source: 'google',
} as never);
const search = vi.spyOn(mapsApi, 'search');
const details = vi.spyOn(mapsApi, 'details').mockResolvedValue({ place: { lat: 34.69, lng: 135.51 } } as never);
const { result } = setup();
act(() => result.current.searchLocation('Hotel Keihan'));
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(suggest).toHaveBeenCalledWith('Hotel Keihan', 'en', undefined, expect.any(AbortSignal), expect.any(String));
expect(search).not.toHaveBeenCalled();
expect(result.current.locationResults).toEqual([
{ name: 'Hotel Keihan Temmabashi', address: 'Osaka, Japan', lat: null, lng: null, placeId: 'g1' },
]);
await act(async () => {
result.current.pickLocation(result.current.locationResults[0]);
await vi.advanceTimersByTimeAsync(0);
});
expect(details).toHaveBeenCalledWith('g1', 'en', expect.any(String));
expect(result.current.locationName).toBe('Hotel Keihan Temmabashi');
expect(result.current.locationLat).toBe(34.69);
expect(result.current.locationLng).toBe(135.51);
});
it('FE-JRN-ENTRYFORM-014: "use my location" fills the point, then the place name', async () => {
locate.mockResolvedValue({ lat: 1.234567, lng: 2.345678 });
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: 'Here' } as never);
@@ -1,14 +1,17 @@
import type { MapsAutocompleteResult } from '@trek/shared';
import type React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { journeyApi, mapsApi, weatherApi } from '../../api/client';
import { getCurrentPositionOnce } from '../../hooks/useGeolocation';
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage';
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions';
import { useTranslation } from '../../i18n';
import type { GalleryPhoto, JourneyEntry, JourneyPhoto, JourneyTrip } from '../../store/journeyStore';
import { getApiErrorMessage } from '../../types';
import { normalizeImageFiles } from '../../utils/convertHeic';
import { localIsoDate } from '../../utils/localDate';
import { PlacesSession } from '../../utils/placesSession';
import type { ResilientResult, UploadProgress } from '../../utils/uploadQueue';
import { useToast } from '../shared/Toast';
import type { ProviderPhotoGroup } from './JourneyDetailPageProviderPicker';
@@ -22,8 +25,10 @@ export type PendingProviderGroup = ProviderPhotoGroup & { provider: string };
export interface LocationResult {
name: string;
address?: string;
lat: number;
lng: number;
/** Null for a suggestion while typing: its point comes with the details when it is picked. */
lat: number | null;
lng: number | null;
placeId?: string;
}
export interface JourneyEntryFormOptions {
@@ -104,6 +109,11 @@ export function useJourneyEntryForm({
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Counts location lookups, so an answer that comes back after the field moved on is dropped.
const locationSearchSeqRef = useRef(0);
// The planner's typed-ahead search (usePlaceSuggestions): names and their place in the
// user's language as the provider has them, billed as one session per search (#3003).
const { autocomplete } = usePlaceSuggestions();
const placesSessionRef = useRef(new PlacesSession());
const suggestAbortRef = useRef<AbortController | null>(null);
const [mood, setMood] = useState(entry.mood || '');
const [weather, setWeather] = useState(entry.weather || '');
// The icon the form filled in by itself (see the weather effect below). It is a
@@ -456,7 +466,33 @@ export function useJourneyEntryForm({
}
locationTimerRef.current = setTimeout(async () => {
setLocationSearching(true);
suggestAbortRef.current?.abort();
const controller = new AbortController();
suggestAbortRef.current = controller;
try {
// Suggestions first, as the planner does; the full search when they bring nothing.
const suggested = await autocomplete(
query,
placeLang,
undefined,
controller.signal,
placesSessionRef.current.current()
)
.then((r): MapsAutocompleteResult['suggestions'] => r.suggestions ?? [])
.catch((): MapsAutocompleteResult['suggestions'] => []);
if (seq !== locationSearchSeqRef.current) return;
if (suggested.length > 0) {
setLocationResults(
suggested.slice(0, 6).map((sg) => ({
name: sg.mainText,
address: sg.secondaryText || undefined,
lat: sg.lat ?? null,
lng: sg.lng ?? null,
placeId: sg.placeId,
}))
);
return;
}
const res = await mapsApi.search(query, placeLang);
if (seq !== locationSearchSeqRef.current) return;
setLocationResults(
@@ -478,7 +514,7 @@ export function useJourneyEntryForm({
};
const pickLocation = (r: LocationResult) => {
locationSearchSeqRef.current++;
const seq = ++locationSearchSeqRef.current;
setLocationSearching(false);
setLocationName(r.name);
setLocationLat(r.lat);
@@ -486,6 +522,26 @@ export function useJourneyEntryForm({
setLocationQuery('');
setShowLocationResults(false);
setLocationResults([]);
if (r.lat != null && r.lng != null) {
placesSessionRef.current.end();
return;
}
// A suggestion names the place; its point comes with the details, which close the session.
if (!r.placeId) return;
void mapsApi
.details(r.placeId, placeLang, placesSessionRef.current.peek())
.then((res) => {
if (seq !== locationSearchSeqRef.current) return;
const place = res.place;
if (place?.lat == null || place?.lng == null) return;
setLocationLat(Number(place.lat));
setLocationLng(Number(place.lng));
})
.catch(() => {
// The name stays without a point, as a typed one would; the map simply has no pin for it.
if (seq === locationSearchSeqRef.current) setLocationLat(null);
})
.finally(() => placesSessionRef.current.end());
};
const handleUseCurrentLocation = async () => {
@@ -12,16 +12,20 @@ import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
import { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
import {
buildSnapshot,
pickTrip,
publishWidgets,
todayOf,
tripDayState,
widgetTripChoices,
widgetsActive,
withBookings,
type TripWidgetSnapshot,
type WidgetLabels,
} from '../../native/widgets';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { Day, Place, TranslationFn, Trip } from '../../types';
import { formatTime } from '../../utils/formatters';
@@ -53,6 +57,14 @@ function labels(t: TranslationFn): WidgetLabels {
places: t('dashboard.places'),
travellers: t('native.widget.travellers'),
allDone: t('native.widget.dayDone'),
missing: t('native.widget.missing'),
countries: t('native.widget.countries'),
world: t('native.widget.world'),
group: t('native.widget.group'),
poll: t('native.widget.poll'),
votes: t('native.widget.votes'),
noMessages: t('native.widget.noMessages'),
departure: t('native.widget.departure'),
};
}
@@ -72,13 +84,15 @@ interface SyncContext {
}
async function tripExtras(trip: Trip, days: Day[], { today, language, unit, time }: SyncContext) {
const [places, reservations, todos, packing, members] = await Promise.all([
const [places, reservations, todos, packing, memberRows] = await Promise.all([
offlineDb.places.where('trip_id').equals(trip.id).toArray(),
offlineDb.reservations.where('trip_id').equals(trip.id).toArray(),
offlineDb.todoItems.where('trip_id').equals(trip.id).toArray(),
offlineDb.packingItems.where('trip_id').equals(trip.id).toArray(),
offlineDb.tripMembers.where('tripId').equals(trip.id).count(),
offlineDb.tripMembers.where('tripId').equals(trip.id).toArray(),
]);
const members = memberRows.length;
const names = [trip.owner_username, ...memberRows.map((m) => m.username)].filter((n): n is string => !!n);
const path = `/trips/${trip.id}`;
const place = tripPlace(places);
// While the trip runs, today where it is; before, its first day, once that is in forecast range.
@@ -89,10 +103,18 @@ async function tripExtras(trip: Trip, days: Day[], { today, language, unit, time
days.find((d) => (d.date ?? '').slice(0, 10) === weatherDate),
places
);
const [weather, cover] = await Promise.all([
const [weather, cover, bags, collab] = await Promise.all([
spot && inRange ? widgetWeather(spot, { date: weatherDate, lang: language, unit, place }) : Promise.resolve(null),
widgetCover(trip.cover_image),
packingDetail(trip.id, packing),
collabSummary(
trip.id,
`/trips/${trip.id}?tab=collab`,
[...new Set(names)],
useAuthStore.getState().user?.id ?? null
),
]);
const packed = packingSummary(packing, `/trips/${trip.id}?tab=listen`);
const dayDates = new Map(days.map((d) => [d.id, (d.date ?? '').slice(0, 10)]));
return {
reservations,
@@ -105,7 +127,8 @@ async function tripExtras(trip: Trip, days: Day[], { today, language, unit, time
weather,
bookings: bookingsAhead(reservations, { dayDates, today, tripPath: path, formatTime: time }),
todos: todosSummary(todos, `${path}?tab=listen`),
packing: packingSummary(packing, `${path}?tab=listen`),
packing: packed && bags ? { ...packed, ...bags } : packed,
collab,
},
};
}
@@ -120,20 +143,43 @@ export default function NativeWidgetsSync(): null {
if (!widgetsActive()) return;
let cancelled = false;
/** One trip's snapshot with its day, as the widgets set to that trip show it. */
const tripSnapshot = async (trip: Trip | null, context: SyncContext, atlas: WidgetAtlas | null) => {
const days = trip ? await offlineDb.days.where('trip_id').equals(trip.id).toArray() : [];
const extra = trip ? await tripExtras(trip, days, context) : null;
const plain = trip
? todayOf(trip, days, context.today, (n, m) => t('native.widget.dayOf', { n, m }), context.time)
: null;
const day = plain ? withBookings(plain, extra?.reservations ?? [], context.today, context.time) : null;
return { day, snapshot: buildSnapshot(trip, day, labels(t), { ...extra?.extras, atlas }) };
};
const sync = async () => {
const now = new Date();
const today = localDateStr(now.getFullYear(), now.getMonth(), now.getDate());
const time = (value: string) => formatTime(value, language, timeFormat);
const context: SyncContext = { today, language, unit, time: (value) => formatTime(value, language, timeFormat) };
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 extra = trip ? await tripExtras(trip, days, { today, language, unit, time }) : null;
const plain = trip ? todayOf(trip, days, today, (n, m) => t('native.widget.dayOf', { n, m }), time) : null;
const day = plain ? withBookings(plain, extra?.reservations ?? [], today, time) : null;
const hero = pickTrip(trips, today);
// The countries do not depend on a trip: the atlas widget fills without one.
const atlas = await atlasSummary(language);
const main = await tripSnapshot(hero, context, atlas);
// Every trip running or ahead also gets its own, for widgets set to it in Edit Widget.
const choices: TripWidgetSnapshot[] = [];
for (const trip of widgetTripChoices(trips, today)) {
const own = trip.id === hero?.id ? main : await tripSnapshot(trip, context, null);
choices.push({
id: trip.id,
title: trip.title,
start: trip.start_date ?? '',
end: trip.end_date ?? '',
snapshot: own.snapshot,
});
}
if (cancelled) return;
const snapshot = buildSnapshot(trip, day, labels(t), extra?.extras);
const live = day ? tripDayState(day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone')) : null;
await publishWidgets(snapshot, live);
const live = main.day
? tripDayState(main.day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone'))
: null;
await publishWidgets(main.snapshot, live, choices);
};
void sync();
+6 -1
View File
@@ -610,8 +610,13 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
<meta charset="UTF-8">
<base href="${window.location.origin}/">
<title>${escHtml(trip?.title || tr('pdf.travelPlan'))}</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
<style>
/* Poppins from this server (client/public/fonts), never from Google: the print view
would otherwise send every reader's IP to Google's font servers. */
@font-face { font-family: 'Poppins'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/Poppins-Regular.ttf') format('truetype'); }
@font-face { font-family: 'Poppins'; font-style: italic; font-weight: 400; font-display: swap; src: url('/fonts/Poppins-Italic.ttf') format('truetype'); }
@font-face { font-family: 'Poppins'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/Poppins-Medium.ttf') format('truetype'); }
@font-face { font-family: 'Poppins'; font-style: normal; font-weight: 600 700; font-display: swap; src: url('/fonts/Poppins-Bold.ttf') format('truetype'); }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+6
View File
@@ -78,6 +78,12 @@ export interface ManagedAdminTab {
group?: 'users' | 'config' | 'integration' | 'maintenance'
/** Rendered in the tab body. /admin already requires an admin to get here. */
element: ReactElement
/**
* The same tab laid out for a phone, in the mobile admin's section picker.
* Without it the phone shows `element`, which then has to hold up on a narrow
* screen by itself.
*/
mobileElement?: ReactElement
}
/** Extra protected routes. Spread into the router next to the built-in ones. */
+3 -1
View File
@@ -20,7 +20,9 @@ export default function MToggle({ checked, onChange, ariaLabel, disabled = false
selectionHaptic()
onChange(!checked)
}}
className={`box-border h-[22px] w-10 flex-none rounded-full p-[2px] transition-colors duration-[180ms] disabled:opacity-50 ${
// Width in px like the knob and its travel: w-10 is rem and followed the text size,
// so a smaller text setting left the knob hanging over the end of the track.
className={`box-border h-[22px] w-[40px] flex-none rounded-full p-[2px] transition-colors duration-[180ms] disabled:opacity-50 ${
checked ? 'bg-m-act' : 'bg-[color:var(--m-trackoff)]'
} ${className}`}
>
@@ -44,6 +44,7 @@ import MAdminSheets from './MAdminSheets'
import MAdminStoragePanel from './MAdminStoragePanel'
import MAdminUsersSection from './MAdminUsersSection'
import { MAdminButton } from './MAdminUi'
import { managedAdminTabs } from '../../../managed'
// Stat card of the 2×2 grid (design §6.3): 34px icon tile, 19px/800 number,
// small tracking label. Count-up animation matches the desktop stat cards.
@@ -98,6 +99,8 @@ export default function MAdmin() {
...(managed ? [] : [{ id: 'backup', label: t('admin.tabs.backup'), icon: Database }]),
{ id: 'audit', label: t('admin.tabs.audit'), icon: ScrollText },
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications', icon: Bug }] : []),
// The install's own tabs (managed/index.tsx), after the built-in ones as on the desktop.
...managedAdminTabs.map(tab => ({ id: tab.id, label: tab.label, icon: tab.Icon })),
]
const activeSection = sections.find((s) => s.id === activeTab) ?? sections[0]
@@ -227,6 +230,9 @@ export default function MAdmin() {
{activeTab === 'backup' && <MAdminBackupPanel />}
{activeTab === 'audit' && <MAdminAuditLogPanel serverTimezone={serverTimezone} />}
{activeTab === 'dev-notifications' && <MAdminDevNotificationsPanel />}
{managedAdminTabs.map(tab =>
activeTab === tab.id ? <div key={tab.id}>{tab.mobileElement ?? tab.element}</div> : null,
)}
<MAdminSheets admin={admin} t={t} />
</div>
@@ -14,7 +14,7 @@ import { useInAppNotificationStore } from '../../../store/inAppNotificationStore
import { useDashboardPlugins } from '../../../components/Dashboard/useDashboardPlugins'
import { useAssistantHomeLauncher } from '../../../components/Assistant/useAssistantHome'
import type { TripCardBadge } from '../../../api/client'
import DemoWelcome from '../../../components/Demo/DemoWelcome'
import MDemoWelcome from './MDemoWelcome'
import { IcsSubscribeModal } from '../../../components/Planner/IcsSubscribeModal'
import PluginWidgets from '../../../components/Plugins/PluginWidgets'
import { entityGradient } from '../../../utils/gradients'
@@ -253,7 +253,7 @@ export default function MDashboard(): React.ReactElement {
<MUserMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
{demoMode && <DemoWelcome />}
{demoMode && <MDemoWelcome />}
<MPullIndicator state={pull} label={t('common.loading')} />
<div ref={contentRef} className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)]">
@@ -0,0 +1,37 @@
import { waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it } from 'vitest';
import { render, screen } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useAuthStore } from '../../../store/authStore';
import MDemoWelcome from './MDemoWelcome';
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { serverTimezone: 'UTC' });
});
describe('MDemoWelcome', () => {
it('FE-COMP-DEMOWELCOME-030: on a phone it is a sheet with the same content and a full-width start', async () => {
render(<MDemoWelcome />);
const sheet = screen.getByRole('dialog', { name: 'Live demo' });
expect(sheet).toHaveTextContent('Welcome to TREK');
expect(within(sheet).getByTestId('demo-countdown')).toBeInTheDocument();
expect(within(sheet).getByText('Everything resets every hour')).toBeInTheDocument();
expect(within(sheet).getByText('Trip planner')).toBeInTheDocument();
expect(within(sheet).getByText('Automatic backups')).toBeInTheDocument();
expect(within(sheet).getByRole('link', { name: /source code/i })).toHaveAttribute('target', '_blank');
await userEvent.click(within(sheet).getByRole('button', { name: /start exploring/i }));
await waitFor(() => expect(screen.queryByText('Trip planner')).not.toBeInTheDocument());
});
it('FE-COMP-DEMOWELCOME-031: the photo credits open in its place and lead back', async () => {
render(<MDemoWelcome />);
const sheet = screen.getByRole('dialog', { name: 'Live demo' });
await userEvent.click(within(sheet).getByRole('button', { name: /photo credits/i }));
const back = await within(sheet).findByRole('button', { name: /back/i });
expect(within(sheet).queryByText('Trip planner')).not.toBeInTheDocument();
await userEvent.click(back);
expect(within(sheet).getByText('Trip planner')).toBeInTheDocument();
});
});
@@ -0,0 +1,177 @@
import { ArrowLeft, ArrowRight, Clock, ExternalLink, Images, RotateCcw } from 'lucide-react';
import { lazy, Suspense } from 'react';
import {
DEMO_FEATURES,
DEMO_FULL_VERSION,
DEMO_LIMITS,
TREK_REPO_URL,
type DemoLine,
} from '../../../components/Demo/demoWelcomeModel';
import { useDemoWelcome } from '../../../components/Demo/useDemoWelcome';
import ErrorBoundary from '../../../components/shared/ErrorBoundary';
import TrekIcon from '../../../components/shared/TrekIcon';
import { useTranslation } from '../../../i18n';
import MSheet from '../../components/MSheet';
import { Eyebrow, SHEET_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi';
import { FormSheetHeader } from '../trip/sheets/PlSheetChrome';
const DemoCredits = lazy(() => import('../../../components/Demo/DemoCredits'));
type Translate = ReturnType<typeof useTranslation>['t'];
const CARD = 'rounded-[18px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]';
function Lines({ lines, accent, t }: { lines: readonly DemoLine[]; accent?: boolean; t: Translate }) {
return (
<ul className={`${CARD} m-0 flex list-none flex-col gap-2 p-3`}>
{lines.map(({ icon: Icon, labelKey }) => (
<li
key={labelKey}
className="flex items-center gap-2.5 text-[length:calc(12.5px*var(--fs-scale-body,1))] text-m-ink"
>
<Icon
size={15}
strokeWidth={2}
className={`flex-none ${accent ? 'text-success' : 'text-m-faint'}`}
aria-hidden
/>
<span>{t(labelKey)}</span>
</li>
))}
</ul>
);
}
function Welcome({ t }: { t: Translate }) {
return (
<div className="flex flex-col gap-4">
<p className="m-0 text-[length:calc(13px*var(--fs-scale-body,1))] leading-snug text-m-muted">
{t('demo.welcome.tagline')}
</p>
<div className="flex items-start gap-2.5 rounded-[18px] bg-warning-soft px-3.5 py-3">
<RotateCcw size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-warning" aria-hidden />
<div className="min-w-0">
<div className="text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink">
{t('demo.welcome.resetTitle')}
</div>
<p className="m-0 mt-0.5 text-[length:calc(12px*var(--fs-scale-caption,1))] leading-snug text-m-muted">
{t('demo.welcome.resetBody')}
</p>
</div>
</div>
<section className="flex flex-col gap-2">
<Eyebrow>{t('demo.welcome.tryTitle')}</Eyebrow>
<ul className={`${CARD} m-0 flex list-none flex-col divide-y divide-[color:var(--m-rowbr)] p-0`}>
{DEMO_FEATURES.map(({ id, icon: Icon, titleKey, bodyKey }) => (
<li key={id} className="flex gap-3 px-3 py-2.5">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-[color:var(--m-sheetop)] text-m-ink">
<Icon size={15} strokeWidth={2} aria-hidden />
</span>
<div className="min-w-0">
<div className="text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink">
{t(titleKey)}
</div>
<p className="m-0 text-[length:calc(11.5px*var(--fs-scale-caption,1))] leading-snug text-m-muted">
{t(bodyKey)}
</p>
</div>
</li>
))}
</ul>
</section>
<section className="flex flex-col gap-2">
<Eyebrow>{t('demo.welcome.limitsTitle')}</Eyebrow>
<Lines lines={DEMO_LIMITS} t={t} />
</section>
<section className="flex flex-col gap-2">
<Eyebrow>{t('demo.welcome.fullTitle')}</Eyebrow>
<Lines lines={DEMO_FULL_VERSION} accent t={t} />
</section>
</div>
);
}
/**
* The demo welcome on a phone: a sheet from the bottom like every other form
* in the phone shell, instead of the desktop dialog squeezed onto a narrow
* screen. The head stays small so the list gets the room, the start button
* runs the full width, and the two links sit under it.
*/
export default function MDemoWelcome() {
const { t } = useTranslation();
const { open, close, countdown, creditsOpen, openCredits, closeCredits } = useDemoWelcome();
const title = t(creditsOpen ? 'demo.credits.title' : 'demo.welcome.title');
return (
<MSheet open={open} onClose={close} variant="bottom" material="opaque" ariaLabel={t('demo.welcome.eyebrow')}>
<div className="flex max-h-[88dvh] min-h-0 flex-col">
<FormSheetHeader
leading={
<span className="grid h-10 w-10 flex-none place-items-center rounded-[13px] bg-[color:var(--m-ic)]">
<TrekIcon className="h-6 w-6 text-m-ink" />
</span>
}
title={title}
subtitle={
<span className="inline-flex items-center gap-1">
<Clock size={11} strokeWidth={2.2} aria-hidden />
{t('demo.welcome.resetIn')}
<span className="tabular-nums" data-testid="demo-countdown">
{countdown}
</span>
</span>
}
onClose={close}
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3 pt-1">
{creditsOpen ? (
<ErrorBoundary boundaryId="demo-credits">
<Suspense fallback={null}>
<DemoCredits />
</Suspense>
</ErrorBoundary>
) : (
<Welcome t={t} />
)}
</div>
<div className="flex flex-none flex-col gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
{creditsOpen ? (
<button type="button" onClick={closeCredits} className={`${SHEET_SECONDARY} w-full`}>
<ArrowLeft size={15} strokeWidth={2.2} className="rtl:-scale-x-100" aria-hidden />
{t('demo.credits.back')}
</button>
) : (
<>
<button type="button" onClick={close} className={`${SHEET_PRIMARY} w-full justify-center`}>
{t('demo.welcome.start')}
<ArrowRight size={15} strokeWidth={2.2} className="rtl:-scale-x-100" aria-hidden />
</button>
<div className="flex items-center justify-center gap-5 text-[length:calc(12px*var(--fs-scale-caption,1))] font-medium text-m-muted">
<a
href={TREK_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-m-muted no-underline"
>
<ExternalLink size={13} strokeWidth={2} aria-hidden />
{t('demo.welcome.repo')}
</a>
<button type="button" onClick={openCredits} className="inline-flex items-center gap-1.5 text-m-muted">
<Images size={13} strokeWidth={2} aria-hidden />
{t('demo.welcome.credits')}
</button>
</div>
</>
)}
</div>
</div>
</MSheet>
);
}
@@ -102,7 +102,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
)
const {
mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays,
mapRef, carouselRef, cardRefs, activeIndex, focusIndex, setActiveIndex, scrubberDays,
syncMapToCard, handleMarkerClick, handleCardTap, openEntryById, jumpToDay,
} = useJourneyCarouselSync<JourneyEntry>({ entries, mapEntries: sidebarMapItems, onOpenEntry: setEditingEntry, syncMapOnTap: true })
@@ -498,7 +498,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
/>
<div
ref={carouselRef}
className="flex items-end gap-[10px] overflow-x-auto px-4 pb-1 [-webkit-overflow-scrolling:touch] [scrollbar-width:none]"
className="flex items-end gap-[22px] overflow-x-auto px-6 pb-1 pt-[30px] [-webkit-overflow-scrolling:touch] [scrollbar-width:none]"
style={{ scrollSnapType: 'x mandatory' }}
>
{entries.map((entry, i) => (
@@ -510,7 +510,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
<JourneyEntryCover
entry={entry}
dayColor={dayColorOf(scrubberDays, entry.entry_date)}
isActive={i === activeIndex}
isActive={i === focusIndex}
onClick={() => handleCardTap(entry, i)}
showMood={current.show_mood !== 0}
showWeather={current.show_weather !== 0}
+96 -6
View File
@@ -1,17 +1,36 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const platform = vi.hoisted(() => ({ ios: true }));
const shell = vi.hoisted(() => ({
getCapabilities: vi.fn(),
storeProducts: vi.fn(),
storePurchase: vi.fn(),
storeFinish: vi.fn(),
storeRestore: vi.fn(),
}));
vi.mock('./platform', () => ({ isNativeIos: () => platform.ios }));
vi.mock('./trekShell', () => ({ TrekShell: shell }));
import { fakeIapClient, nativeIapClient, productIdFor } from './iap';
/** A signed transaction as far as the client reads it: only the payload matters here. */
function jwsFor(productId: string): string {
const payload = btoa(JSON.stringify({ productId })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return `header.${payload}.signature`;
}
beforeEach(() => {
platform.ios = true;
for (const fn of Object.values(shell)) fn.mockReset();
});
describe('in-app purchase client', () => {
it('names store products by the shared scheme', () => {
expect(productIdFor('start', 'month')).toBe('cloud.start.monthly');
expect(productIdFor('pro', 'year')).toBe('cloud.pro.yearly');
});
it('the native client is unavailable until the purchase plugin is wired', async () => {
expect(await nativeIapClient.isAvailable()).toBe(false);
expect(await nativeIapClient.purchase('cloud.plus.monthly')).toEqual({ kind: 'failed' });
});
it('the fake client yields a receipt for the product asked for', async () => {
expect(await fakeIapClient.isAvailable()).toBe(true);
const outcome = await fakeIapClient.purchase('cloud.plus.monthly');
@@ -22,3 +41,74 @@ describe('in-app purchase client', () => {
expect(outcome.purchase.receipt).toContain('cloud.plus.monthly');
});
});
describe('native client', () => {
it('is available only on iOS with the storeKit feature', async () => {
shell.getCapabilities.mockResolvedValue({ features: ['storeKit'] });
expect(await nativeIapClient.isAvailable()).toBe(true);
shell.getCapabilities.mockResolvedValue({ features: ['widgets'] });
expect(await nativeIapClient.isAvailable()).toBe(false);
shell.getCapabilities.mockRejectedValue({ code: 'UNIMPLEMENTED' });
expect(await nativeIapClient.isAvailable()).toBe(false);
platform.ios = false;
shell.getCapabilities.mockResolvedValue({ features: ['storeKit'] });
expect(await nativeIapClient.isAvailable()).toBe(false);
});
it('maps the store price strings by product', async () => {
shell.storeProducts.mockResolvedValue({ products: [{ id: 'cloud.plus.monthly', displayPrice: '9,99 €' }] });
const prices = await nativeIapClient.prices(['cloud.plus.monthly', 'cloud.pro.monthly']);
expect(shell.storeProducts).toHaveBeenCalledWith({ ids: ['cloud.plus.monthly', 'cloud.pro.monthly'] });
expect(prices.get('cloud.plus.monthly')).toBe('9,99 €');
expect(prices.has('cloud.pro.monthly')).toBe(false);
shell.storeProducts.mockRejectedValue(new Error('down'));
expect((await nativeIapClient.prices(['cloud.plus.monthly'])).size).toBe(0);
});
it('turns a bought transaction into a receipt', async () => {
shell.storePurchase.mockResolvedValue({ kind: 'bought', jws: 'signed', transactionId: '42' });
expect(await nativeIapClient.purchase('cloud.plus.yearly')).toEqual({
kind: 'bought',
purchase: { platform: 'apple', productId: 'cloud.plus.yearly', receipt: 'signed', transactionId: '42' },
});
});
it('passes cancel and pending through, and fails on anything else', async () => {
shell.storePurchase.mockResolvedValue({ kind: 'cancelled' });
expect(await nativeIapClient.purchase('cloud.plus.monthly')).toEqual({ kind: 'cancelled' });
shell.storePurchase.mockResolvedValue({ kind: 'pending' });
expect(await nativeIapClient.purchase('cloud.plus.monthly')).toEqual({ kind: 'pending' });
shell.storePurchase.mockResolvedValue({ kind: 'bought' });
expect(await nativeIapClient.purchase('cloud.plus.monthly')).toEqual({ kind: 'failed' });
shell.storePurchase.mockRejectedValue(new Error('boom'));
expect(await nativeIapClient.purchase('cloud.plus.monthly')).toEqual({ kind: 'failed' });
});
it('finishes by transaction id and swallows a failure', async () => {
const purchase = { platform: 'apple' as const, productId: 'cloud.plus.monthly', receipt: 'x', transactionId: '7' };
shell.storeFinish.mockResolvedValue(undefined);
await nativeIapClient.finish(purchase);
expect(shell.storeFinish).toHaveBeenCalledWith({ transactionId: '7' });
shell.storeFinish.mockRejectedValue(new Error('gone'));
await expect(nativeIapClient.finish(purchase)).resolves.toBeUndefined();
});
it('restores the subscription the Apple ID holds, with its product', async () => {
const jws = jwsFor('cloud.pro.yearly');
shell.storeRestore.mockResolvedValue({ jws, transactionId: '9' });
expect(await nativeIapClient.restore()).toEqual({
platform: 'apple',
productId: 'cloud.pro.yearly',
receipt: jws,
transactionId: '9',
});
expect(shell.storeRestore).toHaveBeenCalledWith({ prefix: 'cloud.' });
shell.storeRestore.mockResolvedValue({});
expect(await nativeIapClient.restore()).toBeNull();
});
});
+107 -19
View File
@@ -1,12 +1,15 @@
import { isNativeIos } from './platform';
import { TrekShell } from './trekShell';
/**
* In-app purchase, from the app's side. A seam, like {@link TrekShell}: the
* screens drive a plan to a receipt through this, and never touch the store
* plugin directly. Two implementations stand behind it — a fake one for
* development and preview, so the whole subscription flow can be walked without a
* store, and the native one that will call the platform plugin once the store
* products and accounts exist. Until then the native client reports itself
* unavailable and the plan picker falls back to finishing on the web, so nothing
* half-wired ever faces a customer.
* screens drive a plan to a receipt through this, and never touch StoreKit
* directly. Two implementations stand behind it: a fake one for development and
* preview, so the whole subscription flow can be walked without a store, and the
* native one that runs StoreKit 2 through TrekShell (feature storeKit). Where
* neither can take a payment (Android until Play Billing is wired, a binary from
* before storeKit) the client reports itself unavailable and the shell offers no
* purchase at all: the store rules leave no room for sending buyers to the web.
*/
type IapPlatform = 'apple' | 'google';
@@ -15,26 +18,47 @@ type IapPlatform = 'apple' | 'google';
export interface Purchase {
platform: IapPlatform;
productId: string;
/** Apple: the signed transaction (JWS) the control plane verifies. */
receipt: string;
/** The store's id for this transaction, handed back to {@link IapClient.finish}. */
transactionId: string;
}
/** What a purchase attempt came to. `cancelled` is the customer backing out, not an error. */
type PurchaseOutcome = { kind: 'bought'; purchase: Purchase } | { kind: 'cancelled' } | { kind: 'failed' };
/**
* What a purchase attempt came to. `cancelled` is the customer backing out, not
* an error; `pending` is a purchase waiting on someone else (Ask to Buy, a bank),
* which a restore picks up once it goes through.
*/
type PurchaseOutcome =
{ kind: 'bought'; purchase: Purchase } | { kind: 'cancelled' } | { kind: 'pending' } | { kind: 'failed' };
export interface IapClient {
/** Whether this build can take a payment: the plugin is present and the store products exist. */
/** Whether this build can take a payment. */
isAvailable(): Promise<boolean>;
/**
* The store's own price strings by product id, in the buyer's currency. A
* product missing from the map cannot be bought here. Empty for the fake, whose
* prices come from the plan list.
*/
prices(productIds: string[]): Promise<Map<string, string>>;
/** Runs the platform's purchase sheet for a product and resolves with what became of it. */
purchase(productId: string): Promise<PurchaseOutcome>;
/** Tells the store a purchase was delivered, once its instance exists. */
finish(purchase: Purchase): Promise<void>;
/** The subscription this store account already holds, for a reinstall or a new device. */
restore(): Promise<Purchase | null>;
}
/** Every store product of the cloud starts with this. */
const PRODUCT_PREFIX = 'cloud.';
/**
* The store product id for a plan and interval. Must match the identifiers created
* in App Store Connect and the Play Console, and the control plane's catalog
* (`cloud.<plan>.<monthly|yearly>`); kept here as the one place the app names them.
*/
export function productIdFor(planId: string, interval: 'month' | 'year'): string {
return `cloud.${planId}.${interval === 'year' ? 'yearly' : 'monthly'}`;
return `${PRODUCT_PREFIX}${planId}.${interval === 'year' ? 'yearly' : 'monthly'}`;
}
/**
@@ -46,30 +70,94 @@ export const fakeIapClient: IapClient = {
async isAvailable() {
return true;
},
async prices() {
return new Map();
},
async purchase(productId) {
await new Promise((resolve) => setTimeout(resolve, 900));
const nonce = String(Date.now());
return {
kind: 'bought',
purchase: { platform: 'apple', productId, receipt: `fake-receipt:${productId}:${Date.now()}` },
purchase: { platform: 'apple', productId, receipt: `fake-receipt:${productId}:${nonce}`, transactionId: nonce },
};
},
async finish() {},
async restore() {
return null;
},
};
/**
* The store client. A stub until the purchase plugin is a dependency and the
* products exist: it reports itself unavailable, so the plan picker finishes on
* the web instead of offering a payment it cannot take. When the plugin lands,
* this is where it is wired; the screens do not change.
* StoreKit 2 through TrekShell. Available on iOS when the binary has the
* storeKit feature; everywhere else (Android, the web, an older binary) it
* reports itself unavailable and every call comes to nothing.
*/
export const nativeIapClient: IapClient = {
async isAvailable() {
return false;
if (!isNativeIos()) return false;
try {
const caps = await TrekShell.getCapabilities();
return caps.features.includes('storeKit');
} catch {
return false;
}
},
async purchase() {
return { kind: 'failed' };
async prices(productIds) {
try {
const { products } = await TrekShell.storeProducts({ ids: productIds });
return new Map(products.map((p) => [p.id, p.displayPrice]));
} catch {
return new Map();
}
},
async purchase(productId) {
try {
const result = await TrekShell.storePurchase({ productId });
if (result.kind === 'bought' && result.jws && result.transactionId) {
return {
kind: 'bought',
purchase: { platform: 'apple', productId, receipt: result.jws, transactionId: result.transactionId },
};
}
if (result.kind === 'cancelled' || result.kind === 'pending') return { kind: result.kind };
return { kind: 'failed' };
} catch {
return { kind: 'failed' };
}
},
async finish(purchase) {
try {
await TrekShell.storeFinish({ transactionId: purchase.transactionId });
} catch {
// Left unfinished, the store hands the transaction over again; a restore redeems it idempotently.
}
},
async restore() {
try {
const { jws, transactionId } = await TrekShell.storeRestore({ prefix: PRODUCT_PREFIX });
if (!jws || !transactionId) return null;
return { platform: 'apple', productId: productIdOf(jws), receipt: jws, transactionId };
} catch {
return null;
}
},
};
/**
* The product id inside a signed transaction, read for display and routing only:
* the control plane verifies the signature and takes the product from there.
*/
function productIdOf(jws: string): string {
try {
const part = jws.split('.')[1] ?? '';
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
const id = (JSON.parse(json) as { productId?: unknown }).productId;
return typeof id === 'string' ? id : '';
} catch {
return '';
}
}
/**
* Whether to use the fake. On in a development build, and switchable on in a
* preview build with `localStorage['trek-iap-fake'] = '1'`, so the flow can be
+7
View File
@@ -14,3 +14,10 @@ export const DEMO_SERVER_URL = 'https://demo.liketrek.com';
/** TREK Cloud, for someone who wants a hosted TREK rather than running their own. */
export const CLOUD_URL = 'https://liketrek.com';
/**
* The terms an in-app subscription is sold under: Apple's standard licence
* agreement, which the App Store applies when an app names no own EULA. The
* store rules ask for it next to the purchase, together with the privacy policy.
*/
export const STORE_TERMS_URL = 'https://www.apple.com/legal/internet-services/itunes/dev/stdeula/';
+12 -1
View File
@@ -60,7 +60,8 @@ export interface TrekShellPlugin {
/** 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 widgetTrips adds `key`: "trip.<id>" for one trip's snapshot, "trips" for the list a widget picks from. */
setWidgetSnapshot(options: { json: string; key?: string }): Promise<void>;
/** Feature liveActivity: starts the travel day's Live Activity, or updates the running one. */
syncTripDay(options: {
tripTitle: string;
@@ -72,6 +73,16 @@ export interface TrekShellPlugin {
total: number;
}): Promise<{ running: boolean }>;
endTripDay(): Promise<void>;
/** iOS, feature storeKit: the App Store's price strings for the products it knows. */
storeProducts(options: { ids: string[] }): Promise<{ products: { id: string; displayPrice: string }[] }>;
/** iOS, feature storeKit: the purchase sheet; `jws` is the signed transaction, set when bought. */
storePurchase(options: {
productId: string;
}): Promise<{ kind: 'bought' | 'cancelled' | 'pending' | 'failed'; jws?: string; transactionId?: string }>;
/** iOS, feature storeKit: marks a purchase delivered once its instance exists. */
storeFinish(options: { transactionId: string }): Promise<void>;
/** iOS, feature storeKit: the newest active subscription under `prefix` on this Apple ID, if any. */
storeRestore(options: { prefix: string }): Promise<{ jws?: string; transactionId?: string }>;
addListener(
event: 'pushOpened',
listener: (payload: { url: string }) => void
+14 -6
View File
@@ -42,25 +42,33 @@ export async function widgetWeather(
return value;
}
const COVER_KEY = 'trek.widgetCover';
const COVER_KEY = 'trek.widgetCovers';
/** Covers kept, one per trip a widget can show (widgetTripChoices). */
const COVER_KEEP = 6;
const COVER_MAX_PX = 720;
const COVER_MAX_BYTES = 15 * 1024 * 1024;
/** Kept under this so the keychain item the widget reads stays small. */
const COVER_MAX_BASE64 = 160_000;
function storedCover(url: string): string | null {
function storedCovers(): { url: string; data: string }[] {
try {
const raw = localStorage.getItem(COVER_KEY);
const parsed = raw ? (JSON.parse(raw) as { url?: string; data?: string }) : null;
return parsed?.url === url && parsed.data ? parsed.data : null;
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? (parsed as { url: string; data: string }[]) : [];
} catch {
return null;
return [];
}
}
function storedCover(url: string): string | null {
return storedCovers().find((c) => c.url === url)?.data ?? null;
}
/** Newest first, the oldest dropped past COVER_KEEP. */
function storeCover(url: string, data: string): void {
try {
localStorage.setItem(COVER_KEY, JSON.stringify({ url, data }));
const rest = storedCovers().filter((c) => c.url !== url);
localStorage.setItem(COVER_KEY, JSON.stringify([{ url, data }, ...rest].slice(0, COVER_KEEP)));
} catch {
// Storage full: the cover is fetched again next time, nothing else depends on it.
}
+227
View File
@@ -0,0 +1,227 @@
import { CONTINENT_MAP } from '@trek/shared';
import apiClient, { collabApi, packingApi } from '../api/client';
import type { PackingItem } from '../types';
/**
* The widget parts that only the server knows: the packing bags with their
* weights, the countries of the Atlas, and the trip's chat and polls. Each is
* best effort, so offline or with the addon off the widget goes without it;
* client/ios/App/TREKWidgets/WidgetSnapshot.swift reads the shapes.
*/
interface WidgetBag {
name: string;
color: string;
grams: number;
limit?: number;
packed: number;
total: number;
}
export interface WidgetPackingDetail {
bags: WidgetBag[];
missing: { name: string; category: string | null; color: string | null }[];
}
export interface WidgetAtlas {
count: number;
codes: string[];
/** The latest visited, newest first, with their names in the user's language. */
recent: { code: string; name: string }[];
continents: { name: string; count: number }[];
trips?: number;
days?: number;
}
export interface WidgetCollab {
members: { name: string; initials: string }[];
messages: { author: string; initials: string; text: string; at: string | null; mine: boolean }[];
poll?: { question: string; options: { label: string; votes: number }[]; voters: number };
path: string;
}
const HEX = /^#[0-9a-f]{6}$/i;
/**
* Server answers kept for a while: the widgets sync every quarter hour for up
* to six trips, and bags, chat and polls do not change that fast.
*/
const NETWORK_TTL_MS = 10 * 60 * 1000;
const networkCache = new Map<string, { at: number; value: unknown }>();
async function cached<T>(key: string, read: () => Promise<T>): Promise<T> {
const hit = networkCache.get(key);
if (hit && Date.now() - hit.at < NETWORK_TTL_MS) return hit.value as T;
const value = await read();
networkCache.set(key, { at: Date.now(), value });
return value;
}
function initials(name: string): string {
const parts = name
.trim()
.split(/[\s._-]+/)
.filter(Boolean);
const letters = parts.length > 1 ? parts[0][0] + parts[1][0] : name.trim().slice(0, 2);
return letters.toUpperCase();
}
/** The bags with what is in them, and the items still to pack, those in a bag first. */
export async function packingDetail(
tripId: number,
items: PackingItem[],
limit = 8
): Promise<WidgetPackingDetail | null> {
if (items.length === 0) return null;
let bags: {
id: number;
name: string;
color: string;
weight_limit_grams?: number | null;
total_weight_grams?: number;
}[] = [];
try {
const res = await cached(`bags:${tripId}`, () => packingApi.listBags(tripId) as Promise<{ bags?: typeof bags }>);
bags = res.bags ?? [];
} catch {
// Offline or no bags: the widget shows the items without them.
bags = [];
}
const byBag = new Map(bags.map((b) => [b.id, b]));
const summaries = bags.map((b) => {
const inBag = items.filter((i) => i.bag_id === b.id);
const grams = b.total_weight_grams ?? inBag.reduce((sum, i) => sum + (i.weight_grams ?? 0) * (i.quantity ?? 1), 0);
return {
name: b.name,
// Empty when the bag has no usable colour: the widget then uses its own blue.
color: HEX.test(b.color) ? b.color : '',
grams,
...(b.weight_limit_grams ? { limit: b.weight_limit_grams } : {}),
packed: inBag.filter((i) => i.checked).length,
total: inBag.length,
};
});
const missing = items
.filter((i) => !i.checked)
.sort((a, b) => Number(b.bag_id != null) - Number(a.bag_id != null) || a.sort_order - b.sort_order)
.slice(0, limit)
.map((i) => {
const bag = i.bag_id != null ? byBag.get(i.bag_id) : undefined;
return { name: i.name, category: i.category ?? null, color: bag && HEX.test(bag.color) ? bag.color : null };
});
return { bags: summaries.filter((b) => b.total > 0), missing };
}
interface AtlasStats {
countries?: { code: string; lastVisit?: string | null; firstVisit?: string | null }[];
stats?: { totalCountries?: number; totalTrips?: number; totalDays?: number };
}
const ATLAS_TTL_MS = 60 * 60 * 1000;
let atlasCache: { at: number; language: string; value: WidgetAtlas | null } | null = null;
/** The countries of the Atlas, the latest ones named, and how they spread over the continents. */
export async function atlasSummary(language: string): Promise<WidgetAtlas | null> {
if (atlasCache && atlasCache.language === language && Date.now() - atlasCache.at < ATLAS_TTL_MS)
return atlasCache.value;
const value = await readAtlas(language);
atlasCache = { at: Date.now(), language, value };
return value;
}
async function readAtlas(language: string): Promise<WidgetAtlas | null> {
let data: AtlasStats;
try {
data = (await apiClient.get('/addons/atlas/stats', { timeout: 10_000 })).data as AtlasStats;
} catch {
// The addon is off or the server unreachable: no atlas widget data this time.
return null;
}
const countries = (data.countries ?? []).filter((c) => /^[A-Z]{2}$/.test(c.code));
let names: Intl.DisplayNames | null = null;
try {
names = new Intl.DisplayNames([language], { type: 'region' });
} catch {
names = null;
}
const recent = [...countries]
.sort((a, b) => (b.lastVisit ?? b.firstVisit ?? '').localeCompare(a.lastVisit ?? a.firstVisit ?? ''))
.slice(0, 4)
.map((c) => ({ code: c.code, name: names?.of(c.code) ?? c.code }));
const perContinent = new Map<string, number>();
for (const c of countries) {
const continent = CONTINENT_MAP[c.code];
if (continent) perContinent.set(continent, (perContinent.get(continent) ?? 0) + 1);
}
return {
count: data.stats?.totalCountries ?? countries.length,
codes: countries.map((c) => c.code),
recent,
continents: [...perContinent].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count),
...(data.stats?.totalTrips ? { trips: data.stats.totalTrips } : {}),
...(data.stats?.totalDays ? { days: data.stats.totalDays } : {}),
};
}
interface Message {
user_id: number;
username: string;
text: string;
created_at: string | null;
deleted: number | null;
}
interface Poll {
question: string;
is_closed: boolean;
options: { text: unknown; label: unknown; voters: { user_id: number }[] }[];
}
/** Who travels along, the latest messages in the trip chat, and the open poll. */
export async function collabSummary(
tripId: number,
path: string,
members: string[],
me: number | null
): Promise<WidgetCollab | null> {
let messages: Message[] = [];
let polls: Poll[] = [];
try {
const [m, p] = await Promise.all([
cached(`messages:${tripId}`, () => collabApi.getMessages(tripId) as Promise<{ messages?: Message[] }>),
cached(`polls:${tripId}`, () => collabApi.getPolls(tripId) as Promise<{ polls?: Poll[] }>),
]);
messages = m.messages ?? [];
polls = p.polls ?? [];
} catch {
// Offline or the collab addon is off: the widget shows the members only.
if (members.length < 2) return null;
}
const latest = messages
.filter((m) => !m.deleted && m.text.trim())
.sort((a, b) => (a.created_at ?? '').localeCompare(b.created_at ?? ''))
.slice(-3)
.map((m) => ({
author: m.username,
initials: initials(m.username),
text: m.text.trim().slice(0, 140),
at: m.created_at,
mine: me != null && m.user_id === me,
}));
const open = polls.find((p) => !p.is_closed);
const label = (o: Poll['options'][number]) => String(o.label ?? o.text ?? '');
const poll = open
? {
question: open.question,
options: open.options.slice(0, 4).map((o) => ({ label: label(o), votes: o.voters.length })),
voters: new Set(open.options.flatMap((o) => o.voters.map((v) => v.user_id))).size,
}
: undefined;
if (members.length < 2 && latest.length === 0 && !poll) return null;
return {
members: members.slice(0, 6).map((name) => ({ name, initials: initials(name) })),
messages: latest,
...(poll ? { poll } : {}),
path,
};
}
+43 -1
View File
@@ -13,7 +13,15 @@ vi.mock('./trekShell', () => ({
}));
import { TrekShell } from './trekShell';
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState, withBookings } from './widgets';
import {
buildSnapshot,
pickTrip,
publishWidgets,
todayOf,
tripDayState,
widgetTripChoices,
withBookings,
} 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;
@@ -51,6 +59,15 @@ describe('pickTrip', () => {
expect(pickTrip(trips, '2026-11-12')?.id).toBe(2);
});
it('of several running trips takes the dashboard hero: the one that started first', () => {
const trips = [
trip(1, '2026-11-02', '2026-11-06'),
trip(2, '2026-10-20', '2026-11-18'),
trip(3, '2026-11-04', '2026-11-12'),
];
expect(pickTrip(trips, '2026-11-06')?.id).toBe(2);
});
it('skips undated, archived and finished trips', () => {
expect(
pickTrip(
@@ -211,3 +228,28 @@ describe('the richer snapshot', () => {
expect(snapshot).not.toHaveProperty('packing');
});
});
describe('widgets set to a trip', () => {
it('offers the trips running or ahead, in the dashboard order, at most six', () => {
const trips = [
trip(1, '2026-01-01', '2026-01-05'),
trip(2, '2026-12-01', '2026-12-05'),
trip(3, '2026-11-04', '2026-11-12'),
trip(4, '2026-11-20', '2026-11-22', 1),
trip(5, null, null),
...[6, 7, 8, 9, 10, 11].map((id) => trip(id, `2027-0${id - 5}-01`, `2027-0${id - 5}-03`)),
];
expect(widgetTripChoices(trips, '2026-11-06').map((t) => t.id)).toEqual([3, 2, 6, 7, 8, 9]);
});
it('writes each trip under its own key and the list to pick from', async () => {
vi.mocked(TrekShell.setWidgetSnapshot).mockClear();
const snapshot = buildSnapshot(trip(2, '2026-12-01', '2026-12-05'), null, labels);
await publishWidgets(snapshot, null, [
{ id: 2, title: 'Trip 2', start: '2026-12-01', end: '2026-12-05', snapshot },
]);
const calls = vi.mocked(TrekShell.setWidgetSnapshot).mock.calls.map(([options]) => options);
expect(calls.map((c) => c.key)).toEqual([undefined, 'trip.2', 'trips']);
expect(JSON.parse(calls[2].json)).toEqual([{ id: 2, title: 'Trip 2', start: '2026-12-01', end: '2026-12-05' }]);
});
});
+54 -12
View File
@@ -1,10 +1,12 @@
import type { Day, Reservation, Trip } from '../types';
import { splitReservationDateTime } from '../utils/formatters';
import { heroTrip, sortTripsByRelevance } from '../utils/heroTrip';
import { hasNativeFeature } from './capabilities';
import { isNativeIos } from './platform';
import { reportNativeFailure } from './reportFailure';
import { TrekShell } from './trekShell';
import { stopLook, type WidgetBooking, type WidgetPacking, type WidgetTodos } from './widgetExtras';
import type { WidgetAtlas, WidgetCollab, WidgetPackingDetail } from './widgetOnline';
/**
* What the home-screen widgets and the travel-day Live Activity show, built from
@@ -37,6 +39,14 @@ export interface WidgetLabels {
places?: string;
travellers?: string;
allDone?: string;
missing?: string;
countries?: string;
world?: string;
group?: string;
poll?: string;
votes?: string;
noMessages?: string;
departure?: string;
}
interface WidgetStop {
@@ -73,7 +83,9 @@ export interface WidgetSnapshot {
weather?: WidgetWeather;
bookings?: WidgetBooking[];
todos?: WidgetTodos;
packing?: WidgetPacking;
packing?: WidgetPacking & Partial<WidgetPackingDetail>;
atlas?: WidgetAtlas;
collab?: WidgetCollab;
labels: WidgetLabels;
}
@@ -98,7 +110,9 @@ interface SnapshotExtras {
weather?: WidgetWeather | null;
bookings?: WidgetBooking[];
todos?: WidgetTodos;
packing?: WidgetPacking | null;
packing?: (WidgetPacking & Partial<WidgetPackingDetail>) | null;
atlas?: WidgetAtlas | null;
collab?: WidgetCollab | null;
}
export interface TripDay {
@@ -121,17 +135,23 @@ function minutesOf(time: string | null | undefined): number | null {
}
/**
* The running trip if there is one, else the next to start. Trips without dates
* and trips already over are not candidates.
* The trip the dashboard's hero features (utils/heroTrip.ts): the running one,
* else the next to start, so the widgets show the trip the app opens on.
* Archived trips are not candidates, as on the dashboard.
*/
export function pickTrip(trips: Trip[], today: string): Trip | null {
const dated = trips.flatMap((t) =>
t.start_date && t.end_date && !t.is_archived ? [{ trip: t, start: t.start_date, end: t.end_date }] : []
);
const running = dated.find((d) => d.start <= today && d.end >= today);
if (running) return running.trip;
const upcoming = dated.filter((d) => d.start > today).sort((a, b) => a.start.localeCompare(b.start));
return upcoming[0]?.trip ?? null;
const active = trips.filter((t) => !t.is_archived);
return heroTrip(sortTripsByRelevance(active, today), today);
}
/**
* The trips a widget can be set to in Edit Widget: the ones running or coming
* up, in the dashboard's order (so the hero's trip comes first), at most `limit`,
* each of which gets a snapshot of its own.
*/
export function widgetTripChoices(trips: Trip[], today: string, limit = 6): Trip[] {
const ahead = trips.filter((t) => !t.is_archived && t.start_date && (t.end_date ?? t.start_date) >= today);
return sortTripsByRelevance(ahead, today).slice(0, limit);
}
/** The symbols of the booking kinds a day's timeline shows next to its stops. */
@@ -262,6 +282,8 @@ export function buildSnapshot(
if (extras.bookings) snapshot.bookings = extras.bookings;
if (extras.todos) snapshot.todos = extras.todos;
if (extras.packing) snapshot.packing = extras.packing;
if (extras.atlas) snapshot.atlas = extras.atlas;
if (extras.collab) snapshot.collab = extras.collab;
return snapshot;
}
@@ -282,12 +304,32 @@ export function tripDayState(day: TripDay, nowMinutes: number, allDone: string)
};
}
/** A trip a widget can be set to in Edit Widget, and its snapshot. */
export interface TripWidgetSnapshot {
id: number;
title: string;
start: string;
end: string;
snapshot: WidgetSnapshot;
}
export async function publishWidgets(
snapshot: WidgetSnapshot,
liveDay: ReturnType<typeof tripDayState> | null
liveDay: ReturnType<typeof tripDayState> | null,
trips: TripWidgetSnapshot[] = []
): Promise<void> {
if (!widgetsActive()) return;
await TrekShell.setWidgetSnapshot({ json: JSON.stringify(snapshot) }).catch(reportNativeFailure);
// An app binary without widgetTrips would take every one of these for the dashboard's snapshot.
if (hasNativeFeature('widgetTrips')) {
for (const trip of trips) {
await TrekShell.setWidgetSnapshot({ json: JSON.stringify(trip.snapshot), key: `trip.${trip.id}` }).catch(
reportNativeFailure
);
}
const index = trips.map(({ id, title, start, end }) => ({ id, title, start, end }));
await TrekShell.setWidgetSnapshot({ json: JSON.stringify(index), key: 'trips' }).catch(reportNativeFailure);
}
if (!hasNativeFeature('liveActivity')) return;
if (liveDay && liveDay.total > 0) await TrekShell.syncTripDay(liveDay).catch(reportNativeFailure);
else await TrekShell.endTripDay().catch(reportNativeFailure);
+19 -2
View File
@@ -20,6 +20,7 @@ import {
ThumbsUp,
} from 'lucide-react';
import { DAY_COLORS } from '../components/Journey/dayColors';
import JourneyDayJump from '../components/Journey/JourneyDayJump';
import JournalBody from '../components/Journey/JournalBody';
import JourneyLayeredMap from '../components/Journey/JourneyLayeredMap';
import MobileEntryView from '../components/Journey/MobileEntryView';
@@ -120,6 +121,7 @@ export default function JourneyPublicPage() {
showLangPicker,
setShowLangPicker,
mapRef,
feedRef,
activeEntryId,
setActiveEntryId,
viewingEntry,
@@ -227,7 +229,7 @@ export default function JourneyPublicPage() {
const dayIdx = sortedDates.indexOf(date);
const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length];
return (
<div key={date}>
<div key={date} data-day={date}>
{/* Day header */}
<div className="mb-4 flex items-center gap-3">
<div
@@ -624,9 +626,24 @@ export default function JourneyPublicPage() {
// ── Desktop two-column: scrollable timeline feed + sticky map ──────────
<div className="mx-auto flex max-w-[1440px]" style={{ alignItems: 'flex-start' }}>
{/* Left: feed */}
<div className="min-w-0 flex-1 px-8 py-6 xl:max-w-[50%]">
<div ref={feedRef} className="min-w-0 flex-1 px-8 py-6 xl:max-w-[50%]">
{view === 'timeline' && perms.share_timeline && renderTimeline()}
{view === 'gallery' && perms.share_gallery && renderGallery()}
{/* Every day and its entries one click away, as in the journal itself (#2998). */}
{view === 'timeline' && perms.share_timeline && sortedDates.length > 1 && (
<div className="pointer-events-none sticky bottom-0 z-20 h-0">
<div className="pointer-events-auto absolute inset-x-0 bottom-6 mx-auto w-fit">
<JourneyDayJump
feedRef={feedRef}
days={sortedDates.map((date, i) => ({
date,
color: DAY_COLORS[i % DAY_COLORS.length],
entries: (groupedEntries.get(date) ?? []).map(e => ({ id: e.id, title: e.title || e.location_name || t('journey.detail.newEntry') })),
}))}
/>
</div>
</div>
)}
</div>
{/* Right: sticky map — matches auth page aside proportions */}
+2 -13
View File
@@ -7,6 +7,7 @@
import type { Trip } from '../../types'
import { localIsoDate } from '../../utils/localDate'
import { sortTripsByRelevance } from '../../utils/heroTrip'
// The dashboard works with the canonical Trip shape returned by the list/get
// endpoints (it already carries the computed day_count/place_count/is_owner/
@@ -83,17 +84,5 @@ export function getTripStatus(trip: DashboardTrip): 'ongoing' | 'today' | 'tomor
}
export function sortTrips(trips: DashboardTrip[]): DashboardTrip[] {
const today = localIsoToday()
const rank = (t: DashboardTrip) => {
if (t.start_date && t.end_date && t.start_date <= today && t.end_date >= today) return 0
if (t.start_date && t.start_date >= today) return 1
return 2
}
return [...trips].sort((a, b) => {
const ra = rank(a), rb = rank(b)
if (ra !== rb) return ra - rb
const ad = a.start_date || '', bd = b.start_date || ''
if (ra <= 1) return ad.localeCompare(bd)
return bd.localeCompare(ad)
})
return sortTripsByRelevance(trips, localIsoToday())
}
+2 -3
View File
@@ -9,6 +9,7 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../../components/shared/Toast'
import { getApiErrorMessage } from '../../types'
import { localIsoToday } from './dashboardModel'
import { heroTrip } from '../../utils/heroTrip'
import { useTripSearch } from './useTripSearch'
import type { TripCreateRequest } from '@trek/shared'
import {
@@ -120,9 +121,7 @@ export function useDashboard() {
// A trip the hero features on its own merit: one that is running, else the next
// one coming up. Only that one is taken out of the grid below, so the same trip
// isn't shown twice.
const featured = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|| trips.find(t => t.start_date && t.start_date >= today)
|| null
const featured = heroTrip(trips, today)
// With neither, the hero still shows something rather than sitting empty — but
// that trip is only borrowed for the header and must stay in the grid, or a user
// whose trips are all finished (or undated) sees "No trips yet". #1706
@@ -32,6 +32,8 @@ export function useJourneyPublic() {
const [showLangPicker, setShowLangPicker] = useState(false)
const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null)
// The desktop feed, for the day picker to scroll (#2998).
const feedRef = useRef<HTMLDivElement>(null)
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const [viewingEntry, setViewingEntry] = useState<PublicEntry | null>(null)
@@ -170,7 +172,7 @@ export function useJourneyPublic() {
return {
token, data, loading, error, isMobile, locale,
view, setView, lightbox, setLightbox, showLangPicker, setShowLangPicker,
mapRef, activeEntryId, setActiveEntryId, viewingEntry, setViewingEntry, handleMarkerClick,
mapRef, feedRef, activeEntryId, setActiveEntryId, viewingEntry, setViewingEntry, handleMarkerClick,
perms, journey, stats,
timelineEntries, groupedEntries, sortedDates, displayDates, sidebarMapItems, allPhotos, stopNumberById, mapPhotos,
mapLayers, entryTitleOf,
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { heroTrip, sortTripsByRelevance } from './heroTrip';
const trip = (id: number, start_date: string | null, end_date: string | null) => ({ id, start_date, end_date });
describe('heroTrip', () => {
const today = '2026-11-06';
const trips = [
trip(1, '2026-01-01', '2026-01-05'),
trip(2, '2026-12-01', '2026-12-05'),
trip(3, '2026-11-04', '2026-11-12'),
trip(4, '2026-10-20', '2026-11-18'),
trip(5, null, null),
];
it('orders running trips first, then the ones coming up, then the rest, latest first', () => {
expect(sortTripsByRelevance(trips, today).map((t) => t.id)).toEqual([4, 3, 2, 1, 5]);
});
it('features the running trip that started first, else the next to come', () => {
expect(heroTrip(sortTripsByRelevance(trips, today), today)?.id).toBe(4);
expect(heroTrip(sortTripsByRelevance([trips[0], trips[1]], today), today)?.id).toBe(2);
expect(heroTrip(sortTripsByRelevance([trips[0], trips[4]], today), today)).toBeNull();
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* Which trip the dashboard features, in one place: the dashboard's hero and
* the iOS widgets (native/widgets.ts) both ask here, so the trip on the home
* screen is always the one the app opens on.
*/
interface DatedTrip {
start_date?: string | null;
end_date?: string | null;
}
/** 0 running, 1 coming up, 2 past or undated. */
function rank(trip: DatedTrip, today: string): number {
if (trip.start_date && trip.end_date && trip.start_date <= today && trip.end_date >= today) return 0;
if (trip.start_date && trip.start_date >= today) return 1;
return 2;
}
/** Running trips first, then the ones coming up, both soonest first; the rest latest first. */
export function sortTripsByRelevance<T extends DatedTrip>(trips: T[], today: string): T[] {
return [...trips].sort((a, b) => {
const ra = rank(a, today);
const rb = rank(b, today);
if (ra !== rb) return ra - rb;
const ad = a.start_date || '';
const bd = b.start_date || '';
if (ra <= 1) return ad.localeCompare(bd);
return bd.localeCompare(ad);
});
}
/**
* The trip the hero features on its own merit: the first running one in that
* order, else the first coming up; null when every trip is past or undated.
*/
export function heroTrip<T extends DatedTrip>(sorted: T[], today: string): T | null {
return sorted.find((t) => rank(t, today) === 0) || sorted.find((t) => rank(t, today) === 1) || null;
}
@@ -0,0 +1,83 @@
import { http, HttpResponse } from 'msw';
import { Route, Routes } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MAdmin from '../../../../src/mobile/screens/admin/MAdmin';
import { useAuthStore } from '../../../../src/store/authStore';
import { buildAdmin } from '../../../helpers/factories';
import { server } from '../../../helpers/msw/server';
import { fireEvent, render, screen, waitFor } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
// An install that fills the admin-tab attachment point (managed/index.tsx): one tab
// with a phone layout of its own, one without.
vi.mock('../../../../src/managed', async () => {
// vi.mock is hoisted above the imports, so the factory brings its own.
const { createElement } = await import('react');
const { CreditCard, LifeBuoy } = await import('lucide-react');
return {
managedRoutes: [],
managedNavItems: [],
visibleManagedNavItems: () => [],
managedAdminTabs: [
{
id: 'subscription',
label: 'Subscription',
Icon: CreditCard,
group: 'maintenance',
element: createElement('div', { 'data-testid': 'desktop-subscription' }),
mobileElement: createElement('div', { 'data-testid': 'phone-subscription' }),
},
{
id: 'support',
label: 'Help and support',
Icon: LifeBuoy,
group: 'maintenance',
element: createElement('div', { 'data-testid': 'desktop-support' }),
},
],
};
});
function renderAdmin() {
return render(
<Routes>
<Route path="/admin" element={<MAdmin />} />
</Routes>,
{ initialEntries: ['/admin'] }
);
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
server.use(
http.get('/api/admin/collab-features', () =>
HttpResponse.json({ chat: true, notes: true, polls: true, whatsnext: true })
),
http.get('/api/admin/places-photos', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/places-autocomplete', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/places-details', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/invites/trips', () => HttpResponse.json({ trips: [] }))
);
});
afterEach(() => {
server.resetHandlers();
});
describe('MAdmin with an install’s own tabs', () => {
it('FE-MOB-ADMIN-MANAGED-001: lists them in the section picker and shows their phone layout', async () => {
renderAdmin();
fireEvent.click(screen.getByRole('button', { name: 'Administration' }));
fireEvent.click(screen.getByRole('button', { name: 'Subscription' }));
await waitFor(() => expect(screen.getByTestId('phone-subscription')).toBeInTheDocument());
expect(screen.queryByTestId('desktop-subscription')).not.toBeInTheDocument();
});
it('FE-MOB-ADMIN-MANAGED-002: a tab without a phone layout shows its desktop element', async () => {
renderAdmin();
fireEvent.click(screen.getByRole('button', { name: 'Administration' }));
fireEvent.click(screen.getByRole('button', { name: 'Help and support' }));
await waitFor(() => expect(screen.getByTestId('desktop-support')).toBeInTheDocument());
});
});
+3 -2
View File
@@ -278,7 +278,8 @@ export function applyGlobalMiddleware(
// verifying a real .heic upload in a browser, not just deleting the
// string here.
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://unpkg.com'],
// No Google Fonts: every font ships with TREK (client/public/fonts), so no reader's IP goes to Google.
styleSrc: ["'self'", "'unsafe-inline'", 'https://unpkg.com'],
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
connectSrc: [
"'self'",
@@ -372,7 +373,7 @@ export function applyGlobalMiddleware(
// thumbnail answered 404 (#2341). Invisible in dev, where Vite serves the
// document without this header.
mediaSrc: ["'self'", 'blob:'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
fontSrc: ["'self'", 'data:'],
// 'self' so same-origin file previews can embed PDFs via <object>/<embed>
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
objectSrc: ["'self'"],
@@ -304,11 +304,13 @@ function mapFlightGroup(legs: KiReservation[], source: ParsedBookingItem['source
const arrEp = makeEndpoint(f.arrivalAirport, isLast ? 'to' : 'stop', onward.time, onward.date);
if (arrEp) endpoints.push(arrEp);
const airline = f.airline?.name ?? f.airline?.iataCode ?? '';
const { seat } = segmentDetail(legs[i]);
metaLegs.push({
from: iataOf(f.departureAirport),
to: iataOf(f.arrivalAirport),
...(airline ? { airline } : {}),
...(f.flightNumber ? { flight_number: f.flightNumber } : {}),
...(seat != null ? { seat } : {}),
dep_time: splitIso(f.departureTime).time,
arr_time: arr.time,
});
@@ -709,7 +711,12 @@ function segmentDetail(r: KiReservation): {
}
return undefined;
};
const seat = pick('seat', 'seatNumber');
// KItinerary writes a flight seat as airplaneSeat and a rail seat as
// reservedTicket.ticketedSeat.seatNumber, and drops any other seat key.
const ticket = r.reservedTicket as { ticketedSeat?: { seatNumber?: unknown } } | undefined;
const ticketSeat = ticket?.ticketedSeat?.seatNumber;
const seat =
pick('seat', 'seatNumber', 'airplaneSeat') ?? (ticketSeat != null && ticketSeat !== '' ? ticketSeat : undefined);
const cls = pick('class', 'bookingClass', 'fareClass', 'serviceClass', 'seatingType');
const platform = pick('platform', 'departurePlatform');
const price = pick('price', 'priceAmount', 'totalPrice', 'total');
@@ -634,3 +634,51 @@ describe('kitinerary mapper: a prompt-shaped AI lodging (#2477)', () => {
});
});
});
describe('kitinerary mapper: the seat as KItinerary writes it', () => {
it('reads a flight seat from airplaneSeat', () => {
const node = {
...flight('CE9P8V', SFO, JFK, '2026-06-11T10:00:00', '2026-06-11T18:30:00', '450'),
airplaneSeat: '4D',
};
const { items } = mapReservations(legs(node), 'test.json');
expect(items[0].metadata).toMatchObject({ seat: '4D' });
});
it('reads a seat from reservedTicket.ticketedSeat.seatNumber', () => {
const node = {
...flight('CE9P8V', SFO, JFK, '2026-06-11T10:00:00', '2026-06-11T18:30:00', '450'),
reservedTicket: { ticketedSeat: { seatNumber: '23A' } },
};
const { items } = mapReservations(legs(node), 'test.json');
expect(items[0].metadata).toMatchObject({ seat: '23A' });
});
});
describe('kitinerary mapper: the seat on each leg of a connecting flight', () => {
it('keeps every leg its own seat', () => {
const first = {
...flight('ABC123', FRA, BER, '2026-06-11T10:00:00', '2026-06-11T12:00:00', 'LH 100'),
airplaneSeat: '12C',
};
const second = {
...flight('ABC123', BER, HND, '2026-06-11T14:30:00', '2026-06-11T23:30:00', 'LH 200'),
airplaneSeat: '40A',
};
const { items } = mapReservations(legs(first, second), 'test.json');
const legsMeta = (items[0].metadata as { legs: { seat?: string }[] }).legs;
expect(legsMeta.map((leg) => leg.seat)).toEqual(['12C', '40A']);
});
it('leaves the seat off a leg that has none', () => {
const first = flight('ABC123', FRA, BER, '2026-06-11T10:00:00', '2026-06-11T12:00:00', 'LH 100');
const second = {
...flight('ABC123', BER, HND, '2026-06-11T14:30:00', '2026-06-11T23:30:00', 'LH 200'),
airplaneSeat: '40A',
};
const { items } = mapReservations(legs(first, second), 'test.json');
const legsMeta = (items[0].metadata as { legs: Record<string, unknown>[] }).legs;
expect(legsMeta[0]).not.toHaveProperty('seat');
expect(legsMeta[1]).toMatchObject({ seat: '40A' });
});
});
+17 -1
View File
@@ -52,7 +52,7 @@ const native: TranslationStrings = {
'native.plans.subscribe': 'اشترك',
'native.plans.loadError': 'تعذّر تحميل الخطط. تحقق من اتصالك وحاول مرة أخرى.',
'native.plans.retry': 'حاول مرة أخرى',
'native.plans.soon': 'الاشتراكات داخل التطبيق قادمة قريبًا. أكمل الآن على الموقع.',
'native.plans.soon': 'الاشتراك داخل التطبيق غير متاح بعد.',
'native.claim.title': 'اقتربنا من الانتهاء',
'native.claim.sub': 'اختر عنوانك. سنُعدّه على الفور.',
'native.claim.addressLabel': 'عنوانك',
@@ -66,6 +66,14 @@ const native: TranslationStrings = {
'native.claim.cancelled': 'تم إلغاء الشراء.',
'native.claim.failed': 'لم تتم عملية الشراء. حاول مرة أخرى.',
'native.claim.redeemFailed': 'تعذّر علينا إعداد TREK الخاص بك. حاول مرة أخرى.',
'native.plans.restore': 'استعادة المشتريات',
'native.plans.restoring': 'جارٍ التحقق من مشترياتك…',
'native.plans.restoreNone': 'لم يُعثر على اشتراك نشط لمعرّف Apple هذا.',
'native.plans.restoreFailed': 'تعذّرت استعادة TREK الخاص بك. حاول مرة أخرى.',
'native.plans.legal':
'يُخصم المبلغ من معرّف Apple عند التأكيد. يتجدد الاشتراك تلقائيًا ما لم تلغه قبل 24 ساعة على الأقل من نهاية الفترة. يمكنك إدارته أو إلغاؤه من إعدادات حسابك في App Store.',
'native.plans.terms': 'شروط الاستخدام',
'native.claim.pending': 'عملية الشراء بانتظار الموافقة. عند اكتمالها، اضغط على استعادة المشتريات.',
'native.provisioning.title': 'جارٍ إعداد TREK الخاص بك',
'native.provisioning.sub': 'نُحضّر {host}. يستغرق هذا دقيقة واحدة فقط.',
'native.provisioning.failedTitle': 'شارف على الجهوزية',
@@ -93,6 +101,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'لم يتبق شيء',
'native.widget.now': 'الآن',
'native.widget.travellers': 'مسافرون',
'native.widget.missing': 'متبقٍ للتجهيز',
'native.widget.countries': 'دول',
'native.widget.world': 'من العالم',
'native.widget.group': 'مجموعة السفر',
'native.widget.poll': 'تصويت',
'native.widget.votes': 'أصوات',
'native.widget.noMessages': 'لا توجد رسائل بعد',
'native.widget.departure': 'المغادرة',
};
export default native;
+17 -1
View File
@@ -53,7 +53,7 @@ const native: TranslationStrings = {
'native.plans.subscribe': 'Abunə ol',
'native.plans.loadError': 'Planları yükləmək mümkün olmadı. Bağlantınızı yoxlayın və yenidən cəhd edin.',
'native.plans.retry': 'Yenidən cəhd edin',
'native.plans.soon': 'Tətbiqdaxili abunələr tezliklə gəlir. Hələlik veb saytda tamamlayın.',
'native.plans.soon': 'Tətbiqdaxili abunə hələ mövcud deyil.',
'native.claim.title': 'Az qaldı',
'native.claim.sub': 'Ünvanınızı seçin. Dərhal hazırlayırıq.',
'native.claim.addressLabel': 'Ünvanınız',
@@ -67,6 +67,14 @@ const native: TranslationStrings = {
'native.claim.cancelled': 'Alış ləğv edildi.',
'native.claim.failed': 'Alış baş tutmadı. Zəhmət olmasa yenidən cəhd edin.',
'native.claim.redeemFailed': 'TREK-inizi hazırlaya bilmədik. Zəhmət olmasa yenidən cəhd edin.',
'native.plans.restore': 'Alışları bərpa et',
'native.plans.restoring': 'Alışlarınız yoxlanılır…',
'native.plans.restoreNone': 'Bu Apple ID üçün aktiv abunə tapılmadı.',
'native.plans.restoreFailed': 'TREK-inizi bərpa edə bilmədik. Zəhmət olmasa yenidən cəhd edin.',
'native.plans.legal':
'Ödəniş təsdiq zamanı Apple ID-nizdən tutulur. Dövrün bitməsinə ən azı 24 saat qalmış ləğv etməsəniz, abunə avtomatik yenilənir. Onu App Store hesab ayarlarınızda idarə edə və ya ləğv edə bilərsiniz.',
'native.plans.terms': 'İstifadə şərtləri',
'native.claim.pending': 'Alışınız təsdiq gözləyir. Tamamlandıqdan sonra Alışları bərpa et düyməsinə toxunun.',
'native.provisioning.title': 'TREK-iniz hazırlanır',
'native.provisioning.sub': '{host} hazırlanır. Bu yalnız bir dəqiqə çəkir.',
'native.provisioning.failedTitle': 'Demək olar ki hazırdır',
@@ -95,6 +103,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Görüləsi iş qalmayıb',
'native.widget.now': 'İndi',
'native.widget.travellers': 'səyyah',
'native.widget.missing': 'hələ yığılacaq',
'native.widget.countries': 'ölkə',
'native.widget.world': 'dünyanın',
'native.widget.group': 'Səyahət qrupu',
'native.widget.poll': 'Səsvermə',
'native.widget.votes': 'səs',
'native.widget.noMessages': 'Hələ mesaj yoxdur',
'native.widget.departure': 'Yola düşmə',
};
export default native;
+17 -1
View File
@@ -53,7 +53,7 @@ const native: TranslationStrings = {
'native.plans.subscribe': 'Assinar',
'native.plans.loadError': 'Não foi possível carregar os planos. Verifique sua conexão e tente novamente.',
'native.plans.retry': 'Tentar de novo',
'native.plans.soon': 'As assinaturas no app estão chegando. Por enquanto, conclua no site.',
'native.plans.soon': 'A assinatura no app ainda não está disponível.',
'native.claim.title': 'Quase lá',
'native.claim.sub': 'Escolha seu endereço. Preparamos tudo na hora.',
'native.claim.addressLabel': 'Seu endereço',
@@ -67,6 +67,14 @@ const native: TranslationStrings = {
'native.claim.cancelled': 'Compra cancelada.',
'native.claim.failed': 'A compra não foi concluída. Tente novamente.',
'native.claim.redeemFailed': 'Não conseguimos preparar seu TREK. Tente novamente.',
'native.plans.restore': 'Restaurar compras',
'native.plans.restoring': 'Verificando suas compras…',
'native.plans.restoreNone': 'Nenhuma assinatura ativa encontrada neste ID Apple.',
'native.plans.restoreFailed': 'Não conseguimos restaurar seu TREK. Tente novamente.',
'native.plans.legal':
'O pagamento é cobrado no seu ID Apple ao confirmar. A assinatura é renovada automaticamente, a menos que seja cancelada pelo menos 24 horas antes do fim do período. Gerencie ou cancele nos ajustes da sua conta da App Store.',
'native.plans.terms': 'Termos de uso',
'native.claim.pending': 'Sua compra está aguardando aprovação. Quando for concluída, toque em Restaurar compras.',
'native.provisioning.title': 'Preparando seu TREK',
'native.provisioning.sub': 'Preparando {host}. Isso leva só um minuto.',
'native.provisioning.failedTitle': 'Quase pronto',
@@ -95,6 +103,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Nada pendente',
'native.widget.now': 'Agora',
'native.widget.travellers': 'viajantes',
'native.widget.missing': 'ainda falta arrumar',
'native.widget.countries': 'países',
'native.widget.world': 'do mundo',
'native.widget.group': 'Grupo de viagem',
'native.widget.poll': 'Votação',
'native.widget.votes': 'votos',
'native.widget.noMessages': 'Nenhuma mensagem ainda',
'native.widget.departure': 'Partida',
};
export default native;
+17 -1
View File
@@ -53,7 +53,7 @@ const native: TranslationStrings = {
'native.plans.subscribe': 'Subscriu-te',
'native.plans.loadError': 'No s’han pogut carregar els plans. Comprova la connexió i torna-ho a provar.',
'native.plans.retry': 'Torna-ho a provar',
'native.plans.soon': 'Les subscripcions a l’app arribaran aviat. De moment, acaba al web.',
'native.plans.soon': 'La subscripció a l’app encara no està disponible.',
'native.claim.title': 'Gairebé a punt',
'native.claim.sub': 'Tria la teva adreça. Ho preparem de seguida.',
'native.claim.addressLabel': 'La teva adreça',
@@ -67,6 +67,14 @@ const native: TranslationStrings = {
'native.claim.cancelled': 'Compra cancel·lada.',
'native.claim.failed': 'La compra no s’ha completat. Torna-ho a provar.',
'native.claim.redeemFailed': 'No hem pogut preparar el teu TREK. Torna-ho a provar.',
'native.plans.restore': 'Restaura les compres',
'native.plans.restoring': 'Comprovant les teves compres…',
'native.plans.restoreNone': 'No s’ha trobat cap subscripció activa en aquest ID d’Apple.',
'native.plans.restoreFailed': 'No hem pogut restaurar el teu TREK. Torna-ho a provar.',
'native.plans.legal':
'El pagament es carrega al teu ID d’Apple en confirmar. La subscripció es renova automàticament si no la cancel·les com a mínim 24 hores abans que acabi el període. Gestiona-la o cancel·la-la a la configuració del teu compte de l’App Store.',
'native.plans.terms': 'Condicions d’ús',
'native.claim.pending': 'La teva compra està pendent d’aprovació. Quan es completi, toca Restaura les compres.',
'native.provisioning.title': 'Preparant el teu TREK',
'native.provisioning.sub': 'Preparant {host}. Només triga un minut.',
'native.provisioning.failedTitle': 'Gairebé a punt',
@@ -95,6 +103,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'No queda res per fer',
'native.widget.now': 'Ara',
'native.widget.travellers': 'viatgers',
'native.widget.missing': 'per fer la maleta',
'native.widget.countries': 'països',
'native.widget.world': 'del món',
'native.widget.group': 'Grup de viatge',
'native.widget.poll': 'Votació',
'native.widget.votes': 'vots',
'native.widget.noMessages': 'Encara no hi ha missatges',
'native.widget.departure': 'Sortida',
};
export default native;
+17 -1
View File
@@ -53,7 +53,7 @@ const native: TranslationStrings = {
'native.plans.subscribe': 'Předplatit',
'native.plans.loadError': 'Tarify se nepodařilo načíst. Zkontrolujte připojení a zkuste to znovu.',
'native.plans.retry': 'Zkusit znovu',
'native.plans.soon': 'Předplatné v aplikaci brzy dorazí. Zatím to dokončete na webu.',
'native.plans.soon': 'Předplatné v aplikaci zatím není k dispozici.',
'native.claim.title': 'Skoro hotovo',
'native.claim.sub': 'Vyberte si adresu. Hned ji připravíme.',
'native.claim.addressLabel': 'Vaše adresa',
@@ -67,6 +67,14 @@ const native: TranslationStrings = {
'native.claim.cancelled': 'Nákup zrušen.',
'native.claim.failed': 'Nákup se nezdařil. Zkuste to znovu.',
'native.claim.redeemFailed': 'Nepodařilo se připravit váš TREK. Zkuste to znovu.',
'native.plans.restore': 'Obnovit nákupy',
'native.plans.restoring': 'Kontrolujeme vaše nákupy…',
'native.plans.restoreNone': 'Pro toto Apple ID nebylo nalezeno žádné aktivní předplatné.',
'native.plans.restoreFailed': 'Váš TREK se nepodařilo obnovit. Zkuste to znovu.',
'native.plans.legal':
'Platba se strhne z vašeho Apple ID při potvrzení. Předplatné se automaticky obnovuje, pokud jej nezrušíte alespoň 24 hodin před koncem období. Spravovat nebo zrušit jej můžete v nastavení účtu App Store.',
'native.plans.terms': 'Podmínky použití',
'native.claim.pending': 'Váš nákup čeká na schválení. Jakmile projde, klepněte na Obnovit nákupy.',
'native.provisioning.title': 'Připravujeme váš TREK',
'native.provisioning.sub': 'Připravujeme {host}. Zabere to jen minutu.',
'native.provisioning.failedTitle': 'Téměř hotovo',
@@ -95,6 +103,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Vše hotovo',
'native.widget.now': 'Teď',
'native.widget.travellers': 'cestovatelů',
'native.widget.missing': 'zbývá zabalit',
'native.widget.countries': 'zemí',
'native.widget.world': 'světa',
'native.widget.group': 'Cestovní skupina',
'native.widget.poll': 'Hlasování',
'native.widget.votes': 'hlasů',
'native.widget.noMessages': 'Zatím žádné zprávy',
'native.widget.departure': 'Odjezd',
};
export default native;
+18 -1
View File
@@ -54,7 +54,7 @@ const native: TranslationStrings = {
'native.plans.subscribe': 'Abonnieren',
'native.plans.loadError': 'Die Tarife konnten nicht geladen werden. Prüfe deine Verbindung und versuch es erneut.',
'native.plans.retry': 'Erneut versuchen',
'native.plans.soon': 'In-App-Abos kommen bald. Vorerst auf der Website abschließen.',
'native.plans.soon': 'Abschließen in der App ist noch nicht verfügbar.',
'native.claim.title': 'Fast geschafft',
'native.claim.sub': 'Wähle deine Adresse. Wir richten sie sofort ein.',
'native.claim.addressLabel': 'Deine Adresse',
@@ -68,6 +68,15 @@ const native: TranslationStrings = {
'native.claim.cancelled': 'Kauf abgebrochen.',
'native.claim.failed': 'Der Kauf hat nicht geklappt. Bitte versuch es erneut.',
'native.claim.redeemFailed': 'Wir konnten deine TREK nicht einrichten. Bitte versuch es erneut.',
'native.plans.restore': 'Käufe wiederherstellen',
'native.plans.restoring': 'Deine Käufe werden geprüft…',
'native.plans.restoreNone': 'Für diese Apple-ID wurde kein aktives Abo gefunden.',
'native.plans.restoreFailed': 'Wir konnten deine TREK nicht wiederherstellen. Bitte versuch es erneut.',
'native.plans.legal':
'Die Zahlung wird bei Bestätigung über deine Apple-ID abgerechnet. Das Abo verlängert sich automatisch, wenn du es nicht mindestens 24 Stunden vor Ablauf des Zeitraums kündigst. Verwalten und kündigen kannst du es in den Einstellungen deines App-Store-Accounts.',
'native.plans.terms': 'Nutzungsbedingungen',
'native.claim.pending':
'Dein Kauf wartet noch auf eine Freigabe. Sobald er durch ist, tippe auf Käufe wiederherstellen.',
'native.provisioning.title': 'Deine TREK wird eingerichtet',
'native.provisioning.sub': '{host} wird vorbereitet. Das dauert nur eine Minute.',
'native.provisioning.failedTitle': 'Gleich fertig',
@@ -96,6 +105,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Alles erledigt',
'native.widget.now': 'Jetzt',
'native.widget.travellers': 'Reisende',
'native.widget.missing': 'noch zu packen',
'native.widget.countries': 'Länder',
'native.widget.world': 'der Welt',
'native.widget.group': 'Reisegruppe',
'native.widget.poll': 'Abstimmung',
'native.widget.votes': 'Stimmen',
'native.widget.noMessages': 'Noch keine Nachrichten',
'native.widget.departure': 'Abreise',
};
export default native;
+17 -1
View File
@@ -53,7 +53,7 @@ const native: TranslationStrings = {
'native.plans.subscribe': 'Subscribe',
'native.plans.loadError': 'Could not load the plans. Check your connection and try again.',
'native.plans.retry': 'Try again',
'native.plans.soon': 'In-app subscriptions are coming. For now, finish on the website.',
'native.plans.soon': 'Subscribing in the app is not available yet.',
'native.claim.title': 'Almost there',
'native.claim.sub': 'Choose your address. We set it up right away.',
'native.claim.addressLabel': 'Your address',
@@ -67,6 +67,14 @@ const native: TranslationStrings = {
'native.claim.cancelled': 'Purchase cancelled.',
'native.claim.failed': 'The purchase did not go through. Please try again.',
'native.claim.redeemFailed': 'We could not set up your TREK. Please try again.',
'native.plans.restore': 'Restore purchases',
'native.plans.restoring': 'Checking your purchases…',
'native.plans.restoreNone': 'No active subscription found on this Apple ID.',
'native.plans.restoreFailed': 'We could not restore your TREK. Please try again.',
'native.plans.legal':
'Payment is charged to your Apple ID when you confirm. The subscription renews automatically unless you cancel it at least 24 hours before the period ends. Manage or cancel it in your App Store account settings.',
'native.plans.terms': 'Terms of use',
'native.claim.pending': 'Your purchase is waiting for approval. Once it goes through, tap Restore purchases.',
'native.provisioning.title': 'Setting up your TREK',
'native.provisioning.sub': 'Getting {host} ready. This only takes a minute.',
'native.provisioning.failedTitle': 'Almost ready',
@@ -95,6 +103,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Nothing left to do',
'native.widget.now': 'Now',
'native.widget.travellers': 'travellers',
'native.widget.missing': 'still to pack',
'native.widget.countries': 'countries',
'native.widget.world': 'of the world',
'native.widget.group': 'Travel group',
'native.widget.poll': 'Poll',
'native.widget.votes': 'votes',
'native.widget.noMessages': 'No messages yet',
'native.widget.departure': 'Departure',
};
export default native;
+17 -1
View File
@@ -54,7 +54,7 @@ const native: TranslationStrings = {
'native.plans.subscribe': 'Suscribirse',
'native.plans.loadError': 'No se han podido cargar los planes. Revisa tu conexión e inténtalo de nuevo.',
'native.plans.retry': 'Reintentar',
'native.plans.soon': 'Las suscripciones dentro de la app llegarán pronto. Por ahora, termina en la web.',
'native.plans.soon': 'La suscripción dentro de la app aún no está disponible.',
'native.claim.title': 'Casi listo',
'native.claim.sub': 'Elige tu dirección. La preparamos al instante.',
'native.claim.addressLabel': 'Tu dirección',
@@ -68,6 +68,14 @@ const native: TranslationStrings = {
'native.claim.cancelled': 'Compra cancelada.',
'native.claim.failed': 'La compra no se completó. Inténtalo de nuevo.',
'native.claim.redeemFailed': 'No pudimos preparar tu TREK. Inténtalo de nuevo.',
'native.plans.restore': 'Restaurar compras',
'native.plans.restoring': 'Comprobando tus compras…',
'native.plans.restoreNone': 'No se encontró ninguna suscripción activa en este ID de Apple.',
'native.plans.restoreFailed': 'No pudimos restaurar tu TREK. Inténtalo de nuevo.',
'native.plans.legal':
'El pago se cobra a tu ID de Apple al confirmar. La suscripción se renueva automáticamente salvo que la canceles al menos 24 horas antes de que termine el periodo. Gestiónala o cancélala en los ajustes de tu cuenta del App Store.',
'native.plans.terms': 'Términos de uso',
'native.claim.pending': 'Tu compra está pendiente de aprobación. Cuando se complete, toca Restaurar compras.',
'native.provisioning.title': 'Preparando tu TREK',
'native.provisioning.sub': 'Preparando {host}. Solo tarda un minuto.',
'native.provisioning.failedTitle': 'Casi listo',
@@ -96,6 +104,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'No queda nada pendiente',
'native.widget.now': 'Ahora',
'native.widget.travellers': 'viajeros',
'native.widget.missing': 'por empacar',
'native.widget.countries': 'países',
'native.widget.world': 'del mundo',
'native.widget.group': 'Grupo de viaje',
'native.widget.poll': 'Votación',
'native.widget.votes': 'votos',
'native.widget.noMessages': 'Aún no hay mensajes',
'native.widget.departure': 'Salida',
};
export default native;
+17 -1
View File
@@ -53,7 +53,7 @@ const native: TranslationStrings = {
'native.plans.subscribe': 'Telli',
'native.plans.loadError': 'Pakette ei õnnestunud laadida. Kontrolli ühendust ja proovi uuesti.',
'native.plans.retry': 'Proovi uuesti',
'native.plans.soon': 'Rakendusesisesed tellimused on tulekul. Praegu lõpeta veebis.',
'native.plans.soon': 'Rakendusesisene tellimine pole veel saadaval.',
'native.claim.title': 'Peaaegu valmis',
'native.claim.sub': 'Vali oma aadress. Seadistame selle kohe.',
'native.claim.addressLabel': 'Sinu aadress',
@@ -67,6 +67,14 @@ const native: TranslationStrings = {
'native.claim.cancelled': 'Ost tühistatud.',
'native.claim.failed': 'Ost ei õnnestunud. Proovi uuesti.',
'native.claim.redeemFailed': 'Me ei saanud sinu TREK-i seadistada. Proovi uuesti.',
'native.plans.restore': 'Taasta ostud',
'native.plans.restoring': 'Kontrollime sinu oste…',
'native.plans.restoreNone': 'Selle Apple ID jaoks ei leitud aktiivset tellimust.',
'native.plans.restoreFailed': 'Me ei saanud sinu TREK-i taastada. Proovi uuesti.',
'native.plans.legal':
'Makse võetakse kinnitamisel sinu Apple ID-lt. Tellimus pikeneb automaatselt, kui sa ei tühista seda vähemalt 24 tundi enne perioodi lõppu. Halda või tühista seda oma App Store’i konto seadetes.',
'native.plans.terms': 'Kasutustingimused',
'native.claim.pending': 'Sinu ost ootab kinnitust. Kui see on läbi, puuduta Taasta ostud.',
'native.provisioning.title': 'Seadistame sinu TREK-i',
'native.provisioning.sub': 'Valmistame ette aadressi {host}. See võtab vaid minuti.',
'native.provisioning.failedTitle': 'Peaaegu valmis',
@@ -95,6 +103,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Kõik on tehtud',
'native.widget.now': 'Praegu',
'native.widget.travellers': 'reisijat',
'native.widget.missing': 'veel pakkida',
'native.widget.countries': 'riiki',
'native.widget.world': 'maailmast',
'native.widget.group': 'Reisiseltskond',
'native.widget.poll': 'Hääletus',
'native.widget.votes': 'häält',
'native.widget.noMessages': 'Sõnumeid veel pole',
'native.widget.departure': 'Väljasõit',
};
export default native;
+17 -1
View File
@@ -54,7 +54,7 @@ const native: TranslationStrings = {
'native.plans.subscribe': 'S’abonner',
'native.plans.loadError': 'Impossible de charger les forfaits. Vérifiez votre connexion et réessayez.',
'native.plans.retry': 'Réessayer',
'native.plans.soon': 'Les abonnements dans l’app arrivent bientôt. Pour l’instant, terminez sur le site.',
'native.plans.soon': 'L’abonnement dans l’app n’est pas encore disponible.',
'native.claim.title': 'Presque terminé',
'native.claim.sub': 'Choisissez votre adresse. Nous la préparons aussitôt.',
'native.claim.addressLabel': 'Votre adresse',
@@ -68,6 +68,14 @@ const native: TranslationStrings = {
'native.claim.cancelled': 'Achat annulé.',
'native.claim.failed': 'L’achat n’a pas abouti. Réessayez.',
'native.claim.redeemFailed': 'Nous n’avons pas pu préparer votre TREK. Réessayez.',
'native.plans.restore': 'Restaurer les achats',
'native.plans.restoring': 'Vérification de vos achats…',
'native.plans.restoreNone': 'Aucun abonnement actif trouvé pour cet identifiant Apple.',
'native.plans.restoreFailed': 'Nous n’avons pas pu restaurer votre TREK. Réessayez.',
'native.plans.legal':
'Le paiement est débité de votre identifiant Apple lors de la confirmation. L’abonnement se renouvelle automatiquement, sauf résiliation au moins 24 heures avant la fin de la période. Gérez-le ou résiliez-le dans les réglages de votre compte App Store.',
'native.plans.terms': 'Conditions d’utilisation',
'native.claim.pending': 'Votre achat attend une approbation. Une fois validé, touchez Restaurer les achats.',
'native.provisioning.title': 'Préparation de votre TREK',
'native.provisioning.sub': 'Préparation de {host}. Cela ne prend qu’une minute.',
'native.provisioning.failedTitle': 'Presque prêt',
@@ -96,6 +104,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Plus rien à faire',
'native.widget.now': 'Maintenant',
'native.widget.travellers': 'voyageurs',
'native.widget.missing': 'encore à emballer',
'native.widget.countries': 'pays',
'native.widget.world': 'du monde',
'native.widget.group': 'Groupe de voyage',
'native.widget.poll': 'Sondage',
'native.widget.votes': 'voix',
'native.widget.noMessages': 'Pas encore de messages',
'native.widget.departure': 'Départ',
};
export default native;
+17 -1
View File
@@ -56,7 +56,7 @@ const native: TranslationStrings = {
'native.plans.subscribe': 'Εγγραφή',
'native.plans.loadError': 'Δεν ήταν δυνατή η φόρτωση των πλάνων. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.',
'native.plans.retry': 'Δοκιμάστε ξανά',
'native.plans.soon': 'Οι συνδρομές εντός εφαρμογής έρχονται. Προς το παρόν, ολοκληρώστε στον ιστότοπο.',
'native.plans.soon': 'Η συνδρομή μέσα από την εφαρμογή δεν είναι ακόμη διαθέσιμη.',
'native.claim.title': 'Σχεδόν έτοιμο',
'native.claim.sub': 'Επιλέξτε τη διεύθυνσή σας. Το ετοιμάζουμε αμέσως.',
'native.claim.addressLabel': 'Η διεύθυνσή σας',
@@ -70,6 +70,14 @@ const native: TranslationStrings = {
'native.claim.cancelled': 'Η αγορά ακυρώθηκε.',
'native.claim.failed': 'Η αγορά δεν ολοκληρώθηκε. Δοκιμάστε ξανά.',
'native.claim.redeemFailed': 'Δεν μπορέσαμε να ετοιμάσουμε το TREK σας. Δοκιμάστε ξανά.',
'native.plans.restore': 'Επαναφορά αγορών',
'native.plans.restoring': 'Έλεγχος των αγορών σας…',
'native.plans.restoreNone': 'Δεν βρέθηκε ενεργή συνδρομή για αυτό το Apple ID.',
'native.plans.restoreFailed': 'Δεν μπορέσαμε να επαναφέρουμε το TREK σας. Δοκιμάστε ξανά.',
'native.plans.legal':
'Η πληρωμή χρεώνεται στο Apple ID σας κατά την επιβεβαίωση. Η συνδρομή ανανεώνεται αυτόματα, εκτός αν την ακυρώσετε τουλάχιστον 24 ώρες πριν από το τέλος της περιόδου. Διαχειριστείτε ή ακυρώστε τη στις ρυθμίσεις του λογαριασμού σας στο App Store.',
'native.plans.terms': 'Όροι χρήσης',
'native.claim.pending': 'Η αγορά σας περιμένει έγκριση. Μόλις ολοκληρωθεί, πατήστε Επαναφορά αγορών.',
'native.provisioning.title': 'Ετοιμάζουμε το TREK σας',
'native.provisioning.sub': 'Ετοιμάζουμε το {host}. Χρειάζεται μόνο ένα λεπτό.',
'native.provisioning.failedTitle': 'Σχεδόν έτοιμο',
@@ -98,6 +106,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Δεν απομένει τίποτα',
'native.widget.now': 'Τώρα',
'native.widget.travellers': 'ταξιδιώτες',
'native.widget.missing': 'για πακετάρισμα',
'native.widget.countries': 'χώρες',
'native.widget.world': 'του κόσμου',
'native.widget.group': 'Ομάδα ταξιδιού',
'native.widget.poll': 'Ψηφοφορία',
'native.widget.votes': 'ψήφοι',
'native.widget.noMessages': 'Δεν υπάρχουν ακόμα μηνύματα',
'native.widget.departure': 'Αναχώρηση',
};
export default native;
+17 -1
View File
@@ -52,7 +52,7 @@ const native: TranslationStrings = {
'native.plans.subscribe': 'הרשמה',
'native.plans.loadError': 'לא ניתן לטעון את התוכניות. בדקו את החיבור ונסו שוב.',
'native.plans.retry': 'נסו שוב',
'native.plans.soon': 'הרשמות בתוך האפליקציה בקרוב. בינתיים, השלימו באתר.',
'native.plans.soon': 'הרשמה בתוך האפליקציה עדיין לא זמינה.',
'native.claim.title': 'כמעט סיימנו',
'native.claim.sub': 'בחרו את הכתובת שלכם. אנחנו מכינים אותה מיד.',
'native.claim.addressLabel': 'הכתובת שלכם',
@@ -66,6 +66,14 @@ const native: TranslationStrings = {
'native.claim.cancelled': 'הרכישה בוטלה.',
'native.claim.failed': 'הרכישה לא הושלמה. אנא נסו שוב.',
'native.claim.redeemFailed': 'לא הצלחנו להכין את ה-TREK שלכם. אנא נסו שוב.',
'native.plans.restore': 'שחזור רכישות',
'native.plans.restoring': 'בודקים את הרכישות שלך…',
'native.plans.restoreNone': 'לא נמצא מינוי פעיל עבור Apple ID זה.',
'native.plans.restoreFailed': 'לא הצלחנו לשחזר את ה-TREK שלך. אנא נסו שוב.',
'native.plans.legal':
'התשלום מחויב ב-Apple ID שלך בעת האישור. המינוי מתחדש אוטומטית אלא אם מבטלים אותו לפחות 24 שעות לפני סוף התקופה. אפשר לנהל או לבטל אותו בהגדרות חשבון ה-App Store.',
'native.plans.terms': 'תנאי שימוש',
'native.claim.pending': 'הרכישה שלך ממתינה לאישור. כשהיא תושלם, הקישו על שחזור רכישות.',
'native.provisioning.title': 'מכינים את ה-TREK שלכם',
'native.provisioning.sub': 'מכינים את {host}. זה לוקח רק דקה.',
'native.provisioning.failedTitle': 'כמעט מוכן',
@@ -93,6 +101,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'לא נשאר מה לעשות',
'native.widget.now': 'עכשיו',
'native.widget.travellers': 'מטיילים',
'native.widget.missing': 'עוד לארוז',
'native.widget.countries': 'מדינות',
'native.widget.world': 'מהעולם',
'native.widget.group': 'קבוצת הטיול',
'native.widget.poll': 'סקר',
'native.widget.votes': 'קולות',
'native.widget.noMessages': 'אין עדיין הודעות',
'native.widget.departure': 'יציאה',
};
export default native;
+17 -1
View File
@@ -54,7 +54,7 @@ const native: TranslationStrings = {
'native.plans.subscribe': 'Előfizetés',
'native.plans.loadError': 'A csomagokat nem sikerült betölteni. Ellenőrizd a kapcsolatot, és próbáld újra.',
'native.plans.retry': 'Újra',
'native.plans.soon': 'Az appon belüli előfizetés hamarosan érkezik. Addig fejezd be a weboldalon.',
'native.plans.soon': 'Az appon belüli előfizetés még nem érhető el.',
'native.claim.title': 'Mindjárt kész',
'native.claim.sub': 'Válaszd ki a címed. Azonnal beállítjuk.',
'native.claim.addressLabel': 'A címed',
@@ -68,6 +68,14 @@ const native: TranslationStrings = {
'native.claim.cancelled': 'Vásárlás megszakítva.',
'native.claim.failed': 'A vásárlás nem sikerült. Próbáld újra.',
'native.claim.redeemFailed': 'Nem sikerült beállítani a TREK-edet. Próbáld újra.',
'native.plans.restore': 'Vásárlások visszaállítása',
'native.plans.restoring': 'Vásárlásaid ellenőrzése…',
'native.plans.restoreNone': 'Ehhez az Apple ID-hoz nem található aktív előfizetés.',
'native.plans.restoreFailed': 'Nem sikerült visszaállítani a TREK-edet. Próbáld újra.',
'native.plans.legal':
'A fizetést megerősítéskor az Apple ID-d terheli. Az előfizetés automatikusan megújul, hacsak nem mondod le legalább 24 órával az időszak vége előtt. Kezelni vagy lemondani az App Store-fiókod beállításaiban tudod.',
'native.plans.terms': 'Felhasználási feltételek',
'native.claim.pending': 'A vásárlásod jóváhagyásra vár. Ha teljesült, koppints a Vásárlások visszaállítása gombra.',
'native.provisioning.title': 'A TREK-edet állítjuk be',
'native.provisioning.sub': 'A(z) {host} előkészítése. Csak egy percet vesz igénybe.',
'native.provisioning.failedTitle': 'Majdnem kész',
@@ -97,6 +105,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Minden kész',
'native.widget.now': 'Most',
'native.widget.travellers': 'utazó',
'native.widget.missing': 'még bepakolni',
'native.widget.countries': 'ország',
'native.widget.world': 'a világból',
'native.widget.group': 'Útitársak',
'native.widget.poll': 'Szavazás',
'native.widget.votes': 'szavazat',
'native.widget.noMessages': 'Még nincs üzenet',
'native.widget.departure': 'Indulás',
};
export default native;
+17 -1
View File
@@ -53,7 +53,7 @@ const native: TranslationStrings = {
'native.plans.subscribe': 'Berlangganan',
'native.plans.loadError': 'Tidak dapat memuat paket. Periksa koneksi Anda dan coba lagi.',
'native.plans.retry': 'Coba lagi',
'native.plans.soon': 'Langganan dalam aplikasi akan hadir. Untuk saat ini, selesaikan di situs web.',
'native.plans.soon': 'Berlangganan di aplikasi belum tersedia.',
'native.claim.title': 'Hampir selesai',
'native.claim.sub': 'Pilih alamat Anda. Kami langsung menyiapkannya.',
'native.claim.addressLabel': 'Alamat Anda',
@@ -67,6 +67,14 @@ const native: TranslationStrings = {
'native.claim.cancelled': 'Pembelian dibatalkan.',
'native.claim.failed': 'Pembelian tidak berhasil. Silakan coba lagi.',
'native.claim.redeemFailed': 'Kami tidak dapat menyiapkan TREK Anda. Silakan coba lagi.',
'native.plans.restore': 'Pulihkan pembelian',
'native.plans.restoring': 'Memeriksa pembelian Anda…',
'native.plans.restoreNone': 'Tidak ada langganan aktif untuk Apple ID ini.',
'native.plans.restoreFailed': 'Kami tidak dapat memulihkan TREK Anda. Silakan coba lagi.',
'native.plans.legal':
'Pembayaran ditagihkan ke Apple ID Anda saat konfirmasi. Langganan diperpanjang otomatis kecuali dibatalkan setidaknya 24 jam sebelum periode berakhir. Kelola atau batalkan di pengaturan akun App Store Anda.',
'native.plans.terms': 'Ketentuan penggunaan',
'native.claim.pending': 'Pembelian Anda menunggu persetujuan. Setelah selesai, ketuk Pulihkan pembelian.',
'native.provisioning.title': 'Menyiapkan TREK Anda',
'native.provisioning.sub': 'Menyiapkan {host}. Ini hanya perlu satu menit.',
'native.provisioning.failedTitle': 'Hampir siap',
@@ -96,6 +104,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Semua beres',
'native.widget.now': 'Sekarang',
'native.widget.travellers': 'pelancong',
'native.widget.missing': 'belum dikemas',
'native.widget.countries': 'negara',
'native.widget.world': 'dari dunia',
'native.widget.group': 'Rombongan',
'native.widget.poll': 'Polling',
'native.widget.votes': 'suara',
'native.widget.noMessages': 'Belum ada pesan',
'native.widget.departure': 'Keberangkatan',
};
export default native;

Some files were not shown because too many files have changed in this diff Show More