Compare commits

..

91 Commits

Author SHA1 Message Date
github-actions[bot] 95d62b7a02 chore: bump version to 5.1.1 [skip ci] 2026-11-08 14:52:31 +00:00
Maurice e444e855c0 fix(ci): the release counts only plain vX.Y.Z tags, not the apps' TestFlight tags [skip ci] 2026-11-08 15:48:30 +01:00
Maurice 4ab42c06c9 fix(native): spell out the welcome screen's translucent mixes, which older Android web views drew opaque as halos and rings 2026-11-08 15:07:41 +01:00
Maurice 9f6ab11839 fix(native): no logo shadow on the Android welcome screen, where the web view drew it as a dark halo 2026-11-08 14:36:12 +01:00
Maurice 5f711d5374 fix: format the floating panel stylesheet and keep the locate-button room private 2026-11-08 13:33:01 +01:00
Maurice 4d550112a1 fix(mobile): Studio's inspector stays docked on an iPad in landscape, the linked journal's buttons sit beside its switch, and the iOS app draws Leaflet's basemap as raster tiles 2026-11-08 13:12:53 +01:00
Maurice 26dc88181d fix: the file size, theme and reading direction ratchets the last push tipped; widget previews and watch screens run by hand only 2026-11-08 13:09:53 +01:00
Maurice 3d4f8bfe12 fix(docker): take the base image's pending Debian security fixes, so the image scan passes 2026-11-08 12:48:19 +01:00
Maurice 5fb1838c7a fix(mobile): switching collection lists changes the content in place instead of sliding a new page in 2026-11-08 12:41:17 +01:00
Maurice 38e5d76ad2 fix(mobile): Studio's floating panels are opaque over the page, the journal header lays out in one row on a tablet, and the journal a trip links opens Studio too 2026-11-08 12:38:39 +01:00
Maurice a9af19f02a fix(mobile): the journal as a trip tab keeps its switch and menu in the shell's top row, like the tour tab 2026-11-08 12:34:41 +01:00
Maurice 305b00ae01 fix(mobile): on a narrow 3D map the flyover controls and grade legend stand as a column of icons on the side, the layer pill keeps the bottom row; and the lint ratchets the last push tipped 2026-11-08 12:30:12 +01:00
Maurice ed6724035e fix(offline): a Cache Storage open that never settles is given up on, so preparing for offline and the offline map no longer wait on it forever 2026-11-08 12:26:07 +01:00
Maurice eea83bb1c9 fix: GL maps read the stored copy again while offline, and a switch that is on keeps a visible knob in the dark scheme 2026-11-08 12:21:52 +01:00
Maurice 06c03cbded fix(mobile): the 3D grade legend goes compact on the side, and a field under the keyboard gets room to scroll above it 2026-11-08 12:15:34 +01:00
Maurice b73f749880 fix(mobile): no how-to line on the 3D map on a tablet 2026-11-08 12:13:20 +01:00
Maurice 53c18b861a fix(native): GL maps load straight from their servers in the app, past the schemes they drew nothing through on iPadOS 26 2026-11-08 12:11:23 +01:00
Maurice 9b1bf89382 fix(native): a field low on a screen scrolls above the keyboard when it comes up 2026-11-08 12:07:57 +01:00
Maurice c7f144d3ff fix: the map falls back to raster where the vector style cannot load, the tour layer pill fits narrow maps and stands on the side in 3D, the dashboard's widget column scrolls on its own, and the CI checks the watch sync and journey write-up broke 2026-11-08 12:05:59 +01:00
Maurice cb41f08199 fix(mobile): the tour tab's rail heads are rounded bands like the cards under them 2026-11-08 11:50:25 +01:00
Maurice eafb30555c fix(mobile): the iPad's tour tab opens saved tours, keeps its switch in the top row, and a tap shows no tooltip 2026-11-08 11:43:45 +01:00
Maurice 4f1f48eec5 fix: watch sync reads the roster through trip membership, image viewer on theme tokens, retry flaky release downloads in CI 2026-11-08 11:30:45 +01:00
Maurice 42341c2c76 fix(mobile): iPad feedback: columns start level, map controls sit low, no day-rail chevrons, no bottom-bar settings on a tablet, and a planned place on the journey map can be written up 2026-11-08 11:30:45 +01:00
Maurice 6a60fc183a feat(native): the app asks for notifications by itself, once
A moment after signing in, the app asks whether to turn notifications on, once per device and only where the server offers push and the device is not signed up yet. Turning them on goes the way of the settings card, which brings up the system's question. iOS asked only when someone found that card.
2026-11-08 11:05:07 +01:00
Maurice 236b4c34ac fix(mobile): the chat's composer stays above the keyboard, and its pictures open large
In the app the keyboard resizes only the body, which leaves the trip shell's fixed layers as they were, so the composer sat under the keys. The keyboard inset now also follows the height the app reports, and the chat lifts its composer by it. A picture in the chat opens full screen on a tap and zooms on a second; it sits below its bubble, keeps the long press for the message's actions, and a picture sent alone has no empty bubble beside it.
2026-11-08 11:05:07 +01:00
Maurice c6cb3dc64a feat(native): a silent push keeps every watch on a trip current
A change to a trip's packing lists or to-dos (any writer: REST, MCP, plugin RPC) is noted from the realtime broadcast and, a couple of seconds later, each member with the iPhone app gets a silent push through the app relay, marked as a background push. The push carries no content: the woken app reads both lists of the trip its watch shows from the server with its own session and hands them to the watch. The app gains the remote-notification background mode for it.
2026-11-08 11:05:06 +01:00
Maurice 3fc7487b58 fix(ci): the lint ratchet, and SwiftPM's half-downloaded artifacts on the iOS runners
The import sheet no longer sets state from its preset effect and is below the complexity limit; the trip shell's splash tests await their timers. The iOS and watch builds resolve their Swift packages first on a clean artifact cache, up to three times: a failed download from GitHub left a partial Capacitor xcframework behind and every later attempt stopped on it.
2026-11-08 11:05:06 +01:00
Maurice ac055d531f fix: the checks the tour tab, the shared links and the watch sync broke on dev
The tour tab keeps desktopOnly as a plain property, so the tab list's type still has it. The wiring test follows the tab's new gate. The import sheet keeps the choice and the save with the item they were made for instead of resetting them in an effect, takes the share sheet's trip through an effect event instead of a ref read while rendering, and is split into its parts. The tours tab sets its pane while rendering, as React has it for state that follows a prop. The sheet's tests look for the texts it shows. The Android link test reads the host with java.net.URI, which a JVM unit test can run. Two locale files take Prettier's quotes.
2026-11-08 10:37:41 +01:00
Maurice 0ef3cbf520 feat(mobile): a trip opens without its loading screen on the phone and the tablet
The touch shell waited for the desktop's minimum splash of a second and a half after every trip load. It no longer does: while the trip loads it shows only its background, marked as a route fallback, so the page transition holds the dashboard until the trip is there and the trip slides in whole, as the dashboard slides back on the way out. The splash only comes up when a load takes longer than a second. The trip opened before, still in the store for a frame, is never shown for the one opening.
2026-11-08 10:33:49 +01:00
Maurice 3458238db0 feat(native): a link shared to TREK can go to the trip's links
A page or a link shared from another app now carries its address. The app asks whether to keep it among the trip's links (Collab, Links) or to read a booking from it. A link whose page the phone cannot read is no longer refused: it is handed over as the link alone, which can be kept. With Collab off a page is read as a booking, as before.
2026-11-08 10:15:26 +01:00
Maurice afaacec61a fix(native): a tick on the watch, the phone or another member's device reaches every watch at once
The watch's snapshot was rebuilt every fifteen minutes, so a tick in the app or one arriving over the WebSocket did not reach the watch until then. It is now rebuilt as soon as the packing lists or the to-dos change in the offline tables. A tick on the watch goes as a message while the phone is in reach, which wakes the phone app, and a phone app in the background writes it to the server itself, so the trip's other devices hear of it at once instead of when the phone app is next opened.
2026-11-08 10:15:20 +01:00
Maurice 5f4fb5dae7 feat(mobile): the tour editor as a tab of its own on an iPad and an unfolded foldable
On the wide and split size classes the touch shell no longer sends the tours tab back to the plan. It gets the desktop's tour editor, wired over the shell's own planner (useTripPlannerPageOf): on split the trip's tours and the editor share a pane beside the map, on wide the pane and the map take turns. A phone keeps its tours in the places browser.
2026-11-08 09:58:33 +01:00
Maurice 788b1f63d0 fix(mobile): the trip rail's More wears the dock's ring while its sheet is open 2026-11-08 03:07:24 +00:00
Maurice bfb1b04f8d feat(mobile): TREK Studio opens over the journey on an iPad on its side and an unfolded Duo
Studio is a child route of the journey, opened on top of it. The touch
shell's journey now renders that child in the split size class, where the
header offers Studio beside the menu, as the desktop's bar does. The editor
is the desktop's: tapping a panel, adding an element and dragging it with a
finger work through its pointer events. On the phone and in a single pane
the journey stays alone, whatever the address says, and offers no Studio.
2026-11-08 03:05:56 +00:00
Maurice a77ec6e225 fix(ci): the track sheet reads the distance unit as a DistanceUnit, and knip's baseline drops the type useFollowTrack no longer leaves unused 2026-11-08 03:00:39 +00:00
Maurice 2851eb7afe feat(mobile): a Google Maps route is imported into a road trip day on an iPad and an unfolded Duo
The desktop's import dialog keeps its markup and moves its logic into
useGoogleRouteImport, unchanged: the link previewed, its stops in order with
the unresolved ones named, and the resolved ones appended to the day picked,
at least two, under one request id per opening and day. The large screens
get the same flow as a sheet, offered on the stage card under the track
offer to a member who may add places to days. The phone has no entry to it.
2026-11-08 02:57:43 +00:00
Maurice 6c7605b4a6 fix(mobile): the driving form's vehicle card says what you drive, not a second "Vehicle" under the range card 2026-11-08 02:54:39 +00:00
Maurice 3b286d04c3 feat(mobile): the tour detail edits on an iPad and an unfolded Duo
The phone's tour sheet reads the tour and plans it onto a day. In the wide
and split size classes it now edits what the desktop's tour card edits,
through the same useTourDetailActions: the name and the colour, a copy saved
as a new tour, the recorded tracks, the packing list it points at and its
files, for a member who may edit places. The phone is unchanged.
2026-11-08 02:45:34 +00:00
Maurice 21afe89b13 feat(mobile): a road trip day follows an imported track on an iPad and an unfolded Duo
The desktop's track dialog comes to the large screens as a sheet over the
planner's followTrack, the hook RoadtripTrackModal runs on: the trip's tracks
nearest to the day first, the drive fitted to the one picked with via points,
and those vias dropped again. The stage card offers it in the wide and split
size classes, to an editor of days, on a trip that holds a track, and names
the track the day follows. The phone and the folded Duo have no entry to it.
2026-11-08 02:42:37 +00:00
Maurice 060a43ba64 feat(mobile): the driving figures are a form on an iPad and an unfolded Duo
On the phone the road trip's driving figures stay a read-out, set at the
desktop. A large screen is that table, so in the wide and split size classes
the same sheet edits the two driving limits, the day's window and where it
ends, the vehicle with its range and fill, and what the route may avoid,
through the planner's saveRoadtripLimit, the desktop dialog's own save. A
range worked out from the vehicle's own figures stays read-only, as on the
desktop, and a reader without the right to edit days sees every field
disabled. The phone and the folded Duo are unchanged.
2026-11-08 02:37:26 +00:00
Maurice dedbf16f03 fix(native): Android offers TREK Cloud while orders are closed and says a refused purchase is refunded
The licence testers of the internal test buy from a release build, so the
plan screen no longer hides the purchase on Android before the opening.
Their purchases are Play test purchases, which the cloud serves. A real
purchase made before the opening is refused with ordering-closed and never
acknowledged, so Google Play refunds it after three days; the claim and the
restore now say that instead of a general error. The app still acknowledges
a purchase only after a redeem that returned an instance. iOS is unchanged.
2026-11-08 02:31:13 +00:00
Maurice 796f39fe1f fix(rtl): write the isolate marks of flowText as escapes, not as raw characters
A raw FSI or PDI in the source reads as nothing and is what Sonar's
bidirectional character rule (S6389) flags. The strings are the same.
2026-11-08 01:40:30 +00:00
Maurice ee9cbce23d fix(journey): the feed's entries are gone under the header pills, not half seen between them
Scrolled up, a day's head showed at a third of its strength between the
back button and the segment, because the feed's mask let content through
from 22px below the safe area while the pills reach 38px. It is now
transparent down to 40px and whole from 60px, where the first day's head
starts.
2026-11-08 01:11:01 +00:00
Maurice 82fcdcd0fc test(rtl): read rtl.css selectors with their white space folded
Prettier breaks the arrow rule's long selector over lines, so the literal
look-up found no rule and FE-RTL-CSS-003 failed after the file was
formatted.
2026-11-08 00:38:51 +00:00
Maurice 3b7cdf516a fix(mobile): the rail fades where its entries scroll on a short window; the watch capture says why it retries
On a wide window too short for every entry (a folded Duo on its side) the
rail's list scrolls with its scrollbar hidden, and settings and
administration at its foot were cut off without a sign. The list now fades
at the edge with more behind it, as other scrolling lists do.

watch-screens.sh names the step that failed (launch, not ready,
screenshot) and tries once more after 20 seconds before it gives up.
2026-11-08 00:33:07 +00:00
Maurice 41752887dc fix(journey): a folded Duo on its side gets the compact cards in the wide size class too
The folded Duo on its side is over 500px high and so in the wide size
class, whose large cards won over the short window's compact ones. Short
is now asked first.
2026-11-08 00:10:57 +00:00
Maurice b9d0d3397e style: format the six files the formatting check names
They are outside scripts/format-baseline.json, so new code there must be
formatted; Prettier only, no change in behaviour.
2026-11-08 00:10:57 +00:00
Maurice 2a2dfec247 fix(mobile): large screen polish for the dashboard, the trip dock, transports, the stay chip and the journey strip
- Dashboard: crossing the split size keeps every widget in the same parent
  (the columns dissolve into one ordered stack below it), so the currency
  converter is not remounted and keeps its amount and rates.
- Trip dock: More is marked with the main dock's ring while one of its
  sections (chat, notes, links, polls, files) is on show or it is open,
  instead of the filled circle of a tab.
- Transport cards: the status button is a flex box, so its dot shows when
  the card may be edited.
- Day header: the stay chip's name keeps its own direction, so a Latin name
  in an Arabic header is cut at its end.
- Journey: a folded Duo on its side (about 540px high) gets the compact
  cards, so the map keeps its room.
- Rail: a test pins settings and administration marking their own entry.
2026-11-07 23:31:54 +00:00
Maurice 21e07e20a1 fix(ci): keep index.css and useAtlas.ts within their size baselines
The opening pan now measures the north edge through a function chromePan
calls only where there is chrome to clear, so useAtlas needs no branch of
its own. index.css loses the long note on the dark field rule to one line,
and a .dark .bg-slate-100 rule that two later rules always overrode.
2026-11-07 23:31:54 +00:00
Maurice 322d5d5209 test(map): the phone hook mock keeps the number of hooks stable
The mock called the real useIsPhone only while a test left the answer open,
so a map still mounted when the answer was reset rendered with one hook more
and React threw. It now calls the real hook every time and overrides only its
answer.
2026-11-08 00:05:54 +01:00
Maurice 0346f9c13d refactor(mobile): the file preview's heading as its own part
The type tile beside the name took the preview pane past the complexity
limit; the heading (tile, name, type, wallet, size, date, uploader) is now a
part of its own and the pane reads as before.
2026-11-08 00:04:47 +01:00
Maurice dfd87d413e fix(atlas): measure the opening pan only where there is chrome to clear
The opening view asked Leaflet for the north edge's point on every atlas,
though only the touch shell's rail and header need a pan. The desktop atlas
now opens exactly as before, without the call, which its map mocks also
never had.
2026-11-08 00:02:20 +01:00
Maurice 27aab5c783 test(journey): wait for the dropped create parameter to reach the URL
The draft opens and the parameter goes in the same effect, but the URL shows
it a render later. Under CI load the check ran in between and saw
?create=entry; it now waits for the empty search like it waits for the draft.
2026-11-07 23:59:38 +01:00
Maurice a7cc1b9e57 fix(mobile): the checks the large-screen merge left red
- The trip's segmented control writes its fit through a ref, not into the
  element held in state (react-hooks/immutability).
- The amount field of a payment uses the body type tier, which follows the
  text size setting, instead of a raw size.
- The form sheet tests wait the frame the edge fade now measures on, and read
  the wide variant from the size class the shell writes.
- The native dashes spec lists its locales through node:fs, so shared needs no
  vite types for it (knip).
- Baselines lowered where the fixes removed warnings: no-misused-promises,
  no-non-null-assertion in tests, the MSheet rtl entry.
2026-11-07 23:56:33 +01:00
Maurice 8955790f17 fix(mobile): the trip's screens on an iPad and an unfolded foldable, nothing cut or covered
- Top bar: the segmented control keeps to the room between the back button
  and the trailing button, draws its segments tighter and then scrolls with
  faded edges instead of covering the back button. On a wide window every
  list tab puts its primary pill in the middle and its round buttons at the
  trailing end, so the pill no longer jumps between tabs.
- Day strip: a chevron over the faded end pages the days along on a wide
  window, and the fade reaches further; the chips keep clear of it.
- Plan pane: the day card ends on the rail's bottom line beside the map (as
  tall as its day across the page), scrolls inside a frame and fades the
  rows at its edges. Addresses and the hotel chip end in an ellipsis instead
  of being cut mid-letter; the weather chip keeps its width.
- Map: in the dark theme the map beside the pane takes the dark basemap.
- Places: the All / Unplanned / Planned chips are never narrower than their
  names and scroll with a faded edge; on a short screen Places/Tours shares
  their row.
- Chat: the list fades out under the floating controls instead of cutting a
  bubble. Notes and to-do chip rows fade at their edges and New Note stays
  outside the scrolling chips.
- Costs: the balance amounts share a line, the settle-up action wraps to a
  line of its own, the totals stand under their labels.
- Bookings and transports: one column until two cards fit, titles take two
  lines with the type under them, the time cell is never cut and a range
  names its unit once.
- Files: the preview pane shows a type tile beside the name where no PDF can
  be drawn in place, its actions keep their labels on one line and wrap as a
  row, link labels are badges, and a tile that opens its file is no toggle.
- Polls get a fill that differs from card and track, packing filters are two
  segmented controls, empty checkboxes get an edge with 3:1 contrast, and the
  middle dots between values are gone.
2026-11-07 23:25:16 +01:00
Maurice 61d925f3dd fix(mobile): the chat watches its list once it exists, keeps its draft, and the desktop map opens as before
- useCollabChatData: the resize observer that keeps the chat at its newest
  message attaches when the list is drawn (after the messages load), not at
  mount when there is no list yet. The test renders the real list after load.
- MCollabTab: the chat stays mounted, hidden, while a board fills the page on
  its own, so leaving two panes with Notes picked keeps the half typed
  message and the scroll position. The links card hugs its links on a wide
  window instead of a page-high empty card.
- openingView: the floored opening zoom and the second fit for the map's own
  size apply only beside the pane in the touch shell; the desktop planner
  and the phone open at Leaflet's nearest level again.
2026-11-07 23:25:16 +01:00
Maurice bf00412863 fix(mobile): Arabic reads in its direction: arrows turn round, Latin names keep their ends, figures stay whole
In the touch shell the back, forward, disclosure, date-range and flow
arrows kept pointing right in Arabic, so Settle up showed the debt the
wrong way round and Vacay's year arrows pointed inward. The shell now
mirrors lucide's direction arrows in right-to-left languages; an icon
that names its own treatment keeps it, and the lightbox arrows stay on
their physical sides.

Latin user text in Arabic rows lost its beginning when cut and moved its
punctuation to the wrong end. Names, titles, addresses and file names take
their direction from their own text (dir=auto) and still line up with the
row; markdown paragraphs, chat messages, poll options and journal stories
are isolated; signed balances, amounts, sizes, rates and leg figures read
as one left-to-right run; URL fields read left to right. Name pairs joined
by an arrow isolate each name and point from the first to the second.

The hero progress and poll result bars fill from the start edge. Arabic
and Hebrew uppercase caption labels grow a step and take the muted ink,
and a todo's due date in a notification is written in the reader's
language instead of as YYYY-MM-DD.
2026-11-07 23:25:16 +01:00
Maurice 2a6c771ad9 fix(native): the first screens fit a phone on its side, fill an upright iPad, and read right in Arabic and Russian
On a phone or a folded foldable turned landscape the welcome, address,
TREK Cloud and plan screens were taller than the glass: the second choice
and the main button sat below the edge and Back was cut at the top. There
the explanation and the choices now stand side by side, the explanation
pinned while a long plan list scrolls. Every screen centres while it fits
and starts at the top when it does not.

Upright on an iPad, half of one in Split View and an unfolded foldable,
the photo spans the full width and fades only downwards, and the choices
stand centred in the room below it. Buttons and fields are pills like the
app's, choice cards take the app's card radius, and headings and subtitles
balance their lines.

In Arabic (and Hebrew, which never ran right to left here) chevrons point
along the reading direction and sit at the far edge of their card, the
Cloud lockup stays unmirrored on the leading edge, prices and addresses
read left to right, and a plan's period no longer touches its price. The
slides follow the reading direction. Russian gets Cyrillic stand-ins under
the same family names, so the headline keeps a rounded face.
2026-11-07 23:25:15 +01:00
Maurice 641b1882b5 fix(i18n): the app's first screens read without dashes in every language
The welcome subtitle and the TREK Cloud texts used an em dash as a pause in
all locales. Each is reworded with a comma, a colon or a new clause, and a
spec keeps a dash from coming back in any locale's native strings.
2026-11-07 23:25:15 +01:00
Maurice 5346182807 fix(mobile): the rail, the docks and the top-level screens on a large screen
From the large-screen review (visual findings of the shell and the top-level
screens):

- Rail: 80px wide, one caption size for every name (no shrinking per item),
  balanced two-line names with every entry as tall as the tallest, short rail
  names where a language's word is too long (mobileNav.rail*, all locales),
  and near-opaque glass so a map's colours no longer smear through it.
- Rail head: always one 56px circle, so the entries never jump between pages:
  the create action, the screen's own action (the vacation calendar's
  year/editor switch moves there instead of floating over the days) or the
  quiet logo on settings and administration. It stays put while a short rail
  scrolls.
- Vacation calendar: weekday letters over each month on a wide window; the
  editor's mode switch sits at the foot with room kept for it.
- Docks: More is marked with a ring rather than the centre button's fill, the
  dock glass is denser, and lists fade out under the dock instead of showing
  in the gap below it.
- Dashboard: trip names get the card's width with the dates under them, the
  hero's facts wrap as whole chips, the filter chips scroll with a fade, the
  hero ends above the dock on a phone on its side and its buttons sit on a
  dark ground; the header bar stands in the content's column.
- Journeys: on a wide window the header is the same glass bar, in the column
  of the cards, and the other journeys' label starts with them.
- Notifications: the round chevron back button, in the list's column.
- Settings and administration share one frame and width. Admin's new-user
  action sits under the section list's title, the users card header wraps
  inside the card, stat labels ellipsize, role badges read in dark mode.
- Collections: one card per place with the status inside it, worded in a
  readable mix of its colour.
- Atlas and the journey map frame clear of the rail and the header; the
  journey's day bar sits on a glass plate, and a phone on its side gets
  smaller cards.
- Rail label tests rewritten for the new rule; FE-MOB-PANES-016 duplicate
  renumbered to 017.
2026-11-07 23:25:15 +01:00
Maurice 4dfccaa2a4 fix(native): one size class the keyboard cannot move, shortcuts that wait for sheets
From the large-screen review (code findings H1, M1, M2, M3, M4, M7, L1, L3, L4, L6, L7):

- The size class is one value in JavaScript (hooks/sizeClass.ts) and the
  stylesheet follows it: MobileShell writes it to the shell as data-size, the
  wide:/split: variants and mobile.css key on that instead of media queries of
  their own. A window that only grows shorter at the same width while a field
  has focus is Android's soft keyboard, not a new window, so the rail, the form
  sheets and the panes no longer fold away (and take the focused field with
  them) while one types.
- The large-screen layout is the app's only. In a browser the touch shell is
  the phone's below 768px and stays compact there, as before this branch.
- Shortcuts are the app's only, so a browser keeps Ctrl+1 and Alt+Left. The
  command key is the platform's own (Cmd on Apple, Ctrl elsewhere), digits are
  matched by the key's place as well (AZERTY), and nothing runs while a sheet
  or another modal dialog is open: the page listens to nothing and the app is
  handed no key commands, so Cmd+[ can no longer drop an unsaved form.
- Inside a trip Cmd+N belongs to the trip shell and creates what the tab on
  show creates, named after it.
- Inside the touch shell the desktop navbar of the few PageShell routes is
  hidden and --nav-h is 0.
- The rail comes before the page in the DOM, so a keyboard reaches it first.
- The phone's deep day colour for a journey card without a photo stays the
  phone's; the desktop and shared timelines keep their faint wash. Its card is
  opaque, a suggestion included, so the map's names do not read through.
- The trip widget's iPad mini layout keys on the iPad mini's width alone.
- The trip dock gets the scroll-edge strip and the denser dock glass the
  global dock has.
- Duplicate test id FE-MOB-PANES-016 renumbered.
2026-11-07 23:25:14 +01:00
Maurice 18a16d2aa2 fix(mobile): forms on the shared form sheet, and the details the review found
Expense, payment, booking, transport, day note, place edit, new trip,
journey entry, collab note, poll, task, packing item and packing import
use the form sheet: one header with its icon tile, a body that scrolls
with a fade, the footer, and full height with the actions beside the
title on a short window. Create New Trip loses its nested section cards.

Details:
- Add payment gets its icon tile and the currency inside the amount field.
- The expense's category field has the same fill as every other field.
- Day and date fields keep their year at phone width; From and To stack
  in a narrow form.
- The place sheet's category chip sits under the name, uncut, the photo
  on the cards' leading edge, labels in the sheets' small capitals.
- Stars no longer keep a hover preview after a tap.
- A hotel booking shows its stay's check-in and check-out when it has
  none of its own, instead of the grey hint reading as a saved time.
- The collab note's title field is labelled Title (common.title).
- Shorter airport hint in German and Russian; long hints end in an ellipsis.
- The journey story grows with its lines (useAutoGrow).
- Member avatars show the member's initial, yours too, and turn over on
  a selected chip.
- The trip's More sheet scrolls, dims a short window and is marked in
  the dock while open; long titles wrap to two lines.
2026-11-07 23:25:14 +01:00
Maurice b7a91fc84e fix(mobile): one form sheet for every dialog, opaque and placed beside the rail
Every sheet now stands on the opaque sheet surface: the frosting the
translucent one relied on is dropped while the scrim fades, so the page
read through the fields as sharp ghost text.

On a wide window a sheet is centred in the content beside the navigation
rail instead of on the whole window, so it never lies on the rail, and it
grows to the window's height instead of stopping at 880px. On a phone the
card stands between the status bar and the dock rather than across the
dock. A sheet marked as a form fills the height of a window as short as a
phone on its side, with Cancel and Save beside the title (new `short:`
variant, m-form-sheet grid in mobile.css).

The form chrome gains a scrolling body that fades at the edges with more
behind them (useEdgeFade on the vertical axis), a date field whose
keyboard entry moves into the picker, a field wrapper for amounts with a
currency, and two-line titles. The focused sheet no longer draws the
app-wide focus frame or loses its corners, and the desktop's dark field
paint stays out of the touch shell, which painted a dark box inside every
rounded field.
2026-11-07 23:25:14 +01:00
Maurice bb725ac8d8 fix(native): the first screens on an upright iPad read as one page, not a phone column
On an iPad upright or an unfolded foldable the welcome, address, TREK Cloud
and plan screens kept the phone's narrow column in the middle of the glass,
with the photo fading out across the top left. Now the photo spans the
width as a band across the top, the choices stand under it in a wider
column, and the plans take two columns. Landscape keeps the photo as a
panel beside the choices; phones are unchanged.
2026-11-07 23:25:14 +01:00
Maurice b644235eb6 fix(mobile): level card columns, the day framed beside the plan, the reader's place kept across a fold
From a screen by screen pass over the iPad and foldable sizes:

- Card lists (transports, bookings, tasks, polls) flow down their columns
  instead of filling rows, so the columns start level and a short card no
  longer ends above its neighbour in a ragged row. The cards' own top
  margin gives way to an even gap under each, so every column starts on
  one line.
- A booking's or transport's title may take two lines in a column of a wide
  window instead of being cut after a few words.
- The map beside the plan opened too close: the opening frame was worked
  out for the window, not the narrower map, and Leaflet rounded its zoom up
  to the next whole level, so the day ran off the top and bottom. The map
  now opens at the whole level that fits and, once it knows its own size,
  works the frame out again for it (OpeningFrameFit). On a wide window the
  touch shell frames a day clear of its floating toolbar and corner
  buttons.
- Costs and to-dos in two panes are one element tree with the stacked
  arrangement, so a rotation or a fold between them keeps what is typed or
  open in either pane, and the row at the top of the list stays at the top.
- The place sheet's category pill has room for its name on a wide window.
2026-11-07 23:25:13 +01:00
Maurice d259ecb11c fix(ios): widgets hold at an iPad mini's sizes, the extra large ones balanced
From the iPad renders of the widget previews:

- An iPad mini's small and medium widgets are smaller than an iPhone's. The
  trip widget during the trip lets the weather give way in the small one and
  narrows its stops panel in the medium one, the today widget's weather
  shrinks to its chip and then steps aside before the trip's name is cut,
  the atlas puts "countries" under the number in the small one and lets
  its map shrink in the large one, so the continents and latest countries
  are no longer cut off at the foot.
- The extra large widgets fill their width and height: the trip during the
  trip sets the next stop at the foot of the left column and the next
  booking under the day's stops, today sets the next two bookings across
  the foot, the bookings list spaces its rows beside the card.
- The Live Activity's next stop shrinks a little before it is cut.
2026-11-07 23:25:13 +01:00
Maurice 5d05363f36 feat(ios): the iPad gets the extra large widgets, no Wallet button, a centred share sheet
The app is one binary for iPhone and iPad, so what was built for the
iPhone has to hold on an iPad too:

- Widgets: the trip, today and bookings widgets offer the iPad's extra large
  size. The trip sets its countdown or its day beside the bookings or the
  day's stops, today sets the day, its progress and weather beside the
  timeline, bookings sets the next one as a card beside the rest. The other
  widgets stay at the sizes they were designed for.
- The widget renderer draws every home-screen size of each widget as a 13"
  iPad, an 11" iPad and an iPad mini give it (a mini's small widget is
  smaller than an iPhone's), an iPad lock screen with the accessories and
  the travel day's Live Activity, and that Live Activity at the iPhone's and
  the iPad's widths. To draw it there, its lock-screen banner takes plain
  values (TripDayBanner, now beside the today widget).
- Apple Wallet: an iPad cannot take a pass. The shell reports the wallet
  feature only where PKPassLibrary and PKAddPassesViewController can add
  one, so the UI shows no "Add to Apple Wallet" button there and hands a
  .pkpass to the share sheet like any other file.
- Share extension: on an iPad it is presented over the full screen instead
  of in a form sheet, and in a window of regular width its card stands
  centred over the dimmed app, without the grabber, instead of rising from
  the bottom edge. A narrow Split View window keeps the iPhone's card.
2026-11-07 23:25:13 +01:00
Maurice 6906f80ff8 feat(native): Android tablets get the shortcuts, two panes meet at a foldable's fold
The Android shell now reports keyCommands and windowLayout, so the large
screen layout no longer assumes an iPad:

- The keyboard host hands the shortcuts to the app whenever the shell has
  the keyCommands feature, whatever the platform. Before it also required
  iOS, so an Android tablet with a keyboard answered the keys in the page
  and never listed them in the system's shortcuts.
- useWindowFold follows the layout the Android shell reports (feature
  windowLayout). When a vertical fold separates the window, a foldable half
  open like a book, MobileShell marks it with data-fold and its place, and
  the leading pane (the trip's plan, a tab's summary, a list beside its
  detail) ends at the fold while the trailing one starts there, on either
  reading direction. A flat fold that does not separate stays one screen.
- The split size class starts at 840pt, Android's expanded width, instead
  of 900pt, so a foldable unfolded on its side (about 841dp) gets two panes
  like an iPad does. An 11" iPad upright (834pt) stays a single pane. The
  address screen of the native shell follows the same width.
- TrekShell exports NativeWindowLayout for it.

Also: the sheet's width and anchor types and the collab board type stay
private to their modules, which nothing else imports (knip).
2026-11-07 23:25:13 +01:00
Maurice 7083667cbf fix(mobile): grid cards keep their height, a previewed photo its proportions
Cards in the large-screen grid keep their own height instead of stretching
to a taller neighbour in their row, which drew a short booking as an empty
panel. The file preview beside the grid shows a photo at its own
proportions rather than letterboxed in a portrait frame.

On a 13" iPad on its side the cloud plans of the native shell stand two by
two in their half of the screen, so the whole offer is in view at once.
2026-11-07 23:25:12 +01:00
Maurice 0b7cee0b0d feat(mobile): the trip's tabs, administration, collections and the journal use the large screen
On an iPad or an unfolded foldable the list screens kept one phone-wide
column in the middle of the page. They now lay out for the room they have:

- Transports, bookings, polls and tasks put their cards in a grid of as many
  phone-wide columns as the page holds (CardGrid), three on a 13" iPad on
  its side, two upright, one in a narrow Split View.
- Costs: with room for two panes (split size class) the summary (total,
  owe and owed, settle up, final budget, categories) stands in a pane of its
  own beside the expenses, each scrolling on its own (TabPanes). The search
  field sits in the expenses' column again instead of running past it.
- Files: a grid of tiles with each photo's picture; with room for two panes
  the picked file is previewed beside the grid with what it belongs to and
  its actions, the way Files on an iPad does. The signed preview address
  comes through fileRepo.previewUrl.
- Packing: the categories stand in columns, each card whole in its column;
  the action menu drops from its button at a popover's width.
- To-dos: the four smart filters as tiles and the trip's lists in a pane
  beside the tasks, which take the filter's name as their head.
- Collab: the chat stays in view at the trailing edge beside the notes,
  links or polls, and the header switches only the board. The chat keeps its
  place in the tree, so a fold or a rotation into two panes keeps the draft;
  a chat at its newest message stays there when its box changes size. Notes
  stand as a board in columns.
- Administration and settings share a section list beside the page
  (MSectionNav); the stats grid takes four columns.
- Collections: the lists stand in a column beside the places, which take two
  columns, and the head names the list on show.
- Journal: the entries become a feed beside the map, under their days, with
  photo, place and the start of the story, in step with the map like the
  strip of cards it replaces; it fades out under the header.
- The assistant opens as a tall panel at the trailing edge over a dimmed
  page, as Slide Over would, instead of covering the whole screen.
- The rail fits every name: a word too wide for it shrinks the label before
  it is hyphenated, and the admin entry uses its short name.
- The journeys and notifications headers keep to their column.
- The address screen of the native shell sets the photo as a panel beside
  the choices on a large screen on its side.

On a foldable that reports its fold, a leading pane ends at the fold and a
trailing one starts there. Phones keep their layout.
2026-11-07 23:25:12 +01:00
Maurice 36ed0e0d23 fix(mobile): the map frames the day beside the pane, atlas search as a centred panel
With the plan beside the map, the map frames the day once the splash has
gone, and again once its box has settled after the pane opens or hides, a
rotation or a fold: a frame fitted to the old box left the day half outside
the new one. On a wide window the atlas country search stands as a centred
panel instead of a band across the screen.
2026-11-07 23:25:12 +01:00
Maurice b1c0353548 feat(native): iPad shortcuts in the app menu, pointer input on iPad
The UI's keyboard shortcuts for the screen on show become native key
commands (TrekShell.setKeyCommands, feature keyCommands): they appear in the
overlay a held Command key brings up and in the iPadOS menu bar, named in the
UI's language, and a press is reported back to the page ("keyCommand").
Each screen replaces the list, so only what works there is ever offered.

UIApplicationSupportsIndirectInputEvents turns on the iPad's pointer input
for trackpads and mice.
2026-11-07 23:25:12 +01:00
Maurice c204a25a9c feat(mobile): the touch shell laid out for iPad and unfolded foldables
The app shows the touch shell at every size now, an iPad's and an unfolded
foldable's included, instead of switching to the desktop pages from 768px.
One route tree survives rotation, Split View, Stage Manager and folding, and
the app's own features (Wallet, sharing into a trip, the edge swipe back)
are there on the big screen too.

Two size classes drive the layout (hooks/sizeClass.ts, Tailwind variants
wide: and split:):

- wide (600pt and taller than a phone on its side): a glass navigation rail
  on the leading edge replaces the dock and holds every destination with its
  name; sheets become centred form sheets; the trip's More menu is a popover
  beside the rail; lists keep a readable measure; dashboard, journey and
  vacay get columns; full-bleed maps run under the floating rail.
- split (900pt): the trip's plan, places or road trip chain sit in a pane
  beside the map, which follows the plan's day; the list/map switch hides
  and shows the pane like a sidebar; on a foldable that reports its fold the
  pane ends at the fold. Settings show their sections beside the page and the
  dashboard keeps its widgets in a column of their own.

A hardware keyboard gets the shortcuts an iPad app has (⌘1 to ⌘9 for the
sections, ⌘N, ⌘, , ⌥←/⌥→ through the days, ⌃⌘S for the pane, ⌘[ back),
answered by the page or, with a binary that has the keyCommands feature,
handed to it as native key commands.

The day strip fades at an edge that runs on, journey cards grow on a wide
window and a card without a photo takes the day's colour, and the Leaflet
map measures itself again when its box changes size.
2026-11-07 23:25:12 +01:00
Maurice b008e010a5 fix(native): sort the offline manifest with localeCompare, so Sonar's reliability gate holds 2026-11-07 22:03:20 +01:00
Maurice 02b0454b3d fix(native): watch complications stay current, times hold on clock changes, and a review's findings
The complication timeline has entries for every planned day in its window
and rebuilds at midnight; its countdowns count by themselves. Times of
day are wall-clock hours and minutes, never minutes added to midnight,
and every formatter follows the zone the watch is in. A ride ends in its
arrival's zone, which the snapshot now carries. Bookings without a time
are all-day and get no countdown, a day of untimed places shows its
first place instead of "day done", idle complications say what fits the
trip's phase. The watch finishes WatchConnectivity deliveries in a
background task, sends a tick to the phone only until it is handed over,
and keeps nothing in UserDefaults, so its bundles use no API that needs a
privacy declaration.
2026-11-07 18:13:17 +01:00
Maurice 9c1e63e64b fix(native): the watch bridge stays silent on an iPad
The "watch" feature is offered only where WatchConnectivity is
supported, so the UI never builds a watch snapshot on an iPad, and the
phone's link neither activates a session nor stores or sends anything
there.
2026-11-07 17:18:50 +01:00
Maurice dacc413c34 fix(native): a list's progress bar heads its first section, so two items show on the small watch 2026-11-07 17:14:12 +01:00
Maurice b8f6a99895 fix(native): long single-word menu rows shrink further instead of cutting; lists close up their sections 2026-11-07 17:14:12 +01:00
Maurice db82e04b49 fix(native): compact list progress, one-line menu rows, a booking header that wraps instead of cutting
The packing list and to-dos open with a slim progress bar and their count,
the list right under it. The trip's menu rows stay on one line and shrink
a little before anything wraps. A booking's kind and status share a line
when they fit and stack when they do not; its countdown drops the day
word before it moves under the time. The place map fades its top edge,
where labels were cut, and leaves Apple's attribution as MapKit draws it.
The complication gallery has no English section names.
2026-11-07 17:14:11 +01:00
Maurice 3abc9eacf4 fix(native): no cut words on the watch, in any language or case size
The booking's countdown moves under its time and its reference and seat
under each other when a row is too short, menu rows and the countdown
title take a second line, a list's count opens the list instead of
crowding its title, text people wrote in Latin letters breaks between
words in a Chinese, Japanese or Korean interface, and the day opens at
its top with nothing behind its title. A trip's pages take one of a set
of clean colours by its id, the same in every language; the capture
switches the simulators to a 24 hour clock after English.
2026-11-07 17:14:11 +01:00
Maurice 3a0edafdf0 fix(native): the watch's booking and today cards fit the 40 mm case in every language
The booking's kind and status share one line and its day steps aside when
the countdown needs the room; the next stop's name is a size smaller on
the small cases; eyebrows shrink a little before they cut; the day header
points forward in right-to-left languages; the sample trip keeps the
watch's own 12 or 24 hour clock.
2026-11-07 17:14:11 +01:00
Maurice 35d2b19f4c ci(native): pass the screenshot clock on the Mac's bash 3 2026-11-07 17:14:11 +01:00
Maurice 6b5b2f614d fix(native): watch screens fit every size and language
Countdowns use the system's short relative style in every language
("через 2 ч", "in 5 Min."), and durations take the watch's language;
a stop on now shows its range, not a countdown. The top-level pages drop
the titles that collided with the scroll indicator, the pages keep the
standard margins, the day header carries a chevron instead of a crowded
icon, the booking page fits on one screen with its reference and seat,
the day timeline opens at the stop that is on, the map is drawn from
above without labels, the bookings list has the trip's light, and the
complication preview adapts to the case size. The sample trip speaks
German and Russian in those languages.
2026-11-07 17:14:10 +01:00
Maurice 92f10531d0 ci(native): capture the watch screens one watch at a time, retried, and complete
Each launch is terminated first, retried after 2, 5 and 10 seconds when
the simulator refuses it, and captured once the app reports the screen
drawn instead of after a fixed wait. The job fails when any expected
image is missing.
2026-11-07 17:14:10 +01:00
Maurice d5ba4439f2 feat(native): a calmer watch map, whole street names, a today page that ends clean
The place map is flat and muted without points of interest, with the
stop's own pin; the day timeline gives a street two lines; the today page
puts what follows into the card instead of a list cut off at the fold, and
its header opens the day. The screenshot mode takes the real clock, moved
to the hour a screen needs through the app's time zone, so the clock in
the corner agrees; the capture covers three watch sizes, three languages
and every state.
2026-11-07 17:14:10 +01:00
Maurice 78a28e2747 ci(native): capture the watch screens on simulators the runtime supports 2026-11-07 17:14:10 +01:00
Maurice 71e3ddabed ci(native): install the packages before building the watch app on its own 2026-11-07 17:14:09 +01:00
Maurice e59af6dcb3 feat(native): TREK on Apple Watch, with complications
A watchOS app embedded in the iPhone app: today's next stop with its
countdown, the next booking with reference, seat and platform, the trip
with its days and places (Directions into Apple Maps), and the packing
list and to-dos to tick off. The phone sends the snapshot through
WatchConnectivity; ticks go back as queued user info. Three
complications (next stop, countdown, trip day) in every accessory family,
scored for the Smart Stack. 28 languages.

The native workflow captures every screen from watchOS simulators in a
screenshot mode, on the largest and the smallest watch.
2026-11-07 17:14:09 +01:00
Maurice 9755330113 feat(native): build the Apple Watch snapshot and take back what the watch ticks off
The hero trip goes to the watch in full: every day with its stops and
coordinates, the bookings with reference, seat, platform and both ends,
the packing list and to-dos with ids. Ticks from the watch come back
through TrekShell.takeWatchActions and are written through the trip
store's toggles, so they take the repos and the mutation queue like a tap
in the app.
2026-11-07 17:14:09 +01:00
Maurice 5ca44453ae fix(ci): the Android merge passes the client gates
- wallet.ts: the Google Wallet state is read through a function after it was
  set, so TypeScript no longer narrows it to its old value (TS2367)
- iap.ts: a store listener that cannot be added is reported through
  reportNativeFailure instead of an empty catch
- iap.test.ts: the restored purchase is checked instead of asserted non-null
- trekShell.ts: the fold's rectangle is typed as a DOMRect pick, geometry the
  RTL lint does not read as text sides
2026-11-07 17:13:47 +01:00
Maurice 4461899910 docs(android): each tile store comment sits over the code it describes 2026-11-07 16:49:07 +01:00
505 changed files with 23020 additions and 2245 deletions
+3 -1
View File
@@ -140,7 +140,9 @@ jobs:
set -eu
# Derive version from git tags, no package.json dependency
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
# Only a plain vX.Y.Z is a release: the glob alone also matches the apps' TestFlight tags
# (v5.1.0-tf.15), whose suffix then broke the arithmetic below.
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
+118 -2
View File
@@ -17,6 +17,7 @@ on:
- 'client/ios/**'
- 'client/native-shell/**'
- 'client/src/native/trekShell.ts'
- 'client/src/native/watch.ts'
- 'client/src/native/serverAddress.ts'
- 'client/capacitor.config.ts'
- 'client/vite.native.config.js'
@@ -103,7 +104,7 @@ jobs:
xcodebuild -version
- name: Check the property lists
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings client/ios/App/ShareExtension/Info.plist client/ios/App/ShareExtension/ShareExtension.entitlements client/ios/App/ShareExtension/*.lproj/Localizable.strings
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings client/ios/App/ShareExtension/Info.plist client/ios/App/ShareExtension/ShareExtension.entitlements client/ios/App/ShareExtension/*.lproj/Localizable.strings client/ios/App/TREKWatch/Info.plist client/ios/App/TREKWatch/TREKWatch.entitlements client/ios/App/TREKWatch/*.lproj/Localizable.strings client/ios/App/TREKWatchWidgets/Info.plist client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements
- uses: actions/setup-node@v6
with:
@@ -120,6 +121,23 @@ jobs:
working-directory: client
run: npm run cap:sync -- ios
# Capacitor's xcframeworks come from GitHub release downloads, which answer
# 504 now and then; SwiftPM then reports the half-written file as "already
# exists in file system". Resolve on a clean artifact cache, a few times
# with growing pauses, before the build needs the packages.
- name: Resolve the Swift packages
working-directory: client/ios/App
run: |
for attempt in 1 2 3 4 5; do
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi
echo "Resolving the Swift packages failed (attempt $attempt of 5)"
sleep $((attempt * 30))
done
exit 1
# No -sdk: the scheme builds the embedded watch app and its complications
# against the watchOS simulator SDK and the rest against the iOS one.
- name: Build for the simulator
working-directory: client/ios/App
run: |
@@ -127,14 +145,16 @@ jobs:
-project App.xcodeproj \
-scheme App \
-configuration Debug \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO
# The widgets drawn to PNGs (client/ios/WidgetPreviews): every size, light and
# dark, for a trip ahead, a trip under way and none, plus a home screen and
# a lock screen of them. Nobody has to install a build to see a widget change.
# Started by hand only (Run workflow): the apps are on TestFlight and Play now,
# where a change is looked at on a device; on every PR it only held the queue.
widget-previews:
if: github.event_name == 'workflow_dispatch'
runs-on: macos-15
timeout-minutes: 20
steps:
@@ -159,3 +179,99 @@ jobs:
name: widget-previews
path: client/ios/WidgetPreviews/out
retention-days: 14
# The watch's half in the UI (client/src/native/watch.ts): the snapshot it
# builds and the ticks it takes back, typechecked and tested here as well,
# so a change to the bridge is checked next to the Swift that reads it.
watch-bridge:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
- name: Install
run: npm ci --workspace=shared --workspace=client
- name: Build shared
run: npm run build --workspace=shared
- name: Typecheck the client
working-directory: client
run: npm run typecheck
- name: Test the widget and watch snapshots
working-directory: client
run: npx vitest run src/native/watch.test.ts src/native/widgets.test.ts src/native/widgetExtras.test.ts
# The watch app on watchOS simulators in its screenshot mode (DemoMode.swift):
# every screen on the largest and the smallest watch, the main ones in every
# language, and the complications, as PNGs to look at without a watch.
# Started by hand only, like the widget previews: up to 90 minutes of macOS.
watch-screens:
if: github.event_name == 'workflow_dispatch'
runs-on: macos-15
timeout-minutes: 90
steps:
- uses: actions/checkout@v6
- name: Select Xcode 26
run: |
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
test -n "$XCODE"
sudo xcode-select -s "$XCODE"
# The project resolves the Capacitor packages from node_modules, even for
# a target that uses none of them.
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
- name: Install
run: npm ci --workspace=shared --workspace=client
- name: Make sure a watchOS simulator runtime is there
run: |
xcrun simctl list runtimes | grep -q watchOS || xcodebuild -downloadPlatform watchOS
xcrun simctl list runtimes
# Capacitor's xcframeworks come from GitHub release downloads, which answer
# 504 now and then; SwiftPM then reports the half-written file as "already
# exists in file system". Resolve on a clean artifact cache, a few times
# with growing pauses, before the build needs the packages.
- name: Resolve the Swift packages
working-directory: client/ios/App
run: |
for attempt in 1 2 3 4 5; do
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi
echo "Resolving the Swift packages failed (attempt $attempt of 5)"
sleep $((attempt * 30))
done
exit 1
- name: Build the watch app for the simulator
working-directory: client/ios/App
run: |
xcodebuild build \
-project App.xcodeproj \
-target TREKWatch \
-configuration Debug \
-sdk watchsimulator \
SYMROOT="$PWD/build" \
CODE_SIGNING_ALLOWED=NO
- name: Capture the screens
run: bash client/ios/scripts/watch-screens.sh client/ios/App/build/Debug-watchsimulator/TREKWatch.app "$PWD/watch-screens"
- uses: actions/upload-artifact@v6
if: always()
with:
name: watch-screens
path: watch-screens
retention-days: 14
+4 -2
View File
@@ -1236,10 +1236,12 @@ jobs:
bin="$RUNNER_TEMP/bin"
mkdir -p "$bin"
cd "$RUNNER_TEMP"
curl -sSfLo helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz"
# GitHub's release downloads answer 504 now and then: retry, the checksum still decides.
fetch() { curl -sSfL --retry 5 --retry-delay 15 --retry-all-errors -o "$1" "$2"; }
fetch helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz"
echo "$HELM_SHA256 helm.tgz" | sha256sum -c -
tar -xzf helm.tgz --strip-components=1 -C "$bin" linux-amd64/helm
curl -sSfLo kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz"
fetch kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz"
echo "$KUBECONFORM_SHA256 kubeconform.tgz" | sha256sum -c -
tar -xzf kubeconform.tgz -C "$bin" kubeconform
echo "$bin" >> "$GITHUB_PATH"
+3
View File
@@ -70,7 +70,10 @@ COPY server/package.json ./server/
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
# deps are esbuild's binary check, redundant next to its optional platform
# package, and a telemetry ping.
# The upgrade takes the Debian security fixes the base image was built before
# (zlib, 11/2026, failed the image scan until node:24-trixie-slim caught up).
RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
libkitinerary-bin libsqlite3-0 && \
npm ci --workspace=server --omit=dev --ignore-scripts && \
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 5.1.0
version: 5.1.1
description: Minimal Helm chart for TREK app
appVersion: "5.1.0"
appVersion: "5.1.1"
@@ -83,6 +83,12 @@ final class TrekSharedFiles {
file.put("mimeType", meta.optString("mimeType", "application/octet-stream"));
file.put("data", Base64.encodeToString(item.getSecond(), Base64.NO_WRAP));
if (meta.has("tripId")) file.put("tripId", meta.optLong("tripId"));
// A page or a link: where it came from, so the UI can keep it among the trip's links.
String url = meta.optString("url", "");
if (url.startsWith("https://") || url.startsWith("http://")) {
file.put("url", url);
file.put("title", meta.optString("title", url));
}
pending = file;
if (listener != null) listener.onFile(file);
}
@@ -46,6 +46,7 @@ object ShareHandoff {
fun store(context: Context, item: ShareItem, tripId: Long?): Boolean = runCatching {
val meta = JSONObject().put("name", item.name).put("mimeType", item.mimeType)
if (tripId != null) meta.put("tripId", tripId)
if (item.url != null) meta.put("url", item.url).put("title", item.title)
File(dir(context), "pending.data").writeBytes(item.data)
File(dir(context), "pending.json").writeText(meta.toString())
true
@@ -26,6 +26,11 @@ data class ShareItem(
val title: String,
/** A host, for a page. */
val source: String? = null,
/**
* The web address it came from, when it is a page or a link: the app offers to
* keep it among the trip's links as well as to read a booking out of it.
*/
val url: String? = null,
) {
enum class Kind { PASS, PDF, MAIL, CALENDAR, PAGE, TEXT, IMAGE, FILE }
@@ -90,11 +95,16 @@ object ShareLoader {
return checked(ShareItem(kind, withExtension(name, kind), normalized, data, name.substringBeforeLast('.').ifBlank { name }))
}
/** A link becomes the page it points at; anything else, a text file the import reads. */
/**
* A link becomes the page it points at, or the bare link when the page cannot be
* read (a sign-in wall, an app-only page, no network), which the app can still
* keep among the trip's links. Anything else, a text file the import reads.
*/
internal fun fromText(text: String, subject: String?): ShareItem {
val link = firstLink(text)
if (link != null) {
runCatching { return page(link, subject) }
return linkOnly(link, subject)
}
val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim() ?: text
return checked(ShareItem(ShareItem.Kind.TEXT, "shared-text.txt", "text/plain", text.toByteArray(), (subject ?: firstLine).take(80)))
@@ -121,12 +131,19 @@ object ShareLoader {
val html = String(data, Charsets.UTF_8)
val host = runCatching { Uri.parse(link).host }.getOrNull()
val title = pageTitle(html) ?: subject ?: host ?: "Page"
return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host))
return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host, link))
} finally {
connection.disconnect()
}
}
/** The link alone, as a text/uri-list the app recognises as nothing to read a booking from. */
internal fun linkOnly(link: String, subject: String?): ShareItem {
// java.net.URI rather than android.net.Uri: the same host, and readable in a JVM unit test.
val host = runCatching { java.net.URI(link).host }.getOrNull()
return ShareItem(ShareItem.Kind.PAGE, "link.url", "text/uri-list", link.toByteArray(), subject ?: host ?: link, host, link)
}
/** The kind and the MIME type the import should see, from the name when the type is vague. */
internal fun classify(name: String, mime: String): Pair<ShareItem.Kind, String> {
val ext = name.substringAfterLast('.', "").lowercase()
@@ -100,13 +100,13 @@ object TrekTileStore {
return null
}
/** HttpURLConnection has already undone any gzip, so no Content-Encoding goes along. */
/** Headers that are the request's own; the connection sets the hop-by-hop ones itself. */
private val HOP_BY_HOP = setOf("connection", "keep-alive", "transfer-encoding", "te", "upgrade", "host", "content-length", "accept-encoding")
internal fun forwardedHeaders(headers: Map<String, String>?): Map<String, String> =
headers.orEmpty().filterKeys { it.lowercase() !in HOP_BY_HOP }
/** HttpURLConnection has already undone any gzip, so no Content-Encoding goes along. */
private fun response(bytes: ByteArray, type: String): WebResourceResponse {
val mime = type.substringBefore(';').trim()
val charset = Regex("charset=([^;]+)").find(type)?.groupValues?.get(1)
@@ -49,6 +49,16 @@ class NativeLogicTest {
assertEquals("page", ShareLoader.slug("!!!"))
}
@Test
fun aLinkWhosePageCannotBeReadIsHandedOverAsTheLinkItself() {
val item = ShareLoader.linkOnly("https://www.instagram.com/p/abc", null)
assertEquals("text/uri-list", item.mimeType)
assertEquals("https://www.instagram.com/p/abc", item.url)
assertEquals("https://www.instagram.com/p/abc", String(item.data))
assertEquals("www.instagram.com", item.title)
assertEquals("Ramen place", ShareLoader.linkOnly("https://maps.app.goo.gl/x", "Ramen place").title)
}
@Test
fun theShareSheetOffersComingTripsFirstThenRecentPastOnes() {
val today = LocalDate.of(2026, 11, 7)
+2
View File
@@ -26,6 +26,8 @@ SIGNED_TARGETS = {
"NotificationService" => "com.liketrek.trek.notifications",
"TREKWidgets" => "com.liketrek.trek.widgets",
"ShareExtension" => "com.liketrek.trek.share",
"TREKWatch" => "com.liketrek.trek.watchkitapp",
"TREKWatchWidgets" => "com.liketrek.trek.watchkitapp.widgets",
}
platform :ios do
@@ -10,11 +10,19 @@
01FD437981FC32C2C2C5BDCE /* TREKWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0520A7C30D8D3FACCBB784BE /* ShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2A15008B5664C48B639F94 /* ShareItem.swift */; };
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */; };
0D801738BB09A41A62E9B57F /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
1091DAAC693DAE3E1CBB7D69 /* WatchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8850ACF43C79E9D879ACC44 /* WatchStore.swift */; };
11B5B80E66AB03C92B0EADBD /* TREKWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CE3377B458EDD77B809F61 /* TREKWatchApp.swift */; };
157A7B737E3FFB610A218D47 /* AtlasContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D004544572B0E85DE29A7B8 /* AtlasContent.swift */; };
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
1F8D58E1B4FD9BB84172527A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
2162CD349AFFDF070C16A58A /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E901288FC198E4D33BE754F /* SwiftUI.framework */; };
228EA0E0270314581194EC3A /* TREKWatchWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D88ABD8284E0A39E7586BE /* TREKWatchWidgets.swift */; };
239FC886F02275957A1D449A /* FacesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 787A9D6B7D9EB7FED319C1B5 /* FacesView.swift */; };
2CFE220A73ACB585F3D4CB8F /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2EE43D236E661AA975612A4B /* UserNotifications.framework */; };
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
2FB9B214E9DA5C7A25BE0EA1 /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
318BBC653A438E6D1D493F50 /* DemoMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9B6357E3FBBA4D9CDD1C7A3 /* DemoMode.swift */; };
32797E66C09CF7579AC323FD /* TREKWidgetsBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */; };
3FB1872A25267A8E88FCBE3C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC64330AC19625949E8DC05A /* WidgetKit.framework */; };
45B990B86488D5CC2B5222B5 /* WorldGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC751335E0136865E08990 /* WorldGrid.swift */; };
@@ -31,30 +39,56 @@
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */; };
5BBE9EE02A47EC67C31F5E21 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D0A4B2C6C93971E884561CFA /* Localizable.strings */; };
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = A030A06726542A2D7F61C54B /* TrekFamily.swift */; };
634F4E63DC7C2A0027F0D118 /* Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = F406A4A31BD875A880A7CA14 /* Details.swift */; };
661AEED37F1F278677F4F306 /* WatchStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B991C938B24A275550C15977 /* WatchStyle.swift */; };
6680B5C572AA37E6E3DFC3A2 /* ComplicationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */; };
6EB76B1C6AB4AC78FF021994 /* WatchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8850ACF43C79E9D879ACC44 /* WatchStore.swift */; };
7060F9DB820849A45ECE0507 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3C3D5E2482AC7D20D2054623 /* Assets.xcassets */; };
7A1E00032C0000000000A003 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */; };
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */; };
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00062C0000000000A006 /* TrekShellPlugin.swift */; };
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */; };
896952F864B35C647FF6910A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
91B14997828BEF884E1239B4 /* Pages.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB5322D218E340690F499CA2 /* Pages.swift */; };
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
96DBDCE4766AC9F13F100A06 /* SharePage.js in Resources */ = {isa = PBXBuildFile; fileRef = 9DC696E1A1FD3A1A1695F9EA /* SharePage.js */; };
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B175DE91E1E6E850DB0285 /* TodayContent.swift */; };
A0E8821CA7CF51AF2D6D8422 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 248A72D1C511B4969D89333A /* Localizable.strings */; };
A2B048E183B19BB17E264FDE /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED561D7B7219D23A99FAEB11 /* ShareViewController.swift */; };
A8FDD4DBE3F078BF935CC4B9 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA8802204810ED580DE46F3 /* NotificationService.swift */; };
AAE5F2A3903F1E241C395BB8 /* WatchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42EBD981456D3493C3947441 /* WatchModel.swift */; };
AEE032DFFB63C7BEB8AFABBF /* TREKWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = CF4BA0BDDABFBDB24394025D /* TREKWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
B4C0FC386F71D849E141C91E /* NextTripWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */; };
B65B7CFC62EEFF443491EA64 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 192E69C7E66646BC39566925 /* ActivityKit.framework */; };
B8D1816D155C8D52DFCAE25B /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6831E849332626BE8D54CEF /* ShareView.swift */; };
BE2DBB926DB04D6CEEFD8B43 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 694631A1018DCE5678F29930 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
C109B48C5A072AF9BE685BE4 /* ComplicationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */; };
C3959ACB55B495F7A86F89A9 /* WatchStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B991C938B24A275550C15977 /* WatchStyle.swift */; };
D0983EB4FB8015F96E232919 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
D3C699E2258FBA6A11C1DDE4 /* TripIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */; };
DE14C398EAFA594CB32ABBF2 /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */; };
EA5EDDD4E5F9BC9BC46C563F /* CollabContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D5041DDDAFF5B491B053999 /* CollabContent.swift */; };
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */; };
F30AA6CFFE04035E7D6AC546 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
F9947489F5B9323E519D3903 /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
FEB91385917AC44D5CDAA59C /* PackingContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C0771C8582246A26E0C98E /* PackingContent.swift */; };
FECB6034B17C07C879908CEF /* TREKWatchWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
201AA9320B6C5DCBCC3429BF /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 59A840D13C3074A09FCCC7CB;
remoteInfo = TREKWatch;
};
3BB8A38B2B2C8614B24CF2AD /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
@@ -76,9 +110,38 @@
remoteGlobalIDString = A3BE5653156025C97CA62C68;
remoteInfo = TREKWidgets;
};
EA68232794738AB11E866FA3 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 5B5EA8FB8F58C6BFA3B77661;
remoteInfo = TREKWatchWidgets;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9545786868FF757328C69E1E /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
FECB6034B17C07C879908CEF /* TREKWatchWidgets.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
986D3C6100A1AB6BE603CF1C /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
AEE032DFFB63C7BEB8AFABBF /* TREKWatch.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -95,14 +158,22 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
00B7BB68930D45D35C9AD54A /* az */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = "<group>"; };
037DA05706AE051CEFCA7D75 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
04C0771C8582246A26E0C98E /* PackingContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackingContent.swift; sourceTree = "<group>"; };
07C00C53BA5641B009E40EF1 /* zh-Hans */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
07DF720DA5E63F842526A075 /* vi */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
090D74588DA8436DEC275567 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
095A4E5D36F6E22070842EC1 /* nl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
0A7C4491A7ABDDBEBE00E5BF /* sk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
0BA66BF6D1F4FB57EFF31981 /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
0CC4BCF06E7719280549FFF1 /* el */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TREKWatchWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
10FB55D221F4BA18795F33B5 /* WatchSample.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSample.swift; sourceTree = "<group>"; };
114C13FB91B554AE8DC4372B /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekWatchLink.swift; sourceTree = "<group>"; };
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
1335F1EEB49D744B27A00F31 /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekStyle.swift; sourceTree = "<group>"; };
192E69C7E66646BC39566925 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
@@ -111,19 +182,27 @@
1E7522315D47F086145DC4B8 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1ED347C91E03E51FDF8ABC1A /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
22A904A2E55946892F488B1A /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
230D9B2E5898FCEC3EC4FB44 /* pl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
23AE662FD3762641A2047F72 /* ja */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
241C37D114DDD53C7E915CB8 /* id */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
24B175DE91E1E6E850DB0285 /* TodayContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayContent.swift; sourceTree = "<group>"; };
2D75538DBBAA6B2366CD8B69 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
2EE43D236E661AA975612A4B /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
2FD81D4601E9EB8744171B09 /* zh-Hant */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayLiveActivity.swift; sourceTree = "<group>"; };
32A62EE30F8AD514D7DB2CAA /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayAttributes.swift; sourceTree = "<group>"; };
37FA9D6C0C247EC699CF326C /* TREKWatchWidgets.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWatchWidgets.entitlements; sourceTree = "<group>"; };
38C632E7EE9C0DF1CA0BF0A0 /* cs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
3C3D5E2482AC7D20D2054623 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
3CB565FC61AB5AEA87A3F615 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
3DF4F4305878F949DD09193F /* NotificationService.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = "<group>"; };
405B04DFF3A978D1046147C6 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
408F0E543D49BFCCF6B19194 /* es */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
4167040E2C29E44D55580916 /* ru */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
42EBD981456D3493C3947441 /* WatchModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchModel.swift; sourceTree = "<group>"; };
467929A8EB65D22F8FA91588 /* ca */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
4D004544572B0E85DE29A7B8 /* AtlasContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtlasContent.swift; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -134,6 +213,7 @@
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
56050CCA8DE4BEB4680D9E0B /* ShareHandoff.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareHandoff.swift; sourceTree = "<group>"; };
57DF93CD1B88A7135481BF03 /* de */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
5C0109EA628FA897B7BFDD3E /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
608F00CBD96957EEA888E50A /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
60EB00F3670B41B898765DF9 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -143,24 +223,34 @@
683725459244601B71D92E2D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
6932A417DC4BC05BF07D80E2 /* it */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
694631A1018DCE5678F29930 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
697E5726770333015DDD444D /* TREKWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWatch.entitlements; sourceTree = "<group>"; };
6E901288FC198E4D33BE754F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TREKWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
7328217E23761C22814FEC25 /* az */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = "<group>"; };
772816394C94096090C2F8FE /* ar */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
77A0112CA76E5C1787073AEB /* hu */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
77C217524DA98A6A92F422F4 /* zh-Hant */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
787A9D6B7D9EB7FED319C1B5 /* FacesView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FacesView.swift; sourceTree = "<group>"; };
7887877A91D6C782111A8388 /* it */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
7A1E00012C0000000000A001 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekBridgeViewController.swift; sourceTree = "<group>"; };
7A1E00062C0000000000A006 /* TrekShellPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekShellPlugin.swift; sourceTree = "<group>"; };
7D5041DDDAFF5B491B053999 /* CollabContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CollabContent.swift; sourceTree = "<group>"; };
7D525040492AFBC121FD4CC6 /* ar */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSnapshot.swift; sourceTree = "<group>"; };
8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripIntent.swift; sourceTree = "<group>"; };
849A85B6008760CCD7D9AE6A /* id */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
84CB5C3B6BD4E1DE1A8BD2B4 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
889DA33D98BC601268861D54 /* fr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
8A0289CB321AE008D8815A19 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWidgetsBundle.swift; sourceTree = "<group>"; };
8CBB0E5B9BE05CE82C8DA7F4 /* sv */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
8D361F7B14AE9F5ACE3BFE78 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
91F0DD852C0D6D4578449977 /* en */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
96CE3377B458EDD77B809F61 /* TREKWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWatchApp.swift; sourceTree = "<group>"; };
9A3FAA9DBC67D12389EEF0CF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
9D673E1909A5B750376ECB41 /* en */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
9DC696E1A1FD3A1A1695F9EA /* SharePage.js */ = {isa = PBXFileReference; includeInIndex = 1; path = SharePage.js; sourceTree = "<group>"; };
@@ -172,35 +262,56 @@
A479036A85683EFF2922D096 /* el */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
A5C182FC253CBEE8D5439536 /* ca */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
AA02F5CBC22E08415CC1C9BE /* pl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
AB5322D218E340690F499CA2 /* Pages.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Pages.swift; sourceTree = "<group>"; };
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodosContent.swift; sourceTree = "<group>"; };
B0450E66BFCF00CC4097330A /* he */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
B680CBF1268D2EE1982B165C /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
B7F33B7A5C27C126DF29DCA4 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
B8850ACF43C79E9D879ACC44 /* WatchStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStore.swift; sourceTree = "<group>"; };
B991C938B24A275550C15977 /* WatchStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStyle.swift; sourceTree = "<group>"; };
B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationViews.swift; sourceTree = "<group>"; };
BB2A15008B5664C48B639F94 /* ShareItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareItem.swift; sourceTree = "<group>"; };
BBB2E19CD540AB30B44BECCA /* TREKWidgets.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWidgets.entitlements; sourceTree = "<group>"; };
BC64330AC19625949E8DC05A /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
BE41D7212DCB0C78E00A8462 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
BF10C31D56A1C10C6BF62046 /* pt-BR */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
C24CDF14EBF4CF52CBF3DDD0 /* he */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
C333CDB6CAD048E3F9242222 /* ru */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
C6A6008115A85C9AAE181C5E /* sk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NextTripWidget.swift; sourceTree = "<group>"; };
C73847E8A007B21B8AD1DCD4 /* tr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
C996A3060A2D20A4B5C9C377 /* ko */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
C9B6357E3FBBA4D9CDD1C7A3 /* DemoMode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoMode.swift; sourceTree = "<group>"; };
CC077989456FB9DB5F4FF676 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
CD6763B965A87DDB6FCC2D05 /* de */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
CD83ADB6D661D6F7A64CDAB6 /* es */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
CE79157F1170A47CF7FC6FE0 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
CF4BA0BDDABFBDB24394025D /* TREKWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TREKWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
D0AC751335E0136865E08990 /* WorldGrid.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorldGrid.swift; sourceTree = "<group>"; };
D84D92C18CC022646692CB9B /* sv */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
D863C8994C9DAA79D236401C /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = "<group>"; };
DC0C9AB4D77D77A7BC2BA85B /* ja */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
DD341749E47B7DF89BA9A46D /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
DE0046E6067251CB0D61EC7A /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripContent.swift; sourceTree = "<group>"; };
DE8B592FE52EC6FFB589751E /* fr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
E2CFBDBCC474A7A72E053A8E /* uk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; };
E47D5FF1A217D5F77B3C7548 /* uk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
E4AA747C2B20D0CDA0A9538A /* hu */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
E5800E4ECECED54A47CDABAA /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
E6831E849332626BE8D54CEF /* ShareView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = "<group>"; };
EAFBDD97043B34E4B64FDA86 /* pt-BR */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
EC8D1292C9FC6430C059960A /* nl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
ECC80E1314B31ED2D2347411 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
ED561D7B7219D23A99FAEB11 /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
EE30A6DF943052C6ADE92D63 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
F406A4A31BD875A880A7CA14 /* Details.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Details.swift; sourceTree = "<group>"; };
F410423B093EBD2DD11C0FFC /* ko */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
F4D88ABD8284E0A39E7586BE /* TREKWatchWidgets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWatchWidgets.swift; sourceTree = "<group>"; };
FB2D9CFF5AC22AEB8CA3D7BD /* cs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -223,6 +334,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
622430061CE31239E1D64FB8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
69112BCF6652B92DFA2AE6BB /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
C9AA4ADC0D397C620A35B6A3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -259,6 +384,9 @@
74C1FC6022E562372A71EEC9 /* NotificationService */,
FB9226BC82835538BCE3DC0A /* TREKWidgets */,
DFD2010504102AC0E682D00F /* ShareExtension */,
A5731FDC6B43DF41413C28A6 /* TREKWatch */,
83BCA52D4B145E66DC1B0111 /* TREKWatchKit */,
F3ECD357918C4A98B023F65F /* TREKWatchWidgets */,
);
sourceTree = "<group>";
};
@@ -269,6 +397,8 @@
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */,
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */,
694631A1018DCE5678F29930 /* ShareExtension.appex */,
CF4BA0BDDABFBDB24394025D /* TREKWatch.app */,
0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -289,6 +419,7 @@
7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */,
);
path = App;
sourceTree = "<group>";
@@ -302,6 +433,7 @@
6E901288FC198E4D33BE754F /* SwiftUI.framework */,
192E69C7E66646BC39566925 /* ActivityKit.framework */,
E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */,
DC1BBE9AA7035B32D3653122 /* watchOS */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -317,6 +449,44 @@
path = NotificationService;
sourceTree = "<group>";
};
83BCA52D4B145E66DC1B0111 /* TREKWatchKit */ = {
isa = PBXGroup;
children = (
B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */,
10FB55D221F4BA18795F33B5 /* WatchSample.swift */,
842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */,
B8850ACF43C79E9D879ACC44 /* WatchStore.swift */,
B991C938B24A275550C15977 /* WatchStyle.swift */,
);
name = TREKWatchKit;
path = TREKWatchKit;
sourceTree = "<group>";
};
A5731FDC6B43DF41413C28A6 /* TREKWatch */ = {
isa = PBXGroup;
children = (
3C3D5E2482AC7D20D2054623 /* Assets.xcassets */,
C9B6357E3FBBA4D9CDD1C7A3 /* DemoMode.swift */,
F406A4A31BD875A880A7CA14 /* Details.swift */,
787A9D6B7D9EB7FED319C1B5 /* FacesView.swift */,
D863C8994C9DAA79D236401C /* Info.plist */,
AB5322D218E340690F499CA2 /* Pages.swift */,
697E5726770333015DDD444D /* TREKWatch.entitlements */,
96CE3377B458EDD77B809F61 /* TREKWatchApp.swift */,
42EBD981456D3493C3947441 /* WatchModel.swift */,
3A6054202DCC03D83C2C9602 /* Localizable.strings */,
);
name = TREKWatch;
path = TREKWatch;
sourceTree = "<group>";
};
DC1BBE9AA7035B32D3653122 /* watchOS */ = {
isa = PBXGroup;
children = (
);
name = watchOS;
sourceTree = "<group>";
};
DFD2010504102AC0E682D00F /* ShareExtension */ = {
isa = PBXGroup;
children = (
@@ -333,6 +503,17 @@
path = ShareExtension;
sourceTree = "<group>";
};
F3ECD357918C4A98B023F65F /* TREKWatchWidgets */ = {
isa = PBXGroup;
children = (
2D75538DBBAA6B2366CD8B69 /* Info.plist */,
37FA9D6C0C247EC699CF326C /* TREKWatchWidgets.entitlements */,
F4D88ABD8284E0A39E7586BE /* TREKWatchWidgets.swift */,
);
name = TREKWatchWidgets;
path = TREKWatchWidgets;
sourceTree = "<group>";
};
FB9226BC82835538BCE3DC0A /* TREKWidgets */ = {
isa = PBXGroup;
children = (
@@ -388,6 +569,7 @@
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */,
986D3C6100A1AB6BE603CF1C /* Embed Watch Content */,
);
buildRules = (
);
@@ -395,6 +577,7 @@
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */,
4CD7F5AF02CB806E4189489A /* PBXTargetDependency */,
855AE76937B942D62DFFD7E2 /* PBXTargetDependency */,
DC6A321F5C3C7D5EF0973677 /* PBXTargetDependency */,
);
name = App;
packageProductDependencies = (
@@ -404,6 +587,42 @@
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
59A840D13C3074A09FCCC7CB /* TREKWatch */ = {
isa = PBXNativeTarget;
buildConfigurationList = DF9244A5B1D6A7560CB92D4E /* Build configuration list for PBXNativeTarget "TREKWatch" */;
buildPhases = (
B3023E0FD7245E6465981AC5 /* Sources */,
622430061CE31239E1D64FB8 /* Frameworks */,
E46DCC12600D7B9D1381FBCE /* Resources */,
9545786868FF757328C69E1E /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
5D0E9608BF309D8A27F5C4B6 /* PBXTargetDependency */,
);
name = TREKWatch;
productName = TREKWatch;
productReference = CF4BA0BDDABFBDB24394025D /* TREKWatch.app */;
productType = "com.apple.product-type.application";
};
5B5EA8FB8F58C6BFA3B77661 /* TREKWatchWidgets */ = {
isa = PBXNativeTarget;
buildConfigurationList = C24DF86369B92FC86F735AEE /* Build configuration list for PBXNativeTarget "TREKWatchWidgets" */;
buildPhases = (
068381E1E877C97A3390DE57 /* Sources */,
69112BCF6652B92DFA2AE6BB /* Frameworks */,
F36545081C7270E279639BF4 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = TREKWatchWidgets;
productName = TREKWatchWidgets;
productReference = 0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */;
productType = "com.apple.product-type.app-extension";
};
9480DFBC2C2A112973DA468A /* NotificationService */ = {
isa = PBXNativeTarget;
buildConfigurationList = 4E3E5FA93A15425251E22D48 /* Build configuration list for PBXNativeTarget "NotificationService" */;
@@ -506,6 +725,8 @@
9480DFBC2C2A112973DA468A /* NotificationService */,
A3BE5653156025C97CA62C68 /* TREKWidgets */,
01A63CD6CFBC1463CAA8FFF0 /* ShareExtension */,
59A840D13C3074A09FCCC7CB /* TREKWatch */,
5B5EA8FB8F58C6BFA3B77661 /* TREKWatchWidgets */,
);
};
/* End PBXProject section */
@@ -540,6 +761,15 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
E46DCC12600D7B9D1381FBCE /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7060F9DB820849A45ECE0507 /* Assets.xcassets in Resources */,
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
EB430D4F409D7961AF5603C9 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -549,9 +779,32 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
F36545081C7270E279639BF4 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F30AA6CFFE04035E7D6AC546 /* Localizable.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
068381E1E877C97A3390DE57 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
228EA0E0270314581194EC3A /* TREKWatchWidgets.swift in Sources */,
C109B48C5A072AF9BE685BE4 /* ComplicationViews.swift in Sources */,
896952F864B35C647FF6910A /* WatchSample.swift in Sources */,
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */,
6EB76B1C6AB4AC78FF021994 /* WatchStore.swift in Sources */,
661AEED37F1F278677F4F306 /* WatchStyle.swift in Sources */,
0D801738BB09A41A62E9B57F /* TrekStyle.swift in Sources */,
DE14C398EAFA594CB32ABBF2 /* WidgetSnapshot.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
1EA0B85A2E70AC56F83C09C9 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -569,6 +822,7 @@
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */,
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -595,6 +849,26 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B3023E0FD7245E6465981AC5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
318BBC653A438E6D1D493F50 /* DemoMode.swift in Sources */,
634F4E63DC7C2A0027F0D118 /* Details.swift in Sources */,
239FC886F02275957A1D449A /* FacesView.swift in Sources */,
91B14997828BEF884E1239B4 /* Pages.swift in Sources */,
11B5B80E66AB03C92B0EADBD /* TREKWatchApp.swift in Sources */,
AAE5F2A3903F1E241C395BB8 /* WatchModel.swift in Sources */,
6680B5C572AA37E6E3DFC3A2 /* ComplicationViews.swift in Sources */,
1F8D58E1B4FD9BB84172527A /* WatchSample.swift in Sources */,
D0983EB4FB8015F96E232919 /* WatchSnapshot.swift in Sources */,
1091DAAC693DAE3E1CBB7D69 /* WatchStore.swift in Sources */,
C3959ACB55B495F7A86F89A9 /* WatchStyle.swift in Sources */,
2FB9B214E9DA5C7A25BE0EA1 /* TrekStyle.swift in Sources */,
F9947489F5B9323E519D3903 /* WidgetSnapshot.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
E0B13DA0CF9C1103058FE869 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -615,12 +889,24 @@
target = A3BE5653156025C97CA62C68 /* TREKWidgets */;
targetProxy = B8BBE7F7A64678D780A7F3D1 /* PBXContainerItemProxy */;
};
5D0E9608BF309D8A27F5C4B6 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = TREKWatchWidgets;
target = 5B5EA8FB8F58C6BFA3B77661 /* TREKWatchWidgets */;
targetProxy = EA68232794738AB11E866FA3 /* PBXContainerItemProxy */;
};
855AE76937B942D62DFFD7E2 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = ShareExtension;
target = 01A63CD6CFBC1463CAA8FFF0 /* ShareExtension */;
targetProxy = 64B91219C04CABCE204C7CCC /* PBXContainerItemProxy */;
};
DC6A321F5C3C7D5EF0973677 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = TREKWatch;
target = 59A840D13C3074A09FCCC7CB /* TREKWatch */;
targetProxy = 201AA9320B6C5DCBCC3429BF /* PBXContainerItemProxy */;
};
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = NotificationService;
@@ -665,6 +951,41 @@
name = Localizable.strings;
sourceTree = "<group>";
};
3A6054202DCC03D83C2C9602 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
772816394C94096090C2F8FE /* ar */,
00B7BB68930D45D35C9AD54A /* az */,
467929A8EB65D22F8FA91588 /* ca */,
FB2D9CFF5AC22AEB8CA3D7BD /* cs */,
57DF93CD1B88A7135481BF03 /* de */,
0CC4BCF06E7719280549FFF1 /* el */,
91F0DD852C0D6D4578449977 /* en */,
CD83ADB6D661D6F7A64CDAB6 /* es */,
DE0046E6067251CB0D61EC7A /* et */,
889DA33D98BC601268861D54 /* fr */,
C24CDF14EBF4CF52CBF3DDD0 /* he */,
E4AA747C2B20D0CDA0A9538A /* hu */,
849A85B6008760CCD7D9AE6A /* id */,
7887877A91D6C782111A8388 /* it */,
23AE662FD3762641A2047F72 /* ja */,
F410423B093EBD2DD11C0FFC /* ko */,
EC8D1292C9FC6430C059960A /* nl */,
230D9B2E5898FCEC3EC4FB44 /* pl */,
BF10C31D56A1C10C6BF62046 /* pt-BR */,
C333CDB6CAD048E3F9242222 /* ru */,
C6A6008115A85C9AAE181C5E /* sk */,
8CBB0E5B9BE05CE82C8DA7F4 /* sv */,
1335F1EEB49D744B27A00F31 /* th */,
C73847E8A007B21B8AD1DCD4 /* tr */,
E47D5FF1A217D5F77B3C7548 /* uk */,
07DF720DA5E63F842526A075 /* vi */,
07C00C53BA5641B009E40EF1 /* zh-Hans */,
2FD81D4601E9EB8744171B09 /* zh-Hant */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
@@ -719,6 +1040,33 @@
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
1F7D993B412B63E918648BA1 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = TREKWatch/TREKWatch.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWatch/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Release;
};
31CC9E868E52D06AD12E83D6 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -950,6 +1298,32 @@
};
name = Release;
};
57D289EF9468E358528F5FBB /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = TREKWatch/TREKWatch.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWatch/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Debug;
};
686FA31AEC6FE5BA3EF95E99 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -1001,6 +1375,30 @@
};
name = Release;
};
C04A1F63D9298FA52E6C727E /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = TREKWatchWidgets/TREKWatchWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWatchWidgets/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Debug;
};
C5621F5D75100AAEBB1BD22A /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -1027,6 +1425,31 @@
};
name = Release;
};
DF4816365A7D101A9EA8A0C7 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = TREKWatchWidgets/TREKWatchWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWatchWidgets/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -1075,6 +1498,24 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C24DF86369B92FC86F735AEE /* Build configuration list for PBXNativeTarget "TREKWatchWidgets" */ = {
isa = XCConfigurationList;
buildConfigurations = (
DF4816365A7D101A9EA8A0C7 /* Release */,
C04A1F63D9298FA52E6C727E /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
DF9244A5B1D6A7560CB92D4E /* Build configuration list for PBXNativeTarget "TREKWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1F7D993B412B63E918648BA1 /* Release */,
57D289EF9468E358528F5FBB /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
+21
View File
@@ -10,6 +10,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Taps on a TREK notification, and banners while the app is open.
UNUserNotificationCenter.current().delegate = self
// Early, so ticks the watch sent while the app was closed arrive with this launch.
TrekWatchLink.shared.activate()
return true
}
@@ -23,6 +25,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
TrekPush.shared.didFail(error)
}
/// A silent push from the TREK server: a list of a trip changed, so the Apple
/// Watch is brought up to date (TrekWatchRefresher). Nothing else arrives this way.
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
guard userInfo["trek"] as? String == "watch-sync" else { return completionHandler(.noData) }
TrekWatchRefresher.refresh(completion: completionHandler)
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner, .list, .sound])
@@ -58,6 +68,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
// MARK: - Hardware keyboard
/// The UI's shortcuts for the screen on show (TrekKeyCommands), in the app's
/// menu: the overlay a held Command key brings up lists them there.
override func buildMenu(with builder: UIMenuBuilder) {
super.buildMenu(with: builder)
guard builder.system == .main, !TrekKeyCommands.current.isEmpty else { return }
let parent: UIMenu.Identifier = builder.menu(for: .application) != nil ? .application : .root
builder.insertChild(TrekKeyCommands.menu(), atStartOfMenu: parent)
}
func application(_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
+6
View File
@@ -120,6 +120,10 @@
</array>
</dict>
</array>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -160,6 +164,8 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
+5
View File
@@ -110,6 +110,11 @@ enum TrekSharedFiles {
"data": data.base64EncodedString(),
]
if let tripId = info["tripId"] as? Int { file["tripId"] = tripId }
// A page or a link: where it came from, so the UI can keep it among the trip's links.
if let url = info["url"] as? String, url.hasPrefix("https://") || url.hasPrefix("http://") {
file["url"] = url
if let title = info["title"] as? String { file["title"] = title }
}
pending = file
onReceive?(file)
}
@@ -50,6 +50,87 @@ class TrekBridgeViewController: CAPBridgeViewController {
// the page follows the finger and an open sheet closes first.
webView?.allowsBackForwardNavigationGestures = false
}
/// A key command of the UI's (TrekKeyCommands) was pressed. The commands sit
/// in the main menu, which sends their action up the responder chain from
/// the web view, and this controller is the first stop that answers it.
@objc func trekKeyCommand(_ sender: UIKeyCommand) {
guard let id = sender.propertyList as? String else { return }
TrekKeyCommands.onPress?(id)
}
}
// MARK: - Hardware keyboard (TrekShell.setKeyCommands)
/// The UI's keyboard shortcuts as key commands in the app's main menu. There
/// they show in the overlay a held Command key brings up and, on iPadOS 26, in
/// the menu bar, under the names the UI gives them in its language. Each screen
/// replaces the list (mobile/keyboard/MKeyboardHost.tsx), and the menu is
/// rebuilt with it, so only what works here and now is ever offered.
enum TrekKeyCommands {
struct Command {
let id: String
let title: String
let input: String
let modifiers: UIKeyModifierFlags
init?(raw: JSObject) {
guard let id = raw["id"] as? String, let title = raw["title"] as? String,
let input = raw["input"] as? String, !input.isEmpty else { return nil }
var flags: UIKeyModifierFlags = []
for name in raw["modifiers"] as? [String] ?? [] {
switch name {
case "command": flags.insert(.command)
case "shift": flags.insert(.shift)
case "option": flags.insert(.alternate)
case "control": flags.insert(.control)
default: break
}
}
self.id = id
self.title = title
self.input = Command.key(input)
self.modifiers = flags
}
private static func key(_ name: String) -> String {
switch name {
case "left": return UIKeyCommand.inputLeftArrow
case "right": return UIKeyCommand.inputRightArrow
case "up": return UIKeyCommand.inputUpArrow
case "down": return UIKeyCommand.inputDownArrow
case "escape": return UIKeyCommand.inputEscape
default: return name
}
}
}
static let menuIdentifier = UIMenu.Identifier("com.liketrek.trek.shortcuts")
private(set) static var current: [Command] = []
static var onPress: ((String) -> Void)?
static func set(_ commands: [Command]) {
current = commands
UIMenuSystem.main.setNeedsRebuild()
}
/// The commands as one menu, without a title of its own: it joins the app's
/// menu, where iPadOS lists the shortcuts under the app's name.
static func menu() -> UIMenu {
let children = current.map { command -> UIKeyCommand in
let key = UIKeyCommand(
title: command.title,
action: #selector(TrekBridgeViewController.trekKeyCommand(_:)),
input: command.input,
modifierFlags: command.modifiers,
propertyList: command.id
)
// ⌘[ and ⌘, belong to the app here, not to the web view's editing.
key.wantsPriorityOverSystemBehavior = true
return key
}
return UIMenu(title: "", identifier: menuIdentifier, options: .displayInline, children: children)
}
}
/// The remembered server and the offline copy of its start page.
+55 -1
View File
@@ -46,6 +46,9 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setKeyCommands", returnType: CAPPluginReturnPromise),
]
/// What this binary offers the server's UI, raised with every native feature
@@ -53,7 +56,20 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
static let shellApi = 2
/// The native features by name, so the UI can turn each on by itself.
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "wallet"]
/// "watch" only where WatchConnectivity is (an iPhone, not an iPad), so the UI never
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
static var features: [String] {
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands"]
if canAddWalletPasses { names.append("wallet") }
if TrekWatchLink.isAvailable { names.append("watch") }
return names
}
/// Whether this device can add a pass to Apple Wallet (an iPhone; not an iPad).
static var canAddWalletPasses: Bool {
PKPassLibrary.isPassLibraryAvailable() && PKAddPassesViewController.canAddPasses()
}
// MARK: - Shared booking documents
@@ -65,6 +81,23 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
DispatchQueue.main.async { self?.notifyListeners("pushOpened", data: ["url": url]) }
}
TrekStore.watchUpdates()
TrekWatchLink.shared.onAction = { [weak self] in self?.notifyListeners("watchAction", data: [:]) }
TrekKeyCommands.onPress = { [weak self] id in
self?.notifyListeners("keyCommand", data: ["id": id])
}
}
// MARK: - Hardware keyboard
/// The UI's keyboard shortcuts for the screen it shows (mobile/keyboard),
/// named in its language, as key commands in the app's menu.
@objc func setKeyCommands(_ call: CAPPluginCall) {
let raw = call.getArray("commands", JSObject.self) ?? []
let commands = raw.compactMap { TrekKeyCommands.Command(raw: $0) }
DispatchQueue.main.async {
TrekKeyCommands.set(commands)
call.resolve()
}
}
// MARK: - Widgets and the travel-day Live Activity
@@ -137,6 +170,27 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
call.resolve()
}
// MARK: - Apple Watch
/// Passes what the watch app shows (native/watch.ts builds it) on to the watch.
@objc func setWatchSnapshot(_ call: CAPPluginCall) {
guard TrekWatchLink.isAvailable else {
call.resolve()
return
}
guard let json = call.getString("json"), let data = json.data(using: .utf8) else {
call.reject("json is required")
return
}
TrekWatchLink.shared.publish(data)
call.resolve()
}
/// The packing items and to-dos ticked on the watch since the last call, oldest first.
@objc func takeWatchActions(_ call: CAPPluginCall) {
call.resolve(["actions": TrekWatchLink.shared.takeActions()])
}
// MARK: - Push
/// Asks for permission, registers with APNs and answers with a Web Push
+399
View File
@@ -0,0 +1,399 @@
import CryptoKit
import Foundation
import UIKit
import WatchConnectivity
import WebKit
/// The phone's end of WatchConnectivity (feature "watch"). The UI hands over the
/// watch's snapshot (client/src/native/watch.ts through TrekShell.setWatchSnapshot);
/// this keeps the latest one, sends it as the application context, and as
/// complication user info while the day's budget for those lasts and something
/// a complication shows has changed. Ticks from the watch arrive as user info,
/// are queued here until the UI takes them (TrekShell.takeWatchActions) and
/// writes them through its offline-first path, and announced to it at once.
final class TrekWatchLink: NSObject, WCSessionDelegate {
static let shared = TrekWatchLink()
/// Told when a tick arrived, on the main queue.
var onAction: (() -> Void)?
private let queue = DispatchQueue(label: "com.liketrek.trek.watch")
private let snapshotKey = "trek.watch.snapshot"
private let complicationKey = "trek.watch.complicationDigest"
private let actionsKey = "trek.watch.actions"
/// The most WatchConnectivity carries in one application context, with room to spare.
private let maxBytes = 60_000
/// False on an iPad (and anything else without a paired watch): then this is
/// a no-op throughout, and the UI is not offered the feature at all.
static var isAvailable: Bool { WCSession.isSupported() }
func activate() {
guard Self.isAvailable else { return }
WCSession.default.delegate = self
WCSession.default.activate()
}
// MARK: Snapshots to the watch
func publish(_ json: Data) {
guard Self.isAvailable else { return }
let data = withSmallCover(json)
queue.async {
UserDefaults.standard.set(data, forKey: self.snapshotKey)
self.send(data)
}
}
private func send(_ data: Data) {
guard Self.isAvailable else { return }
let session = WCSession.default
guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return }
try? session.updateApplicationContext(["snapshot": data])
let digest = complicationDigest(data)
if session.isComplicationEnabled, session.remainingComplicationUserInfoTransfers > 0,
digest != UserDefaults.standard.string(forKey: complicationKey) {
session.transferCurrentComplicationUserInfo(["snapshot": data])
UserDefaults.standard.set(digest, forKey: complicationKey)
}
}
/// What the complications show of a snapshot: everything but when it was built and the applied ticks.
private func complicationDigest(_ data: Data) -> String {
guard var object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { return "" }
object.removeValue(forKey: "generatedAt")
object.removeValue(forKey: "applied")
object.removeValue(forKey: "packing")
object.removeValue(forKey: "todos")
let canonical = (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])) ?? Data()
return SHA256.hash(data: canonical).map { String(format: "%02x", $0) }.joined()
}
/// The trip's cover scaled down to a thumbnail the watch can draw, or dropped when it would not fit.
private func withSmallCover(_ json: Data) -> Data {
guard var object = (try? JSONSerialization.jsonObject(with: json)) as? [String: Any],
var trip = object["trip"] as? [String: Any], let cover = trip["cover"] as? String else { return json }
trip.removeValue(forKey: "cover")
if let data = Data(base64Encoded: cover), let image = UIImage(data: data) {
let width: CGFloat = 220
let size = CGSize(width: width, height: (width * image.size.height / max(image.size.width, 1)).rounded())
let format = UIGraphicsImageRendererFormat()
format.scale = 1
let small = UIGraphicsImageRenderer(size: size, format: format).image { _ in image.draw(in: CGRect(origin: .zero, size: size)) }
if let jpeg = small.jpegData(compressionQuality: 0.6), jpeg.count < 24_000 {
trip["cover"] = jpeg.base64EncodedString()
}
}
object["trip"] = trip
guard let result = try? JSONSerialization.data(withJSONObject: object) else { return json }
if result.count > maxBytes, trip["cover"] != nil {
trip.removeValue(forKey: "cover")
object["trip"] = trip
return (try? JSONSerialization.data(withJSONObject: object)) ?? json
}
return result
}
/// The snapshot the watch has now, as last published.
func storedSnapshot() -> Data? {
queue.sync { UserDefaults.standard.data(forKey: snapshotKey) }
}
// MARK: Ticks from the watch
/// The ticks queued so far, oldest first, and the queue emptied.
func takeActions() -> [[String: Any]] {
queue.sync {
let actions = storedActions()
UserDefaults.standard.removeObject(forKey: actionsKey)
return actions
}
}
private func storedActions() -> [[String: Any]] {
guard let data = UserDefaults.standard.data(forKey: actionsKey),
let list = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else { return [] }
return list
}
/// While the app is in front, its UI writes the tick, through its offline queue
/// as a tap would. In the background the WebView is asleep, so the tick is
/// written to the server from here at once (TrekWatchWriter), and the trip's
/// other devices hear of it over their WebSocket; only a write that fails is
/// queued for the UI, which then tries again once it runs.
private func receive(_ action: [String: Any], then done: @escaping () -> Void) {
DispatchQueue.main.async {
guard UIApplication.shared.applicationState != .active else {
self.enqueue(action)
done()
return
}
TrekWatchWriter.write(action) { written in
if !written { self.enqueue(action) }
done()
}
}
}
private func enqueue(_ action: [String: Any]) {
queue.async {
// A tick sent twice (the watch resends after a restart) replaces the earlier copy.
var actions = self.storedActions().filter { ($0["id"] as? String) != (action["id"] as? String) }
actions.append(action)
if let data = try? JSONSerialization.data(withJSONObject: Array(actions.suffix(200))) {
UserDefaults.standard.set(data, forKey: self.actionsKey)
}
DispatchQueue.main.async { self.onAction?() }
}
}
// MARK: WCSessionDelegate
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
guard activationState == .activated else { return }
queue.async {
if let data = UserDefaults.standard.data(forKey: self.snapshotKey) { self.send(data) }
}
}
func sessionDidBecomeInactive(_ session: WCSession) {}
/// After switching watches the session has to be activated again for the new one.
func sessionDidDeactivate(_ session: WCSession) {
WCSession.default.activate()
}
func sessionWatchStateDidChange(_ session: WCSession) {
queue.async {
if let data = UserDefaults.standard.data(forKey: self.snapshotKey) { self.send(data) }
}
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
if let action = userInfo["action"] as? [String: Any] { receive(action) {} }
}
/// The watch asks for the latest snapshot when it opens while the phone is in
/// reach, and sends its ticks this way while it is (WatchLink.send).
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
if let action = message["action"] as? [String: Any] {
receive(action) { replyHandler(["queued": true]) }
return
}
queue.async {
if message["request"] as? String == "snapshot", let data = UserDefaults.standard.data(forKey: self.snapshotKey) {
replyHandler(["snapshot": data])
} else {
replyHandler([:])
}
}
}
}
/// Writes a tick from the watch straight to the TREK server, for when the app is
/// in the background and its UI cannot. The same request the UI makes for a tap
/// (PUT /api/trips/:trip/packing|todo/:item with the box's new state), with the
/// session the WebView holds (its cookies) and the tick's id as the idempotency
/// key, so a tick that also reaches the server another way is taken once.
enum TrekWatchWriter {
private final class BackgroundTask {
var id = UIBackgroundTaskIdentifier.invalid
func end() {
guard id != .invalid else { return }
UIApplication.shared.endBackgroundTask(id)
id = .invalid
}
}
/// Called on the main queue, as is `completion`.
static func write(_ action: [String: Any], completion: @escaping (Bool) -> Void) {
guard let request = Self.request(for: action) else {
completion(false)
return
}
let task = BackgroundTask()
task.id = UIApplication.shared.beginBackgroundTask(withName: "trek.watch.tick") { task.end() }
let finish: (Bool) -> Void = { written in
DispatchQueue.main.async {
completion(written)
task.end()
}
}
perform(request) { _, ok in finish(ok) }
}
/// Sends a request with the WebView's session (its cookies for the server).
/// Called on the main queue; `completion` runs on a background queue.
static func perform(_ request: URLRequest, completion: @escaping (Data?, Bool) -> Void) {
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
guard let host = request.url?.host else { return completion(nil, false) }
let session = cookies.filter { cookie in
let domain = cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain
return host == domain || host.hasSuffix("." + domain)
}
guard !session.isEmpty else { return completion(nil, false) }
var signed = request
for (field, value) in HTTPCookie.requestHeaderFields(with: session) {
signed.setValue(value, forHTTPHeaderField: field)
}
URLSession.shared.dataTask(with: signed) { data, response, error in
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
completion(data, error == nil && (200..<300).contains(status))
}.resume()
}
}
/// The server's address for an API path, or nil without a server.
static func url(_ path: [String]) -> URL? {
guard let server = TrekServerStore.url, let base = URL(string: server),
base.scheme == "https" || base.scheme == "http" else { return nil }
return path.reduce(base.appendingPathComponent("api")) { $0.appendingPathComponent($1) }
}
/// The write for a tick as the watch sends it, or nil for anything else.
static func request(for action: [String: Any]) -> URLRequest? {
guard let server = TrekServerStore.url, let base = URL(string: server),
base.scheme == "https" || base.scheme == "http",
let id = action["id"] as? String, !id.isEmpty, id.count <= 64,
let tripId = action["tripId"] as? Int, let itemId = action["itemId"] as? Int,
let list = action["list"] as? String, list == "packing" || list == "todo",
let done = action["done"] as? Bool,
let body = try? JSONSerialization.data(withJSONObject: ["checked": done]) else { return nil }
let url = base.appendingPathComponent("api").appendingPathComponent("trips")
.appendingPathComponent(String(tripId)).appendingPathComponent(list).appendingPathComponent(String(itemId))
var request = URLRequest(url: url, timeoutInterval: 10)
request.httpMethod = "PUT"
request.httpShouldHandleCookies = false
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("watch-\(id)", forHTTPHeaderField: "X-Idempotency-Key")
request.httpBody = body
return request
}
}
/// Brings the watch's packing list and to-dos up to date while the app is in the
/// background, woken by the server's silent push (WatchSyncPushService in TREK):
/// reads both lists of the trip the watch shows from the server and puts them
/// into the snapshot the watch has, built the way the UI builds them
/// (watchPacking and watchTodos in client/src/native/watch.ts). Everything else
/// in the snapshot stays as the UI last made it. In front, the UI does this
/// itself as soon as the lists change, so nothing is done here then.
enum TrekWatchRefresher {
private static let maxName = 80
private static let maxGroup = 40
/// Below what WatchConnectivity carries in one application context.
private static let maxBytes = 55_000
static func refresh(completion: @escaping (UIBackgroundFetchResult) -> Void) {
guard TrekWatchLink.isAvailable, UIApplication.shared.applicationState != .active,
let stored = TrekWatchLink.shared.storedSnapshot(),
var snapshot = (try? JSONSerialization.jsonObject(with: stored)) as? [String: Any],
let trip = snapshot["trip"] as? [String: Any], let tripId = trip["id"] as? Int,
let packingURL = TrekWatchWriter.url(["trips", String(tripId), "packing"]),
let todoURL = TrekWatchWriter.url(["trips", String(tripId), "todo"]) else {
return completion(.noData)
}
let group = DispatchGroup()
var packing: [[String: Any]]?
var todos: [[String: Any]]?
group.enter()
items(at: packingURL) { list in
packing = list
group.leave()
}
group.enter()
items(at: todoURL) { list in
todos = list
group.leave()
}
group.notify(queue: .main) {
guard let packing, let todos else { return completion(.failed) }
snapshot["packing"] = packingList(packing)
snapshot["todos"] = todoList(todos)
snapshot["generatedAt"] = ISO8601DateFormatter().string(from: Date())
guard let data = fitted(snapshot) else { return completion(.failed) }
TrekWatchLink.shared.publish(data)
completion(.newData)
}
}
/// The `items` of a list endpoint, on the main queue; nil when it could not be read.
private static func items(at url: URL, then completion: @escaping ([[String: Any]]?) -> Void) {
var request = URLRequest(url: url, timeoutInterval: 10)
request.httpShouldHandleCookies = false
request.setValue("application/json", forHTTPHeaderField: "Accept")
TrekWatchWriter.perform(request) { data, ok in
var list: [[String: Any]]?
if ok, let data, let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
list = body["items"] as? [[String: Any]]
}
DispatchQueue.main.async { completion(list) }
}
}
private static func done(_ item: [String: Any]) -> Bool {
(item["checked"] as? Bool) ?? ((item["checked"] as? Int).map { $0 != 0 } ?? false)
}
private static func trimmed(_ value: Any?, _ max: Int) -> String? {
guard let text = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil }
return String(text.prefix(max))
}
private static func entry(_ item: [String: Any]) -> [String: Any]? {
guard let id = item["id"] as? Int, let name = item["name"] as? String else { return nil }
var out: [String: Any] = ["id": id, "name": String(name.prefix(maxName)), "done": done(item)]
if let group = trimmed(item["category"], maxGroup) { out["group"] = group }
return out
}
/// Unpacked first, then packed, each in the list's own order.
static func packingList(_ items: [[String: Any]]) -> Any {
guard !items.isEmpty else { return NSNull() }
let sorted = items.sorted {
let (a, b) = (done($0) ? 1 : 0, done($1) ? 1 : 0)
return a != b ? a < b : (($0["sort_order"] as? Int) ?? 0) < (($1["sort_order"] as? Int) ?? 0)
}
let packed = items.filter { done($0) }.count
return ["items": sorted.compactMap(entry), "packed": packed, "total": items.count]
}
/// Open ones first by due date (undated last) and priority, then the done ones.
static func todoList(_ items: [[String: Any]]) -> Any {
guard !items.isEmpty else { return NSNull() }
let due = { (item: [String: Any]) -> String? in trimmed(item["due_date"], 10) }
let sorted = items.sorted { a, b in
if done(a) != done(b) { return !done(a) }
if (due(a) == nil) != (due(b) == nil) { return due(a) != nil }
if let da = due(a), let db = due(b), da != db { return da < db }
let (pa, pb) = ((a["priority"] as? Int) ?? 0, (b["priority"] as? Int) ?? 0)
if pa != pb { return pa > pb }
return ((a["sort_order"] as? Int) ?? 0) < ((b["sort_order"] as? Int) ?? 0)
}
let rows = sorted.compactMap { item -> [String: Any]? in
guard var row = entry(item) else { return nil }
if let date = due(item) { row["due"] = date }
if let priority = item["priority"] as? Int, priority > 0 { row["priority"] = min(3, priority) }
return row
}
return ["items": rows, "open": items.filter { !done($0) }.count, "total": items.count]
}
/// The snapshot as JSON within the size the watch takes: done items go first, as on the UI's side.
private static func fitted(_ snapshot: [String: Any]) -> Data? {
var object = snapshot
// A list that is now empty leaves the snapshot, as the UI leaves it out.
for key in ["packing", "todos"] where object[key] is NSNull { object.removeValue(forKey: key) }
guard var data = try? JSONSerialization.data(withJSONObject: object) else { return nil }
if data.count > maxBytes {
for key in ["packing", "todos"] {
guard var list = object[key] as? [String: Any], let items = list["items"] as? [[String: Any]] else { continue }
list["items"] = items.filter { ($0["done"] as? Bool) != true }
object[key] = list
}
data = (try? JSONSerialization.data(withJSONObject: object)) ?? data
}
return data
}
}
@@ -54,6 +54,10 @@ enum ShareHandoff {
static func store(_ item: ShareItem, tripId: Int?) -> Bool {
var meta: [String: Any] = ["name": item.name, "mimeType": item.mimeType]
if let tripId { meta["tripId"] = tripId }
if let url = item.url {
meta["url"] = url
meta["title"] = item.title
}
guard let metaData = try? JSONSerialization.data(withJSONObject: meta) else { return false }
return write(dataAccount, item.data) && write(metaAccount, metaData)
}
+35 -10
View File
@@ -32,6 +32,9 @@ struct ShareItem {
var title: String
/// A host, for a page.
var source: String?
/// The web address it came from, when it is a page or a link: the app offers to
/// keep it among the trip's links as well as to read a booking out of it.
var url: String? = nil
var sizeLabel: String {
ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
@@ -88,6 +91,10 @@ enum ShareLoader {
let text = (item as? String) ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) } ?? ""
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw ShareLoadError.unsupported }
// A text that is just a link (what many apps share) is that link.
if !trimmed.contains(where: \.isWhitespace), let url = URL(string: trimmed), webAddress(url) != nil {
return try await page(at: url)
}
let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed
return try checked(ShareItem(kind: .text, name: "shared-text.txt", mimeType: "text/plain",
data: Data(trimmed.utf8), title: String(firstLine.prefix(80)), source: nil))
@@ -133,21 +140,39 @@ enum ShareLoader {
let url = (results["url"] as? String).flatMap(URL.init(string:))
let title = (results["title"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? url?.host ?? "Page"
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
data: Data(html.utf8), title: title, source: url?.host))
data: Data(html.utf8), title: title, source: url?.host,
url: url.flatMap(webAddress)))
}
/// A link shared from an app other than Safari: the page as the server sends it.
/// A link shared from an app other than Safari: the page as the server sends it,
/// or the bare link when the page cannot be read (a sign-in wall, an app-only
/// page, no network), which the app can still keep among the trip's links.
private static func page(at url: URL) async throws -> ShareItem {
guard url.scheme == "https" || url.scheme == "http" else { throw ShareLoadError.unsupported }
guard let address = webAddress(url) else { throw ShareLoadError.unsupported }
var request = URLRequest(url: url, timeoutInterval: 10)
request.setValue("text/html", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
(http.mimeType ?? "").contains("html") else { throw ShareLoadError.unsupported }
let html = String(decoding: data, as: UTF8.self)
let title = pageTitle(html) ?? url.host ?? "Page"
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
data: data, title: title, source: url.host))
if let fetched = try? await URLSession.shared.data(for: request),
let http = fetched.1 as? HTTPURLResponse, (200..<300).contains(http.statusCode),
(http.mimeType ?? "").contains("html"), !fetched.0.isEmpty, fetched.0.count <= maxBytes {
let data = fetched.0
let html = String(decoding: data, as: UTF8.self)
let title = pageTitle(html) ?? url.host ?? "Page"
return ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
data: data, title: title, source: url.host, url: address)
}
return link(url, address)
}
/// The link alone, as a text/uri-list the app recognises as nothing to read a booking from.
private static func link(_ url: URL, _ address: String) -> ShareItem {
ShareItem(kind: .page, name: "link.url", mimeType: "text/uri-list", data: Data(address.utf8),
title: url.host ?? address, source: url.host, url: address)
}
/// An http(s) address, the only kind a trip's links take.
private static func webAddress(_ url: URL) -> String? {
guard url.scheme == "https" || url.scheme == "http", url.host != nil else { return nil }
return url.absoluteString
}
private static func fromFile(_ url: URL) throws -> ShareItem {
+22 -11
View File
@@ -6,16 +6,24 @@ import SwiftUI
struct ShareView: View {
@ObservedObject var model: ShareModel
@Environment(\.colorScheme) private var scheme
@Environment(\.horizontalSizeClass) private var widthClass
@State private var shown = false
/// An iPad window of regular width: the card stands centred over the dimmed
/// app, as a form does there, instead of rising from the bottom edge. A
/// narrow Split View or Slide Over window keeps the iPhone's bottom card.
private var centred: Bool { widthClass == .regular }
var body: some View {
ZStack(alignment: .bottom) {
Color.black.opacity(shown ? 0.28 : 0)
ZStack(alignment: centred ? .center : .bottom) {
Color.black.opacity(shown ? (centred ? 0.36 : 0.28) : 0)
.ignoresSafeArea()
.onTapGesture { dismiss() }
card
.offset(y: shown ? 0 : 520)
.offset(y: shown ? 0 : (centred ? 40 : 520))
.opacity(centred && !shown ? 0 : 1)
.padding(.vertical, centred ? 24 : 0)
}
.onAppear {
withAnimation(.spring(response: 0.5, dampingFraction: 0.86)) { shown = true }
@@ -31,14 +39,17 @@ struct ShareView: View {
private var card: some View {
VStack(spacing: 0) {
Capsule()
.fill(ShareStyle.faint(scheme).opacity(0.5))
.frame(width: 38, height: 5)
.padding(.top, 8)
// The grabber belongs to a card that rises from the bottom edge.
if !centred {
Capsule()
.fill(ShareStyle.faint(scheme).opacity(0.5))
.frame(width: 38, height: 5)
.padding(.top, 8)
}
header
.padding(.horizontal, 20)
.padding(.top, 14)
.padding(.top, centred ? 22 : 14)
Group {
switch model.phase {
@@ -50,7 +61,7 @@ struct ShareView: View {
}
.padding(.top, 16)
}
.padding(.bottom, 12)
.padding(.bottom, centred ? 20 : 12)
.frame(maxWidth: 560)
.background(
RoundedRectangle(cornerRadius: 32, style: .continuous)
@@ -60,9 +71,9 @@ struct ShareView: View {
.strokeBorder(ShareStyle.edge(scheme), lineWidth: 1)
)
.shadow(color: .black.opacity(0.25), radius: 30, y: 10)
.ignoresSafeArea(edges: .bottom)
.ignoresSafeArea(edges: centred ? [] : .bottom)
)
.padding(.horizontal, 6)
.padding(.horizontal, centred ? 24 : 6)
}
private var header: some View {
@@ -9,6 +9,24 @@ import UIKit
final class ShareViewController: UIViewController {
private let model = ShareModel()
// On an iPad the system would put the extension in a form sheet, a box in the
// middle of the screen with this sheet's own dimming and card inside it. Over
// the full screen instead, the card stands centred over the app that shared,
// the way an iPad presents a small form.
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
presentOverFullScreenOnPad()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
presentOverFullScreenOnPad()
}
private func presentOverFullScreenOnPad() {
if UIDevice.current.userInterfaceIdiom == .pad { modalPresentationStyle = .overFullScreen }
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
@@ -0,0 +1,15 @@
{
"colors": [
{
"color": {
"color-space": "srgb",
"components": { "alpha": "1.000", "blue": "1.000", "green": "1.000", "red": "1.000" }
},
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

@@ -0,0 +1,14 @@
{
"images": [
{
"filename": "AppIcon-1024.png",
"idiom": "universal",
"platform": "watchos",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
+72
View File
@@ -0,0 +1,72 @@
import Foundation
/// The screenshot mode: started with launch arguments, the app runs on the
/// sample trip at a fixed time and opens on one screen, so the CI can capture
/// every screen in every size and language from a simulator
/// (.github/workflows/native.yml, watch-screens). Nothing is sent or stored.
///
/// -TrekDemo <screen> today, day, place, booking, booking-detail, bookings,
/// trip, days, packing, todos, lists, countdown, empty,
/// nosync, faces
/// -TrekScenario <name> running, travelday or upcoming (default by screen)
/// -TrekReady <token> once the screen has drawn, writes Documents/ready-<token>
/// in the app's container, which the script waits for
/// -TrekNow <HH:mm> the time the screens think it is; without it the
/// real time, which the capture script moves to the
/// hour it wants with the app's time zone (TZ), so the
/// clock in the corner agrees with the screen
enum DemoMode {
enum Screen: String {
case today, day, place, booking, bookings, trip, days, packing, todos, lists, countdown, empty, nosync, faces
case bookingDetail = "booking-detail"
}
static var screen: Screen? {
value(after: "-TrekDemo").flatMap(Screen.init(rawValue:))
}
/// Tells the capture script the screen is up: a moment after it appeared,
/// so the navigation to it and the first layout have settled.
static func signalReady() {
guard let token = value(after: "-TrekReady"),
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
try? FileManager.default.createDirectory(at: documents, withIntermediateDirectories: true)
try? Data().write(to: documents.appendingPathComponent("ready-\(token)"))
}
}
@MainActor
static func start() {
guard let screen else { return }
let now = clock()
let scenario = value(after: "-TrekScenario").flatMap(WatchSample.Scenario.init(rawValue:)) ?? defaultScenario(screen)
switch screen {
case .nosync:
WatchModel.shared.startDemo(nil, now: now)
case .empty:
WatchModel.shared.startDemo(WatchSnapshot(generatedAt: ISO8601DateFormatter().string(from: now), trip: nil), now: now)
default:
WatchModel.shared.startDemo(WatchSample.snapshot(scenario, now: now), now: now)
}
}
private static func defaultScenario(_ screen: Screen) -> WatchSample.Scenario {
switch screen {
case .booking, .bookings, .bookingDetail: return .travelday
case .countdown, .packing, .lists: return .upcoming
default: return .running
}
}
private static func clock() -> Date {
guard let parts = value(after: "-TrekNow")?.split(separator: ":").compactMap({ Int($0) }), parts.count == 2 else { return Date() }
return TrekDate.at(parts[0] * 60 + parts[1], on: Date())
}
private static func value(after flag: String) -> String? {
let args = ProcessInfo.processInfo.arguments
guard let index = args.firstIndex(of: flag), index + 1 < args.count else { return nil }
return args[index + 1]
}
}
+538
View File
@@ -0,0 +1,538 @@
import MapKit
import SwiftUI
// MARK: - Days
/// Every day of the trip, today marked, each opening its plan.
struct DaysListView: View {
let snapshot: WatchSnapshot
@Environment(\.trekNow) private var now
var body: some View {
let today = snapshot.todayIndex(at: now)
List {
ForEach(Array(snapshot.days.enumerated()), id: \.offset) { index, day in
NavigationLink(value: Route.day(index)) {
HStack(spacing: 10) {
Text("\(day.number)")
.font(.system(.headline, design: .rounded).weight(.heavy).monospacedDigit())
.foregroundStyle(index == today ? Color.black : WatchLook.ink)
.frame(width: 30, height: 30)
.background(Circle().fill(index == today ? Color.white : Color.white.opacity(0.12)))
VStack(alignment: .leading, spacing: 1) {
Text(day.title ?? day.dateValue.map { WatchFormat.day($0) } ?? WatchFormat.dayOf(day.number, snapshot.days.count))
.font(.system(.body, design: .rounded).weight(.semibold))
.lineLimit(1)
Text(summary(day)).userText(summary(day))
.font(.system(.caption2, design: .rounded))
.foregroundStyle(WatchLook.muted)
.lineLimit(1)
}
}
.opacity(isPast(day) ? 0.55 : 1)
.accessibilityElement(children: .combine)
}
}
}
.navigationTitle(L("trip.days"))
.containerBackground(WatchLook.tripGradient(snapshot.trip), for: .navigation)
}
private func summary(_ day: WatchSnapshot.Day) -> String {
guard let first = day.stops.first else { return L("day.empty") }
return first.booking.map { "\(WatchLook.kindName($0)) \(first.name)" } ?? first.name
}
private func isPast(_ day: WatchSnapshot.Day) -> Bool {
guard let date = day.dateValue else { return false }
return TrekDate.daysBetween(now, date) < 0
}
}
/// One day's plan as a timeline: times on the leading edge, the line between
/// the stops filled up to now, the current stop lit. The crown scrolls it.
struct DayView: View {
let snapshot: WatchSnapshot
let index: Int
@Environment(\.trekNow) private var now
var body: some View {
let day = snapshot.days.indices.contains(index) ? snapshot.days[index] : nil
let isToday = day?.date == TrekDate.iso(now)
let nowMinutes = isToday ? TrekDate.minutesOfDay(now) : (isPastDay(day) ? 24 * 60 : -1)
ScrollView {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 1) {
Text(WatchFormat.dayOf(day?.number ?? index + 1, max(snapshot.tripLength, snapshot.days.count)))
.watchEyebrow(.white.opacity(0.85))
if let date = day?.dateValue {
Text(WatchFormat.day(date))
.font(.system(.headline, design: .rounded))
}
}
.padding(.bottom, 8)
let stops = day?.stops ?? []
if stops.isEmpty {
Text(L("day.empty"))
.font(.footnote)
.foregroundStyle(WatchLook.muted)
}
ForEach(Array(stops.enumerated()), id: \.offset) { offset, stop in
NavigationLink(value: Route.stop(day: index, stop: offset)) {
TimelineRow(
stop: stop,
hour12: snapshot.hour12,
state: state(stop, next: offset + 1 < stops.count ? stops[offset + 1] : nil, nowMinutes: nowMinutes),
isLast: offset == stops.count - 1
)
}
.buttonStyle(.plain)
}
}
}
.navigationTitle(day?.dateValue.map { WatchFormat.weekday($0) } ?? L("trip.days"))
.containerBackground(WatchLook.tripGradient(snapshot.trip), for: .navigation)
}
private func isPastDay(_ day: WatchSnapshot.Day?) -> Bool {
guard let date = day?.dateValue else { return false }
return TrekDate.daysBetween(now, date) < 0
}
private func state(_ stop: WatchSnapshot.Stop, next: WatchSnapshot.Stop?, nowMinutes: Int) -> TimelineRow.State {
guard let start = stop.minutes else { return .ahead }
let end = stop.endMinutes ?? next?.minutes ?? start + 60
if nowMinutes >= end { return .past }
if nowMinutes >= start { return .now }
return .ahead
}
}
struct TimelineRow: View {
enum State { case past, now, ahead }
let stop: WatchSnapshot.Stop
let hour12: Bool
let state: State
let isLast: Bool
var body: some View {
HStack(alignment: .top, spacing: 8) {
Text(stop.minutes.map { WatchFormat.time(minutes: $0, hour12: hour12) } ?? "")
.font(.system(.caption2, design: .rounded).weight(.semibold).monospacedDigit())
.foregroundStyle(state == .now ? Trek.confirmed : WatchLook.muted)
.frame(width: (hour12 ? 50 : 36) - (WatchScreen.compact ? 4 : 0), alignment: .trailing)
.padding(.top, 5)
.lineLimit(1)
.minimumScaleFactor(0.7)
VStack(spacing: 0) {
StopGlyph(stop: stop, size: WatchScreen.compact ? 18 : 22, dimmed: state == .past)
.overlay(Circle().stroke(Trek.confirmed, lineWidth: state == .now ? 2 : 0).padding(-3))
if !isLast {
Rectangle()
.fill(state == .ahead ? Color.white.opacity(0.18) : WatchLook.stopColor(stop).opacity(0.7))
.frame(width: 2)
.frame(minHeight: 14, maxHeight: .infinity)
}
}
VStack(alignment: .leading, spacing: 1) {
Text(stop.name).userText(stop.name)
.font(.system(.footnote, design: .rounded).weight(state == .now ? .bold : .semibold))
.foregroundStyle(state == .past ? WatchLook.muted : WatchLook.ink)
.lineLimit(2)
// The street, whole: the first part of the address, on up to two lines.
if let detail = stop.booking.map(WatchLook.kindName) ?? stop.address?.split(separator: ",").first.map(String.init) {
Text(detail).userText(detail)
.font(.system(.caption2, design: .rounded))
.foregroundStyle(WatchLook.faint)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.top, 3)
.padding(.bottom, 10)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
}
}
// MARK: - A stop
/// A stop of the plan: where it is on the map, when, the note, and Directions into Apple Maps.
struct StopDetailView: View {
let snapshot: WatchSnapshot
let day: Int
let stop: Int
var body: some View {
let item = snapshot.days.indices.contains(day) && snapshot.days[day].stops.indices.contains(stop) ? snapshot.days[day].stops[stop] : nil
ScrollView {
if let item {
VStack(alignment: .leading, spacing: 8) {
if let lat = item.lat, let lng = item.lng {
MiniMap(name: item.name, lat: lat, lng: lng, color: WatchLook.stopColor(item))
}
HStack(alignment: .top, spacing: 8) {
StopGlyph(stop: item, size: 26)
VStack(alignment: .leading, spacing: 1) {
Text(item.name).userText(item.name)
.font(.system(.headline, design: .rounded))
.lineLimit(3)
if let start = item.minutes {
let from = WatchFormat.time(minutes: start, hour12: snapshot.hour12)
Text(item.endMinutes.map { "\(from) – \(WatchFormat.time(minutes: $0, hour12: snapshot.hour12))" } ?? from)
.font(.system(.footnote, design: .rounded).weight(.semibold).monospacedDigit())
.foregroundStyle(WatchLook.muted)
}
}
}
if let address = item.address {
Text(address).userText(address)
.font(.footnote)
.foregroundStyle(WatchLook.muted)
}
if let note = item.note {
Text(note).userText(note)
.font(.footnote)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.watchCard(cornerRadius: 12)
}
if let lat = item.lat, let lng = item.lng {
DirectionsButton(name: item.name, lat: lat, lng: lng)
}
}
}
}
.navigationTitle(item?.booking.map(WatchLook.kindName) ?? L("place.title"))
.containerBackground(WatchLook.tint(item.map(WatchLook.stopColor) ?? Trek.info), for: .navigation)
}
}
/// A small map of one spot, with its pin.
struct MiniMap: View {
let name: String
let lat: Double
let lng: Double
let color: Color
var body: some View {
let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lng)
// From above, without street names or points of interest, so the stop's
// own pin is what stands out; Apple's attribution stays in its corner.
Map(initialPosition: .region(MKCoordinateRegion(center: coordinate, latitudinalMeters: 1100, longitudinalMeters: 1100)), interactionModes: []) {
Annotation(name, coordinate: coordinate, anchor: .center) {
Circle()
.fill(color.gradient)
.frame(width: 22, height: 22)
.overlay(Circle().stroke(.white, lineWidth: 2.5))
.shadow(color: .black.opacity(0.4), radius: 3, y: 1)
}
.annotationTitles(.hidden)
}
.mapStyle(.standard(elevation: .flat, emphasis: .muted, pointsOfInterest: .excludingAll, showsTraffic: false))
.frame(height: 118)
// A soft fade over the top edge only, where labels would be cut; the
// attribution sits at the bottom and stays as MapKit draws it.
.overlay(alignment: .top) {
LinearGradient(colors: [.black.opacity(0.55), .clear], startPoint: .top, endPoint: .bottom)
.frame(height: 26)
.allowsHitTesting(false)
}
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.accessibilityHidden(true)
}
}
/// Opens Apple Maps with directions to a spot.
struct DirectionsButton: View {
let name: String
let lat: Double
let lng: Double
var body: some View {
Button {
let item = MKMapItem(placemark: MKPlacemark(coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lng)))
item.name = name
_ = item.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault])
} label: {
Label(L("place.directions"), systemImage: "arrow.triangle.turn.up.right.diamond.fill")
.font(.system(.body, design: .rounded).weight(.semibold))
.frame(maxWidth: .infinity)
}
.tint(Trek.info)
.buttonStyle(.borderedProminent)
}
}
// MARK: - Bookings
struct BookingDetailView: View {
let snapshot: WatchSnapshot
let id: Int
var body: some View {
let booking = snapshot.bookings.first { $0.id == id }
ScrollView {
if let booking {
VStack(alignment: .leading, spacing: 8) {
BookingSummary(booking: booking, hour12: snapshot.hour12)
if let from = booking.from, let to = booking.to {
LegRow(label: L("booking.departs"), point: from, date: booking.date, time: booking.time, hour12: snapshot.hour12)
LegRow(label: L("booking.arrives"), point: to, date: booking.endDate, time: booking.endTime, hour12: snapshot.hour12)
} else if booking.kind == "hotel", let checkOut = TrekDate.minutes(booking.checkOut ?? booking.endTime) {
FactTile(label: L("booking.checkOut"), value: [TrekDate.day(booking.endDate).map { WatchFormat.day($0) }, WatchFormat.time(minutes: checkOut, hour12: snapshot.hour12)].compactMap { $0 }.joined(separator: " "))
}
if let location = booking.location, booking.route == nil {
Text(location).userText(location)
.font(.footnote)
.foregroundStyle(WatchLook.muted)
}
if let target = booking.destination {
DirectionsButton(name: target.name, lat: target.lat, lng: target.lng)
}
}
}
}
.navigationTitle(booking.map { WatchLook.kindName($0.kind) } ?? "")
.containerBackground(WatchLook.tint(Trek.booking(booking?.kind ?? "other").color), for: .navigation)
}
}
/// One end of a ride: the place, its code, the day and time.
struct LegRow: View {
let label: String
let point: WatchSnapshot.Point
let date: String?
let time: String?
let hour12: Bool
var body: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 1) {
Text(label).watchEyebrow(WatchLook.faint)
Text(point.name).userText(point.name)
.font(.system(.footnote, design: .rounded).weight(.semibold))
.lineLimit(1)
}
Spacer(minLength: 4)
VStack(alignment: .trailing, spacing: 1) {
if let minutes = TrekDate.minutes(time) {
Text(WatchFormat.time(minutes: minutes, hour12: hour12))
.font(.system(.footnote, design: .rounded).weight(.bold).monospacedDigit())
}
if let day = TrekDate.day(date) {
Text(WatchFormat.day(day))
.font(.system(.caption2, design: .rounded))
.foregroundStyle(WatchLook.muted)
}
}
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.watchCard(cornerRadius: 12)
.accessibilityElement(children: .combine)
}
}
struct BookingsListView: View {
let snapshot: WatchSnapshot
@Environment(\.trekNow) private var now
var body: some View {
let bookings = snapshot.bookingsAhead(at: now)
List {
if bookings.isEmpty {
Text(L("booking.none")).foregroundStyle(WatchLook.muted)
}
ForEach(bookings) { booking in
NavigationLink(value: Route.booking(booking.id)) {
HStack(spacing: 8) {
KindBadge(kind: booking.kind, size: 24)
VStack(alignment: .leading, spacing: 1) {
Text(booking.route ?? booking.title).userText((booking.route ?? booking.title))
.font(.system(.footnote, design: .rounded).weight(.semibold))
.lineLimit(1)
Text(when(booking))
.font(.system(.caption2, design: .rounded))
.foregroundStyle(WatchLook.muted)
.lineLimit(1)
}
}
.accessibilityElement(children: .combine)
}
}
}
.navigationTitle(L("booking.all"))
.containerBackground(WatchLook.tripGradient(snapshot.trip), for: .navigation)
}
private func when(_ booking: WatchSnapshot.Booking) -> String {
guard let day = TrekDate.day(booking.date) else { return WatchLook.kindName(booking.kind) }
let days = TrekDate.daysBetween(now, day)
let label = days == 0 ? L("time.today") : days == 1 ? L("time.tomorrow") : WatchFormat.day(day)
guard let minutes = TrekDate.minutes(booking.time) else { return label }
return "\(label) \(WatchFormat.time(minutes: minutes, hour12: snapshot.hour12))"
}
}
// MARK: - The lists
/// The packing list: what is still to pack, by category, ticked off with a tap.
struct PackingListView: View {
private let model = WatchModel.shared
var body: some View {
let packing = model.display?.packing
let items = packing?.items ?? []
ChecklistView(
title: L("packing.title"),
color: WatchLook.packing,
open: items.filter { !$0.done },
done: items.filter(\.done),
progress: packing.map { ($0.packed, $0.total) },
doneTitle: L("packing.packedSection"),
allDone: L("packing.allPacked"),
grouped: true
) { model.toggle(.packing, item: $0) }
}
}
struct TodoListView: View {
private let model = WatchModel.shared
var body: some View {
let todos = model.display?.todos
let items = todos?.items ?? []
ChecklistView(
title: L("todos.title"),
color: WatchLook.todo,
open: items.filter { !$0.done },
done: items.filter(\.done),
progress: todos.map { ($0.total - $0.open, $0.total) },
doneTitle: L("todos.doneSection"),
allDone: L("todos.allDone"),
grouped: false
) { model.toggle(.todo, item: $0) }
}
}
/// A list to tick off: the open items (grouped by category when asked), then the done ones, dimmed.
struct ChecklistView: View {
let title: String
let color: Color
let open: [WatchSnapshot.Item]
let done: [WatchSnapshot.Item]
let progress: (done: Int, total: Int)?
let doneTitle: String
let allDone: String
let grouped: Bool
let toggle: (WatchSnapshot.Item) -> Void
@State private var ticks = 0
var body: some View {
List {
// The progress sits in the first section's header, so the list starts right under it.
if open.isEmpty {
Section {
Label(allDone, systemImage: "checkmark.circle.fill")
.foregroundStyle(color)
.font(.system(.body, design: .rounded).weight(.semibold))
} header: {
header(nil)
}
} else if grouped {
ForEach(Array(groups.enumerated()), id: \.element.name) { index, group in
Section {
ForEach(group.items) { row($0) }
} header: {
if index == 0 { header(group.name) } else { Text(group.name).watchEyebrow() }
}
}
} else {
Section {
ForEach(open) { row($0) }
} header: {
header(nil)
}
}
if !done.isEmpty {
Section {
ForEach(done) { row($0) }
} header: {
Text(doneTitle).watchEyebrow()
}
}
}
.listSectionSpacing(.compact)
.navigationTitle(title)
.sensoryFeedback(.success, trigger: ticks)
.containerBackground(WatchLook.tint(color), for: .navigation)
}
/// The bar with its count, and under it the first group's name when there is one.
@ViewBuilder
private func header(_ group: String?) -> some View {
VStack(alignment: .leading, spacing: 6) {
if let progress {
HStack(spacing: 8) {
ProgressView(value: Double(progress.done), total: Double(max(progress.total, 1)))
.tint(color)
Text("\(progress.done)/\(progress.total)")
.font(.system(.footnote, design: .rounded).weight(.bold).monospacedDigit())
.foregroundStyle(color)
.fixedSize()
}
.accessibilityElement(children: .combine)
}
if let group { Text(group).watchEyebrow() }
}
.textCase(nil)
}
private var groups: [(name: String, items: [WatchSnapshot.Item])] {
var order: [String] = []
var byName: [String: [WatchSnapshot.Item]] = [:]
for item in open {
let name = item.group ?? L("packing.other")
if byName[name] == nil { order.append(name) }
byName[name, default: []].append(item)
}
return order.map { ($0, byName[$0] ?? []) }
}
private func row(_ item: WatchSnapshot.Item) -> some View {
Button {
ticks += 1
withAnimation(.snappy) { toggle(item) }
} label: {
HStack(spacing: 8) {
Image(systemName: item.done ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundStyle(item.done ? color : WatchLook.faint)
.contentTransition(.symbolEffect(.replace))
VStack(alignment: .leading, spacing: 1) {
Text(item.name).userText(item.name)
.font(.system(.body, design: .rounded).weight(.medium))
.strikethrough(item.done, color: WatchLook.faint)
.foregroundStyle(item.done ? WatchLook.muted : WatchLook.ink)
.lineLimit(2)
if !item.done, let due = TrekDate.day(item.due) {
HStack(spacing: 3) {
if let priority = item.priority, let tint = Trek.priority(priority) {
Circle().fill(tint).frame(width: 5, height: 5)
}
Text(WatchFormat.day(due))
}
.font(.system(.caption2, design: .rounded))
.foregroundStyle(WatchLook.muted)
} else if !item.done, let priority = item.priority, let tint = Trek.priority(priority) {
Circle().fill(tint).frame(width: 5, height: 5)
}
}
}
}
.accessibilityValue(L(item.done ? "a11y.done" : "a11y.open"))
}
}
+59
View File
@@ -0,0 +1,59 @@
import SwiftUI
/// Screenshot mode only (-TrekDemo faces): every complication in every family
/// it ships in, at the sizes a watch face gives them on this watch, to review
/// them without setting up a face. Circular and corner ones three to a row,
/// rectangular and inline ones across the screen's width.
struct FacesView: View {
let snapshot: WatchSnapshot?
let now: Date
/// A circular complication's diameter, from the screen: 42 pt on 40 mm, 50 on 49 mm.
@State private var dial: CGFloat = 44
var body: some View {
let state = ComplicationState(snapshot, now: now)
ScrollView {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
round { NextStopComplication(state: state, family: .circular, now: now) }
round { CountdownComplication(state: state, family: .circular, now: now) }
round { TripDayComplication(state: state, family: .circular) }
}
Divider().opacity(0.3)
HStack(spacing: 8) {
round { NextStopComplication(state: state, family: .corner, now: now) }
round { CountdownComplication(state: state, family: .corner, now: now) }
round { TripDayComplication(state: state, family: .corner) }
}
Divider().opacity(0.3)
wide(56) { NextStopComplication(state: state, family: .rectangular, now: now) }
wide(56) { CountdownComplication(state: state, family: .rectangular, now: now) }
wide(56) { TripDayComplication(state: state, family: .rectangular) }
Divider().opacity(0.3)
wide(18) { NextStopComplication(state: state, family: .inline, now: now) }
wide(18) { CountdownComplication(state: state, family: .inline, now: now) }
wide(18) { TripDayComplication(state: state, family: .inline) }
}
.foregroundStyle(.white)
.tint(Color(hex: 0xFF9F0A))
.frame(maxWidth: .infinity, alignment: .leading)
.onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in
dial = min(50, max(38, (width - 16) / 3))
}
}
}
private func round<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
content()
.frame(width: dial, height: dial)
.clipShape(Circle())
}
private func wide<Content: View>(_ height: CGFloat, @ViewBuilder _ content: () -> Content) -> some View {
content()
.frame(maxWidth: .infinity, minHeight: height, maxHeight: height, alignment: .leading)
.padding(.horizontal, 6)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.06)))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>TREK</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key>
<string>com.liketrek.trek</string>
<key>WKRunsIndependentlyOfCompanionApp</key>
<false/>
</dict>
</plist>
+728
View File
@@ -0,0 +1,728 @@
import SwiftUI
import WatchKit
/// The 40 and 41 mm cases, where a line less decides whether a screen ends clean.
enum WatchScreen {
static let compact = WKInterfaceDevice.current().screenBounds.width < 180
}
// MARK: - Today
/// The trip day at a glance: what is on now or next, how long until it, how
/// far the day has come and what follows. A tap opens the stop.
struct TodayPage: View {
let snapshot: WatchSnapshot
@Environment(\.trekNow) private var now
@Environment(\.isLuminanceReduced) private var reduced
var body: some View {
let todayIndex = snapshot.todayIndex(at: now)
let day = todayIndex.map { snapshot.days[$0] }
let stops = day?.stops ?? []
let nowMinutes = TrekDate.minutesOfDay(now)
let currentIndex = stops.firstIndex { stop in
guard let start = stop.minutes, let end = stop.endMinutes else { return false }
return start <= nowMinutes && nowMinutes < end
}
let nextIndex = stops.firstIndex { ($0.minutes ?? -1) >= nowMinutes }
// A day planned without times still has its places: the first of them,
// and "day done" only once every stop has a time and it is behind.
let focus = currentIndex ?? nextIndex ?? stops.firstIndex { $0.minutes == nil }
ScrollView {
VStack(alignment: .leading, spacing: 8) {
header
if let focus, let todayIndex {
NavigationLink(value: Route.stop(day: todayIndex, stop: focus)) {
FocusCard(
stop: stops[focus],
isNow: focus == currentIndex,
hour12: snapshot.hour12,
then: reduced ? nil : stops.dropFirst(focus + 1).first
)
}
.buttonStyle(.plain)
DayProgressBar(stops: stops, nowMinutes: nowMinutes)
.padding(.horizontal, 2)
} else if stops.isEmpty {
DoneCard(title: L("today.free"), detail: L("today.freeBody"), symbol: "sun.max.fill")
} else {
DoneCard(title: L("today.done"), detail: L("today.doneBody"), symbol: "checkmark.circle.fill")
}
}
}
.contentMargins(.horizontal, 5, for: .scrollContent)
.containerBackground(WatchLook.tripGradient(snapshot.trip), for: .tabView)
.toolbar {
if let weather = snapshot.weather {
ToolbarItem(placement: .topBarTrailing) { WeatherBadge(weather: weather) }
}
}
}
/// The day and the trip; a tap opens the whole day's timeline.
@ViewBuilder
private var header: some View {
if case .running(let index, let total) = snapshot.phase(at: now), let todayIndex = snapshot.todayIndex(at: now) {
NavigationLink(value: Route.day(todayIndex)) {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 3) {
Text(WatchFormat.dayOf(index, total)).watchEyebrow(.white.opacity(0.85))
Image(systemName: "chevron.forward")
.font(.system(size: 9, weight: .heavy))
.foregroundStyle(WatchLook.muted)
}
Text(snapshot.trip?.title ?? "").userText((snapshot.trip?.title ?? ""))
.font(.system(.footnote, design: .rounded).weight(.semibold))
.foregroundStyle(WatchLook.muted)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityElement(children: .combine)
.accessibilityHint(L("today.plan"))
}
}
}
/// The stop that is on now or comes next, large.
struct FocusCard: View {
let stop: WatchSnapshot.Stop
let isNow: Bool
let hour12: Bool
/// The stop after it, in a line at the foot.
var then: WatchSnapshot.Stop?
@Environment(\.trekNow) private var now
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .center, spacing: 6) {
StopGlyph(stop: stop, size: 24)
Text(L(isNow ? "today.now" : "today.next"))
.watchEyebrow(isNow ? Trek.confirmed : WatchLook.muted)
Spacer(minLength: 0)
}
Text(stop.name).userText(stop.name)
.font(.system(WatchScreen.compact ? .headline : .title3, design: .rounded).weight(.bold))
.foregroundStyle(WatchLook.ink)
.lineLimit(2)
.minimumScaleFactor(0.75)
HStack(alignment: .firstTextBaseline, spacing: 4) {
if let times = timeRange {
Text(times)
.font(.system(.footnote, design: .rounded).weight(.medium).monospacedDigit())
.foregroundStyle(WatchLook.muted)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
Spacer(minLength: 2)
if let target = countdownTarget, target > now {
Text(WatchFormat.relative(target, now: now))
.font(.system(.footnote, design: .rounded).weight(.bold))
.foregroundStyle(isNow ? WatchLook.muted : Trek.confirmed)
.lineLimit(1)
.layoutPriority(1)
}
}
if let then {
Divider().overlay(Color.white.opacity(0.12)).padding(.vertical, 2)
HStack(spacing: 5) {
Image(systemName: "arrow.turn.down.right")
.foregroundStyle(WatchLook.faint)
if let minutes = then.minutes {
Text(WatchFormat.time(minutes: minutes, hour12: hour12)).monospacedDigit()
.foregroundStyle(WatchLook.muted)
}
Text(then.name).userText(then.name)
.foregroundStyle(WatchLook.ink.opacity(0.85))
.lineLimit(1)
}
.font(.system(.caption, design: .rounded).weight(.semibold))
.accessibilityElement(children: .combine)
.accessibilityLabel("\(L("today.later")): \(then.name)")
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.watchCard()
.accessibilityElement(children: .combine)
}
/// Next: until it starts. Now: until it ends.
private var countdownTarget: Date? {
guard !isNow, let minutes = stop.minutes else { return nil }
let start = TrekDate.at(minutes, on: now)
return start.timeIntervalSince(now) >= 60 ? start : nil
}
private var timeRange: String? {
guard let start = stop.minutes else { return nil }
let from = WatchFormat.time(minutes: start, hour12: hour12)
guard let end = stop.endMinutes else { return from }
return "\(from) – \(WatchFormat.time(minutes: end, hour12: hour12))"
}
}
/// The day's timed stops as segments, those behind filled.
struct DayProgressBar: View {
let stops: [WatchSnapshot.Stop]
let nowMinutes: Int
var body: some View {
let timed = stops.filter { $0.minutes != nil }
if timed.count > 1 {
HStack(spacing: 3) {
ForEach(Array(timed.enumerated()), id: \.offset) { _, stop in
Capsule()
.fill((stop.minutes ?? 0) < nowMinutes ? WatchLook.stopColor(stop) : Color.white.opacity(0.18))
.frame(height: 4)
}
}
.accessibilityHidden(true)
}
}
}
struct CompactStopRow: View {
let stop: WatchSnapshot.Stop
let hour12: Bool
var body: some View {
HStack(spacing: 8) {
StopGlyph(stop: stop, size: 20)
Text(stop.name).userText(stop.name)
.font(.system(.footnote, design: .rounded).weight(.semibold))
.foregroundStyle(WatchLook.ink)
.lineLimit(1)
Spacer(minLength: 4)
if let minutes = stop.minutes {
Text(WatchFormat.time(minutes: minutes, hour12: hour12))
.font(.system(.caption2, design: .rounded).weight(.semibold).monospacedDigit())
.foregroundStyle(WatchLook.muted)
}
}
.padding(.vertical, 2)
.accessibilityElement(children: .combine)
}
}
struct DoneCard: View {
let title: String
let detail: String
let symbol: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Image(systemName: symbol)
.font(.title2)
.symbolRenderingMode(.multicolor)
.foregroundStyle(Trek.confirmed)
Text(title).font(.system(.headline, design: .rounded)).foregroundStyle(WatchLook.ink)
Text(detail).userText(detail).font(.footnote).foregroundStyle(WatchLook.muted)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.watchCard()
.accessibilityElement(children: .combine)
}
}
struct WeatherBadge: View {
let weather: WatchSnapshot.Weather
var body: some View {
HStack(spacing: 2) {
Image(systemName: Trek.weatherSymbol(weather.main)).symbolRenderingMode(.multicolor)
Text("\(weather.temp)°").monospacedDigit()
}
.font(.system(.footnote, design: .rounded).weight(.semibold))
.accessibilityLabel("\(weather.label), \(weather.temp)°")
}
}
// MARK: - Before the trip
/// The days to the trip, big, over its cover or its own colours.
struct CountdownPage: View {
let snapshot: WatchSnapshot
let days: Int
@ScaledMetric(relativeTo: .largeTitle) private var numberSize: CGFloat = 54
var body: some View {
let trip = snapshot.trip
VStack(alignment: .leading, spacing: 2) {
Text(L("countdown.title")).watchEyebrow(.white.opacity(0.85))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(days)")
.font(.system(size: numberSize, weight: .heavy, design: .rounded).monospacedDigit())
.minimumScaleFactor(0.5)
Text(WatchFormat.daysUnit(days))
.font(.system(.headline, design: .rounded))
.foregroundStyle(.white.opacity(0.85))
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.foregroundStyle(.white)
Text(trip?.title ?? "").userText((trip?.title ?? ""))
.font(.system(.headline, design: .rounded))
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.8)
HStack(spacing: 4) {
if let range = trip.flatMap({ WatchFormat.range($0.start, $0.end) }) {
Text(range)
}
if let place = trip?.place?.split(separator: ",").first {
Text("·")
Text(place)
}
}
.font(.system(.caption2, design: .rounded).weight(.semibold))
.foregroundStyle(.white.opacity(0.75))
.lineLimit(1)
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: .white)
.frame(height: 20)
.padding(.top, 6)
.accessibilityHidden(true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
.padding(.horizontal, 5)
.containerBackground(for: .tabView) {
if trip?.cover != nil {
TripBackdrop(trip: widgetTrip(trip), shade: 0.7)
} else {
let (first, second) = WatchLook.tripColors(trip)
ZStack {
LinearGradient(colors: [first, second, .black], startPoint: .topLeading, endPoint: .bottom)
ContourLines(seed: UInt64(trip?.id ?? 1) % 997, rings: 11)
.stroke(Color.white.opacity(0.12), lineWidth: 0.9)
}
}
}
.toolbar {
if let weather = snapshot.weather {
ToolbarItem(placement: .topBarTrailing) { WeatherBadge(weather: weather) }
}
}
}
private func widgetTrip(_ trip: WatchSnapshot.Trip?) -> WidgetSnapshot.Trip? {
trip.map { WidgetSnapshot.Trip(title: $0.title, start: $0.start, end: $0.end, path: "", place: $0.place, cover: $0.cover) }
}
}
// MARK: - The next booking
/// The next flight, train, hotel or table: where to, when, the countdown, and
/// what is asked for at the gate or the desk.
struct BookingPage: View {
let booking: WatchSnapshot.Booking
let hour12: Bool
var body: some View {
ScrollView {
NavigationLink(value: Route.booking(booking.id)) {
BookingSummary(booking: booking, hour12: hour12, compactFacts: true)
}
.buttonStyle(.plain)
}
.contentMargins(.horizontal, 5, for: .scrollContent)
.containerBackground(WatchLook.tint(Trek.booking(booking.kind).color), for: .tabView)
}
}
/// The head of a booking, shared by its page and its detail.
struct BookingSummary: View {
let booking: WatchSnapshot.Booking
let hour12: Bool
var showFacts = true
/// The page's single row of facts rather than all of them.
var compactFacts = false
@Environment(\.trekNow) private var now
var body: some View {
VStack(alignment: .leading, spacing: WatchScreen.compact ? 4 : 6) {
// The kind and the status in one line ("FLIGHT · Confirmed"), the status under it when that is too long.
ViewThatFits(in: .horizontal) {
HStack(spacing: 5) {
KindBadge(kind: booking.kind, size: 20)
kindLabel.fixedSize()
status.fixedSize()
Spacer(minLength: 0)
}
HStack(spacing: 6) {
KindBadge(kind: booking.kind, size: 22)
VStack(alignment: .leading, spacing: 0) {
kindLabel
status
}
Spacer(minLength: 0)
}
}
.accessibilityElement(children: .combine)
if let route = booking.route {
Text(route)
.font(.system(.title2, design: .rounded).weight(.heavy))
.lineLimit(1)
.minimumScaleFactor(0.55)
Text(booking.carrier ?? booking.title)
.font(.system(WatchScreen.compact ? .caption : .footnote, design: .rounded).weight(.medium))
.foregroundStyle(WatchLook.muted)
.lineLimit(1)
} else {
Text(booking.title).userText(booking.title)
.font(.system(.title3, design: .rounded).weight(.bold))
.lineLimit(2)
.minimumScaleFactor(0.75)
}
timing
if showFacts { facts }
}
.foregroundStyle(WatchLook.ink)
.frame(maxWidth: .infinity, alignment: .leading)
}
/// The time it starts (or, for a stay under way, ends), its day, and how long until then, in one line.
@ViewBuilder
private var timing: some View {
let running = booking.kind == "hotel" && (booking.start ?? .distantFuture) < now
let moment = running ? booking.end : booking.start
let clock = running ? booking.checkOut ?? booking.endTime : booking.time
if let moment {
// The day goes first when the line is too short for all three.
let label = running ? L("booking.checkOut") : dayLabel(moment)
// A booking with no time lasts the day: its day, but no countdown to midnight.
let counts = running ? clock != nil : !booking.isAllDay
ViewThatFits(in: .horizontal) {
timeLine(clock: clock, label: label, moment: moment, pill: counts)
timeLine(clock: clock, label: nil, moment: moment, pill: counts)
VStack(alignment: .leading, spacing: 4) {
timeLine(clock: clock, label: label, moment: moment, pill: false)
if counts { countdownPill(moment) }
}
}
}
}
private func timeLine(clock: String?, label: String?, moment: Date, pill: Bool) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 5) {
if let clock, let minutes = TrekDate.minutes(clock) {
Text(WatchFormat.time(minutes: minutes, hour12: hour12))
.font(.system(.title3, design: .rounded).weight(.bold).monospacedDigit())
.fixedSize()
}
if let label {
Text(label)
.font(.system(.caption2, design: .rounded).weight(.semibold))
.foregroundStyle(WatchLook.muted)
.fixedSize()
}
if pill {
Spacer(minLength: 4)
countdownPill(moment)
}
}
}
@ViewBuilder
private func countdownPill(_ moment: Date) -> some View {
if moment > now {
Text(WatchFormat.relative(moment, now: now))
.font(.system(.caption2, design: .rounded).weight(.bold))
.foregroundStyle(.white)
.fixedSize()
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(Trek.booking(booking.kind).color.opacity(0.85)))
}
}
private var kindLabel: some View {
Text(WatchLook.kindName(booking.kind))
.font(.system(.caption2, design: .rounded).weight(.bold))
.textCase(.uppercase)
.tracking(0.4)
.foregroundStyle(.white.opacity(0.85))
.lineLimit(1)
}
private var status: some View {
HStack(spacing: 4) {
Circle().fill(booking.confirmed ? Trek.confirmed : Trek.pending).frame(width: 5, height: 5)
Text(L(booking.confirmed ? "booking.confirmed" : "booking.pending"))
.font(.system(.caption2, design: .rounded).weight(.medium))
.foregroundStyle(WatchLook.muted)
.lineLimit(1)
}
}
private var factTiles: [(label: String, value: String)] {
var tiles: [(label: String, value: String)] = []
if let seat = booking.seat { tiles.append((L("booking.seat"), seat)) }
if let platform = booking.platform { tiles.append((L("booking.platform"), platform)) }
if booking.kind == "hotel", let checkIn = TrekDate.minutes(booking.checkIn) {
tiles.append((L("booking.checkIn"), WatchFormat.time(minutes: checkIn, hour12: hour12)))
}
return tiles
}
private func dayLabel(_ date: Date) -> String {
let days = TrekDate.daysBetween(now, date)
if days == 0 { return L("time.today") }
if days == 1 { return L("time.tomorrow") }
return WatchFormat.day(date)
}
/// On the page one row, the reference and the seat; in the detail all of them.
@ViewBuilder
private var facts: some View {
let tiles = factTiles
if compactFacts {
let row = [booking.code.map { (L("booking.ref"), $0, true) }, tiles.first.map { ($0.label, $0.value, false) }].compactMap { $0 }
ViewThatFits(in: .horizontal) {
HStack(spacing: 6) {
ForEach(row, id: \.0) { FactTile(label: $0.0, value: $0.1, mono: $0.2, fits: true) }
}
VStack(spacing: 6) {
ForEach(row, id: \.0) { FactTile(label: $0.0, value: $0.1, mono: $0.2) }
}
}
} else {
factsInFull(tiles)
}
}
@ViewBuilder
private func factsInFull(_ tiles: [(label: String, value: String)]) -> some View {
if !tiles.isEmpty {
HStack(spacing: 6) {
ForEach(tiles.prefix(2), id: \.label) { tile in
FactTile(label: tile.label, value: tile.value)
}
}
}
if let code = booking.code {
FactTile(label: L("booking.ref"), value: code, mono: true)
}
}
}
// MARK: - The trip
/// The trip as a whole: how far along, and the ways into its days, bookings and lists.
struct TripPage: View {
let snapshot: WatchSnapshot
@Environment(\.trekNow) private var now
var body: some View {
let trip = snapshot.trip
let total = snapshot.tripLength
let index: Int = {
if case .running(let day, _) = snapshot.phase(at: now) { return day }
return 0
}()
ScrollView {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
ProgressRing(value: Double(index) / Double(max(total, 1)), lineWidth: 5, colors: [.white.opacity(0.7), .white], trackColor: .white.opacity(0.2)) {
VStack(spacing: -2) {
Text(index > 0 ? "\(index)" : "\(total)")
.font(.system(.headline, design: .rounded).weight(.heavy).monospacedDigit())
Text(index > 0 ? "/\(total)" : WatchFormat.daysUnit(total))
.font(.system(size: 9, weight: .bold, design: .rounded))
.foregroundStyle(WatchLook.muted)
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.padding(4)
}
.frame(width: 46, height: 46)
.accessibilityLabel(index > 0 ? WatchFormat.dayOf(index, total) : "\(total) \(WatchFormat.daysUnit(total))")
VStack(alignment: .leading, spacing: 1) {
Text(trip?.title ?? "").userText((trip?.title ?? ""))
.font(.system(.headline, design: .rounded))
.lineLimit(2)
.minimumScaleFactor(0.8)
if let range = trip.flatMap({ WatchFormat.range($0.start, $0.end) }) {
Text(range)
.font(.system(.caption2, design: .rounded).weight(.semibold))
.foregroundStyle(WatchLook.muted)
.lineLimit(1)
}
}
}
NavigationLink(value: Route.days) {
MenuRow(symbol: "calendar", color: Trek.info, title: L("trip.days"), value: "\(total)")
}
if !snapshot.bookings.isEmpty {
NavigationLink(value: Route.bookings) {
MenuRow(symbol: "ticket.fill", color: Trek.booking("event").color, title: L("booking.all"), value: "\(snapshot.bookingsAhead(at: now).count)")
}
}
if let packing = snapshot.packing {
NavigationLink(value: Route.packing) {
MenuRow(symbol: "suitcase.rolling.fill", color: WatchLook.packing, title: L("packing.title"), value: "\(packing.packed)/\(packing.total)")
}
}
if let todos = snapshot.todos {
NavigationLink(value: Route.todos) {
MenuRow(symbol: "checklist", color: WatchLook.todo, title: L("todos.title"), value: "\(todos.open)")
}
}
}
.buttonStyle(.plain)
.foregroundStyle(WatchLook.ink)
}
.contentMargins(.horizontal, 5, for: .scrollContent)
.containerBackground(WatchLook.tripGradient(trip), for: .tabView)
}
}
/// A way into a part of the trip: its symbol, its name, a count.
struct MenuRow: View {
let symbol: String
let color: Color
let title: String
let value: String?
var body: some View {
let compact = WatchScreen.compact
HStack(spacing: compact ? 6 : 8) {
Image(systemName: symbol)
.font(.system(size: compact ? 11 : 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: compact ? 22 : 26, height: compact ? 22 : 26)
.background(Circle().fill(color.gradient))
Text(title)
.font(.system(compact ? .callout : .body, design: .rounded).weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.65)
.allowsTightening(true)
Spacer(minLength: 2)
if let value {
Text(value)
.font(.system(.footnote, design: .rounded).weight(.semibold).monospacedDigit())
.foregroundStyle(WatchLook.muted)
}
}
.padding(.horizontal, WatchScreen.compact ? 8 : 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.watchCard(cornerRadius: 16)
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
}
}
// MARK: - The lists
/// The packing list and the to-dos as two rings, each opening its list.
struct ListsPage: View {
let snapshot: WatchSnapshot
var body: some View {
VStack(spacing: 10) {
HStack(spacing: 10) {
if let packing = snapshot.packing {
NavigationLink(value: Route.packing) {
ListRing(value: Double(packing.packed) / Double(max(packing.total, 1)), color: WatchLook.packing,
number: "\(packing.packed)", of: "/\(packing.total)", title: L("packing.title"), detail: L("packing.packed"))
}
}
if let todos = snapshot.todos {
NavigationLink(value: Route.todos) {
ListRing(value: Double(todos.total - todos.open) / Double(max(todos.total, 1)), color: WatchLook.todo,
number: "\(todos.open)", of: nil, title: L("todos.title"), detail: L("todos.open"))
}
}
}
.buttonStyle(.plain)
if let next = snapshot.packing?.items.first(where: { !$0.done }) {
HStack(spacing: 6) {
Image(systemName: "circle").foregroundStyle(WatchLook.packing)
Text(next.name).userText(next.name).lineLimit(1)
Spacer(minLength: 0)
}
.font(.system(.footnote, design: .rounded).weight(.medium))
.padding(.horizontal, 10)
.padding(.vertical, 7)
.watchCard(cornerRadius: 12)
}
}
.frame(maxHeight: .infinity)
.padding(.horizontal, 5)
.containerBackground(WatchLook.tint(WatchLook.packing), for: .tabView)
}
}
struct ListRing: View {
let value: Double
let color: Color
let number: String
let of: String?
let title: String
let detail: String
var body: some View {
VStack(spacing: 5) {
ProgressRing(value: value, lineWidth: 6, colors: [color.opacity(0.7), color], trackColor: color.opacity(0.22)) {
VStack(spacing: -3) {
Text(number).font(.system(.title3, design: .rounded).weight(.heavy).monospacedDigit())
if let of {
Text(of).font(.system(size: 10, weight: .bold, design: .rounded)).foregroundStyle(WatchLook.muted)
} else {
Text(detail).userText(detail).font(.system(size: 9, weight: .bold, design: .rounded)).foregroundStyle(WatchLook.muted).lineLimit(1).minimumScaleFactor(0.6)
}
}
.padding(6)
}
.frame(width: 62, height: 62)
Text(title)
.font(.system(.caption2, design: .rounded).weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.watchCard()
.accessibilityElement(children: .combine)
}
}
// MARK: - Empty
struct EmptyStateView: View {
enum Kind { case notSynced, noTrip }
let kind: Kind
var body: some View {
ScrollView {
VStack(spacing: 8) {
TrekMark()
.fill(.white)
.frame(width: 44, height: 44)
.padding(14)
.background(Circle().fill(Color.white.opacity(0.1)))
.accessibilityHidden(true)
Text(L(kind == .notSynced ? "empty.sync.title" : "empty.trip.title"))
.font(.system(.headline, design: .rounded))
.multilineTextAlignment(.center)
Text(L(kind == .notSynced ? "empty.sync.body" : "empty.trip.body"))
.font(.footnote)
.foregroundStyle(WatchLook.muted)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.top, 6)
}
.containerBackground(WatchLook.tint(Color(hex: 0x3A3A44)), for: .navigation)
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
</array>
</dict>
</plist>
+160
View File
@@ -0,0 +1,160 @@
import SwiftUI
@main
struct TREKWatchApp: App {
init() {
if DemoMode.screen != nil {
DemoMode.start()
} else {
WatchLink.shared.activate()
}
}
var body: some Scene {
WindowGroup {
RootView()
}
// Woken in the background to take a snapshot or complication update from
// the phone: the task lasts until it is delivered, stored and drawn.
.backgroundTask(.watchConnectivity) {
await WatchLink.shared.finishPendingDeliveries()
}
}
}
private struct NowKey: EnvironmentKey {
static let defaultValue = Date()
}
extension EnvironmentValues {
/// The minute the screens show, from the root's TimelineView (or the screenshot clock).
var trekNow: Date {
get { self[NowKey.self] }
set { self[NowKey.self] = newValue }
}
}
/// Where a tap leads from the pages.
enum Route: Hashable {
case day(Int)
case stop(day: Int, stop: Int)
case booking(Int)
case bookings
case days
case packing
case todos
}
enum Page: Hashable {
case now, booking, trip, lists
}
/// The pages under each other, turned with the Digital Crown: today (or the
/// countdown before the trip), the next booking, the trip, the lists. A
/// minute timeline keeps every countdown current, Always On included.
struct RootView: View {
private let model = WatchModel.shared
@State private var path: [Route] = []
@State private var page: Page = .now
@Environment(\.scenePhase) private var scenePhase
var body: some View {
TimelineView(.everyMinute) { context in
let now = model.now(context.date)
NavigationStack(path: $path) {
content(now)
.navigationDestination(for: Route.self) { route in
destination(route)
}
}
.environment(\.trekNow, now)
}
.onAppear {
openDemoScreen()
DemoMode.signalReady()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active && !model.isDemo { WatchLink.shared.requestRefresh() }
}
}
@ViewBuilder
private func content(_ now: Date) -> some View {
if DemoMode.screen == .faces {
FacesView(snapshot: model.display, now: now)
} else if let snapshot = model.display {
if snapshot.trip == nil || snapshot.phase(at: now) == .past {
EmptyStateView(kind: .noTrip)
} else {
pages(snapshot, now)
}
} else {
EmptyStateView(kind: .notSynced)
}
}
@ViewBuilder
private func pages(_ snapshot: WatchSnapshot, _ now: Date) -> some View {
let booking = snapshot.nextBooking(at: now)
TabView(selection: $page) {
Group {
if case .upcoming(let days) = snapshot.phase(at: now) {
CountdownPage(snapshot: snapshot, days: days)
} else {
TodayPage(snapshot: snapshot)
}
}
.tag(Page.now)
if let booking {
BookingPage(booking: booking, hour12: snapshot.hour12)
.tag(Page.booking)
}
TripPage(snapshot: snapshot)
.tag(Page.trip)
if snapshot.packing != nil || snapshot.todos != nil {
ListsPage(snapshot: snapshot)
.tag(Page.lists)
}
}
.tabViewStyle(.verticalPage)
}
@ViewBuilder
private func destination(_ route: Route) -> some View {
if let snapshot = model.display {
switch route {
case .day(let index): DayView(snapshot: snapshot, index: index)
case .stop(let day, let stop): StopDetailView(snapshot: snapshot, day: day, stop: stop)
case .booking(let id): BookingDetailView(snapshot: snapshot, id: id)
case .bookings: BookingsListView(snapshot: snapshot)
case .days: DaysListView(snapshot: snapshot)
case .packing: PackingListView()
case .todos: TodoListView()
}
}
}
/// Screenshot mode: opens the screen the launch arguments name.
private func openDemoScreen() {
guard let screen = DemoMode.screen, let snapshot = model.display else { return }
let now = model.now()
let today = snapshot.todayIndex(at: now) ?? 0
switch screen {
case .booking: page = .booking
case .trip: page = .trip
case .lists: page = .lists
case .day: path = [.day(today)]
case .days: path = [.days]
case .place:
let stops = snapshot.days.indices.contains(today) ? snapshot.days[today].stops : []
let next = stops.firstIndex { ($0.minutes ?? 0) >= TrekDate.minutesOfDay(now) } ?? 0
path = [.stop(day: today, stop: next)]
case .bookings: path = [.bookings]
case .bookingDetail:
if let booking = snapshot.nextBooking(at: now) { path = [.booking(booking.id)] }
case .packing: path = [.packing]
case .todos: path = [.todos]
default: break
}
}
}
+165
View File
@@ -0,0 +1,165 @@
import Foundation
import Observation
import WatchConnectivity
import WidgetKit
/// The watch's state: the last snapshot from the phone with the ticks it has
/// not confirmed yet laid over it. Everything the screens show comes from here.
@MainActor
@Observable
final class WatchModel {
static let shared = WatchModel()
private(set) var snapshot: WatchSnapshot?
private(set) var pending: [PendingAction] = []
/// Set in screenshot mode (DemoMode.swift): the clock the screens read, and nothing is sent or stored.
private(set) var fixedNow: Date?
private(set) var isDemo = false
/// The snapshot as the screens show it.
var display: WatchSnapshot? { snapshot?.applying(pending) }
private init() {
snapshot = WatchStore.loadSnapshot()
pending = WatchStore.loadPending()
}
func now(_ date: Date = Date()) -> Date { fixedNow ?? date }
func startDemo(_ snapshot: WatchSnapshot?, now: Date) {
isDemo = true
fixedNow = now
self.snapshot = snapshot
pending = []
}
/// A snapshot from the phone: kept, the confirmed ticks dropped, the complications redrawn.
func receive(_ data: Data) {
guard !isDemo, let incoming = WatchSnapshot.decode(data) else { return }
if let current = snapshot, let a = current.generated, let b = incoming.generated, b < a { return }
snapshot = incoming
WatchStore.saveSnapshot(data)
pending = incoming.stillPending(pending)
WatchStore.savePending(pending)
WidgetCenter.shared.reloadAllTimelines()
}
/// Ticks a packing item or to-do: shown at once, sent to the phone, which
/// writes it through the app's own offline-first path.
func toggle(_ list: PendingAction.List, item: WatchSnapshot.Item) {
guard let tripId = display?.trip?.id else { return }
let action = PendingAction(id: UUID().uuidString, tripId: tripId, list: list, itemId: item.id, done: !item.done, at: Date())
pending.removeAll { $0.list == list && $0.itemId == item.id }
pending.append(action)
guard !isDemo else { return }
WatchStore.savePending(pending)
WatchLink.shared.send(action)
WidgetCenter.shared.reloadAllTimelines()
}
var isWaitingForPhone: Bool { !pending.isEmpty }
/// WatchConnectivity handed a tick to the phone: it is not sent again.
func markSent(_ id: String) {
guard let index = pending.firstIndex(where: { $0.id == id }) else { return }
pending[index].sent = true
WatchStore.savePending(pending)
}
/// Ticks that never reached the phone's queue: not yet handed over, or failed.
func unsent(excluding queued: Set<String>) -> [PendingAction] {
pending.filter { $0.sent != true && !queued.contains($0.id) }
}
}
/// The watch's end of WatchConnectivity: snapshots arrive as the application
/// context (the latest one, kept by the system across launches) and as
/// complication user info (sent when the phone has budget for it), ticks
/// leave as user info, which the system queues until the phone takes them.
final class WatchLink: NSObject, WCSessionDelegate {
static let shared = WatchLink()
func activate() {
guard WCSession.isSupported() else { return }
if WCSession.default.delegate == nil { WCSession.default.delegate = self }
if WCSession.default.activationState != .activated { WCSession.default.activate() }
}
/// For the system's WatchConnectivity background task: activates the session
/// and returns only once what the phone sent has been delivered (and the
/// snapshot stored and the complications told), or after twenty seconds.
func finishPendingDeliveries() async {
activate()
for _ in 0..<100 {
let session = WCSession.default
if session.activationState == .activated && !session.hasContentPending { break }
try? await Task.sleep(nanoseconds: 200_000_000)
}
// Let the deliveries already queued on the main actor run before the task ends.
await MainActor.run {}
try? await Task.sleep(nanoseconds: 300_000_000)
}
/// A tick to the phone. In reach it goes as a message, which arrives at once and
/// wakes the phone app for it, so the trip's other devices see it within seconds.
/// Out of reach, or when the message fails or a phone app too old to take it
/// answers without "queued", it goes as user info, which the system delivers later.
func send(_ action: PendingAction) {
let session = WCSession.default
guard session.activationState == .activated else { return }
let message: [String: Any] = ["action": action.message]
guard session.isReachable else {
session.transferUserInfo(message)
return
}
session.sendMessage(message, replyHandler: { reply in
if reply["queued"] as? Bool == true {
Task { @MainActor in WatchModel.shared.markSent(action.id) }
} else {
session.transferUserInfo(message)
}
}, errorHandler: { _ in
session.transferUserInfo(message)
})
}
/// Asks the phone for a fresh snapshot while it is in reach (the app answers from what it stored).
func requestRefresh() {
guard WCSession.default.activationState == .activated, WCSession.default.isReachable else { return }
WCSession.default.sendMessage(["request": "snapshot"], replyHandler: { reply in
if let data = reply["snapshot"] as? Data { Task { @MainActor in WatchModel.shared.receive(data) } }
}, errorHandler: nil)
}
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
guard activationState == .activated else { return }
if let data = session.receivedApplicationContext["snapshot"] as? Data {
Task { @MainActor in WatchModel.shared.receive(data) }
}
// Only ticks that never reached the phone's queue go again: those still in the
// system's queue are on their way, and those handed over are the phone's now.
let queued = Set(session.outstandingUserInfoTransfers.compactMap { ($0.userInfo["action"] as? [String: Any])?["id"] as? String })
Task { @MainActor in
for action in WatchModel.shared.unsent(excluding: queued) {
session.transferUserInfo(["action": action.message])
}
}
requestRefresh()
}
/// A tick reached the phone's queue (or failed, and is sent again on the next activation).
func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) {
guard error == nil, let id = (userInfoTransfer.userInfo["action"] as? [String: Any])?["id"] as? String else { return }
Task { @MainActor in WatchModel.shared.markSent(id) }
}
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
guard let data = applicationContext["snapshot"] as? Data else { return }
Task { @MainActor in WatchModel.shared.receive(data) }
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
guard let data = userInfo["snapshot"] as? Data else { return }
Task { @MainActor in WatchModel.shared.receive(data) }
}
}
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "اليوم";
"today.next" = "التالي";
"today.now" = "الآن";
"today.later" = "لاحقًا";
"today.done" = "انتهى اليوم";
"today.doneBody" = "لا شيء آخر مخطط لليوم.";
"today.free" = "يوم حر";
"today.freeBody" = "لا شيء مخطط لليوم. استمتع.";
"today.plan" = "خطة اليوم";
"day.of" = "اليوم %1$d من %2$d";
"day.empty" = "لا شيء مخطط";
"time.now" = "الآن";
"time.today" = "اليوم";
"time.tomorrow" = "غدًا";
"countdown.title" = "الرحلة القادمة";
"booking.next" = "الحجز القادم";
"booking.all" = "الحجوزات";
"booking.none" = "لا حجوزات قادمة";
"booking.confirmed" = "مؤكد";
"booking.pending" = "قيد الانتظار";
"booking.seat" = "المقعد";
"booking.platform" = "الرصيف";
"booking.ref" = "رقم الحجز";
"booking.checkIn" = "تسجيل الوصول";
"booking.checkOut" = "تسجيل المغادرة";
"booking.departs" = "المغادرة";
"booking.arrives" = "الوصول";
"kind.flight" = "رحلة جوية";
"kind.train" = "قطار";
"kind.bus" = "حافلة";
"kind.car" = "سيارة";
"kind.cruise" = "عبّارة";
"kind.hotel" = "فندق";
"kind.restaurant" = "مطعم";
"kind.event" = "فعالية";
"kind.tour" = "جولة";
"kind.other" = "حجز";
"trip.title" = "الرحلة";
"trip.days" = "الأيام";
"place.title" = "المكان";
"place.directions" = "الاتجاهات";
"lists.title" = "القوائم";
"packing.title" = "الأمتعة";
"packing.packed" = "محزوم";
"packing.packedSection" = "محزومة";
"packing.allPacked" = "كل شيء محزوم";
"packing.other" = "أخرى";
"todos.title" = "المهام";
"todos.open" = "مفتوحة";
"todos.doneSection" = "منجزة";
"todos.allDone" = "كل شيء منجز";
"empty.sync.title" = "افتح TREK على iPhone";
"empty.sync.body" = "ستظهر رحلتك هنا بعد مزامنة التطبيق على iPhone.";
"empty.trip.title" = "لا توجد رحلة مخططة";
"empty.trip.body" = "خطط لرحلتك القادمة في TREK وستظهر هنا.";
"complication.nextStop.name" = "المحطة التالية";
"complication.nextStop.description" = "المحطة التالية في اليوم وموعد بدئها.";
"complication.countdown.name" = "العد التنازلي";
"complication.countdown.description" = "الوقت المتبقي حتى رحلتك الجوية أو القطار أو تسجيل الوصول أو الرحلة القادمة.";
"complication.tripDay.name" = "يوم الرحلة";
"complication.tripDay.description" = "في أي يوم من الرحلة أنت.";
"complication.noTrip" = "لا رحلة";
"a11y.done" = "منجزة";
"a11y.open" = "غير منجزة";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Bu gün";
"today.next" = "Növbəti";
"today.now" = "İndi";
"today.later" = "Sonra";
"today.done" = "Gün bitdi";
"today.doneBody" = "Bu gün üçün başqa plan yoxdur.";
"today.free" = "Boş gün";
"today.freeBody" = "Bu gün plan yoxdur. Həzz alın.";
"today.plan" = "Bu günün planı";
"day.of" = "Gün %1$d / %2$d";
"day.empty" = "Plan yoxdur";
"time.now" = "indi";
"time.today" = "Bu gün";
"time.tomorrow" = "Sabah";
"countdown.title" = "Növbəti səyahət";
"booking.next" = "Növbəti rezervasiya";
"booking.all" = "Rezervasiyalar";
"booking.none" = "Qarşıda rezervasiya yoxdur";
"booking.confirmed" = "Təsdiqlənib";
"booking.pending" = "Gözləyir";
"booking.seat" = "Oturacaq";
"booking.platform" = "Platforma";
"booking.ref" = "Rezervasiya kodu";
"booking.checkIn" = "Qeydiyyat";
"booking.checkOut" = "Çıxış";
"booking.departs" = "Yola düşmə";
"booking.arrives" = "Çatma";
"kind.flight" = "Uçuş";
"kind.train" = "Qatar";
"kind.bus" = "Avtobus";
"kind.car" = "Avtomobil";
"kind.cruise" = "Bərə";
"kind.hotel" = "Otel";
"kind.restaurant" = "Restoran";
"kind.event" = "Tədbir";
"kind.tour" = "Tur";
"kind.other" = "Rezervasiya";
"trip.title" = "Səyahət";
"trip.days" = "Günlər";
"place.title" = "Məkan";
"place.directions" = "Marşrut";
"lists.title" = "Siyahılar";
"packing.title" = "Baqaj";
"packing.packed" = "yığılıb";
"packing.packedSection" = "Yığılıb";
"packing.allPacked" = "Hər şey yığılıb";
"packing.other" = "Digər";
"todos.title" = "Tapşırıqlar";
"todos.open" = "açıq";
"todos.doneSection" = "Hazırdır";
"todos.allDone" = "Hamısı hazırdır";
"empty.sync.title" = "TREK-i iPhone-da açın";
"empty.sync.body" = "Səyahətiniz iPhone-dakı tətbiq sinxronlaşandan sonra burada görünəcək.";
"empty.trip.title" = "Planlaşdırılmış səyahət yoxdur";
"empty.trip.body" = "Növbəti səyahətinizi TREK-də planlaşdırın, burada görünəcək.";
"complication.nextStop.name" = "Növbəti dayanacaq";
"complication.nextStop.description" = "Günün növbəti dayanacağı və başlama vaxtı.";
"complication.countdown.name" = "Geri sayım";
"complication.countdown.description" = "Növbəti uçuşa, qatara, qeydiyyata və ya səyahətə qalan vaxt.";
"complication.tripDay.name" = "Səyahət günü";
"complication.tripDay.description" = "Səyahətin neçənci günü olduğu.";
"complication.noTrip" = "Səyahət yoxdur";
"a11y.done" = "Hazırdır";
"a11y.open" = "Hazır deyil";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Avui";
"today.next" = "Següent";
"today.now" = "Ara";
"today.later" = "Més tard";
"today.done" = "Dia acabat";
"today.doneBody" = "Avui no hi ha res més previst.";
"today.free" = "Dia lliure";
"today.freeBody" = "Avui no hi ha res previst. Gaudeix-ne.";
"today.plan" = "Pla d'avui";
"day.of" = "Dia %1$d de %2$d";
"day.empty" = "Res previst";
"time.now" = "ara";
"time.today" = "Avui";
"time.tomorrow" = "Demà";
"countdown.title" = "Proper viatge";
"booking.next" = "Propera reserva";
"booking.all" = "Reserves";
"booking.none" = "Cap reserva pròxima";
"booking.confirmed" = "Confirmada";
"booking.pending" = "Pendent";
"booking.seat" = "Seient";
"booking.platform" = "Andana";
"booking.ref" = "Localitzador";
"booking.checkIn" = "Entrada";
"booking.checkOut" = "Sortida";
"booking.departs" = "Surt";
"booking.arrives" = "Arriba";
"kind.flight" = "Vol";
"kind.train" = "Tren";
"kind.bus" = "Autobús";
"kind.car" = "Cotxe";
"kind.cruise" = "Ferri";
"kind.hotel" = "Hotel";
"kind.restaurant" = "Restaurant";
"kind.event" = "Esdeveniment";
"kind.tour" = "Excursió";
"kind.other" = "Reserva";
"trip.title" = "Viatge";
"trip.days" = "Dies";
"place.title" = "Lloc";
"place.directions" = "Com arribar-hi";
"lists.title" = "Llistes";
"packing.title" = "Equipatge";
"packing.packed" = "a la maleta";
"packing.packedSection" = "A la maleta";
"packing.allPacked" = "Tot a la maleta";
"packing.other" = "Altres";
"todos.title" = "Tasques";
"todos.open" = "obertes";
"todos.doneSection" = "Fetes";
"todos.allDone" = "Tot fet";
"empty.sync.title" = "Obre TREK a l'iPhone";
"empty.sync.body" = "El viatge apareixerà aquí quan l'app de l'iPhone s'hagi sincronitzat.";
"empty.trip.title" = "Cap viatge previst";
"empty.trip.body" = "Planifica el proper viatge a TREK i apareixerà aquí.";
"complication.nextStop.name" = "Propera parada";
"complication.nextStop.description" = "La propera parada del dia i quan comença.";
"complication.countdown.name" = "Compte enrere";
"complication.countdown.description" = "Temps fins al proper vol, tren, entrada a l'hotel o viatge.";
"complication.tripDay.name" = "Dia del viatge";
"complication.tripDay.description" = "Quin dia del viatge és.";
"complication.noTrip" = "Cap viatge";
"a11y.done" = "Feta";
"a11y.open" = "No feta";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Dnes";
"today.next" = "Další";
"today.now" = "Teď";
"today.later" = "Později";
"today.done" = "Den hotový";
"today.doneBody" = "Na dnešek už není nic naplánováno.";
"today.free" = "Volný den";
"today.freeBody" = "Dnes není nic naplánováno. Užijte si to.";
"today.plan" = "Dnešní plán";
"day.of" = "Den %1$d z %2$d";
"day.empty" = "Nic naplánováno";
"time.now" = "teď";
"time.today" = "Dnes";
"time.tomorrow" = "Zítra";
"countdown.title" = "Příští cesta";
"booking.next" = "Příští rezervace";
"booking.all" = "Rezervace";
"booking.none" = "Žádné nadcházející rezervace";
"booking.confirmed" = "Potvrzeno";
"booking.pending" = "Čeká";
"booking.seat" = "Sedadlo";
"booking.platform" = "Nástupiště";
"booking.ref" = "Číslo rezervace";
"booking.checkIn" = "Příjezd";
"booking.checkOut" = "Odjezd";
"booking.departs" = "Odjezd";
"booking.arrives" = "Příjezd";
"kind.flight" = "Let";
"kind.train" = "Vlak";
"kind.bus" = "Autobus";
"kind.car" = "Auto";
"kind.cruise" = "Trajekt";
"kind.hotel" = "Hotel";
"kind.restaurant" = "Restaurace";
"kind.event" = "Akce";
"kind.tour" = "Výlet";
"kind.other" = "Rezervace";
"trip.title" = "Cesta";
"trip.days" = "Dny";
"place.title" = "Místo";
"place.directions" = "Navigovat";
"lists.title" = "Seznamy";
"packing.title" = "Balení";
"packing.packed" = "zabaleno";
"packing.packedSection" = "Zabaleno";
"packing.allPacked" = "Vše zabaleno";
"packing.other" = "Ostatní";
"todos.title" = "Úkoly";
"todos.open" = "otevřené";
"todos.doneSection" = "Hotovo";
"todos.allDone" = "Vše hotovo";
"empty.sync.title" = "Otevřete TREK na iPhonu";
"empty.sync.body" = "Vaše cesta se tu zobrazí, jakmile se aplikace na iPhonu synchronizuje.";
"empty.trip.title" = "Žádná naplánovaná cesta";
"empty.trip.body" = "Naplánujte příští cestu v TREK a zobrazí se tady.";
"complication.nextStop.name" = "Další zastávka";
"complication.nextStop.description" = "Další zastávka dne a kdy začíná.";
"complication.countdown.name" = "Odpočet";
"complication.countdown.description" = "Čas do dalšího letu, vlaku, ubytování nebo cesty.";
"complication.tripDay.name" = "Den cesty";
"complication.tripDay.description" = "Kolikátý den cesty je.";
"complication.noTrip" = "Žádná cesta";
"a11y.done" = "Hotovo";
"a11y.open" = "Nehotovo";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Heute";
"today.next" = "Als Nächstes";
"today.now" = "Jetzt";
"today.later" = "Später";
"today.done" = "Tag geschafft";
"today.doneBody" = "Für heute ist nichts mehr geplant.";
"today.free" = "Freier Tag";
"today.freeBody" = "Heute ist nichts geplant. Genieß es.";
"today.plan" = "Tagesplan";
"day.of" = "Tag %1$d von %2$d";
"day.empty" = "Nichts geplant";
"time.now" = "jetzt";
"time.today" = "Heute";
"time.tomorrow" = "Morgen";
"countdown.title" = "Nächste Reise";
"booking.next" = "Nächste Buchung";
"booking.all" = "Buchungen";
"booking.none" = "Keine anstehenden Buchungen";
"booking.confirmed" = "Bestätigt";
"booking.pending" = "Ausstehend";
"booking.seat" = "Sitz";
"booking.platform" = "Gleis";
"booking.ref" = "Buchungsnr.";
"booking.checkIn" = "Check-in";
"booking.checkOut" = "Check-out";
"booking.departs" = "Abfahrt";
"booking.arrives" = "Ankunft";
"kind.flight" = "Flug";
"kind.train" = "Zug";
"kind.bus" = "Bus";
"kind.car" = "Auto";
"kind.cruise" = "Fähre";
"kind.hotel" = "Hotel";
"kind.restaurant" = "Restaurant";
"kind.event" = "Event";
"kind.tour" = "Tour";
"kind.other" = "Buchung";
"trip.title" = "Reise";
"trip.days" = "Tage";
"place.title" = "Ort";
"place.directions" = "Route";
"lists.title" = "Listen";
"packing.title" = "Packliste";
"packing.packed" = "gepackt";
"packing.packedSection" = "Gepackt";
"packing.allPacked" = "Alles gepackt";
"packing.other" = "Sonstiges";
"todos.title" = "To-dos";
"todos.open" = "offen";
"todos.doneSection" = "Erledigt";
"todos.allDone" = "Alles erledigt";
"empty.sync.title" = "Öffne TREK auf deinem iPhone";
"empty.sync.body" = "Deine Reise erscheint hier, sobald die App auf deinem iPhone synchronisiert hat.";
"empty.trip.title" = "Keine Reise geplant";
"empty.trip.body" = "Plane deine nächste Reise in TREK, dann erscheint sie hier.";
"complication.nextStop.name" = "Nächster Stopp";
"complication.nextStop.description" = "Der nächste Stopp des Tages und wann er beginnt.";
"complication.countdown.name" = "Countdown";
"complication.countdown.description" = "Zeit bis zum nächsten Flug, Zug, Check-in oder bis zur Reise.";
"complication.tripDay.name" = "Reisetag";
"complication.tripDay.description" = "Welcher Tag der Reise heute ist.";
"complication.noTrip" = "Keine Reise";
"a11y.done" = "Erledigt";
"a11y.open" = "Offen";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Σήμερα";
"today.next" = "Επόμενο";
"today.now" = "Τώρα";
"today.later" = "Αργότερα";
"today.done" = "Η μέρα τελείωσε";
"today.doneBody" = "Τίποτα άλλο για σήμερα.";
"today.free" = "Ελεύθερη μέρα";
"today.freeBody" = "Τίποτα προγραμματισμένο σήμερα. Απόλαυσέ το.";
"today.plan" = "Πρόγραμμα ημέρας";
"day.of" = "Ημέρα %1$d από %2$d";
"day.empty" = "Τίποτα προγραμματισμένο";
"time.now" = "τώρα";
"time.today" = "Σήμερα";
"time.tomorrow" = "Αύριο";
"countdown.title" = "Επόμενο ταξίδι";
"booking.next" = "Επόμενη κράτηση";
"booking.all" = "Κρατήσεις";
"booking.none" = "Καμία επερχόμενη κράτηση";
"booking.confirmed" = "Επιβεβαιωμένη";
"booking.pending" = "Εκκρεμεί";
"booking.seat" = "Θέση";
"booking.platform" = "Αποβάθρα";
"booking.ref" = "Κωδ. κράτησης";
"booking.checkIn" = "Check-in";
"booking.checkOut" = "Check-out";
"booking.departs" = "Αναχώρηση";
"booking.arrives" = "Άφιξη";
"kind.flight" = "Πτήση";
"kind.train" = "Τρένο";
"kind.bus" = "Λεωφορείο";
"kind.car" = "Αυτοκίνητο";
"kind.cruise" = "Πλοίο";
"kind.hotel" = "Ξενοδοχείο";
"kind.restaurant" = "Εστιατόριο";
"kind.event" = "Εκδήλωση";
"kind.tour" = "Περιήγηση";
"kind.other" = "Κράτηση";
"trip.title" = "Ταξίδι";
"trip.days" = "Ημέρες";
"place.title" = "Μέρος";
"place.directions" = "Οδηγίες";
"lists.title" = "Λίστες";
"packing.title" = "Βαλίτσα";
"packing.packed" = "στη βαλίτσα";
"packing.packedSection" = "Στη βαλίτσα";
"packing.allPacked" = "Όλα στη βαλίτσα";
"packing.other" = "Άλλα";
"todos.title" = "Εκκρεμότητες";
"todos.open" = "ανοιχτές";
"todos.doneSection" = "Ολοκληρώθηκαν";
"todos.allDone" = "Όλα έτοιμα";
"empty.sync.title" = "Άνοιξε το TREK στο iPhone";
"empty.sync.body" = "Το ταξίδι σου εμφανίζεται εδώ μόλις συγχρονιστεί η εφαρμογή στο iPhone.";
"empty.trip.title" = "Κανένα ταξίδι";
"empty.trip.body" = "Σχεδίασε το επόμενο ταξίδι στο TREK και θα εμφανιστεί εδώ.";
"complication.nextStop.name" = "Επόμενη στάση";
"complication.nextStop.description" = "Η επόμενη στάση της ημέρας και πότε ξεκινά.";
"complication.countdown.name" = "Αντίστροφη μέτρηση";
"complication.countdown.description" = "Χρόνος ως την επόμενη πτήση, το τρένο, το check-in ή το ταξίδι.";
"complication.tripDay.name" = "Ημέρα ταξιδιού";
"complication.tripDay.description" = "Ποια μέρα του ταξιδιού είναι.";
"complication.noTrip" = "Χωρίς ταξίδι";
"a11y.done" = "Έγινε";
"a11y.open" = "Εκκρεμεί";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Today";
"today.next" = "Next";
"today.now" = "Now";
"today.later" = "Later today";
"today.done" = "Day done";
"today.doneBody" = "Nothing more planned for today.";
"today.free" = "Free day";
"today.freeBody" = "Nothing planned today. Enjoy it.";
"today.plan" = "Today's plan";
"day.of" = "Day %1$d of %2$d";
"day.empty" = "Nothing planned";
"time.now" = "now";
"time.today" = "Today";
"time.tomorrow" = "Tomorrow";
"countdown.title" = "Next trip";
"booking.next" = "Next booking";
"booking.all" = "Bookings";
"booking.none" = "No bookings ahead";
"booking.confirmed" = "Confirmed";
"booking.pending" = "Pending";
"booking.seat" = "Seat";
"booking.platform" = "Platform";
"booking.ref" = "Booking ref";
"booking.checkIn" = "Check-in";
"booking.checkOut" = "Check-out";
"booking.departs" = "Departs";
"booking.arrives" = "Arrives";
"kind.flight" = "Flight";
"kind.train" = "Train";
"kind.bus" = "Bus";
"kind.car" = "Car";
"kind.cruise" = "Ferry";
"kind.hotel" = "Hotel";
"kind.restaurant" = "Restaurant";
"kind.event" = "Event";
"kind.tour" = "Tour";
"kind.other" = "Booking";
"trip.title" = "Trip";
"trip.days" = "Days";
"place.title" = "Place";
"place.directions" = "Directions";
"lists.title" = "Lists";
"packing.title" = "Packing";
"packing.packed" = "packed";
"packing.packedSection" = "Packed";
"packing.allPacked" = "Everything packed";
"packing.other" = "Other";
"todos.title" = "To-dos";
"todos.open" = "open";
"todos.doneSection" = "Done";
"todos.allDone" = "All done";
"empty.sync.title" = "Open TREK on your iPhone";
"empty.sync.body" = "Your trip shows up here once the app on your iPhone has synced.";
"empty.trip.title" = "No trip planned";
"empty.trip.body" = "Plan your next trip in TREK and it shows up here.";
"complication.nextStop.name" = "Next stop";
"complication.nextStop.description" = "The next stop of the day and when it starts.";
"complication.countdown.name" = "Countdown";
"complication.countdown.description" = "Time until your next flight, train, check-in or trip.";
"complication.tripDay.name" = "Trip day";
"complication.tripDay.description" = "Which day of the trip it is.";
"complication.noTrip" = "No trip";
"a11y.done" = "Done";
"a11y.open" = "Not done";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Hoy";
"today.next" = "Siguiente";
"today.now" = "Ahora";
"today.later" = "Más tarde";
"today.done" = "Día completado";
"today.doneBody" = "No hay nada más previsto para hoy.";
"today.free" = "Día libre";
"today.freeBody" = "Hoy no hay nada previsto. Disfrútalo.";
"today.plan" = "Plan de hoy";
"day.of" = "Día %1$d de %2$d";
"day.empty" = "Nada previsto";
"time.now" = "ahora";
"time.today" = "Hoy";
"time.tomorrow" = "Mañana";
"countdown.title" = "Próximo viaje";
"booking.next" = "Próxima reserva";
"booking.all" = "Reservas";
"booking.none" = "No hay reservas próximas";
"booking.confirmed" = "Confirmada";
"booking.pending" = "Pendiente";
"booking.seat" = "Asiento";
"booking.platform" = "Andén";
"booking.ref" = "Localizador";
"booking.checkIn" = "Entrada";
"booking.checkOut" = "Salida";
"booking.departs" = "Sale";
"booking.arrives" = "Llega";
"kind.flight" = "Vuelo";
"kind.train" = "Tren";
"kind.bus" = "Autobús";
"kind.car" = "Coche";
"kind.cruise" = "Ferri";
"kind.hotel" = "Hotel";
"kind.restaurant" = "Restaurante";
"kind.event" = "Evento";
"kind.tour" = "Tour";
"kind.other" = "Reserva";
"trip.title" = "Viaje";
"trip.days" = "Días";
"place.title" = "Lugar";
"place.directions" = "Cómo llegar";
"lists.title" = "Listas";
"packing.title" = "Equipaje";
"packing.packed" = "guardado";
"packing.packedSection" = "Guardado";
"packing.allPacked" = "Todo guardado";
"packing.other" = "Otros";
"todos.title" = "Tareas";
"todos.open" = "pendientes";
"todos.doneSection" = "Hechas";
"todos.allDone" = "Todo hecho";
"empty.sync.title" = "Abre TREK en tu iPhone";
"empty.sync.body" = "Tu viaje aparecerá aquí cuando la app de tu iPhone se haya sincronizado.";
"empty.trip.title" = "Ningún viaje previsto";
"empty.trip.body" = "Planifica tu próximo viaje en TREK y aparecerá aquí.";
"complication.nextStop.name" = "Siguiente parada";
"complication.nextStop.description" = "La siguiente parada del día y cuándo empieza.";
"complication.countdown.name" = "Cuenta atrás";
"complication.countdown.description" = "Tiempo hasta tu próximo vuelo, tren, entrada al hotel o viaje.";
"complication.tripDay.name" = "Día del viaje";
"complication.tripDay.description" = "En qué día del viaje estás.";
"complication.noTrip" = "Sin viaje";
"a11y.done" = "Hecha";
"a11y.open" = "Pendiente";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Täna";
"today.next" = "Järgmine";
"today.now" = "Praegu";
"today.later" = "Hiljem";
"today.done" = "Päev läbi";
"today.doneBody" = "Tänaseks pole rohkem midagi plaanis.";
"today.free" = "Vaba päev";
"today.freeBody" = "Täna pole midagi plaanis. Naudi.";
"today.plan" = "Tänane plaan";
"day.of" = "Päev %1$d/%2$d";
"day.empty" = "Midagi pole plaanis";
"time.now" = "praegu";
"time.today" = "Täna";
"time.tomorrow" = "Homme";
"countdown.title" = "Järgmine reis";
"booking.next" = "Järgmine broneering";
"booking.all" = "Broneeringud";
"booking.none" = "Eesootavaid broneeringuid pole";
"booking.confirmed" = "Kinnitatud";
"booking.pending" = "Ootel";
"booking.seat" = "Iste";
"booking.platform" = "Platvorm";
"booking.ref" = "Broneeringu kood";
"booking.checkIn" = "Sisseregistreerimine";
"booking.checkOut" = "Väljaregistreerimine";
"booking.departs" = "Väljub";
"booking.arrives" = "Saabub";
"kind.flight" = "Lend";
"kind.train" = "Rong";
"kind.bus" = "Buss";
"kind.car" = "Auto";
"kind.cruise" = "Praam";
"kind.hotel" = "Hotell";
"kind.restaurant" = "Restoran";
"kind.event" = "Üritus";
"kind.tour" = "Ekskursioon";
"kind.other" = "Broneering";
"trip.title" = "Reis";
"trip.days" = "Päevad";
"place.title" = "Koht";
"place.directions" = "Teejuhised";
"lists.title" = "Loendid";
"packing.title" = "Pakkimine";
"packing.packed" = "pakitud";
"packing.packedSection" = "Pakitud";
"packing.allPacked" = "Kõik pakitud";
"packing.other" = "Muu";
"todos.title" = "Ülesanded";
"todos.open" = "avatud";
"todos.doneSection" = "Tehtud";
"todos.allDone" = "Kõik tehtud";
"empty.sync.title" = "Ava TREK oma iPhone'is";
"empty.sync.body" = "Sinu reis ilmub siia, kui iPhone'i rakendus on sünkroonitud.";
"empty.trip.title" = "Reisi pole plaanitud";
"empty.trip.body" = "Planeeri järgmine reis TREKis ja see ilmub siia.";
"complication.nextStop.name" = "Järgmine peatus";
"complication.nextStop.description" = "Päeva järgmine peatus ja selle algus.";
"complication.countdown.name" = "Pöördloendus";
"complication.countdown.description" = "Aeg järgmise lennu, rongi, sisseregistreerimise või reisini.";
"complication.tripDay.name" = "Reisipäev";
"complication.tripDay.description" = "Mitmes reisipäev on.";
"complication.noTrip" = "Reisi pole";
"a11y.done" = "Tehtud";
"a11y.open" = "Tegemata";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Aujourd'hui";
"today.next" = "Ensuite";
"today.now" = "En cours";
"today.later" = "Plus tard";
"today.done" = "Journée terminée";
"today.doneBody" = "Plus rien de prévu aujourd'hui.";
"today.free" = "Journée libre";
"today.freeBody" = "Rien de prévu aujourd'hui. Profitez-en.";
"today.plan" = "Programme du jour";
"day.of" = "Jour %1$d sur %2$d";
"day.empty" = "Rien de prévu";
"time.now" = "maintenant";
"time.today" = "Aujourd'hui";
"time.tomorrow" = "Demain";
"countdown.title" = "Prochain voyage";
"booking.next" = "Prochaine réservation";
"booking.all" = "Réservations";
"booking.none" = "Aucune réservation à venir";
"booking.confirmed" = "Confirmée";
"booking.pending" = "En attente";
"booking.seat" = "Siège";
"booking.platform" = "Voie";
"booking.ref" = "Réf. de réservation";
"booking.checkIn" = "Arrivée";
"booking.checkOut" = "Départ";
"booking.departs" = "Départ";
"booking.arrives" = "Arrivée";
"kind.flight" = "Vol";
"kind.train" = "Train";
"kind.bus" = "Bus";
"kind.car" = "Voiture";
"kind.cruise" = "Ferry";
"kind.hotel" = "Hôtel";
"kind.restaurant" = "Restaurant";
"kind.event" = "Événement";
"kind.tour" = "Visite";
"kind.other" = "Réservation";
"trip.title" = "Voyage";
"trip.days" = "Jours";
"place.title" = "Lieu";
"place.directions" = "Itinéraire";
"lists.title" = "Listes";
"packing.title" = "Bagages";
"packing.packed" = "emballés";
"packing.packedSection" = "Dans la valise";
"packing.allPacked" = "Tout est emballé";
"packing.other" = "Autre";
"todos.title" = "À faire";
"todos.open" = "ouvertes";
"todos.doneSection" = "Terminées";
"todos.allDone" = "Tout est fait";
"empty.sync.title" = "Ouvrez TREK sur votre iPhone";
"empty.sync.body" = "Votre voyage apparaît ici dès que l'app de votre iPhone est synchronisée.";
"empty.trip.title" = "Aucun voyage prévu";
"empty.trip.body" = "Planifiez votre prochain voyage dans TREK et il apparaîtra ici.";
"complication.nextStop.name" = "Prochaine étape";
"complication.nextStop.description" = "La prochaine étape du jour et son heure de début.";
"complication.countdown.name" = "Compte à rebours";
"complication.countdown.description" = "Le temps restant avant votre prochain vol, train, arrivée à l'hôtel ou voyage.";
"complication.tripDay.name" = "Jour du voyage";
"complication.tripDay.description" = "Le jour du voyage où vous en êtes.";
"complication.noTrip" = "Aucun voyage";
"a11y.done" = "Fait";
"a11y.open" = "À faire";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "היום";
"today.next" = "הבא";
"today.now" = "עכשיו";
"today.later" = "בהמשך היום";
"today.done" = "היום הסתיים";
"today.doneBody" = "אין עוד תוכניות להיום.";
"today.free" = "יום חופשי";
"today.freeBody" = "אין תוכניות להיום. תיהנו.";
"today.plan" = "התוכנית להיום";
"day.of" = "יום %1$d מתוך %2$d";
"day.empty" = "אין תוכניות";
"time.now" = "עכשיו";
"time.today" = "היום";
"time.tomorrow" = "מחר";
"countdown.title" = "הטיול הבא";
"booking.next" = "ההזמנה הבאה";
"booking.all" = "הזמנות";
"booking.none" = "אין הזמנות קרובות";
"booking.confirmed" = "מאושרת";
"booking.pending" = "ממתינה";
"booking.seat" = "מושב";
"booking.platform" = "רציף";
"booking.ref" = "מספר הזמנה";
"booking.checkIn" = "צ'ק-אין";
"booking.checkOut" = "צ'ק-אאוט";
"booking.departs" = "יציאה";
"booking.arrives" = "הגעה";
"kind.flight" = "טיסה";
"kind.train" = "רכבת";
"kind.bus" = "אוטובוס";
"kind.car" = "רכב";
"kind.cruise" = "מעבורת";
"kind.hotel" = "מלון";
"kind.restaurant" = "מסעדה";
"kind.event" = "אירוע";
"kind.tour" = "סיור";
"kind.other" = "הזמנה";
"trip.title" = "טיול";
"trip.days" = "ימים";
"place.title" = "מקום";
"place.directions" = "הוראות הגעה";
"lists.title" = "רשימות";
"packing.title" = "אריזה";
"packing.packed" = "ארוזים";
"packing.packedSection" = "ארוז";
"packing.allPacked" = "הכול ארוז";
"packing.other" = "אחר";
"todos.title" = "משימות";
"todos.open" = "פתוחות";
"todos.doneSection" = "בוצעו";
"todos.allDone" = "הכול בוצע";
"empty.sync.title" = "פתחו את TREK ב-iPhone";
"empty.sync.body" = "הטיול יופיע כאן אחרי שהאפליקציה ב-iPhone תסתנכרן.";
"empty.trip.title" = "אין טיול מתוכנן";
"empty.trip.body" = "תכננו את הטיול הבא ב-TREK והוא יופיע כאן.";
"complication.nextStop.name" = "העצירה הבאה";
"complication.nextStop.description" = "העצירה הבאה של היום ומתי היא מתחילה.";
"complication.countdown.name" = "ספירה לאחור";
"complication.countdown.description" = "הזמן עד הטיסה, הרכבת, הצ'ק-אין או הטיול הבאים.";
"complication.tripDay.name" = "יום בטיול";
"complication.tripDay.description" = "באיזה יום של הטיול אתם.";
"complication.noTrip" = "אין טיול";
"a11y.done" = "בוצע";
"a11y.open" = "לא בוצע";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Ma";
"today.next" = "Következő";
"today.now" = "Most";
"today.later" = "Később";
"today.done" = "A nap kész";
"today.doneBody" = "Mára nincs több program.";
"today.free" = "Szabadnap";
"today.freeBody" = "Mára nincs program. Élvezd!";
"today.plan" = "Mai program";
"day.of" = "%1$d. nap / %2$d";
"day.empty" = "Nincs program";
"time.now" = "most";
"time.today" = "Ma";
"time.tomorrow" = "Holnap";
"countdown.title" = "Következő utazás";
"booking.next" = "Következő foglalás";
"booking.all" = "Foglalások";
"booking.none" = "Nincs közelgő foglalás";
"booking.confirmed" = "Megerősítve";
"booking.pending" = "Függőben";
"booking.seat" = "Ülés";
"booking.platform" = "Vágány";
"booking.ref" = "Foglalási szám";
"booking.checkIn" = "Bejelentkezés";
"booking.checkOut" = "Kijelentkezés";
"booking.departs" = "Indulás";
"booking.arrives" = "Érkezés";
"kind.flight" = "Repülés";
"kind.train" = "Vonat";
"kind.bus" = "Busz";
"kind.car" = "Autó";
"kind.cruise" = "Komp";
"kind.hotel" = "Szálloda";
"kind.restaurant" = "Étterem";
"kind.event" = "Esemény";
"kind.tour" = "Túra";
"kind.other" = "Foglalás";
"trip.title" = "Utazás";
"trip.days" = "Napok";
"place.title" = "Hely";
"place.directions" = "Útvonal";
"lists.title" = "Listák";
"packing.title" = "Csomagolás";
"packing.packed" = "bepakolva";
"packing.packedSection" = "Bepakolva";
"packing.allPacked" = "Minden bepakolva";
"packing.other" = "Egyéb";
"todos.title" = "Teendők";
"todos.open" = "nyitott";
"todos.doneSection" = "Kész";
"todos.allDone" = "Minden kész";
"empty.sync.title" = "Nyisd meg a TREK-et az iPhone-on";
"empty.sync.body" = "Az utazásod itt jelenik meg, amint az iPhone-os app szinkronizált.";
"empty.trip.title" = "Nincs tervezett utazás";
"empty.trip.body" = "Tervezd meg a következő utazást a TREK-ben, és itt megjelenik.";
"complication.nextStop.name" = "Következő megálló";
"complication.nextStop.description" = "A nap következő megállója és a kezdés ideje.";
"complication.countdown.name" = "Visszaszámlálás";
"complication.countdown.description" = "Idő a következő repülésig, vonatig, bejelentkezésig vagy utazásig.";
"complication.tripDay.name" = "Utazási nap";
"complication.tripDay.description" = "Hányadik napja tart az utazás.";
"complication.noTrip" = "Nincs utazás";
"a11y.done" = "Kész";
"a11y.open" = "Nincs kész";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Hari ini";
"today.next" = "Berikutnya";
"today.now" = "Sekarang";
"today.later" = "Nanti";
"today.done" = "Hari selesai";
"today.doneBody" = "Tidak ada rencana lagi untuk hari ini.";
"today.free" = "Hari bebas";
"today.freeBody" = "Tidak ada rencana hari ini. Nikmati.";
"today.plan" = "Rencana hari ini";
"day.of" = "Hari %1$d dari %2$d";
"day.empty" = "Tidak ada rencana";
"time.now" = "sekarang";
"time.today" = "Hari ini";
"time.tomorrow" = "Besok";
"countdown.title" = "Perjalanan berikutnya";
"booking.next" = "Pemesanan berikutnya";
"booking.all" = "Pemesanan";
"booking.none" = "Tidak ada pemesanan mendatang";
"booking.confirmed" = "Terkonfirmasi";
"booking.pending" = "Menunggu";
"booking.seat" = "Kursi";
"booking.platform" = "Peron";
"booking.ref" = "Kode pemesanan";
"booking.checkIn" = "Check-in";
"booking.checkOut" = "Check-out";
"booking.departs" = "Berangkat";
"booking.arrives" = "Tiba";
"kind.flight" = "Penerbangan";
"kind.train" = "Kereta";
"kind.bus" = "Bus";
"kind.car" = "Mobil";
"kind.cruise" = "Feri";
"kind.hotel" = "Hotel";
"kind.restaurant" = "Restoran";
"kind.event" = "Acara";
"kind.tour" = "Tur";
"kind.other" = "Pemesanan";
"trip.title" = "Perjalanan";
"trip.days" = "Hari";
"place.title" = "Tempat";
"place.directions" = "Petunjuk arah";
"lists.title" = "Daftar";
"packing.title" = "Bawaan";
"packing.packed" = "dikemas";
"packing.packedSection" = "Sudah dikemas";
"packing.allPacked" = "Semua sudah dikemas";
"packing.other" = "Lainnya";
"todos.title" = "Tugas";
"todos.open" = "terbuka";
"todos.doneSection" = "Selesai";
"todos.allDone" = "Semua selesai";
"empty.sync.title" = "Buka TREK di iPhone";
"empty.sync.body" = "Perjalananmu muncul di sini setelah app di iPhone tersinkron.";
"empty.trip.title" = "Belum ada perjalanan";
"empty.trip.body" = "Rencanakan perjalanan berikutnya di TREK dan akan muncul di sini.";
"complication.nextStop.name" = "Pemberhentian berikutnya";
"complication.nextStop.description" = "Pemberhentian berikutnya hari ini dan kapan dimulai.";
"complication.countdown.name" = "Hitung mundur";
"complication.countdown.description" = "Waktu menuju penerbangan, kereta, check-in, atau perjalanan berikutnya.";
"complication.tripDay.name" = "Hari perjalanan";
"complication.tripDay.description" = "Hari ke berapa dari perjalanan.";
"complication.noTrip" = "Tidak ada perjalanan";
"a11y.done" = "Selesai";
"a11y.open" = "Belum selesai";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Oggi";
"today.next" = "Prossimo";
"today.now" = "Ora";
"today.later" = "Più tardi";
"today.done" = "Giornata finita";
"today.doneBody" = "Per oggi non c'è altro in programma.";
"today.free" = "Giorno libero";
"today.freeBody" = "Oggi niente in programma. Goditelo.";
"today.plan" = "Programma di oggi";
"day.of" = "Giorno %1$d di %2$d";
"day.empty" = "Niente in programma";
"time.now" = "ora";
"time.today" = "Oggi";
"time.tomorrow" = "Domani";
"countdown.title" = "Prossimo viaggio";
"booking.next" = "Prossima prenotazione";
"booking.all" = "Prenotazioni";
"booking.none" = "Nessuna prenotazione in arrivo";
"booking.confirmed" = "Confermata";
"booking.pending" = "In attesa";
"booking.seat" = "Posto";
"booking.platform" = "Binario";
"booking.ref" = "Codice prenotazione";
"booking.checkIn" = "Check-in";
"booking.checkOut" = "Check-out";
"booking.departs" = "Partenza";
"booking.arrives" = "Arrivo";
"kind.flight" = "Volo";
"kind.train" = "Treno";
"kind.bus" = "Autobus";
"kind.car" = "Auto";
"kind.cruise" = "Traghetto";
"kind.hotel" = "Hotel";
"kind.restaurant" = "Ristorante";
"kind.event" = "Evento";
"kind.tour" = "Tour";
"kind.other" = "Prenotazione";
"trip.title" = "Viaggio";
"trip.days" = "Giorni";
"place.title" = "Luogo";
"place.directions" = "Indicazioni";
"lists.title" = "Liste";
"packing.title" = "Bagagli";
"packing.packed" = "in valigia";
"packing.packedSection" = "In valigia";
"packing.allPacked" = "Tutto in valigia";
"packing.other" = "Altro";
"todos.title" = "Da fare";
"todos.open" = "aperte";
"todos.doneSection" = "Fatte";
"todos.allDone" = "Tutto fatto";
"empty.sync.title" = "Apri TREK sul tuo iPhone";
"empty.sync.body" = "Il tuo viaggio apparirà qui appena l'app sul tuo iPhone si sarà sincronizzata.";
"empty.trip.title" = "Nessun viaggio in programma";
"empty.trip.body" = "Pianifica il tuo prossimo viaggio in TREK e apparirà qui.";
"complication.nextStop.name" = "Prossima tappa";
"complication.nextStop.description" = "La prossima tappa del giorno e quando inizia.";
"complication.countdown.name" = "Conto alla rovescia";
"complication.countdown.description" = "Il tempo che manca al prossimo volo, treno, check-in o viaggio.";
"complication.tripDay.name" = "Giorno di viaggio";
"complication.tripDay.description" = "Che giorno del viaggio è oggi.";
"complication.noTrip" = "Nessun viaggio";
"a11y.done" = "Fatto";
"a11y.open" = "Da fare";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "今日";
"today.next" = "次";
"today.now" = "進行中";
"today.later" = "このあと";
"today.done" = "今日はおしまい";
"today.doneBody" = "今日の予定はもうありません。";
"today.free" = "自由な一日";
"today.freeBody" = "今日は予定がありません。のんびりどうぞ。";
"today.plan" = "今日の予定";
"day.of" = "%2$d日中%1$d日目";
"day.empty" = "予定なし";
"time.now" = "今";
"time.today" = "今日";
"time.tomorrow" = "明日";
"countdown.title" = "次の旅行";
"booking.next" = "次の予約";
"booking.all" = "予約";
"booking.none" = "今後の予約はありません";
"booking.confirmed" = "確定";
"booking.pending" = "保留中";
"booking.seat" = "座席";
"booking.platform" = "番線";
"booking.ref" = "予約番号";
"booking.checkIn" = "チェックイン";
"booking.checkOut" = "チェックアウト";
"booking.departs" = "出発";
"booking.arrives" = "到着";
"kind.flight" = "フライト";
"kind.train" = "列車";
"kind.bus" = "バス";
"kind.car" = "車";
"kind.cruise" = "フェリー";
"kind.hotel" = "ホテル";
"kind.restaurant" = "レストラン";
"kind.event" = "イベント";
"kind.tour" = "ツアー";
"kind.other" = "予約";
"trip.title" = "旅行";
"trip.days" = "日程";
"place.title" = "スポット";
"place.directions" = "経路";
"lists.title" = "リスト";
"packing.title" = "持ち物";
"packing.packed" = "準備済み";
"packing.packedSection" = "準備済み";
"packing.allPacked" = "すべて準備完了";
"packing.other" = "その他";
"todos.title" = "ToDo";
"todos.open" = "未完了";
"todos.doneSection" = "完了";
"todos.allDone" = "すべて完了";
"empty.sync.title" = "iPhoneでTREKを開いてください";
"empty.sync.body" = "iPhoneのアプリが同期すると、ここに旅行が表示されます。";
"empty.trip.title" = "予定している旅行はありません";
"empty.trip.body" = "TREKで次の旅行を計画すると、ここに表示されます。";
"complication.nextStop.name" = "次の目的地";
"complication.nextStop.description" = "今日の次の目的地と開始時刻。";
"complication.countdown.name" = "カウントダウン";
"complication.countdown.description" = "次のフライト、列車、チェックイン、旅行までの時間。";
"complication.tripDay.name" = "旅行の日";
"complication.tripDay.description" = "旅行の何日目か。";
"complication.noTrip" = "旅行なし";
"a11y.done" = "完了";
"a11y.open" = "未完了";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "오늘";
"today.next" = "다음";
"today.now" = "진행 중";
"today.later" = "이후";
"today.done" = "오늘 일정 끝";
"today.doneBody" = "오늘은 더 이상 일정이 없습니다.";
"today.free" = "자유로운 날";
"today.freeBody" = "오늘은 일정이 없습니다. 즐기세요.";
"today.plan" = "오늘 일정";
"day.of" = "%2$d일 중 %1$d일째";
"day.empty" = "일정 없음";
"time.now" = "지금";
"time.today" = "오늘";
"time.tomorrow" = "내일";
"countdown.title" = "다음 여행";
"booking.next" = "다음 예약";
"booking.all" = "예약";
"booking.none" = "예정된 예약 없음";
"booking.confirmed" = "확정";
"booking.pending" = "대기 중";
"booking.seat" = "좌석";
"booking.platform" = "승강장";
"booking.ref" = "예약 번호";
"booking.checkIn" = "체크인";
"booking.checkOut" = "체크아웃";
"booking.departs" = "출발";
"booking.arrives" = "도착";
"kind.flight" = "항공편";
"kind.train" = "기차";
"kind.bus" = "버스";
"kind.car" = "자동차";
"kind.cruise" = "페리";
"kind.hotel" = "호텔";
"kind.restaurant" = "레스토랑";
"kind.event" = "이벤트";
"kind.tour" = "투어";
"kind.other" = "예약";
"trip.title" = "여행";
"trip.days" = "일정";
"place.title" = "장소";
"place.directions" = "경로";
"lists.title" = "목록";
"packing.title" = "짐 싸기";
"packing.packed" = "챙김";
"packing.packedSection" = "챙긴 짐";
"packing.allPacked" = "모두 챙김";
"packing.other" = "기타";
"todos.title" = "할 일";
"todos.open" = "남음";
"todos.doneSection" = "완료";
"todos.allDone" = "모두 완료";
"empty.sync.title" = "iPhone에서 TREK을 여세요";
"empty.sync.body" = "iPhone의 앱이 동기화되면 여행이 여기에 표시됩니다.";
"empty.trip.title" = "계획된 여행 없음";
"empty.trip.body" = "TREK에서 다음 여행을 계획하면 여기에 표시됩니다.";
"complication.nextStop.name" = "다음 장소";
"complication.nextStop.description" = "오늘의 다음 장소와 시작 시간.";
"complication.countdown.name" = "카운트다운";
"complication.countdown.description" = "다음 항공편, 기차, 체크인 또는 여행까지 남은 시간.";
"complication.tripDay.name" = "여행 일차";
"complication.tripDay.description" = "여행 며칠째인지 보여 줍니다.";
"complication.noTrip" = "여행 없음";
"a11y.done" = "완료";
"a11y.open" = "미완료";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Vandaag";
"today.next" = "Hierna";
"today.now" = "Nu";
"today.later" = "Later vandaag";
"today.done" = "Dag klaar";
"today.doneBody" = "Voor vandaag staat er niets meer gepland.";
"today.free" = "Vrije dag";
"today.freeBody" = "Vandaag staat er niets gepland. Geniet ervan.";
"today.plan" = "Dagplanning";
"day.of" = "Dag %1$d van %2$d";
"day.empty" = "Niets gepland";
"time.now" = "nu";
"time.today" = "Vandaag";
"time.tomorrow" = "Morgen";
"countdown.title" = "Volgende reis";
"booking.next" = "Volgende boeking";
"booking.all" = "Boekingen";
"booking.none" = "Geen komende boekingen";
"booking.confirmed" = "Bevestigd";
"booking.pending" = "In afwachting";
"booking.seat" = "Stoel";
"booking.platform" = "Spoor";
"booking.ref" = "Boekingsnr.";
"booking.checkIn" = "Inchecken";
"booking.checkOut" = "Uitchecken";
"booking.departs" = "Vertrek";
"booking.arrives" = "Aankomst";
"kind.flight" = "Vlucht";
"kind.train" = "Trein";
"kind.bus" = "Bus";
"kind.car" = "Auto";
"kind.cruise" = "Veerboot";
"kind.hotel" = "Hotel";
"kind.restaurant" = "Restaurant";
"kind.event" = "Evenement";
"kind.tour" = "Tour";
"kind.other" = "Boeking";
"trip.title" = "Reis";
"trip.days" = "Dagen";
"place.title" = "Plek";
"place.directions" = "Routebeschrijving";
"lists.title" = "Lijsten";
"packing.title" = "Paklijst";
"packing.packed" = "ingepakt";
"packing.packedSection" = "Ingepakt";
"packing.allPacked" = "Alles ingepakt";
"packing.other" = "Overig";
"todos.title" = "To-do's";
"todos.open" = "open";
"todos.doneSection" = "Klaar";
"todos.allDone" = "Alles klaar";
"empty.sync.title" = "Open TREK op je iPhone";
"empty.sync.body" = "Je reis verschijnt hier zodra de app op je iPhone is gesynchroniseerd.";
"empty.trip.title" = "Geen reis gepland";
"empty.trip.body" = "Plan je volgende reis in TREK en hij verschijnt hier.";
"complication.nextStop.name" = "Volgende stop";
"complication.nextStop.description" = "De volgende stop van de dag en wanneer die begint.";
"complication.countdown.name" = "Aftellen";
"complication.countdown.description" = "Tijd tot je volgende vlucht, trein, check-in of reis.";
"complication.tripDay.name" = "Reisdag";
"complication.tripDay.description" = "Welke dag van de reis het is.";
"complication.noTrip" = "Geen reis";
"a11y.done" = "Klaar";
"a11y.open" = "Open";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Dziś";
"today.next" = "Dalej";
"today.now" = "Teraz";
"today.later" = "Później";
"today.done" = "Dzień zakończony";
"today.doneBody" = "Na dziś nic więcej nie zaplanowano.";
"today.free" = "Wolny dzień";
"today.freeBody" = "Na dziś nic nie zaplanowano. Odpocznij.";
"today.plan" = "Plan dnia";
"day.of" = "Dzień %1$d z %2$d";
"day.empty" = "Nic nie zaplanowano";
"time.now" = "teraz";
"time.today" = "Dziś";
"time.tomorrow" = "Jutro";
"countdown.title" = "Następna podróż";
"booking.next" = "Następna rezerwacja";
"booking.all" = "Rezerwacje";
"booking.none" = "Brak nadchodzących rezerwacji";
"booking.confirmed" = "Potwierdzona";
"booking.pending" = "Oczekuje";
"booking.seat" = "Miejsce";
"booking.platform" = "Peron";
"booking.ref" = "Nr rezerwacji";
"booking.checkIn" = "Zameldowanie";
"booking.checkOut" = "Wymeldowanie";
"booking.departs" = "Odjazd";
"booking.arrives" = "Przyjazd";
"kind.flight" = "Lot";
"kind.train" = "Pociąg";
"kind.bus" = "Autobus";
"kind.car" = "Samochód";
"kind.cruise" = "Prom";
"kind.hotel" = "Hotel";
"kind.restaurant" = "Restauracja";
"kind.event" = "Wydarzenie";
"kind.tour" = "Wycieczka";
"kind.other" = "Rezerwacja";
"trip.title" = "Podróż";
"trip.days" = "Dni";
"place.title" = "Miejsce";
"place.directions" = "Wskazówki dojazdu";
"lists.title" = "Listy";
"packing.title" = "Pakowanie";
"packing.packed" = "spakowane";
"packing.packedSection" = "Spakowane";
"packing.allPacked" = "Wszystko spakowane";
"packing.other" = "Inne";
"todos.title" = "Zadania";
"todos.open" = "otwarte";
"todos.doneSection" = "Gotowe";
"todos.allDone" = "Wszystko gotowe";
"empty.sync.title" = "Otwórz TREK na iPhonie";
"empty.sync.body" = "Twoja podróż pojawi się tutaj, gdy aplikacja na iPhonie się zsynchronizuje.";
"empty.trip.title" = "Brak zaplanowanej podróży";
"empty.trip.body" = "Zaplanuj następną podróż w TREK, a pojawi się tutaj.";
"complication.nextStop.name" = "Następny przystanek";
"complication.nextStop.description" = "Następny przystanek dnia i godzina rozpoczęcia.";
"complication.countdown.name" = "Odliczanie";
"complication.countdown.description" = "Czas do następnego lotu, pociągu, zameldowania lub podróży.";
"complication.tripDay.name" = "Dzień podróży";
"complication.tripDay.description" = "Który to dzień podróży.";
"complication.noTrip" = "Brak podróży";
"a11y.done" = "Gotowe";
"a11y.open" = "Niegotowe";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Hoje";
"today.next" = "A seguir";
"today.now" = "Agora";
"today.later" = "Mais tarde";
"today.done" = "Dia concluído";
"today.doneBody" = "Nada mais planejado para hoje.";
"today.free" = "Dia livre";
"today.freeBody" = "Nada planejado para hoje. Aproveite.";
"today.plan" = "Plano de hoje";
"day.of" = "Dia %1$d de %2$d";
"day.empty" = "Nada planejado";
"time.now" = "agora";
"time.today" = "Hoje";
"time.tomorrow" = "Amanhã";
"countdown.title" = "Próxima viagem";
"booking.next" = "Próxima reserva";
"booking.all" = "Reservas";
"booking.none" = "Nenhuma reserva à frente";
"booking.confirmed" = "Confirmada";
"booking.pending" = "Pendente";
"booking.seat" = "Assento";
"booking.platform" = "Plataforma";
"booking.ref" = "Código da reserva";
"booking.checkIn" = "Check-in";
"booking.checkOut" = "Check-out";
"booking.departs" = "Partida";
"booking.arrives" = "Chegada";
"kind.flight" = "Voo";
"kind.train" = "Trem";
"kind.bus" = "Ônibus";
"kind.car" = "Carro";
"kind.cruise" = "Balsa";
"kind.hotel" = "Hotel";
"kind.restaurant" = "Restaurante";
"kind.event" = "Evento";
"kind.tour" = "Passeio";
"kind.other" = "Reserva";
"trip.title" = "Viagem";
"trip.days" = "Dias";
"place.title" = "Local";
"place.directions" = "Como chegar";
"lists.title" = "Listas";
"packing.title" = "Bagagem";
"packing.packed" = "na mala";
"packing.packedSection" = "Na mala";
"packing.allPacked" = "Tudo na mala";
"packing.other" = "Outros";
"todos.title" = "Tarefas";
"todos.open" = "abertas";
"todos.doneSection" = "Feitas";
"todos.allDone" = "Tudo feito";
"empty.sync.title" = "Abra o TREK no seu iPhone";
"empty.sync.body" = "Sua viagem aparece aqui assim que o app no seu iPhone sincronizar.";
"empty.trip.title" = "Nenhuma viagem planejada";
"empty.trip.body" = "Planeje sua próxima viagem no TREK e ela aparece aqui.";
"complication.nextStop.name" = "Próxima parada";
"complication.nextStop.description" = "A próxima parada do dia e quando começa.";
"complication.countdown.name" = "Contagem regressiva";
"complication.countdown.description" = "Tempo até o próximo voo, trem, check-in ou viagem.";
"complication.tripDay.name" = "Dia da viagem";
"complication.tripDay.description" = "Em que dia da viagem você está.";
"complication.noTrip" = "Sem viagem";
"a11y.done" = "Feita";
"a11y.open" = "Não feita";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Сегодня";
"today.next" = "Далее";
"today.now" = "Сейчас";
"today.later" = "Позже";
"today.done" = "День завершён";
"today.doneBody" = "На сегодня больше ничего не запланировано.";
"today.free" = "Свободный день";
"today.freeBody" = "На сегодня ничего не запланировано. Отдыхайте.";
"today.plan" = "План на день";
"day.of" = "День %1$d из %2$d";
"day.empty" = "Ничего не запланировано";
"time.now" = "сейчас";
"time.today" = "Сегодня";
"time.tomorrow" = "Завтра";
"countdown.title" = "Следующая поездка";
"booking.next" = "Следующая бронь";
"booking.all" = "Брони";
"booking.none" = "Предстоящих броней нет";
"booking.confirmed" = "Подтверждено";
"booking.pending" = "Ожидает";
"booking.seat" = "Место";
"booking.platform" = "Платформа";
"booking.ref" = "Номер брони";
"booking.checkIn" = "Заезд";
"booking.checkOut" = "Выезд";
"booking.departs" = "Отправление";
"booking.arrives" = "Прибытие";
"kind.flight" = "Рейс";
"kind.train" = "Поезд";
"kind.bus" = "Автобус";
"kind.car" = "Авто";
"kind.cruise" = "Паром";
"kind.hotel" = "Отель";
"kind.restaurant" = "Ресторан";
"kind.event" = "Событие";
"kind.tour" = "Экскурсия";
"kind.other" = "Бронь";
"trip.title" = "Поездка";
"trip.days" = "Дни";
"place.title" = "Место";
"place.directions" = "Маршрут";
"lists.title" = "Списки";
"packing.title" = "Сборы";
"packing.packed" = "собрано";
"packing.packedSection" = "Собрано";
"packing.allPacked" = "Всё собрано";
"packing.other" = "Другое";
"todos.title" = "Задачи";
"todos.open" = "открыто";
"todos.doneSection" = "Готово";
"todos.allDone" = "Всё готово";
"empty.sync.title" = "Откройте TREK на iPhone";
"empty.sync.body" = "Поездка появится здесь, как только приложение на iPhone синхронизируется.";
"empty.trip.title" = "Поездок не запланировано";
"empty.trip.body" = "Запланируйте следующую поездку в TREK, и она появится здесь.";
"complication.nextStop.name" = "Следующая точка";
"complication.nextStop.description" = "Следующая точка дня и время её начала.";
"complication.countdown.name" = "Обратный отсчёт";
"complication.countdown.description" = "Время до следующего рейса, поезда, заезда или поездки.";
"complication.tripDay.name" = "День поездки";
"complication.tripDay.description" = "Какой сейчас день поездки.";
"complication.noTrip" = "Нет поездки";
"a11y.done" = "Готово";
"a11y.open" = "Не готово";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Dnes";
"today.next" = "Ďalej";
"today.now" = "Teraz";
"today.later" = "Neskôr";
"today.done" = "Deň hotový";
"today.doneBody" = "Na dnes už nie je nič naplánované.";
"today.free" = "Voľný deň";
"today.freeBody" = "Dnes nie je nič naplánované. Užite si to.";
"today.plan" = "Dnešný plán";
"day.of" = "Deň %1$d z %2$d";
"day.empty" = "Nič naplánované";
"time.now" = "teraz";
"time.today" = "Dnes";
"time.tomorrow" = "Zajtra";
"countdown.title" = "Ďalšia cesta";
"booking.next" = "Ďalšia rezervácia";
"booking.all" = "Rezervácie";
"booking.none" = "Žiadne nadchádzajúce rezervácie";
"booking.confirmed" = "Potvrdené";
"booking.pending" = "Čaká";
"booking.seat" = "Sedadlo";
"booking.platform" = "Nástupište";
"booking.ref" = "Číslo rezervácie";
"booking.checkIn" = "Príchod";
"booking.checkOut" = "Odchod";
"booking.departs" = "Odchod";
"booking.arrives" = "Príchod";
"kind.flight" = "Let";
"kind.train" = "Vlak";
"kind.bus" = "Autobus";
"kind.car" = "Auto";
"kind.cruise" = "Trajekt";
"kind.hotel" = "Hotel";
"kind.restaurant" = "Reštaurácia";
"kind.event" = "Podujatie";
"kind.tour" = "Výlet";
"kind.other" = "Rezervácia";
"trip.title" = "Cesta";
"trip.days" = "Dni";
"place.title" = "Miesto";
"place.directions" = "Navigovať";
"lists.title" = "Zoznamy";
"packing.title" = "Balenie";
"packing.packed" = "zbalené";
"packing.packedSection" = "Zbalené";
"packing.allPacked" = "Všetko zbalené";
"packing.other" = "Ostatné";
"todos.title" = "Úlohy";
"todos.open" = "otvorené";
"todos.doneSection" = "Hotové";
"todos.allDone" = "Všetko hotové";
"empty.sync.title" = "Otvorte TREK v iPhone";
"empty.sync.body" = "Vaša cesta sa tu zobrazí, keď sa aplikácia v iPhone zosynchronizuje.";
"empty.trip.title" = "Žiadna naplánovaná cesta";
"empty.trip.body" = "Naplánujte ďalšiu cestu v TREK a zobrazí sa tu.";
"complication.nextStop.name" = "Ďalšia zastávka";
"complication.nextStop.description" = "Ďalšia zastávka dňa a kedy začína.";
"complication.countdown.name" = "Odpočítavanie";
"complication.countdown.description" = "Čas do ďalšieho letu, vlaku, ubytovania alebo cesty.";
"complication.tripDay.name" = "Deň cesty";
"complication.tripDay.description" = "Koľký deň cesty je.";
"complication.noTrip" = "Žiadna cesta";
"a11y.done" = "Hotové";
"a11y.open" = "Nehotové";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Idag";
"today.next" = "Nästa";
"today.now" = "Nu";
"today.later" = "Senare idag";
"today.done" = "Dagen klar";
"today.doneBody" = "Inget mer planerat för idag.";
"today.free" = "Ledig dag";
"today.freeBody" = "Inget planerat idag. Njut av det.";
"today.plan" = "Dagens plan";
"day.of" = "Dag %1$d av %2$d";
"day.empty" = "Inget planerat";
"time.now" = "nu";
"time.today" = "Idag";
"time.tomorrow" = "Imorgon";
"countdown.title" = "Nästa resa";
"booking.next" = "Nästa bokning";
"booking.all" = "Bokningar";
"booking.none" = "Inga kommande bokningar";
"booking.confirmed" = "Bekräftad";
"booking.pending" = "Väntar";
"booking.seat" = "Plats";
"booking.platform" = "Spår";
"booking.ref" = "Bokningsnr";
"booking.checkIn" = "Incheckning";
"booking.checkOut" = "Utcheckning";
"booking.departs" = "Avgår";
"booking.arrives" = "Ankommer";
"kind.flight" = "Flyg";
"kind.train" = "Tåg";
"kind.bus" = "Buss";
"kind.car" = "Bil";
"kind.cruise" = "Färja";
"kind.hotel" = "Hotell";
"kind.restaurant" = "Restaurang";
"kind.event" = "Evenemang";
"kind.tour" = "Tur";
"kind.other" = "Bokning";
"trip.title" = "Resa";
"trip.days" = "Dagar";
"place.title" = "Plats";
"place.directions" = "Vägbeskrivning";
"lists.title" = "Listor";
"packing.title" = "Packlista";
"packing.packed" = "packat";
"packing.packedSection" = "Packat";
"packing.allPacked" = "Allt packat";
"packing.other" = "Övrigt";
"todos.title" = "Att göra";
"todos.open" = "öppna";
"todos.doneSection" = "Klara";
"todos.allDone" = "Allt klart";
"empty.sync.title" = "Öppna TREK på din iPhone";
"empty.sync.body" = "Din resa visas här när appen på din iPhone har synkroniserats.";
"empty.trip.title" = "Ingen resa planerad";
"empty.trip.body" = "Planera din nästa resa i TREK så visas den här.";
"complication.nextStop.name" = "Nästa stopp";
"complication.nextStop.description" = "Dagens nästa stopp och när det börjar.";
"complication.countdown.name" = "Nedräkning";
"complication.countdown.description" = "Tid till nästa flyg, tåg, incheckning eller resa.";
"complication.tripDay.name" = "Resdag";
"complication.tripDay.description" = "Vilken dag av resan det är.";
"complication.noTrip" = "Ingen resa";
"a11y.done" = "Klar";
"a11y.open" = "Inte klar";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "วันนี้";
"today.next" = "ถัดไป";
"today.now" = "ตอนนี้";
"today.later" = "ภายหลัง";
"today.done" = "จบวันแล้ว";
"today.doneBody" = "วันนี้ไม่มีแผนอื่นแล้ว";
"today.free" = "วันว่าง";
"today.freeBody" = "วันนี้ไม่มีแผน พักผ่อนให้สบาย";
"today.plan" = "แผนวันนี้";
"day.of" = "วันที่ %1$d จาก %2$d";
"day.empty" = "ไม่มีแผน";
"time.now" = "ตอนนี้";
"time.today" = "วันนี้";
"time.tomorrow" = "พรุ่งนี้";
"countdown.title" = "ทริปถัดไป";
"booking.next" = "การจองถัดไป";
"booking.all" = "การจอง";
"booking.none" = "ไม่มีการจองที่จะถึง";
"booking.confirmed" = "ยืนยันแล้ว";
"booking.pending" = "รอดำเนินการ";
"booking.seat" = "ที่นั่ง";
"booking.platform" = "ชานชาลา";
"booking.ref" = "รหัสการจอง";
"booking.checkIn" = "เช็คอิน";
"booking.checkOut" = "เช็คเอาท์";
"booking.departs" = "ออกเดินทาง";
"booking.arrives" = "ถึง";
"kind.flight" = "เที่ยวบิน";
"kind.train" = "รถไฟ";
"kind.bus" = "รถบัส";
"kind.car" = "รถยนต์";
"kind.cruise" = "เรือเฟอร์รี่";
"kind.hotel" = "โรงแรม";
"kind.restaurant" = "ร้านอาหาร";
"kind.event" = "อีเวนต์";
"kind.tour" = "ทัวร์";
"kind.other" = "การจอง";
"trip.title" = "ทริป";
"trip.days" = "วัน";
"place.title" = "สถานที่";
"place.directions" = "เส้นทาง";
"lists.title" = "รายการ";
"packing.title" = "จัดกระเป๋า";
"packing.packed" = "จัดแล้ว";
"packing.packedSection" = "จัดแล้ว";
"packing.allPacked" = "จัดครบแล้ว";
"packing.other" = "อื่นๆ";
"todos.title" = "สิ่งที่ต้องทำ";
"todos.open" = "ค้างอยู่";
"todos.doneSection" = "เสร็จแล้ว";
"todos.allDone" = "เสร็จหมดแล้ว";
"empty.sync.title" = "เปิด TREK บน iPhone";
"empty.sync.body" = "ทริปของคุณจะแสดงที่นี่เมื่อแอปบน iPhone ซิงค์แล้ว";
"empty.trip.title" = "ยังไม่มีทริป";
"empty.trip.body" = "วางแผนทริปถัดไปใน TREK แล้วจะแสดงที่นี่";
"complication.nextStop.name" = "จุดแวะถัดไป";
"complication.nextStop.description" = "จุดแวะถัดไปของวันและเวลาที่เริ่ม";
"complication.countdown.name" = "นับถอยหลัง";
"complication.countdown.description" = "เวลาที่เหลือก่อนเที่ยวบิน รถไฟ เช็คอิน หรือทริปถัดไป";
"complication.tripDay.name" = "วันของทริป";
"complication.tripDay.description" = "วันนี้เป็นวันที่เท่าไรของทริป";
"complication.noTrip" = "ไม่มีทริป";
"a11y.done" = "เสร็จแล้ว";
"a11y.open" = "ยังไม่เสร็จ";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Bugün";
"today.next" = "Sıradaki";
"today.now" = "Şimdi";
"today.later" = "Daha sonra";
"today.done" = "Gün tamam";
"today.doneBody" = "Bugün için başka plan yok.";
"today.free" = "Serbest gün";
"today.freeBody" = "Bugün plan yok. Tadını çıkar.";
"today.plan" = "Bugünün planı";
"day.of" = "Gün %1$d / %2$d";
"day.empty" = "Plan yok";
"time.now" = "şimdi";
"time.today" = "Bugün";
"time.tomorrow" = "Yarın";
"countdown.title" = "Sonraki seyahat";
"booking.next" = "Sonraki rezervasyon";
"booking.all" = "Rezervasyonlar";
"booking.none" = "Yaklaşan rezervasyon yok";
"booking.confirmed" = "Onaylandı";
"booking.pending" = "Beklemede";
"booking.seat" = "Koltuk";
"booking.platform" = "Peron";
"booking.ref" = "Rezervasyon no.";
"booking.checkIn" = "Giriş";
"booking.checkOut" = "Çıkış";
"booking.departs" = "Kalkış";
"booking.arrives" = "Varış";
"kind.flight" = "Uçuş";
"kind.train" = "Tren";
"kind.bus" = "Otobüs";
"kind.car" = "Araba";
"kind.cruise" = "Feribot";
"kind.hotel" = "Otel";
"kind.restaurant" = "Restoran";
"kind.event" = "Etkinlik";
"kind.tour" = "Tur";
"kind.other" = "Rezervasyon";
"trip.title" = "Seyahat";
"trip.days" = "Günler";
"place.title" = "Yer";
"place.directions" = "Yol tarifi";
"lists.title" = "Listeler";
"packing.title" = "Bavul";
"packing.packed" = "bavulda";
"packing.packedSection" = "Bavulda";
"packing.allPacked" = "Her şey bavulda";
"packing.other" = "Diğer";
"todos.title" = "Yapılacaklar";
"todos.open" = "açık";
"todos.doneSection" = "Bitti";
"todos.allDone" = "Hepsi bitti";
"empty.sync.title" = "TREK'i iPhone'unda aç";
"empty.sync.body" = "Seyahatin, iPhone'undaki uygulama eşitlendiğinde burada görünür.";
"empty.trip.title" = "Planlanmış seyahat yok";
"empty.trip.body" = "Sonraki seyahatini TREK'te planla, burada görünsün.";
"complication.nextStop.name" = "Sonraki durak";
"complication.nextStop.description" = "Günün sonraki durağı ve başlama saati.";
"complication.countdown.name" = "Geri sayım";
"complication.countdown.description" = "Sonraki uçuşa, trene, otele girişe veya seyahate kalan süre.";
"complication.tripDay.name" = "Seyahat günü";
"complication.tripDay.description" = "Seyahatin kaçıncı günü olduğu.";
"complication.noTrip" = "Seyahat yok";
"a11y.done" = "Bitti";
"a11y.open" = "Bitmedi";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Сьогодні";
"today.next" = "Далі";
"today.now" = "Зараз";
"today.later" = "Пізніше";
"today.done" = "День завершено";
"today.doneBody" = "На сьогодні більше нічого не заплановано.";
"today.free" = "Вільний день";
"today.freeBody" = "На сьогодні нічого не заплановано. Відпочивайте.";
"today.plan" = "План на день";
"day.of" = "День %1$d з %2$d";
"day.empty" = "Нічого не заплановано";
"time.now" = "зараз";
"time.today" = "Сьогодні";
"time.tomorrow" = "Завтра";
"countdown.title" = "Наступна подорож";
"booking.next" = "Наступне бронювання";
"booking.all" = "Бронювання";
"booking.none" = "Немає майбутніх бронювань";
"booking.confirmed" = "Підтверджено";
"booking.pending" = "Очікує";
"booking.seat" = "Місце";
"booking.platform" = "Платформа";
"booking.ref" = "Номер бронювання";
"booking.checkIn" = "Заїзд";
"booking.checkOut" = "Виїзд";
"booking.departs" = "Відправлення";
"booking.arrives" = "Прибуття";
"kind.flight" = "Рейс";
"kind.train" = "Потяг";
"kind.bus" = "Автобус";
"kind.car" = "Авто";
"kind.cruise" = "Пором";
"kind.hotel" = "Готель";
"kind.restaurant" = "Ресторан";
"kind.event" = "Подія";
"kind.tour" = "Екскурсія";
"kind.other" = "Бронювання";
"trip.title" = "Подорож";
"trip.days" = "Дні";
"place.title" = "Місце";
"place.directions" = "Маршрут";
"lists.title" = "Списки";
"packing.title" = "Пакування";
"packing.packed" = "спаковано";
"packing.packedSection" = "Спаковано";
"packing.allPacked" = "Усе спаковано";
"packing.other" = "Інше";
"todos.title" = "Завдання";
"todos.open" = "відкрито";
"todos.doneSection" = "Виконано";
"todos.allDone" = "Усе виконано";
"empty.sync.title" = "Відкрийте TREK на iPhone";
"empty.sync.body" = "Подорож з’явиться тут, щойно застосунок на iPhone синхронізується.";
"empty.trip.title" = "Подорожей не заплановано";
"empty.trip.body" = "Сплануйте наступну подорож у TREK, і вона з’явиться тут.";
"complication.nextStop.name" = "Наступна зупинка";
"complication.nextStop.description" = "Наступна зупинка дня і час її початку.";
"complication.countdown.name" = "Зворотний відлік";
"complication.countdown.description" = "Час до наступного рейсу, потяга, заїзду чи подорожі.";
"complication.tripDay.name" = "День подорожі";
"complication.tripDay.description" = "Який зараз день подорожі.";
"complication.noTrip" = "Немає подорожі";
"a11y.done" = "Виконано";
"a11y.open" = "Не виконано";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "Hôm nay";
"today.next" = "Tiếp theo";
"today.now" = "Đang diễn ra";
"today.later" = "Lát nữa";
"today.done" = "Xong ngày";
"today.doneBody" = "Hôm nay không còn gì trong kế hoạch.";
"today.free" = "Ngày tự do";
"today.freeBody" = "Hôm nay không có kế hoạch. Hãy tận hưởng.";
"today.plan" = "Kế hoạch hôm nay";
"day.of" = "Ngày %1$d/%2$d";
"day.empty" = "Chưa có kế hoạch";
"time.now" = "bây giờ";
"time.today" = "Hôm nay";
"time.tomorrow" = "Ngày mai";
"countdown.title" = "Chuyến đi tới";
"booking.next" = "Đặt chỗ tiếp theo";
"booking.all" = "Đặt chỗ";
"booking.none" = "Không có đặt chỗ sắp tới";
"booking.confirmed" = "Đã xác nhận";
"booking.pending" = "Đang chờ";
"booking.seat" = "Ghế";
"booking.platform" = "Sân ga";
"booking.ref" = "Mã đặt chỗ";
"booking.checkIn" = "Nhận phòng";
"booking.checkOut" = "Trả phòng";
"booking.departs" = "Khởi hành";
"booking.arrives" = "Đến nơi";
"kind.flight" = "Chuyến bay";
"kind.train" = "Tàu hỏa";
"kind.bus" = "Xe buýt";
"kind.car" = "Ô tô";
"kind.cruise" = "Phà";
"kind.hotel" = "Khách sạn";
"kind.restaurant" = "Nhà hàng";
"kind.event" = "Sự kiện";
"kind.tour" = "Tour";
"kind.other" = "Đặt chỗ";
"trip.title" = "Chuyến đi";
"trip.days" = "Ngày";
"place.title" = "Địa điểm";
"place.directions" = "Chỉ đường";
"lists.title" = "Danh sách";
"packing.title" = "Hành lý";
"packing.packed" = "đã xếp";
"packing.packedSection" = "Đã xếp";
"packing.allPacked" = "Đã xếp hết";
"packing.other" = "Khác";
"todos.title" = "Việc cần làm";
"todos.open" = "còn lại";
"todos.doneSection" = "Đã xong";
"todos.allDone" = "Xong hết";
"empty.sync.title" = "Mở TREK trên iPhone";
"empty.sync.body" = "Chuyến đi sẽ hiện ở đây khi ứng dụng trên iPhone đồng bộ xong.";
"empty.trip.title" = "Chưa có chuyến đi";
"empty.trip.body" = "Lên kế hoạch chuyến đi tiếp theo trong TREK, nó sẽ hiện ở đây.";
"complication.nextStop.name" = "Điểm dừng tiếp theo";
"complication.nextStop.description" = "Điểm dừng tiếp theo trong ngày và giờ bắt đầu.";
"complication.countdown.name" = "Đếm ngược";
"complication.countdown.description" = "Thời gian đến chuyến bay, tàu, nhận phòng hoặc chuyến đi tiếp theo.";
"complication.tripDay.name" = "Ngày của chuyến đi";
"complication.tripDay.description" = "Hôm nay là ngày thứ mấy của chuyến đi.";
"complication.noTrip" = "Không có chuyến đi";
"a11y.done" = "Đã xong";
"a11y.open" = "Chưa xong";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "今天";
"today.next" = "下一站";
"today.now" = "进行中";
"today.later" = "稍后";
"today.done" = "今天结束了";
"today.doneBody" = "今天没有其他安排了。";
"today.free" = "空闲日";
"today.freeBody" = "今天没有安排,好好享受。";
"today.plan" = "今日行程";
"day.of" = "第 %1$d 天,共 %2$d 天";
"day.empty" = "没有安排";
"time.now" = "现在";
"time.today" = "今天";
"time.tomorrow" = "明天";
"countdown.title" = "下次旅行";
"booking.next" = "下一个预订";
"booking.all" = "预订";
"booking.none" = "没有即将到来的预订";
"booking.confirmed" = "已确认";
"booking.pending" = "待确认";
"booking.seat" = "座位";
"booking.platform" = "站台";
"booking.ref" = "预订号";
"booking.checkIn" = "入住";
"booking.checkOut" = "退房";
"booking.departs" = "出发";
"booking.arrives" = "到达";
"kind.flight" = "航班";
"kind.train" = "火车";
"kind.bus" = "巴士";
"kind.car" = "汽车";
"kind.cruise" = "渡轮";
"kind.hotel" = "酒店";
"kind.restaurant" = "餐厅";
"kind.event" = "活动";
"kind.tour" = "游览";
"kind.other" = "预订";
"trip.title" = "旅行";
"trip.days" = "日程";
"place.title" = "地点";
"place.directions" = "路线";
"lists.title" = "清单";
"packing.title" = "行李";
"packing.packed" = "已装好";
"packing.packedSection" = "已装好";
"packing.allPacked" = "全部装好了";
"packing.other" = "其他";
"todos.title" = "待办";
"todos.open" = "未完成";
"todos.doneSection" = "已完成";
"todos.allDone" = "全部完成";
"empty.sync.title" = "在 iPhone 上打开 TREK";
"empty.sync.body" = "iPhone 上的 App 同步后,你的旅行会显示在这里。";
"empty.trip.title" = "没有计划中的旅行";
"empty.trip.body" = "在 TREK 中计划下一次旅行,它就会显示在这里。";
"complication.nextStop.name" = "下一站";
"complication.nextStop.description" = "今天的下一站及开始时间。";
"complication.countdown.name" = "倒计时";
"complication.countdown.description" = "距下一个航班、火车、入住或旅行的时间。";
"complication.tripDay.name" = "旅行第几天";
"complication.tripDay.description" = "今天是旅行的第几天。";
"complication.noTrip" = "没有旅行";
"a11y.done" = "已完成";
"a11y.open" = "未完成";
@@ -0,0 +1,65 @@
/* The watch app and its complications (TREKWatch, TREKWatchKit, TREKWatchWidgets). */
"today.title" = "今天";
"today.next" = "下一站";
"today.now" = "進行中";
"today.later" = "稍後";
"today.done" = "今天結束了";
"today.doneBody" = "今天沒有其他安排了。";
"today.free" = "空閒日";
"today.freeBody" = "今天沒有安排,好好享受。";
"today.plan" = "今日行程";
"day.of" = "第 %1$d 天,共 %2$d 天";
"day.empty" = "沒有安排";
"time.now" = "現在";
"time.today" = "今天";
"time.tomorrow" = "明天";
"countdown.title" = "下次旅行";
"booking.next" = "下一個預訂";
"booking.all" = "預訂";
"booking.none" = "沒有即將到來的預訂";
"booking.confirmed" = "已確認";
"booking.pending" = "待確認";
"booking.seat" = "座位";
"booking.platform" = "月台";
"booking.ref" = "訂位代號";
"booking.checkIn" = "入住";
"booking.checkOut" = "退房";
"booking.departs" = "出發";
"booking.arrives" = "抵達";
"kind.flight" = "航班";
"kind.train" = "火車";
"kind.bus" = "巴士";
"kind.car" = "汽車";
"kind.cruise" = "渡輪";
"kind.hotel" = "飯店";
"kind.restaurant" = "餐廳";
"kind.event" = "活動";
"kind.tour" = "導覽";
"kind.other" = "預訂";
"trip.title" = "旅行";
"trip.days" = "行程天數";
"place.title" = "地點";
"place.directions" = "路線";
"lists.title" = "清單";
"packing.title" = "行李";
"packing.packed" = "已裝好";
"packing.packedSection" = "已裝好";
"packing.allPacked" = "全部裝好了";
"packing.other" = "其他";
"todos.title" = "待辦";
"todos.open" = "未完成";
"todos.doneSection" = "已完成";
"todos.allDone" = "全部完成";
"empty.sync.title" = "在 iPhone 上打開 TREK";
"empty.sync.body" = "iPhone 上的 App 同步後,你的旅行會顯示在這裡。";
"empty.trip.title" = "沒有計畫中的旅行";
"empty.trip.body" = "在 TREK 中計畫下一次旅行,它就會顯示在這裡。";
"complication.nextStop.name" = "下一站";
"complication.nextStop.description" = "今天的下一站及開始時間。";
"complication.countdown.name" = "倒數計時";
"complication.countdown.description" = "距下一個航班、火車、入住或旅行的時間。";
"complication.tripDay.name" = "旅行第幾天";
"complication.tripDay.description" = "今天是旅行的第幾天。";
"complication.noTrip" = "沒有旅行";
"a11y.done" = "已完成";
"a11y.open" = "未完成";
@@ -0,0 +1,356 @@
import SwiftUI
import WidgetKit
/// The sizes the complications draw for. Their own enum rather than
/// WidgetKit's, so the watch app's screenshot mode can lay them out too.
enum ComplicationFamily: String, CaseIterable {
case circular, rectangular, corner, inline
var widgetFamily: WidgetFamily {
switch self {
case .circular: return .accessoryCircular
case .rectangular: return .accessoryRectangular
case .corner: return .accessoryCorner
case .inline: return .accessoryInline
}
}
init?(_ family: WidgetFamily) {
switch family {
case .accessoryCircular: self = .circular
case .accessoryRectangular: self = .rectangular
case .accessoryCorner: self = .corner
case .accessoryInline: self = .inline
default: return nil
}
}
}
/// Which complication an idle face belongs to: each says something of its own.
enum ComplicationKind { case nextStop, countdown, tripDay }
/// What the complications read from a snapshot at a moment.
struct ComplicationState {
struct NextStop {
var stop: WatchSnapshot.Stop
var start: Date?
var isNow: Bool
}
struct Countdown {
var title: String
var detail: String?
var symbol: String
var color: Color
var target: Date
}
var tripTitle: String?
var phase: WatchSnapshot.Phase
var next: NextStop?
var countdown: Countdown?
var hour12: Bool
init(_ snapshot: WatchSnapshot?, now: Date) {
tripTitle = snapshot?.trip?.title
phase = snapshot?.phase(at: now) ?? .none
hour12 = snapshot?.hour12 ?? false
let minutes = TrekDate.minutesOfDay(now)
if let day = snapshot?.today(at: now) {
let current = day.stops.first { ($0.minutes ?? .max) <= minutes && minutes < ($0.endMinutes ?? -1) }
let upcoming = day.stops.first { ($0.minutes ?? -1) >= minutes }
// A day planned without times still has its places: the first of them, not "day done".
let untimed = day.stops.first { $0.minutes == nil }
if let stop = current ?? upcoming ?? untimed {
next = NextStop(stop: stop, start: stop.minutes.map { TrekDate.at($0, on: now) }, isNow: current != nil)
}
}
// Only bookings with a time: one that lasts the whole day has no moment to count down to.
if let booking = snapshot?.bookings.first(where: { !$0.isAllDay && ($0.start ?? .distantPast) > now }), let start = booking.start {
let look = Trek.booking(booking.kind)
countdown = Countdown(title: booking.route ?? booking.title, detail: booking.route == nil ? nil : booking.carrier,
symbol: look.symbol, color: look.color, target: start)
} else if let trip = snapshot?.trip, case .upcoming = phase, let start = TrekDate.day(trip.start) {
countdown = Countdown(title: trip.title, detail: trip.place, symbol: "suitcase.rolling.fill", color: Trek.info, target: start)
}
}
/// The moments within `window` a complication should redraw at: the start
/// and end of every stop on every day of the plan in it, every timed
/// booking, and each midnight.
static func changes(_ snapshot: WatchSnapshot?, from now: Date, window: TimeInterval = 86_400 * 2) -> [Date] {
guard let snapshot else { return [] }
let horizon = now.addingTimeInterval(window)
var dates: [Date] = []
for day in snapshot.days {
guard let date = day.dateValue, date <= horizon, date >= Calendar.current.startOfDay(for: now) else { continue }
for stop in day.stops {
if let start = stop.minutes { dates.append(TrekDate.at(start, on: date)) }
if let end = stop.endMinutes { dates.append(TrekDate.at(end, on: date)) }
}
}
dates += snapshot.bookings.filter { !$0.isAllDay }.compactMap(\.start)
var midnight = Calendar.current.startOfDay(for: now)
while let next = Calendar.current.date(byAdding: .day, value: 1, to: midnight), next <= horizon {
dates.append(next)
midnight = next
}
return dates.filter { $0 > now && $0 <= horizon }.sorted()
}
}
// MARK: - Next stop
struct NextStopComplication: View {
let state: ComplicationState
let family: ComplicationFamily
let now: Date
var body: some View {
if let next = state.next {
let time = next.stop.minutes.map { WatchFormat.time(minutes: $0, hour12: state.hour12) }
switch family {
case .rectangular:
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: WatchLook.stopSymbol(next.stop)).widgetAccentable()
Text(next.isNow ? L("today.now") : (time ?? L("today.next")))
.fontWeight(.semibold)
.widgetAccentable()
if let start = next.start, !next.isNow, start > now {
// Counts down by itself between timeline entries.
Text(start, style: .relative).foregroundStyle(.secondary).lineLimit(1)
}
}
.font(.system(.footnote, design: .rounded))
Text(next.stop.name)
.font(.system(.headline, design: .rounded))
.lineLimit(2)
.minimumScaleFactor(0.8)
if case .running(let day, let total) = state.phase {
Text(WatchFormat.dayOf(day, total))
.font(.system(.caption2, design: .rounded))
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
case .circular:
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 0) {
Image(systemName: WatchLook.stopSymbol(next.stop))
.font(.system(size: 15, weight: .semibold))
.widgetAccentable()
Text(time ?? "")
.font(.system(size: 12, weight: .bold, design: .rounded).monospacedDigit())
.minimumScaleFactor(0.6)
}
.padding(4)
}
case .corner:
Image(systemName: WatchLook.stopSymbol(next.stop))
.font(.system(size: 20, weight: .semibold))
.widgetAccentable()
.widgetLabel { Text([time, next.stop.name].compactMap { $0 }.joined(separator: " ")) }
case .inline:
Label([time, next.stop.name].compactMap { $0 }.joined(separator: " "), systemImage: WatchLook.stopSymbol(next.stop))
}
} else {
ComplicationIdle(state: state, family: family, kind: .nextStop)
}
}
}
// MARK: - Countdown
struct CountdownComplication: View {
let state: ComplicationState
let family: ComplicationFamily
let now: Date
var body: some View {
if let countdown = state.countdown {
switch family {
case .rectangular:
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: countdown.symbol).widgetAccentable()
Text(countdown.title).fontWeight(.semibold).lineLimit(1)
}
.font(.system(.footnote, design: .rounded))
Text(countdown.target, style: .relative)
.font(.system(.title3, design: .rounded).weight(.bold).monospacedDigit())
.widgetAccentable()
.lineLimit(1)
.minimumScaleFactor(0.7)
if let detail = countdown.detail {
Text(detail).font(.system(.caption2, design: .rounded)).foregroundStyle(.secondary).lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
case .circular:
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 0) {
Image(systemName: countdown.symbol).font(.system(size: 14, weight: .semibold)).widgetAccentable()
Text(short(countdown.target))
.font(.system(size: 13, weight: .bold, design: .rounded).monospacedDigit())
.minimumScaleFactor(0.5)
}
.padding(4)
}
case .corner:
Image(systemName: countdown.symbol)
.font(.system(size: 20, weight: .semibold))
.widgetAccentable()
.widgetLabel { Text(countdown.target, style: .relative) }
case .inline:
Label {
Text("\(countdown.title) · ") + Text(countdown.target, style: .relative)
} icon: {
Image(systemName: countdown.symbol)
}
}
} else {
ComplicationIdle(state: state, family: family, kind: .countdown)
}
}
/// The single biggest unit: "2h", "35m", "12d".
private func short(_ target: Date) -> String {
let formatter = WatchFormat.components()
formatter.unitsStyle = .abbreviated
formatter.maximumUnitCount = 1
let seconds = max(target.timeIntervalSince(now), 60)
formatter.allowedUnits = seconds >= 86_400 ? [.day] : seconds >= 3_600 ? [.hour] : [.minute]
return formatter.string(from: seconds) ?? ""
}
}
// MARK: - Trip day
struct TripDayComplication: View {
let state: ComplicationState
let family: ComplicationFamily
var body: some View {
switch state.phase {
case .running(let day, let total):
running(day, total)
case .upcoming(let days):
upcoming(days)
default:
ComplicationIdle(state: state, family: family, kind: .tripDay)
}
}
@ViewBuilder
private func running(_ day: Int, _ total: Int) -> some View {
switch family {
case .circular:
Gauge(value: Double(day), in: 0...Double(max(total, 1))) {
Image(systemName: "suitcase.rolling.fill")
} currentValueLabel: {
Text("\(day)").font(.system(.title3, design: .rounded).weight(.bold))
}
.gaugeStyle(.accessoryCircularCapacity)
.widgetAccentable()
case .rectangular:
VStack(alignment: .leading, spacing: 3) {
Text(WatchFormat.dayOf(day, total))
.font(.system(.headline, design: .rounded))
.widgetAccentable()
Text(state.tripTitle ?? "")
.font(.system(.footnote, design: .rounded))
.foregroundStyle(.secondary)
.lineLimit(1)
DaySegments(index: day, total: total, color: .white, track: .white.opacity(0.25))
.widgetAccentable()
}
.frame(maxWidth: .infinity, alignment: .leading)
case .corner:
Text("\(day)")
.font(.system(size: 20, weight: .bold, design: .rounded))
.widgetAccentable()
.widgetLabel {
Gauge(value: Double(day), in: 0...Double(max(total, 1))) { Text(state.tripTitle ?? "") }
}
case .inline:
Label(WatchFormat.dayOf(day, total), systemImage: "suitcase.rolling.fill")
}
}
@ViewBuilder
private func upcoming(_ days: Int) -> some View {
let unit = WatchFormat.daysUnit(days)
switch family {
case .circular:
ZStack {
AccessoryWidgetBackground()
VStack(spacing: -2) {
Text("\(days)").font(.system(size: 20, weight: .heavy, design: .rounded)).widgetAccentable()
Text(unit).font(.system(size: 9, weight: .semibold, design: .rounded)).lineLimit(1).minimumScaleFactor(0.6)
}
.padding(4)
}
case .rectangular:
VStack(alignment: .leading, spacing: 1) {
Text(L("countdown.title")).font(.system(.caption2, design: .rounded).weight(.semibold)).foregroundStyle(.secondary)
Text(state.tripTitle ?? "").font(.system(.headline, design: .rounded)).lineLimit(1)
Text("\(days) \(unit)").font(.system(.title3, design: .rounded).weight(.bold)).widgetAccentable()
}
.frame(maxWidth: .infinity, alignment: .leading)
case .corner:
Image(systemName: "suitcase.rolling.fill")
.font(.system(size: 20, weight: .semibold))
.widgetAccentable()
.widgetLabel { Text("\(days) \(unit)") }
case .inline:
Label("\(state.tripTitle ?? "") · \(days) \(unit)", systemImage: "suitcase.rolling.fill")
}
}
}
/// Nothing to count down or show: the TREK mark, and a word when there is room.
struct ComplicationIdle: View {
let state: ComplicationState
let family: ComplicationFamily
let kind: ComplicationKind
/// What an idle face says: before the trip how far off it is, while it runs
/// "day done" for the next stop and the day of the trip for the rest, and
/// "no trip" outside of one.
private var text: String {
switch state.phase {
case .upcoming(let days):
return "\(state.tripTitle ?? "") · \(days) \(WatchFormat.daysUnit(days))"
case .running(let day, let total):
return kind == .nextStop ? L("today.done") : WatchFormat.dayOf(day, total)
case .past, .none:
return L("complication.noTrip")
}
}
var body: some View {
switch family {
case .circular:
ZStack {
AccessoryWidgetBackground()
TrekMark().frame(width: 22, height: 22).widgetAccentable()
}
case .rectangular:
HStack(spacing: 8) {
TrekMark().frame(width: 26, height: 26).widgetAccentable()
VStack(alignment: .leading, spacing: 1) {
Text("TREK").font(.system(.headline, design: .rounded))
Text(text).font(.system(.footnote, design: .rounded)).foregroundStyle(.secondary).lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
case .corner:
TrekMark().frame(width: 20, height: 20).widgetAccentable().widgetLabel { Text(text) }
case .inline:
Text("TREK · \(text)")
}
}
}
@@ -0,0 +1,187 @@
import Foundation
/// A trip to Kyoto, the one the widgets' gallery shows too: what the
/// complication gallery draws before the phone sent anything, and what the
/// screenshot mode of the watch app runs on (TREKWatch/DemoMode.swift).
enum WatchSample {
enum Scenario: String {
/// Day 3 of 9, early afternoon.
case running
/// Day 1, the flight out leaves in the afternoon.
case travelday
/// Twelve days before the trip.
case upcoming
}
static func snapshot(_ scenario: Scenario, now: Date = Date()) -> WatchSnapshot {
let startOffset: Int
switch scenario {
case .running: startOffset = -2
case .travelday: startOffset = 0
case .upcoming: startOffset = 12
}
let calendar = Calendar.current
let iso: (Int) -> String = { TrekDate.iso(calendar.date(byAdding: .day, value: startOffset + $0, to: now) ?? now) }
// The flight out leaves in the afternoon, so the screenshot mode can run
// every scenario at about one o'clock, the hour the capture sets its clock to.
let stops: [[WatchSnapshot.Stop]] = [
[
stop("LH 716", 15 * 60 + 35, symbol: "airplane", color: nil, booking: "flight", lat: 50.0379, lng: 8.5622, address: "Frankfurt Airport"),
],
[
stop("Shinkansen Nozomi 21", 13 * 60 + 33, symbol: "tram.fill", color: nil, booking: "train", lat: 35.6812, lng: 139.7671, address: "Tokyo Station"),
stop("Hotel Kanra Kyoto", 16 * 60 + 30, symbol: "bed.double.fill", color: nil, booking: "hotel", lat: 34.9922, lng: 135.7585, address: "190 Kitamachi, Shimogyo Ward, Kyoto"),
],
[
stop("Fushimi Inari Taisha", 8 * 60 + 30, end: 10 * 60 + 30, symbol: "building.columns.fill", color: "#D6273B", lat: 34.9671, lng: 135.7727, address: "68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto"),
stop(t("Nishiki Market"), 11 * 60, end: 12 * 60 + 30, symbol: "basket.fill", color: "#E8A13A", lat: 35.0050, lng: 135.7649, address: "Nishikikoji-dori, Nakagyo Ward, Kyoto"),
stop("Kiyomizu-dera", 13 * 60 + 30, end: 15 * 60 + 30, symbol: "leaf.fill", color: "#2FA37A", lat: 34.9949, lng: 135.7850, address: "1-294 Kiyomizu, Higashiyama Ward, Kyoto", note: t("Go up the Sannenzaka steps for the view.")),
stop(t("Gion walk"), 16 * 60, symbol: "figure.walk", color: "#4A7DDB", lat: 35.0037, lng: 135.7750, address: "Hanamikoji-dori, Higashiyama Ward, Kyoto"),
stop(t("Kaiseki at Kikunoi"), 19 * 60, symbol: "fork.knife", color: nil, booking: "restaurant", lat: 34.9989, lng: 135.7812, address: "459 Shimokawaracho, Higashiyama Ward, Kyoto"),
],
[
stop(t("Arashiyama Bamboo Grove"), 9 * 60, symbol: "tree.fill", color: "#2FA37A", lat: 35.0170, lng: 135.6713, address: "Sagaogurayama Tabuchiyamacho, Kyoto"),
stop("Tenryu-ji", 10 * 60 + 30, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0157, lng: 135.6738, address: "68 Sagatenryuji Susukinobabacho, Kyoto"),
stop("Kinkaku-ji", 14 * 60, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0394, lng: 135.7292, address: "1 Kinkakujicho, Kita Ward, Kyoto"),
],
[stop(t("Nara Park"), 10 * 60, symbol: "leaf.fill", color: "#2FA37A", lat: 34.6851, lng: 135.8430, address: "Nara"), stop("Todai-ji", 11 * 60 + 30, symbol: "building.columns.fill", color: "#D6273B", lat: 34.6890, lng: 135.8398, address: "406-1 Zoshicho, Nara")],
[stop(t("Philosopher's Path"), 9 * 60 + 30, symbol: "figure.walk", color: "#4A7DDB", lat: 35.0266, lng: 135.7948, address: "Sakyo Ward, Kyoto"), stop("Ginkaku-ji", 11 * 60, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0270, lng: 135.7982, address: "2 Ginkakujicho, Sakyo Ward, Kyoto")],
[stop(t("Tea ceremony"), 10 * 60 + 30, symbol: "ticket.fill", color: nil, booking: "event", lat: 35.0036, lng: 135.7788, address: "Camellia Flower, Higashiyama")],
[stop(t("Fushimi sake district"), 14 * 60, symbol: "wineglass.fill", color: "#8E6CD9", lat: 34.9318, lng: 135.7601, address: "Fushimi Ward, Kyoto"), stop(t("Pontocho alley"), 18 * 60 + 30, symbol: "fork.knife", color: "#E8A13A", lat: 35.0050, lng: 135.7708, address: "Pontocho, Nakagyo Ward, Kyoto")],
[stop(t("Shinkansen to Osaka"), 9 * 60 + 12, symbol: "tram.fill", color: nil, booking: "train", lat: 34.9858, lng: 135.7588, address: "Kyoto Station"), stop("LH 741", 13 * 60 + 25, symbol: "airplane", color: nil, booking: "flight", lat: 34.4320, lng: 135.2304, address: "Kansai International Airport")],
]
let days = stops.enumerated().map { index, stops in
WatchSnapshot.Day(date: iso(index), number: index + 1, title: nil, stops: stops)
}
let bookings: [WatchSnapshot.Booking] = [
WatchSnapshot.Booking(
id: 1, title: "LH 716", kind: "flight", confirmed: true, date: iso(0), time: "15:35", endDate: iso(1), endTime: "11:20",
zone: nil, endZone: nil, from: .init(name: "Frankfurt", code: "FRA", lat: 50.0379, lng: 8.5622),
to: .init(name: "Tokyo Haneda", code: "HND", lat: 35.5494, lng: 139.7798), code: "K7Q2XD",
carrier: "Lufthansa LH 716", seat: "42A", platform: nil, location: nil, lat: nil, lng: nil, checkIn: nil, checkOut: nil
),
WatchSnapshot.Booking(
id: 7, title: "Shinkansen Nozomi 21", kind: "train", confirmed: true, date: iso(1), time: "13:33", endDate: iso(1), endTime: "15:48",
zone: nil, endZone: nil, from: .init(name: "Tokyo", code: nil, lat: 35.6812, lng: 139.7671), to: .init(name: "Kyoto", code: nil, lat: 34.9858, lng: 135.7588),
code: "JR-5521", carrier: "JR Nozomi 21", seat: "7D", platform: "14", location: nil, lat: nil, lng: nil, checkIn: nil, checkOut: nil
),
WatchSnapshot.Booking(
id: 2, title: "Hotel Kanra Kyoto", kind: "hotel", confirmed: true, date: iso(1), time: "16:30", endDate: iso(7), endTime: "11:00",
zone: nil, endZone: nil, from: nil, to: nil, code: "HK-208431", carrier: nil, seat: nil, platform: nil,
location: "Hotel Kanra Kyoto", lat: 34.9922, lng: 135.7585, checkIn: "15:00", checkOut: "11:00"
),
WatchSnapshot.Booking(
id: 3, title: t("Kaiseki at Kikunoi"), kind: "restaurant", confirmed: false, date: iso(2), time: "19:00", endDate: nil, endTime: nil,
zone: nil, endZone: nil, from: nil, to: nil, code: "KK-77", carrier: nil, seat: nil, platform: nil,
location: "459 Shimokawaracho, Higashiyama", lat: 34.9989, lng: 135.7812, checkIn: nil, checkOut: nil
),
WatchSnapshot.Booking(
id: 4, title: t("Tea ceremony"), kind: "event", confirmed: true, date: iso(5), time: "10:30", endDate: nil, endTime: nil,
zone: nil, endZone: nil, from: nil, to: nil, code: nil, carrier: nil, seat: nil, platform: nil,
location: "Camellia Flower", lat: 35.0036, lng: 135.7788, checkIn: nil, checkOut: nil
),
WatchSnapshot.Booking(
id: 5, title: "Shinkansen Hikari 503", kind: "train", confirmed: true, date: iso(8), time: "09:12", endDate: iso(8), endTime: "09:27",
zone: nil, endZone: nil, from: .init(name: "Kyoto", code: nil, lat: 34.9858, lng: 135.7588), to: .init(name: "Shin-Osaka", code: nil, lat: 34.7334, lng: 135.5001),
code: "E4521", carrier: "JR Hikari 503", seat: "7D", platform: "12", location: nil, lat: nil, lng: nil, checkIn: nil, checkOut: nil
),
WatchSnapshot.Booking(
id: 6, title: "LH 741", kind: "flight", confirmed: true, date: iso(8), time: "13:25", endDate: iso(8), endTime: "19:05",
zone: nil, endZone: nil, from: .init(name: "Osaka Kansai", code: "KIX", lat: 34.4320, lng: 135.2304),
to: .init(name: "Frankfurt", code: "FRA", lat: 50.0379, lng: 8.5622), code: "K7Q2XD",
carrier: "Lufthansa LH 741", seat: "38C", platform: nil, location: nil, lat: nil, lng: nil, checkIn: nil, checkOut: nil
),
]
let packingItems: [(String, String, Bool)] = [
(t("Passport"), t("Documents"), true), (t("JR Pass voucher"), t("Documents"), true), (t("Travel insurance"), t("Documents"), true),
(t("Rain jacket"), t("Clothes"), false), (t("Walking shoes"), t("Clothes"), true), (t("Warm sweater"), t("Clothes"), false), (t("Socks"), t("Clothes"), true),
(t("Camera"), t("Tech"), true), (t("Power bank"), t("Tech"), false), (t("Type A adapter"), t("Tech"), false),
(t("Toothbrush"), t("Toiletries"), false), (t("Sunscreen"), t("Toiletries"), true),
]
var packingList: [WatchSnapshot.Item] = []
for (offset, entry) in packingItems.enumerated() {
packingList.append(WatchSnapshot.Item(id: 100 + offset, name: entry.0, done: entry.2, group: entry.1))
}
let packing = WatchSnapshot.Packing(
items: packingList.filter { !$0.done } + packingList.filter(\.done),
packed: packingList.filter(\.done).count,
total: packingList.count
)
// Due dates in days from now, priority, done.
let todoItems: [(String, Int?, Int, Bool)] = [
(t("Reserve the Ghibli Museum"), 2, 3, false), (t("Buy a Suica card"), nil, 2, false),
(t("Book the tea ceremony"), nil, 1, false), (t("Print the hotel voucher"), nil, 0, false),
(t("Order yen"), -1, 0, true), (t("Renew passport"), -20, 0, true),
]
var todoList: [WatchSnapshot.Item] = []
for (offset, entry) in todoItems.enumerated() {
let due: String? = entry.1.map { iso($0 - startOffset) }
todoList.append(WatchSnapshot.Item(id: 200 + offset, name: entry.0, done: entry.3, group: nil, due: due, priority: entry.2))
}
let todos = WatchSnapshot.Todos(items: todoList, open: todoItems.filter { !$0.3 }.count, total: todoItems.count)
return WatchSnapshot(
generatedAt: ISO8601DateFormatter().string(from: now.addingTimeInterval(-4 * 60)),
// The clock of the watch's region, as a user's TREK setting would usually match it.
hour12: DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: .current)?.contains("a") ?? false,
trip: .init(id: 1, title: t("Autumn in Kyoto"), start: iso(0), end: iso(8), place: t("Kyoto, Japan"), cover: nil),
days: days,
bookings: bookings,
weather: .init(temp: 18, high: 21, low: 11, main: scenario == .upcoming ? "Clouds" : "Clear", label: t("Mostly sunny"), place: "Kyoto", rain: 10),
packing: packing,
todos: todos,
applied: []
)
}
/// The sample's own texts in German and Russian, so the screenshots in those
/// languages show a trip written in them; every other language gets the English.
private static let translations: [String: [String: String]] = [
"Autumn in Kyoto": ["de": "Herbst in Kyoto", "ru": "Осень в Киото"],
"Kyoto, Japan": ["de": "Kyoto, Japan", "ru": "Киото, Япония"],
"Nishiki Market": ["de": "Nishiki-Markt", "ru": "Рынок Нисики"],
"Gion walk": ["de": "Spaziergang durch Gion", "ru": "Прогулка по Гиону"],
"Kaiseki at Kikunoi": ["de": "Kaiseki im Kikunoi", "ru": "Кайсэки в Kikunoi"],
"Arashiyama Bamboo Grove": ["de": "Bambuswald Arashiyama", "ru": "Бамбуковая роща Арасиямы"],
"Nara Park": ["de": "Nara-Park", "ru": "Парк Нара"],
"Philosopher's Path": ["de": "Philosophenweg", "ru": "Тропа философа"],
"Tea ceremony": ["de": "Teezeremonie", "ru": "Чайная церемония"],
"Fushimi sake district": ["de": "Sake-Viertel Fushimi", "ru": "Саке-квартал Фусими"],
"Pontocho alley": ["de": "Gasse Pontocho", "ru": "Улочка Понтотё"],
"Shinkansen to Osaka": ["de": "Shinkansen nach Osaka", "ru": "Синкансэн в Осаку"],
"Go up the Sannenzaka steps for the view.": ["de": "Für den Ausblick die Sannenzaka-Treppe hinauf.", "ru": "Ради вида поднимитесь по лестнице Саннэндзака."],
"Mostly sunny": ["de": "Überwiegend sonnig", "ru": "Преимущественно солнечно"],
"Passport": ["de": "Reisepass", "ru": "Паспорт"],
"JR Pass voucher": ["de": "JR-Pass-Gutschein", "ru": "Ваучер JR Pass"],
"Travel insurance": ["de": "Reiseversicherung", "ru": "Страховка"],
"Rain jacket": ["de": "Regenjacke", "ru": "Дождевик"],
"Walking shoes": ["de": "Wanderschuhe", "ru": "Удобная обувь"],
"Warm sweater": ["de": "Warmer Pullover", "ru": "Тёплый свитер"],
"Socks": ["de": "Socken", "ru": "Носки"],
"Camera": ["de": "Kamera", "ru": "Фотоаппарат"],
"Power bank": ["de": "Powerbank", "ru": "Пауэрбанк"],
"Type A adapter": ["de": "Reiseadapter Typ A", "ru": "Переходник типа A"],
"Toothbrush": ["de": "Zahnbürste", "ru": "Зубная щётка"],
"Sunscreen": ["de": "Sonnencreme", "ru": "Солнцезащитный крем"],
"Documents": ["de": "Dokumente", "ru": "Документы"],
"Clothes": ["de": "Kleidung", "ru": "Одежда"],
"Tech": ["de": "Technik", "ru": "Техника"],
"Toiletries": ["de": "Kulturbeutel", "ru": "Гигиена"],
"Reserve the Ghibli Museum": ["de": "Ghibli-Museum reservieren", "ru": "Забронировать музей Гибли"],
"Buy a Suica card": ["de": "Suica-Karte kaufen", "ru": "Купить карту Suica"],
"Book the tea ceremony": ["de": "Teezeremonie buchen", "ru": "Записаться на чайную церемонию"],
"Print the hotel voucher": ["de": "Hotelgutschein drucken", "ru": "Распечатать ваучер отеля"],
"Order yen": ["de": "Yen bestellen", "ru": "Заказать иены"],
"Renew passport": ["de": "Reisepass verlängern", "ru": "Продлить паспорт"],
]
private static func t(_ english: String) -> String {
let language = Locale.current.language.languageCode?.identifier ?? "en"
return translations[english]?[language] ?? english
}
private static func stop(_ name: String, _ minutes: Int, end: Int? = nil, symbol: String, color: String?, booking: String? = nil,
lat: Double, lng: Double, address: String, note: String? = nil) -> WatchSnapshot.Stop {
WatchSnapshot.Stop(name: name, minutes: minutes, endMinutes: end, symbol: symbol, color: color, address: address,
lat: lat, lng: lng, booking: booking, note: note)
}
}
@@ -0,0 +1,309 @@
import Foundation
/// What the iPhone app last sent the watch (client/src/native/watch.ts builds
/// it, TrekWatchLink in the app passes it on through WatchConnectivity): the
/// trip the dashboard features, in full, so the watch shows it without a
/// network or a session of its own. Data only; every text the watch shows of
/// its own comes from its Localizable.strings, and it formats times itself.
///
/// Everything past `v`, `generatedAt` and `trip` is decoded leniently, so a
/// phone that is newer or older than this binary still gets through.
struct WatchSnapshot: Codable, Equatable {
struct Trip: Codable, Equatable {
var id: Int
var title: String
var start: String
var end: String
var place: String?
/// A small base64 JPEG of the cover, when the phone had one that fit.
var cover: String?
}
struct Stop: Codable, Equatable, Hashable {
var name: String
var minutes: Int?
var endMinutes: Int?
var symbol: String?
var color: String?
var address: String?
var lat: Double?
var lng: Double?
/// The booking kind when the stop is a booking (flight, hotel, ...).
var booking: String?
var note: String?
}
struct Day: Codable, Equatable, Hashable {
var date: String?
var number: Int
var title: String?
var stops: [Stop]
}
struct Point: Codable, Equatable, Hashable {
var name: String
var code: String?
var lat: Double?
var lng: Double?
}
struct Booking: Codable, Equatable, Hashable, Identifiable {
var id: Int
var title: String
var kind: String
var confirmed: Bool
var date: String?
var time: String?
var endDate: String?
var endTime: String?
var zone: String?
/// The zone the end time is local to (the arrival airport's), when the booking names one.
var endZone: String?
var from: Point?
var to: Point?
var code: String?
var carrier: String?
var seat: String?
var platform: String?
var location: String?
var lat: Double?
var lng: Double?
var checkIn: String?
var checkOut: String?
}
struct Weather: Codable, Equatable {
var temp: Int
var high: Int?
var low: Int?
var main: String
var label: String
var place: String?
var rain: Int?
}
struct Item: Codable, Equatable, Hashable, Identifiable {
var id: Int
var name: String
var done: Bool
var group: String?
var due: String?
var priority: Int?
}
struct Packing: Codable, Equatable {
var items: [Item]
var packed: Int
var total: Int
}
struct Todos: Codable, Equatable {
var items: [Item]
var open: Int
var total: Int
}
var v: Int
var generatedAt: String
var hour12: Bool
var trip: Trip?
var days: [Day]
var bookings: [Booking]
var weather: Weather?
var packing: Packing?
var todos: Todos?
var applied: [String]
init(v: Int = 1, generatedAt: String, hour12: Bool = false, trip: Trip?, days: [Day] = [], bookings: [Booking] = [],
weather: Weather? = nil, packing: Packing? = nil, todos: Todos? = nil, applied: [String] = []) {
self.v = v
self.generatedAt = generatedAt
self.hour12 = hour12
self.trip = trip
self.days = days
self.bookings = bookings
self.weather = weather
self.packing = packing
self.todos = todos
self.applied = applied
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
v = (try? c.decode(Int.self, forKey: .v)) ?? 1
generatedAt = (try? c.decode(String.self, forKey: .generatedAt)) ?? ""
hour12 = (try? c.decode(Bool.self, forKey: .hour12)) ?? false
trip = try? c.decodeIfPresent(Trip.self, forKey: .trip)
days = (try? c.decode([Day].self, forKey: .days)) ?? []
bookings = (try? c.decode([Booking].self, forKey: .bookings)) ?? []
weather = try? c.decodeIfPresent(Weather.self, forKey: .weather)
packing = try? c.decodeIfPresent(Packing.self, forKey: .packing)
todos = try? c.decodeIfPresent(Todos.self, forKey: .todos)
applied = (try? c.decode([String].self, forKey: .applied)) ?? []
}
static func decode(_ data: Data) -> WatchSnapshot? {
try? JSONDecoder().decode(WatchSnapshot.self, from: data)
}
var generated: Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.date(from: generatedAt) ?? ISO8601DateFormatter().date(from: generatedAt)
}
}
// MARK: - Dates
/// Days and times as the watch reads them: always in the time zone it is in
/// now (it may have flown since the last launch), and times of day as wall-clock
/// hours and minutes, never as minutes added to midnight, which are an hour off
/// on the days the clocks change.
enum TrekDate {
/// The start of yyyy-MM-dd here.
static func day(_ iso: String?) -> Date? {
guard let iso, iso.count >= 10 else { return nil }
let ymd = iso.prefix(10).split(separator: "-").compactMap { Int($0) }
guard ymd.count == 3 else { return nil }
return Calendar.current.date(from: DateComponents(year: ymd[0], month: ymd[1], day: ymd[2]))
}
static func iso(_ date: Date) -> String {
let parts = Calendar.current.dateComponents([.year, .month, .day], from: date)
return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)
}
/// yyyy-MM-dd and HH:mm as a moment, in `zone` when given, else here; midnight without a time.
static func moment(_ date: String?, _ time: String?, zone: String? = nil) -> Date? {
guard let date, date.count >= 10 else { return nil }
let ymd = date.prefix(10).split(separator: "-").compactMap { Int($0) }
guard ymd.count == 3 else { return nil }
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = zone.flatMap(TimeZone.init(identifier:)) ?? .current
let m = minutes(time) ?? 0
return calendar.date(from: DateComponents(year: ymd[0], month: ymd[1], day: ymd[2], hour: m / 60, minute: m % 60))
}
/// "13:05" or "1:05 PM" to minutes after midnight.
static func minutes(_ time: String?) -> Int? {
guard let time else { return nil }
let pieces = time.split(separator: ":")
guard pieces.count >= 2, let hour = Int(pieces[0].trimmingCharacters(in: .whitespaces)),
let minute = Int(pieces[1].prefix(2)) else { return nil }
var h = hour
let upper = time.uppercased()
if upper.contains("PM"), h < 12 { h += 12 }
if upper.contains("AM"), h == 12 { h = 0 }
return h * 60 + minute
}
static func daysBetween(_ from: Date, _ to: Date) -> Int {
let calendar = Calendar.current
return calendar.dateComponents([.day], from: calendar.startOfDay(for: from), to: calendar.startOfDay(for: to)).day ?? 0
}
static func minutesOfDay(_ date: Date) -> Int {
let parts = Calendar.current.dateComponents([.hour, .minute], from: date)
return (parts.hour ?? 0) * 60 + (parts.minute ?? 0)
}
/// The moment the clock shows `minutes` after midnight on `day`.
static func at(_ minutes: Int, on day: Date) -> Date {
let m = min(max(minutes, 0), 24 * 60 - 1)
return Calendar.current.date(bySettingHour: m / 60, minute: m % 60, second: 0, of: day) ?? day
}
}
// MARK: - What the screens ask of a snapshot
extension WatchSnapshot {
enum Phase: Equatable {
/// The trip starts in this many days (0 = today, before its first day is in the plan).
case upcoming(days: Int)
/// The trip runs; the 1-based index of today and the number of days.
case running(day: Int, of: Int)
/// The trip is over.
case past
case none
}
func phase(at now: Date) -> Phase {
guard let trip, let start = TrekDate.day(trip.start) else { return .none }
let end = TrekDate.day(trip.end) ?? start
let untilStart = TrekDate.daysBetween(now, start)
if untilStart > 0 { return .upcoming(days: untilStart) }
if TrekDate.daysBetween(now, end) < 0 { return .past }
let total = max(days.count, TrekDate.daysBetween(start, end) + 1, 1)
let index = (todayIndex(at: now).map { days[$0].number }) ?? (TrekDate.daysBetween(start, now) + 1)
return .running(day: min(max(index, 1), total), of: total)
}
/// The position of today in `days`, when the plan has a day with today's date.
func todayIndex(at now: Date) -> Int? {
let iso = TrekDate.iso(now)
return days.firstIndex { $0.date == iso }
}
func today(at now: Date) -> Day? {
todayIndex(at: now).map { days[$0] }
}
var tripLength: Int {
guard let trip, let start = TrekDate.day(trip.start), let end = TrekDate.day(trip.end) else { return max(days.count, 1) }
return max(days.count, TrekDate.daysBetween(start, end) + 1, 1)
}
/// The bookings that have not ended yet, soonest first.
func bookingsAhead(at now: Date) -> [Booking] {
let today = Calendar.current.startOfDay(for: now)
return bookings.filter { booking in
if let end = booking.end, booking.endTime != nil || booking.endDate != booking.date {
if end >= now { return true }
}
guard let day = TrekDate.day(booking.date) else { return true }
guard let start = booking.start, booking.time != nil else { return day >= today }
return start >= now.addingTimeInterval(-30 * 60)
}
}
/// The next booking worth a countdown: one not yet started, else a running stay.
func nextBooking(at now: Date) -> Booking? {
let ahead = bookingsAhead(at: now)
return ahead.first { booking in
guard let start = booking.start else { return false }
return start >= now.addingTimeInterval(-30 * 60)
} ?? ahead.first
}
}
extension WatchSnapshot.Booking {
var start: Date? { TrekDate.moment(date, time, zone: zone) }
/// The end in the arrival's zone; a ride without one ends in the zone the watch is in, never the departure's.
var end: Date? { TrekDate.moment(endDate ?? date, endTime, zone: endZone ?? (isTransport ? nil : zone)) }
/// A booking with a day but no time lasts the whole day: there is nothing to count down to.
var isAllDay: Bool { time == nil }
var isTransport: Bool { ["flight", "train", "bus", "car", "cruise"].contains(kind) }
/// "FRA → HND" from the codes, else the names, for a transport with both ends.
var route: String? {
guard let from, let to else { return nil }
return "\(from.code ?? from.name) → \(to.code ?? to.name)"
}
/// Where Directions goes: the departure for a ride, the place itself for the rest.
var destination: (name: String, lat: Double, lng: Double)? {
if isTransport, let from, let lat = from.lat, let lng = from.lng { return (from.name, lat, lng) }
if let lat, let lng { return (location ?? title, lat, lng) }
return nil
}
}
extension WatchSnapshot.Day {
var dateValue: Date? { TrekDate.day(date) }
}
extension WatchSnapshot.Stop {
var hasLocation: Bool { lat != nil && lng != nil }
}
@@ -0,0 +1,125 @@
import Foundation
import Security
/// A packing item or to-do ticked on the watch and not yet confirmed by the
/// phone. It goes to the phone through WatchConnectivity (guaranteed, queued
/// delivery) and stays laid over the snapshot until the phone's next snapshot
/// shows it applied, so a tick never flips back while the phone catches up.
struct PendingAction: Codable, Equatable, Identifiable {
enum List: String, Codable { case packing, todo }
var id: String
var tripId: Int
var list: List
var itemId: Int
var done: Bool
var at: Date
/// Set once WatchConnectivity has handed the tick to the phone; such a tick
/// is never sent again, it only stays laid over the snapshot until confirmed.
var sent: Bool?
/// What the phone receives (parseWatchActions in client/src/native/watch.ts).
var message: [String: Any] {
["id": id, "tripId": tripId, "list": list.rawValue, "itemId": itemId, "done": done]
}
}
/// Where the watch keeps the last snapshot and its pending ticks: the keychain
/// group the watch app shares with its complications (no App Group needed,
/// the same way the phone shares the widget snapshot). Nothing goes to
/// UserDefaults, which the complications could not read anyway.
enum WatchStore {
private static let service = "com.liketrek.trek.watch"
private static let snapshotAccount = "watch.snapshot"
private static let pendingAccount = "watch.pending"
static func loadSnapshot() -> WatchSnapshot? {
read(snapshotAccount).flatMap(WatchSnapshot.decode)
}
static func saveSnapshot(_ data: Data) {
write(snapshotAccount, data)
}
static func loadPending() -> [PendingAction] {
guard let data = read(pendingAccount) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
return (try? decoder.decode([PendingAction].self, from: data)) ?? []
}
static func savePending(_ actions: [PendingAction]) {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .secondsSince1970
guard let data = try? encoder.encode(actions) else { return }
write(pendingAccount, data)
}
private static func query(_ account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
private static func read(_ account: String) -> Data? {
var q = query(account)
q[kSecReturnData as String] = true
var result: CFTypeRef?
return SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess ? result as? Data : nil
}
/// Into the first keychain group of the entitlements, the one the complications read.
@discardableResult
private static func write(_ account: String, _ data: Data) -> Bool {
let q = query(account)
let update = [kSecValueData as String: data] as CFDictionary
if SecItemUpdate(q as CFDictionary, update) == errSecSuccess { return true }
var add = q
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
}
}
extension WatchSnapshot {
/// The snapshot with the ticks the phone has not confirmed yet laid over it.
func applying(_ pending: [PendingAction]) -> WatchSnapshot {
guard !pending.isEmpty, let tripId = trip?.id else { return self }
var copy = self
for action in pending where action.tripId == tripId {
switch action.list {
case .packing:
if var packing = copy.packing, let index = packing.items.firstIndex(where: { $0.id == action.itemId }) {
if packing.items[index].done != action.done {
packing.packed += action.done ? 1 : -1
packing.items[index].done = action.done
}
copy.packing = packing
}
case .todo:
if var todos = copy.todos, let index = todos.items.firstIndex(where: { $0.id == action.itemId }) {
if todos.items[index].done != action.done {
todos.open += action.done ? -1 : 1
todos.items[index].done = action.done
}
copy.todos = todos
}
}
}
return copy
}
/// The pending ticks still to lay over this snapshot: those the phone has
/// not applied, whose item does not show the ticked state yet, and that
/// are less than a day old (someone else may have changed it since).
func stillPending(_ pending: [PendingAction], now: Date = Date()) -> [PendingAction] {
pending.filter { action in
if applied.contains(action.id) || now.timeIntervalSince(action.at) > 86_400 { return false }
let items = action.list == .packing ? packing?.items : todos?.items
guard let item = items?.first(where: { $0.id == action.itemId }) else { return false }
return item.done != action.done
}
}
}
@@ -0,0 +1,265 @@
import SwiftUI
/// A text from the watch's Localizable.strings.
func L(_ key: String) -> String {
NSLocalizedString(key, comment: "")
}
/// The watch's own formatting: times in the clock the user picked in TREK,
/// days, ranges and countdowns in the watch's language.
enum WatchFormat {
private static var formatters: [String: DateFormatter] = [:]
private static let utc = TimeZone(identifier: "UTC") ?? .current
/// A formatter for the template in the watch's language, in `zone`
/// (the current one unless given), so a flight across zones is followed.
private static func formatter(_ template: String, zone: TimeZone = .current) -> DateFormatter {
let key = template + "|" + Locale.current.identifier + "|" + zone.identifier
if let cached = formatters[key] { return cached }
let formatter = DateFormatter()
formatter.locale = Locale.current
formatter.timeZone = zone
formatter.setLocalizedDateFormatFromTemplate(template)
formatters[key] = formatter
return formatter
}
static func time(_ date: Date, hour12: Bool) -> String {
formatter(hour12 ? "h:mm a" : "HH:mm").string(from: date)
}
/// A time of day from its minutes, with no date involved, so no change of
/// the clocks can shift it: the minutes after the reference date, in UTC.
static func time(minutes: Int, hour12: Bool) -> String {
let m = min(max(minutes, 0), 24 * 60 - 1)
return formatter(hour12 ? "h:mm a" : "HH:mm", zone: utc).string(from: Date(timeIntervalSinceReferenceDate: TimeInterval(m * 60)))
}
/// "Tue 18 Nov"
static func day(_ date: Date) -> String {
formatter("EEEdMMM").string(from: date)
}
/// "Tuesday"
static func weekday(_ date: Date) -> String {
formatter("EEEE").string(from: date)
}
/// "17 – 25 Nov"
static func range(_ start: String, _ end: String) -> String? {
guard let from = TrekDate.day(start), let to = TrekDate.day(end) else { return nil }
let formatter = DateIntervalFormatter()
formatter.dateTemplate = "dMMM"
return formatter.string(from: from, to: to)
}
/// "in 25 min", "in 2 hr", "5 min ago": named and plural-correct in every language.
static func relative(_ date: Date, now: Date) -> String {
if abs(date.timeIntervalSince(now)) < 60 { return L("time.now") }
// Against `now`, not the system clock, so the screenshot mode's clock counts too.
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
formatter.dateTimeStyle = .numeric
return formatter.localizedString(for: date, relativeTo: now)
}
/// The time to a moment as a short count: "25 min", "2 h 15 min", "3 d".
static func countdown(_ date: Date, now: Date) -> String {
let seconds = max(date.timeIntervalSince(now), 0)
let formatter = WatchFormat.components()
formatter.unitsStyle = .short
formatter.maximumUnitCount = 2
formatter.allowedUnits = seconds >= 86_400 ? [.day, .hour] : seconds >= 3_600 ? [.hour, .minute] : [.minute]
return formatter.string(from: max(seconds, 60)) ?? ""
}
/// The word for a number of days in its right plural form ("days", "Tage",
/// "дней", "日"), taken from the system's own phrase so no plural rules
/// have to be kept here: the number is formatted and then cut out.
static func daysUnit(_ count: Int) -> String {
let formatter = WatchFormat.components()
formatter.unitsStyle = .full
formatter.allowedUnits = [.day]
let phrase = formatter.string(from: DateComponents(day: count)) ?? ""
let word = phrase.unicodeScalars.filter { !CharacterSet.decimalDigits.contains($0) }
return String(String.UnicodeScalarView(word)).trimmingCharacters(in: .whitespacesAndNewlines.union(.punctuationCharacters))
}
/// A duration formatter in the watch's language: it takes its words from
/// its calendar's locale, which is not the app's own unless set.
static func components() -> DateComponentsFormatter {
let formatter = DateComponentsFormatter()
var calendar = Calendar.current
calendar.locale = Locale.current
formatter.calendar = calendar
return formatter
}
static func dayOf(_ index: Int, _ total: Int) -> String {
String(format: L("day.of"), index, total)
}
}
// MARK: - The look
/// The watch's take on the widgets' look (TrekStyle.swift): black glass instead
/// of paper, the trip's own two colours as the light behind its pages, the
/// booking kinds' colours behind theirs.
enum WatchLook {
static let ink = Color.white
static let muted = Color.white.opacity(0.62)
static let faint = Color.white.opacity(0.38)
static let packing = Color(hex: 0x2FA37A)
static let todo = Color(hex: 0x4A7DDB)
/// The lights a trip's pages can have: clear, saturated hues that stay
/// clean when they fade into black (no ochre or olive, which turn muddy).
static let palette: [(Color, Color)] = [
(Color(hex: 0x3D6FD8), Color(hex: 0x1C2E73)),
(Color(hex: 0x2BA59A), Color(hex: 0x0E4E5A)),
(Color(hex: 0x7A5AE0), Color(hex: 0x2E1F6B)),
(Color(hex: 0xD2557A), Color(hex: 0x5C1A3A)),
(Color(hex: 0x2E8BC0), Color(hex: 0x113A5C)),
(Color(hex: 0xE0703C), Color(hex: 0x5E2414)),
(Color(hex: 0x3FA866), Color(hex: 0x134A2C)),
]
/// A trip's own two colours, picked by its id, so they do not change with
/// its title or the language it is shown in.
static func tripColors(_ trip: WatchSnapshot.Trip?) -> (Color, Color) {
palette[(trip?.id ?? 1) % palette.count]
}
/// A trip's page light: its first colour at the top, fading into black.
static func tripGradient(_ trip: WatchSnapshot.Trip?) -> LinearGradient {
let (first, second) = tripColors(trip)
return LinearGradient(colors: [first.opacity(0.85), second.opacity(0.6), .black], startPoint: .top, endPoint: .bottom)
}
static func tint(_ color: Color) -> LinearGradient {
LinearGradient(colors: [color.opacity(0.75), color.opacity(0.28), .black], startPoint: .top, endPoint: .bottom)
}
static func stopColor(_ stop: WatchSnapshot.Stop) -> Color {
if let kind = stop.booking { return Trek.booking(kind).color }
return Color(css: stop.color) ?? Trek.info
}
static func stopSymbol(_ stop: WatchSnapshot.Stop) -> String {
stop.symbol ?? (stop.booking.map { Trek.booking($0).symbol } ?? "mappin")
}
static func kindName(_ kind: String) -> String {
L("kind.\(["flight", "train", "bus", "car", "cruise", "hotel", "restaurant", "event", "tour"].contains(kind) ? kind : "other")")
}
}
/// The small capitals above a title.
struct WatchEyebrow: ViewModifier {
var color: Color = WatchLook.muted
func body(content: Content) -> some View {
content
.font(.system(.caption2, design: .rounded).weight(.bold))
.textCase(.uppercase)
.tracking(0.4)
.foregroundStyle(color)
.lineLimit(1)
.minimumScaleFactor(0.65)
}
}
extension View {
/// Text a person wrote (a place, a booking, an item): when it is in Latin
/// letters it is set as such, so in a Chinese, Japanese or Korean interface
/// it still breaks between words rather than inside one.
func userText(_ text: String?) -> some View {
let latin = (text ?? "").unicodeScalars.allSatisfy { $0.value < 0x0250 || $0.properties.isWhitespace || $0.properties.generalCategory == .dashPunctuation || $0.properties.generalCategory == .otherPunctuation }
return typesettingLanguage(Locale.Language(identifier: "en"), isEnabled: latin)
}
func watchEyebrow(_ color: Color = WatchLook.muted) -> some View { modifier(WatchEyebrow(color: color)) }
/// A card of glass on watchOS 26, of frosted white before.
@ViewBuilder
func watchCard(cornerRadius: CGFloat = 18, tint: Color? = nil) -> some View {
if #available(watchOS 26.0, iOS 26.0, macOS 26.0, *) {
self.glassEffect(tint.map { Glass.regular.tint($0.opacity(0.35)) } ?? .regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
} else {
self.background(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill((tint ?? .white).opacity(tint == nil ? 0.12 : 0.28))
)
}
}
}
/// A stop's category as a tinted disc with its symbol.
struct StopGlyph: View {
let stop: WatchSnapshot.Stop
var size: CGFloat = 26
var dimmed = false
var body: some View {
let color = WatchLook.stopColor(stop)
Circle()
.fill(color.gradient)
.frame(width: size, height: size)
.overlay(
Image(systemName: WatchLook.stopSymbol(stop))
.font(.system(size: size * 0.46, weight: .semibold))
.foregroundStyle(.white)
)
.opacity(dimmed ? 0.45 : 1)
.accessibilityHidden(true)
}
}
/// The TREK mark: four slanted tiles, as on the app icon.
struct TrekMark: Shape {
func path(in rect: CGRect) -> Path {
// The icon's tiles in its own 1024 grid, the mark spanning 270...754 by 256...768.
let tiles: [[CGPoint]] = [
[CGPoint(x: 382, y: 256), CGPoint(x: 621, y: 256), CGPoint(x: 558, y: 494), CGPoint(x: 318, y: 494)],
[CGPoint(x: 620, y: 358), CGPoint(x: 754, y: 358), CGPoint(x: 718, y: 494), CGPoint(x: 585, y: 494)],
[CGPoint(x: 302, y: 528), CGPoint(x: 438, y: 528), CGPoint(x: 405, y: 664), CGPoint(x: 270, y: 664)],
[CGPoint(x: 468, y: 528), CGPoint(x: 706, y: 528), CGPoint(x: 644, y: 768), CGPoint(x: 404, y: 768)],
]
let minX: CGFloat = 270, minY: CGFloat = 256, span: CGFloat = 512
let scale = min(rect.width, rect.height) / span
let dx = rect.minX + (rect.width - (754 - minX) * scale) / 2
let dy = rect.minY + (rect.height - (768 - minY) * scale) / 2
var path = Path()
for tile in tiles {
let points = tile.map { CGPoint(x: dx + ($0.x - minX) * scale, y: dy + ($0.y - minY) * scale) }
path.addLines(points)
path.closeSubpath()
}
return path
}
}
/// A label and a value in a small tile: "SEAT 42A".
struct FactTile: View {
let label: String
let value: String
var mono = false
/// Laid out at its natural width, so a ViewThatFits can tell when a row of them is too wide.
var fits = false
var body: some View {
VStack(alignment: .leading, spacing: 1) {
Text(label).watchEyebrow(WatchLook.faint)
.fixedSize(horizontal: fits, vertical: false)
Text(value)
.font(mono ? .system(.body, design: .monospaced).weight(.semibold) : .system(.body, design: .rounded).weight(.semibold))
.foregroundStyle(WatchLook.ink)
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.watchCard(cornerRadius: 12)
.accessibilityElement(children: .combine)
}
}
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>TREK</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
</array>
</dict>
</plist>
@@ -0,0 +1,131 @@
import SwiftUI
import WidgetKit
/// The watch's complications and Smart Stack widgets, drawn from the snapshot
/// the watch app keeps (WatchStore, in the keychain group both share): the
/// next stop of the day, a countdown to the next departure, the day of the trip.
@main
struct TREKWatchWidgets: WidgetBundle {
var body: some Widget {
NextStopWidget()
CountdownWidget()
TripDayWidget()
}
}
struct WatchEntry: TimelineEntry {
let date: Date
let snapshot: WatchSnapshot?
let relevance: TimelineEntryRelevance?
var state: ComplicationState { ComplicationState(snapshot, now: date) }
}
/// One entry now and one at every moment the face should change (a stop
/// starts or ends, a booking departs, midnight), each scored for the Smart
/// Stack: highest just before something happens.
struct WatchProvider: TimelineProvider {
let kind: Kind
enum Kind { case nextStop, countdown, tripDay }
func placeholder(in context: Context) -> WatchEntry {
WatchEntry(date: Date(), snapshot: WatchSample.snapshot(.running), relevance: nil)
}
func getSnapshot(in context: Context, completion: @escaping (WatchEntry) -> Void) {
let stored = WatchStore.loadSnapshot()
completion(WatchEntry(date: Date(), snapshot: context.isPreview && stored == nil ? WatchSample.snapshot(.running) : stored, relevance: nil))
}
/// Entries at every change in the next two days, every quarter hour for two
/// hours and every hour after that (for the circular faces' counts; the
/// others count by themselves), rebuilt at midnight or after the last entry,
/// whichever comes first. Without a trip nothing changes until the app writes
/// a new snapshot, which reloads the timelines itself.
func getTimeline(in context: Context, completion: @escaping (Timeline<WatchEntry>) -> Void) {
let now = Date()
let snapshot = WatchStore.loadSnapshot()?.applying(WatchStore.loadPending())
guard snapshot?.trip != nil else {
completion(Timeline(entries: [WatchEntry(date: now, snapshot: snapshot, relevance: nil)], policy: .never))
return
}
var moments = ComplicationState.changes(snapshot, from: now)
for step in 1...8 { moments.append(now.addingTimeInterval(Double(step) * 15 * 60)) }
for hour in 3...24 { moments.append(now.addingTimeInterval(Double(hour) * 3_600)) }
let dates = ([now] + moments).sorted().reduce(into: [Date]()) { out, date in
if let last = out.last, date.timeIntervalSince(last) < 60 { return }
out.append(date)
}
let entries = dates.prefix(80).map { date in
WatchEntry(date: date, snapshot: snapshot, relevance: relevance(snapshot, at: date))
}
let midnight = Calendar.current.date(byAdding: .day, value: 1, to: Calendar.current.startOfDay(for: now)) ?? now.addingTimeInterval(86_400)
let reload = min(midnight, entries.last?.date ?? midnight)
completion(Timeline(entries: entries, policy: .after(reload > now ? reload : now.addingTimeInterval(3_600))))
}
private func relevance(_ snapshot: WatchSnapshot?, at date: Date) -> TimelineEntryRelevance? {
let state = ComplicationState(snapshot, now: date)
switch kind {
case .nextStop:
guard let next = state.next else { return TimelineEntryRelevance(score: 0) }
if next.isNow { return TimelineEntryRelevance(score: 60, duration: 30 * 60) }
let minutes = (next.start ?? date).timeIntervalSince(date) / 60
return TimelineEntryRelevance(score: minutes <= 45 ? 80 : minutes <= 120 ? 40 : 10)
case .countdown:
guard let countdown = state.countdown else { return TimelineEntryRelevance(score: 0) }
let hours = countdown.target.timeIntervalSince(date) / 3_600
return TimelineEntryRelevance(score: hours <= 3 ? 100 : hours <= 24 ? 50 : 5)
case .tripDay:
if case .running = state.phase { return TimelineEntryRelevance(score: 20) }
return TimelineEntryRelevance(score: 0)
}
}
}
private let families: [WidgetFamily] = [.accessoryCircular, .accessoryRectangular, .accessoryCorner, .accessoryInline]
private struct EntryView<Content: View>: View {
let entry: WatchEntry
@Environment(\.widgetFamily) private var widgetFamily
@ViewBuilder let content: (ComplicationState, ComplicationFamily, Date) -> Content
var body: some View {
content(entry.state, ComplicationFamily(widgetFamily) ?? .rectangular, entry.date)
.containerBackground(for: .widget) { Color.clear }
}
}
struct NextStopWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "com.liketrek.trek.watch.nextStop", provider: WatchProvider(kind: .nextStop)) { entry in
EntryView(entry: entry) { state, family, now in NextStopComplication(state: state, family: family, now: now) }
}
.configurationDisplayName(L("complication.nextStop.name"))
.description(L("complication.nextStop.description"))
.supportedFamilies(families)
}
}
struct CountdownWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "com.liketrek.trek.watch.countdown", provider: WatchProvider(kind: .countdown)) { entry in
EntryView(entry: entry) { state, family, now in CountdownComplication(state: state, family: family, now: now) }
}
.configurationDisplayName(L("complication.countdown.name"))
.description(L("complication.countdown.description"))
.supportedFamilies(families)
}
}
struct TripDayWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "com.liketrek.trek.watch.tripDay", provider: WatchProvider(kind: .tripDay)) { entry in
EntryView(entry: entry) { state, family, _ in TripDayComplication(state: state, family: family) }
}
.configurationDisplayName(L("complication.tripDay.name"))
.description(L("complication.tripDay.description"))
.supportedFamilies(families)
}
}
+30 -13
View File
@@ -44,9 +44,16 @@ struct AtlasContent: View {
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)
// The word goes under the number where both do not fit on one line (an iPad mini).
ViewThatFits(in: .horizontal) {
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)
}
VStack(alignment: .leading, spacing: -2) {
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)
@@ -71,18 +78,15 @@ struct AtlasContent: View {
}
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) }
// The trips and days step aside before the share of the world is cut (an iPad mini).
ViewThatFits(in: .horizontal) {
largeHeader(atlas, labels, stats: true)
largeHeader(atlas, labels, stats: false)
}
// The map takes what the rows leave, so a smaller large widget (an
// iPad mini's) keeps the continents and the latest countries whole.
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 2.2)
.frame(height: 150)
.frame(minHeight: 60, maxHeight: 150)
ContinentBars(continents: atlas?.continents ?? [], scheme: scheme)
HStack(spacing: 6) {
ForEach(Array((atlas?.recent ?? []).prefix(3).enumerated()), id: \.offset) { index, country in
@@ -94,6 +98,19 @@ struct AtlasContent: View {
}
}
private func largeHeader(_ atlas: WidgetSnapshot.Atlas?, _ labels: WidgetSnapshot.Labels?, stats: Bool) -> some View {
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 stats, let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) }
if stats, let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) }
}
}
private var ink: Color { Trek.ink(scheme) }
private var muted: Color { Trek.muted(scheme) }
private var glow: [Color] { [Color(hex: 0x3DDC97), Color(hex: 0x4A7DDB)] }
@@ -29,6 +29,7 @@ struct BookingsContent: View {
switch family {
case .small: hero(first, labels)
case .medium: list(Array(bookings.prefix(2)), labels, featureFirst: true)
case .extraLarge: wide(bookings, labels)
default: list(Array(bookings.prefix(5)), labels, featureFirst: true)
}
} else {
@@ -74,7 +75,8 @@ struct BookingsContent: View {
// MARK: Medium and large: a list, the first one featured
private func list(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels, featureFirst: Bool) -> some View {
VStack(alignment: .leading, spacing: family == .large ? 8 : 7) {
// The extra large's list stands beside a card as tall as the widget: its rows space out to match.
VStack(alignment: .leading, spacing: family == .extraLarge ? 14 : family.isTall ? 8 : 7) {
HStack {
Text(labels.bookings ?? "").trekEyebrow(scheme)
Spacer(minLength: 0)
@@ -89,7 +91,33 @@ struct BookingsContent: View {
}
}
}
if family == .large { Spacer(minLength: 0) }
if family.isTall { Spacer(minLength: 0) }
}
}
// MARK: Extra large (iPad): the next one as a card, the rest beside it
private func wide(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels) -> some View {
let first = bookings[0]
let look = Trek.booking(first.kind)
let rest = Array(bookings.dropFirst().prefix(5))
return HStack(alignment: .top, spacing: 18) {
RowLink(path: first.path) {
hero(first, labels)
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(LinearGradient(colors: [look.color.opacity(scheme == .dark ? 0.24 : 0.14), look.color.opacity(scheme == .dark ? 0.08 : 0.04)], startPoint: .topLeading, endPoint: .bottomTrailing))
)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 18, style: .continuous).stroke(look.color.opacity(0.25), lineWidth: 0.8))
}
.frame(maxWidth: rest.isEmpty ? CGFloat.infinity : 240)
if !rest.isEmpty {
list(rest, labels, featureFirst: false)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
}
@@ -51,6 +51,7 @@ extension TrekFamily {
switch family {
case .systemSmall: self = .small
case .systemMedium: self = .medium
case .systemExtraLarge: self = .extraLarge
case .accessoryCircular: self = .circular
case .accessoryRectangular: self = .rectangular
case .accessoryInline: self = .inline
@@ -92,7 +93,8 @@ struct NextTripWidget: Widget {
}
.configurationDisplayName("widget.trip.name")
.description("widget.trip.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
// systemExtraLarge is the iPad's; an iPhone leaves it out of its gallery.
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
@@ -123,7 +125,7 @@ struct TodayWidget: Widget {
}
.configurationDisplayName("widget.today.name")
.description("widget.today.description")
.supportedFamilies([.systemMedium, .systemLarge])
.supportedFamilies([.systemMedium, .systemLarge, .systemExtraLarge])
}
}
@@ -146,7 +148,7 @@ struct BookingsWidget: Widget {
}
.configurationDisplayName("widget.bookings.name")
.description("widget.bookings.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, .accessoryInline])
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge, .accessoryRectangular, .accessoryInline])
}
}
+151 -36
View File
@@ -14,55 +14,49 @@ struct TodayContent: View {
let total = today.total ?? snapshot.tripLength ?? 1
let timed = today.stops.filter { $0.minutes != nil }
let done = timed.filter { ($0.minutes ?? 0) < nowMinutes }.count
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .center, spacing: 10) {
DayBadge(index: today.index ?? 1, total: total)
VStack(alignment: .leading, spacing: 1) {
Text(trip.title).trekEyebrow(scheme)
Text(today.dayLabel)
.font(.system(size: family == .large ? 19 : 16, weight: .heavy))
.foregroundStyle(Trek.ink(scheme))
.lineLimit(1)
if family == .extraLarge {
wide(snapshot, trip, today, total: total, done: done, timed: timed.count, nowMinutes: nowMinutes)
} else {
VStack(alignment: .leading, spacing: 0) {
// On a narrow widget (an iPad mini's) the weather shrinks to its
// chip, then steps aside, before the trip's name is cut short.
ViewThatFits(in: .horizontal) {
header(trip, today, total: total, weather: family == .large ? .full : .chip)
header(trip, today, total: total, weather: .chip)
header(trip, today, total: total, weather: .hidden)
}
Spacer(minLength: 0)
if let weather = snapshot.weather {
if family == .large {
PaperWeather(weather: weather)
} else {
WeatherChip(weather: weather)
if family == .large {
DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone)
.padding(.top, 12)
.padding(.bottom, 14)
} else {
Spacer(minLength: 8)
}
StopList(
stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 7 : 3,
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
)
if family == .large {
Spacer(minLength: 8)
if let booking = snapshot.bookings?.first {
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
}
}
}
if family == .large {
DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone)
.padding(.top, 12)
.padding(.bottom, 14)
} else {
Spacer(minLength: 8)
}
StopList(
stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 7 : 3,
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
)
if family == .large {
Spacer(minLength: 8)
if let booking = snapshot.bookings?.first {
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
}
}
}
} else if let snapshot, let trip = snapshot.trip {
// Before the trip there is no day yet: count down to it instead.
let days = max(snapshot.daysUntilStart(from: now) ?? 0, 0)
let roomy = family == .extraLarge
VStack(alignment: .leading, spacing: 4) {
Text(snapshot.labels.nextTrip).trekEyebrow(scheme)
Text(trip.title).font(.system(size: 17, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
Text(trip.title).font(.system(size: roomy ? 24 : 17, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
Spacer(minLength: 0)
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(days)").font(Trek.number(40, .heavy)).foregroundStyle(Trek.ink(scheme))
Text(snapshot.labels.days ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.muted(scheme))
Text("\(days)").font(Trek.number(roomy ? 56 : 40, .heavy)).foregroundStyle(Trek.ink(scheme))
Text(snapshot.labels.days ?? "").font(.system(size: roomy ? 16 : 13, weight: .bold)).foregroundStyle(Trek.muted(scheme))
}
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: Trek.ink(scheme)).frame(height: 24)
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: Trek.ink(scheme)).frame(height: roomy ? 34 : 24)
}
} else {
EmptyTrip(text: snapshot?.labels.noTrip ?? "", family: family)
@@ -70,6 +64,66 @@ struct TodayContent: View {
}
}
extension TodayContent {
enum HeaderWeather { case full, chip, hidden }
/// The day's badge, the trip and the day, and the weather as much as fits.
func header(_ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today, total: Int, weather: HeaderWeather) -> some View {
HStack(alignment: .center, spacing: 10) {
DayBadge(index: today.index ?? 1, total: total)
VStack(alignment: .leading, spacing: 1) {
Text(trip.title).trekEyebrow(scheme)
Text(today.dayLabel)
.font(.system(size: family == .medium ? 16 : 19, weight: .heavy))
.foregroundStyle(Trek.ink(scheme))
.lineLimit(1)
}
Spacer(minLength: 0)
if let forecast = snapshot?.weather {
switch weather {
case .full: PaperWeather(weather: forecast)
case .chip: WeatherChip(weather: forecast)
case .hidden: EmptyView()
}
}
}
}
/// The iPad's extra large: the day, its progress and the weather beside
/// the day's timeline, and the next two bookings across the foot.
func wide(
_ snapshot: WidgetSnapshot, _ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today,
total: Int, done: Int, timed: Int, nowMinutes: Int
) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 22) {
VStack(alignment: .leading, spacing: 0) {
header(trip, today, total: total, weather: .hidden)
DayProgress(done: done, total: timed, scheme: scheme, label: snapshot.labels.allDone)
.padding(.top, 12)
if let weather = snapshot.weather {
PaperWeather(weather: weather).padding(.top, 16)
}
}
.frame(width: 250)
StopList(
stops: today.stops, nowMinutes: nowMinutes, limit: 6,
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
Spacer(minLength: 10)
if let bookings = snapshot.bookings, !bookings.isEmpty {
HStack(spacing: 12) {
ForEach(Array(bookings.prefix(2).enumerated()), id: \.offset) { _, booking in
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
}
}
}
}
}
}
/// Today's number in a dark tile with the trip's length under it.
struct DayBadge: View {
let index: Int
@@ -142,3 +196,64 @@ struct DayProgress: View {
}
}
}
/// The travel day as its Live Activity shows it on the lock screen (iPhone and
/// iPad): trip and day on top, the next stop large, the day's stops as
/// segments. Plain values rather than the activity's attributes, so the
/// preview renderer can draw it at every width the lock screen gives it.
struct TripDayBanner: View {
let tripTitle: String
let dayLabel: String
let nextName: String
let nextTime: String?
let done: Int
let total: Int
var body: some View {
HStack(alignment: .center, spacing: 14) {
ZStack {
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
Circle()
.trim(from: 0, to: total > 0 ? max(0.02, Double(done) / Double(total)) : 0)
.stroke(
AngularGradient(colors: [Trek.confirmed.opacity(0.7), Trek.confirmed], center: .center),
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(tripTitle)
.font(.system(size: 11, weight: .bold))
.tracking(0.5)
.textCase(.uppercase)
.foregroundStyle(.white.opacity(0.65))
.lineLimit(1)
Spacer(minLength: 4)
Text(dayLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
}
HStack(alignment: .firstTextBaseline) {
Text(nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1).minimumScaleFactor(0.8)
Spacer(minLength: 4)
if let nextTime {
Text(nextTime).font(Trek.number(18, .heavy)).foregroundStyle(.white)
}
}
DayStopProgress(done: done, total: total).padding(.top, 3)
}
}
.padding(16)
}
}
/// The day's timed stops as segments, those behind filled (the Live Activity's bar).
struct DayStopProgress: View {
let done: Int
let total: Int
var body: some View {
DaySegments(index: done + 1, total: max(total, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
}
}
+36 -2
View File
@@ -4,16 +4,22 @@ import Foundation
/// so the views also render outside a widget (the preview renderer in
/// client/ios/WidgetPreviews draws every size to a PNG on the CI's Mac).
enum TrekFamily: String, CaseIterable {
case small, medium, large, circular, rectangular, inline
case small, medium, large, extraLarge, circular, rectangular, inline
var isAccessory: Bool { self == .circular || self == .rectangular || self == .inline }
/// The point size of the family on a 6.1" iPhone, for the renderer.
/// A home-screen widget as tall as a large one: the large and the iPad's
/// extra large, which have room for a list with a foot under it.
var isTall: Bool { self == .large || self == .extraLarge }
/// The point size of the family on a 6.1" iPhone, for the renderer. The
/// extra large exists on an iPad only; its size is that of an 11" iPad.
var previewSize: CGSize {
switch self {
case .small: return CGSize(width: 170, height: 170)
case .medium: return CGSize(width: 364, height: 170)
case .large: return CGSize(width: 364, height: 382)
case .extraLarge: return CGSize(width: 715.5, height: 342)
case .circular: return CGSize(width: 76, height: 76)
case .rectangular: return CGSize(width: 172, height: 76)
case .inline: return CGSize(width: 250, height: 26)
@@ -21,6 +27,34 @@ enum TrekFamily: String, CaseIterable {
}
}
/// The iPads the renderer draws the home-screen widgets for, with the sizes
/// iPadOS gives each family there (Human Interface Guidelines, Widgets). An
/// iPad mini's small widget is smaller than an iPhone's, a 13" iPad's medium
/// and large are wider.
enum TrekPad: String, CaseIterable {
case ipad13, ipad11, ipadMini
/// The small widget's side, the medium's and large's width, the extra large's width.
private var metrics: (small: CGFloat, wide: CGFloat, extraWide: CGFloat) {
switch self {
case .ipad13: return (170, 378.5, 795)
case .ipad11: return (155, 342, 715.5)
case .ipadMini: return (141, 305.5, 634.5)
}
}
func size(_ family: TrekFamily) -> CGSize {
let m = metrics
switch family {
case .small: return CGSize(width: m.small, height: m.small)
case .medium: return CGSize(width: m.wide, height: m.small)
case .large: return CGSize(width: m.wide, height: m.wide)
case .extraLarge: return CGSize(width: m.extraWide, height: m.wide)
default: return family.previewSize
}
}
}
extension WidgetSnapshot {
/// What the widget gallery shows before the app ever wrote a snapshot, and
/// what the renderer draws: a trip to Kyoto with a full day, bookings and to-dos.
+134 -24
View File
@@ -85,6 +85,53 @@ struct TripContent: View {
}
.frame(width: 96, height: 96)
}
case .extraLarge:
// The iPad's wide widget: the trip and its countdown on the left, the
// bookings ahead in a column on the right.
HStack(alignment: .top, spacing: 20) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
Text(snapshot.labels.nextTrip).trekEyebrowOnPhoto()
Spacer(minLength: 0)
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true) }
}
Spacer(minLength: 0)
HStack(alignment: .bottom, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(trip.title)
.font(.system(size: 28, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.75)
if let place = trip.place {
Label(place, systemImage: "location.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
.lineLimit(1)
}
}
Spacer(minLength: 0)
countdown(days, snapshot.labels, size: 54)
}
RouteArc(progress: progress, color: .white)
.frame(height: 34)
.padding(.top, 10)
.padding(.bottom, 12)
TripFacts(snapshot: snapshot, onPhoto: true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
if let bookings = snapshot.bookings, !bookings.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text(snapshot.labels.bookings ?? "").trekEyebrowOnPhoto()
ForEach(Array(bookings.prefix(4).enumerated()), id: \.offset) { _, booking in
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
}
Spacer(minLength: 0)
}
.frame(width: 290)
.frame(maxHeight: .infinity, alignment: .top)
}
}
default:
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
@@ -154,10 +201,17 @@ struct TripContent: View {
switch family {
case .small:
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
Text(today.dayLabel).trekEyebrowOnPhoto()
Spacer(minLength: 0)
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
// An iPad mini's small widget has no room for both: the day stays, the weather gives way.
ViewThatFits(in: .horizontal) {
HStack(alignment: .top) {
Text(today.dayLabel).trekEyebrowOnPhoto()
Spacer(minLength: 0)
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
}
HStack(alignment: .top) {
Text(today.dayLabel).trekEyebrowOnPhoto()
Spacer(minLength: 0)
}
}
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 7)
Spacer(minLength: 0)
@@ -175,31 +229,87 @@ struct TripContent: View {
Text(trip.title).font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)).lineLimit(1).padding(.top, 2)
}
case .medium:
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 0) {
Text(trip.title).trekEyebrowOnPhoto()
Text(today.dayLabel)
.font(.system(size: 21, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.8)
.padding(.top, 3)
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8)
Spacer(minLength: 0)
if let weather = snapshot.weather {
HStack(spacing: 6) {
WeatherChip(weather: weather, onPhoto: true)
if let high = weather.high, let low = weather.low {
Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8))
// 176 pt for the stops on an iPhone and an 11" or 13" iPad; an iPad
// mini's medium widget is narrower, so the panel gives up some width
// and the weather keeps to its chip, and the day still reads whole.
// The line is drawn by the iPad mini alone: its medium widget is
// 305.5 pt wide, 273.5 inside the content margins, while the smallest
// iPhone's (a 4.7" SE) is 321 pt, 289 inside, and keeps the full layout.
GeometryReader { geo in
let narrow = geo.size.width < 281
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 0) {
Text(trip.title).trekEyebrowOnPhoto()
Text(today.dayLabel)
.font(.system(size: 21, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.8)
.padding(.top, 3)
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8)
Spacer(minLength: 0)
if let weather = snapshot.weather {
HStack(spacing: 6) {
WeatherChip(weather: weather, onPhoto: true, compact: narrow)
if !narrow, let high = weather.high, let low = weather.low {
Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8))
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
GlassPanel {
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
}
.frame(width: narrow ? 150 : 176)
}
.frame(maxWidth: .infinity, alignment: .leading)
GlassPanel {
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
.frame(width: geo.size.width, height: geo.size.height)
}
case .extraLarge:
// The iPad's wide widget: the day and the next stop on the left, the
// day's stops and the next booking on the right, each column
// anchored at its top and its foot.
HStack(alignment: .top, spacing: 20) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 3) {
Text(trip.title).trekEyebrowOnPhoto()
Text(today.dayLabel)
.font(.system(size: 26, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
Spacer(minLength: 0)
if let weather = snapshot.weather { BigWeather(weather: weather) }
}
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 10)
Spacer(minLength: 8)
if let next {
Text(next.time ?? snapshot.labels.today)
.font(Trek.number(34, .heavy))
.foregroundStyle(.white)
Text(next.name)
.font(.system(size: 17, weight: .bold))
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.85)
} else {
Text(snapshot.labels.nothingPlanned).font(.system(size: 17, weight: .bold)).foregroundStyle(.white).lineLimit(2)
}
}
.frame(width: 176)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 10) {
GlassPanel {
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 6, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
}
Spacer(minLength: 0)
if let booking = snapshot.bookings?.first {
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
}
}
.frame(width: 300)
.frame(maxHeight: .infinity, alignment: .top)
}
default:
VStack(alignment: .leading, spacing: 0) {
@@ -7,7 +7,11 @@ import WidgetKit
struct TripDayLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TripDayAttributes.self) { context in
TripDayBanner(attributes: context.attributes, state: context.state)
TripDayBanner(
tripTitle: context.attributes.tripTitle, dayLabel: context.state.dayLabel,
nextName: context.state.nextName, nextTime: context.state.nextTime,
done: context.state.done, total: context.state.total
)
.activityBackgroundTint(Color(hex: 0x0C0C0F).opacity(0.92))
.activitySystemActionForegroundColor(.white)
.widgetURL(WidgetSnapshot.link(context.attributes.path))
@@ -31,7 +35,7 @@ struct TripDayLiveActivity: Widget {
DynamicIslandExpandedRegion(.bottom) {
VStack(alignment: .leading, spacing: 8) {
Text(state.nextName).font(.system(size: 17, weight: .heavy)).lineLimit(1)
StopProgress(done: state.done, total: state.total)
DayStopProgress(done: state.done, total: state.total)
}
.padding(.horizontal, 4)
}
@@ -55,58 +59,3 @@ struct TripDayLiveActivity: Widget {
}
}
}
/// The lock-screen banner: trip and day on top, the next stop large, the
/// day's stops as segments.
private struct TripDayBanner: View {
let attributes: TripDayAttributes
let state: TripDayAttributes.ContentState
var body: some View {
HStack(alignment: .center, spacing: 14) {
ZStack {
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
Circle()
.trim(from: 0, to: state.total > 0 ? max(0.02, Double(state.done) / Double(state.total)) : 0)
.stroke(
AngularGradient(colors: [Trek.confirmed.opacity(0.7), Trek.confirmed], center: .center),
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(attributes.tripTitle)
.font(.system(size: 11, weight: .bold))
.tracking(0.5)
.textCase(.uppercase)
.foregroundStyle(.white.opacity(0.65))
.lineLimit(1)
Spacer(minLength: 4)
Text(state.dayLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
}
HStack(alignment: .firstTextBaseline) {
Text(state.nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
Spacer(minLength: 4)
if let time = state.nextTime {
Text(time).font(Trek.number(18, .heavy)).foregroundStyle(.white)
}
}
StopProgress(done: state.done, total: state.total).padding(.top, 3)
}
}
.padding(16)
}
}
/// The day's timed stops as segments, those behind filled.
private struct StopProgress: View {
let done: Int
let total: Int
var body: some View {
DaySegments(index: done + 1, total: max(total, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
}
}
@@ -51,7 +51,23 @@ 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"))
// The iPad: every home-screen size of each widget as that iPad gives
// them, the extra large included, per kind in one strip.
for pad in TrekPad.allCases {
for (stateName, snapshot) in states where ["upcoming", "coverrunning", "empty"].contains(stateName) {
for kind in WidgetKind.allCases {
let strip = PadStrip(kind: kind, pad: pad, snapshot: snapshot, now: now)
.environment(\.colorScheme, scheme)
.environment(\.trekStaticPreview, true)
try write(strip, to: out.appendingPathComponent("pad-\(pad.rawValue)-\(kind.rawValue)-\(stateName)-\(schemeName).png"), scale: 2)
}
}
}
let padLock = PadLockScreen(snapshot: states[1].1, now: now).environment(\.colorScheme, scheme)
try write(padLock, to: out.appendingPathComponent("pad-lock-\(schemeName).png"), scale: 2)
}
try write(LiveActivityWidths(), to: out.appendingPathComponent("liveactivity-widths.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)")
@@ -77,9 +93,9 @@ struct Render {
}
@MainActor
static func write<V: View>(_ view: V, to url: URL) throws {
static func write<V: View>(_ view: V, to url: URL, scale: CGFloat = 3) throws {
let renderer = ImageRenderer(content: view)
renderer.scale = 3
renderer.scale = scale
guard let image = renderer.nsImage,
let tiff = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
@@ -95,9 +111,9 @@ enum WidgetKind: String, CaseIterable {
var families: [TrekFamily] {
switch self {
case .trip: return [.small, .medium, .large, .circular, .rectangular, .inline]
case .today: return [.medium, .large]
case .bookings: return [.small, .medium, .large, .rectangular, .inline]
case .trip: return [.small, .medium, .large, .extraLarge, .circular, .rectangular, .inline]
case .today: return [.medium, .large, .extraLarge]
case .bookings: return [.small, .medium, .large, .extraLarge, .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]
@@ -113,9 +129,11 @@ struct WidgetFrame: View {
let family: TrekFamily
let snapshot: WidgetSnapshot
let now: Date
/// The device's size for the family, when it is not the 6.1" iPhone's.
var size: CGSize? = nil
var body: some View {
let size = family.previewSize
let size = self.size ?? family.previewSize
if family.isAccessory {
// As the lock screen draws them: white on the wallpaper, no box of their own
// (the circular one gets the system's soft disc).
@@ -270,3 +288,98 @@ struct NewWidgets: View {
)
}
}
/// One widget kind in every home-screen size an iPad gives it, side by side on
/// that iPad's wallpaper, the extra large last.
struct PadStrip: View {
let kind: WidgetKind
let pad: TrekPad
let snapshot: WidgetSnapshot
let now: Date
@Environment(\.colorScheme) private var scheme
var body: some View {
HStack(alignment: .top, spacing: 24) {
ForEach(kind.families.filter { !$0.isAccessory }, id: \.self) { family in
WidgetFrame(kind: kind, family: family, snapshot: snapshot, now: now, size: pad.size(family))
}
}
.padding(28)
.background(
LinearGradient(
colors: scheme == .dark ? [Color(hex: 0x1D2440), Color(hex: 0x0B0F1E)] : [Color(hex: 0xC9D8F0), Color(hex: 0xF3E3D3)],
startPoint: .topLeading, endPoint: .bottomTrailing
)
)
}
}
/// An 11" iPad's lock screen on its side: the clock with the accessories under
/// it on the left, as iPadOS places them, and the travel day's Live Activity.
struct PadLockScreen: View {
let snapshot: WidgetSnapshot
let now: Date
var body: some View {
HStack(alignment: .top, spacing: 0) {
VStack(alignment: .leading, spacing: 14) {
WidgetFrame(kind: .trip, family: .inline, snapshot: snapshot, now: now)
Text(now, format: .dateTime.hour().minute())
.font(.system(size: 96, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
HStack(spacing: 10) {
WidgetFrame(kind: .trip, family: .circular, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .circular, snapshot: snapshot, now: now)
WidgetFrame(kind: .packing, family: .circular, snapshot: snapshot, now: now)
}
WidgetFrame(kind: .trip, family: .rectangular, snapshot: snapshot, now: now)
WidgetFrame(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .rectangular, snapshot: snapshot, now: now)
}
.padding(.leading, 64)
.padding(.top, 70)
Spacer(minLength: 0)
}
.frame(width: 1194, height: 834, alignment: .topLeading)
.overlay(alignment: .bottom) {
LiveActivityCard(width: 500).padding(.bottom, 40)
}
.background(
LinearGradient(colors: [Color(hex: 0x2B3A67), Color(hex: 0x6B4A6E), Color(hex: 0x1C1726)], startPoint: .top, endPoint: .bottom)
)
}
}
/// The travel day's Live Activity as the lock screen tints it.
struct LiveActivityCard: View {
let width: CGFloat
var body: some View {
TripDayBanner(
tripTitle: "Autumn in Kyoto", dayLabel: "Day 3 of 9",
nextName: "Kiyomizu-dera", nextTime: "13:30", done: 2, total: 5
)
.frame(width: width)
.background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.92)))
}
}
/// The Live Activity at the widths an iPhone's and an iPad's lock screen give
/// it, and with a long German day and stop, to see it hold at each.
struct LiveActivityWidths: View {
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ForEach([364, 500, 640] as [CGFloat], id: \.self) { width in
LiveActivityCard(width: width)
}
TripDayBanner(
tripTitle: "Herbst in Kyoto und Nara", dayLabel: "Tag 3 von 9",
nextName: "Kiyomizu-dera und Sannenzaka-Spaziergang", nextTime: "13:30", done: 2, total: 5
)
.frame(width: 364)
.background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.92)))
}
.padding(28)
.background(Color(hex: 0x1E2A3A))
}
}
+101 -19
View File
@@ -1,18 +1,37 @@
# Adds an app extension target to App.xcodeproj and embeds it in the app.
# Adds an extension target (or the watch app) to App.xcodeproj and embeds it.
#
# ruby ios/scripts/add-extension.rb <name> <bundle-id> <type> [extra frameworks...]
# ruby ios/scripts/add-extension.rb <name> <bundle-id> <type> [options] [extra frameworks...]
#
# type is "notification" (Notification Service Extension), "widget" (WidgetKit,
# which also carries the Live Activities) or "share" (the share sheet entry). The extension's sources live in
# ios/App/<name>/ with an Info.plist and <name>.entitlements next to them. The
# which also carries the Live Activities), "share" (the share sheet entry),
# "watch" (the Apple Watch app, embedded in the phone app's Watch folder) or
# "watchwidget" (the watch's complications, embedded in the watch app). The
# target's sources live in ios/App/<name>/ with an Info.plist and
# <name>.entitlements next to them; an asset catalog or a privacy manifest
# there is a resource. The
# project file stays checked in, so the targets show in Xcode like any other;
# this script is how they got there, and running it again changes nothing.
#
# Options:
# --with=<path>,... more sources under ios/App compiled into the target
# too, each a folder of .swift files or one file
# (code shared between targets keeps one reference)
# --host=<target> the target a watchwidget is embedded in
# --app-file=<path>,... sources added to the phone app itself: the phone's end
# of what the new target talks to
# --strings-from=<name> the target uses the Localizable.strings of that folder
require 'xcodeproj'
name, bundle_id, type, *frameworks = ARGV
abort 'usage: add-extension.rb <name> <bundle-id> <notification|widget|share> [frameworks...]' unless name && bundle_id && type
name, bundle_id, type, *rest = ARGV
types = %w[notification widget share watch watchwidget]
abort "usage: add-extension.rb <name> <bundle-id> <#{types.join('|')}> [options] [frameworks...]" unless name && bundle_id && types.include?(type)
options, frameworks = rest.partition { |arg| arg.start_with?('--') }
option = ->(key) { options.find { |o| o.start_with?("--#{key}=") }&.split('=', 2)&.last }
list = ->(key) { (option.(key) || '').split(',').reject(&:empty?) }
watch = %w[watch watchwidget].include?(type)
app_dir = File.expand_path('../App', __dir__)
project_path = File.expand_path('../App/App.xcodeproj', __dir__)
project_path = File.join(app_dir, 'App.xcodeproj')
project = Xcodeproj::Project.open(project_path)
app = project.targets.find { |t| t.name == 'App' } or abort 'no App target'
@@ -21,7 +40,11 @@ if project.targets.any? { |t| t.name == name }
exit 0
end
target = project.new_target(:app_extension, name, :ios, '15.0', nil, :swift)
target = if type == 'watch'
project.new_target(:application, name, :watchos, '11.0', nil, :swift)
else
project.new_target(:app_extension, name, watch ? :watchos : :ios, watch ? '11.0' : '15.0', nil, :swift)
end
# new_target links Foundation by a path into one SDK version; it is linked
# implicitly anyway, and a pinned SDK path breaks on the next Xcode.
target.frameworks_build_phase.files.select { |f| f.display_name.to_s.include?('Foundation') }.each do |f|
@@ -33,15 +56,15 @@ end
group = project.main_group.find_subpath(name, true)
group.set_source_tree('<group>')
group.set_path(name)
sources = Dir.children(File.expand_path("../App/#{name}", __dir__)).sort
sources = Dir.children(File.join(app_dir, name)).sort
# Localizations go in as one variant group, so Xcode copies each language's
# strings into the extension bundle; JavaScript (Safari's page preprocessing) is
# a resource as well.
# strings into the extension bundle; JavaScript (Safari's page preprocessing) and
# an asset catalog are resources as well.
lprojs, files = sources.partition { |file| file.end_with?('.lproj') }
files.each do |file|
ref = group.new_reference(file)
target.add_file_references([ref]) if file.end_with?('.swift')
target.add_resources([ref]) if file.end_with?('.js')
target.add_resources([ref]) if file.end_with?('.js', '.xcassets', '.xcprivacy')
end
unless lprojs.empty?
variant = group.new_variant_group('Localizable.strings')
@@ -55,6 +78,39 @@ unless lprojs.empty?
target.add_resources([variant])
end
# A file already in the project (the widgets' style, shared with the watch)
# keeps its one reference; one new to it goes in a group named after its folder.
reference_for = lambda do |relative|
full = File.join(app_dir, relative)
project.files.find { |f| f.real_path.to_s == full } || begin
dir = File.dirname(relative)
parent = project.main_group
unless dir == '.'
parent = project.main_group.find_subpath(dir, true)
parent.set_source_tree('<group>')
parent.set_path(dir)
end
parent.new_reference(File.basename(relative))
end
end
list.('with').each do |entry|
folder = File.join(app_dir, entry)
paths = File.directory?(folder) ? Dir.children(folder).sort.select { |f| f.end_with?('.swift') }.map { |f| "#{entry}/#{f}" } : [entry]
target.add_file_references(paths.map { |path| reference_for.(path) })
end
list.('app-file').each do |path|
ref = reference_for.(path)
app.add_file_references([ref]) unless app.source_build_phase.files_references.include?(ref)
end
if (strings_from = option.('strings-from'))
variant = project.main_group.find_subpath(strings_from, false)&.children&.find { |c| c.isa == 'PBXVariantGroup' && c.name == 'Localizable.strings' }
abort "no Localizable.strings in #{strings_from}" unless variant
target.add_resources([variant])
end
frameworks.each do |framework|
ref = project.frameworks_group.new_file("System/Library/Frameworks/#{framework}.framework", :sdk_root)
target.frameworks_build_phase.add_file_reference(ref, true)
@@ -71,20 +127,46 @@ target.build_configurations.each do |config|
s['CODE_SIGN_STYLE'] = 'Automatic'
s['DEVELOPMENT_TEAM'] = app_settings['DEVELOPMENT_TEAM'] if app_settings['DEVELOPMENT_TEAM']
s['SWIFT_VERSION'] = '5.0'
s['TARGETED_DEVICE_FAMILY'] = '1,2'
s['IPHONEOS_DEPLOYMENT_TARGET'] = type == 'widget' ? '16.2' : '15.0'
s['MARKETING_VERSION'] = app_settings['MARKETING_VERSION'] || '1.0'
s['CURRENT_PROJECT_VERSION'] = app_settings['CURRENT_PROJECT_VERSION'] || '1'
s['SKIP_INSTALL'] = 'YES'
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks', '@executable_path/../../Frameworks']
if watch
s.delete('IPHONEOS_DEPLOYMENT_TARGET')
s['SDKROOT'] = 'watchos'
s['TARGETED_DEVICE_FAMILY'] = '4'
s['WATCHOS_DEPLOYMENT_TARGET'] = '11.0'
if type == 'watch'
s['ASSETCATALOG_COMPILER_APPICON_NAME'] = 'AppIcon'
s['ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME'] = 'AccentColor'
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks']
else
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks', '@executable_path/../../Frameworks']
end
else
s['TARGETED_DEVICE_FAMILY'] = '1,2'
s['IPHONEOS_DEPLOYMENT_TARGET'] = type == 'widget' ? '16.2' : '15.0'
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks', '@executable_path/../../Frameworks']
end
end
embed = app.copy_files_build_phases.find { |p| p.name == 'Embed Foundation Extensions' } ||
app.new_copy_files_build_phase('Embed Foundation Extensions')
embed.symbol_dst_subfolder_spec = :plug_ins
# The watch app goes into the phone app's Watch folder, every extension into the
# PlugIns of its host: the phone app, or the watch app for the complications.
if type == 'watch'
host = app
embed = app.copy_files_build_phases.find { |p| p.name == 'Embed Watch Content' } ||
app.new_copy_files_build_phase('Embed Watch Content')
embed.symbol_dst_subfolder_spec = :products_directory
embed.dst_path = '$(CONTENTS_FOLDER_PATH)/Watch'
else
host = type == 'watchwidget' ? project.targets.find { |t| t.name == option.('host') } : app
abort 'a watchwidget needs --host=<watch target>' unless host
embed = host.copy_files_build_phases.find { |p| p.name == 'Embed Foundation Extensions' } ||
host.new_copy_files_build_phase('Embed Foundation Extensions')
embed.symbol_dst_subfolder_spec = :plug_ins
end
build_file = embed.add_file_reference(target.product_reference, true)
build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }
app.add_dependency(target)
host.add_dependency(target)
project.save
puts "added #{name} (#{bundle_id})"
+171
View File
@@ -0,0 +1,171 @@
#!/bin/bash
# Captures the watch app's screens from watchOS simulators, in screenshot mode
# (client/ios/App/TREKWatch/DemoMode.swift), for review and the App Store:
# every screen and its states in English, German and Russian on the newest
# Ultra, the largest other watch and the smallest one, and the main screens in
# every language on the smallest, where text is tightest.
# .github/workflows/native.yml runs it after building the app.
#
# watch-screens.sh <TREKWatch.app> <output directory>
set -euo pipefail
APP="$1"
OUT="$2"
BUNDLE=com.liketrek.trek.watchkitapp
mkdir -p "$OUT"
# The newest watchOS runtime, its newest Ultra, the largest other watch and the
# smallest one it still runs on.
read -r RUNTIME LARGE MEDIUM SMALL < <(xcrun simctl list runtimes -j | python3 -c '
import json, re, sys
runtimes = [r for r in json.load(sys.stdin)["runtimes"] if r["platform"] == "watchOS" and r["isAvailable"]]
runtime = sorted(runtimes, key=lambda r: [int(x) for x in r["version"].split(".")])[-1]
names = [t["name"] for t in runtime["supportedDeviceTypes"]]
def mm(n):
m = re.search(r"(\d+)mm", n); return int(m.group(1)) if m else 0
def gen(n):
m = re.search(r"(?:Series|Ultra|SE) ?(\d+)?", n); return int(m.group(1) or 1) if m else 0
ultra = sorted([n for n in names if "Ultra" in n], key=gen)[-1]
rest = sorted([n for n in names if "Ultra" not in n], key=lambda n: (mm(n), gen(n)))
print(runtime["identifier"], *[n.replace(" ", "_") for n in (ultra, rest[-1], rest[0])])')
LARGE=${LARGE//_/ }
MEDIUM=${MEDIUM//_/ }
SMALL=${SMALL//_/ }
echo "runtime $RUNTIME; large: $LARGE, medium: $MEDIUM, small: $SMALL"
boot() {
local udid
udid=$(xcrun simctl create "$1" "$1" "$RUNTIME")
xcrun simctl boot "$udid"
xcrun simctl bootstatus "$udid" -b >/dev/null
xcrun simctl install "$udid" "$APP"
echo "$udid"
}
# The watch draws the Mac's clock in its corner, so the Mac goes to a zone in
# which it is about one o'clock: the hour the sample trip's screens are made for
# (WatchSample.swift), and the clock and the countdowns agree.
set_clock() {
local offset=$(( (13 - 10#$(date -u +%H) + 36) % 24 - 12 ))
local zone
if [ "$offset" -ge 0 ]; then zone="Etc/GMT-$offset"; else zone="Etc/GMT+$(( -offset ))"; fi
sudo ln -sf "/usr/share/zoneinfo/$zone" /etc/localtime
echo "clock: $zone, $(date +%H:%M)"
}
# Waits for the ready file the app writes once the screen has drawn (DemoMode.signalReady).
wait_ready() {
local udid=$1 token=$2 container
for _ in $(seq 1 40); do
container=$(xcrun simctl get_app_container "$udid" "$BUNDLE" data 2>/dev/null || true)
[ -n "$container" ] && [ -f "$container/Documents/ready-$token" ] && return 0
sleep 0.25
done
return 1
}
TOKEN=0
# shot <udid> <file> <screen> <scenario> <time or -> <language>: one launch, retried
# after 2, 5, 10 and 20 seconds when the simulator refuses it, saying which step failed.
shot() {
local udid=$1 file=$2 screen=$3 scenario=$4 clock=$5 lang=$6
local locale=${lang//-/_} attempt failed=
for attempt in 0 2 5 10 20; do
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s ($failed)"; sleep "$attempt"; }
TOKEN=$((TOKEN + 1))
xcrun simctl terminate "$udid" "$BUNDLE" >/dev/null 2>&1 || true
sleep 0.5
local extra=()
[ "$clock" = - ] || extra=(-TrekNow "$clock")
xcrun simctl launch "$udid" "$BUNDLE" \
-AppleLanguages "($lang)" -AppleLocale "$locale" -TrekDemo "$screen" -TrekScenario "$scenario" \
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || { failed=launch; continue; }
wait_ready "$udid" "$TOKEN" || { failed='not ready'; continue; }
# The map's tiles come over the network after the screen is up.
[ "$screen" = place ] && sleep 6
xcrun simctl io "$udid" screenshot --type=png "$file" >/dev/null 2>&1 && return 0
failed=screenshot
done
echo "gave up on $(basename "$file")"
}
# name screen scenario clock: every screen, and the states a screen has; a
# clock only where the state needs another hour than the real one.
SHOTS="
today today running -
today-travelday today travelday -
today-done today running 22:30
day day running -
place place running -
booking booking travelday -
booking-dinner booking running -
bookings bookings travelday -
booking-detail booking-detail travelday -
trip trip running -
trip-upcoming trip upcoming -
days days running -
countdown countdown upcoming -
lists lists upcoming -
packing packing upcoming -
todos todos upcoming -
empty empty running -
nosync nosync running -
faces faces running -
"
MAIN="today booking countdown trip lists packing day"
LANGS="ar az ca cs de el en es et fr he hu id it ja ko nl pl pt-BR ru sk sv th tr uk vi zh-Hans zh-Hant"
# The watch draws its corner clock in its own region's format, not the app's:
# English goes first on a 12 hour clock, then the watch is switched to 24
# hours, as the other languages read it, and restarted for the clock to follow.
use_24_hours() {
local udid=$1
xcrun simctl spawn "$udid" defaults write -g AppleICUForce24HourTime -bool YES
xcrun simctl spawn "$udid" defaults write -g AppleLocale de_DE
xcrun simctl shutdown "$udid"
xcrun simctl boot "$udid"
xcrun simctl bootstatus "$udid" -b >/dev/null
}
EXPECTED=()
run() {
local size=$1 udid=$2 lang shotname screen scenario clock
for lang in en de ru; do
[ "$lang" = de ] && use_24_hours "$udid"
while read -r shotname screen scenario clock; do
[ -n "$shotname" ] || continue
EXPECTED+=("$size-$lang-$shotname.png")
shot "$udid" "$OUT/$size-$lang-$shotname.png" "$screen" "$scenario" "$clock" "$lang"
done <<< "$SHOTS"
done
if [ "$size" = small ]; then
for lang in $LANGS; do
case "$lang" in en|de|ru) continue ;; esac
while read -r shotname screen scenario clock; do
[ -n "$shotname" ] || continue
case " $MAIN " in
*" $shotname "*)
EXPECTED+=("$size-$lang-$shotname.png")
shot "$udid" "$OUT/$size-$lang-$shotname.png" "$screen" "$scenario" "$clock" "$lang"
;;
esac
done <<< "$SHOTS"
done
fi
xcrun simctl shutdown "$udid" || true
}
# One watch at a time: launches on several booted simulators at once is what
# makes SpringBoard refuse them ("Scene update failed").
set_clock
run large "$(boot "$LARGE" | tail -1)"
run medium "$(boot "$MEDIUM" | tail -1)"
run small "$(boot "$SMALL" | tail -1)"
printf '%s\n' "large: $LARGE" "medium: $MEDIUM" "small: $SMALL" > "$OUT/devices.txt"
missing=0
for file in "${EXPECTED[@]}"; do
[ -s "$OUT/$file" ] || { echo "missing: $file"; missing=$((missing + 1)); }
done
echo "${#EXPECTED[@]} expected, $missing missing"
[ "$missing" -eq 0 ]
+6 -1
View File
@@ -1,4 +1,4 @@
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared/i18n'
import { SUPPORTED_LANGUAGE_CODES, isRtlLanguage } from '@trek/shared/i18n'
import type { TranslationStrings } from '@trek/shared/i18n'
// Only the native strings of every locale, not the whole UI: this page ships
@@ -32,3 +32,8 @@ export function translator(code: string) {
: key
}
}
/** Arabic and Hebrew run right to left. */
export function directionOf(code: string): 'rtl' | 'ltr' {
return isRtlLanguage(code) ? 'rtl' : 'ltr'
}
+114 -59
View File
@@ -5,11 +5,12 @@ 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 { opensSoon, refundedBeforeOpening } from '../src/native/cloudOrders';
import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap';
import { CLOUD_TERMS_URL, 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';
import { directionOf, pickLocale, translator } from './i18n';
import './shell.css';
/**
@@ -24,7 +25,7 @@ const locale = pickLocale(navigator.languages?.length ? navigator.languages : [n
const onPlay = Capacitor.getPlatform() === 'android';
const t = translator(locale);
document.documentElement.lang = locale;
document.documentElement.dir = locale === 'ar' ? 'rtl' : 'ltr';
document.documentElement.dir = directionOf(locale);
const root = document.getElementById('shell')!;
const PROBE_TIMEOUT_MS = 8000;
@@ -128,6 +129,11 @@ function show(screen: HTMLElement, opts: { back?: boolean } = {}): void {
if (opts.back) screen.classList.add('screen-back');
const previous = current;
if (previous) {
// The leaving screen is lifted out of the flow where it stands, so it fades
// from its own place rather than jumping to the top of a centred layout.
previous.style.top = `${previous.offsetTop}px`;
previous.style.left = `${previous.offsetLeft}px`;
previous.style.width = `${previous.offsetWidth}px`;
previous.classList.remove('screen-enter');
previous.classList.add('screen-leave');
if (opts.back) previous.classList.add('screen-back');
@@ -144,6 +150,15 @@ function externalLink(text: string, href: string): HTMLAnchorElement {
function footLinks(...extra: Node[]): HTMLElement {
return el('div', { className: 'foot-links' }, ...extra, externalLink(t('native.privacy'), PRIVACY_URL));
}
/**
* A screen's two halves: what it is (brand, title, explanation) and what to do
* on it (the choices, the form). Stacked in one column they read as before; on
* a phone or a foldable on its side the screen is too short for one column, so
* the halves stand side by side there (shell.css, `.col`).
*/
function columns(about: Node[], act: Node[]): HTMLElement[] {
return [el('div', { className: 'col col-about' }, ...about), el('div', { className: 'col col-act' }, ...act)];
}
function topbar(onBack: () => void): HTMLElement {
const back = el('button', { type: 'button', className: 'back' }, icon('M15 18l-6-6 6-6', 18), t('native.back'));
back.addEventListener('click', () => {
@@ -202,13 +217,14 @@ function showWelcome(opts: { back?: boolean } = {}): void {
el(
'div',
{},
hero,
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
el('p', { className: 'sub' }, t('native.welcome.sub')),
cloud,
connect,
demo,
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
...columns(
[
hero,
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
el('p', { className: 'sub' }, t('native.welcome.sub')),
],
[cloud, connect, demo, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))]
)
),
opts
);
@@ -225,6 +241,8 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
});
input.setAttribute('inputmode', 'url');
input.setAttribute('autocomplete', 'url');
// An address reads left to right in every language, Arabic too.
input.dir = 'ltr';
// The on-screen keyboard's return key submits the address; it reads "Go", not a newline.
input.setAttribute('enterkeyhint', 'go');
input.setAttribute('aria-label', t('native.connect.title'));
@@ -266,11 +284,14 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
el(
'div',
{},
topbar(() => showWelcome({ back: true })),
el('h1', { className: 'title-sm' }, t('native.connect.title')),
el('p', { className: 'sub' }, t('native.connect.hint')),
form,
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
...columns(
[
topbar(() => showWelcome({ back: true })),
el('h1', { className: 'title-sm' }, t('native.connect.title')),
el('p', { className: 'sub' }, t('native.connect.hint')),
],
[form, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))]
)
),
opts
);
@@ -288,7 +309,13 @@ function feature(path: string, text: string): HTMLElement {
}
function showCloud(opts: { back?: boolean } = {}): void {
const cta = el('button', { type: 'button', className: 'btn' }, t('native.cloud.cta'), icon(I.chevron, 20, 'chev'));
// The label centred, the chevron at the far edge: it points on, not at the word.
const cta = el(
'button',
{ type: 'button', className: 'btn btn-go' },
el('span', { className: 'btn-label' }, t('native.cloud.cta')),
icon(I.chevron, 20, 'chev')
);
cta.addEventListener('click', () => {
void tap();
showPlans();
@@ -304,25 +331,32 @@ function showCloud(opts: { back?: boolean } = {}): void {
el(
'div',
{},
topbar(() => showWelcome({ back: true })),
el(
'div',
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title') },
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
el('span', { className: 'cloud-tag' }, 'cloud')
),
el('p', { className: 'sub' }, t('native.cloud.sub')),
el(
'div',
{ className: 'features' },
feature(I.globe, t('native.cloud.f1')),
feature(I.bolt, t('native.cloud.f2')),
feature(I.shield, t('native.cloud.f3'))
),
cta,
have,
footLinks()
...columns(
[
topbar(() => showWelcome({ back: true })),
// The lockup is a logo: "trek" then "cloud" in every language, never mirrored.
el(
'div',
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title'), dir: 'ltr' },
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
el('span', { className: 'cloud-tag' }, 'cloud')
),
el('p', { className: 'sub' }, t('native.cloud.sub')),
],
[
el(
'div',
{ className: 'features' },
feature(I.globe, t('native.cloud.f1')),
feature(I.bolt, t('native.cloud.f2')),
feature(I.shield, t('native.cloud.f3'))
),
cta,
have,
footLinks(),
]
)
),
opts
);
@@ -396,12 +430,14 @@ function offerFor(offer: Offer, plan: CloudPlan, interval: 'month' | 'year'): {
async function loadOffer(list: CloudPlans): Promise<Offer> {
const available = await iapReady;
// Closed: a store purchase would be refused at the cloud, so only a test build may buy.
const opensSoon = available && list.closed && !(await iap.isSandbox().catch(() => false));
const canBuy = available && !opensSoon;
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon };
// Closed: a real purchase is refused at the cloud, so only a test build may buy on iOS;
// Android keeps the button for the licence testers (see cloudOrders.ts).
const sandbox = list.closed && !onPlay ? await iap.isSandbox().catch(() => false) : false;
const soon = opensSoon({ available, closed: list.closed, sandbox, onPlay });
const canBuy = available && !soon;
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon: soon };
const ids = list.plans.flatMap((p) => [productIdFor(p.id, 'month'), productIdFor(p.id, 'year')]);
return { canBuy, storePrices: await iap.prices(ids), opensSoon };
return { canBuy, storePrices: await iap.prices(ids), opensSoon: soon };
}
function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HTMLElement {
@@ -427,7 +463,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HT
{ className: 'plan-top' },
el('span', { className: 'plan-name' }, plan.name),
// No price yet (orders not open): the plan shows without one rather than with an empty one.
...(label ? [el('span', { className: 'plan-price' }, label, el('small', {}, per))] : [])
// The price is figures and a currency sign: one left-to-right run, so Arabic
// keeps "5,99 €" whole, with the period standing apart beside it.
...(label ? [el('span', { className: 'plan-price' }, el('bdi', { dir: 'ltr' }, label), el('small', {}, per))] : [])
),
el(
'div',
@@ -491,7 +529,10 @@ function planFooter(offer: Offer, plans: CloudPlan[]): Node[] {
void notify(NotificationType.Error);
restore.disabled = false;
status.className = 'hint bad';
status.textContent = t('native.plans.restoreFailed');
// Bought before the opening: refused, unacknowledged, and refunded by Google Play.
status.textContent = refundedBeforeOpening(redeemed.code, onPlay)
? t('native.claim.closedRefund')
: t('native.plans.restoreFailed');
});
// A payment that was pending (cash, some carrier billing) and has cleared since:
@@ -578,12 +619,15 @@ function showPlans(opts: { back?: boolean } = {}): void {
el(
'div',
{},
topbar(() => showCloud({ back: true })),
el('h1', { className: 'title-sm' }, t('native.plans.title')),
el('p', { className: 'sub' }, t('native.plans.sub')),
seg,
content,
footer
...columns(
[
topbar(() => showCloud({ back: true })),
el('h1', { className: 'title-sm' }, t('native.plans.title')),
el('p', { className: 'sub' }, t('native.plans.sub')),
seg,
],
[content, footer]
)
),
opts
);
@@ -661,9 +705,10 @@ function showClaim(
slugInput.setAttribute('autocomplete', 'off');
slugInput.setAttribute('maxlength', '40');
slugInput.setAttribute('aria-label', t('native.claim.addressLabel'));
// "name.liketrek.com" is one address, left to right in every language.
const slugField = el(
'div',
{ className: 'field slug-field' },
{ className: 'field slug-field', dir: 'ltr' },
slugInput,
el('span', { className: 'slug-suffix' }, cloudSuffix())
);
@@ -680,6 +725,8 @@ function showClaim(
emailInput.setAttribute('autocomplete', 'email');
emailInput.setAttribute('enterkeyhint', 'go');
emailInput.setAttribute('aria-label', t('native.claim.emailLabel'));
emailInput.dir = 'ltr';
emailInput.classList.add('ltr-value');
const emailField = el('div', { className: 'field' }, emailInput);
const submit = el('button', { type: 'submit', className: 'btn' }, t('native.claim.subscribe', { price }));
@@ -797,7 +844,9 @@ function showClaim(
slugNote.textContent = t('native.claim.taken');
slugNote.className = 'hint bad';
} else {
error.textContent = t('native.claim.redeemFailed');
error.textContent = refundedBeforeOpening(redeemed.code, onPlay)
? t('native.claim.closedRefund')
: t('native.claim.redeemFailed');
}
});
@@ -805,10 +854,14 @@ function showClaim(
el(
'div',
{},
topbar(() => showPlans({ back: true })),
el('h1', { className: 'title-sm' }, t('native.claim.title')),
el('p', { className: 'sub' }, t('native.claim.sub')),
form
...columns(
[
topbar(() => showPlans({ back: true })),
el('h1', { className: 'title-sm' }, t('native.claim.title')),
el('p', { className: 'sub' }, t('native.claim.sub')),
],
[form]
)
),
opts
);
@@ -933,12 +986,14 @@ function showUnreachable(server: string): void {
el(
'div',
{},
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
el('h1', { className: 'title-sm' }, t('native.offline.title')),
el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })),
retry,
change,
footLinks()
...columns(
[
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
el('h1', { className: 'title-sm' }, t('native.offline.title')),
el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })),
],
[retry, change, footLinks()]
)
)
);
}
+324 -41
View File
@@ -38,6 +38,45 @@
font-display: swap;
src: url('./fonts/museomoderno-latin-600.woff2') format('woff2');
}
/*
* Poppins and MuseoModerno have no Cyrillic, so Russian and Ukrainian fell back
* to the system's plain sans. Each family gets a Cyrillic-only stand-in under its
* own name, picked for the same build: Montserrat for the geometric text face,
* Comfortaa for the rounded headline. The unicode-range keeps Latin on TREK's
* own faces and loads these only when a Cyrillic letter is on screen.
*/
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./fonts/montserrat-cyrillic-500.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('./fonts/montserrat-cyrillic-600.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./fonts/montserrat-cyrillic-700.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'MuseoModerno';
font-style: normal;
font-weight: 600 700;
font-display: swap;
src: url('./fonts/comfortaa-cyrillic-700.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* Italic, subset to the letters of "cloud" — the wordmark's badge, nothing else. */
@font-face {
font-family: 'MuseoModerno';
@@ -64,6 +103,19 @@
/* The hairline the ambient grid is drawn in, and a travel-dusk wash for depth. */
--grid: rgba(255, 255, 255, 0.055);
--wash-1: rgba(129, 140, 248, 0.12);
/* Mixes with transparent, spelled out per scheme: older Android web views (Chrome 113)
resolved color-mix() towards transparent as opaque and drew solid halos and rings. */
--accent-ink-a16: rgba(9, 9, 11, 0.16);
--accent-ink-a35: rgba(9, 9, 11, 0.35);
--danger-a16: rgba(248, 113, 113, 0.16);
--ink-a22: rgba(250, 250, 250, 0.22);
--ink-a3: rgba(250, 250, 250, 0.03);
--ink-a4: rgba(250, 250, 250, 0.04);
--ink-a9: rgba(250, 250, 250, 0.09);
--ok-a16: rgba(74, 222, 128, 0.16);
--ok-a22: rgba(74, 222, 128, 0.22);
--wash-1-a85: rgba(129, 140, 248, 0.102);
--wash-1-a90: rgba(129, 140, 248, 0.108);
--wash-2: rgba(244, 114, 182, 0.08);
--veil-top: rgba(9, 9, 11, 0.4);
--veil-mid: rgba(9, 9, 11, 0.72);
@@ -71,8 +123,13 @@
--radius: 16px;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
/* 1 left to right, -1 in Arabic: a screen that comes next slides in from the trailing side. */
--flow: 1;
color-scheme: dark;
}
:root[dir='rtl'] {
--flow: -1;
}
@media (prefers-color-scheme: light) {
:root {
@@ -91,6 +148,17 @@
--ok: #16a34a;
--grid: rgba(17, 24, 39, 0.075);
--wash-1: rgba(79, 70, 229, 0.1);
--accent-ink-a16: rgba(255, 255, 255, 0.16);
--accent-ink-a35: rgba(255, 255, 255, 0.35);
--danger-a16: rgba(220, 38, 38, 0.16);
--ink-a22: rgba(17, 24, 39, 0.22);
--ink-a3: rgba(17, 24, 39, 0.03);
--ink-a4: rgba(17, 24, 39, 0.04);
--ink-a9: rgba(17, 24, 39, 0.09);
--ok-a16: rgba(22, 163, 74, 0.16);
--ok-a22: rgba(22, 163, 74, 0.22);
--wash-1-a85: rgba(79, 70, 229, 0.085);
--wash-1-a90: rgba(79, 70, 229, 0.09);
--wash-2: rgba(219, 39, 119, 0.07);
--veil-top: rgba(255, 255, 255, 0.12);
--veil-mid: rgba(255, 255, 255, 0.74);
@@ -167,6 +235,18 @@ body::after {
pointer-events: none;
z-index: 0;
}
/* In Arabic the photo holds the leading (right) side and the grid the other. */
:root[dir='rtl'] body::after {
background-position:
0 0,
right 28%;
-webkit-mask-image: linear-gradient(270deg, #000 0%, #000 40%, transparent 80%);
mask-image: linear-gradient(270deg, #000 0%, #000 40%, transparent 80%);
}
:root[dir='rtl'] body::before {
-webkit-mask-image: radial-gradient(135% 95% at 0% 0%, #000 34%, transparent 80%);
mask-image: radial-gradient(135% 95% at 0% 0%, #000 34%, transparent 80%);
}
.shell {
position: relative;
@@ -175,11 +255,12 @@ body::after {
min-height: 100dvh;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 440px;
margin: 0 auto;
padding: calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 24px);
overflow: hidden;
/* Clip only the sideways slide: a screen taller than the window scrolls the
page instead (and a sticky column needs an ancestor that does not scroll). */
overflow-x: clip;
}
/* ── Screen transitions ──────────────────────────────────────────────────── */
@@ -187,14 +268,22 @@ body::after {
display: flex;
flex-direction: column;
gap: 14px;
/* Centred while it fits; taller than the window it starts at the top and the
page scrolls, rather than overflowing both edges as justify-content would. */
margin-block: auto;
}
/* A screen's two halves (main.ts, columns()): one column unless the layout asks. */
.col {
display: contents;
}
.screen-enter {
animation: screenIn 0.5s var(--ease) both;
}
/* Lifted out where it stood: main.ts sets its top, left and width. */
.screen-leave {
animation: screenOut 0.28s var(--ease-in) both;
position: absolute;
inset: calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 24px);
margin: 0;
}
.screen-back.screen-enter {
animation-name: screenInBack;
@@ -205,7 +294,7 @@ body::after {
@keyframes screenIn {
from {
opacity: 0;
transform: translateX(26px);
transform: translateX(calc(26px * var(--flow)));
}
to {
opacity: 1;
@@ -215,13 +304,13 @@ body::after {
@keyframes screenOut {
to {
opacity: 0;
transform: translateX(-22px);
transform: translateX(calc(-22px * var(--flow)));
}
}
@keyframes screenInBack {
from {
opacity: 0;
transform: translateX(-26px);
transform: translateX(calc(-26px * var(--flow)));
}
to {
opacity: 1;
@@ -231,14 +320,40 @@ body::after {
@keyframes screenOutBack {
to {
opacity: 0;
transform: translateX(22px);
transform: translateX(calc(22px * var(--flow)));
}
}
/* Children stagger in, so a screen assembles rather than snaps. */
.screen-enter > * {
/* Children stagger in, so a screen assembles rather than snaps. Inside the two
halves the explanation comes first and the choices follow. */
.screen-enter > :not(.col),
.screen-enter > .col > * {
animation: riseIn 0.55s var(--ease) both;
}
.screen-enter > .col > :nth-child(1) {
animation-delay: 0.02s;
}
.screen-enter > .col > :nth-child(2) {
animation-delay: 0.07s;
}
.screen-enter > .col > :nth-child(3) {
animation-delay: 0.12s;
}
.screen-enter > .col > :nth-child(4) {
animation-delay: 0.17s;
}
.screen-enter > .col-act > :nth-child(1) {
animation-delay: 0.15s;
}
.screen-enter > .col-act > :nth-child(2) {
animation-delay: 0.2s;
}
.screen-enter > .col-act > :nth-child(3) {
animation-delay: 0.25s;
}
.screen-enter > .col-act > :nth-child(4) {
animation-delay: 0.3s;
}
.screen-enter > *:nth-child(1) {
animation-delay: 0.02s;
}
@@ -307,16 +422,16 @@ body::after {
height: 80px;
margin-bottom: 20px;
animation: floaty 6s ease-in-out infinite;
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
filter: drop-shadow(0 14px 36px var(--wash-1-a90))
drop-shadow(0 6px 16px var(--ink-a22));
}
/* The full TREK wordmark on the welcome screen, in the variant that reads on the scheme. */
.hero-logo {
display: block;
margin-bottom: 22px;
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
filter: drop-shadow(0 14px 36px var(--wash-1-a90))
drop-shadow(0 6px 16px var(--ink-a22));
animation: logoRise 0.7s var(--ease) both;
}
.hero-logo img {
@@ -364,6 +479,7 @@ body::after {
margin: 0;
/* Honour the newline in the headline string (e.g. "Deine Reisen.\nDein Plan."). */
white-space: pre-line;
text-wrap: balance;
}
/* The welcome headline as the brand says it: MuseoModerno, lower case. */
.brand-title {
@@ -380,6 +496,8 @@ body::after {
display: inline-flex;
align-items: center;
gap: 9px;
/* Left to right inside (dir="ltr"), yet on the screen's start edge in Arabic. */
align-self: flex-start;
}
.cloud-wordmark img {
display: block;
@@ -414,6 +532,7 @@ body::after {
font-weight: 700;
letter-spacing: -0.025em;
margin: 0;
text-wrap: balance;
}
.sub {
color: var(--muted);
@@ -421,6 +540,8 @@ body::after {
line-height: 1.55;
font-size: 0.9rem;
overflow-wrap: anywhere;
/* No lone last word ("AI.") on a line of its own. */
text-wrap: balance;
}
/* ── Buttons ─────────────────────────────────────────────────────────────── */
@@ -428,9 +549,10 @@ body::after {
font: inherit;
font-weight: 600;
font-size: 1.02rem;
padding: 16px 18px;
padding: 16px 22px;
border: 1px solid transparent;
border-radius: var(--radius);
/* A pill, as the app's buttons are. */
border-radius: 999px;
background: var(--accent);
color: var(--accent-ink);
cursor: pointer;
@@ -455,13 +577,18 @@ body::after {
.btn:disabled {
opacity: 0.55;
}
/* A two-line choice (welcome) is a card rather than a button: the app's card radius. */
.btn:has(.btn-row) {
border-radius: 22px;
padding-inline: 18px;
}
.btn-ghost {
background: var(--surface);
color: var(--ink);
border-color: var(--edge);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.22),
inset 0 0 0 1px color-mix(in srgb, var(--ink) 4%, transparent);
inset 0 0 0 1px var(--ink-a4);
}
.btn-ghost:active {
background: var(--surface-2);
@@ -471,6 +598,8 @@ body::after {
flex-direction: column;
align-items: flex-start;
gap: 2px;
/* A subtitle that wraps keeps to the start edge under its title. */
text-align: start;
}
.btn-row b {
font-weight: 650;
@@ -481,9 +610,20 @@ body::after {
opacity: 0.7;
}
.btn .chev {
margin-left: auto;
flex: none;
margin-inline-start: auto;
opacity: 0.5;
}
/* A centred label with the chevron at the far edge, the label optically centred. */
.btn-go .btn-label {
flex: 1;
padding-inline-start: 20px;
}
/* Chevrons point along the reading direction. */
:root[dir='rtl'] .chev,
:root[dir='rtl'] .back svg {
transform: scaleX(-1);
}
.linkbtn {
font: inherit;
@@ -507,7 +647,7 @@ body::after {
margin-top: 2px;
padding: 9px 18px;
min-height: 40px;
border-radius: var(--radius);
border-radius: 999px;
border: 1px solid var(--edge);
background: transparent;
color: var(--muted);
@@ -565,15 +705,15 @@ a.linkbtn {
width: 100%;
font: inherit;
font-size: 1rem;
padding: 13px 16px;
border-radius: var(--radius);
padding: 13px 18px;
border-radius: 999px;
border: 1px solid var(--edge);
background: var(--surface);
color: var(--ink);
outline: none;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
inset 0 1px 0 var(--ink-a4);
transition:
border-color 0.2s,
box-shadow 0.25s;
@@ -585,11 +725,11 @@ a.linkbtn {
}
.field input:focus {
border-color: var(--edge-strong);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
box-shadow: 0 0 0 4px var(--ink-a9);
}
.field.bad input {
border-color: var(--danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
box-shadow: 0 0 0 4px var(--danger-a16);
animation: shake 0.3s;
}
@keyframes shake {
@@ -617,7 +757,7 @@ a.linkbtn {
.spinner {
width: 18px;
height: 18px;
border: 2px solid color-mix(in srgb, var(--accent-ink) 35%, transparent);
border: 2px solid var(--accent-ink-a35);
border-top-color: var(--accent-ink);
border-radius: 50%;
animation: spin 0.7s linear infinite;
@@ -672,7 +812,7 @@ a.linkbtn {
width: 72px;
height: 72px;
border-radius: 50%;
background: color-mix(in srgb, var(--ok) 16%, transparent);
background: var(--ok-a16);
display: grid;
place-items: center;
margin-bottom: 8px;
@@ -707,7 +847,8 @@ a.linkbtn {
.topbar {
display: flex;
align-items: center;
margin: 0 0 2px -8px;
margin-block: 0 2px;
margin-inline-start: -8px;
}
.back {
font: inherit;
@@ -741,7 +882,7 @@ a.linkbtn {
padding: 4px;
background: var(--surface);
border: 1px solid var(--edge);
border-radius: 13px;
border-radius: 999px;
align-self: stretch;
}
.segmented button {
@@ -751,7 +892,7 @@ a.linkbtn {
font-size: 0.95rem;
padding: 9px;
border: 0;
border-radius: 9px;
border-radius: 999px;
background: none;
color: var(--muted);
cursor: pointer;
@@ -772,11 +913,11 @@ a.linkbtn {
font-weight: 600;
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--ok) 22%, transparent);
background: var(--ok-a22);
color: var(--ok);
}
.segmented button[aria-pressed='true'] .save {
background: color-mix(in srgb, var(--accent-ink) 16%, transparent);
background: var(--accent-ink-a16);
color: var(--accent-ink);
}
@@ -792,7 +933,7 @@ a.linkbtn {
background: var(--surface);
box-shadow:
0 2px 10px -4px rgba(0, 0, 0, 0.3),
inset 0 0 0 1px color-mix(in srgb, var(--ink) 3%, transparent);
inset 0 0 0 1px var(--ink-a3);
transition:
border-color 0.2s,
box-shadow 0.2s,
@@ -800,7 +941,7 @@ a.linkbtn {
}
.plan.featured {
box-shadow:
0 10px 30px -10px color-mix(in srgb, var(--wash-1) 85%, transparent),
0 10px 30px -10px var(--wash-1-a85),
0 2px 8px -3px rgba(0, 0, 0, 0.35);
}
.plan:active {
@@ -829,7 +970,7 @@ a.linkbtn {
font-weight: 500;
font-size: 0.8rem;
color: var(--muted);
margin-left: 7px;
margin-inline-start: 7px;
}
.plan-feats {
margin: 10px 0 14px;
@@ -902,39 +1043,45 @@ a.linkbtn {
.slug-field {
display: flex;
align-items: center;
padding-right: 16px;
border-radius: var(--radius);
padding-inline-end: 18px;
border-radius: 999px;
border: 1px solid var(--edge);
background: var(--surface);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
inset 0 1px 0 var(--ink-a4);
transition:
border-color 0.2s,
box-shadow 0.25s;
}
.slug-field .slug-input {
.slug-field .slug-input,
.slug-field .slug-input:focus {
flex: 1;
min-width: 0;
text-align: left;
text-align: start;
border: 0;
background: transparent;
/* The ring is the whole field's (focus-within below), not a second one inside it. */
box-shadow: none;
}
/* An email is typed left to right, but its Arabic placeholder reads from the right. */
:root[dir='rtl'] .field input.ltr-value:placeholder-shown {
direction: rtl;
}
.slug-field:focus-within {
border-color: var(--edge-strong);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
box-shadow: 0 0 0 4px var(--ink-a9);
}
.slug-field.bad {
border-color: var(--danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
box-shadow: 0 0 0 4px var(--danger-a16);
animation: shake 0.3s;
}
.slug-suffix {
flex: none;
color: var(--muted);
font-size: 0.95rem;
padding-left: 4px;
padding-inline-start: 4px;
user-select: none;
}
.hint {
@@ -976,3 +1123,139 @@ a.linkbtn {
border-color: var(--edge-strong);
border-top-color: var(--accent);
}
/* ── A large screen ──────────────────────────────────────────────────────────
On its side (an iPad, a foldable unfolded) the photo becomes a panel of its
own on the leading half, full height, and the column of choices stands
centred in the other half, the way a split sign-in page uses the width.
Upright, the photo band grows with the screen instead of stopping at a
phone's 440px. Logical sides, so Arabic mirrors it. */
@media (min-width: 840px) and (min-height: 600px) and (orientation: landscape) {
body::after,
:root[dir='rtl'] body::after {
inset: 16px auto 16px 16px;
inset-block: 16px;
inset-inline: 16px auto;
width: calc(50% - 24px);
height: auto;
border-radius: 28px;
background:
linear-gradient(180deg, transparent 50%, rgba(0, 0, 0, 0.32) 100%),
url('./img/kyoto.webp') 30% 45% / cover no-repeat;
-webkit-mask-image: none;
mask-image: none;
box-shadow: 0 30px 70px -30px rgba(0, 0, 0, 0.55);
}
body::before {
inset: 0;
inset-inline-start: 50%;
height: auto;
}
.shell {
max-width: none;
width: 50%;
margin-inline: 50% 0;
padding-inline: max(32px, calc(25vw - 220px));
}
}
/* With the whole half of a 13" iPad on its side, the plans stand two by two
rather than in a column to scroll through. */
@media (min-width: 1200px) and (min-height: 600px) and (orientation: landscape) {
.shell:has(.plans) {
padding-inline: max(32px, calc(25vw - 320px));
}
.plans {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* An iPad upright, a foldable unfolded, half of an iPad in Split View: the photo
spans the whole width as a band across the top and fades out only downwards,
and the choices stand under it in a wider column than a phone's, centred in
the room the band leaves, so the page reads as one composition rather than a
phone screen at the top of the glass. The plans take two columns. */
@media (min-width: 600px) and (min-height: 800px) and (orientation: portrait) {
body::before,
body::after {
height: 56vh;
}
body::after,
:root[dir='rtl'] body::after {
background-position:
0 0,
center 30%;
-webkit-mask-image: none;
mask-image: none;
}
.shell {
max-width: 560px;
padding-top: calc(env(safe-area-inset-top) + 28vh);
padding-bottom: calc(env(safe-area-inset-bottom) + 6vh);
}
.shell:has(.plans) {
max-width: 760px;
padding-top: calc(env(safe-area-inset-top) + 16vh);
}
.plans {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* ── A short screen on its side ──────────────────────────────────────────────
A phone, or a foldable folded, turned landscape: one column of choices is
taller than the glass, and the second choice or the main button ended up
below the edge. The two halves of a screen stand side by side instead, what
it is on the leading side and what to do on the other, top-aligned under the
status bar. The leading half stays put while a long list beside it (the
plans) scrolls, so Back is always in reach. */
@media (orientation: landscape) and (max-height: 500px) {
.shell {
max-width: 880px;
padding-top: calc(env(safe-area-inset-top) + 16px);
padding-bottom: calc(env(safe-area-inset-bottom) + 16px);
padding-inline: max(28px, env(safe-area-inset-left)) max(28px, env(safe-area-inset-right));
}
.screen:has(> .col) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
column-gap: 40px;
align-items: center;
}
/* The plans run longer than the window: both halves start at the top. */
.screen:has(.plans) {
align-items: start;
}
.col {
display: flex;
flex-direction: column;
gap: 12px;
}
.col-about {
position: sticky;
top: calc(env(safe-area-inset-top) + 16px);
}
.hero-logo {
margin-bottom: 4px;
}
.hero-logo img {
height: 46px;
}
.title {
font-size: 1.75rem;
}
.btn {
min-height: 50px;
padding-block: 12px;
}
.features {
margin: 0;
}
.feature {
padding: 7px 0;
}
.foot-links {
margin-top: 0;
padding-top: 10px;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "5.1.0",
"version": "5.1.1",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
+2 -2
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 375,
"@typescript-eslint/no-misused-promises": 847,
"@typescript-eslint/no-misused-promises": 845,
"no-console": 88,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 144,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1912,
"@typescript-eslint/no-non-null-assertion": 1911,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+30 -2
View File
@@ -282,11 +282,17 @@
"mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx", "src/mobile/screens/trip/tabs/MCostsTab.tsx"]
},
{
"feature": "Costs/budget ledger: useCostsLedger, useSettlementForm, useExpenseLinks",
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts", "src/components/Budget/useExpenseLinks.ts"],
"feature": "Costs/budget ledger: useCostsLedger, useExpenseLinks",
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useExpenseLinks.ts"],
"desktop": ["src/components/Budget/CostsPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MCostsTab.tsx"]
},
{
"feature": "Costs/budget settle-up payment: useSettlementForm",
"hooks": ["src/components/Budget/useSettlementForm.ts"],
"desktop": ["src/components/Budget/CostsPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MCostsPayment.tsx"]
},
{
"feature": "Create/edit trip form",
"hooks": ["src/components/Trips/tripFormModel.ts", "src/components/Trips/useTripCoverPicker.ts"],
@@ -663,6 +669,12 @@
"desktop": ["src/components/Roadtrip/RoadtripStopPopup.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx"]
},
{
"feature": "Road-trip Google Maps route import",
"hooks": ["src/components/Roadtrip/useGoogleRouteImport.ts"],
"desktop": ["src/components/Roadtrip/GoogleRouteImport.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtRouteImportSheet.tsx"]
},
{
"feature": "Road-trip stay length draft",
"hooks": ["src/components/Roadtrip/useStayDraft.ts"],
@@ -822,6 +834,12 @@
"desktop": ["src/components/shared/Toast.tsx"],
"mobile": ["src/mobile/components/MToastHost.tsx"]
},
{
"feature": "Tour editor workspace",
"hooks": ["src/pages/tripPlanner/useTripPlannerPage.ts"],
"desktop": ["src/pages/TripPlannerPage.tsx"],
"mobile": ["src/mobile/screens/trip/tours/MTourWorkspace.tsx"]
},
{
"feature": "Tours list filter",
"hooks": ["src/components/Tours/tourPresentation.ts"],
@@ -1002,11 +1020,13 @@
"src/mobile/screens/collections/MCollInvites.tsx": "Markup only; the invites and accept and decline come through props from useCollections via MCollections.",
"src/mobile/screens/collections/MCollLabelsSheet.tsx": "Phone label sheet; create, update, delete and assign come through props from useCollections.",
"src/mobile/screens/collections/MCollLinksEditor.tsx": "Markup only; link rows and the change handler come through props from the collection sheets.",
"src/mobile/screens/collections/MCollListNav.tsx": "Markup only; the collections' lists as the header dropdown or as the column beside the places on a wide window, with the lists and the selection handed in through props from useCollections.",
"src/mobile/screens/collections/MCollListPickerSheet.tsx": "Phone list picker sheet; the move or copy action comes through onPick from useCollections.",
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
"src/mobile/screens/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.",
"src/mobile/screens/journey/MJourneyFeed.tsx": "Markup only; the journey's entries as a feed beside the map on a wide window, with the entries, the active one and the tap handed in through props from useJourneyDetail and useJourneyCarouselSync.",
"src/mobile/screens/settings/MAppLockCard.tsx": "Phone only: the app lock exists only in the native app, so there is no desktop setting to share with.",
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
@@ -1018,6 +1038,9 @@
"src/mobile/screens/settings/MSettingsUi.tsx": "Phone only settings UI primitives (card, row, input, button, segments, hint).",
"src/mobile/screens/settings/MTileCacheUsage.tsx": "Phone only: the native tile cache size and clear action, which the browser build has no counterpart for.",
"src/mobile/screens/settings/MWebPushCard.tsx": "Already shares hooks/useWebPush with components/Settings/WebPushCard.tsx.",
"src/mobile/screens/trip/tabHeadSlot.ts": "Phone shell only; the place in the shell's top row the tours tab and the journal fill with their own switches. The desktop heads its panels itself.",
"src/mobile/screens/trip/MTripRail.tsx": "Phone shell only; the trip's sections on the leading edge of a wide window in place of the dock, with the tabs and handlers MTripShell hands in.",
"src/mobile/screens/trip/MTripShellParts.tsx": "Markup only; small pieces of MTripShell's top controls (segment pill, compact toggle, document sync button), with the state and handlers MTripShell hands in.",
"src/mobile/screens/trip/dockTabs.ts": "Phone only helper that picks which trip tabs get a seat in the mobile dock.",
"src/mobile/screens/trip/lib/dayNotes.ts": "Phone only helper that splits a leading time off a day note for the plan timeline.",
"src/mobile/screens/trip/map/MMapArea.tsx": "Phone map stage framing; already shares usePoiExplore and useMergedMapPois with the desktop trip planner.",
@@ -1035,8 +1058,10 @@
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": "Markup only; its corridor state comes through props from useMRtCorridor.",
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": "Phone corridor search sheet over useMRtCorridor and the planner's shared corridor state.",
"src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": "Read only figures sheet that already shares useHotelBookends, dayWindow and parseAvoid with RoadtripLimitsCard.",
"src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx": "Large screen form inside MRtInfoSheet over the planner's saveRoadtripLimit, the desktop dialog's own save, and the shared dayWindow, parseAvoid and rangeFromSpec.",
"src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.",
"src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.",
"src/mobile/screens/trip/roadtrip/MRtTrackSheet.tsx": "Large screen sheet over the planner's followTrack, the same hook RoadtripTrackModal runs on; the phone has no entry to it.",
"src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.",
"src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": "Phone stage screen adapter over the shared roadtripRowModel plus a live clock tick.",
"src/mobile/screens/trip/roadtrip/useMRtAlternatives.ts": "Tap to preview, confirm to write layer over the planner's shared route alternatives.",
@@ -1053,10 +1078,13 @@
"src/mobile/screens/trip/sheets/PlTimeFields.tsx": "Phone time fields that already share timeCollisions with the desktop PlaceFormModal.",
"src/mobile/screens/trip/sheets/tourSheetOpen.ts": "Phone only context for MTourDetailShell: whether the tour sheet is open and the name it carries.",
"src/mobile/screens/trip/tabs/MCollabTab.tsx": "Phone collab sub tab router that reuses the desktop CollabLinks component.",
"src/mobile/screens/trip/tabs/MFilesBoard.tsx": "Markup only; the trip's files as tiles and the picked file's preview on a wide window, with the files and their actions handed in through props from MFilesTab.",
"src/mobile/screens/trip/tabs/MListsTab.tsx": "Phone router between the packing and to-do bodies, with no logic of its own.",
"src/mobile/screens/trip/tabs/MReceiptScanButton.tsx": "Already shares useReceiptScan and ReceiptScanModal with the desktop CostsPanel.",
"src/mobile/screens/trip/tabs/MReservationCardParts.tsx": "Markup only: the title, actions, date, files and delete sheet parts both phone booking tabs draw.",
"src/mobile/screens/trip/tabs/MTodoSidebar.tsx": "Markup only; the to-do filters as a pane beside the tasks on a wide window, with the filter state and the counts handed in through props from useTodoView.",
"src/mobile/screens/trip/tabs/MTripTabPanel.tsx": "Phone tab router that mounts the per tab screens and plugin frames, with no logic of its own.",
"src/mobile/screens/trip/tabs/collabPanes.ts": "Phone only; decides whether the collab tab shows the chat beside a board on a wide window, a layout question of the touch shell that the desktop planner does not have.",
"src/mobile/screens/trip/tabs/listsModel.ts": "Priority colour and label constants only the phone to-do list and task sheet use.",
"src/mobile/screens/trip/tabs/tabChrome.tsx": "Phone tab chrome primitives (scroller, section headers, fields, traveler chips, plugin slots).",
"src/mobile/screens/trip/tabs/tabModel.ts": "Phone tab prop type and status colour tokens only the phone tabs use.",
+1 -1
View File
@@ -2,7 +2,7 @@
"admin.ts": 112,
"atlas.ts": 9,
"backup.ts": 10,
"budget.ts": 36,
"budget.ts": 35,
"categories.ts": 1,
"collab.ts": 9,
"collection.ts": 35,
+3 -7
View File
@@ -24,8 +24,8 @@
"components/Map/DawarichTrailPill.tsx": 1,
"components/Map/LocationButton.tsx": 1,
"components/Map/MapTogglePill.tsx": 1,
"components/Map/MapView.tsx": 6,
"components/Map/MapViewGL.tsx": 8,
"components/Map/MapView.tsx": 5,
"components/Map/MapViewGL.tsx": 7,
"components/Map/NightPauseTooltip.tsx": 1,
"components/Map/PlaceHoverCard.tsx": 1,
"components/Map/PoiCategoryPill.tsx": 1,
@@ -34,6 +34,7 @@
"components/Map/locationMarkerMapbox.ts": 2,
"components/Map/nightPauseMarker.ts": 4,
"components/Map/ratingBadge.ts": 1,
"components/Map/switcherRow.ts": 1,
"components/PDF/TripPDF.tsx": 7,
"components/PDF/tripMapImage.ts": 1,
"components/Packing/PackingListPanelBagCard.tsx": 1,
@@ -79,15 +80,10 @@
"hooks/useAnchoredPosition.ts": 3,
"hooks/useElementRect.ts": 3,
"mobile/components/MSegmented.tsx": 1,
"mobile/components/MSheet.tsx": 1,
"mobile/screens/dashboard/MDashboard.tsx": 1,
"mobile/screens/journey/MJourneyDetail.tsx": 3,
"mobile/screens/journey/MJourneyEntrySheet.tsx": 1,
"mobile/screens/trip/MTripShell.tsx": 3,
"mobile/screens/trip/map/MMapArea.tsx": 3,
"mobile/screens/trip/tabs/MCollabPolls.tsx": 1,
"mobile/screens/trip/tabs/MFileLightbox.tsx": 2,
"mobile/screens/vacay/MVacay.tsx": 1,
"mobile/screens/vacay/MVacayMonth.tsx": 1,
"pages/AtlasPage.tsx": 2,
"pages/JourneyDetailPage.tsx": 1,
+3 -3
View File
@@ -5,9 +5,9 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1214,
"src/components/Map/MapView.tsx": 1200,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2127,
"src/components/Map/MapViewGL.tsx": 2126,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2929,
@@ -20,7 +20,7 @@
"src/pages/JourneyDetailPage.test.tsx": 4028,
"src/pages/LoginPage.tsx": 1347,
"src/pages/TripPlannerPage.tsx": 1128,
"src/pages/atlas/useAtlas.ts": 1210,
"src/pages/atlas/useAtlas.ts": 1207,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 1918
+10 -9
View File
@@ -40,7 +40,7 @@
"components/Journey/JourneyDetailPageProviderPicker.tsx": 163,
"components/Journey/JourneyDetailPageScrollTrigger.tsx": 4,
"components/Journey/JourneyDetailPageVerdictSection.tsx": 51,
"components/Journey/JourneyEntryCover.tsx": 15,
"components/Journey/JourneyEntryCover.tsx": 13,
"components/Journey/JourneyMap.tsx": 3,
"components/Journey/JourneyMapGL.tsx": 5,
"components/Journey/MobileEntryView.tsx": 94,
@@ -60,8 +60,8 @@
"components/Map/LocationButton.tsx": 1,
"components/Map/MapPluginMarkers.tsx": 2,
"components/Map/MapTogglePill.tsx": 1,
"components/Map/MapView.tsx": 8,
"components/Map/MapViewGL.tsx": 11,
"components/Map/MapView.tsx": 7,
"components/Map/MapViewGL.tsx": 10,
"components/Map/NightPauseTooltip.tsx": 1,
"components/Map/PlaceHoverCard.tsx": 1,
"components/Map/ReservationOverlay.tsx": 5,
@@ -71,6 +71,7 @@
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
"components/Map/switcherRow.ts": 1,
"components/PDF/TripPDF.tsx": 51,
"components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
@@ -146,7 +147,7 @@
"mobile/components/MStorageTestResult.tsx": 3,
"mobile/components/MToastHost.tsx": 2,
"mobile/components/MUserPickerList.tsx": 1,
"mobile/screens/admin/MAdmin.tsx": 9,
"mobile/screens/admin/MAdmin.tsx": 8,
"mobile/screens/admin/MAdminAddonManager.tsx": 9,
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
@@ -181,9 +182,9 @@
"mobile/screens/collections/MCollShareSheet.tsx": 14,
"mobile/screens/collections/MCollSheetKit.tsx": 5,
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
"mobile/screens/collections/MCollections.tsx": 18,
"mobile/screens/collections/MCollections.tsx": 14,
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
"mobile/screens/dashboard/MDashboard.tsx": 31,
"mobile/screens/dashboard/MDashboard.tsx": 30,
"mobile/screens/dashboard/MNewTripSheet.tsx": 12,
"mobile/screens/dashboard/MUserMenu.tsx": 7,
"mobile/screens/journey/MJourney.tsx": 10,
@@ -202,7 +203,7 @@
"mobile/screens/settings/MPhotoProvidersSection.tsx": 2,
"mobile/screens/settings/MScopeGroupPicker.tsx": 5,
"mobile/screens/settings/MSetPickerSheet.tsx": 1,
"mobile/screens/settings/MSettings.tsx": 2,
"mobile/screens/settings/MSettings.tsx": 1,
"mobile/screens/settings/MSettingsAbout.tsx": 5,
"mobile/screens/settings/MSettingsAccount.tsx": 20,
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
@@ -214,7 +215,7 @@
"mobile/screens/settings/MSettingsUi.tsx": 11,
"mobile/screens/settings/MWebPushCard.tsx": 1,
"mobile/screens/trip/MTripLoadingSplash.tsx": 2,
"mobile/screens/trip/MTripShell.tsx": 9,
"mobile/screens/trip/MTripShell.tsx": 7,
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 2,
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
@@ -262,7 +263,7 @@
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
"mobile/screens/trip/tabs/MCollabTab.tsx": 1,
"mobile/screens/trip/tabs/MCostsTab.tsx": 71,
"mobile/screens/trip/tabs/MCostsTab.tsx": 70,
"mobile/screens/trip/tabs/MDocSyncSheet.tsx": 43,
"mobile/screens/trip/tabs/MFileLightbox.tsx": 12,
"mobile/screens/trip/tabs/MFileLinkSheet.tsx": 2,
+2
View File
@@ -36,6 +36,7 @@ import NewVersionNotice from './components/Layout/NewVersionNotice'
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
import AppLockGate from './components/Layout/AppLockGate'
import NativeImportSheet from './mobile/components/NativeImportSheet'
import MPushPrompt from './mobile/components/MPushPrompt'
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel'
@@ -453,6 +454,7 @@ export default function App() {
<ErrorBoundary boundaryId="widget:native-update" fallback={null}><NativeUpdateNotice /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:app-lock" fallback={null}><AppLockGate /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:native-import" fallback={null}><NativeImportSheet /></ErrorBoundary>
{!hideAuthedWidgets && isPhone && <ErrorBoundary boundaryId="widget:push-prompt" fallback={null}><MPushPrompt /></ErrorBoundary>}
<ErrorBoundary boundaryId="widget:native-widgets" fallback={null}><NativeWidgetsSync /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
@@ -1,8 +1,8 @@
// FE-COLLAB-CHATDATA-001 to FE-COLLAB-CHATDATA-016: the trip chat logic behind the desktop
// FE-COLLAB-CHATDATA-001 to FE-COLLAB-CHATDATA-018: the trip chat logic behind the desktop
// Collab panel (quiet older-page errors, scroll on remote delete, upload progress) and the
// phone's chat tab (toasted older-page errors, sending gated on the edit permission).
import { act, renderHook, waitFor } from '@testing-library/react';
import type { ChangeEvent } from 'react';
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
import { createElement, type ChangeEvent } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { collabApi } from '../../api/client';
@@ -285,3 +285,80 @@ describe('useCollabChatData', () => {
expect(toast.error).toHaveBeenCalledWith('common.error');
});
});
describe('useCollabChatData on a box that changes size', () => {
// The observer reports when the test says the list's box has changed.
let report: () => void = () => {};
let observed: Element[] = [];
class Observer {
constructor(callback: ResizeObserverCallback) {
report = () => callback([], this as unknown as ResizeObserver);
}
observe(el: Element) {
observed.push(el);
}
unobserve() {}
disconnect() {}
}
/**
* The list as both views draw it: only once the messages are in, so at mount there
* is no element yet. Its sizes are the test's to set, since jsdom lays nothing out.
*/
function ChatList({ scrollHeight }: { scrollHeight: () => number }) {
const chat = useCollabChatData({
tripId: 1,
t,
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
});
const setList = (el: HTMLDivElement | null) => {
if (el && !Object.getOwnPropertyDescriptor(el, 'scrollHeight')) {
let scrollTop = 0;
Object.defineProperty(el, 'scrollHeight', { configurable: true, get: scrollHeight });
Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => 400 });
Object.defineProperty(el, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (v: number) => {
scrollTop = v;
},
});
el.scrollTo = () => {};
}
(chat.scrollRef as { current: HTMLDivElement | null }).current = el;
};
if (chat.loading || chat.messages.length === 0) return createElement('p', null, 'loading');
return createElement('div', { 'data-testid': 'list', ref: setList, onScroll: chat.checkAtBottom });
}
async function mountList(height: number) {
let scrollHeight = height;
render(createElement(ChatList, { scrollHeight: () => scrollHeight }));
const list = await screen.findByTestId('list');
return { list, grow: (to: number) => (scrollHeight = to) };
}
beforeEach(() => {
observed = [];
});
afterEach(() => vi.unstubAllGlobals());
it('FE-COLLAB-CHATDATA-017: a list at its newest message stays there when a rotation or a fold resizes it', async () => {
vi.stubGlobal('ResizeObserver', Observer);
const { list, grow } = await mountList(400);
expect(observed).toContain(list);
grow(900);
report();
expect(list.scrollTop).toBe(900);
});
it('FE-COLLAB-CHATDATA-018: a list read further up keeps its place', async () => {
vi.stubGlobal('ResizeObserver', Observer);
const { list, grow } = await mountList(2000);
list.scrollTop = 200;
fireEvent.scroll(list);
grow(2600);
report();
expect(list.scrollTop).toBe(200);
});
});
@@ -77,6 +77,22 @@ export function useCollabChatData({
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
}, []);
// A list that sat at its newest message stays there when its box changes size (a
// rotation, a fold, a pane opening beside it): the messages wrap anew and the list
// grows, which left the newest ones below the composer. Both views draw the list
// only once there are messages to show, so the observer attaches when it appears,
// not at mount, when there is nothing yet to observe.
const listShown = !loading && messages.length > 0;
useEffect(() => {
const el = scrollRef.current;
if (!listShown || !el || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
if (isAtBottom.current) el.scrollTop = el.scrollHeight;
});
observer.observe(el);
return () => observer.disconnect();
}, [listShown]);
useEffect(() => {
let cancelled = false;
setLoading(true);
@@ -77,8 +77,9 @@ describe('JournalBody', () => {
const { container } = render(<JournalBody text={'# Big\n\n### Small'} />);
expect(container.querySelector('h1')).not.toBeInTheDocument();
expect(container.querySelector('h3')).not.toBeInTheDocument();
expect(screen.getByText('Big').tagName).toBe('P');
expect(screen.getByText('Small').tagName).toBe('P');
// Each paragraph's text sits in an isolate (<bdi>) so it keeps its own direction.
expect(screen.getByText('Big').parentElement?.tagName).toBe('P');
expect(screen.getByText('Small').parentElement?.tagName).toBe('P');
});
it('FE-COMP-JOURNALBODY-012: keeps a setext underline as text instead of a heading', () => {
@@ -15,13 +15,14 @@ export default function JournalBody({ text, dark }: Props) {
lineHeight: 1.6,
color: 'inherit',
}}>
{/* Each paragraph in its own direction (bdi): a Latin story in the Arabic UI keeps its full stop at its end. */}
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
p: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
blockquote: ({ children }) => (
<blockquote style={{
borderInlineStart: `3px solid var(--journal-accent)`,
@@ -1,4 +1,4 @@
// FE-JRN-COVER-001 to FE-JRN-COVER-010
// FE-JRN-COVER-001 to FE-JRN-COVER-011
import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
@@ -133,4 +133,25 @@ describe('JourneyEntryCover', () => {
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
expect(container.querySelector('svg.lucide-play')).toBeNull()
})
it('FE-JRN-COVER-011: only the phone draws a card without a photo in the deep day colour, opaque even as a suggestion', () => {
const entry = buildEntry({ photos: [], type: 'skeleton' })
const phone = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} tone="mobile" />)
const phoneCard = phone.getByRole('button')
// d9, the deep wash (jsdom spells the colour out as rgba)
expect(phoneCard.style.backgroundImage).toMatch(/rgba\(99, 102, 241, 0\.85/)
expect(phoneCard.className).not.toContain('opacity-90')
expect(phone.container.querySelector('svg.lucide-map-pin')?.getAttribute('class')).toContain('-mt-6')
phone.unmount()
// The desktop and shared timelines keep their faint wash and the day-coloured pin.
const desk = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
const deskCard = desk.getByRole('button')
// 2e, the faint one
expect(deskCard.style.backgroundImage).toMatch(/rgba\(99, 102, 241, 0\.18/)
expect(deskCard.className).toContain('opacity-90')
const pin = desk.container.querySelector('svg.lucide-map-pin') as SVGElement
expect(pin.getAttribute('class')).not.toContain('-mt-6')
expect(pin.style.color).not.toBe('')
})
})

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