Compare commits

...

56 Commits

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

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

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

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

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

TestFlight builds now carry the release tag's message as What to Test.
2026-11-06 20:12:26 +01:00
Maurice 89f7bd2e2f feat(native): category emoji of older installs as widget symbols 2026-11-06 19:13:59 +01:00
Maurice 2fe487154f feat(native): the widget gallery in the app's languages, previews with a cover
- Names and descriptions of the four widgets in TREKWidgets/<lang>.lproj for
  the 28 languages the app speaks, linted with the other property lists
- The preview renderer draws a cover photo state too, and the lock-screen
  accessories as the system does, without a box of their own
2026-11-06 19:08:51 +01:00
Maurice 71d014af95 feat(native): fuller large widgets, the live activity in the widgets' look
- The large trip and today widgets put the stops right under the day and the
  next booking at their foot instead of an empty middle; the large to-dos
  widget names its two rings with their counts
- The trip and day for a travel day as a Live Activity: a progress ring, the
  next stop large, the stops as segments, the Dynamic Island in the same green
- The place shows with a location arrow, and rows link through RowLink, which
  the preview renderer draws without the Link it cannot render
2026-11-06 19:06:18 +01:00
Maurice b1037dec62 feat(native): home and lock screen widgets in the TREK look
Four widgets instead of one plain text block, drawn in the phone tokens
(warm paper by day, near black by night) with contour lines and a route arc
as the travel motif:

- Trip (small, medium, large, lock screen): the cover photo, a countdown ring
  and a plane on its arc before the trip; the day of the trip as segments,
  the weather and the next stops on frosted glass while it runs
- Today (medium, large): the day as a timeline in its category colours, with
  a now line, the day's progress and the weather
- Bookings (small, medium, large, lock screen): flights, trains, hotels and
  tables ahead as coloured tiles, the next one featured, status dots
- To-dos (small, medium, large, lock screen): to-dos and packing as nested
  rings, the most pressing open to-dos with priority and due dates

The app now sends the destination, a small cover JPEG, the weather where the
trip is, the bookings ahead, the open to-dos and the packing count; every
new field is optional on both sides, so older binaries and servers keep
working. Timelines step every quarter hour so the now line moves without
the app. client/ios/WidgetPreviews renders every widget in every size, light
and dark, on the CI's Mac, uploaded as the widget-previews artifact.
2026-11-06 19:01:08 +01:00
Maurice 3466556849 test(e2e): swipe the day pager past its middle, as a native pager needs
The days are a scroll-snap pager since the native UX round: a page turns once
the finger has carried it past half its width. The spec swiped 180 px on a
412 px phone and read the chip right after; it now swipes 40 % either side of
the middle and polls the chip until the snap and the selection have landed.
2026-11-06 18:53:30 +01:00
Maurice b7951baf23 fix(ci): green the dev gates after the native and popup rounds
- RTL: the measured pills and centred controls use logical forms or a marked line
- i18n: the two native widget numbers are positions, not counts (NOT_PLURAL)
- Types: styleOverride on the lazy GL map, the location and shared-file payloads,
  the capabilities fallback, the transport sheet's stay metadata
- ESLint: latest-value refs move to layout effects, usePresence keeps the last
  open state in state, the booking import opens clean with a shared document
  kept in the list (the reset effect used to clear it again)
- Layers: the phone import sheet lives under mobile/ and reads through tripRepo;
  the offline host check moves to utils/tileUrl
- Sizes: plugin layer geometry, hardNavigate and the exit animations get
  modules of their own
- Tests follow the status radios, the stay chip overview, the dock + button
  for the first list and the field token; store registry lists the new stores
2026-11-06 18:24:25 +01:00
Maurice f81fe8cb17 fix(native): a signed-out first start no longer ends on Server not reachable
Connecting the app to a server as someone not signed in there showed
"Server not reachable" with the server up. The UI's first API call answered
401, the client redirected with a full page load while the first page was
still loading, WebKit cancelled that load (-999), and Capacitor answers every
failed navigation, a replaced one included, with its error page: the shell's
unreachable screen.

The client now waits for the first page to finish loading before such a
redirect inside the apps, which helps the binaries already out; and the iOS
shell puts a small navigation delegate in front of Capacitor's that lets a
cancelled or interrupted navigation pass without the error page.
2026-11-06 16:54:02 +01:00
Maurice 7e8e342e29 fix(mobile): stay editor and save to list match the common sheet header
Their icon tiles match TileHeader's (40 px, ink icon) and the stay editor's
search field is round like the others.
2026-11-06 16:50:46 +01:00
Maurice 445fb173fb feat(mobile): one sheet material, Vacay people and legend headers
Docked sheets defaulted to the lighter bar glass, through which their header
buttons hardly stood out; every sheet now uses the same glass. The Vacay
people and legend sheets get the icon tile header.
2026-11-06 16:49:54 +01:00
Maurice b317877c41 feat(mobile): remaining sheets get the common header
Atlas bucket list, open route in, open in TREK, the place actions and the
MCP/OAuth client sheets now show the icon tile header (or, where a sheet
closes only by its buttons, the same title row without the close).
2026-11-06 16:48:25 +01:00
Maurice 27c19b5ff5 feat(mobile): admin, settings picker and place sheets in the common style, all sheets on glass
Admin sheets: their frame draws the icon tile header (an icon per sheet, the
plugin sheets' icons moved out of the title into the tile), the soft scroll
edge and 42 px footer buttons; admin fields use the sheets' eyebrow labels
and round inputs. The settings picker, the bulk category sheet and the save
to list sheet get the common header (TileHeader may now leave out the icon
for a plain picker). Every phone sheet now uses the glass material, as the
trip settings do; it stays 94 % opaque, so forms over the map remain
legible.
2026-11-06 16:45:09 +01:00
Maurice ffd848e5dc feat(mobile): confirm dialogs and Vacay sheets in the common sheet style
The shared confirm sheet (44 places) gets the icon tile with the title, a
warning icon for destructive questions, and the sheets' 42 px buttons with
the danger colour on a destructive confirm. The dashboard's and the
collections' own confirm dialogs now use it.

Vacay invite, shared calendars and settings get the icon tile header; the
settings' rows separated by hairlines become cards on the inner surface, as
the trip settings' sections.
2026-11-06 16:40:06 +01:00
Maurice 45361c276c feat(mobile): journey create and entry sheets in the common sheet style
Both get the icon tile header (the entry keeps its move buttons between
title and close), glass, the soft scroll edge and the sheets' 42 px footer
buttons; the journey name is a round field, delete in the entry's footer a
round icon button. The collection share sheet's invite button is round like
the trip settings' one.
2026-11-06 16:34:44 +01:00
Maurice 1b24e3dcef feat(mobile): collections sheets in the common sheet style
The collections sheets had a header of their own with a hairline, white
square fields and short buttons. Their kit now draws the icon tile header
every phone sheet has (an icon per sheet), round 42 px fields on the field
surface and 42 px footer buttons; the sheets sit on glass with the soft
scroll edge. New list and add place lose their last own field styles: the
labels are the sheets' eyebrows, the search fields, the category picker and
the status choices are round fields of the same height.
2026-11-06 16:32:37 +01:00
Maurice 69d88ffefd fix(mobile): booking status toggle as tall as the fields, with status dots
The pending / confirmed toggle was shorter than the booking code field next
to it. It is now a 42 px framed field with the status colour as a dot in
each option, marked up as a radio group, and sits under the code at full
width, where both words fit.
2026-11-06 16:29:33 +01:00
Maurice 92fc8ef28f feat(mobile): new trip sheet and shared pickers in the sheets' style
New trip: the icon tile header, sections with labels above round fields
instead of floating labels in boxes, the round date fields, the shared
footer buttons, and no box within a box for the reminder and the cover
search.

The shared select, date, time and airport pickers style themselves inline
for the desktop and looked out of place in phone sheets, white and
squarer than the fields beside them. Inside the phone UI they now take the
sheets' 42 px round field on the field surface; the desktop is unchanged.
2026-11-06 16:28:06 +01:00
Maurice 2aa25e3fe7 fix(mobile): a tour opens in a sheet instead of the desktop dialog
A tour tapped in the plan opened the desktop detail dialog in a hand-built
overlay: no slide, no drag to close, the desktop's frame on a phone. The
dialog now renders into the frame it is given; on the phone that is a sheet
like every other detail (MTourDetailShell), with the tour's name as its
title, its whole content and every action, the back gesture and an exit
animation.
2026-11-06 16:22:23 +01:00
Maurice f6bcdb8e6f fix(mobile): a stay in the day plan opens its booking's overview, not the editor
Tapping the accommodation in the plan went straight to the stay's editor,
where travellers only wanted to look it up. It now opens the overview of the
booking behind it, as on the desktop, with check-in, check-out and nights as
tiles (one shared stayStats for both), and only a stay without a booking or
a place still leads to the editor.

Edit in that overview opened the transport editor for every booking, a stay
or a restaurant table included; non-transport bookings now open the booking
editor, as in the Bookings tab.

A stay with two linked bookings showed its chips twice in the day header,
because the list repeats a stay per booking; the header counts it once.
2026-11-06 16:17:32 +01:00
Maurice 867a52bc0d feat(mobile): form sheets use the trip settings' fields and footer
The shared form fields are round like the trip settings' fields (multi-line
ones keep a softer radius), and the shared footer's cancel, submit and
delete are the sheets' 42 px buttons. Every trip form built on these parts
(place, transport, booking, cost, note, packing, tasks and more) follows.
2026-11-06 16:11:08 +01:00
Maurice ec87f280c3 feat(mobile): journey settings in the trip settings' style
The journey settings sheet looked like a different app next to the trip
settings. It now uses the same parts: the glass card with the icon tile
header and the journey's name under the title, sections as inner cards with
a title, count badge and hint, 42 px round fields and buttons, the shared
link field, and switch rows for tracks, entry fields and the public link.
Those parts (SheetSection and the SHEET_* button and field classes) moved
into MTripSheetUi for every sheet to use.

Fields and buttons sitting directly in a column no longer shrink below
their height; name, subtitle and the archive button were squeezed flat.
2026-11-06 16:07:57 +01:00
Maurice 6e362de3da fix(mobile): collections start their first list from the dock, settings sheets aligned
Collections: the empty screen's own New list button is gone; the dock's +
starts the first list while there is none and adds places once there is one.

Trip settings: counts sit in a badge next to the section title instead of
reading as part of it (ACCESS 1), and every round button of the sheet,
invite and add guest as well as the member and guest actions, is one 42 px
circle; the icon-only buttons were wider than tall.

Journey settings: the public link is the same field as the trip's share and
invite links, through one shared MLinkField; the shared map's extras hang
under the map row with a guide line and smaller labels; removing the link is
a full-size button.
2026-11-06 16:01:28 +01:00
Maurice 654900bc50 fix(mobile): page transitions hung four seconds on every navigation to a lazy page
The transition waited for the new page to replace its route fallback and
polled for that with requestAnimationFrame. While a view transition waits
for its update the browser renders no frames, so the callback never fired
and every navigation to a page whose chunk was still loading sat frozen
until the browser gave up after four seconds. The wait now polls with a
timer: from Vacay to the journal takes 0.45 s instead of 4.1 s with an
empty cache.
2026-11-06 15:58:53 +01:00
Maurice 75d0a6181b feat(ui): dialogs, confirms and context menus leave as gently as they come
They faded and scaled in but vanished at once. A small presence hook keeps
them for 170 ms after closing while the mirror of their entrance plays:
scaling down and fading on a desktop, sliding down on a phone where the
dialog is docked as a drawer, with the backdrop fading out. During the exit
they show their last content, since an owner often clears the subject in the
same step, and take no clicks. Reduced motion gets a short fade. Unit tests
keep the old instant close.
2026-11-06 15:51:28 +01:00
Maurice 6de15a92a4 feat(mobile): fold the place details card away in the add place form
Its header is a button with a chevron; folded, the card shrinks to that
header with an eased height and the form's own fields move up.
2026-11-06 15:47:03 +01:00
Maurice b15dcd9730 fix(mobile): place details card in the phone form's style
The card gets a header with its icon in a round badge, its section labels use
the eyebrow of the form's own fields below it, and the description sits on
the mobile field surface with a pill button to take its text over.
2026-11-06 15:45:54 +01:00
Maurice 821f23aed4 fix(mobile): compact place details, softer sheet edges, icon buttons for copy and share
Add place on the phone: the place details showed the mascot half a screen
high while nothing was picked or nothing was found, and pushed the name field
below the fold. Both states are now one quiet line, as in the desktop dialog,
and the details sit in a card in the mobile tokens.

Sheets with a header over a scroll box (trip settings, day, export,
transport, files and more) cut their content off in a hard line right under
the header icon. The scroll box now keeps a little room under the header and
fades its content out at the edges.

Copy and share next to a share or invite link are round icon buttons of the
same size; the share button showed no icon because the copy button's padding
left it no room.
2026-11-06 15:44:07 +01:00
Maurice 79420cd945 revert(mobile): keep the page transitions' timing and drop the unused route preload
The previous commit carried, by mistake, shorter page transitions and a
route preloader that was never wired up. Both go back to how they were;
the warm-up and day rail fixes stay.
2026-11-06 15:38:15 +01:00
Maurice e3541535e2 fix(native): first visits no longer wait behind the offline warm-up, day rail follows the swipe
The iOS app pulls every file of a build into WebKit's cache so it can start
offline. It did so four at a time at normal priority, for all 1,900 files of
every new build including all 27 translations, and a page opened meanwhile
queued behind it: right after a server update, a first visit to Vacay took
five to seven seconds. The warm-up now runs two at a time at low priority,
takes only the user's language and English, and walks a build once.

The day rail in the trip header lit the new day only after the pager's snap
had landed and the plan had re-rendered, a second or two after the swipe.
The pager now reports the day a swipe heads for as soon as its page is the
nearer one, and the rail and its pill move with the finger; the plan still
switches when the snap lands.
2026-11-06 15:37:55 +01:00
270 changed files with 9705 additions and 1674 deletions
+13
View File
@@ -161,6 +161,19 @@ jobs:
# development profile, which needs a registered device), so client/fastlane/Fastfile
# imports the stored distribution certificate and creates the App Store profile
# through the API key.
# The tag's message, past its subject line, is what testers read under
# "What to Test": one short line per change worth trying.
- name: TestFlight notes from the tag
if: startsWith(github.ref, 'refs/tags/')
run: |
git fetch --force --depth=1 origin "+${GITHUB_REF}:${GITHUB_REF}"
{
echo 'TESTFLIGHT_NOTES<<TREK_NOTES_END'
# A signed tag carries its signature at the end of the message.
git for-each-ref "$GITHUB_REF" --format='%(contents:body)' | sed '/-----BEGIN .*SIGNATURE-----/,$d'
echo 'TREK_NOTES_END'
} >> "$GITHUB_ENV"
- name: Build and upload to TestFlight
working-directory: client
env:
+30 -1
View File
@@ -103,7 +103,7 @@ jobs:
xcodebuild -version
- name: Check the property lists
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings
- uses: actions/setup-node@v6
with:
@@ -130,3 +130,32 @@ jobs:
-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.
widget-previews:
runs-on: macos-15
timeout-minutes: 20
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"
- name: Render the widgets
working-directory: client/ios/WidgetPreviews
run: |
mkdir -p Sources/WidgetPreviews/Widgets
cp ../App/TREKWidgets/WidgetSnapshot.swift ../App/TREKWidgets/TrekStyle.swift ../App/TREKWidgets/TrekFamily.swift ../App/TREKWidgets/WorldGrid.swift \
../App/TREKWidgets/*Content.swift Sources/WidgetPreviews/Widgets/
swift run -c release WidgetPreviews out "$PWD/../App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png"
- uses: actions/upload-artifact@v6
with:
name: widget-previews
path: client/ios/WidgetPreviews/out
retention-days: 14
+13 -9
View File
@@ -32,8 +32,8 @@ async function swipe(page: Page, from: { x: number; y: number }, to: { x: number
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await cdp.detach()
// The out leg is 150ms and the in leg 220ms.
await page.waitForTimeout(500)
// The strip snaps natively; give the snap and the selection that follows it time to land.
await page.waitForTimeout(900)
}
const activeChip = (page: Page) => page.locator('button[aria-current="true"]')
@@ -72,19 +72,23 @@ test('#2051 phone: the day details area swipes between days', async ({ page }) =
const day1 = (await activeChip(page).textContent())!.trim()
// The days are a scroll-snap pager: like a native pager it turns once the
// finger has carried the page past its middle, so the swipe covers most of it.
const reach = Math.round(env.width * 0.4)
const chipText = async () => (await activeChip(page).textContent())!.trim()
// 1. A swipe left steps forward.
await swipe(page, { x: mid + 90, y }, { x: mid - 90, y })
const day2 = (await activeChip(page).textContent())!.trim()
expect(day2, 'swiping left moved to the next day').not.toBe(day1)
await swipe(page, { x: mid + reach, y }, { x: mid - reach, y })
await expect.poll(chipText, { message: 'swiping left moved to the next day' }).not.toBe(day1)
// 2. A swipe right steps back to where it started.
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
await swipe(page, { x: mid - reach, y }, { x: mid + reach, y })
await expect.poll(chipText).toBe(day1)
// 3. The first day has nowhere to go back to — it must stay put, and the
// browser must not have been handed a page-back either.
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
await swipe(page, { x: mid - reach, y }, { x: mid + reach, y })
expect(await chipText()).toBe(day1)
expect(page.url()).toContain(`/trips/${tripId}`)
// 4. A vertical drag belongs to the card's own scroller, never to the day.
+5 -1
View File
@@ -103,9 +103,13 @@ platform :ios do
# Upload to App Store Connect; it shows up in TestFlight after Apple finishes processing.
# Internal testing needs no App Review, so nothing here submits the app for review.
# "What to Test" comes from the release tag's message (native-release.yml). Apple only
# takes it once the build is processed, so with notes the lane waits for that.
notes = ENV.fetch("TESTFLIGHT_NOTES", "").strip
upload_to_testflight(
api_key: key,
skip_waiting_for_build_processing: true,
changelog: notes.empty? ? nil : notes,
skip_waiting_for_build_processing: notes.empty?,
skip_submission: true,
)
end
+139 -2
View File
@@ -26,12 +26,24 @@
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */; };
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00062C0000000000A006 /* TrekShellPlugin.swift */; };
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
D3C699E2258FBA6A11C1DDE4 /* TripIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */; };
157A7B737E3FFB610A218D47 /* AtlasContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D004544572B0E85DE29A7B8 /* AtlasContent.swift */; };
FEB91385917AC44D5CDAA59C /* PackingContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C0771C8582246A26E0C98E /* PackingContent.swift */; };
EA5EDDD4E5F9BC9BC46C563F /* CollabContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D5041DDDAFF5B491B053999 /* CollabContent.swift */; };
45B990B86488D5CC2B5222B5 /* WorldGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC751335E0136865E08990 /* WorldGrid.swift */; };
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = A030A06726542A2D7F61C54B /* TrekFamily.swift */; };
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B175DE91E1E6E850DB0285 /* TodayContent.swift */; };
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */; };
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */; };
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
A8FDD4DBE3F078BF935CC4B9 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA8802204810ED580DE46F3 /* NotificationService.swift */; };
B4C0FC386F71D849E141C91E /* NextTripWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */; };
B65B7CFC62EEFF443491EA64 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 192E69C7E66646BC39566925 /* ActivityKit.framework */; };
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */; };
A0E8821CA7CF51AF2D6D8422 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 248A72D1C511B4969D89333A /* Localizable.strings */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -97,7 +109,46 @@
BE41D7212DCB0C78E00A8462 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NextTripWidget.swift; sourceTree = "<group>"; };
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = "<group>"; };
8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripIntent.swift; sourceTree = "<group>"; };
4D004544572B0E85DE29A7B8 /* AtlasContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtlasContent.swift; sourceTree = "<group>"; };
04C0771C8582246A26E0C98E /* PackingContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackingContent.swift; sourceTree = "<group>"; };
7D5041DDDAFF5B491B053999 /* CollabContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CollabContent.swift; sourceTree = "<group>"; };
D0AC751335E0136865E08990 /* WorldGrid.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorldGrid.swift; sourceTree = "<group>"; };
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekStyle.swift; sourceTree = "<group>"; };
A030A06726542A2D7F61C54B /* TrekFamily.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekFamily.swift; sourceTree = "<group>"; };
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripContent.swift; sourceTree = "<group>"; };
24B175DE91E1E6E850DB0285 /* TodayContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayContent.swift; sourceTree = "<group>"; };
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = "<group>"; };
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodosContent.swift; sourceTree = "<group>"; };
FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
84CB5C3B6BD4E1DE1A8BD2B4 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
EE30A6DF943052C6ADE92D63 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
ECC80E1314B31ED2D2347411 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
7328217E23761C22814FEC25 /* az */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = "<group>"; };
CC077989456FB9DB5F4FF676 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt-BR; path = pt-BR.lproj/Localizable.strings; sourceTree = "<group>"; };
405B04DFF3A978D1046147C6 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
DD341749E47B7DF89BA9A46D /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
9A3FAA9DBC67D12389EEF0CF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
8D361F7B14AE9F5ACE3BFE78 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
60EB00F3670B41B898765DF9 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
B680CBF1268D2EE1982B165C /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
A2F3670F49C3D74C3D98E457 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
1947CC62E5A6FF3483BEB578 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
8A0289CB321AE008D8815A19 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
B7F33B7A5C27C126DF29DCA4 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
683725459244601B71D92E2D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
32A62EE30F8AD514D7DB2CAA /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
037DA05706AE051CEFCA7D75 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
090D74588DA8436DEC275567 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
9F756414EFBB3542F65C14CE /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
5C0109EA628FA897B7BFDD3E /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
E5800E4ECECED54A47CDABAA /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
A06BBDA4CF70465F2E3F21D3 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
3CB565FC61AB5AEA87A3F615 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.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>"; };
1ED347C91E03E51FDF8ABC1A /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh-Hant; path = zh-Hant.lproj/Localizable.strings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -206,12 +257,24 @@
isa = PBXGroup;
children = (
BE41D7212DCB0C78E00A8462 /* Info.plist */,
248A72D1C511B4969D89333A /* Localizable.strings */,
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */,
BBB2E19CD540AB30B44BECCA /* TREKWidgets.entitlements */,
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */,
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */,
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */,
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */,
8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */,
4D004544572B0E85DE29A7B8 /* AtlasContent.swift */,
04C0771C8582246A26E0C98E /* PackingContent.swift */,
7D5041DDDAFF5B491B053999 /* CollabContent.swift */,
D0AC751335E0136865E08990 /* WorldGrid.swift */,
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */,
A030A06726542A2D7F61C54B /* TrekFamily.swift */,
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */,
24B175DE91E1E6E850DB0285 /* TodayContent.swift */,
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */,
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */,
);
name = TREKWidgets;
path = TREKWidgets;
@@ -305,6 +368,33 @@
knownRegions = (
en,
Base,
de,
ar,
az,
"pt-BR",
ca,
cs,
es,
et,
fr,
el,
he,
hu,
id,
it,
ja,
ko,
nl,
pl,
ru,
sk,
sv,
th,
tr,
uk,
vi,
"zh-Hans",
"zh-Hant",
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
@@ -326,6 +416,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A0E8821CA7CF51AF2D6D8422 /* Localizable.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -382,6 +473,17 @@
492FA24FBB57059B0B5007CE /* TripDayAttributes.swift in Sources */,
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */,
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */,
D3C699E2258FBA6A11C1DDE4 /* TripIntent.swift in Sources */,
157A7B737E3FFB610A218D47 /* AtlasContent.swift in Sources */,
FEB91385917AC44D5CDAA59C /* PackingContent.swift in Sources */,
EA5EDDD4E5F9BC9BC46C563F /* CollabContent.swift in Sources */,
45B990B86488D5CC2B5222B5 /* WorldGrid.swift in Sources */,
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */,
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */,
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */,
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */,
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */,
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -403,6 +505,41 @@
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
248A72D1C511B4969D89333A /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
84CB5C3B6BD4E1DE1A8BD2B4 /* en */,
EE30A6DF943052C6ADE92D63 /* de */,
ECC80E1314B31ED2D2347411 /* ar */,
7328217E23761C22814FEC25 /* az */,
CC077989456FB9DB5F4FF676 /* pt-BR */,
405B04DFF3A978D1046147C6 /* ca */,
DD341749E47B7DF89BA9A46D /* cs */,
9A3FAA9DBC67D12389EEF0CF /* es */,
8D361F7B14AE9F5ACE3BFE78 /* et */,
60EB00F3670B41B898765DF9 /* fr */,
B680CBF1268D2EE1982B165C /* el */,
12DE24E0B7E2E5F3564D952B /* he */,
15884CA899CAD3FFD98BA927 /* hu */,
A2F3670F49C3D74C3D98E457 /* id */,
1947CC62E5A6FF3483BEB578 /* it */,
8A0289CB321AE008D8815A19 /* ja */,
B7F33B7A5C27C126DF29DCA4 /* ko */,
683725459244601B71D92E2D /* nl */,
32A62EE30F8AD514D7DB2CAA /* pl */,
037DA05706AE051CEFCA7D75 /* ru */,
090D74588DA8436DEC275567 /* sk */,
9F756414EFBB3542F65C14CE /* sv */,
5C0109EA628FA897B7BFDD3E /* th */,
E5800E4ECECED54A47CDABAA /* tr */,
A06BBDA4CF70465F2E3F21D3 /* uk */,
3CB565FC61AB5AEA87A3F615 /* vi */,
608F00CBD96957EEA888E50A /* zh-Hans */,
1ED347C91E03E51FDF8ABC1A /* zh-Hant */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
@@ -431,7 +568,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -662,7 +799,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -29,8 +29,20 @@ class TrekBridgeViewController: CAPBridgeViewController {
return configuration
}
/// Kept alive here: WKWebView holds its navigation delegate weakly.
private var navigationProxy: TrekNavigationProxy?
override func capacitorDidLoad() {
TrekTileStore.shared.prune()
// Capacitor shows its error page for every failed navigation, a replaced
// one included: the UI moving to /login while the first page still loads
// cancels that load (-999), and a first-time user landed on "Server not
// reachable" with a server that was there all along.
if let webView, let inner = webView.navigationDelegate {
let proxy = TrekNavigationProxy(inner: inner)
navigationProxy = proxy
webView.navigationDelegate = proxy
}
bridge?.registerPluginInstance(TrekShellPlugin())
// WebKit's own swipe back slides away a screenshot of the whole page,
// closes no sheet and takes every drag near the edge from the UI. The UI
@@ -272,3 +284,39 @@ final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
task.didFinish()
}
}
/// Stands in front of Capacitor's navigation delegate and hands it everything
/// but the failures of a navigation that another one replaced: a cancelled
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
/// are no fault, and Capacitor would answer them with its error page.
final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
private let inner: WKNavigationDelegate
init(inner: WKNavigationDelegate) {
self.inner = inner
}
static func isReplaced(_ error: Error) -> Bool {
let error = error as NSError
return (error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled)
|| (error.domain == "WebKitErrorDomain" && error.code == 102)
}
override func responds(to aSelector: Selector!) -> Bool {
super.responds(to: aSelector) || (inner as AnyObject).responds(to: aSelector)
}
override func forwardingTarget(for aSelector: Selector!) -> Any? {
(inner as AnyObject).responds(to: aSelector) ? inner : nil
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
if Self.isReplaced(error) { return }
inner.webView?(webView, didFail: navigation, withError: error)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
if Self.isReplaced(error) { return }
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
}
}
+143 -2
View File
@@ -3,6 +3,7 @@ import Capacitor
import CoreLocation
import CryptoKit
import Security
import StoreKit
import UserNotifications
import WidgetKit
#if canImport(ActivityKit)
@@ -39,6 +40,10 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "setWidgetSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "syncTripDay", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "endTripDay", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storeProducts", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storePurchase", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise),
]
/// What this binary offers the server's UI, raised with every native feature
@@ -46,7 +51,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
static let shellApi = 2
/// The native features by name, so the UI can turn each on by itself.
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity"]
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit"]
// MARK: - Shared booking documents
@@ -57,18 +62,26 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
TrekPush.shared.onOpen = { [weak self] url in
DispatchQueue.main.async { self?.notifyListeners("pushOpened", data: ["url": url]) }
}
TrekStore.watchUpdates()
}
// MARK: - Widgets and the travel-day Live Activity
/// Stores what the home-screen widget shows (native/widgets.ts builds it, texts
/// already translated) where the widget extension reads it, and redraws it.
/// `key` (feature widgetTrips) stores one trip's snapshot ("trip.<id>") or the
/// list of trips a widget can be set to ("trips"); without it, the dashboard's.
@objc func setWidgetSnapshot(_ call: CAPPluginCall) {
guard let json = call.getString("json"), let data = json.data(using: .utf8) else {
call.reject("json is required")
return
}
TrekSharedKeychain.write("widget.snapshot", data)
let key = call.getString("key") ?? "snapshot"
guard key == "snapshot" || key == "trips" || key.range(of: "^trip\\.[0-9]{1,12}$", options: .regularExpression) != nil else {
call.reject("key must be snapshot, trips or trip.<id>")
return
}
TrekSharedKeychain.write("widget.\(key)", data)
WidgetCenter.shared.reloadAllTimelines()
call.resolve()
}
@@ -368,6 +381,134 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
/// When-In-Use permission asked the first time.
// MARK: - In-app purchase
extension TrekShellPlugin {
/// Feature storeKit: the App Store's own price strings for the given products,
/// already in the buyer's currency and format. Products the store does not
/// know are left out, so the shell offers only what can really be bought.
@objc func storeProducts(_ call: CAPPluginCall) {
let ids = call.getArray("ids", String.self) ?? []
Task {
guard AppStore.canMakePayments, let products = try? await Product.products(for: ids) else {
call.resolve(["products": []])
return
}
call.resolve(["products": products.map { ["id": $0.id, "displayPrice": $0.displayPrice] }])
}
}
/// Feature storeKit: runs the App Store purchase sheet. Resolves with `kind`
/// bought (with the signed transaction the control plane verifies), cancelled,
/// pending (Ask to Buy, a payment the bank still has to approve) or failed.
/// The transaction stays unfinished until storeFinish, so a purchase whose
/// instance was never set up is delivered again rather than lost.
@objc func storePurchase(_ call: CAPPluginCall) {
guard let id = call.getString("productId") else {
call.reject("productId is required")
return
}
Task {
do {
guard let product = try await Product.products(for: [id]).first else {
call.resolve(["kind": "failed"])
return
}
switch try await product.purchase() {
case .success(let verification):
guard case .verified(let transaction) = verification else {
call.resolve(["kind": "failed"])
return
}
call.resolve([
"kind": "bought",
"jws": verification.jwsRepresentation,
"transactionId": String(transaction.id),
])
case .userCancelled:
call.resolve(["kind": "cancelled"])
case .pending:
call.resolve(["kind": "pending"])
@unknown default:
call.resolve(["kind": "failed"])
}
} catch {
call.resolve(["kind": "failed"])
}
}
}
/// Feature storeKit: marks a purchase as delivered, once its instance exists.
@objc func storeFinish(_ call: CAPPluginCall) {
guard let raw = call.getString("transactionId"), let id = UInt64(raw) else {
call.reject("transactionId is required")
return
}
Task {
await TrekStore.finish(id)
call.resolve()
}
}
/// Feature storeKit: the newest active subscription on this Apple ID among
/// products starting with `prefix`, after syncing with the App Store. Resolves
/// without `jws` when there is none.
@objc func storeRestore(_ call: CAPPluginCall) {
let prefix = call.getString("prefix") ?? "cloud."
Task {
// Asks for the Apple ID password when needed; a cancel still leaves the local entitlements to check.
try? await AppStore.sync()
guard let found = await TrekStore.currentEntitlement(prefix: prefix) else {
call.resolve([:])
return
}
call.resolve(["jws": found.jws, "transactionId": String(found.id)])
}
}
}
enum TrekStore {
private static var updates: Task<Void, Never>?
/// Listens for transactions that arrive outside a purchase sheet. Renewals are
/// finished at once: the control plane learns of them from Apple's server
/// notifications. A first purchase that lands here (Ask to Buy approved later,
/// a purchase interrupted by the app closing) stays unfinished for the shell
/// to redeem through a restore.
static func watchUpdates() {
guard updates == nil else { return }
updates = Task.detached {
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
if transaction.originalID != transaction.id {
await transaction.finish()
}
}
}
}
static func finish(_ id: UInt64) async {
for await result in Transaction.unfinished {
if case .verified(let transaction) = result, transaction.id == id {
await transaction.finish()
}
}
}
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64)? {
var newest: (date: Date, jws: String, id: UInt64)?
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result,
transaction.productID.hasPrefix(prefix),
transaction.revocationDate == nil else { continue }
if newest == nil || transaction.purchaseDate > newest!.date {
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id)
}
}
return newest.map { ($0.jws, $0.id) }
}
}
final class TrekLocation: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private let emit: ([String: Any]) -> Void
@@ -0,0 +1,253 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
/// The Atlas on the home screen: the world as dots, the visited countries
/// glowing, the latest one ringed, and how many there are.
struct AtlasContent: View {
let snapshot: WidgetSnapshot?
let family: TrekFamily
@Environment(\.colorScheme) private var scheme
var body: some View {
let atlas = snapshot?.atlas
let labels = snapshot?.labels
let visited = Set(atlas?.codes ?? [])
let latest = atlas?.recent.first?.code
switch family {
case .circular:
ZStack {
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
Circle().trim(from: 0, to: min(1, Double(atlas?.count ?? 0) / 195))
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: -2) {
Image(systemName: "globe.europe.africa.fill").font(.system(size: 9, weight: .bold))
Text("\(atlas?.count ?? 0)").font(Trek.number(19, .heavy))
}
}
.padding(3)
case .rectangular:
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "globe.europe.africa.fill").font(.system(size: 11, weight: .bold))
Text("\(atlas?.count ?? 0) \(labels?.countries ?? "")").font(.system(size: 14, weight: .heavy))
}
DotWorld(visited: visited, latest: latest, accent: [.white], rest: Color.white.opacity(0.3), dot: 1.3)
.frame(height: 34)
}
.frame(maxWidth: .infinity, alignment: .leading)
case .inline:
Text("\(Image(systemName: "globe.europe.africa.fill")) \(atlas?.count ?? 0) \(labels?.countries ?? "")")
case .small:
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
}
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
Spacer(minLength: 6)
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 1.35)
.frame(height: 64)
}
case .medium:
VStack(alignment: .leading, spacing: 8) {
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 2.2)
.frame(maxHeight: .infinity)
HStack(alignment: .lastTextBaseline, spacing: 6) {
Text("\(atlas?.count ?? 0)").font(Trek.number(30, .heavy)).foregroundStyle(ink)
VStack(alignment: .leading, spacing: 0) {
Text(labels?.countries ?? "").font(.system(size: 12, weight: .bold)).foregroundStyle(ink)
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 10.5, weight: .semibold)).foregroundStyle(muted)
}
Spacer(minLength: 4)
if let recent = atlas?.recent.first {
CountryPill(country: recent, highlight: true)
}
}
}
default:
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .lastTextBaseline, spacing: 6) {
Text("\(atlas?.count ?? 0)").font(Trek.number(40, .heavy)).foregroundStyle(ink)
VStack(alignment: .leading, spacing: 0) {
Text(labels?.countries ?? "").font(.system(size: 14, weight: .heavy)).foregroundStyle(ink)
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
}
Spacer(minLength: 4)
if let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) }
if let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) }
}
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 2.2)
.frame(height: 150)
ContinentBars(continents: atlas?.continents ?? [], scheme: scheme)
HStack(spacing: 6) {
ForEach(Array((atlas?.recent ?? []).prefix(3).enumerated()), id: \.offset) { index, country in
CountryPill(country: country, highlight: index == 0)
}
Spacer(minLength: 0)
}
}
}
}
private var ink: Color { Trek.ink(scheme) }
private var muted: Color { Trek.muted(scheme) }
private var glow: [Color] { [Color(hex: 0x3DDC97), Color(hex: 0x4A7DDB)] }
private var rest: Color { scheme == .dark ? Color.white.opacity(0.13) : Color(hex: 0x101013).opacity(0.11) }
/// "12 % of the world", of the 195 countries there are.
private func share(_ count: Int, _ labels: WidgetSnapshot.Labels?) -> String {
let percent = Int((Double(count) / 195 * 100).rounded())
return "\(percent) % \(labels?.world ?? "")"
}
}
/// The world map as a grid of dots (WorldGrid): visited countries in the
/// accent from west to east, the rest faint, the latest one ringed.
struct DotWorld: View {
let visited: Set<String>
let latest: String?
let accent: [Color]
let rest: Color
/// The dot radius at a 364 pt wide map; scaled with the width.
var dot: CGFloat = 2
var body: some View {
Canvas { context, size in
let columns = CGFloat(WorldGrid.columns)
let rows = CGFloat(WorldGrid.rows.count)
let step = min(size.width / columns, size.height / rows)
let originX = (size.width - step * columns) / 2
let originY = (size.height - step * rows) / 2
let radius = max(0.6, step * 0.36 * dot / 2)
var ringed: CGPoint?
var ringCount = 0
var ringSum = CGPoint.zero
for row in 0..<WorldGrid.rows.count {
for column in 0..<WorldGrid.columns {
guard let code = WorldGrid.code(row: row, column: column) else { continue }
let center = CGPoint(x: originX + (CGFloat(column) + 0.5) * step, y: originY + (CGFloat(row) + 0.5) * step)
let rect = CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2)
if visited.contains(code) {
let t = CGFloat(column) / columns
context.fill(Path(ellipseIn: rect.insetBy(dx: -radius * 0.25, dy: -radius * 0.25)), with: .color(blend(t)))
if code == latest {
ringSum.x += center.x
ringSum.y += center.y
ringCount += 1
}
} else {
context.fill(Path(ellipseIn: rect), with: .color(rest))
}
}
}
if ringCount > 0 { ringed = CGPoint(x: ringSum.x / CGFloat(ringCount), y: ringSum.y / CGFloat(ringCount)) }
if let ringed {
let color = accent.last ?? .white
for (scale, alpha) in [(5.5, 0.18), (3.6, 0.35)] as [(CGFloat, Double)] {
let r = step * scale / 2
context.stroke(
Path(ellipseIn: CGRect(x: ringed.x - r, y: ringed.y - r, width: r * 2, height: r * 2)),
with: .color(color.opacity(alpha)), lineWidth: max(1, step * 0.22)
)
}
let core = step * 0.55
context.fill(Path(ellipseIn: CGRect(x: ringed.x - core, y: ringed.y - core, width: core * 2, height: core * 2)), with: .color(.white))
}
}
}
/// The accent at a point from west (0) to east (1), blended between its colours.
private func blend(_ t: CGFloat) -> Color {
guard accent.count > 1, let from = rgb(accent[0]), let to = rgb(accent[accent.count - 1]) else { return accent.first ?? .white }
let k = Double(max(0, min(1, t)))
return Color(.sRGB, red: from.0 + (to.0 - from.0) * k, green: from.1 + (to.1 - from.1) * k, blue: from.2 + (to.2 - from.2) * k)
}
private func rgb(_ color: Color) -> (Double, Double, Double)? {
#if canImport(UIKit)
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
guard UIColor(color).getRed(&r, green: &g, blue: &b, alpha: &a) else { return nil }
return (Double(r), Double(g), Double(b))
#elseif canImport(AppKit)
guard let c = NSColor(color).usingColorSpace(.sRGB) else { return nil }
return (Double(c.redComponent), Double(c.greenComponent), Double(c.blueComponent))
#else
return nil
#endif
}
}
/// A country with its flag, the latest one tinted.
struct CountryPill: View {
let country: WidgetSnapshot.Atlas.Country
var highlight = false
@Environment(\.colorScheme) private var scheme
var body: some View {
HStack(spacing: 5) {
Text(flag(country.code)).font(.system(size: 13))
Text(country.name).font(.system(size: 11.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
}
.padding(.horizontal, 9)
.padding(.vertical, 4)
.background(Capsule().fill(highlight ? Color(hex: 0x3DDC97).opacity(scheme == .dark ? 0.2 : 0.16) : Trek.card(scheme)))
.overlay(Capsule().stroke(highlight ? Color(hex: 0x3DDC97).opacity(0.45) : Trek.cardBorder(scheme), lineWidth: 0.7))
}
private func flag(_ code: String) -> String {
code.uppercased().unicodeScalars.compactMap { UnicodeScalar(127_397 + $0.value) }.map { String($0) }.joined()
}
}
/// The continents as bars, longest first.
struct ContinentBars: View {
let continents: [WidgetSnapshot.Atlas.Continent]
let scheme: ColorScheme
var body: some View {
let top = continents.prefix(4)
let most = max(top.map(\.count).max() ?? 1, 1)
VStack(spacing: 6) {
ForEach(Array(top.enumerated()), id: \.offset) { index, continent in
HStack(spacing: 8) {
Text(continent.name).font(.system(size: 11, weight: .bold)).foregroundStyle(Trek.ink(scheme))
.frame(width: 92, alignment: .leading).lineLimit(1)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Trek.track(scheme))
Capsule()
.fill(LinearGradient(colors: [Color(hex: 0x3DDC97), Color(hex: 0x4A7DDB)], startPoint: .leading, endPoint: .trailing))
.frame(width: max(6, geo.size.width * CGFloat(continent.count) / CGFloat(most)))
.opacity(1 - Double(index) * 0.15)
}
}
.frame(height: 6)
Text("\(continent.count)").font(Trek.number(11, .heavy)).foregroundStyle(Trek.muted(scheme)).frame(width: 20, alignment: .trailing)
}
}
}
}
}
/// A figure with its symbol, for the large atlas header.
private struct Stat: View {
let value: String
let symbol: String
let scheme: ColorScheme
var body: some View {
HStack(spacing: 3) {
Image(systemName: symbol).font(.system(size: 10, weight: .bold))
Text(value).font(Trek.number(12, .heavy))
}
.foregroundStyle(Trek.muted(scheme))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Trek.card(scheme)))
}
}
@@ -0,0 +1,196 @@
import SwiftUI
/// What is booked next: flights, trains, the hotel, the table for tonight,
/// each with its kind as a coloured tile and when it is due.
struct BookingsContent: View {
let snapshot: WidgetSnapshot?
let family: TrekFamily
let now: Date
@Environment(\.colorScheme) private var scheme
var body: some View {
let bookings = snapshot?.bookings ?? []
let labels = snapshot?.labels
if family == .rectangular {
accessory(bookings.first, labels)
} else if family == .circular {
ZStack {
Circle().fill(Color.white.opacity(0.18))
Image(systemName: bookings.first.map { Trek.booking($0.kind).symbol } ?? "bookmark.fill")
.font(.system(size: 22, weight: .semibold))
}
} else if family == .inline {
if let first = bookings.first {
Text("\(Image(systemName: Trek.booking(first.kind).symbol)) \(first.time ?? "") \(first.title)")
} else {
Text(labels?.noBookings ?? "")
}
} else if let labels, let first = bookings.first {
switch family {
case .small: hero(first, labels)
case .medium: list(Array(bookings.prefix(2)), labels, featureFirst: true)
default: list(Array(bookings.prefix(5)), labels, featureFirst: true)
}
} else {
empty(labels)
}
}
// MARK: Small: the next one, large
private func hero(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
let look = Trek.booking(booking.kind)
return VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
KindBadge(kind: booking.kind, size: 34)
Spacer(minLength: 0)
StatusDot(confirmed: booking.confirmed)
}
Spacer(minLength: 0)
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
Text(day)
.font(.system(size: 11, weight: .heavy))
.tracking(0.5)
.textCase(.uppercase)
.foregroundStyle(look.color)
}
if let time = booking.time {
Text(time).font(Trek.number(30, .heavy)).foregroundStyle(Trek.ink(scheme))
}
Text(booking.title).font(.system(size: 14, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
if let detail = booking.detail {
Text(detail).font(.system(size: 11.5, weight: .semibold)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
}
}
.background(alignment: .topTrailing) {
Image(systemName: look.symbol)
.font(.system(size: 92, weight: .bold))
.foregroundStyle(look.color.opacity(scheme == .dark ? 0.1 : 0.08))
.rotationEffect(.degrees(booking.kind == "flight" ? -20 : 0))
.offset(x: 28, y: 12)
}
}
// 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) {
HStack {
Text(labels.bookings ?? "").trekEyebrow(scheme)
Spacer(minLength: 0)
if let trip = snapshot?.trip { Text(trip.title).font(.system(size: 11, weight: .semibold)).foregroundStyle(Trek.faint(scheme)).lineLimit(1) }
}
ForEach(Array(bookings.enumerated()), id: \.offset) { index, booking in
RowLink(path: booking.path) {
if index == 0 && featureFirst {
featured(booking, labels)
} else {
row(booking, labels)
}
}
}
if family == .large { Spacer(minLength: 0) }
}
}
private func featured(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
let look = Trek.booking(booking.kind)
return HStack(spacing: 10) {
KindBadge(kind: booking.kind, size: 38)
VStack(alignment: .leading, spacing: 1) {
Text(booking.title).font(.system(size: 15, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
if let detail = booking.detail {
Text(detail).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
}
}
Spacer(minLength: 4)
VStack(alignment: .trailing, spacing: 0) {
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
Text(day).font(.system(size: 10.5, weight: .heavy)).textCase(.uppercase).foregroundStyle(look.color)
}
if let time = booking.time { Text(time).font(Trek.number(20, .heavy)).foregroundStyle(Trek.ink(scheme)) }
}
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(LinearGradient(colors: [look.color.opacity(scheme == .dark ? 0.24 : 0.14), look.color.opacity(scheme == .dark ? 0.08 : 0.04)], startPoint: .leading, endPoint: .trailing))
)
.overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(look.color.opacity(0.25), lineWidth: 0.8))
}
private func row(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
HStack(spacing: 10) {
KindBadge(kind: booking.kind, size: 28)
.padding(.leading, 5)
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 5) {
Text(booking.title).font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
if !booking.confirmed { StatusDot(confirmed: false, size: 6) }
}
if let detail = booking.detail {
Text(detail).font(.system(size: 11, weight: .medium)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
}
}
Spacer(minLength: 4)
VStack(alignment: .trailing, spacing: 0) {
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
Text(day).font(.system(size: 10.5, weight: .bold)).foregroundStyle(Trek.muted(scheme))
}
if let time = booking.time { Text(time).font(Trek.number(13, .heavy)).foregroundStyle(Trek.ink(scheme)) }
}
.padding(.trailing, 5)
}
}
private func empty(_ labels: WidgetSnapshot.Labels?) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(labels?.bookings ?? "").trekEyebrow(scheme)
Spacer(minLength: 0)
HStack(spacing: -8) {
ForEach(["flight", "train", "hotel"], id: \.self) { kind in
KindBadge(kind: kind, size: 30).opacity(0.35)
}
}
Text(labels?.noBookings ?? "")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Trek.muted(scheme))
.lineLimit(2)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
@ViewBuilder
private func accessory(_ booking: WidgetSnapshot.Booking?, _ labels: WidgetSnapshot.Labels?) -> some View {
if let booking, let labels {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: Trek.booking(booking.kind).symbol).font(.system(size: 11, weight: .bold))
Text([RelativeDay.label(booking.date, now: now, labels: labels), booking.time].compactMap { $0 }.joined(separator: " "))
.font(.system(size: 12, weight: .bold))
.lineLimit(1)
}
Text(booking.title).font(.system(size: 14, weight: .heavy)).lineLimit(1)
if let detail = booking.detail { Text(detail).font(.system(size: 11, weight: .medium)).opacity(0.75).lineLimit(1) }
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Text(labels?.noBookings ?? "").font(.system(size: 12, weight: .medium)).frame(maxWidth: .infinity, alignment: .leading)
}
}
}
/// Confirmed in green, pending in amber, with a soft halo.
struct StatusDot: View {
let confirmed: Bool
var size: CGFloat = 9
var body: some View {
let color = confirmed ? Trek.confirmed : Trek.pending
Circle()
.fill(color)
.frame(width: size, height: size)
.overlay(Circle().stroke(color.opacity(0.3), lineWidth: size * 0.45).frame(width: size * 1.7, height: size * 1.7))
.frame(width: size * 1.8, height: size * 1.8)
}
}
@@ -0,0 +1,207 @@
import SwiftUI
/// The travel group: who comes along, the latest messages in the trip chat
/// as bubbles, and the open poll with its votes as bars.
struct CollabContent: View {
let snapshot: WidgetSnapshot?
let family: TrekFamily
@Environment(\.colorScheme) private var scheme
var body: some View {
let collab = snapshot?.collab
let labels = snapshot?.labels
if let collab, let labels {
switch family {
case .small:
VStack(alignment: .leading, spacing: 0) {
Text(labels.group ?? "").trekEyebrow(scheme)
AvatarStack(members: collab.members, size: 30, scheme: scheme).padding(.top, 8)
Spacer(minLength: 0)
if let poll = collab.poll {
Label(labels.poll ?? "", systemImage: "chart.bar.fill")
.font(.system(size: 10.5, weight: .heavy))
.foregroundStyle(Trek.info)
Text(poll.question).font(.system(size: 13.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
} else if let last = collab.messages.last {
Text(last.author).font(.system(size: 10.5, weight: .heavy)).foregroundStyle(Trek.muted(scheme))
Text(last.text).font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(3)
} else {
Text(labels.noMessages ?? "").font(.system(size: 12.5, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
}
}
case .medium:
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(snapshot?.trip?.title ?? labels.group ?? "").trekEyebrow(scheme)
Spacer(minLength: 4)
AvatarStack(members: collab.members, size: 22, scheme: scheme)
}
bubbles(Array(collab.messages.suffix(2)), labels)
Spacer(minLength: 0)
}
case .large:
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 2) {
Text(labels.group ?? "").trekEyebrow(scheme)
if let trip = snapshot?.trip {
Text(trip.title).font(.system(size: 16, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
}
}
Spacer(minLength: 4)
AvatarStack(members: collab.members, size: 28, scheme: scheme)
}
bubbles(Array(collab.messages.suffix(3)), labels)
Spacer(minLength: 0)
if let poll = collab.poll { PollCard(poll: poll, labels: labels, scheme: scheme) }
}
default:
accessory(collab, labels)
}
} else {
VStack(alignment: .leading, spacing: 6) {
Text(labels?.group ?? "").trekEyebrow(scheme)
Spacer(minLength: 0)
Image(systemName: "person.3.fill").font(.system(size: 28)).foregroundStyle(Trek.muted(scheme).opacity(0.5))
Text(labels?.noMessages ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
}
@ViewBuilder
private func bubbles(_ messages: [WidgetSnapshot.Collab.Message], _ labels: WidgetSnapshot.Labels) -> some View {
if messages.isEmpty {
Text(labels.noMessages ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
} else {
VStack(spacing: 6) {
ForEach(Array(messages.enumerated()), id: \.offset) { _, message in
ChatBubble(message: message, scheme: scheme)
}
}
}
}
@ViewBuilder
private func accessory(_ collab: WidgetSnapshot.Collab, _ labels: WidgetSnapshot.Labels) -> some View {
if let last = collab.messages.last {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: "bubble.left.and.bubble.right.fill").font(.system(size: 11, weight: .bold))
Text(last.author).font(.system(size: 12, weight: .bold)).lineLimit(1)
}
Text(last.text).font(.system(size: 13, weight: .semibold)).lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Text(labels.noMessages ?? "").font(.system(size: 12, weight: .medium)).frame(maxWidth: .infinity, alignment: .leading)
}
}
}
extension Trek {
/// A colour of a person's own, from their name.
static func personColor(_ name: String) -> Color {
let palette: [UInt32] = [0x4A7DDB, 0x2FA37A, 0xE8A13A, 0x8E6CD9, 0xD6408B, 0x2BA6A6, 0xD6673B, 0x5A6CDE]
return Color(hex: palette[Int(seed(name) % UInt64(palette.count))])
}
}
/// Overlapping initials in each person's colour, with a count for the rest.
struct AvatarStack: View {
let members: [WidgetSnapshot.Collab.Member]
let size: CGFloat
let scheme: ColorScheme
var body: some View {
HStack(spacing: -size * 0.18) {
ForEach(Array(members.prefix(4).enumerated()), id: \.offset) { _, member in
Circle()
.fill(LinearGradient(colors: [Trek.personColor(member.name), Trek.personColor(member.name).opacity(0.75)], startPoint: .top, endPoint: .bottom))
.frame(width: size, height: size)
.overlay(Text(member.initials).font(.system(size: size * 0.34, weight: .heavy)).minimumScaleFactor(0.7).lineLimit(1).foregroundStyle(.white).padding(.horizontal, size * 0.1))
.overlay(Circle().stroke(scheme == .dark ? Color(hex: 0x111115) : Color(hex: 0xF4F2EE), lineWidth: 2))
}
if members.count > 4 {
Circle()
.fill(Trek.card(scheme))
.frame(width: size, height: size)
.overlay(Text("+\(members.count - 4)").font(.system(size: size * 0.34, weight: .heavy)).foregroundStyle(Trek.muted(scheme)))
.overlay(Circle().stroke(scheme == .dark ? Color(hex: 0x111115) : Color(hex: 0xF4F2EE), lineWidth: 2))
}
}
}
}
/// A chat message: the others on the left with their initials, mine on the right in ink.
struct ChatBubble: View {
let message: WidgetSnapshot.Collab.Message
let scheme: ColorScheme
var body: some View {
HStack(alignment: .bottom, spacing: 6) {
if message.mine { Spacer(minLength: 36) }
if !message.mine {
Circle()
.fill(Trek.personColor(message.author))
.frame(width: 20, height: 20)
.overlay(Text(message.initials).font(.system(size: 8, weight: .heavy)).foregroundStyle(.white))
}
Text(message.text)
.font(.system(size: 12.5, weight: .medium))
.foregroundStyle(message.mine ? (scheme == .dark ? Color(hex: 0x101013) : .white) : Trek.ink(scheme))
.lineLimit(2)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(message.mine ? Trek.ink(scheme) : Trek.card(scheme))
)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(message.mine ? Color.clear : Trek.cardBorder(scheme), lineWidth: 0.7)
)
if !message.mine { Spacer(minLength: 36) }
}
}
}
/// The open poll: the question and each option as a bar of its votes.
struct PollCard: View {
let poll: WidgetSnapshot.Collab.Poll
let labels: WidgetSnapshot.Labels
let scheme: ColorScheme
var body: some View {
let most = max(poll.options.map(\.votes).max() ?? 1, 1)
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 5) {
Image(systemName: "chart.bar.fill").font(.system(size: 10, weight: .bold)).foregroundStyle(Trek.info)
Text(labels.poll ?? "").font(.system(size: 10.5, weight: .heavy)).textCase(.uppercase).foregroundStyle(Trek.info)
Spacer(minLength: 0)
Text("\(poll.voters) \(labels.votes ?? "")").font(.system(size: 10.5, weight: .bold)).foregroundStyle(Trek.muted(scheme))
}
Text(poll.question).font(.system(size: 13.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
ForEach(Array(poll.options.prefix(3).enumerated()), id: \.offset) { _, option in
let leading = option.votes == most && option.votes > 0
ZStack(alignment: .leading) {
GeometryReader { geo in
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(leading ? Trek.info.opacity(scheme == .dark ? 0.35 : 0.22) : Trek.track(scheme))
.frame(width: max(8, geo.size.width * CGFloat(option.votes) / CGFloat(most)))
}
HStack {
Text(option.label).font(.system(size: 12, weight: leading ? .heavy : .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
Spacer(minLength: 4)
Text("\(option.votes)").font(Trek.number(12, .heavy)).foregroundStyle(Trek.muted(scheme))
}
.padding(.horizontal, 9)
}
.frame(height: 24)
}
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 15, style: .continuous).fill(Trek.card(scheme)))
.overlay(RoundedRectangle(cornerRadius: 15, style: .continuous).stroke(Trek.cardBorder(scheme), lineWidth: 0.7))
}
}
+219 -88
View File
@@ -1,118 +1,249 @@
import SwiftUI
import WidgetKit
/// "Next trip" on the home screen: the trip and how far off it is, or while it
/// runs, today's day and the next stops (medium size).
struct NextTripEntry: TimelineEntry {
/// The home-screen and lock-screen widgets; all but the Atlas show one trip,
/// the dashboard's or the one picked in Edit Widget (TripIntent.swift). Their gallery names and
/// descriptions are in <language>.lproj/Localizable.strings. Each reads the snapshot the app
/// last wrote (WidgetSnapshot) and draws it with the views in *Content.swift,
/// which know nothing of WidgetKit so the preview renderer can draw them too.
struct TrekEntry: TimelineEntry {
let date: Date
let snapshot: WidgetSnapshot?
}
struct NextTripProvider: TimelineProvider {
func placeholder(in context: Context) -> NextTripEntry {
NextTripEntry(date: Date(), snapshot: nil)
struct TrekProvider: TimelineProvider {
func placeholder(in context: Context) -> TrekEntry {
TrekEntry(date: Date(), snapshot: .sample(running: false))
}
func getSnapshot(in context: Context, completion: @escaping (NextTripEntry) -> Void) {
completion(NextTripEntry(date: Date(), snapshot: WidgetSnapshot.load()))
/// The gallery shows the user's own trip when the app has written one, a
/// sample trip before that.
func getSnapshot(in context: Context, completion: @escaping (TrekEntry) -> Void) {
let stored = WidgetSnapshot.load()
let snapshot: WidgetSnapshot? = (stored?.trip != nil || !context.isPreview) ? stored : WidgetSnapshot.sample(running: false)
completion(TrekEntry(date: Date(), snapshot: snapshot))
}
/// The app reloads the timeline whenever it writes a new snapshot; past
/// midnight the countdown moves on by itself.
func getTimeline(in context: Context, completion: @escaping (Timeline<NextTripEntry>) -> Void) {
/// An entry every quarter hour for the next six hours, so the now line and
/// the next stop move on without the app; the app reloads the timeline
/// whenever it writes a new snapshot.
func getTimeline(in context: Context, completion: @escaping (Timeline<TrekEntry>) -> Void) {
completion(TrekProvider.timeline(WidgetSnapshot.load()))
}
static func timeline(_ snapshot: WidgetSnapshot?) -> Timeline<TrekEntry> {
let now = Date()
let midnight = Calendar.current.startOfDay(for: now.addingTimeInterval(86_400))
let entry = NextTripEntry(date: now, snapshot: WidgetSnapshot.load())
completion(Timeline(entries: [entry], policy: .after(midnight)))
let calendar = Calendar.current
let minute = calendar.component(.minute, from: now)
let firstStep = calendar.date(byAdding: .minute, value: 15 - minute % 15, to: now) ?? now
var entries = [TrekEntry(date: now, snapshot: snapshot)]
for step in 0..<24 {
if let date = calendar.date(byAdding: .minute, value: step * 15, to: firstStep) {
entries.append(TrekEntry(date: calendar.date(bySetting: .second, value: 0, of: date) ?? date, snapshot: snapshot))
}
}
return Timeline(entries: entries, policy: .atEnd)
}
}
struct NextTripWidgetView: View {
extension TrekFamily {
init(_ family: WidgetFamily) {
switch family {
case .systemSmall: self = .small
case .systemMedium: self = .medium
case .accessoryCircular: self = .circular
case .accessoryRectangular: self = .rectangular
case .accessoryInline: self = .inline
default: self = .large
}
}
}
/// The widget's ground: the content on its background, through the container
/// background on iOS 17 (which the system can tint and remove), the plain way before.
struct TrekContainer<Content: View, Background: View>: View {
@Environment(\.widgetFamily) private var family
let entry: NextTripEntry
let link: URL?
@ViewBuilder var content: () -> Content
@ViewBuilder var background: () -> Background
var body: some View {
content
.widgetURL(entry.snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) })
.modifier(WidgetBackground())
}
@ViewBuilder
private var content: some View {
if let snapshot = entry.snapshot, let trip = snapshot.trip {
VStack(alignment: .leading, spacing: 4) {
Text(snapshot.today == nil ? snapshot.labels.nextTrip : snapshot.labels.today)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(trip.title)
.font(.headline)
.lineLimit(2)
if let today = snapshot.today {
Text(today.dayLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
if family != .systemSmall {
stops(today.stops, nothing: snapshot.labels.nothingPlanned)
}
} else if let days = snapshot.daysUntilStart(from: entry.date), days > 0 {
Spacer(minLength: 0)
Text(days == 1 ? snapshot.labels.tomorrow : snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: String(days)))
.font(.title3.weight(.bold))
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading, spacing: 6) {
Text("TREK").font(.headline)
Text(entry.snapshot?.labels.noTrip ?? "")
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder
private func stops(_ stops: [WidgetSnapshot.Stop], nothing: String) -> some View {
if stops.isEmpty {
Text(nothing).font(.caption).foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(stops.prefix(3).enumerated()), id: \.offset) { _, stop in
HStack(spacing: 6) {
Text(stop.time ?? "")
.font(.caption2.monospacedDigit().weight(.semibold))
.foregroundStyle(.secondary)
.frame(minWidth: 36, alignment: .leading)
Text(stop.name).font(.caption).lineLimit(1)
}
let accessory = TrekFamily(family).isAccessory
Group {
if #available(iOSApplicationExtension 17.0, *) {
content().containerBackground(for: .widget) {
if accessory { Color.clear } else { background() }
}
} else {
content().padding(accessory ? 0 : 16).background(accessory ? nil : background())
}
}
.widgetURL(link)
}
}
/// The system background on iOS 17 and later, the plain one before.
struct WidgetBackground: ViewModifier {
func body(content: Content) -> some View {
if #available(iOSApplicationExtension 17.0, *) {
content.containerBackground(.fill.tertiary, for: .widget)
} else {
content.padding()
}
}
}
// MARK: - Trip
struct NextTripWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "NextTrip", provider: NextTripProvider()) { entry in
NextTripWidgetView(entry: entry)
// The kind stays "NextTrip" so widgets placed before the redesign keep working.
AppIntentConfiguration(kind: "NextTrip", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TripWidgetView(entry: entry)
}
.configurationDisplayName("TREK")
.description("Your next trip, and today's plan while you travel.")
.supportedFamilies([.systemSmall, .systemMedium])
.configurationDisplayName("widget.trip.name")
.description("widget.trip.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
struct TripWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: TrekEntry
var body: some View {
let snapshot = entry.snapshot
TrekContainer(link: snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) }) {
TripContent(snapshot: snapshot, family: TrekFamily(family), now: entry.date)
} background: {
if snapshot?.trip != nil { TripBackdrop(trip: snapshot?.trip) } else { TrekPaper() }
}
}
}
// MARK: - Today
struct TodayWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: "TripToday", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TrekContainer(link: entry.snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) }) {
TodayFamilyView(entry: entry)
} background: {
TrekPaper()
}
}
.configurationDisplayName("widget.today.name")
.description("widget.today.description")
.supportedFamilies([.systemMedium, .systemLarge])
}
}
private struct TodayFamilyView: View {
@Environment(\.widgetFamily) private var family
let entry: TrekEntry
var body: some View { TodayContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
}
// MARK: - Bookings
struct BookingsWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: "TripBookings", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TrekContainer(link: entry.snapshot?.bookings?.first.flatMap { WidgetSnapshot.link($0.path) }) {
BookingsFamilyView(entry: entry)
} background: {
TrekPaper()
}
}
.configurationDisplayName("widget.bookings.name")
.description("widget.bookings.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, .accessoryInline])
}
}
private struct BookingsFamilyView: View {
@Environment(\.widgetFamily) private var family
let entry: TrekEntry
var body: some View { BookingsContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
}
// MARK: - To-dos
struct TodosWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: "TripTodos", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TrekContainer(link: entry.snapshot?.todos.flatMap { WidgetSnapshot.link($0.path) }) {
TodosFamilyView(entry: entry)
} background: {
TrekPaper()
}
}
.configurationDisplayName("widget.todos.name")
.description("widget.todos.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular])
}
}
private struct TodosFamilyView: View {
@Environment(\.widgetFamily) private var family
let entry: TrekEntry
var body: some View { TodosContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
}
// MARK: - Packing
struct PackingWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: "TripPacking", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TrekContainer(link: entry.snapshot?.packing.flatMap { WidgetSnapshot.link($0.path) }) {
PackingFamilyView(entry: entry)
} background: {
TrekPaper()
}
}
.configurationDisplayName("widget.packing.name")
.description("widget.packing.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular])
}
}
private struct PackingFamilyView: View {
@Environment(\.widgetFamily) private var family
let entry: TrekEntry
var body: some View { PackingContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
}
// MARK: - Atlas
struct AtlasWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "TripAtlas", provider: TrekProvider()) { entry in
TrekContainer(link: WidgetSnapshot.link("/atlas")) {
AtlasFamilyView(entry: entry)
} background: {
TrekPaper(contours: false)
}
}
.configurationDisplayName("widget.atlas.name")
.description("widget.atlas.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
private struct AtlasFamilyView: View {
@Environment(\.widgetFamily) private var family
let entry: TrekEntry
var body: some View { AtlasContent(snapshot: entry.snapshot, family: TrekFamily(family)) }
}
// MARK: - Travel group
struct CollabWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: "TripCollab", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TrekContainer(link: entry.snapshot?.collab.flatMap { WidgetSnapshot.link($0.path) }) {
CollabFamilyView(entry: entry)
} background: {
TrekPaper()
}
}
.configurationDisplayName("widget.collab.name")
.description("widget.collab.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular])
}
}
private struct CollabFamilyView: View {
@Environment(\.widgetFamily) private var family
let entry: TrekEntry
var body: some View { CollabContent(snapshot: entry.snapshot, family: TrekFamily(family)) }
}
@@ -0,0 +1,209 @@
import SwiftUI
/// The packing list: how much is in the bags, each bag against its weight
/// limit, and what is still missing, with a nudge when departure is close.
struct PackingContent: View {
let snapshot: WidgetSnapshot?
let family: TrekFamily
let now: Date
@Environment(\.colorScheme) private var scheme
var body: some View {
let packing = snapshot?.packing
let labels = snapshot?.labels
if family.isAccessory {
accessory(packing, labels)
} else if let packing, let labels, packing.total > 0 {
let missing = packing.total - packing.packed
switch family {
case .small:
VStack(alignment: .leading, spacing: 0) {
header(labels)
Spacer(minLength: 0)
HStack {
Spacer(minLength: 0)
ring(packing, size: 88)
Spacer(minLength: 0)
}
Spacer(minLength: 0)
Text("\(missing) \(labels.missing ?? "")")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Trek.muted(scheme))
.lineLimit(1)
}
case .medium:
HStack(spacing: 14) {
ring(packing, size: 104)
VStack(alignment: .leading, spacing: 6) {
header(labels)
missingList(Array((packing.missing ?? []).prefix(4)))
Spacer(minLength: 0)
}
}
default:
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 14) {
ring(packing, size: 86)
VStack(alignment: .leading, spacing: 3) {
header(labels)
if let trip = snapshot?.trip {
Text(trip.title).font(.system(size: 15, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
}
Text("\(missing) \(labels.missing ?? "")")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Trek.muted(scheme))
}
Spacer(minLength: 0)
}
VStack(spacing: 7) {
ForEach(Array((packing.bags ?? []).prefix(3).enumerated()), id: \.offset) { _, bag in
BagCard(bag: bag, scheme: scheme)
}
}
missingGrid(Array((packing.missing ?? []).prefix(8)))
Spacer(minLength: 0)
}
}
} else {
VStack(alignment: .leading, spacing: 6) {
header(labels)
Spacer(minLength: 0)
Image(systemName: "suitcase.rolling.fill").font(.system(size: 32)).foregroundStyle(Trek.muted(scheme).opacity(0.5))
Text(labels?.noTodos ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
}
/// The days to departure while it is two or fewer away.
private var departureSoon: Int? {
guard snapshot?.today == nil, let days = snapshot?.daysUntilStart(from: now), (0...2).contains(days) else { return nil }
return days
}
private func header(_ labels: WidgetSnapshot.Labels?) -> some View {
HStack(spacing: 6) {
Text(labels?.packing ?? "").trekEyebrow(scheme)
Spacer(minLength: 0)
if let days = departureSoon, let labels {
Text(days == 0 ? (labels.todayShort ?? labels.today) : days == 1 ? labels.tomorrow : labels.inDays.replacingOccurrences(of: "{n}", with: "\(days)"))
.font(.system(size: 10, weight: .heavy))
.foregroundStyle(.white)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(Capsule().fill(Trek.pending))
}
}
}
private func ring(_ packing: WidgetSnapshot.Packing, size: CGFloat) -> some View {
let share = Double(packing.packed) / Double(max(packing.total, 1))
return ProgressRing(value: share, lineWidth: size * 0.1, colors: [Color(hex: 0x2FA37A), Color(hex: 0x7BC96F)], trackColor: Trek.track(scheme)) {
VStack(spacing: -1) {
Image(systemName: "suitcase.rolling.fill").font(.system(size: size * 0.16, weight: .semibold)).foregroundStyle(Trek.confirmed)
Text("\(Int((share * 100).rounded()))%").font(Trek.number(size * 0.22, .heavy)).foregroundStyle(Trek.ink(scheme))
}
}
.frame(width: size, height: size)
}
private func missingList(_ items: [WidgetSnapshot.Packing.Item]) -> some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.stroke(Color(css: item.color) ?? Trek.faint(scheme), lineWidth: 1.7)
.frame(width: 13, height: 13)
Text(item.name).font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
}
}
}
}
private func missingGrid(_ items: [WidgetSnapshot.Packing.Item]) -> some View {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], alignment: .leading, spacing: 7) {
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
HStack(spacing: 7) {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.stroke(Color(css: item.color) ?? Trek.faint(scheme), lineWidth: 1.6)
.frame(width: 12, height: 12)
Text(item.name).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
}
}
}
}
@ViewBuilder
private func accessory(_ packing: WidgetSnapshot.Packing?, _ labels: WidgetSnapshot.Labels?) -> some View {
let total = packing?.total ?? 0
let packed = packing?.packed ?? 0
switch family {
case .circular:
ZStack {
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
Circle().trim(from: 0, to: total > 0 ? Double(packed) / Double(total) : 0)
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: -2) {
Image(systemName: "suitcase.rolling.fill").font(.system(size: 9, weight: .bold))
Text("\(total - packed)").font(Trek.number(18, .heavy))
}
}
.padding(3)
default:
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: "suitcase.rolling.fill").font(.system(size: 11, weight: .bold))
Text("\(labels?.packing ?? "") \(packed)/\(total)").font(.system(size: 12, weight: .bold))
}
ForEach(Array((packing?.missing ?? []).prefix(2).enumerated()), id: \.offset) { _, item in
Text("\(Image(systemName: "square")) \(item.name)").font(.system(size: 12, weight: .medium)).lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
/// A bag in its colour: how full, and its weight against the limit.
struct BagCard: View {
let bag: WidgetSnapshot.Packing.Bag
let scheme: ColorScheme
var body: some View {
let color = Color(css: bag.color) ?? Trek.info
let share = bag.limit.map { Double(bag.grams) / Double(max($0, 1)) }
HStack(spacing: 10) {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(LinearGradient(colors: [color, color.opacity(0.75)], startPoint: .top, endPoint: .bottom))
.frame(width: 30, height: 30)
.overlay(Image(systemName: "bag.fill").font(.system(size: 13, weight: .semibold)).foregroundStyle(.white))
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(bag.name).font(.system(size: 12.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
Spacer(minLength: 4)
Text(weight(bag)).font(Trek.number(11.5, .heavy)).foregroundStyle((share ?? 0) > 1 ? Trek.danger : Trek.muted(scheme))
}
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Trek.track(scheme))
Capsule()
.fill((share ?? 0) > 1 ? Trek.danger : color)
.frame(width: max(5, geo.size.width * CGFloat(min(share ?? Double(bag.packed) / Double(max(bag.total, 1)), 1))))
}
}
.frame(height: 5)
}
}
.padding(8)
.background(RoundedRectangle(cornerRadius: 13, style: .continuous).fill(Trek.card(scheme)))
.overlay(RoundedRectangle(cornerRadius: 13, style: .continuous).stroke(Trek.cardBorder(scheme), lineWidth: 0.7))
}
/// "14.2 / 23 kg", or the packed count when the bag has no limit.
private func weight(_ bag: WidgetSnapshot.Packing.Bag) -> String {
let kg = { (grams: Int) in String(format: "%.1f", Double(grams) / 1000) }
if let limit = bag.limit { return "\(kg(bag.grams)) / \(kg(limit)) kg" }
return "\(bag.packed)/\(bag.total)"
}
}
@@ -1,12 +1,19 @@
import SwiftUI
import WidgetKit
/// The TREK widgets: the next trip on the home screen, and the travel day as a
/// Live Activity.
/// The TREK widgets: the trip, today's plan, the bookings ahead, the to-dos,
/// the packing list, the Atlas and the travel group on the home and lock
/// screen, and the travel day as a Live Activity.
@main
struct TREKWidgetsBundle: WidgetBundle {
var body: some Widget {
NextTripWidget()
TodayWidget()
BookingsWidget()
TodosWidget()
PackingWidget()
AtlasWidget()
CollabWidget()
TripDayLiveActivity()
}
}
@@ -0,0 +1,144 @@
import SwiftUI
/// Today's plan on paper: the day, the weather, and the stops as a timeline
/// with a now line, so a glance says what is next and what is behind.
struct TodayContent: View {
let snapshot: WidgetSnapshot?
let family: TrekFamily
let now: Date
@Environment(\.colorScheme) private var scheme
var body: some View {
let nowMinutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
if let snapshot, let trip = snapshot.trip, let today = snapshot.today {
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)
}
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)
}
}
}
} 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)
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)
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))
}
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: Trek.ink(scheme)).frame(height: 24)
}
} else {
EmptyTrip(text: snapshot?.labels.noTrip ?? "", family: family)
}
}
}
/// Today's number in a dark tile with the trip's length under it.
struct DayBadge: View {
let index: Int
let total: Int
@Environment(\.colorScheme) private var scheme
var body: some View {
VStack(spacing: -2) {
Text("\(index)").font(Trek.number(19, .heavy))
Text("/\(total)").font(Trek.number(9, .bold)).opacity(0.7)
}
.foregroundStyle(scheme == .dark ? Color(hex: 0x101013) : .white)
.frame(width: 40, height: 40)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Trek.ink(scheme))
)
.shadow(color: .black.opacity(scheme == .dark ? 0 : 0.18), radius: 5, y: 2)
}
}
/// The weather on paper: symbol, temperature, and the day's range and rain.
struct PaperWeather: View {
let weather: WidgetSnapshot.Weather
@Environment(\.colorScheme) private var scheme
var body: some View {
HStack(spacing: 6) {
Image(systemName: Trek.weatherSymbol(weather.main))
.symbolRenderingMode(.multicolor)
.font(.system(size: 24))
VStack(alignment: .leading, spacing: 0) {
Text("\(weather.temp)°").font(Trek.number(20, .heavy)).foregroundStyle(Trek.ink(scheme))
HStack(spacing: 4) {
if let high = weather.high, let low = weather.low { Text("\(high)°/\(low)°") }
if let rain = weather.rain, rain > 0 {
Text("\(Image(systemName: "drop.fill"))\(rain)%")
}
}
.font(Trek.number(10, .semibold))
.foregroundStyle(Trek.muted(scheme))
}
}
}
}
/// How much of the day is behind: a bar of the stops passed.
struct DayProgress: View {
let done: Int
let total: Int
let scheme: ColorScheme
let label: String?
var body: some View {
HStack(spacing: 8) {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Trek.track(scheme))
Capsule()
.fill(LinearGradient(colors: [Trek.confirmed.opacity(0.8), Trek.confirmed], startPoint: .leading, endPoint: .trailing))
.frame(width: total > 0 ? max(6, geo.size.width * CGFloat(done) / CGFloat(total)) : 0)
}
}
.frame(height: 6)
Text(done >= total && total > 0 ? (label ?? "\(done)/\(total)") : "\(done)/\(total)")
.font(Trek.number(11, .bold))
.foregroundStyle(Trek.muted(scheme))
.lineLimit(1)
.fixedSize()
}
}
}
@@ -0,0 +1,220 @@
import SwiftUI
/// The trip's open to-dos and the packing list: how far along both are, as
/// rings and a bar, and the to-dos that matter most, by due date and priority.
struct TodosContent: View {
let snapshot: WidgetSnapshot?
let family: TrekFamily
let now: Date
@Environment(\.colorScheme) private var scheme
var body: some View {
let todos = snapshot?.todos
let labels = snapshot?.labels
if family.isAccessory {
accessory(todos, labels)
} else if let labels, let todos, todos.total > 0 {
switch family {
case .small: small(todos, labels)
case .medium:
HStack(spacing: 14) {
rings(todos, labels, size: 104)
VStack(alignment: .leading, spacing: 0) {
header(labels, todos)
items(Array(todos.items.prefix(3)), labels).padding(.top, 6)
Spacer(minLength: 0)
}
}
default:
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .center, spacing: 14) {
rings(todos, labels, size: 96)
VStack(alignment: .leading, spacing: 8) {
header(labels, todos)
}
}
Divider().overlay(Trek.track(scheme)).padding(.vertical, 10)
items(Array(todos.items.prefix(7)), labels)
Spacer(minLength: 8)
legend(todos, labels)
}
}
} else {
VStack(alignment: .leading, spacing: 6) {
Text(labels?.todos ?? "").trekEyebrow(scheme)
Spacer(minLength: 0)
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 34))
.foregroundStyle(Trek.confirmed.opacity(0.85))
Text(labels?.noTodos ?? "").font(.system(size: 14, weight: .bold)).foregroundStyle(Trek.ink(scheme))
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
}
private func small(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
Text(labels.todos ?? "").trekEyebrow(scheme)
Spacer(minLength: 0)
}
Spacer(minLength: 0)
HStack {
Spacer(minLength: 0)
rings(todos, labels, size: 92)
Spacer(minLength: 0)
}
Spacer(minLength: 0)
if let first = todos.items.first {
HStack(spacing: 5) {
Circle().stroke(Trek.priority(first.priority) ?? Trek.faint(scheme), lineWidth: 1.6).frame(width: 10, height: 10)
Text(first.name).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
}
}
}
}
/// The share of the to-dos done as a ring, the open ones in the middle.
private func rings(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels, size: CGFloat) -> some View {
let done = todos.total > 0 ? Double(todos.total - todos.open) / Double(todos.total) : 1
let width = size * 0.1
return ZStack {
ProgressRing(value: done, lineWidth: width, colors: [Color(hex: 0x4A7DDB), Color(hex: 0x6A5BE0)], trackColor: Trek.track(scheme)) { EmptyView() }
VStack(spacing: -2) {
Text("\(todos.open)").font(Trek.number(size * 0.3, .heavy)).foregroundStyle(Trek.ink(scheme))
Image(systemName: "checklist").font(.system(size: size * 0.11, weight: .bold)).foregroundStyle(Trek.muted(scheme))
}
}
.frame(width: size, height: size)
}
/// What the two rings stand for, with their counts.
private func legend(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels) -> some View {
HStack(spacing: 8) {
LegendPill(color: Color(hex: 0x5A6CDE), symbol: "checklist", text: labels.todos ?? "", count: "\(todos.total - todos.open)/\(todos.total)")
Spacer(minLength: 0)
}
}
private func header(_ labels: WidgetSnapshot.Labels, _ todos: WidgetSnapshot.Todos) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(labels.todos ?? "").trekEyebrow(scheme)
if let trip = snapshot?.trip {
Text(trip.title).font(.system(size: 15, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
}
}
}
private func items(_ items: [WidgetSnapshot.Todo], _ labels: WidgetSnapshot.Labels) -> some View {
VStack(alignment: .leading, spacing: 7) {
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
HStack(spacing: 8) {
Circle()
.stroke(Trek.priority(item.priority) ?? Trek.faint(scheme), lineWidth: 1.7)
.frame(width: 14, height: 14)
Text(item.name)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Trek.ink(scheme))
.lineLimit(1)
Spacer(minLength: 4)
if let due = RelativeDay.label(item.due, now: now, labels: labels) {
Text(due)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(overdue(item.due) ? Trek.danger : Trek.muted(scheme))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(overdue(item.due) ? Trek.danger.opacity(0.12) : Trek.card(scheme)))
}
}
}
}
}
private func overdue(_ due: String?) -> Bool {
guard let due, let date = WidgetSnapshot.day(due), let days = WidgetSnapshot.daysBetween(now, date) else { return false }
return days <= 0
}
@ViewBuilder
private func accessory(_ todos: WidgetSnapshot.Todos?, _ labels: WidgetSnapshot.Labels?) -> some View {
let open = todos?.open ?? 0
let total = todos?.total ?? 0
switch family {
case .circular:
ZStack {
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
Circle().trim(from: 0, to: total > 0 ? Double(total - open) / Double(total) : 1)
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: -2) {
Image(systemName: "checklist").font(.system(size: 9, weight: .bold))
Text("\(open)").font(Trek.number(18, .heavy))
}
}
.padding(3)
case .rectangular:
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: "checklist").font(.system(size: 11, weight: .bold))
Text("\(labels?.todos ?? "") \(open)/\(total)").font(.system(size: 12, weight: .bold))
}
ForEach(Array((todos?.items ?? []).prefix(2).enumerated()), id: \.offset) { _, item in
Text("\(Image(systemName: "circle")) \(item.name)").font(.system(size: 12, weight: .medium)).lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
default:
Text("\(Image(systemName: "checklist")) \(open) \(labels?.todos ?? "")")
}
}
}
/// A ring's colour, what it counts, and how far it is.
struct LegendPill: View {
let color: Color
let symbol: String
let text: String
let count: String
@Environment(\.colorScheme) private var scheme
var body: some View {
HStack(spacing: 5) {
Circle().fill(color).frame(width: 7, height: 7)
Image(systemName: symbol).font(.system(size: 10, weight: .bold)).foregroundStyle(color)
Text(text).font(.system(size: 11, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
Text(count).font(Trek.number(11, .heavy)).foregroundStyle(Trek.muted(scheme))
}
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(Capsule().fill(Trek.card(scheme)))
.overlay(Capsule().stroke(Trek.cardBorder(scheme), lineWidth: 0.6))
}
}
/// The packing list as a bar with the suitcase and the count.
struct PackingBar: View {
let packing: WidgetSnapshot.Packing
let labels: WidgetSnapshot.Labels
@Environment(\.colorScheme) private var scheme
var body: some View {
let share = packing.total > 0 ? Double(packing.packed) / Double(packing.total) : 1
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 5) {
Image(systemName: "suitcase.rolling.fill").font(.system(size: 11, weight: .bold)).foregroundStyle(Trek.confirmed)
Text(labels.packing ?? "").font(.system(size: 11.5, weight: .bold)).foregroundStyle(Trek.ink(scheme))
Spacer(minLength: 0)
Text("\(packing.packed)/\(packing.total)").font(Trek.number(11.5, .heavy)).foregroundStyle(Trek.muted(scheme))
}
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Trek.track(scheme))
Capsule()
.fill(LinearGradient(colors: [Color(hex: 0x2FA37A), Color(hex: 0x7BC96F)], startPoint: .leading, endPoint: .trailing))
.frame(width: max(6, geo.size.width * CGFloat(share)))
}
}
.frame(height: 6)
}
}
}
+145
View File
@@ -0,0 +1,145 @@
import Foundation
/// The sizes the widget views draw for. Their own enum rather than WidgetKit's,
/// 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
var isAccessory: Bool { self == .circular || self == .rectangular || self == .inline }
/// The point size of the family on a 6.1" iPhone, for the renderer.
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 .circular: return CGSize(width: 76, height: 76)
case .rectangular: return CGSize(width: 172, height: 76)
case .inline: return CGSize(width: 250, height: 26)
}
}
}
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.
static func sample(running: Bool, now: Date = Date()) -> WidgetSnapshot {
let calendar = Calendar.current
let iso: (Int) -> String = { offset in
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: calendar.date(byAdding: .day, value: offset, to: now) ?? now)
}
let start = running ? -2 : 12
var snapshot = WidgetSnapshot(
trip: Trip(
title: "Autumn in Kyoto", start: iso(start), end: iso(start + 8), path: "/trips/1",
place: "Kyoto, Japan", cover: nil, dayCount: 9, placeCount: 34, memberCount: 3
),
today: running
? Today(
dayLabel: "Day 3 of 9",
stops: [
Stop(time: "08:30", name: "Fushimi Inari Taisha", minutes: 510, color: "#D6273B", symbol: "building.columns.fill"),
Stop(time: "11:00", name: "Nishiki Market", minutes: 660, color: "#E8A13A", symbol: "basket.fill"),
Stop(time: "13:30", name: "Kiyomizu-dera", minutes: 810, color: "#2FA37A", symbol: "leaf.fill"),
Stop(time: "16:00", name: "Gion walk", minutes: 960, color: "#4A7DDB", symbol: "figure.walk"),
Stop(time: "19:30", name: "Dinner at Pontocho", minutes: 1170, color: "#8E6CD9", symbol: "fork.knife"),
],
index: 3, total: 9
)
: nil,
weather: Weather(temp: 18, high: 21, low: 11, main: running ? "Clear" : "Clouds", label: "Mostly sunny", place: "Kyoto", rain: 10),
bookings: [
Booking(title: "LH 716", kind: "flight", date: iso(running ? 6 : 12), time: "13:05", detail: "FRA → HND", confirmed: true, path: "/trips/1"),
Booking(title: "Shinkansen Nozomi 21", kind: "train", date: iso(running ? 0 : 13), time: "09:12", detail: "Tokyo → Kyoto", confirmed: true, path: "/trips/1"),
Booking(title: "Hotel Kanra Kyoto", kind: "hotel", date: iso(running ? 0 : 13), time: "15:00", detail: "6 nights", confirmed: true, path: "/trips/1"),
Booking(title: "Kaiseki at Kikunoi", kind: "restaurant", date: iso(running ? 1 : 15), time: "19:00", detail: "Higashiyama", confirmed: false, path: "/trips/1"),
Booking(title: "Tea ceremony", kind: "event", date: iso(running ? 2 : 16), time: "10:30", detail: "Camellia Flower", confirmed: true, path: "/trips/1"),
],
todos: Todos(
open: 4, total: 11,
items: [
Todo(name: "Order JR Pass", due: iso(running ? 0 : 3), priority: 3),
Todo(name: "Book Ghibli Museum tickets", due: iso(running ? 1 : 5), priority: 2),
Todo(name: "Get yen in cash", due: nil, priority: 1),
Todo(name: "Download offline maps", due: nil, priority: 0),
],
path: "/trips/1/todos"
),
packing: Packing(packed: 23, total: 31, path: "/trips/1/packing"),
labels: Labels(
nextTrip: "Next trip", today: "Today", noTrip: "No trip coming up. Plan the next one in TREK.",
inDays: "in {n} days", tomorrow: "Tomorrow", nothingPlanned: "Nothing planned today",
days: "days", dayOf: "Day {n} of {m}", bookings: "Coming up", noBookings: "No bookings ahead",
todos: "To-dos", noTodos: "All done", packing: "Packing", packed: "packed",
confirmed: "Confirmed", pending: "Pending", todayShort: "Today", tomorrowShort: "Tomorrow",
now: "Now", places: "places", travellers: "travellers", allDone: "Day done", openTrek: "Open TREK",
missing: "still to pack", countries: "countries", world: "of the world", group: "Travel group",
poll: "Poll", votes: "votes", noMessages: "No messages yet", departure: "Departure"
)
)
snapshot.packing?.bags = [
.init(name: "Suitcase", color: "#4A7DDB", grams: 14_200, limit: 23_000, packed: 15, total: 19),
.init(name: "Daypack", color: "#2FA37A", grams: 4_100, limit: 8_000, packed: 8, total: 12),
]
snapshot.packing?.missing = [
.init(name: "Rain jacket", category: "Clothes", color: "#4A7DDB"),
.init(name: "Power bank", category: "Tech", color: "#2FA37A"),
.init(name: "Passport", category: "Documents", color: "#2FA37A"),
.init(name: "Sandals", category: "Shoes", color: "#4A7DDB"),
.init(name: "Travel adapter", category: "Tech", color: "#2FA37A"),
.init(name: "Sunscreen", category: "Toiletries", color: nil),
.init(name: "Earplugs", category: nil, color: nil),
.init(name: "Book", category: nil, color: nil),
]
snapshot.atlas = Atlas(
count: 23,
codes: ["DE", "AT", "CH", "FR", "IT", "ES", "PT", "NL", "BE", "DK", "NO", "SE", "GB", "IE", "GR", "HR",
"JP", "TH", "VN", "US", "CA", "MA", "AU"],
recent: [
.init(code: "JP", name: "Japan"), .init(code: "NO", name: "Norway"),
.init(code: "PT", name: "Portugal"), .init(code: "MA", name: "Morocco"),
],
continents: [
.init(name: "Europe", count: 16), .init(name: "Asia", count: 3), .init(name: "North America", count: 2),
.init(name: "Africa", count: 1), .init(name: "Oceania", count: 1),
],
trips: 41, days: 318
)
snapshot.collab = Collab(
members: [
.init(name: "Maurice", initials: "MA"), .init(name: "Julien", initials: "JU"),
.init(name: "Elmo", initials: "EL"), .init(name: "Sara", initials: "SA"),
],
messages: [
.init(author: "Julien", initials: "JU", text: "Got the JR Pass vouchers, all four of us", at: nil, mine: false),
.init(author: "Sara", initials: "SA", text: "Can we push dinner to 20:00? Train is late", at: nil, mine: false),
.init(author: "Maurice", initials: "MA", text: "Sure, I moved the table at Pontocho", at: nil, mine: true),
],
poll: .init(
question: "Day trip on Saturday?",
options: [.init(label: "Nara", votes: 3), .init(label: "Uji", votes: 1), .init(label: "Kurama", votes: 0)],
voters: 4
),
path: "/trips/1?tab=collab"
)
return snapshot
}
/// An empty state: no trip at all.
static var sampleEmpty: WidgetSnapshot {
var snapshot = sample(running: false)
snapshot.trip = nil
snapshot.today = nil
snapshot.weather = nil
snapshot.bookings = []
snapshot.todos = Todos(open: 0, total: 0, items: [], path: "/")
snapshot.packing = nil
snapshot.atlas = Atlas(count: 0, codes: [], recent: [], continents: [])
snapshot.collab = nil
return snapshot
}
}
+411
View File
@@ -0,0 +1,411 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
/// The widgets' look, from the app's phone tokens (client/src/mobile/mobile.css):
/// warm paper by day, near black by night, one ink colour, the status colours,
/// and the topographic lines and route arcs that stand for travel throughout.
enum Trek {
static func ink(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0xF5F5F7) : Color(hex: 0x101013) }
static func muted(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0x9C9CA3) : Color(hex: 0x68686F) }
static func faint(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0x6E6E76) : Color(hex: 0x9A9AA1) }
static func card(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.07) : Color.white.opacity(0.62) }
static func cardBorder(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.09) : Color.white.opacity(0.9) }
static func track(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.12) : Color(hex: 0x101013).opacity(0.09) }
static let confirmed = Color(hex: 0x2FA37A)
static let pending = Color(hex: 0xE8A13A)
static let info = Color(hex: 0x4A7DDB)
static let danger = Color(hex: 0xD6273B)
/// A stable number for a text (FNV-1a), unlike hashValue, which changes per launch.
static func seed(_ text: String) -> UInt64 {
var hash: UInt64 = 1469598103934665603
for byte in text.utf8 { hash = (hash ^ UInt64(byte)) &* 1099511628211 }
return hash
}
/// Two colours of the trip's own, from its title: the same trip always gets
/// the same sky, and two trips side by side rarely the same one.
static func tripHues(_ title: String) -> (Color, Color) {
let base = Double(seed(title) % 360) / 360
let first = Color(hue: base, saturation: 0.62, brightness: 0.78)
let second = Color(hue: (base + 0.11).truncatingRemainder(dividingBy: 1), saturation: 0.7, brightness: 0.42)
return (first, second)
}
static func number(_ size: CGFloat, _ weight: Font.Weight = .bold) -> Font {
.system(size: size, weight: weight, design: .rounded).monospacedDigit()
}
}
private struct Eyebrow: ViewModifier {
let scheme: ColorScheme
func body(content: Content) -> some View {
content
.font(.system(size: 10.5, weight: .bold))
.tracking(0.6)
.textCase(.uppercase)
.foregroundStyle(Trek.muted(scheme))
.lineLimit(1)
}
}
extension View {
func trekEyebrow(_ scheme: ColorScheme) -> some View { modifier(Eyebrow(scheme: scheme)) }
}
extension Color {
init(hex: UInt32, opacity: Double = 1) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255,
opacity: opacity
)
}
/// "#rrggbb" or "rrggbb"; nil for anything else.
init?(css: String?) {
guard let css else { return nil }
let hex = css.hasPrefix("#") ? String(css.dropFirst()) : css
guard hex.count == 6, let value = UInt32(hex, radix: 16) else { return nil }
self.init(hex: value)
}
}
// MARK: - Backgrounds
/// The paper the widgets sit on: the app's screen gradient, with a faint
/// contour map over it so even an empty widget reads as TREK.
struct TrekPaper: View {
@Environment(\.colorScheme) private var scheme
var contours = true
var body: some View {
ZStack {
if scheme == .dark {
RadialGradient(
colors: [Color(hex: 0x1B1B21), Color(hex: 0x0C0C0F), Color(hex: 0x070709)],
center: UnitPoint(x: 0.5, y: -0.1), startRadius: 0, endRadius: 360
)
} else {
RadialGradient(
colors: [Color(hex: 0xFDFCF9), Color(hex: 0xF1EFEA), Color(hex: 0xE7E5DF)],
center: UnitPoint(x: 0.5, y: -0.1), startRadius: 0, endRadius: 360
)
}
if contours {
ContourLines(seed: 7, rings: 9)
.stroke(Trek.ink(scheme).opacity(scheme == .dark ? 0.05 : 0.045), lineWidth: 0.8)
}
}
}
}
/// A trip's own backdrop: its cover photo when the app sent one, else its two
/// colours as a sky with contour lines, both darkened towards the text.
struct TripBackdrop: View {
let trip: WidgetSnapshot.Trip?
var shade: Double = 0.62
var body: some View {
ZStack {
if let image = coverImage {
image.resizable().scaledToFill()
} else {
let (first, second) = Trek.tripHues(trip?.title ?? "TREK")
LinearGradient(colors: [first, second], startPoint: .topLeading, endPoint: .bottomTrailing)
RadialGradient(colors: [Color.white.opacity(0.28), .clear], center: UnitPoint(x: 0.85, y: 0.05), startRadius: 0, endRadius: 220)
ContourLines(seed: Trek.seed(trip?.title ?? "") % 997, rings: 11)
.stroke(Color.white.opacity(0.14), lineWidth: 0.9)
}
LinearGradient(
colors: [Color.black.opacity(shade * 0.35), Color.black.opacity(0.05), Color.black.opacity(shade)],
startPoint: .top, endPoint: .bottom
)
}
}
private var coverImage: Image? {
guard let base64 = trip?.cover, let data = Data(base64Encoded: base64) else { return nil }
#if canImport(UIKit)
guard let image = UIImage(data: data) else { return nil }
return Image(uiImage: image)
#elseif canImport(AppKit)
guard let image = NSImage(data: data) else { return nil }
return Image(nsImage: image)
#else
return nil
#endif
}
}
/// Wobbly closed rings around an off-centre summit: a contour map in a few
/// lines, the same for the same seed.
struct ContourLines: Shape {
var seed: UInt64
var rings: Int
func path(in rect: CGRect) -> Path {
var path = Path()
let phaseA = CGFloat(seed % 13) * 0.7
let phaseB = CGFloat(seed % 7) * 1.3
let offset: CGFloat = 0.62 + CGFloat(seed % 5) * 0.04
let centerX: CGFloat = rect.minX + rect.width * offset
let centerY: CGFloat = rect.minY + rect.height * 0.32
let maxRadius: CGFloat = max(rect.width, rect.height) * 1.05
let count = max(rings, 1)
let steps = 72
for ring in 1...count {
let base: CGFloat = maxRadius * CGFloat(ring) / CGFloat(count)
let twist: CGFloat = CGFloat(ring) * 0.35
for step in 0...steps {
let angle: CGFloat = CGFloat(step) / CGFloat(steps) * 2 * .pi
let first: CGFloat = 0.09 * sin(angle * 3 + phaseA + twist)
let second: CGFloat = 0.05 * sin(angle * 5 + phaseB)
let radius: CGFloat = base * (1 + first + second)
let point = CGPoint(x: centerX + cos(angle) * radius * 1.15, y: centerY + sin(angle) * radius * 0.82)
if step == 0 { path.move(to: point) } else { path.addLine(to: point) }
}
path.closeSubpath()
}
return path
}
}
/// A dotted flight path from home to the trip, with the plane on it as far as
/// the countdown has come.
struct RouteArc: View {
/// 0 at home, 1 arrived.
let progress: Double
let color: Color
var body: some View {
GeometryReader { geo in
let start = CGPoint(x: 6, y: geo.size.height - 6)
let end = CGPoint(x: geo.size.width - 6, y: geo.size.height - 6)
let control = CGPoint(x: geo.size.width / 2, y: geo.size.height * -0.55)
ZStack {
Path { path in
path.move(to: start)
path.addQuadCurve(to: end, control: control)
}
.stroke(color.opacity(0.45), style: StrokeStyle(lineWidth: 1.4, lineCap: .round, dash: [1.5, 4.5]))
Path { path in
path.move(to: start)
path.addQuadCurve(to: end, control: control)
}
.trim(from: 0, to: progress)
.stroke(color, style: StrokeStyle(lineWidth: 1.8, lineCap: .round))
Circle().fill(color).frame(width: 6, height: 6).position(start)
Circle().stroke(color, lineWidth: 1.6).frame(width: 9, height: 9).position(end)
let point = quadPoint(start, control, end, progress)
let tangent = quadTangent(start, control, end, progress)
Image(systemName: "airplane")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(color)
.rotationEffect(.radians(Double(atan2(tangent.y, tangent.x))))
.position(point)
}
}
}
private func quadPoint(_ a: CGPoint, _ c: CGPoint, _ b: CGPoint, _ progress: Double) -> CGPoint {
let t = CGFloat(progress)
let u: CGFloat = 1 - t
let x: CGFloat = u * u * a.x + 2 * u * t * c.x + t * t * b.x
let y: CGFloat = u * u * a.y + 2 * u * t * c.y + t * t * b.y
return CGPoint(x: x, y: y)
}
private func quadTangent(_ a: CGPoint, _ c: CGPoint, _ b: CGPoint, _ progress: Double) -> CGPoint {
let t = CGFloat(progress)
let x: CGFloat = 2 * (1 - t) * (c.x - a.x) + 2 * t * (b.x - c.x)
let y: CGFloat = 2 * (1 - t) * (c.y - a.y) + 2 * t * (b.y - c.y)
return CGPoint(x: x, y: y)
}
}
// MARK: - Components
private struct StaticPreviewKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
/// Set by the preview renderer, which cannot draw a Link.
var trekStaticPreview: Bool {
get { self[StaticPreviewKey.self] }
set { self[StaticPreviewKey.self] = newValue }
}
}
/// A row that opens its own place in the app, or just the row in a preview.
struct RowLink<Label: View>: View {
let path: String
@ViewBuilder var label: () -> Label
@Environment(\.trekStaticPreview) private var staticPreview
var body: some View {
if staticPreview {
label()
} else if let url = WidgetSnapshot.link(path) {
Link(destination: url) { label() }
} else {
label()
}
}
}
/// A ring that fills with progress, a soft track under it.
struct ProgressRing<Label: View>: View {
let value: Double
var lineWidth: CGFloat = 6
var colors: [Color]
var trackColor: Color
@ViewBuilder var label: () -> Label
var body: some View {
ZStack {
Circle().stroke(trackColor, lineWidth: lineWidth)
Circle()
.trim(from: 0, to: max(0.001, min(1, value)))
.stroke(
AngularGradient(colors: colors + [colors.first ?? .white], center: .center, startAngle: .degrees(-90), endAngle: .degrees(270)),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(-90))
label()
}
}
}
/// The trip's days as a row of segments, those behind filled, today glowing.
struct DaySegments: View {
let index: Int
let total: Int
let color: Color
let track: Color
var body: some View {
let count = min(max(total, 1), 21)
let current = total > 21 ? Int((Double(index) / Double(total) * 21).rounded(.up)) : index
HStack(spacing: count > 12 ? 2 : 3) {
ForEach(1...count, id: \.self) { day in
Capsule()
.fill(day < current ? color : day == current ? color : track)
.opacity(day < current ? 0.55 : 1)
.shadow(color: day == current ? color.opacity(0.7) : .clear, radius: 3)
}
}
.frame(height: 4)
}
}
/// Today's weather in a pill: the multicolour symbol and the temperature.
struct WeatherChip: View {
let weather: WidgetSnapshot.Weather
var onPhoto = false
var compact = false
@Environment(\.colorScheme) private var scheme
var body: some View {
HStack(spacing: 4) {
Image(systemName: Trek.weatherSymbol(weather.main))
.symbolRenderingMode(.multicolor)
.font(.system(size: compact ? 12 : 14, weight: .semibold))
Text("\(weather.temp)°")
.font(Trek.number(compact ? 12 : 14, .semibold))
.foregroundStyle(onPhoto ? .white : Trek.ink(scheme))
}
.padding(.horizontal, compact ? 7 : 9)
.padding(.vertical, compact ? 3 : 4)
.background(
Capsule().fill(onPhoto ? Color.black.opacity(0.28) : Trek.card(scheme))
)
.overlay(Capsule().stroke(onPhoto ? Color.white.opacity(0.22) : Trek.cardBorder(scheme), lineWidth: 0.6))
}
}
extension Trek {
static func weatherSymbol(_ main: String) -> String {
switch main.lowercased() {
case "clear": return "sun.max.fill"
case "clouds": return "cloud.sun.fill"
case "rain", "drizzle": return "cloud.rain.fill"
case "thunderstorm": return "cloud.bolt.rain.fill"
case "snow": return "cloud.snow.fill"
case "mist", "fog", "haze": return "cloud.fog.fill"
default: return "cloud.fill"
}
}
/// Symbol and colour of a booking's kind.
static func booking(_ kind: String) -> (symbol: String, color: Color) {
switch kind {
case "flight": return ("airplane", Color(hex: 0x4A7DDB))
case "train": return ("tram.fill", Color(hex: 0x2FA37A))
case "bus": return ("bus.fill", Color(hex: 0x2BA6A6))
case "car": return ("car.fill", Color(hex: 0xD6673B))
case "cruise", "ferry": return ("ferry.fill", Color(hex: 0x2E8BC0))
case "hotel": return ("bed.double.fill", Color(hex: 0x8E6CD9))
case "restaurant": return ("fork.knife", Color(hex: 0xE8A13A))
case "event": return ("ticket.fill", Color(hex: 0xD6408B))
case "tour": return ("figure.hiking", Color(hex: 0x4E9F3D))
default: return ("bookmark.fill", Color(hex: 0x8A8A93))
}
}
static func priority(_ value: Int) -> Color? {
switch value {
case 3: return danger
case 2: return pending
case 1: return info
default: return nil
}
}
}
/// A booking's kind as a tinted rounded square.
struct KindBadge: View {
let kind: String
var size: CGFloat = 30
var body: some View {
let look = Trek.booking(kind)
RoundedRectangle(cornerRadius: size * 0.32, style: .continuous)
.fill(LinearGradient(colors: [look.color, look.color.opacity(0.78)], startPoint: .top, endPoint: .bottom))
.frame(width: size, height: size)
.overlay(
Image(systemName: look.symbol)
.font(.system(size: size * 0.46, weight: .semibold))
.foregroundStyle(.white)
)
.shadow(color: look.color.opacity(0.35), radius: 4, y: 2)
}
}
/// "Today", "Tomorrow" or the short weekday and date of a yyyy-MM-dd.
enum RelativeDay {
static func label(_ iso: String?, now: Date, labels: WidgetSnapshot.Labels) -> String? {
guard let iso, let date = WidgetSnapshot.day(iso), let days = WidgetSnapshot.daysBetween(now, date) else { return nil }
if days == 0 { return labels.todayShort ?? labels.today }
if days == 1 { return labels.tomorrowShort ?? labels.tomorrow }
let formatter = DateFormatter()
formatter.setLocalizedDateFormatFromTemplate(days < 7 ? "EEE" : "EEEdMMM")
return formatter.string(from: date)
}
static func range(_ start: String, _ end: String) -> String? {
guard let from = WidgetSnapshot.day(start), let to = WidgetSnapshot.day(end) else { return nil }
let formatter = DateIntervalFormatter()
formatter.dateTemplate = "dMMM"
return formatter.string(from: from, to: to)
}
}
@@ -0,0 +1,564 @@
import SwiftUI
/// The trip widget: a countdown on the trip's own cover before it starts, the
/// day and what comes next while it runs, an invitation when there is none.
struct TripContent: View {
let snapshot: WidgetSnapshot?
let family: TrekFamily
let now: Date
@Environment(\.colorScheme) private var scheme
private var labels: WidgetSnapshot.Labels? { snapshot?.labels }
var body: some View {
if family.isAccessory {
TripAccessory(snapshot: snapshot, family: family, now: now)
} else if let snapshot, let trip = snapshot.trip {
if let today = snapshot.today {
running(snapshot, trip, today)
} else {
upcoming(snapshot, trip)
}
} else {
EmptyTrip(text: snapshot?.labels.noTrip ?? "", family: family)
}
}
// MARK: Before the trip
@ViewBuilder
private func upcoming(_ snapshot: WidgetSnapshot, _ trip: WidgetSnapshot.Trip) -> some View {
let days = max(snapshot.daysUntilStart(from: now) ?? 0, 0)
// The ring and the plane cover the last 60 days before departure.
let progress = 1 - min(Double(days), 60) / 60
switch family {
case .small:
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
PlaneMark()
Spacer(minLength: 0)
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
}
Spacer(minLength: 0)
countdown(days, snapshot.labels, size: 46)
Text(trip.title)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.85)
.padding(.top, 1)
if let range = RelativeDay.range(trip.start, trip.end) {
Text(range).font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.75)).lineLimit(1)
}
}
case .medium:
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 0) {
Text(snapshot.labels.nextTrip).trekEyebrowOnPhoto()
Text(trip.title)
.font(.system(size: 20, weight: .bold))
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.8)
.padding(.top, 3)
if let place = trip.place {
Label(place, systemImage: "location.fill")
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(.white.opacity(0.82))
.lineLimit(1)
.padding(.top, 3)
}
Spacer(minLength: 6)
RouteArc(progress: progress, color: .white)
.frame(height: 26)
.padding(.bottom, 8)
HStack(spacing: 6) {
if let range = RelativeDay.range(trip.start, trip.end) { PhotoPill(symbol: "calendar", text: range) }
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
}
}
ProgressRing(value: progress, lineWidth: 7, colors: [.white.opacity(0.75), .white], trackColor: .white.opacity(0.2)) {
VStack(spacing: -2) {
Text("\(days)").font(Trek.number(days > 99 ? 26 : 32, .heavy)).foregroundStyle(.white)
Text(snapshot.labels.days ?? "").font(.system(size: 10, weight: .bold)).foregroundStyle(.white.opacity(0.75)).textCase(.uppercase)
}
}
.frame(width: 96, height: 96)
}
default:
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: 26, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.75)
if let place = trip.place {
Label(place, systemImage: "location.fill")
.font(.system(size: 12.5, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
}
}
Spacer(minLength: 0)
countdown(days, snapshot.labels, size: 52)
}
RouteArc(progress: progress, color: .white)
.frame(height: 34)
.padding(.top, 10)
.padding(.bottom, 12)
TripFacts(snapshot: snapshot, onPhoto: true)
if let booking = snapshot.bookings?.first {
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
.padding(.top, 10)
}
}
}
}
private func countdown(_ days: Int, _ labels: WidgetSnapshot.Labels, size: CGFloat) -> some View {
Group {
if days == 0 {
Text(labels.todayShort ?? labels.today)
.font(.system(size: size * 0.62, weight: .heavy, design: .rounded))
} else if days == 1 {
Text(labels.tomorrow)
.font(.system(size: size * 0.52, weight: .heavy, design: .rounded))
.minimumScaleFactor(0.6)
} else {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(days)").font(Trek.number(size, .heavy))
Text(labels.days ?? "").font(.system(size: size * 0.28, weight: .bold)).opacity(0.8)
}
}
}
.foregroundStyle(.white)
.lineLimit(1)
.shadow(color: .black.opacity(0.25), radius: 6, y: 2)
}
// MARK: While travelling
@ViewBuilder
private func running(_ snapshot: WidgetSnapshot, _ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today) -> some View {
let nowMinutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
let next = today.stops.first { ($0.minutes ?? -1) >= nowMinutes } ?? today.stops.last
let index = today.index ?? 1
let total = today.total ?? snapshot.tripLength ?? 1
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) }
}
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 7)
Spacer(minLength: 0)
if let next {
Text(next.time ?? snapshot.labels.today)
.font(Trek.number(28, .heavy))
.foregroundStyle(.white)
Text(next.name)
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
.lineLimit(2)
} else {
Text(snapshot.labels.nothingPlanned).font(.system(size: 14, weight: .bold)).foregroundStyle(.white).lineLimit(3)
}
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))
}
}
}
}
.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: 176)
}
default:
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 3) {
Text(trip.title).trekEyebrowOnPhoto()
Text(today.dayLabel).font(.system(size: 24, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
}
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)
GlassPanel {
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 6, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
}
.padding(.top, 14)
Spacer(minLength: 8)
if let booking = snapshot.bookings?.first {
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
}
}
}
}
}
// MARK: - Pieces
private extension Text {
func trekEyebrowOnPhoto() -> some View {
self.font(.system(size: 10.5, weight: .bold))
.tracking(0.7)
.textCase(.uppercase)
.foregroundStyle(.white.opacity(0.78))
.lineLimit(1)
}
}
/// The plane in a frosted circle that marks a trip widget.
struct PlaneMark: View {
var body: some View {
Circle()
.fill(.white.opacity(0.22))
.overlay(Circle().stroke(.white.opacity(0.35), lineWidth: 0.6))
.frame(width: 26, height: 26)
.overlay(Image(systemName: "airplane").font(.system(size: 12, weight: .bold)).foregroundStyle(.white).rotationEffect(.degrees(-35)))
}
}
struct PhotoPill: View {
let symbol: String
let text: String
var body: some View {
HStack(spacing: 4) {
Image(systemName: symbol).font(.system(size: 10, weight: .bold))
Text(text).font(.system(size: 11, weight: .semibold)).lineLimit(1)
}
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Color.black.opacity(0.28)))
.overlay(Capsule().stroke(Color.white.opacity(0.22), lineWidth: 0.6))
}
}
/// Frosted glass over the photo, for lists that need a calm ground.
struct GlassPanel<Content: View>: View {
@ViewBuilder var content: () -> Content
var body: some View {
content()
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.black.opacity(0.32))
)
.overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(Color.white.opacity(0.16), lineWidth: 0.7))
}
}
/// Days, places and travellers as three small columns.
struct TripFacts: View {
let snapshot: WidgetSnapshot
var onPhoto = false
@Environment(\.colorScheme) private var scheme
var body: some View {
let labels = snapshot.labels
HStack(spacing: 0) {
if let length = snapshot.tripLength {
fact("\(length)", labels.days ?? "", "calendar")
}
if let places = snapshot.trip?.placeCount, places > 0 {
divider
fact("\(places)", labels.places ?? "", "mappin")
}
if let members = snapshot.trip?.memberCount, members > 0 {
divider
fact("\(members)", labels.travellers ?? "", "person.2.fill")
}
}
.padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(onPhoto ? Color.black.opacity(0.28) : Trek.card(scheme)))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(onPhoto ? Color.white.opacity(0.18) : Trek.cardBorder(scheme), lineWidth: 0.7))
}
private var divider: some View {
Rectangle().fill(onPhoto ? Color.white.opacity(0.2) : Trek.track(scheme)).frame(width: 0.7, height: 26)
}
private func fact(_ value: String, _ label: String, _ symbol: String) -> some View {
VStack(spacing: 1) {
HStack(spacing: 4) {
Image(systemName: symbol).font(.system(size: 10, weight: .bold)).opacity(0.75)
Text(value).font(Trek.number(17, .heavy))
}
Text(label).font(.system(size: 9.5, weight: .bold)).textCase(.uppercase).tracking(0.4).opacity(0.7).lineLimit(1)
}
.foregroundStyle(onPhoto ? .white : Trek.ink(scheme))
.frame(maxWidth: .infinity)
}
}
/// The first booking ahead, as a strip under the trip.
struct NextBookingStrip: View {
let booking: WidgetSnapshot.Booking
let labels: WidgetSnapshot.Labels
let now: Date
var onPhoto = true
@Environment(\.colorScheme) private var scheme
var body: some View {
HStack(spacing: 9) {
KindBadge(kind: booking.kind, size: 28)
VStack(alignment: .leading, spacing: 1) {
Text(booking.title).font(.system(size: 13, weight: .bold)).lineLimit(1)
if let detail = booking.detail {
Text(detail).font(.system(size: 11, weight: .medium)).opacity(0.75).lineLimit(1)
}
}
Spacer(minLength: 4)
VStack(alignment: .trailing, spacing: 1) {
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
Text(day).font(.system(size: 11, weight: .bold)).opacity(0.8)
}
if let time = booking.time { Text(time).font(Trek.number(14, .heavy)) }
}
}
.foregroundStyle(onPhoto ? .white : Trek.ink(scheme))
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(onPhoto ? Color.black.opacity(0.3) : Trek.card(scheme)))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(onPhoto ? Color.white.opacity(0.16) : Trek.cardBorder(scheme), lineWidth: 0.7))
}
}
/// The weather as the large widget shows it: symbol, temperature, high and low.
struct BigWeather: View {
let weather: WidgetSnapshot.Weather
var body: some View {
HStack(spacing: 6) {
Image(systemName: Trek.weatherSymbol(weather.main))
.symbolRenderingMode(.multicolor)
.font(.system(size: 26))
VStack(alignment: .leading, spacing: 0) {
Text("\(weather.temp)°").font(Trek.number(22, .heavy))
if let high = weather.high, let low = weather.low {
Text("\(high)° / \(low)°").font(Trek.number(10.5, .semibold)).opacity(0.8)
}
}
}
.foregroundStyle(.white)
}
}
/// The day's stops as a timeline: a dot in each stop's colour on a thread, the
/// ones behind faded, a now marker between the past and the next.
struct StopList: View {
let stops: [WidgetSnapshot.Stop]
let nowMinutes: Int
let limit: Int
var onPhoto = false
let nothing: String
var nowLabel: String?
@Environment(\.colorScheme) private var scheme
var body: some View {
if stops.isEmpty {
HStack(spacing: 8) {
Image(systemName: "sparkles").font(.system(size: 15, weight: .semibold))
Text(nothing).font(.system(size: 13, weight: .semibold)).lineLimit(2)
}
.foregroundStyle(ink.opacity(0.85))
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
} else {
let window = visible
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(window.enumerated()), id: \.offset) { offset, stop in
let past = (stop.minutes ?? Int.max) < nowMinutes
let isNext = !past && (offset == 0 || (window[offset - 1].minutes ?? Int.max) < nowMinutes)
if isNext && offset > 0, let nowLabel { nowLine(nowLabel) }
row(stop, past: past, isNext: isNext, last: offset == window.count - 1)
}
}
}
}
/// The stops around now: the last one behind, then the ones ahead.
private var visible: [WidgetSnapshot.Stop] {
let nextIndex = stops.firstIndex { ($0.minutes ?? Int.max) >= nowMinutes } ?? max(stops.count - limit, 0)
let first = max(0, min(nextIndex - 1, stops.count - limit))
return Array(stops[first..<min(stops.count, first + limit)])
}
private var ink: Color { onPhoto ? .white : Trek.ink(scheme) }
private func row(_ stop: WidgetSnapshot.Stop, past: Bool, isNext: Bool, last: Bool) -> some View {
let color = Color(css: stop.color) ?? (onPhoto ? .white : Trek.info)
return HStack(alignment: .top, spacing: 8) {
Text(stop.time ?? "–")
.font(Trek.number(11.5, isNext ? .heavy : .semibold))
.frame(width: 38, alignment: .leading)
.padding(.top, 1)
VStack(spacing: 0) {
ZStack {
Circle().fill(color).frame(width: isNext ? 11 : 8, height: isNext ? 11 : 8)
if isNext { Circle().stroke(color.opacity(0.4), lineWidth: 3).frame(width: 15, height: 15) }
}
.frame(width: 15, height: 15)
if !last {
Rectangle().fill(ink.opacity(0.22)).frame(width: 1.2).frame(maxHeight: .infinity)
}
}
.frame(width: 15)
HStack(spacing: 5) {
if let symbol = stop.symbol {
Image(systemName: symbol).font(.system(size: 10, weight: .semibold)).foregroundStyle(color)
}
Text(stop.name)
.font(.system(size: 12.5, weight: isNext ? .bold : .semibold))
.lineLimit(1)
}
.padding(.top, 0.5)
Spacer(minLength: 0)
}
.foregroundStyle(ink)
.opacity(past ? 0.5 : 1)
.frame(height: 24, alignment: .top)
}
private func nowLine(_ label: String) -> some View {
HStack(spacing: 5) {
Text(label.uppercased()).font(.system(size: 8.5, weight: .heavy)).tracking(0.5).foregroundStyle(Trek.danger)
.frame(width: 38, alignment: .leading)
Circle().fill(Trek.danger).frame(width: 5, height: 5).frame(width: 15)
Rectangle().fill(Trek.danger).frame(height: 1.2)
}
.frame(height: 9)
.padding(.bottom, 3)
}
}
/// No trip: the paper, the contour lines, and an invitation.
struct EmptyTrip: View {
let text: String
let family: TrekFamily
@Environment(\.colorScheme) private var scheme
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "map.fill").font(.system(size: 13, weight: .bold))
Text("TREK").font(.system(size: 15, weight: .heavy)).tracking(1.2)
}
.foregroundStyle(Trek.ink(scheme))
Spacer(minLength: 0)
Image(systemName: "globe.europe.africa.fill")
.font(.system(size: family == .small ? 30 : 38, weight: .light))
.foregroundStyle(Trek.muted(scheme).opacity(0.6))
Text(text)
.font(.system(size: family == .small ? 12.5 : 14, weight: .semibold))
.foregroundStyle(Trek.muted(scheme))
.lineLimit(3)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
}
/// The trip on the lock screen: a ring with the days left or the day of the
/// trip, a line with the next stop, a word in the status row.
struct TripAccessory: View {
let snapshot: WidgetSnapshot?
let family: TrekFamily
let now: Date
var body: some View {
let trip = snapshot?.trip
let today = snapshot?.today
let days = max(snapshot?.daysUntilStart(from: now) ?? 0, 0)
let nowMinutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
let next = today?.stops.first { ($0.minutes ?? -1) >= nowMinutes }
switch family {
case .circular:
ZStack {
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
if let today, let index = today.index, let total = today.total ?? snapshot?.tripLength {
Circle().trim(from: 0, to: Double(index) / Double(max(total, 1)))
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: -3) {
Text("\(index)").font(Trek.number(20, .heavy))
Text("/\(total)").font(Trek.number(9, .bold)).opacity(0.75)
}
} else if trip != nil {
Circle().trim(from: 0, to: 1 - min(Double(days), 60) / 60)
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: -3) {
Image(systemName: "airplane").font(.system(size: 9, weight: .bold))
Text("\(days)").font(Trek.number(days > 99 ? 15 : 19, .heavy))
}
} else {
Image(systemName: "map.fill").font(.system(size: 20))
}
}
.padding(3)
case .rectangular:
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: today == nil ? "airplane" : "mappin.circle.fill").font(.system(size: 11, weight: .bold))
Text(trip?.title ?? "TREK").font(.system(size: 13, weight: .bold)).lineLimit(1)
}
if let today {
Text(next.map { "\($0.time ?? "") \($0.name)" } ?? snapshot?.labels.allDone ?? today.dayLabel)
.font(.system(size: 13, weight: .semibold)).lineLimit(1)
Text(today.dayLabel).font(.system(size: 11, weight: .medium)).opacity(0.75).lineLimit(1)
} else if let snapshot, trip != nil {
Text(days == 1 ? snapshot.labels.tomorrow : snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: "\(days)"))
.font(.system(size: 15, weight: .heavy)).lineLimit(1)
if let range = trip.flatMap({ RelativeDay.range($0.start, $0.end) }) {
Text(range).font(.system(size: 11, weight: .medium)).opacity(0.75)
}
} else {
Text(snapshot?.labels.noTrip ?? "").font(.system(size: 12, weight: .medium)).lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
default:
if let trip, let snapshot {
if today != nil, let next {
Text("\(Image(systemName: "mappin.circle.fill")) \(next.time ?? "") \(next.name)")
} else {
Text("\(Image(systemName: "airplane")) \(trip.title), \(snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: "\(days)"))")
}
} else {
Text("\(Image(systemName: "map.fill")) TREK")
}
}
}
}
@@ -3,53 +3,110 @@ import SwiftUI
import WidgetKit
/// A travel day on the lock screen and in the Dynamic Island: the next stop and
/// its time, and how much of the day is behind.
/// its time, and how much of the day is behind, in the widgets' look.
struct TripDayLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TripDayAttributes.self) { context in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(context.attributes.tripTitle).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
Spacer()
Text(context.state.dayLabel).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
}
HStack(alignment: .firstTextBaseline) {
Image(systemName: "mappin.circle.fill")
Text(context.state.nextName).font(.headline).lineLimit(1)
Spacer()
if let time = context.state.nextTime {
Text(time).font(.headline.monospacedDigit())
}
}
ProgressView(value: Double(context.state.done), total: Double(max(context.state.total, 1)))
}
.padding()
.widgetURL(WidgetSnapshot.link(context.attributes.path))
TripDayBanner(attributes: context.attributes, state: context.state)
.activityBackgroundTint(Color(hex: 0x0C0C0F).opacity(0.92))
.activitySystemActionForegroundColor(.white)
.widgetURL(WidgetSnapshot.link(context.attributes.path))
} dynamicIsland: { context in
DynamicIsland {
let state = context.state
return DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Label(context.state.dayLabel, systemImage: "map").font(.caption)
HStack(spacing: 6) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 18))
.foregroundStyle(Trek.confirmed)
Text(state.dayLabel).font(.system(size: 13, weight: .bold)).lineLimit(1)
}
.padding(.leading, 4)
}
DynamicIslandExpandedRegion(.trailing) {
if let time = context.state.nextTime {
Text(time).font(.caption.monospacedDigit())
if let time = state.nextTime {
Text(time).font(Trek.number(20, .heavy)).padding(.trailing, 4)
}
}
DynamicIslandExpandedRegion(.bottom) {
VStack(alignment: .leading, spacing: 4) {
Text(context.state.nextName).font(.headline).lineLimit(1)
ProgressView(value: Double(context.state.done), total: Double(max(context.state.total, 1)))
VStack(alignment: .leading, spacing: 8) {
Text(state.nextName).font(.system(size: 17, weight: .heavy)).lineLimit(1)
StopProgress(done: state.done, total: state.total)
}
.padding(.horizontal, 4)
}
} compactLeading: {
Image(systemName: "mappin.circle.fill")
Image(systemName: "mappin.circle.fill").foregroundStyle(Trek.confirmed)
} compactTrailing: {
Text(context.state.nextTime ?? "\(context.state.done)/\(context.state.total)")
.font(.caption2.monospacedDigit())
Text(state.nextTime ?? "\(state.done)/\(state.total)")
.font(Trek.number(13, .heavy))
} minimal: {
Image(systemName: "mappin.circle.fill")
ZStack {
Circle().stroke(Color.white.opacity(0.25), lineWidth: 2.5)
Circle()
.trim(from: 0, to: state.total > 0 ? Double(state.done) / Double(state.total) : 0)
.stroke(Trek.confirmed, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
.rotationEffect(.degrees(-90))
}
.padding(2)
}
.widgetURL(WidgetSnapshot.link(context.attributes.path))
.keylineTint(Trek.confirmed)
}
}
}
/// 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))
}
}
@@ -0,0 +1,72 @@
import AppIntents
import WidgetKit
/// The trip a widget shows, picked in "Edit Widget": the dashboard's trip by
/// default ("Automatic"), or any trip the app listed in widget.trips.
struct TripChoice: AppEntity {
static var typeDisplayRepresentation: TypeDisplayRepresentation = "widget.intent.trip"
static var defaultQuery = TripChoiceQuery()
/// The id of the automatic choice, which follows the dashboard's trip.
static let automaticID = "auto"
var id: String
var title: String
var dates: String?
var displayRepresentation: DisplayRepresentation {
if id == TripChoice.automaticID {
return DisplayRepresentation(title: "widget.intent.auto", subtitle: "widget.intent.autoHint")
}
return DisplayRepresentation(title: "\(title)", subtitle: dates.map { "\($0)" })
}
static var automatic: TripChoice { TripChoice(id: automaticID, title: "", dates: nil) }
}
struct TripChoiceQuery: EntityQuery {
func entities(for identifiers: [TripChoice.ID]) async throws -> [TripChoice] {
all().filter { identifiers.contains($0.id) }
}
func suggestedEntities() async throws -> [TripChoice] {
all()
}
func defaultResult() async -> TripChoice? {
.automatic
}
/// Automatic first, then the trips the app sent, soonest first.
private func all() -> [TripChoice] {
[.automatic] + WidgetSnapshot.tripIndex().map {
TripChoice(id: String($0.id), title: $0.title, dates: RelativeDay.range($0.start, $0.end))
}
}
}
struct SelectTripIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "widget.intent.title"
static var description = IntentDescription("widget.intent.description")
@Parameter(title: "widget.intent.trip")
var trip: TripChoice?
}
/// Like TrekProvider, for the widgets that show one trip: the snapshot of the
/// trip picked for the widget, or the dashboard's.
struct TrekTripProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> TrekEntry {
TrekEntry(date: Date(), snapshot: .sample(running: false))
}
func snapshot(for configuration: SelectTripIntent, in context: Context) async -> TrekEntry {
let stored = WidgetSnapshot.load(trip: configuration.trip?.id)
let snapshot: WidgetSnapshot? = (stored?.trip != nil || !context.isPreview) ? stored : WidgetSnapshot.sample(running: false)
return TrekEntry(date: Date(), snapshot: snapshot)
}
func timeline(for configuration: SelectTripIntent, in context: Context) async -> Timeline<TrekEntry> {
TrekProvider.timeline(WidgetSnapshot.load(trip: configuration.trip?.id))
}
}
+210 -4
View File
@@ -5,22 +5,159 @@ import Security
/// TrekShell.setWidgetSnapshot into the keychain group the app and its
/// extensions share). Texts arrive already translated, so the widget needs no
/// localisation of its own and always speaks the language the app does.
///
/// Everything past `trip`, `today` and `labels` is optional: a binary that is
/// newer than the server's UI shows what it gets, an older one ignores the rest.
struct WidgetSnapshot: Codable {
struct Trip: Codable {
var title: String
var start: String
var end: String
var path: String
/// The destination in a few words ("Tokyo, Japan").
var place: String?
/// The trip's cover, a small JPEG as base64, already cropped for a widget.
var cover: String?
var dayCount: Int?
var placeCount: Int?
var memberCount: Int?
}
struct Stop: Codable, Hashable {
var time: String?
var name: String
/// Minutes after midnight, for the now line.
var minutes: Int?
/// The category colour as #rrggbb.
var color: String?
/// An SF Symbol name for the stop's kind.
var symbol: String?
}
struct Today: Codable {
var dayLabel: String
var stops: [Stop]
/// 1-based position of today in the trip.
var index: Int?
var total: Int?
}
struct Weather: Codable {
var temp: Int
var high: Int?
var low: Int?
/// The provider's main condition ("Clear", "Clouds", "Rain", ...).
var main: String
var label: String
var place: String?
/// Chance of rain in percent.
var rain: Int?
}
struct Booking: Codable, Hashable {
var title: String
/// flight, train, bus, car, cruise, hotel, restaurant, event, tour, other
var kind: String
/// yyyy-MM-dd, for the relative day.
var date: String?
var time: String?
/// "FRA → HND", the location or the hotel's nights.
var detail: String?
var confirmed: Bool
var path: String
}
struct Todo: Codable, Hashable {
var name: String
var due: String?
/// 0 none, 1 low, 2 medium, 3 high.
var priority: Int
}
struct Todos: Codable {
var open: Int
var total: Int
var items: [Todo]
var path: String
}
struct Packing: Codable {
var packed: Int
var total: Int
var path: String
var bags: [Bag]?
var missing: [Item]?
struct Bag: Codable, Hashable {
var name: String
/// #rrggbb
var color: String
var grams: Int
/// The bag's weight limit in grams, if it has one.
var limit: Int?
var packed: Int
var total: Int
}
struct Item: Codable, Hashable {
var name: String
var category: String?
/// The colour of the bag it goes in.
var color: String?
}
}
/// The countries of the Atlas, independent of any trip.
struct Atlas: Codable {
var count: Int
/// ISO 3166-1 alpha-2 codes.
var codes: [String]
var recent: [Country]
var continents: [Continent]
var trips: Int?
var days: Int?
struct Country: Codable, Hashable {
var code: String
var name: String
}
struct Continent: Codable, Hashable {
var name: String
var count: Int
}
}
/// Who travels along, the trip chat and its open poll.
struct Collab: Codable {
var members: [Member]
var messages: [Message]
var poll: Poll?
var path: String
struct Member: Codable, Hashable {
var name: String
var initials: String
}
struct Message: Codable, Hashable {
var author: String
var initials: String
var text: String
var at: String?
var mine: Bool
}
struct Poll: Codable {
var question: String
var options: [Option]
var voters: Int
struct Option: Codable, Hashable {
var label: String
var votes: Int
}
}
}
struct Labels: Codable {
@@ -31,29 +168,98 @@ struct WidgetSnapshot: Codable {
var inDays: String
var tomorrow: String
var nothingPlanned: String
var days: String?
var dayOf: String?
var bookings: String?
var noBookings: String?
var todos: String?
var noTodos: String?
var packing: String?
var packed: String?
var confirmed: String?
var pending: String?
var todayShort: String?
var tomorrowShort: String?
var now: String?
var places: String?
var travellers: String?
var allDone: String?
var openTrek: String?
var missing: String?
var countries: String?
var world: String?
var group: String?
var poll: String?
var votes: String?
var noMessages: String?
var departure: String?
}
var trip: Trip?
var today: Today?
var weather: Weather?
var bookings: [Booking]?
var todos: Todos?
var packing: Packing?
var atlas: Atlas?
var collab: Collab?
var labels: Labels
/// The dashboard's snapshot.
static func load() -> WidgetSnapshot? {
read("widget.snapshot").flatMap { try? JSONDecoder().decode(WidgetSnapshot.self, from: $0) }
}
/// The snapshot of the trip a widget is set to, the dashboard's for none or
/// "auto", and the dashboard's too while the app has not written that trip yet.
static func load(trip id: String?) -> WidgetSnapshot? {
guard let id, id != "auto", let data = read("widget.trip.\(id)"),
var snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else { return load() }
if snapshot.atlas == nil { snapshot.atlas = load()?.atlas }
return snapshot
}
/// A trip a widget can be set to, as the app lists them.
struct TripRef: Codable {
var id: Int
var title: String
var start: String
var end: String
}
static func tripIndex() -> [TripRef] {
read("widget.trips").flatMap { try? JSONDecoder().decode([TripRef].self, from: $0) } ?? []
}
private static func read(_ account: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.liketrek.trek.push",
kSecAttrAccount as String: "widget.snapshot",
kSecAttrAccount as String: account,
kSecReturnData as String: true,
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }
return result as? Data
}
/// Whole days from today to the trip's start, in the device's calendar.
func daysUntilStart(from now: Date = Date()) -> Int? {
guard let trip, let start = WidgetSnapshot.day(trip.start) else { return nil }
return WidgetSnapshot.daysBetween(now, start)
}
/// The trip's length in days, from its dates when the app sent no count.
var tripLength: Int? {
if let count = trip?.dayCount, count > 0 { return count }
guard let trip, let start = WidgetSnapshot.day(trip.start), let end = WidgetSnapshot.day(trip.end),
let days = WidgetSnapshot.daysBetween(start, end) else { return nil }
return days + 1
}
static func daysBetween(_ from: Date, _ to: Date) -> Int? {
let calendar = Calendar.current
return calendar.dateComponents([.day], from: calendar.startOfDay(for: now), to: start).day
return calendar.dateComponents([.day], from: calendar.startOfDay(for: from), to: calendar.startOfDay(for: to)).day
}
static func day(_ iso: String) -> Date? {
@@ -0,0 +1,53 @@
// Generated by client/ios/WidgetPreviews/make-world-grid.py from
// server/assets/atlas/admin0.geojson.gz; do not edit by hand.
import Foundation
/// The world as 76 by 34 dots from 80° north to 58° south,
/// each the ISO code of the country under it, ".." for sea (977 land dots).
enum WorldGrid {
static let columns = 76
static let rows: [String] = [
"........................CACACA..CACACACACACA..GLGLGLGLGLGLGLGLGLGLGL............NONONO..................RU..........RURURU..............................",
"......................CACACACACA..CACACACACA........GLGLGLGLGLGLGLGL..............................RURU....RURURURURURURURURURURURURU..RURURU............",
"......USUSUSUSUSCACACACACACACACACACACACACACACACA....GLGLGLGLGLGLGL................NONONORURU....RURU..RURURURURURURURURURURURURURURURURURURURURURURURURU",
"RURU..USUSUSUSUSCACACACACACACACACACACACACACACACACA....GLGLGLGL....ISIS..........NOSESEFIRURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURU",
"......USUSUSUSUSCACACACACACACACACACACA....CACACACA....GLGL....................NONOSEFIFIRURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURU",
"........USUS......USCACACACACACACACACACA..CACACACA........................GB..NOSESELVLVRURURURURURURURURURURURURURURURURURURURURURURURU......RURU......",
"......US............CACACACACACACACACACACACACACACACA....................IEGBGBNLDEPLPLBYBYRURURURURURUKZKZRURURURURURURURURURURURURURURURU....RU........",
"......................CACACACACACACACACACACACACACACACA....................FRFRDEDECZPLUAUAUARURUKZKZKZKZKZKZKZKZRUMNMNMNMNRUMNCNCNRURURURU..............",
"........................USUSUSUSUSUSUSUSCACACAUSCA........................FRFRITITHRROROMDUARURURUKZKZKZKZKZKZCNCNMNMNMNMNMNCNCNCNCNRU..................",
"......................USUSUSUSUSUSUSUSUSUSUSUS..........................ESESES..ITITALBGTRTRTRGEAZTMTMUZKZKGKGCNCNCNCNCNCNCNCNCNCNCN..JPJP..............",
"........................USUSUSUSUSUSUSUSUSUS............................ESESDZDZ....GRTRTRTRTRIRIRIRIRTMTJTJCNCNCNCNCNCNCNCNCNCNKPKR..JP................",
"..........................USUSUSUSUSUSUSUSUS............................MAMADZDZTN..LY....LBIQIQIRIRIRAFAFPKINCNCNCNCNCNCNCNCNCNKRJPJP..................",
"..........................MXMXMXMXUSUS..US............................MADZDZDZDZLYLYLYEGEGSASAIQIRIRIRPKPKININNPCNCNCNCNCNCNCNCN........................",
"............................MXMXMXUS....US..........................EHEHMRDZDZDZLYLYLYEGEGSASASASAAEIRPKPKINININININMMCNCNCNCNCN........................",
"..............................MXMXMX..MXCUCUBS......................MRMRMRMLDZDZNETDLYSDSDSDSASASASAOM..INININININMMMMLAVNCN............................",
"................................MXMXMXMX..JM........................MRMRMRMLMLNENETDTDSDSDSDERYEYEOM......INININ....MMTHVN....PH........................",
"....................................GTSVNI....VE....................SNSNMLBFNENENGTDTDSDSDSDETYEYE........ININ......MMTHKH....PH........................",
"........................................CRPACOVEVEVE..................SLCIGHBJNGNGTDCFSSSSETETSOSO..........IN....IN....VN......PH......................",
"..........................................COCOCOVEGYSR..................CICI..NGCMCFCFCDSSKEETSOSO..................IDMY....MY..ID......................",
"..........................................ECCOBRBRBRBRBR........................GACGCDCDUGKEKESO......................ID..IDIDID........................",
"..........................................ECPEBRBRBRBRBRBRBR....................CGCDCDCDBITZKE........................IDIDIDIDID..IDIDIDPG..PG..........",
"..........................................PEBRBRBRBRBRBRBRBRBR..................AOAOCDCDTZTZTZ..........................IDIDID........IDPGPGPG..........",
"..........................................PEPEBOBRBRBRBRBRBRBR....................AOAOCDZMTZMZKM..................................AUAU....PG............",
"............................................PEBOBOBRBRBRBRBR....................AOAOAOZMMZMZMZMGMG..............................AUAUAU..AU..............",
"..............................................CLBOPYBRBRBRBR....................NANABWBWZWMZ..MGMG............................AUAUAUAUAUAUAU....FR......",
"..............................................CLARPYBRBRBR........................NABWBWZAMZ..MG............................AUAUAUAUAUAUAUAU............",
"..............................................ARARARBRBR..........................NAZAZAZA..................................AUAUAUAUAUAUAUAUAU..........",
"..............................................ARARARUY............................ZAZAZAZA..................................AUAUAUAUAUAUAUAUAU..........",
"............................................CLARARARUY..............................ZA......................................AU........AUAUAU............",
"............................................CLARARAR....................................................................................AU............NZ",
"............................................CLARAR......................................................................................AU........NZNZ..",
"............................................CLAR........................................................................................................",
"............................................CLAR........................................................................................................",
"..............................................CL........................................................................................................",
]
/// The country code of a dot, nil for sea.
static func code(row: Int, column: Int) -> String? {
let line = rows[row]
let start = line.index(line.startIndex, offsetBy: column * 2)
let code = String(line[start..<line.index(start, offsetBy: 2)])
return code == ".." ? nil : code
}
}
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "رحلة";
"widget.trip.description" = "عدّ تنازلي لرحلتك القادمة، ثم اليوم وما يليه أثناء السفر.";
"widget.today.name" = "اليوم";
"widget.today.description" = "خطة اليوم كخط زمني، مع الطقس والمحطة التالية.";
"widget.bookings.name" = "الحجوزات";
"widget.bookings.description" = "رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها.";
"widget.todos.name" = "المهام";
"widget.todos.description" = "ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة.";
"widget.packing.name" = "قائمة الأمتعة";
"widget.packing.description" = "مدى امتلاء الحقائب ووزنها وما الذي ما زال ناقصًا.";
"widget.atlas.name" = "خريطة العالم";
"widget.atlas.description" = "الدول التي زرتها مضيئة على الخريطة.";
"widget.collab.name" = "مجموعة السفر";
"widget.collab.description" = "من يرافقك وأحدث الرسائل والتصويت المفتوح.";
"widget.intent.title" = "اختر الرحلة";
"widget.intent.description" = "الرحلة التي تعرضها هذه الأداة.";
"widget.intent.trip" = "الرحلة";
"widget.intent.auto" = "تلقائي";
"widget.intent.autoHint" = "نفس رحلة لوحة التحكم";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Səyahət";
"widget.trip.description" = "Növbəti səyahətə qədər günləri sayır, yolda isə günü və sonrakını göstərir.";
"widget.today.name" = "Bu gün";
"widget.today.description" = "Günün planı zaman xətti kimi, hava və növbəti dayanacaqla.";
"widget.bookings.name" = "Bronlar";
"widget.bookings.description" = "Növbəti uçuşlar, qatarlar, otellər və restoranlar, nə vaxt olduqları ilə.";
"widget.todos.name" = "Tapşırıqlar";
"widget.todos.description" = "Səyahət üçün hələ nəyin qaldığı və yığışmağın nə yerdə olduğu.";
"widget.packing.name" = "Yük siyahısı";
"widget.packing.description" = "Çantaların doluluğu, çəkisi və hələ nəyin çatışmadığı.";
"widget.atlas.name" = "Dünya xəritəsi";
"widget.atlas.description" = "Olduğun ölkələr xəritədə parlayır.";
"widget.collab.name" = "Səyahət qrupu";
"widget.collab.description" = "Kim gəlir, son mesajlar və açıq səsvermə.";
"widget.intent.title" = "Səyahəti seç";
"widget.intent.description" = "Bu vidcetin göstərdiyi səyahət.";
"widget.intent.trip" = "Səyahət";
"widget.intent.auto" = "Avtomatik";
"widget.intent.autoHint" = "İdarə panelindəki səyahət";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Viatge";
"widget.trip.description" = "Compta els dies fins al proper viatge i, durant el viatge, mostra el dia i el que ve després.";
"widget.today.name" = "Avui";
"widget.today.description" = "El pla del dia com a línia de temps, amb el temps i la propera parada.";
"widget.bookings.name" = "Reserves";
"widget.bookings.description" = "Els teus propers vols, trens, hotels i taules, i quan toquen.";
"widget.todos.name" = "Tasques";
"widget.todos.description" = "El que encara queda per al viatge i com va l'equipatge.";
"widget.packing.name" = "Equipatge";
"widget.packing.description" = "Com de plenes són les maletes, el pes i el que encara falta.";
"widget.atlas.name" = "Mapa del món";
"widget.atlas.description" = "Els països on has estat, brillant al mapa.";
"widget.collab.name" = "Grup de viatge";
"widget.collab.description" = "Qui ve, els darrers missatges i la votació oberta.";
"widget.intent.title" = "Tria el viatge";
"widget.intent.description" = "El viatge que mostra aquest giny.";
"widget.intent.trip" = "Viatge";
"widget.intent.auto" = "Automàtic";
"widget.intent.autoHint" = "El mateix viatge que el tauler";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Cesta";
"widget.trip.description" = "Odpočítává dny do další cesty a během ní ukazuje den a co přijde dál.";
"widget.today.name" = "Dnes";
"widget.today.description" = "Plán dne jako časová osa, s počasím a další zastávkou.";
"widget.bookings.name" = "Rezervace";
"widget.bookings.description" = "Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají.";
"widget.todos.name" = "Úkoly";
"widget.todos.description" = "Co ještě zbývá k cestě a jak daleko je balení.";
"widget.packing.name" = "Balení";
"widget.packing.description" = "Jak plná jsou zavazadla, jejich váha a co ještě chybí.";
"widget.atlas.name" = "Mapa světa";
"widget.atlas.description" = "Země, kde jsi byl, zářící na mapě.";
"widget.collab.name" = "Cestovní skupina";
"widget.collab.description" = "Kdo jede s tebou, nejnovější zprávy a otevřené hlasování.";
"widget.intent.title" = "Vybrat cestu";
"widget.intent.description" = "Cesta, kterou tento widget zobrazuje.";
"widget.intent.trip" = "Cesta";
"widget.intent.auto" = "Automaticky";
"widget.intent.autoHint" = "Stejná cesta jako na nástěnce";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Reise";
"widget.trip.description" = "Zählt die Tage bis zur nächsten Reise und zeigt unterwegs den Tag und was als Nächstes kommt.";
"widget.today.name" = "Heute";
"widget.today.description" = "Der Tagesplan als Zeitleiste, mit dem Wetter und dem nächsten Stopp.";
"widget.bookings.name" = "Buchungen";
"widget.bookings.description" = "Deine nächsten Flüge, Züge, Hotels und Tische, und wann sie anstehen.";
"widget.todos.name" = "To-dos";
"widget.todos.description" = "Was für die Reise noch offen ist und wie weit das Packen ist.";
"widget.packing.name" = "Packliste";
"widget.packing.description" = "Wie voll die Taschen sind, ihr Gewicht und was noch fehlt.";
"widget.atlas.name" = "Weltkarte";
"widget.atlas.description" = "Die Länder, in denen du warst, leuchtend auf der Karte.";
"widget.collab.name" = "Reisegruppe";
"widget.collab.description" = "Wer mitreist, die neuesten Nachrichten und die offene Abstimmung.";
"widget.intent.title" = "Reise wählen";
"widget.intent.description" = "Die Reise, die dieses Widget zeigt.";
"widget.intent.trip" = "Reise";
"widget.intent.auto" = "Automatisch";
"widget.intent.autoHint" = "Dieselbe Reise wie im Dashboard";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Ταξίδι";
"widget.trip.description" = "Μετρά αντίστροφα ως το επόμενο ταξίδι και στη διάρκειά του δείχνει τη μέρα και τι ακολουθεί.";
"widget.today.name" = "Σήμερα";
"widget.today.description" = "Το πρόγραμμα της ημέρας ως χρονολόγιο, με τον καιρό και την επόμενη στάση.";
"widget.bookings.name" = "Κρατήσεις";
"widget.bookings.description" = "Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι.";
"widget.todos.name" = "Εκκρεμότητες";
"widget.todos.description" = "Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα.";
"widget.packing.name" = "Αποσκευές";
"widget.packing.description" = "Πόσο γεμάτες είναι οι τσάντες, το βάρος τους και τι λείπει ακόμα.";
"widget.atlas.name" = "Παγκόσμιος χάρτης";
"widget.atlas.description" = "Οι χώρες που έχεις επισκεφθεί, φωτεινές στον χάρτη.";
"widget.collab.name" = "Ομάδα ταξιδιού";
"widget.collab.description" = "Ποιος έρχεται, τα τελευταία μηνύματα και η ανοιχτή ψηφοφορία.";
"widget.intent.title" = "Επιλογή ταξιδιού";
"widget.intent.description" = "Το ταξίδι που δείχνει αυτό το widget.";
"widget.intent.trip" = "Ταξίδι";
"widget.intent.auto" = "Αυτόματα";
"widget.intent.autoHint" = "Το ίδιο ταξίδι με τον πίνακα";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Trip";
"widget.trip.description" = "Counts down to your next trip, then shows the day and what comes next.";
"widget.today.name" = "Today";
"widget.today.description" = "Today's plan as a timeline, with the weather and what is next.";
"widget.bookings.name" = "Bookings";
"widget.bookings.description" = "Your next flights, trains, hotels and tables, and when they are due.";
"widget.todos.name" = "To-dos";
"widget.todos.description" = "What is still open for the trip, and how far the packing has come.";
"widget.packing.name" = "Packing";
"widget.packing.description" = "How full the bags are, their weight, and what is still missing.";
"widget.atlas.name" = "World map";
"widget.atlas.description" = "The countries you have been to, glowing on the map.";
"widget.collab.name" = "Travel group";
"widget.collab.description" = "Who comes along, the latest messages and the open poll.";
"widget.intent.title" = "Choose trip";
"widget.intent.description" = "The trip this widget shows.";
"widget.intent.trip" = "Trip";
"widget.intent.auto" = "Automatic";
"widget.intent.autoHint" = "Same trip as the dashboard";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Viaje";
"widget.trip.description" = "Cuenta los días hasta tu próximo viaje y, durante él, muestra el día y lo que sigue.";
"widget.today.name" = "Hoy";
"widget.today.description" = "El plan del día como línea de tiempo, con el tiempo y la próxima parada.";
"widget.bookings.name" = "Reservas";
"widget.bookings.description" = "Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan.";
"widget.todos.name" = "Tareas";
"widget.todos.description" = "Lo que aún queda para el viaje y cómo va la maleta.";
"widget.packing.name" = "Equipaje";
"widget.packing.description" = "Lo llenas que van las maletas, su peso y lo que aún falta.";
"widget.atlas.name" = "Mapa del mundo";
"widget.atlas.description" = "Los países donde has estado, brillando en el mapa.";
"widget.collab.name" = "Grupo de viaje";
"widget.collab.description" = "Quién viene, los últimos mensajes y la votación abierta.";
"widget.intent.title" = "Elegir viaje";
"widget.intent.description" = "El viaje que muestra este widget.";
"widget.intent.trip" = "Viaje";
"widget.intent.auto" = "Automático";
"widget.intent.autoHint" = "El mismo viaje que el panel";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Reis";
"widget.trip.description" = "Loeb päevi järgmise reisini ja näitab reisi ajal päeva ning seda, mis järgmisena tuleb.";
"widget.today.name" = "Täna";
"widget.today.description" = "Päeva plaan ajajoonena, koos ilma ja järgmise peatusega.";
"widget.bookings.name" = "Broneeringud";
"widget.bookings.description" = "Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on.";
"widget.todos.name" = "Ülesanded";
"widget.todos.description" = "Mis reisi jaoks veel tegemata on ja kui kaugel pakkimine on.";
"widget.packing.name" = "Pakkimine";
"widget.packing.description" = "Kui täis kotid on, nende kaal ja mis veel puudu on.";
"widget.atlas.name" = "Maailmakaart";
"widget.atlas.description" = "Riigid, kus oled käinud, kaardil helendamas.";
"widget.collab.name" = "Reisiseltskond";
"widget.collab.description" = "Kes tuleb kaasa, viimased sõnumid ja avatud hääletus.";
"widget.intent.title" = "Vali reis";
"widget.intent.description" = "Reis, mida see vidin näitab.";
"widget.intent.trip" = "Reis";
"widget.intent.auto" = "Automaatne";
"widget.intent.autoHint" = "Sama reis mis töölaual";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Voyage";
"widget.trip.description" = "Compte les jours jusqu'au prochain voyage, puis montre la journée et la suite pendant le voyage.";
"widget.today.name" = "Aujourd'hui";
"widget.today.description" = "Le programme du jour en frise, avec la météo et la prochaine étape.";
"widget.bookings.name" = "Réservations";
"widget.bookings.description" = "Vos prochains vols, trains, hôtels et tables, et quand ils arrivent.";
"widget.todos.name" = "À faire";
"widget.todos.description" = "Ce qui reste à faire pour le voyage et où en sont les bagages.";
"widget.packing.name" = "Bagages";
"widget.packing.description" = "Le remplissage des sacs, leur poids et ce qui manque encore.";
"widget.atlas.name" = "Carte du monde";
"widget.atlas.description" = "Les pays où vous êtes allé, illuminés sur la carte.";
"widget.collab.name" = "Groupe de voyage";
"widget.collab.description" = "Qui vient, les derniers messages et le sondage ouvert.";
"widget.intent.title" = "Choisir le voyage";
"widget.intent.description" = "Le voyage affiché par ce widget.";
"widget.intent.trip" = "Voyage";
"widget.intent.auto" = "Automatique";
"widget.intent.autoHint" = "Le même voyage que le tableau de bord";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "טיול";
"widget.trip.description" = "סופר את הימים עד הטיול הבא, ובמהלכו מציג את היום ואת מה שבא אחריו.";
"widget.today.name" = "היום";
"widget.today.description" = "תוכנית היום כציר זמן, עם מזג האוויר והעצירה הבאה.";
"widget.bookings.name" = "הזמנות";
"widget.bookings.description" = "הטיסות, הרכבות, המלונות והשולחנות הבאים שלך, ומתי הם.";
"widget.todos.name" = "משימות";
"widget.todos.description" = "מה עוד פתוח לטיול וכמה התקדמה האריזה.";
"widget.packing.name" = "אריזה";
"widget.packing.description" = "כמה מלאים התיקים, המשקל שלהם ומה עוד חסר.";
"widget.atlas.name" = "מפת העולם";
"widget.atlas.description" = "המדינות שביקרת בהן, זוהרות על המפה.";
"widget.collab.name" = "קבוצת הטיול";
"widget.collab.description" = "מי מצטרף, ההודעות האחרונות והסקר הפתוח.";
"widget.intent.title" = "בחירת טיול";
"widget.intent.description" = "הטיול שהווידג׳ט הזה מציג.";
"widget.intent.trip" = "טיול";
"widget.intent.auto" = "אוטומטי";
"widget.intent.autoHint" = "אותו טיול כמו בלוח הבקרה";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Utazás";
"widget.trip.description" = "Visszaszámol a következő utazásig, útközben pedig a napot és a következő programot mutatja.";
"widget.today.name" = "Ma";
"widget.today.description" = "A nap terve idővonalon, az időjárással és a következő megállóval.";
"widget.bookings.name" = "Foglalások";
"widget.bookings.description" = "A következő járataid, vonataid, szállásaid és asztalfoglalásaid, és hogy mikor esedékesek.";
"widget.todos.name" = "Teendők";
"widget.todos.description" = "Ami még hátravan az utazáshoz, és hol tart a csomagolás.";
"widget.packing.name" = "Csomagolás";
"widget.packing.description" = "Mennyire telik a táska, a súlya, és mi hiányzik még.";
"widget.atlas.name" = "Világtérkép";
"widget.atlas.description" = "Az országok, ahol jártál, világítva a térképen.";
"widget.collab.name" = "Útitársak";
"widget.collab.description" = "Ki jön veled, a legújabb üzenetek és a nyitott szavazás.";
"widget.intent.title" = "Utazás választása";
"widget.intent.description" = "Az utazás, amelyet ez a widget mutat.";
"widget.intent.trip" = "Utazás";
"widget.intent.auto" = "Automatikus";
"widget.intent.autoHint" = "Ugyanaz az utazás, mint az irányítópulton";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Perjalanan";
"widget.trip.description" = "Menghitung mundur ke perjalanan berikutnya, lalu menampilkan hari dan apa yang berikutnya.";
"widget.today.name" = "Hari ini";
"widget.today.description" = "Rencana hari ini sebagai linimasa, dengan cuaca dan perhentian berikutnya.";
"widget.bookings.name" = "Pemesanan";
"widget.bookings.description" = "Penerbangan, kereta, hotel, dan meja berikutnya, beserta waktunya.";
"widget.todos.name" = "Tugas";
"widget.todos.description" = "Yang masih tersisa untuk perjalanan dan sejauh mana persiapan barang.";
"widget.packing.name" = "Barang bawaan";
"widget.packing.description" = "Seberapa penuh tas, beratnya, dan yang masih kurang.";
"widget.atlas.name" = "Peta dunia";
"widget.atlas.description" = "Negara yang pernah kamu kunjungi, bersinar di peta.";
"widget.collab.name" = "Rombongan";
"widget.collab.description" = "Siapa yang ikut, pesan terbaru, dan polling yang terbuka.";
"widget.intent.title" = "Pilih perjalanan";
"widget.intent.description" = "Perjalanan yang ditampilkan widget ini.";
"widget.intent.trip" = "Perjalanan";
"widget.intent.auto" = "Otomatis";
"widget.intent.autoHint" = "Perjalanan yang sama dengan dasbor";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Viaggio";
"widget.trip.description" = "Conta i giorni al prossimo viaggio e durante il viaggio mostra la giornata e cosa viene dopo.";
"widget.today.name" = "Oggi";
"widget.today.description" = "Il programma del giorno come linea del tempo, con il meteo e la prossima tappa.";
"widget.bookings.name" = "Prenotazioni";
"widget.bookings.description" = "I tuoi prossimi voli, treni, hotel e tavoli, e quando sono.";
"widget.todos.name" = "Da fare";
"widget.todos.description" = "Cosa manca ancora per il viaggio e a che punto è la valigia.";
"widget.packing.name" = "Bagagli";
"widget.packing.description" = "Quanto sono piene le borse, il loro peso e cosa manca ancora.";
"widget.atlas.name" = "Mappa del mondo";
"widget.atlas.description" = "I paesi in cui sei stato, illuminati sulla mappa.";
"widget.collab.name" = "Gruppo di viaggio";
"widget.collab.description" = "Chi viene, gli ultimi messaggi e il sondaggio aperto.";
"widget.intent.title" = "Scegli viaggio";
"widget.intent.description" = "Il viaggio mostrato da questo widget.";
"widget.intent.trip" = "Viaggio";
"widget.intent.auto" = "Automatico";
"widget.intent.autoHint" = "Lo stesso viaggio della dashboard";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "旅行";
"widget.trip.description" = "次の旅行までをカウントダウンし、旅行中はその日の予定と次の行き先を表示します。";
"widget.today.name" = "今日";
"widget.today.description" = "今日の予定をタイムラインで、天気と次の予定とともに表示します。";
"widget.bookings.name" = "予約";
"widget.bookings.description" = "次のフライト、列車、ホテル、レストランとその日時。";
"widget.todos.name" = "やること";
"widget.todos.description" = "旅行のために残っていることと、荷造りの進み具合。";
"widget.packing.name" = "持ち物";
"widget.packing.description" = "バッグの詰め具合と重さ、まだ足りないもの。";
"widget.atlas.name" = "世界地図";
"widget.atlas.description" = "訪れた国が地図上で光ります。";
"widget.collab.name" = "旅行グループ";
"widget.collab.description" = "一緒に行くメンバー、最新のメッセージ、受付中の投票。";
"widget.intent.title" = "旅行を選択";
"widget.intent.description" = "このウィジェットに表示する旅行。";
"widget.intent.trip" = "旅行";
"widget.intent.auto" = "自動";
"widget.intent.autoHint" = "ダッシュボードと同じ旅行";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "여행";
"widget.trip.description" = "다음 여행까지 남은 날을 세고, 여행 중에는 오늘과 다음 일정을 보여 줍니다.";
"widget.today.name" = "오늘";
"widget.today.description" = "오늘 일정을 타임라인으로, 날씨와 다음 장소와 함께 보여 줍니다.";
"widget.bookings.name" = "예약";
"widget.bookings.description" = "다가오는 항공편, 기차, 호텔, 식당과 그 시간.";
"widget.todos.name" = "할 일";
"widget.todos.description" = "여행을 위해 남은 일과 짐 싸기 진행 상황.";
"widget.packing.name" = "짐 싸기";
"widget.packing.description" = "가방이 얼마나 찼는지, 무게, 아직 빠진 것.";
"widget.atlas.name" = "세계 지도";
"widget.atlas.description" = "다녀온 나라가 지도 위에서 빛납니다.";
"widget.collab.name" = "여행 그룹";
"widget.collab.description" = "함께 가는 사람, 최근 메시지, 진행 중인 투표.";
"widget.intent.title" = "여행 선택";
"widget.intent.description" = "이 위젯에 표시할 여행.";
"widget.intent.trip" = "여행";
"widget.intent.auto" = "자동";
"widget.intent.autoHint" = "대시보드와 같은 여행";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Reis";
"widget.trip.description" = "Telt af naar je volgende reis en toont onderweg de dag en wat er hierna komt.";
"widget.today.name" = "Vandaag";
"widget.today.description" = "Het dagplan als tijdlijn, met het weer en de volgende stop.";
"widget.bookings.name" = "Boekingen";
"widget.bookings.description" = "Je volgende vluchten, treinen, hotels en tafels, en wanneer ze zijn.";
"widget.todos.name" = "Taken";
"widget.todos.description" = "Wat er nog openstaat voor de reis en hoe ver het inpakken is.";
"widget.packing.name" = "Bagage";
"widget.packing.description" = "Hoe vol de tassen zijn, hun gewicht en wat er nog mist.";
"widget.atlas.name" = "Wereldkaart";
"widget.atlas.description" = "De landen waar je bent geweest, oplichtend op de kaart.";
"widget.collab.name" = "Reisgezelschap";
"widget.collab.description" = "Wie er meegaat, de nieuwste berichten en de open peiling.";
"widget.intent.title" = "Reis kiezen";
"widget.intent.description" = "De reis die deze widget toont.";
"widget.intent.trip" = "Reis";
"widget.intent.auto" = "Automatisch";
"widget.intent.autoHint" = "Dezelfde reis als het dashboard";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Podróż";
"widget.trip.description" = "Odlicza dni do następnej podróży, a w trakcie pokazuje dzień i to, co dalej.";
"widget.today.name" = "Dziś";
"widget.today.description" = "Plan dnia jako oś czasu, z pogodą i kolejnym przystankiem.";
"widget.bookings.name" = "Rezerwacje";
"widget.bookings.description" = "Twoje najbliższe loty, pociągi, hotele i stoliki oraz ich terminy.";
"widget.todos.name" = "Zadania";
"widget.todos.description" = "Co jeszcze zostało do zrobienia przed podróżą i jak idzie pakowanie.";
"widget.packing.name" = "Pakowanie";
"widget.packing.description" = "Jak pełne są torby, ich waga i czego jeszcze brakuje.";
"widget.atlas.name" = "Mapa świata";
"widget.atlas.description" = "Kraje, w których byłeś, świecące na mapie.";
"widget.collab.name" = "Grupa podróży";
"widget.collab.description" = "Kto jedzie, najnowsze wiadomości i otwarta ankieta.";
"widget.intent.title" = "Wybierz podróż";
"widget.intent.description" = "Podróż, którą pokazuje ten widżet.";
"widget.intent.trip" = "Podróż";
"widget.intent.auto" = "Automatycznie";
"widget.intent.autoHint" = "Ta sama podróż co na pulpicie";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Viagem";
"widget.trip.description" = "Conta os dias até a próxima viagem e, durante ela, mostra o dia e o que vem a seguir.";
"widget.today.name" = "Hoje";
"widget.today.description" = "O plano do dia como linha do tempo, com o clima e a próxima parada.";
"widget.bookings.name" = "Reservas";
"widget.bookings.description" = "Seus próximos voos, trens, hotéis e mesas, e quando acontecem.";
"widget.todos.name" = "Tarefas";
"widget.todos.description" = "O que ainda falta para a viagem e quanto da mala já está pronto.";
"widget.packing.name" = "Bagagem";
"widget.packing.description" = "Quão cheias estão as malas, o peso e o que ainda falta.";
"widget.atlas.name" = "Mapa-múndi";
"widget.atlas.description" = "Os países em que você esteve, brilhando no mapa.";
"widget.collab.name" = "Grupo de viagem";
"widget.collab.description" = "Quem vai junto, as mensagens recentes e a votação aberta.";
"widget.intent.title" = "Escolher viagem";
"widget.intent.description" = "A viagem que este widget mostra.";
"widget.intent.trip" = "Viagem";
"widget.intent.auto" = "Automático";
"widget.intent.autoHint" = "A mesma viagem do painel";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Поездка";
"widget.trip.description" = "Отсчитывает дни до следующей поездки, а в пути показывает день и что дальше.";
"widget.today.name" = "Сегодня";
"widget.today.description" = "План дня в виде ленты времени, с погодой и следующей остановкой.";
"widget.bookings.name" = "Бронирования";
"widget.bookings.description" = "Ближайшие рейсы, поезда, отели и столики и когда они.";
"widget.todos.name" = "Задачи";
"widget.todos.description" = "Что ещё осталось сделать к поездке и как идут сборы.";
"widget.packing.name" = "Сборы";
"widget.packing.description" = "Насколько заполнены сумки, их вес и чего ещё не хватает.";
"widget.atlas.name" = "Карта мира";
"widget.atlas.description" = "Страны, где вы были, светятся на карте.";
"widget.collab.name" = "Группа поездки";
"widget.collab.description" = "Кто едет, последние сообщения и открытый опрос.";
"widget.intent.title" = "Выбрать поездку";
"widget.intent.description" = "Поездка, которую показывает этот виджет.";
"widget.intent.trip" = "Поездка";
"widget.intent.auto" = "Автоматически";
"widget.intent.autoHint" = "Та же поездка, что и на главной";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Cesta";
"widget.trip.description" = "Odpočítava dni do ďalšej cesty a počas nej ukazuje deň a čo príde ďalej.";
"widget.today.name" = "Dnes";
"widget.today.description" = "Plán dňa ako časová os, s počasím a ďalšou zastávkou.";
"widget.bookings.name" = "Rezervácie";
"widget.bookings.description" = "Tvoje ďalšie lety, vlaky, hotely a stoly a kedy ťa čakajú.";
"widget.todos.name" = "Úlohy";
"widget.todos.description" = "Čo ešte zostáva na cestu a ako ďaleko je balenie.";
"widget.packing.name" = "Balenie";
"widget.packing.description" = "Aké plné sú tašky, ich váha a čo ešte chýba.";
"widget.atlas.name" = "Mapa sveta";
"widget.atlas.description" = "Krajiny, kde si bol, žiariace na mape.";
"widget.collab.name" = "Cestovná skupina";
"widget.collab.description" = "Kto ide s tebou, najnovšie správy a otvorené hlasovanie.";
"widget.intent.title" = "Vybrať cestu";
"widget.intent.description" = "Cesta, ktorú zobrazuje tento widget.";
"widget.intent.trip" = "Cesta";
"widget.intent.auto" = "Automaticky";
"widget.intent.autoHint" = "Rovnaká cesta ako na nástenke";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Resa";
"widget.trip.description" = "Räknar ner till nästa resa och visar under resan dagen och vad som kommer härnäst.";
"widget.today.name" = "Idag";
"widget.today.description" = "Dagens plan som tidslinje, med vädret och nästa stopp.";
"widget.bookings.name" = "Bokningar";
"widget.bookings.description" = "Dina nästa flyg, tåg, hotell och bord, och när de är.";
"widget.todos.name" = "Att göra";
"widget.todos.description" = "Vad som återstår inför resan och hur långt packningen har kommit.";
"widget.packing.name" = "Packning";
"widget.packing.description" = "Hur fulla väskorna är, deras vikt och vad som fortfarande saknas.";
"widget.atlas.name" = "Världskarta";
"widget.atlas.description" = "Länderna du har besökt, lysande på kartan.";
"widget.collab.name" = "Resesällskap";
"widget.collab.description" = "Vem som följer med, de senaste meddelandena och den öppna omröstningen.";
"widget.intent.title" = "Välj resa";
"widget.intent.description" = "Resan som den här widgeten visar.";
"widget.intent.trip" = "Resa";
"widget.intent.auto" = "Automatiskt";
"widget.intent.autoHint" = "Samma resa som på startsidan";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "ทริป";
"widget.trip.description" = "นับถอยหลังสู่ทริปถัดไป และระหว่างทริปแสดงวันนี้และสิ่งที่จะตามมา";
"widget.today.name" = "วันนี้";
"widget.today.description" = "แผนของวันนี้เป็นไทม์ไลน์ พร้อมสภาพอากาศและจุดหมายถัดไป";
"widget.bookings.name" = "การจอง";
"widget.bookings.description" = "เที่ยวบิน รถไฟ โรงแรม และร้านอาหารถัดไปของคุณ และเวลาของแต่ละรายการ";
"widget.todos.name" = "สิ่งที่ต้องทำ";
"widget.todos.description" = "สิ่งที่ยังค้างอยู่สำหรับทริป และความคืบหน้าการจัดกระเป๋า";
"widget.packing.name" = "จัดกระเป๋า";
"widget.packing.description" = "กระเป๋าเต็มแค่ไหน น้ำหนัก และอะไรที่ยังขาด";
"widget.atlas.name" = "แผนที่โลก";
"widget.atlas.description" = "ประเทศที่คุณเคยไป ส่องแสงบนแผนที่";
"widget.collab.name" = "กลุ่มเดินทาง";
"widget.collab.description" = "ใครไปด้วย ข้อความล่าสุด และโหวตที่เปิดอยู่";
"widget.intent.title" = "เลือกทริป";
"widget.intent.description" = "ทริปที่วิดเจ็ตนี้แสดง";
"widget.intent.trip" = "ทริป";
"widget.intent.auto" = "อัตโนมัติ";
"widget.intent.autoHint" = "ทริปเดียวกับแดชบอร์ด";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Seyahat";
"widget.trip.description" = "Sonraki seyahate kalan günleri sayar, yolculukta günü ve sırada ne olduğunu gösterir.";
"widget.today.name" = "Bugün";
"widget.today.description" = "Günün planı zaman çizelgesi olarak, hava durumu ve sonraki durakla.";
"widget.bookings.name" = "Rezervasyonlar";
"widget.bookings.description" = "Sıradaki uçuşların, trenlerin, otellerin ve masaların, ne zaman oldukları ile.";
"widget.todos.name" = "Yapılacaklar";
"widget.todos.description" = "Seyahat için hâlâ açık olanlar ve bavulun ne durumda olduğu.";
"widget.packing.name" = "Bavul";
"widget.packing.description" = "Çantaların ne kadar dolu olduğu, ağırlıkları ve eksikler.";
"widget.atlas.name" = "Dünya haritası";
"widget.atlas.description" = "Bulunduğun ülkeler haritada parlıyor.";
"widget.collab.name" = "Seyahat grubu";
"widget.collab.description" = "Kimler geliyor, son mesajlar ve açık anket.";
"widget.intent.title" = "Seyahat seç";
"widget.intent.description" = "Bu widget'ın gösterdiği seyahat.";
"widget.intent.trip" = "Seyahat";
"widget.intent.auto" = "Otomatik";
"widget.intent.autoHint" = "Paneldeki seyahatin aynısı";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Подорож";
"widget.trip.description" = "Відлічує дні до наступної подорожі, а в дорозі показує день і що далі.";
"widget.today.name" = "Сьогодні";
"widget.today.description" = "План дня у вигляді стрічки часу, з погодою і наступною зупинкою.";
"widget.bookings.name" = "Бронювання";
"widget.bookings.description" = "Найближчі рейси, поїзди, готелі й столики та коли вони.";
"widget.todos.name" = "Завдання";
"widget.todos.description" = "Що ще лишилося зробити до подорожі і як просувається пакування.";
"widget.packing.name" = "Пакування";
"widget.packing.description" = "Наскільки заповнені сумки, їхня вага і чого ще бракує.";
"widget.atlas.name" = "Мапа світу";
"widget.atlas.description" = "Країни, де ви були, світяться на мапі.";
"widget.collab.name" = "Група подорожі";
"widget.collab.description" = "Хто їде, останні повідомлення і відкрите опитування.";
"widget.intent.title" = "Вибрати подорож";
"widget.intent.description" = "Подорож, яку показує цей віджет.";
"widget.intent.trip" = "Подорож";
"widget.intent.auto" = "Автоматично";
"widget.intent.autoHint" = "Та сама подорож, що й на панелі";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "Chuyến đi";
"widget.trip.description" = "Đếm ngược đến chuyến đi tiếp theo, trong chuyến đi thì hiện ngày và điểm tiếp theo.";
"widget.today.name" = "Hôm nay";
"widget.today.description" = "Kế hoạch hôm nay dạng dòng thời gian, kèm thời tiết và điểm dừng tiếp theo.";
"widget.bookings.name" = "Đặt chỗ";
"widget.bookings.description" = "Các chuyến bay, tàu, khách sạn và bàn sắp tới của bạn, cùng thời gian.";
"widget.todos.name" = "Việc cần làm";
"widget.todos.description" = "Những gì còn lại cho chuyến đi và việc soạn hành lý đã tới đâu.";
"widget.packing.name" = "Hành lý";
"widget.packing.description" = "Túi đã đầy đến đâu, cân nặng và những gì còn thiếu.";
"widget.atlas.name" = "Bản đồ thế giới";
"widget.atlas.description" = "Những quốc gia bạn đã đến, sáng lên trên bản đồ.";
"widget.collab.name" = "Nhóm du lịch";
"widget.collab.description" = "Ai đi cùng, tin nhắn mới nhất và cuộc bình chọn đang mở.";
"widget.intent.title" = "Chọn chuyến đi";
"widget.intent.description" = "Chuyến đi mà tiện ích này hiển thị.";
"widget.intent.trip" = "Chuyến đi";
"widget.intent.auto" = "Tự động";
"widget.intent.autoHint" = "Cùng chuyến đi với bảng điều khiển";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "旅行";
"widget.trip.description" = "倒数到下一次旅行,旅途中显示当天行程和接下来的安排。";
"widget.today.name" = "今天";
"widget.today.description" = "以时间线显示今天的计划,附带天气和下一站。";
"widget.bookings.name" = "预订";
"widget.bookings.description" = "你接下来的航班、火车、酒店和餐厅,以及时间。";
"widget.todos.name" = "待办";
"widget.todos.description" = "旅行还剩哪些事没做,以及行李收拾到哪一步。";
"widget.packing.name" = "行李";
"widget.packing.description" = "包装得多满、重量多少,以及还缺什么。";
"widget.atlas.name" = "世界地图";
"widget.atlas.description" = "你去过的国家在地图上亮起。";
"widget.collab.name" = "旅行小组";
"widget.collab.description" = "谁一起去、最新消息和进行中的投票。";
"widget.intent.title" = "选择旅行";
"widget.intent.description" = "此小组件显示的旅行。";
"widget.intent.trip" = "旅行";
"widget.intent.auto" = "自动";
"widget.intent.autoHint" = "与仪表板相同的旅行";
@@ -0,0 +1,20 @@
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
"widget.trip.name" = "旅行";
"widget.trip.description" = "倒數到下一次旅行,旅途中顯示當天行程與接下來的安排。";
"widget.today.name" = "今天";
"widget.today.description" = "以時間軸顯示今天的計畫,附上天氣與下一站。";
"widget.bookings.name" = "預訂";
"widget.bookings.description" = "你接下來的航班、火車、飯店與餐廳,以及時間。";
"widget.todos.name" = "待辦";
"widget.todos.description" = "旅行還剩哪些事沒做,以及行李整理到哪一步。";
"widget.packing.name" = "行李";
"widget.packing.description" = "包包裝得多滿、重量多少,以及還缺什麼。";
"widget.atlas.name" = "世界地圖";
"widget.atlas.description" = "你去過的國家在地圖上亮起。";
"widget.collab.name" = "旅行小組";
"widget.collab.description" = "誰一起去、最新訊息與進行中的投票。";
"widget.intent.title" = "選擇旅行";
"widget.intent.description" = "此小工具顯示的旅行。";
"widget.intent.trip" = "旅行";
"widget.intent.auto" = "自動";
"widget.intent.autoHint" = "與儀表板相同的旅行";
+3
View File
@@ -0,0 +1,3 @@
Sources/WidgetPreviews/Widgets/
.build/
out/
+14
View File
@@ -0,0 +1,14 @@
// swift-tools-version:5.9
import PackageDescription
// Renders the widget views (client/ios/App/TREKWidgets/*Content.swift and the
// files they use) to PNGs on a Mac, so a change to a widget can be looked at
// without a phone: .github/workflows/native.yml copies them into Sources/ and
// uploads what this writes. Not part of the app.
let package = Package(
name: "WidgetPreviews",
platforms: [.macOS(.v14)],
targets: [
.executableTarget(name: "WidgetPreviews", path: "Sources/WidgetPreviews"),
]
)
@@ -0,0 +1,388 @@
import AppKit
import SwiftUI
/// Home and lock screens of an iPhone filled with the widgets, at the size of
/// an iPhone 16 Pro (402 by 874 points), for showing what the widgets look like
/// where they live. The app icon comes from the asset catalogue.
enum Mockups {
@MainActor
static func render(to out: URL, iconPath: String?, now: Date) throws {
let icon = iconPath.flatMap { NSImage(contentsOfFile: $0) }.map { Image(nsImage: $0) }
var upcoming = WidgetSnapshot.sample(running: false, now: now)
upcoming.trip?.cover = Render.sampleCover()
var running = WidgetSnapshot.sample(running: true, now: now)
running.trip?.cover = upcoming.trip?.cover
let pages: [(String, ColorScheme, AnyView)] = [
("1-travelling", .dark, AnyView(PhonePage(wallpaper: .night, icon: icon, page: 0) {
WidgetFrame(kind: .trip, family: .large, snapshot: running, now: now)
WidgetFrame(kind: .today, family: .medium, snapshot: running, now: now)
AppRow(icon: icon, apps: [.maps, .photos, .camera, .weather])
})),
("2-countdown", .light, AnyView(PhonePage(wallpaper: .dawn, icon: icon, page: 1) {
WidgetFrame(kind: .trip, family: .medium, snapshot: upcoming, now: now)
HStack(spacing: 24) {
WidgetFrame(kind: .bookings, family: .small, snapshot: upcoming, now: now)
WidgetFrame(kind: .packing, family: .small, snapshot: upcoming, now: now)
}
WidgetFrame(kind: .todos, family: .medium, snapshot: upcoming, now: now)
AppRow(icon: icon, apps: [.calendar, .trek, .mail, .notes])
})),
("3-explorer", .dark, AnyView(PhonePage(wallpaper: .ocean, icon: icon, page: 2) {
WidgetFrame(kind: .atlas, family: .large, snapshot: upcoming, now: now)
WidgetFrame(kind: .collab, family: .medium, snapshot: running, now: now)
AppRow(icon: icon, apps: [.photos, .trek, .music, .settings])
})),
("4-getting-ready", .light, AnyView(PhonePage(wallpaper: .sand, icon: icon, page: 3) {
WidgetFrame(kind: .packing, family: .large, snapshot: upcoming, now: now)
HStack(spacing: 24) {
WidgetFrame(kind: .todos, family: .small, snapshot: upcoming, now: now)
WidgetFrame(kind: .atlas, family: .small, snapshot: upcoming, now: now)
}
AppRow(icon: icon, apps: [.wallet, .trek, .maps, .clock])
})),
("5-on-the-road", .dark, AnyView(PhonePage(wallpaper: .night, icon: icon, page: 4) {
HStack(spacing: 24) {
WidgetFrame(kind: .trip, family: .small, snapshot: running, now: now)
WidgetFrame(kind: .collab, family: .small, snapshot: running, now: now)
}
WidgetFrame(kind: .bookings, family: .medium, snapshot: running, now: now)
HStack(spacing: 24) {
WidgetFrame(kind: .bookings, family: .small, snapshot: running, now: now)
WidgetFrame(kind: .atlas, family: .small, snapshot: running, now: now)
}
AppRow(icon: icon, apps: [.maps, .trek, .camera, .weather])
})),
("6-lock-screen", .dark, AnyView(LockPage(snapshot: running, now: now))),
]
for (name, scheme, view) in pages {
try Render.write(
view.environment(\.colorScheme, scheme).environment(\.trekStaticPreview, true),
to: out.appendingPathComponent("mockup-\(name).png")
)
}
}
}
// MARK: - The phone
enum Wallpaper {
case night, dawn, ocean, sand
@ViewBuilder var view: some View {
switch self {
case .night:
ZStack {
LinearGradient(colors: [Color(hex: 0x0E1230), Color(hex: 0x1B1340), Color(hex: 0x05060C)], startPoint: .top, endPoint: .bottom)
RadialGradient(colors: [Color(hex: 0x6A3FD8).opacity(0.75), .clear], center: UnitPoint(x: 0.15, y: 0.25), startRadius: 0, endRadius: 360)
RadialGradient(colors: [Color(hex: 0x1E8FD8).opacity(0.6), .clear], center: UnitPoint(x: 0.9, y: 0.7), startRadius: 0, endRadius: 380)
}
case .dawn:
ZStack {
LinearGradient(colors: [Color(hex: 0xF6D5C3), Color(hex: 0xE9B8C9), Color(hex: 0xB9C6EE)], startPoint: .top, endPoint: .bottom)
RadialGradient(colors: [Color(hex: 0xFFE2A8).opacity(0.9), .clear], center: UnitPoint(x: 0.8, y: 0.15), startRadius: 0, endRadius: 320)
RadialGradient(colors: [Color(hex: 0x8FA8F0).opacity(0.7), .clear], center: UnitPoint(x: 0.1, y: 0.85), startRadius: 0, endRadius: 360)
}
case .ocean:
ZStack {
LinearGradient(colors: [Color(hex: 0x03263A), Color(hex: 0x064D5C), Color(hex: 0x021018)], startPoint: .topLeading, endPoint: .bottomTrailing)
RadialGradient(colors: [Color(hex: 0x22C7A9).opacity(0.55), .clear], center: UnitPoint(x: 0.85, y: 0.2), startRadius: 0, endRadius: 340)
RadialGradient(colors: [Color(hex: 0x2E5BD8).opacity(0.55), .clear], center: UnitPoint(x: 0.1, y: 0.75), startRadius: 0, endRadius: 360)
}
case .sand:
ZStack {
LinearGradient(colors: [Color(hex: 0xEFE6D8), Color(hex: 0xE3D2BC), Color(hex: 0xCFD9D4)], startPoint: .top, endPoint: .bottom)
RadialGradient(colors: [Color(hex: 0xF4B183).opacity(0.65), .clear], center: UnitPoint(x: 0.2, y: 0.2), startRadius: 0, endRadius: 330)
RadialGradient(colors: [Color(hex: 0x9CC5B8).opacity(0.7), .clear], center: UnitPoint(x: 0.9, y: 0.8), startRadius: 0, endRadius: 360)
}
}
}
}
/// One page of the home screen: status bar, the widgets and apps, the page
/// dots and the dock, on the wallpaper, with the screen's rounded corners.
struct PhonePage<Content: View>: View {
let wallpaper: Wallpaper
let icon: Image?
let page: Int
@ViewBuilder var content: () -> Content
var body: some View {
ZStack(alignment: .top) {
wallpaper.view
VStack(spacing: 0) {
StatusBar(dark: wallpaper == .night || wallpaper == .ocean)
VStack(spacing: 22) { content() }
.padding(.top, 14)
Spacer(minLength: 0)
PageDots(count: 5, current: page)
.padding(.bottom, 12)
Dock(icon: icon)
.padding(.horizontal, 12)
.padding(.bottom, 14)
}
}
.frame(width: 402, height: 874)
.clipShape(RoundedRectangle(cornerRadius: 56, style: .continuous))
.padding(30)
.background(Color(hex: 0x111214))
}
}
struct StatusBar: View {
let dark: Bool
var body: some View {
HStack {
Text("9:41").font(.system(size: 17, weight: .semibold))
.frame(width: 120)
Spacer()
Capsule().fill(.black).frame(width: 124, height: 36)
Spacer()
HStack(spacing: 6) {
Image(systemName: "cellularbars")
Image(systemName: "wifi")
Image(systemName: "battery.100percent")
}
.font(.system(size: 15, weight: .semibold))
.frame(width: 120)
}
.foregroundStyle(dark ? .white : .black)
.padding(.top, 12)
.frame(height: 58)
}
}
struct PageDots: View {
let count: Int
let current: Int
var body: some View {
HStack(spacing: 8) {
ForEach(0..<count, id: \.self) { index in
Circle().fill(Color.white.opacity(index == current ? 0.95 : 0.4)).frame(width: 7, height: 7)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(Capsule().fill(Color.black.opacity(0.18)))
}
}
/// A generic app, drawn as a symbol on a gradient, never a real app's icon.
enum MockApp {
case trek, maps, photos, camera, weather, calendar, mail, notes, music, settings, wallet, clock, phone, messages, browser
var name: String {
switch self {
case .trek: return "TREK"
case .maps: return "Maps"
case .photos: return "Photos"
case .camera: return "Camera"
case .weather: return "Weather"
case .calendar: return "Calendar"
case .mail: return "Mail"
case .notes: return "Notes"
case .music: return "Music"
case .settings: return "Settings"
case .wallet: return "Wallet"
case .clock: return "Clock"
case .phone: return "Phone"
case .messages: return "Messages"
case .browser: return "Browser"
}
}
var look: (String, [Color], Color) {
switch self {
case .trek: return ("map.fill", [Color(hex: 0x202024), Color(hex: 0x0C0C0F)], .white)
case .maps: return ("map", [Color(hex: 0x6FD08C), Color(hex: 0x2FA37A)], .white)
case .photos: return ("camera.macro", [Color(hex: 0xFFFFFF), Color(hex: 0xF2F2F2)], Color(hex: 0xE8A13A))
case .camera: return ("camera.fill", [Color(hex: 0xC8C8CC), Color(hex: 0x8E8E93)], Color(hex: 0x2B2B2E))
case .weather: return ("cloud.sun.fill", [Color(hex: 0x5AB3F5), Color(hex: 0x2E6FD8)], .white)
case .calendar: return ("calendar", [Color(hex: 0xFFFFFF), Color(hex: 0xF4F4F4)], Color(hex: 0xE5484D))
case .mail: return ("envelope.fill", [Color(hex: 0x56B4FF), Color(hex: 0x1D7AF2)], .white)
case .notes: return ("note.text", [Color(hex: 0xFFE27A), Color(hex: 0xF5C542)], Color(hex: 0x5A4A12))
case .music: return ("music.note", [Color(hex: 0xFF6B81), Color(hex: 0xF0304F)], .white)
case .settings: return ("gearshape.fill", [Color(hex: 0xA6A6AB), Color(hex: 0x6E6E73)], .white)
case .wallet: return ("wallet.pass.fill", [Color(hex: 0x2B2B2E), Color(hex: 0x111113)], Color(hex: 0x7BC96F))
case .clock: return ("clock.fill", [Color(hex: 0x2B2B2E), Color(hex: 0x111113)], .white)
case .phone: return ("phone.fill", [Color(hex: 0x6FE38A), Color(hex: 0x34C759)], .white)
case .messages: return ("message.fill", [Color(hex: 0x6FE38A), Color(hex: 0x34C759)], .white)
case .browser: return ("safari.fill", [Color(hex: 0x56B4FF), Color(hex: 0x1D7AF2)], .white)
}
}
}
struct AppIcon: View {
let app: MockApp
let icon: Image?
var label = true
var body: some View {
VStack(spacing: 5) {
Group {
if app == .trek, let icon {
icon.resizable().scaledToFill()
} else {
let (symbol, colors, ink) = app.look
LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
.overlay(Image(systemName: symbol).font(.system(size: 28, weight: .medium)).foregroundStyle(ink))
}
}
.frame(width: 64, height: 64)
.clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
.shadow(color: .black.opacity(0.18), radius: 3, y: 1)
if label {
Text(app.name).font(.system(size: 12, weight: .medium)).foregroundStyle(.white).shadow(color: .black.opacity(0.35), radius: 2)
}
}
.frame(width: 80)
}
}
struct AppRow: View {
let icon: Image?
let apps: [MockApp]
var body: some View {
HStack(spacing: 13) {
ForEach(Array(apps.enumerated()), id: \.offset) { _, app in AppIcon(app: app, icon: icon) }
}
}
}
struct Dock: View {
let icon: Image?
var body: some View {
HStack(spacing: 13) {
ForEach([MockApp.phone, .browser, .messages, .trek], id: \.name) { app in
AppIcon(app: app, icon: icon, label: false)
}
}
.padding(.vertical, 14)
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: 38, style: .continuous).fill(Color.white.opacity(0.22)))
.overlay(RoundedRectangle(cornerRadius: 38, style: .continuous).stroke(Color.white.opacity(0.25), lineWidth: 0.6))
}
}
// MARK: - The lock screen
struct LockPage: View {
let snapshot: WidgetSnapshot
let now: Date
var body: some View {
VStack(spacing: 0) {
StatusBar(dark: true)
Image(systemName: "lock.fill").font(.system(size: 18, weight: .semibold)).foregroundStyle(.white).padding(.top, 6)
LockAccessory(kind: .trip, family: .inline, snapshot: snapshot, now: now)
.padding(.top, 10)
Text("9:41")
.font(.system(size: 104, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.92))
.padding(.top, -6)
HStack(spacing: 14) {
LockAccessory(kind: .trip, family: .circular, snapshot: snapshot, now: now)
LockAccessory(kind: .packing, family: .circular, snapshot: snapshot, now: now)
LockAccessory(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
}
.padding(.top, 4)
Spacer(minLength: 0)
LiveActivityMock(snapshot: snapshot, now: now)
.padding(.horizontal, 12)
HStack {
LockButton(symbol: "flashlight.off.fill")
Spacer()
LockButton(symbol: "camera.fill")
}
.padding(.horizontal, 46)
.padding(.top, 22)
Capsule().fill(.white).frame(width: 140, height: 5).padding(.top, 20).padding(.bottom, 10)
}
.frame(width: 402, height: 874)
// Behind, not inside: a photo filled into a stack would widen it past the screen.
.background(TripBackdrop(trip: snapshot.trip, shade: 0.35).frame(width: 402, height: 874).clipped())
.clipShape(RoundedRectangle(cornerRadius: 56, style: .continuous))
.padding(30)
.background(Color(hex: 0x111214))
}
}
private struct LockButton: View {
let symbol: String
var body: some View {
Circle().fill(Color.black.opacity(0.35)).frame(width: 50, height: 50)
.overlay(Image(systemName: symbol).font(.system(size: 19)).foregroundStyle(.white))
}
}
/// The travel day's Live Activity as the lock screen shows it (the real one is
/// TripDayLiveActivity, which needs ActivityKit).
struct LiveActivityMock: View {
let snapshot: WidgetSnapshot
let now: Date
var body: some View {
let today = snapshot.today
let minutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
let stops = today?.stops ?? []
let next = stops.first { ($0.minutes ?? -1) >= minutes } ?? stops.last
let done = stops.filter { ($0.minutes ?? Int.max) < minutes }.count
HStack(spacing: 14) {
ZStack {
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
Circle().trim(from: 0, to: stops.isEmpty ? 0 : Double(done) / Double(stops.count))
.stroke(Trek.confirmed, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white)
}
.frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(snapshot.trip?.title ?? "").font(.system(size: 11, weight: .bold)).tracking(0.5).textCase(.uppercase).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
Spacer(minLength: 4)
Text(today?.dayLabel ?? "").font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65))
}
HStack(alignment: .firstTextBaseline) {
Text(next?.name ?? "").font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
Spacer(minLength: 4)
Text(next?.time ?? "").font(Trek.number(18, .heavy)).foregroundStyle(.white)
}
DaySegments(index: done + 1, total: max(stops.count, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
.padding(.top, 3)
}
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 26, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.78)))
}
}
/// A lock-screen accessory as the system draws it: white on the wallpaper,
/// the circular ones on a soft disc, no box of their own.
struct LockAccessory: View {
let kind: WidgetKind
let family: TrekFamily
let snapshot: WidgetSnapshot
let now: Date
var body: some View {
let size = family.previewSize
Group {
switch kind {
case .trip: TripContent(snapshot: snapshot, family: family, now: now)
case .bookings: BookingsContent(snapshot: snapshot, family: family, now: now)
case .todos: TodosContent(snapshot: snapshot, family: family, now: now)
case .packing: PackingContent(snapshot: snapshot, family: family, now: now)
case .atlas: AtlasContent(snapshot: snapshot, family: family)
default: EmptyView()
}
}
.foregroundStyle(.white)
.frame(width: size.width, height: size.height)
.background(Circle().fill(Color.white.opacity(family == .circular ? 0.16 : 0)))
}
}
@@ -0,0 +1,272 @@
import AppKit
import SwiftUI
/// Draws every widget in every size, light and dark, for a trip ahead, a trip
/// under way and no trip, plus a home screen of them on a wallpaper per scheme.
/// Usage: WidgetPreviews <output directory>
@main
struct Render {
@MainActor
static func main() throws {
let out = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "out", isDirectory: true)
try FileManager.default.createDirectory(at: out, withIntermediateDirectories: true)
// A fixed afternoon, so the now line sits between the stops.
var components = Calendar.current.dateComponents([.year, .month, .day], from: Date())
components.hour = 12
components.minute = 10
let now = Calendar.current.date(from: components) ?? Date()
var withCover = WidgetSnapshot.sample(running: false, now: now)
withCover.trip?.cover = sampleCover()
var runningCover = WidgetSnapshot.sample(running: true, now: now)
runningCover.trip?.cover = withCover.trip?.cover
let states: [(String, WidgetSnapshot)] = [
("upcoming", .sample(running: false, now: now)),
("running", .sample(running: true, now: now)),
("empty", .sampleEmpty),
("cover", withCover),
("coverrunning", runningCover),
]
for scheme in [ColorScheme.light, .dark] {
let schemeName = scheme == .dark ? "dark" : "light"
for (stateName, snapshot) in states {
for kind in WidgetKind.allCases {
for family in kind.families {
let view = WidgetFrame(kind: kind, family: family, snapshot: snapshot, now: now)
.environment(\.colorScheme, scheme)
.environment(\.trekStaticPreview, true)
try write(view, to: out.appendingPathComponent("\(kind.rawValue)-\(family.rawValue)-\(stateName)-\(schemeName).png"))
}
}
}
let showcase = NewWidgets(snapshot: states[0].1, now: now)
.environment(\.colorScheme, scheme)
.environment(\.trekStaticPreview, true)
try write(showcase, to: out.appendingPathComponent("new-widgets-\(schemeName).png"))
for (stateName, snapshot) in states where stateName != "empty" {
let sheet = HomeScreen(snapshot: snapshot, now: now)
.environment(\.colorScheme, scheme)
.environment(\.trekStaticPreview, true)
try write(sheet, to: out.appendingPathComponent("home-\(stateName)-\(schemeName).png"))
}
let lock = LockScreen(snapshots: states.prefix(3).map(\.1), now: now).environment(\.colorScheme, scheme)
try write(lock, to: out.appendingPathComponent("lock-\(schemeName).png"))
}
let args = Array(CommandLine.arguments.dropFirst())
try Mockups.render(to: out, iconPath: args.count > 1 ? args[1] : nil, now: now)
print("wrote \(try FileManager.default.contentsOfDirectory(atPath: out.path).count) images to \(out.path)")
}
/// A stand-in for a trip's cover photo: dusk over layered mountains, as the
/// small JPEG the app would send.
@MainActor
static func sampleCover() -> String? {
let scene = ZStack(alignment: .bottom) {
LinearGradient(colors: [Color(hex: 0x2B3A67), Color(hex: 0xC0617A), Color(hex: 0xF3A65A)], startPoint: .top, endPoint: .bottom)
Circle().fill(Color(hex: 0xFFE2A8)).frame(width: 120, height: 120).offset(x: 140, y: -170).blur(radius: 2)
Ridge(height: 0.55, seed: 3).fill(Color(hex: 0x6B4A6E))
Ridge(height: 0.42, seed: 7).fill(Color(hex: 0x3E2E4F))
Ridge(height: 0.26, seed: 11).fill(Color(hex: 0x1C1726))
}
.frame(width: 720, height: 480)
let renderer = ImageRenderer(content: scene)
renderer.scale = 1
guard let image = renderer.nsImage, let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff),
let jpeg = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.74]) else { return nil }
return jpeg.base64EncodedString()
}
@MainActor
static func write<V: View>(_ view: V, to url: URL) throws {
let renderer = ImageRenderer(content: view)
renderer.scale = 3
guard let image = renderer.nsImage,
let tiff = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
let png = bitmap.representation(using: .png, properties: [:]) else {
throw NSError(domain: "WidgetPreviews", code: 1, userInfo: [NSLocalizedDescriptionKey: "could not render \(url.lastPathComponent)"])
}
try png.write(to: url)
}
}
enum WidgetKind: String, CaseIterable {
case trip, today, bookings, todos, packing, atlas, collab
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 .todos: return [.small, .medium, .large, .circular, .rectangular]
case .packing: return [.small, .medium, .large, .circular, .rectangular]
case .atlas: return [.small, .medium, .large, .circular, .rectangular, .inline]
case .collab: return [.small, .medium, .large, .rectangular]
}
}
}
/// One widget as the home screen draws it: its background, the 16 pt content
/// margin, the rounded corners; accessories white on a lock-screen tint.
struct WidgetFrame: View {
let kind: WidgetKind
let family: TrekFamily
let snapshot: WidgetSnapshot
let now: Date
var body: some View {
let 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).
content
.foregroundStyle(.white)
.frame(width: size.width, height: size.height)
.background(Circle().fill(Color.white.opacity(family == .circular ? 0.14 : 0)))
.padding(6)
.background(Color(hex: 0x1E2A3A))
} else {
content
.padding(16)
.frame(width: size.width, height: size.height)
.background(background)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
}
}
@ViewBuilder
private var content: some View {
switch kind {
case .trip: TripContent(snapshot: snapshot, family: family, now: now)
case .today: TodayContent(snapshot: snapshot, family: family, now: now)
case .bookings: BookingsContent(snapshot: snapshot, family: family, now: now)
case .todos: TodosContent(snapshot: snapshot, family: family, now: now)
case .packing: PackingContent(snapshot: snapshot, family: family, now: now)
case .atlas: AtlasContent(snapshot: snapshot, family: family)
case .collab: CollabContent(snapshot: snapshot, family: family)
}
}
@ViewBuilder
private var background: some View {
if kind == .trip && snapshot.trip != nil {
TripBackdrop(trip: snapshot.trip)
} else {
TrekPaper(contours: kind != .atlas)
}
}
}
/// A home screen of the widgets on an iPhone-sized wallpaper.
struct HomeScreen: View {
let snapshot: WidgetSnapshot
let now: Date
@Environment(\.colorScheme) private var scheme
var body: some View {
VStack(spacing: 22) {
HStack(spacing: 22) {
WidgetFrame(kind: .trip, family: .small, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .small, snapshot: snapshot, now: now)
}
WidgetFrame(kind: snapshot.today == nil ? .trip : .today, family: .medium, snapshot: snapshot, now: now)
WidgetFrame(kind: .bookings, family: .medium, snapshot: snapshot, now: now)
WidgetFrame(kind: .trip, family: .large, snapshot: snapshot, now: now)
HStack(spacing: 22) {
WidgetFrame(kind: .bookings, family: .small, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .small, snapshot: snapshot, now: now)
}
}
.padding(.vertical, 60)
.padding(.horizontal, 24)
.background(
LinearGradient(
colors: scheme == .dark ? [Color(hex: 0x1D2440), Color(hex: 0x0B0F1E)] : [Color(hex: 0xC9D8F0), Color(hex: 0xF3E3D3)],
startPoint: .top, endPoint: .bottom
)
)
}
}
/// The lock-screen accessories in a row per state.
struct LockScreen: View {
let snapshots: [WidgetSnapshot]
let now: Date
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ForEach(Array(snapshots.enumerated()), id: \.offset) { _, snapshot in
VStack(alignment: .leading, spacing: 8) {
WidgetFrame(kind: .trip, family: .inline, snapshot: snapshot, now: now)
HStack(spacing: 10) {
WidgetFrame(kind: .trip, family: .circular, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .circular, snapshot: snapshot, now: now)
WidgetFrame(kind: .trip, family: .rectangular, snapshot: snapshot, now: now)
}
HStack(spacing: 10) {
WidgetFrame(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .rectangular, snapshot: snapshot, now: now)
}
}
}
}
.padding(24)
.background(Color(hex: 0x1E2A3A))
}
}
/// A mountain ridge across the bottom of its frame.
struct Ridge: Shape {
let height: CGFloat
let seed: Int
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
let steps = 48
for step in 0...steps {
let x: CGFloat = rect.minX + rect.width * CGFloat(step) / CGFloat(steps)
let phase: CGFloat = CGFloat(step) * 0.35 + CGFloat(seed)
let wave: CGFloat = sin(phase) * 0.06 + sin(phase * 2.7) * 0.035
let y: CGFloat = rect.maxY - rect.height * (height + wave)
path.addLine(to: CGPoint(x: x, y: y))
}
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.closeSubpath()
return path
}
}
/// The packing, atlas and group widgets side by side, the sheet to judge them by.
struct NewWidgets: View {
let snapshot: WidgetSnapshot
let now: Date
@Environment(\.colorScheme) private var scheme
var body: some View {
VStack(spacing: 22) {
HStack(spacing: 22) {
WidgetFrame(kind: .packing, family: .small, snapshot: snapshot, now: now)
WidgetFrame(kind: .atlas, family: .small, snapshot: snapshot, now: now)
}
WidgetFrame(kind: .atlas, family: .medium, snapshot: snapshot, now: now)
WidgetFrame(kind: .packing, family: .medium, snapshot: snapshot, now: now)
WidgetFrame(kind: .collab, family: .medium, snapshot: snapshot, now: now)
HStack(spacing: 22) {
WidgetFrame(kind: .collab, family: .small, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .small, snapshot: snapshot, now: now)
}
WidgetFrame(kind: .atlas, family: .large, snapshot: snapshot, now: now)
WidgetFrame(kind: .packing, family: .large, snapshot: snapshot, now: now)
WidgetFrame(kind: .collab, family: .large, snapshot: snapshot, now: now)
}
.padding(.vertical, 60)
.padding(.horizontal, 24)
.background(
LinearGradient(
colors: scheme == .dark ? [Color(hex: 0x1D2440), Color(hex: 0x0B0F1E)] : [Color(hex: 0xC9D8F0), Color(hex: 0xF3E3D3)],
startPoint: .top, endPoint: .bottom
)
)
}
}
@@ -0,0 +1,93 @@
"""Writes client/ios/App/TREKWidgets/WorldGrid.swift: the world as a grid of dots,
each with the ISO code of the country under it, for the atlas widget.
Run from the repo root after the country shapes change:
python client/ios/WidgetPreviews/make-world-grid.py
Reads server/assets/atlas/admin0.geojson.gz, the same shapes the Atlas page draws.
"""
import gzip
import json
COLS, ROWS = 76, 34
LAT_TOP, LAT_BOTTOM = 80.0, -58.0 # Antarctica and the far north stay off the map
SOURCE = 'server/assets/atlas/admin0.geojson.gz'
TARGET = 'client/ios/App/TREKWidgets/WorldGrid.swift'
def rings(geometry):
if geometry['type'] == 'Polygon':
yield geometry['coordinates']
elif geometry['type'] == 'MultiPolygon':
yield from geometry['coordinates']
def inside(ring, x, y):
hit = False
j = len(ring) - 1
for i in range(len(ring)):
xi, yi = ring[i][0], ring[i][1]
xj, yj = ring[j][0], ring[j][1]
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / ((yj - yi) or 1e-12) + xi:
hit = not hit
j = i
return hit
def in_polygon(polygon, x, y):
return inside(polygon[0], x, y) and not any(inside(hole, x, y) for hole in polygon[1:])
features = []
for feature in json.load(gzip.open(SOURCE))['features']:
code = feature['properties'].get('ISO_A2') or ''
if len(code) != 2 or not code.isalpha():
continue
for polygon in rings(feature['geometry']):
xs = [p[0] for p in polygon[0]]
ys = [p[1] for p in polygon[0]]
features.append((code, (min(xs), min(ys), max(xs), max(ys)), polygon))
# Each cell is sampled at five points, so a narrow country still claims its dot.
offsets = [(0.5, 0.5), (0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)]
cell_w = 360.0 / COLS
cell_h = (LAT_TOP - LAT_BOTTOM) / ROWS
rows = []
for r in range(ROWS):
row = []
for c in range(COLS):
votes = {}
for ox, oy in offsets:
x = -180 + (c + ox) * cell_w
y = LAT_TOP - (r + oy) * cell_h
for code, (x0, y0, x1, y1), polygon in features:
if x0 <= x <= x1 and y0 <= y <= y1 and in_polygon(polygon, x, y):
votes[code] = votes.get(code, 0) + 1
break
row.append(max(votes, key=votes.get) if votes else '..')
rows.append(''.join(row))
land = sum(len(r) // 2 - r.count('..') for r in rows)
body = '\n'.join(f' "{r}",' for r in rows)
swift = f'''// Generated by client/ios/WidgetPreviews/make-world-grid.py from
// server/assets/atlas/admin0.geojson.gz; do not edit by hand.
import Foundation
/// The world as {COLS} by {ROWS} dots from {LAT_TOP:g}° north to {-LAT_BOTTOM:g}° south,
/// each the ISO code of the country under it, ".." for sea ({land} land dots).
enum WorldGrid {{
static let columns = {COLS}
static let rows: [String] = [
{body}
]
/// The country code of a dot, nil for sea.
static func code(row: Int, column: Int) -> String? {{
let line = rows[row]
let start = line.index(line.startIndex, offsetBy: column * 2)
let code = String(line[start..<line.index(start, offsetBy: 2)])
return code == ".." ? nil : code
}}
}}
'''
open(TARGET, 'w', encoding='utf8', newline='\n').write(swift)
print('land dots', land)
+127 -43
View File
@@ -5,8 +5,8 @@ import { StatusBar, Style } from '@capacitor/status-bar';
import logoUrl from '../public/icons/icon-white.svg';
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
import { getIapClient, productIdFor, type Purchase } from '../src/native/iap';
import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL } from '../src/native/links';
import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap';
import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL, STORE_TERMS_URL } from '../src/native/links';
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
import { TrekShell } from '../src/native/trekShell';
import { pickLocale, translator } from './i18n';
@@ -28,8 +28,12 @@ const root = document.getElementById('shell')!;
const PROBE_TIMEOUT_MS = 8000;
const iap = getIapClient();
/** Whether this build can take a payment in-app; decided once at start. Until then, and in a store build without the purchase plugin, the plan picker finishes on the web. */
let iapAvailable = false;
/**
* Whether this build can take a payment in-app, asked once at start. Without it
* the plan picker shows the plans but offers no way to buy: the store rules
* allow no link or button that sends a buyer to the website instead.
*/
const iapReady: Promise<boolean> = iap.isAvailable().catch(() => false);
/** The subdomain suffix a cloud instance sits on, from the cloud address: `liketrek.com` → `.liketrek.com`. */
function cloudSuffix(): string {
@@ -359,34 +363,54 @@ async function fetchPlans(): Promise<CloudPlan[] | null> {
return null;
}
function subscribe(plan: CloudPlan, interval: 'month' | 'year'): void {
void tap(ImpactStyle.Medium);
// In-app purchase when this build can take one; otherwise finish on the web, so
// nothing half-wired faces a customer until the store products exist.
if (iapAvailable) {
showClaim(plan, interval);
} else {
window.open(`${CLOUD_URL}/order?plan=${encodeURIComponent(plan.id)}`, '_blank', 'noopener');
}
/**
* What the plan picker can sell. With the store, a plan is offered at the price
* string the App Store gives for its product, in the buyer's currency, and only
* when the store knows that product. The fake (development, preview) sells
* every plan at the plan list's price. Without either, nothing is for sale.
*/
interface Offer {
canBuy: boolean;
storePrices: Map<string, string> | null;
}
function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean): HTMLElement {
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
function offerFor(offer: Offer, plan: CloudPlan, interval: 'month' | 'year'): { label: string; buyable: boolean } {
const cents = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
const storePrice = offer.storePrices?.get(productIdFor(plan.id, interval));
if (offer.storePrices) return { label: storePrice ?? money(cents), buyable: offer.canBuy && !!storePrice };
return { label: money(cents), buyable: offer.canBuy };
}
async function loadOffer(plans: CloudPlan[]): Promise<Offer> {
const canBuy = await iapReady;
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null };
const ids = plans.flatMap((p) => [productIdFor(p.id, 'month'), productIdFor(p.id, 'year')]);
return { canBuy, storePrices: await iap.prices(ids) };
}
function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HTMLElement {
const { label, buyable } = offerFor(offer, plan, interval);
const per = interval === 'year' ? t('native.plans.perYear') : t('native.plans.perMonth');
const feat = (text: string) => el('span', {}, icon(I.check, 16), text);
// An absent feature (e.g. a plan with no AI documents): a muted dash, not a
// green tick, so "none" never reads as a perk.
const featOff = (text: string) => el('span', { className: 'off' }, icon('M5 12h14', 16), text);
const sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe'));
sub.addEventListener('click', () => subscribe(plan, interval));
let sub: HTMLElement | null = null;
if (buyable) {
sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe'));
sub.addEventListener('click', () => {
void tap(ImpactStyle.Medium);
showClaim(plan, interval, label);
});
}
return el(
'div',
{ className: featured ? 'plan featured' : 'plan' },
{ className: 'plan' },
el(
'div',
{ className: 'plan-top' },
el('span', { className: 'plan-name' }, plan.name),
el('span', { className: 'plan-price' }, money(price), el('small', {}, per))
el('span', { className: 'plan-price' }, label, el('small', {}, per))
),
el(
'div',
@@ -397,15 +421,65 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean
? feat(t('native.plans.aiDocs', { n: String(plan.aiDocuments) }))
: featOff(t('native.plans.noAi'))
),
sub
...(sub ? [sub] : [])
);
}
/**
* Below the plans. With a store: how the subscription renews, the terms and the
* privacy policy, and a way to restore a subscription this Apple ID already
* holds (a reinstall, a new phone). Without one: only that buying in the app is
* not available, and no pointer elsewhere.
*/
function planFooter(offer: Offer): Node[] {
if (!offer.canBuy) return [el('p', { className: 'note' }, t('native.plans.soon'))];
const status = el('p', { className: 'hint', role: 'status' });
const restore = el('button', { type: 'button', className: 'btn btn-ghost' }, t('native.plans.restore'));
restore.addEventListener('click', async () => {
void tap();
restore.disabled = true;
status.className = 'hint';
status.textContent = t('native.plans.restoring');
const purchase = await iap.restore();
if (!purchase) {
restore.disabled = false;
status.textContent = t('native.plans.restoreNone');
return;
}
// A restore finds the instance the purchase was first redeemed for; address and email are not asked again.
const redeemed = await redeemPurchase('', '', purchase);
if (redeemed.ok && redeemed.instanceUrl) {
await iap.finish(purchase);
showProvisioning(redeemed.instanceUrl);
return;
}
void notify(NotificationType.Error);
restore.disabled = false;
status.className = 'hint bad';
status.textContent = t('native.plans.restoreFailed');
});
return [
restore,
status,
el('p', { className: 'note' }, t('native.plans.legal')),
el(
'div',
{ className: 'foot-links' },
externalLink(t('native.plans.terms'), STORE_TERMS_URL),
externalLink(t('native.privacy'), PRIVACY_URL)
),
];
}
function showPlans(opts: { back?: boolean } = {}): void {
let interval: 'month' | 'year' = 'month';
let plans: CloudPlan[] | null = null;
let offer: Offer = { canBuy: false, storePrices: null };
const content = el('div', { className: 'loading' }, el('span', { className: 'spinner' }));
const footer = el('div', { className: 'plans-foot' });
const monthBtn = el('button', { type: 'button' }, t('native.plans.monthly'));
const yearBtn = el('button', { type: 'button' }, t('native.plans.yearly'));
@@ -419,7 +493,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
const list = plans;
if (!list) return;
content.className = 'plans';
content.replaceChildren(...list.map((p) => planCard(p, interval, false)));
content.replaceChildren(...list.map((p) => planCard(p, interval, offer)));
};
const choose = (next: 'month' | 'year') => {
if (interval === next) return;
@@ -433,9 +507,12 @@ function showPlans(opts: { back?: boolean } = {}): void {
paintSeg();
const load = async () => {
plans = await fetchPlans();
if (plans) {
const list = await fetchPlans();
if (list) {
offer = await loadOffer(list);
plans = list;
renderList();
footer.replaceChildren(...planFooter(offer));
return;
}
content.className = '';
@@ -458,7 +535,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
el('p', { className: 'sub' }, t('native.plans.sub')),
seg,
content,
el('p', { className: 'note' }, t('native.plans.soon'))
footer
),
opts
);
@@ -507,8 +584,9 @@ async function redeemPurchase(slug: string, email: string, purchase: Purchase):
}
}
function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: boolean } = {}): void {
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
function showClaim(plan: CloudPlan, interval: 'month' | 'year', price: string, opts: { back?: boolean } = {}): void {
/** A purchase that went through but whose redeem did not: a retry redeems it again instead of buying twice. */
let bought: Purchase | null = null;
const slugInput = el('input', {
type: 'text',
@@ -546,7 +624,7 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
const submit = el(
'button',
{ type: 'submit', className: 'btn' },
t('native.claim.subscribe', { price: money(price) })
t('native.claim.subscribe', { price })
);
const error = el('p', { className: 'error', role: 'alert' });
const form = el(
@@ -617,24 +695,33 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking'));
const restore = () => {
lock(false);
submit.replaceChildren(t('native.claim.subscribe', { price: money(price) }));
submit.replaceChildren(t('native.claim.subscribe', { price }));
};
const outcome = await iap.purchase(productIdFor(plan.id, interval));
if (outcome.kind === 'cancelled') {
restore();
error.textContent = t('native.claim.cancelled');
return;
}
if (outcome.kind === 'failed') {
void notify(NotificationType.Error);
restore();
error.textContent = t('native.claim.failed');
return;
if (!bought) {
const outcome = await iap.purchase(productIdFor(plan.id, interval));
if (outcome.kind === 'cancelled') {
restore();
error.textContent = t('native.claim.cancelled');
return;
}
if (outcome.kind === 'pending') {
restore();
error.textContent = t('native.claim.pending');
return;
}
if (outcome.kind === 'failed') {
void notify(NotificationType.Error);
restore();
error.textContent = t('native.claim.failed');
return;
}
bought = outcome.purchase;
}
const redeemed = await redeemPurchase(slug, email, outcome.purchase);
const redeemed = await redeemPurchase(slug, email, bought);
if (redeemed.ok && redeemed.instanceUrl) {
await iap.finish(bought);
showProvisioning(redeemed.instanceUrl);
return;
}
@@ -808,9 +895,6 @@ async function paintStatusBar(): Promise<void> {
async function start(): Promise<void> {
void paintStatusBar();
iap.isAvailable().then((ok) => {
iapAvailable = ok;
});
let url: string | null;
try {
({ url } = await TrekShell.getServer());
+14
View File
@@ -881,6 +881,20 @@ a.linkbtn {
margin: 2px 0 0;
line-height: 1.45;
}
/* Under the plans: restore, the renewal terms, the legal links. */
.plans-foot {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 4px;
}
.plans-foot .hint {
text-align: center;
margin: 0;
}
.plans-foot .hint:empty {
display: none;
}
/* ── Claim: address + email, and the provisioning wait ───────────────────── */
/* One rounded field holding the address input and its fixed ".liketrek.com"
+7 -7
View File
@@ -1,18 +1,18 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 377,
"@typescript-eslint/no-non-null-assertion": 375,
"@typescript-eslint/no-misused-promises": 847,
"no-console": 88,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 144,
"trek/no-swallowed-catch": 128,
"max-params": 32,
"max-params": 31,
"trek/store-without-selector": 67,
"react-hooks/refs": 553,
"react-hooks/set-state-in-effect": 193,
"react-hooks/set-state-in-effect": 192,
"@typescript-eslint/no-floating-promises": 164,
"react-hooks/exhaustive-deps": 115,
"@typescript-eslint/no-unused-vars": 124,
"react-hooks/exhaustive-deps": 114,
"@typescript-eslint/no-unused-vars": 123,
"react-hooks/immutability": 28,
"max-lines-per-function": 130,
"react-hooks/static-components": 50,
@@ -21,7 +21,7 @@
"no-empty": 19,
"trek/no-window-globals": 41,
"react-refresh/only-export-components": 44,
"unused-disable-directive": 6,
"unused-disable-directive": 5,
"max-depth": 21,
"react-hooks/void-use-memo": 1,
"@typescript-eslint/no-unused-expressions": 2,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1914,
"@typescript-eslint/no-non-null-assertion": 1912,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+13 -4
View File
@@ -620,8 +620,7 @@
"desktop": ["src/components/Planner/PlacesSidebarHeader.tsx", "src/components/Planner/usePlacesSidebar.ts"],
"mobile": [
"src/mobile/screens/trip/places/MPlacesBrowser.tsx",
"src/mobile/screens/trip/places/MPlacesFilterControls.tsx",
"src/mobile/screens/trip/places/MPlacesFilterSheet.tsx"
"src/mobile/screens/trip/places/MPlacesFilterControls.tsx"
]
},
{
@@ -670,6 +669,12 @@
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
},
{
"feature": "Demo welcome",
"hooks": ["src/components/Demo/useDemoWelcome.ts"],
"desktop": ["src/components/Demo/DemoWelcome.tsx"],
"mobile": ["src/mobile/screens/dashboard/MDemoWelcome.tsx"]
},
{
"feature": "Settings / Demo account locks",
"hooks": ["src/components/Demo/useDemoLock.ts"],
@@ -1002,6 +1007,7 @@
"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/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.",
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
@@ -1010,17 +1016,18 @@
"src/mobile/screens/settings/MSettingsAbout.tsx": "Markup only about card with static links and no state.",
"src/mobile/screens/settings/MSettingsOffline.tsx": "Already shares components/Settings/useOfflineSettings with components/Settings/OfflineTab.tsx.",
"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/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.",
"src/mobile/screens/trip/map/MPlacesFilterPill.tsx": "Phone only map control for the active places filter count, built on the shared MapTogglePill.",
"src/mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": "Markup only; categories and the pick handler come through props from MPlacesBrowser.",
"src/mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": "Markup only places or tours toggle; value and onChange come through props from MPlacesBrowser.",
"src/mobile/screens/trip/places/placesBrowserModel.ts": "Phone pool helpers over the shared utils/placesFilter, placeSearch and plannedPlaces.",
"src/mobile/screens/trip/plan/MPlanTimelineRows.tsx": "Markup only timeline rows; row data comes from MPlanTimeline and planTimelineModel.",
"src/mobile/screens/trip/plan/MRouteAppsSheet.tsx": "Markup only; the route apps come from the shared components/Planner/dayRouteApps the desktop menu uses too.",
"src/mobile/screens/trip/plan/openRouteTarget.ts": "Phone only helper that opens a route target: a web link in a new tab, geo: in place.",
"src/mobile/screens/trip/plan/planTimelineModel.ts": "Phone plan timeline row builder over the shared utils/dayMerge, dayOrder and dayTourProjection.",
"src/mobile/screens/trip/plan/useMPlanDaySwipe.ts": "Phone only touch swipe gesture for switching days.",
"src/mobile/screens/trip/plan/useMPlanDragReorder.ts": "Phone only long press drag gesture; the reorder itself runs through the handler it is given.",
"src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx": "Markup only road trip rows over the shared Roadtrip models the desktop sidebar rows also use.",
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": "Phone road trip tab over the shared roadtripRowModel, stopBookings and nightBookend RoadtripSidebar uses.",
@@ -1039,10 +1046,12 @@
"src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": "Phone action sheet for a pool place that calls planner handlers and the shared placeToSaveTarget.",
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
"src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.",
"src/mobile/screens/trip/sheets/MTourDetailShell.tsx": "Markup only phone frame for the shared TourDetailDialog, which renders its own content into it.",
"src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.",
"src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.",
"src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).",
"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/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.",
-5
View File
@@ -1351,11 +1351,9 @@
"src/mobile/screens/trip/dockTabs.ts",
"src/mobile/screens/trip/lib/dayNotes.ts",
"src/mobile/screens/trip/map/MMapArea.tsx",
"src/mobile/screens/trip/map/MPlacesFilterPill.tsx",
"src/mobile/screens/trip/places/MPlacesBrowser.tsx",
"src/mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx",
"src/mobile/screens/trip/places/MPlacesFilterControls.tsx",
"src/mobile/screens/trip/places/MPlacesFilterSheet.tsx",
"src/mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx",
"src/mobile/screens/trip/places/MPlacesToursModeSwitch.tsx",
"src/mobile/screens/trip/places/MToursSelectionList.test.tsx",
@@ -1366,8 +1364,6 @@
"src/mobile/screens/trip/plan/MPlanTimelineRows.tsx",
"src/mobile/screens/trip/plan/planTimelineModel.test.ts",
"src/mobile/screens/trip/plan/planTimelineModel.ts",
"src/mobile/screens/trip/plan/useMPlanDaySwipe.test.tsx",
"src/mobile/screens/trip/plan/useMPlanDaySwipe.ts",
"src/mobile/screens/trip/plan/useMPlanDragReorder.test.ts",
"src/mobile/screens/trip/plan/useMPlanDragReorder.ts",
"src/mobile/screens/trip/plan/useMPlanTimeline.ts",
@@ -1982,7 +1978,6 @@
"tests/unit/mobile/trip/MPlaceSheet.test.tsx",
"tests/unit/mobile/trip/MPlacesBrowser.test.tsx",
"tests/unit/mobile/trip/MPlacesBulkCategorySheet.test.tsx",
"tests/unit/mobile/trip/MPlacesFilterSheet.test.tsx",
"tests/unit/mobile/trip/MPlacesSaveToCollectionSheet.test.tsx",
"tests/unit/mobile/trip/MPlanTimeline.test.tsx",
"tests/unit/mobile/trip/MPlanTimelineRows.test.tsx",
+1 -1
View File
@@ -7,7 +7,7 @@
"collab.ts": 9,
"collection.ts": 35,
"common.ts": 7,
"dashboard.ts": 65,
"dashboard.ts": 64,
"dawarich.ts": 15,
"dayplan.ts": 17,
"docsync.ts": 25,
+1 -1
View File
@@ -78,7 +78,7 @@
"help/contexts/tripMap.ts": 1,
"hooks/useAnchoredPosition.ts": 3,
"hooks/useElementRect.ts": 3,
"mobile/components/MSegmented.tsx": 3,
"mobile/components/MSegmented.tsx": 1,
"mobile/components/MSheet.tsx": 1,
"mobile/screens/dashboard/MDashboard.tsx": 1,
"mobile/screens/journey/MJourneyDetail.tsx": 3,
+2
View File
@@ -4,5 +4,7 @@
"components/Studio/StudioSelectionChrome.tsx": 2,
"components/Studio/inspector/FocalPad.tsx": 1,
"mobile/components/trek3d/MascotClip.tsx": 1,
"mobile/screens/trip/MTripShell.tsx": 1,
"mobile/screens/vacay/MVacay.tsx": 1,
"pages/tripPlanner/PlanMapStage.tsx": 4
}
+2 -2
View File
@@ -7,7 +7,7 @@
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1214,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2183,
"src/components/Map/MapViewGL.tsx": 2127,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2929,
@@ -16,7 +16,7 @@
"src/components/Studio/TravelElements.tsx": 1559,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1506,
"src/pages/JourneyDetailPage.test.tsx": 4028,
"src/pages/LoginPage.tsx": 1347,
"src/pages/TripPlannerPage.tsx": 1128,
+1 -1
View File
@@ -105,7 +105,7 @@
"src/mobile/screens/trip/map/MMapArea.tsx": 1,
"src/mobile/screens/trip/places/MPlacesBrowser.tsx": 2,
"src/mobile/screens/trip/plan/planTimelineModel.ts": 1,
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 2,
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 1,
"src/mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 1,
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 1,
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 2,
+20 -21
View File
@@ -166,31 +166,31 @@
"mobile/screens/admin/MAdminUsersSection.tsx": 14,
"mobile/screens/admin/MApiProviderBlocks.tsx": 10,
"mobile/screens/atlas/MAtlas.tsx": 5,
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 16,
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 14,
"mobile/screens/atlas/MAtlasCountryPopup.tsx": 11,
"mobile/screens/atlas/MAtlasSearch.tsx": 5,
"mobile/screens/atlas/MAtlasStatsCard.tsx": 3,
"mobile/screens/collections/MCollAddSheet.tsx": 7,
"mobile/screens/collections/MCollAddSheet.tsx": 6,
"mobile/screens/collections/MCollCategoryPicker.tsx": 2,
"mobile/screens/collections/MCollEditSheet.tsx": 5,
"mobile/screens/collections/MCollEditSheet.tsx": 2,
"mobile/screens/collections/MCollLabelsSheet.tsx": 6,
"mobile/screens/collections/MCollLinksEditor.tsx": 2,
"mobile/screens/collections/MCollListPickerSheet.tsx": 3,
"mobile/screens/collections/MCollPlaceRow.tsx": 3,
"mobile/screens/collections/MCollPlaceSheet.tsx": 11,
"mobile/screens/collections/MCollShareSheet.tsx": 14,
"mobile/screens/collections/MCollSheetKit.tsx": 6,
"mobile/screens/collections/MCollSheetKit.tsx": 5,
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
"mobile/screens/collections/MCollections.tsx": 21,
"mobile/screens/collections/MCollections.tsx": 18,
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
"mobile/screens/dashboard/MDashboard.tsx": 36,
"mobile/screens/dashboard/MNewTripSheet.tsx": 20,
"mobile/screens/dashboard/MDashboard.tsx": 31,
"mobile/screens/dashboard/MNewTripSheet.tsx": 12,
"mobile/screens/dashboard/MUserMenu.tsx": 7,
"mobile/screens/journey/MJourney.tsx": 10,
"mobile/screens/journey/MJourneyCreateSheet.tsx": 10,
"mobile/screens/journey/MJourneyCreateSheet.tsx": 7,
"mobile/screens/journey/MJourneyDetail.tsx": 6,
"mobile/screens/journey/MJourneyEntrySheet.tsx": 52,
"mobile/screens/journey/MJourneySettingsSheet.tsx": 32,
"mobile/screens/journey/MJourneyEntrySheet.tsx": 48,
"mobile/screens/journey/MJourneySettingsSheet.tsx": 2,
"mobile/screens/journey/mobileJourneyMeta.ts": 4,
"mobile/screens/notifications/MNotificationRow.tsx": 4,
"mobile/screens/notifications/MNotifications.tsx": 3,
@@ -201,13 +201,13 @@
"mobile/screens/settings/MMobileNavCustomizer.tsx": 8,
"mobile/screens/settings/MPhotoProvidersSection.tsx": 2,
"mobile/screens/settings/MScopeGroupPicker.tsx": 5,
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
"mobile/screens/settings/MSetPickerSheet.tsx": 1,
"mobile/screens/settings/MSettings.tsx": 2,
"mobile/screens/settings/MSettingsAbout.tsx": 5,
"mobile/screens/settings/MSettingsAccount.tsx": 20,
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
"mobile/screens/settings/MSettingsMap.tsx": 5,
"mobile/screens/settings/MSettingsMcp.tsx": 30,
"mobile/screens/settings/MSettingsMcp.tsx": 26,
"mobile/screens/settings/MSettingsNotifications.tsx": 5,
"mobile/screens/settings/MSettingsOffline.tsx": 18,
"mobile/screens/settings/MSettingsPlugins.tsx": 14,
@@ -216,9 +216,9 @@
"mobile/screens/trip/MTripLoadingSplash.tsx": 2,
"mobile/screens/trip/MTripShell.tsx": 9,
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 4,
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 2,
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7,
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 6,
"mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1,
"mobile/screens/trip/plan/MPlanTimeline.tsx": 12,
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 24,
@@ -235,8 +235,8 @@
"mobile/screens/trip/sheets/ImpFileStep.tsx": 12,
"mobile/screens/trip/sheets/ImpListStep.tsx": 3,
"mobile/screens/trip/sheets/MAccommodationSheet.tsx": 10,
"mobile/screens/trip/sheets/MBookingFields.tsx": 6,
"mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 5,
"mobile/screens/trip/sheets/MBookingFields.tsx": 5,
"mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 3,
"mobile/screens/trip/sheets/MCostSheet.tsx": 33,
"mobile/screens/trip/sheets/MDaySheet.tsx": 22,
"mobile/screens/trip/sheets/MDaysSheet.tsx": 4,
@@ -251,11 +251,10 @@
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
"mobile/screens/trip/sheets/MTripSheetUi.tsx": 5,
"mobile/screens/trip/sheets/MTripSheets.tsx": 2,
"mobile/screens/trip/sheets/PlCategoryPicker.tsx": 2,
"mobile/screens/trip/sheets/PlFileAttach.tsx": 7,
"mobile/screens/trip/sheets/PlPlaceSearch.tsx": 6,
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 5,
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
@@ -280,10 +279,10 @@
"mobile/screens/trip/tabs/filesModel.ts": 5,
"mobile/screens/trip/tabs/tabChrome.tsx": 12,
"mobile/screens/vacay/MVacay.tsx": 17,
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
"mobile/screens/vacay/MVacayInviteSheet.tsx": 8,
"mobile/screens/vacay/MVacayMonth.tsx": 3,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 34,
"mobile/screens/vacay/MVacayShareSheet.tsx": 10,
"mobile/screens/vacay/vacayDayModel.ts": 1,
"pages/AtlasPage.tsx": 32,
"pages/DashboardPage.tsx": 1,
+1 -1
View File
@@ -35,7 +35,7 @@ import OfflineBanner from './components/Layout/OfflineBanner'
import NewVersionNotice from './components/Layout/NewVersionNotice'
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
import AppLockGate from './components/Layout/AppLockGate'
import NativeImportSheet from './components/Layout/NativeImportSheet'
import NativeImportSheet from './mobile/components/NativeImportSheet'
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel'
+20
View File
@@ -12,6 +12,8 @@ const { probeNow } = vi.hoisted(() => ({
probeNow: vi.fn(async (): Promise<'online' | 'offline' | 'proxy-wall'> => 'offline'),
}))
vi.mock('../sync/connectivity', () => ({ probeNow }))
const isNativeApp = vi.hoisted(() => vi.fn(() => false))
vi.mock('../native/platform', () => ({ isNativeApp }))
const { apiClient, adminApi, mapsApi, pluginsApi, parseInDev } = await import('./client')
@@ -357,6 +359,24 @@ describe('client > redirect handling', () => {
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/trips/7?tab=map#day-2'))
})
it('FE-APIWIRE-019b: in the app it waits for the first page to finish loading before it leaves', async () => {
isNativeApp.mockReturnValue(true)
const ready = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState')
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true })
const loc = setLocation('/dashboard')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 })))
await captureError(() => apiClient.get('/auth/me'))
expect(loc.href).not.toContain('/login')
window.dispatchEvent(new Event('load'))
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/dashboard'))
delete (document as { readyState?: unknown }).readyState
if (ready) Object.defineProperty(Document.prototype, 'readyState', ready)
isNativeApp.mockReturnValue(false)
})
it('FE-APIWIRE-020: an MFA_REQUIRED 403 sends the user to the settings page', async () => {
const loc = setLocation('/dashboard')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 })))
+4 -4
View File
@@ -70,13 +70,13 @@ import {
RoadtripVia,
RoadtripViaBatchRequest,
RoadtripViaCreateRequest,
RoadtripViaReanchorRequest,
RoadtripViaUpdateRequest,
RoadtripViaReanchorRequest, RoadtripViaUpdateRequest,
} from '@trek/shared'
import { getSocketId } from './websocket'
import { probeNow } from '../sync/connectivity'
import { downloadBlob } from '../utils/fileDownload'
import { translateRateLimit } from './rateLimitMessage'
import { hardNavigate } from './hardNavigate'
/**
* Validate a response payload against its @trek/shared Zod schema — but only in
@@ -218,7 +218,7 @@ apiClient.interceptors.response.use(
const { pathname } = window.location
if (!isAuthPublicPath(pathname)) {
const currentPath = pathname + window.location.search + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
hardNavigate('/login?redirect=' + encodeURIComponent(currentPath))
}
}
if (
@@ -226,7 +226,7 @@ apiClient.interceptors.response.use(
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
!window.location.pathname.startsWith('/settings')
) {
window.location.href = '/settings?mfa=required'
hardNavigate('/settings?mfa=required')
}
if (error.response?.status === 429) {
const translated = translateRateLimit()
+15
View File
@@ -0,0 +1,15 @@
import { isNativeApp } from '../native/platform';
/**
* A full page load to `url`. In the apps it waits for the first page to finish
* loading: started earlier, it cancels that load, and the shell answers a
* cancelled load with its "Server not reachable" page, which is where a
* signed-out user opening the app for the first time ended up.
*/
export function hardNavigate(url: string): void {
const go = () => {
window.location.href = url;
};
if (isNativeApp() && document.readyState !== 'complete') window.addEventListener('load', go, { once: true });
else go();
}
@@ -20,7 +20,7 @@ export default function MSaveToCollectionSheet() {
{/* Header — mirrors the place detail sheet: icon + title + target name + close */}
<div className="flex-none px-[18px] pt-4">
<div className="flex items-start gap-3">
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-[14px] bg-[color:var(--m-ic)] text-m-muted">
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
<Bookmark size={18} strokeWidth={2} />
</span>
<div className="min-w-0 flex-1">
@@ -84,10 +84,12 @@ describe('JourneyEntryCover', () => {
const { container, rerender } = render(
<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
)
expect(container.querySelector('button')!.className).toContain('w-[136px]')
expect(container.querySelector('button')!.className).toContain('scale-100')
rerender(<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive onClick={() => {}} />)
expect(container.querySelector('button')!.className).toContain('w-[164px]')
// Drawn larger, not laid out larger: the box stays, so nothing beside it moves.
expect(container.querySelector('button')?.className).toContain('scale-[1.18]')
expect(container.querySelector('button')?.className).toContain('w-[136px]')
})
it('FE-JRN-COVER-007: a shared journey serves its photos through the share token', () => {
@@ -103,8 +103,13 @@ export default function JourneyEntryCover({
// The whole card is the photo, so its own ring is what separates it from the
// map behind. A suggestion is drawn as an outline rather than a solid thing:
// it is somewhere you planned to be, not somewhere you have written about.
className={`relative flex-none overflow-hidden rounded-[18px] text-start transition-[width,height] duration-150 ${
isActive ? 'h-[180px] w-[164px] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'h-[152px] w-[136px] shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
//
// Every card takes the same room and the active one is only drawn larger:
// growing its box moved every card after it once a swipe had landed, and the
// snap then pulled the strip again, which read as a jolt (carousels keep the
// room for it with their gap and top padding).
className={`relative h-[152px] w-[136px] flex-none origin-bottom overflow-hidden rounded-[18px] text-start transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
isActive ? 'z-[1] scale-[1.18] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'scale-100 shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
} ${src ? '' : emptyGround} ${isSuggestion ? 'opacity-90' : ''}`}
// backgroundImage, not background: the shorthand would drop the opaque colour
// the class supplies and leave the map showing through a card that is only a
+7 -1
View File
@@ -691,7 +691,13 @@ function JourneyMap(
ref={containerRef}
style={{ width: '100%', height: '100%' }}
/>
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
{/* A finger zooms with a pinch, and on a phone these sat under the bottom bar (#3001). */}
<div
// The display lives in the classes, not the style: an inline display beat the
// hidden class and left the buttons on phones.
className="flex flex-col gap-1 [@media(pointer:coarse)]:hidden"
style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400 }}
>
<button type="button" onClick={() => zoom(true)} style={zoomButtonStyle(dark)}>+</button>
<button type="button" onClick={() => zoom(false)} style={zoomButtonStyle(dark)}>−</button>
</div>
@@ -150,7 +150,7 @@ describe('MobileMapTimeline', () => {
await userEvent.click(segments[1])
const { cards } = carouselOf(container)
expect(cards[1].querySelector('button')!.className).toContain('w-[164px]')
expect(cards[1].querySelector('button')!.className).toContain('scale-[1.18]')
})
it('FE-COMP-JMAPTL-003: the first entry starts out active and drives the map highlight', () => {
@@ -285,13 +285,14 @@ describe('MobileMapTimeline', () => {
carousel.getBoundingClientRect = () => fakeRect(0, 300)
cards.forEach((c, i) => { c.getBoundingClientRect = () => fakeRect(i === 2 ? 30 : -400, 240) })
// The strip counts as landed 90 ms after its last scroll event.
fireEvent.scroll(carousel)
act(() => { vi.advanceTimersByTime(100) })
act(() => { vi.advanceTimersByTime(60) })
fireEvent.scroll(carousel)
act(() => { vi.advanceTimersByTime(100) })
act(() => { vi.advanceTimersByTime(60) })
expect(lastMapProps.current.activeMarkerId).toBe('1')
act(() => { vi.advanceTimersByTime(100) })
act(() => { vi.advanceTimersByTime(40) })
expect(lastMapProps.current.activeMarkerId).toBe('3')
} finally {
vi.useRealTimers()
@@ -69,7 +69,7 @@ export default function MobileMapTimeline({
controlsTop,
}: Props) {
const {
mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays,
mapRef, carouselRef, cardRefs, activeIndex, focusIndex, setActiveIndex, scrubberDays,
syncMapToCard: syncMapToCarousel, handleMarkerClick, handleCardTap, openEntryById, jumpToDay,
} = useJourneyCarouselSync({ entries, mapEntries, onOpenEntry: onEntryClick })
// The delayed initial focus reads both through refs so it always works off
@@ -108,8 +108,8 @@ export default function MobileMapTimeline({
if (entries.length === 0) {
return (
<div
className="fixed inset-x-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
className="fixed inset-x-0 bottom-0 z-10"
style={{ top: 'var(--nav-h, 0px)' }}
>
<JourneyLayeredMap
ref={mapRef}
@@ -147,8 +147,10 @@ export default function MobileMapTimeline({
return (
<div
className="fixed inset-x-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
// Down to the very edge: Safari draws its toolbar over the page, and a map
// that stopped at the safe area left a bare strip under it (#3002).
className="fixed inset-x-0 bottom-0 z-10"
style={{ top: 'var(--nav-h, 0px)' }}
>
{/* Full-screen map */}
<JourneyLayeredMap
@@ -187,7 +189,7 @@ export default function MobileMapTimeline({
/>
<div
ref={carouselRef}
className="flex items-end gap-[10px] overflow-x-auto px-4 pb-3 pt-1"
className="flex items-end gap-[22px] overflow-x-auto px-6 pb-3 pt-[30px]"
style={{
scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch',
@@ -205,7 +207,7 @@ export default function MobileMapTimeline({
<JourneyEntryCover
entry={entry}
dayColor={dayColorOf(scrubberDays, entry.entry_date)}
isActive={i === activeIndex}
isActive={i === focusIndex}
onClick={() => handleCardTap(entry, i)}
showMood={showMood}
showWeather={showWeather}
@@ -127,9 +127,9 @@ describe('useJourneyCarouselSync', () => {
});
carousel.dispatchEvent(new Event('scroll'));
act(() => vi.advanceTimersByTime(100));
act(() => vi.advanceTimersByTime(60));
carousel.dispatchEvent(new Event('scroll'));
act(() => vi.advanceTimersByTime(149));
act(() => vi.advanceTimersByTime(89));
expect(hook.result.current.activeIndex).toBe(0);
act(() => vi.advanceTimersByTime(1));
// Centre 250: card 1 spans 200 to 300.
@@ -170,7 +170,7 @@ describe('useJourneyCarouselSync', () => {
Object.defineProperty(carousel, 'scrollLeft', { value: scrollLeft, configurable: true });
carousel.dispatchEvent(new Event('scroll'));
act(() => {
vi.advanceTimersByTime(150);
vi.advanceTimersByTime(90);
});
};
@@ -19,6 +19,42 @@ export interface JourneyCarouselSyncOptions<E extends CarouselEntry> {
syncMapOnTap?: boolean;
}
/**
* The card closest to the strip's horizontal centre. More stable than
* IntersectionObserver thresholds when the active card can drift toward the
* viewport edge with proximity snapping.
*/
function nearestCardIndex(el: HTMLDivElement | null, cards: Map<number, HTMLDivElement>): number | null {
if (!el) return null;
const center = el.getBoundingClientRect().left + el.clientWidth / 2;
let bestIdx = 0;
let bestDist = Infinity;
cards.forEach((node, idx) => {
const r = node.getBoundingClientRect();
const d = Math.abs(r.left + r.width / 2 - center);
if (d < bestDist) {
bestDist = d;
bestIdx = idx;
}
});
// The first and the last card can never reach the centre, so a carousel scrolled
// as far as it goes means the card at that end: without this, opening on the
// latest day settled on the one before it (#2851). Measured from the start in
// either direction: in a right-to-left layout scrollLeft runs negative.
const travel = el.scrollWidth - el.clientWidth;
const scrolled = Math.abs(el.scrollLeft);
if (travel > 1 && scrolled >= travel - 1) bestIdx = cards.size - 1;
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
return bestIdx;
}
/**
* How long the strip has to be still before the swipe counts as landed: long
* enough to see past the gaps between scroll events of a momentum swipe, short
* enough that the map follows the card without a noticeable wait.
*/
const SETTLE_MS = 90;
/**
* The card carousel over the journey map, behind both the phone journey screen and the
* narrow desktop and public timeline: the active card is the one nearest the centre
@@ -35,6 +71,10 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
const carouselRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const [activeIndex, setActiveIndex] = useState(0);
// The card nearest the centre while a swipe is still moving: drawn larger at
// once (a transform, so nothing reflows), while the active card and the map
// only follow once the strip settles. Null when not swiping.
const [liveIndex, setLiveIndex] = useState<number | null>(null);
const scrubberDays = useMemo(() => journeyDays(entries), [entries]);
@@ -54,36 +94,17 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
[entries, mapEntries]
);
// Pick the card closest to the horizontal centre. More stable than
// IntersectionObserver thresholds when the active card can drift toward the
// viewport edge with proximity snapping.
const nearestCard = useCallback(() => nearestCardIndex(carouselRef.current, cardRefs.current), []);
const pickNearestCard = useCallback(() => {
const el = carouselRef.current;
if (!el) return;
const center = el.getBoundingClientRect().left + el.clientWidth / 2;
let bestIdx = 0;
let bestDist = Infinity;
cardRefs.current.forEach((node, idx) => {
const r = node.getBoundingClientRect();
const d = Math.abs(r.left + r.width / 2 - center);
if (d < bestDist) {
bestDist = d;
bestIdx = idx;
}
});
// The first and the last card can never reach the centre, so a carousel scrolled
// as far as it goes means the card at that end: without this, opening on the
// latest day settled on the one before it (#2851). Measured from the start in
// either direction: in a right-to-left layout scrollLeft runs negative.
const travel = el.scrollWidth - el.clientWidth;
const scrolled = Math.abs(el.scrollLeft);
if (travel > 1 && scrolled >= travel - 1) bestIdx = cardRefs.current.size - 1;
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
const bestIdx = nearestCard();
if (bestIdx == null) return;
setLiveIndex(null);
setActiveIndex((prev) => {
if (prev !== bestIdx) syncMapToCard(bestIdx);
return bestIdx;
});
}, [syncMapToCard]);
}, [nearestCard, syncMapToCard]);
// Defer every state update until scrolling settles: updating the active card
// mid swipe resizes the cards and reflows the layout on every frame.
@@ -91,16 +112,33 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
const el = carouselRef.current;
if (!el || entries.length === 0) return;
let settleTimer: number | null = null;
let frame: number | null = null;
const onScroll = () => {
if (frame == null) {
frame = window.requestAnimationFrame(() => {
frame = null;
setLiveIndex(nearestCard());
});
}
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(pickNearestCard, 150);
settleTimer = window.setTimeout(pickNearestCard, SETTLE_MS);
};
// Where the browser says when the snap has landed, the card grows right then
// instead of a beat later; the timer stays for the ones that do not.
const onScrollEnd = () => {
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = null;
pickNearestCard();
};
el.addEventListener('scroll', onScroll, { passive: true });
el.addEventListener('scrollend', onScrollEnd, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
el.removeEventListener('scrollend', onScrollEnd);
if (settleTimer != null) window.clearTimeout(settleTimer);
if (frame != null) window.cancelAnimationFrame(frame);
};
}, [entries.length, pickNearestCard]);
}, [entries.length, pickNearestCard, nearestCard]);
const scrollCardIntoCenter = useCallback((idx: number) => {
cardRefs.current.get(idx)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
@@ -159,6 +197,8 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
carouselRef,
cardRefs,
activeIndex,
/** The card to draw as the active one: the one at the centre while swiping, else the active card. */
focusIndex: liveIndex ?? activeIndex,
setActiveIndex,
scrubberDays,
syncMapToCard,
@@ -281,6 +281,8 @@ describe('useJourneyEntryForm', () => {
vi.useFakeTimers();
const places = Array.from({ length: 8 }, (_, i) => ({ name: `P${i}`, lat: String(i), lng: '1' }));
const search = vi.spyOn(mapsApi, 'search').mockResolvedValue({ places } as never);
// No suggestions: the full search answers.
vi.spyOn(mapsApi, 'autocomplete').mockResolvedValue({ suggestions: [], source: 'none' } as never);
const { result } = setup();
act(() => result.current.searchLocation('R'));
expect(result.current.showLocationResults).toBe(true);
@@ -306,6 +308,35 @@ describe('useJourneyEntryForm', () => {
expect(result.current.showLocationResults).toBe(false);
});
it('FE-JRN-ENTRYFORM-013b: suggestions come first, as in the planner, and a pick fetches its point (#3003)', async () => {
vi.useFakeTimers();
const suggest = vi.spyOn(mapsApi, 'autocomplete').mockResolvedValue({
suggestions: [{ placeId: 'g1', mainText: 'Hotel Keihan Temmabashi', secondaryText: 'Osaka, Japan' }],
source: 'google',
} as never);
const search = vi.spyOn(mapsApi, 'search');
const details = vi.spyOn(mapsApi, 'details').mockResolvedValue({ place: { lat: 34.69, lng: 135.51 } } as never);
const { result } = setup();
act(() => result.current.searchLocation('Hotel Keihan'));
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(suggest).toHaveBeenCalledWith('Hotel Keihan', 'en', undefined, expect.any(AbortSignal), expect.any(String));
expect(search).not.toHaveBeenCalled();
expect(result.current.locationResults).toEqual([
{ name: 'Hotel Keihan Temmabashi', address: 'Osaka, Japan', lat: null, lng: null, placeId: 'g1' },
]);
await act(async () => {
result.current.pickLocation(result.current.locationResults[0]);
await vi.advanceTimersByTimeAsync(0);
});
expect(details).toHaveBeenCalledWith('g1', 'en', expect.any(String));
expect(result.current.locationName).toBe('Hotel Keihan Temmabashi');
expect(result.current.locationLat).toBe(34.69);
expect(result.current.locationLng).toBe(135.51);
});
it('FE-JRN-ENTRYFORM-014: "use my location" fills the point, then the place name', async () => {
locate.mockResolvedValue({ lat: 1.234567, lng: 2.345678 });
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: 'Here' } as never);
@@ -1,14 +1,17 @@
import type { MapsAutocompleteResult } from '@trek/shared';
import type React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { journeyApi, mapsApi, weatherApi } from '../../api/client';
import { getCurrentPositionOnce } from '../../hooks/useGeolocation';
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage';
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions';
import { useTranslation } from '../../i18n';
import type { GalleryPhoto, JourneyEntry, JourneyPhoto, JourneyTrip } from '../../store/journeyStore';
import { getApiErrorMessage } from '../../types';
import { normalizeImageFiles } from '../../utils/convertHeic';
import { localIsoDate } from '../../utils/localDate';
import { PlacesSession } from '../../utils/placesSession';
import type { ResilientResult, UploadProgress } from '../../utils/uploadQueue';
import { useToast } from '../shared/Toast';
import type { ProviderPhotoGroup } from './JourneyDetailPageProviderPicker';
@@ -22,8 +25,10 @@ export type PendingProviderGroup = ProviderPhotoGroup & { provider: string };
export interface LocationResult {
name: string;
address?: string;
lat: number;
lng: number;
/** Null for a suggestion while typing: its point comes with the details when it is picked. */
lat: number | null;
lng: number | null;
placeId?: string;
}
export interface JourneyEntryFormOptions {
@@ -104,6 +109,11 @@ export function useJourneyEntryForm({
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Counts location lookups, so an answer that comes back after the field moved on is dropped.
const locationSearchSeqRef = useRef(0);
// The planner's typed-ahead search (usePlaceSuggestions): names and their place in the
// user's language as the provider has them, billed as one session per search (#3003).
const { autocomplete } = usePlaceSuggestions();
const placesSessionRef = useRef(new PlacesSession());
const suggestAbortRef = useRef<AbortController | null>(null);
const [mood, setMood] = useState(entry.mood || '');
const [weather, setWeather] = useState(entry.weather || '');
// The icon the form filled in by itself (see the weather effect below). It is a
@@ -456,7 +466,33 @@ export function useJourneyEntryForm({
}
locationTimerRef.current = setTimeout(async () => {
setLocationSearching(true);
suggestAbortRef.current?.abort();
const controller = new AbortController();
suggestAbortRef.current = controller;
try {
// Suggestions first, as the planner does; the full search when they bring nothing.
const suggested = await autocomplete(
query,
placeLang,
undefined,
controller.signal,
placesSessionRef.current.current()
)
.then((r): MapsAutocompleteResult['suggestions'] => r.suggestions ?? [])
.catch((): MapsAutocompleteResult['suggestions'] => []);
if (seq !== locationSearchSeqRef.current) return;
if (suggested.length > 0) {
setLocationResults(
suggested.slice(0, 6).map((sg) => ({
name: sg.mainText,
address: sg.secondaryText || undefined,
lat: sg.lat ?? null,
lng: sg.lng ?? null,
placeId: sg.placeId,
}))
);
return;
}
const res = await mapsApi.search(query, placeLang);
if (seq !== locationSearchSeqRef.current) return;
setLocationResults(
@@ -478,7 +514,7 @@ export function useJourneyEntryForm({
};
const pickLocation = (r: LocationResult) => {
locationSearchSeqRef.current++;
const seq = ++locationSearchSeqRef.current;
setLocationSearching(false);
setLocationName(r.name);
setLocationLat(r.lat);
@@ -486,6 +522,26 @@ export function useJourneyEntryForm({
setLocationQuery('');
setShowLocationResults(false);
setLocationResults([]);
if (r.lat != null && r.lng != null) {
placesSessionRef.current.end();
return;
}
// A suggestion names the place; its point comes with the details, which close the session.
if (!r.placeId) return;
void mapsApi
.details(r.placeId, placeLang, placesSessionRef.current.peek())
.then((res) => {
if (seq !== locationSearchSeqRef.current) return;
const place = res.place;
if (place?.lat == null || place?.lng == null) return;
setLocationLat(Number(place.lat));
setLocationLng(Number(place.lng));
})
.catch(() => {
// The name stays without a point, as a typed one would; the map simply has no pin for it.
if (seq === locationSearchSeqRef.current) setLocationLat(null);
})
.finally(() => placesSessionRef.current.end());
};
const handleUseCurrentLocation = async () => {
@@ -12,7 +12,7 @@ vi.mock('../../native/appLock', () => ({
}));
function stateListener(): (state: { isActive: boolean }) => void {
const call = vi.mocked(App.addListener).mock.calls.find(([name]) => name === 'appStateChange');
const call = vi.mocked(App.addListener).mock.calls.find(([name]) => (name as string) === 'appStateChange');
if (!call) throw new Error('no appStateChange listener');
return call[1] as unknown as (state: { isActive: boolean }) => void;
}
+9 -4
View File
@@ -28,11 +28,16 @@ export default function AppLockGate(): React.ReactElement | null {
if (ok) setLocked(false);
}, [t]);
// At launch, once the capabilities are known.
// At launch, once the capabilities are known: locked in the same render the
// capabilities arrive in, so nothing of the app shows before the prompt.
const [capsSeen, setCapsSeen] = useState<typeof caps | undefined>(undefined);
if (caps !== capsSeen) {
setCapsSeen(caps);
if (isAppLockEnabled()) setLocked(true);
}
useEffect(() => {
if (!isAppLockEnabled()) return;
setLocked(true);
void ask();
// Asked after the effect, not inside it: the prompt's answer sets state.
if (isAppLockEnabled()) void Promise.resolve().then(ask);
}, [caps, ask]);
// Back from the background after a while.
@@ -1,57 +1,185 @@
/**
* NativeWidgetsSync: keeps the iOS home-screen widget and the travel-day Live
* Activity in step with the trips stored on this device (native/widgets.ts).
* Runs at start, whenever the app comes back to the foreground and every
* quarter hour while it is open, so the next stop moves on through the day.
* Renders nothing; outside an iOS binary with the widgets it does nothing.
* NativeWidgetsSync: keeps the iOS widgets and the travel-day Live Activity in
* step with the trips stored on this device (native/widgets.ts). Runs at start,
* whenever the app comes back to the foreground and every quarter hour while it
* is open, so the next stop moves on through the day. Renders nothing; outside
* an iOS binary with the widgets it does nothing.
*/
import { App } from '@capacitor/app';
import { useEffect } from 'react';
import { offlineDb } from '../../db/offlineDb';
import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState, widgetsActive } from '../../native/widgets';
import { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
import {
buildSnapshot,
pickTrip,
publishWidgets,
todayOf,
tripDayState,
widgetTripChoices,
widgetsActive,
withBookings,
type TripWidgetSnapshot,
type WidgetLabels,
} from '../../native/widgets';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { Day, Place, TranslationFn, Trip } from '../../types';
import { formatTime } from '../../utils/formatters';
import { localDateStr } from '../../vacay/monthGrid';
const REFRESH_MS = 15 * 60 * 1000;
/** The forecast reaches about two weeks ahead; past that the widget shows no weather. */
const FORECAST_DAYS = 14;
function labels(t: TranslationFn): WidgetLabels {
return {
nextTrip: t('dashboard.nextTrip'),
today: t('native.widget.today'),
noTrip: t('native.widget.noTrip'),
inDays: t('native.widget.inDays'),
tomorrow: t('native.widget.tomorrow'),
nothingPlanned: t('native.widget.nothingPlanned'),
days: t('atlas.days'),
bookings: t('assistant.card.tripChat.phase.upcoming'),
noBookings: t('native.widget.noBookings'),
todos: t('todo.subtab.todo'),
noTodos: t('native.widget.noTodos'),
packing: t('packing.title'),
confirmed: t('reservations.confirmed'),
pending: t('reservations.pending'),
todayShort: t('native.widget.today'),
tomorrowShort: t('native.widget.tomorrow'),
now: t('native.widget.now'),
places: t('dashboard.places'),
travellers: t('native.widget.travellers'),
allDone: t('native.widget.dayDone'),
missing: t('native.widget.missing'),
countries: t('native.widget.countries'),
world: t('native.widget.world'),
group: t('native.widget.group'),
poll: t('native.widget.poll'),
votes: t('native.widget.votes'),
noMessages: t('native.widget.noMessages'),
departure: t('native.widget.departure'),
};
}
/** Where to ask for the weather: today's first place with coordinates, else any of the trip's. */
function weatherSpot(day: Day | undefined, places: Place[]): { lat: number; lng: number } | null {
const planned = (day?.assignments ?? []).map((a) => a.place).find((p) => p?.lat != null && p?.lng != null);
if (planned?.lat != null && planned.lng != null) return { lat: planned.lat, lng: planned.lng };
const any = places.find((p) => p.lat != null && p.lng != null);
return any?.lat != null && any.lng != null ? { lat: any.lat, lng: any.lng } : null;
}
interface SyncContext {
today: string;
language: string;
unit: string | undefined;
time: (value: string) => string;
}
async function tripExtras(trip: Trip, days: Day[], { today, language, unit, time }: SyncContext) {
const [places, reservations, todos, packing, memberRows] = await Promise.all([
offlineDb.places.where('trip_id').equals(trip.id).toArray(),
offlineDb.reservations.where('trip_id').equals(trip.id).toArray(),
offlineDb.todoItems.where('trip_id').equals(trip.id).toArray(),
offlineDb.packingItems.where('trip_id').equals(trip.id).toArray(),
offlineDb.tripMembers.where('tripId').equals(trip.id).toArray(),
]);
const members = memberRows.length;
const names = [trip.owner_username, ...memberRows.map((m) => m.username)].filter((n): n is string => !!n);
const path = `/trips/${trip.id}`;
const place = tripPlace(places);
// While the trip runs, today where it is; before, its first day, once that is in forecast range.
const start = trip.start_date ?? today;
const weatherDate = start > today ? start : today;
const inRange = (Date.parse(weatherDate) - Date.parse(today)) / 86_400_000 <= FORECAST_DAYS;
const spot = weatherSpot(
days.find((d) => (d.date ?? '').slice(0, 10) === weatherDate),
places
);
const [weather, cover, bags, collab] = await Promise.all([
spot && inRange ? widgetWeather(spot, { date: weatherDate, lang: language, unit, place }) : Promise.resolve(null),
widgetCover(trip.cover_image),
packingDetail(trip.id, packing),
collabSummary(
trip.id,
`/trips/${trip.id}?tab=collab`,
[...new Set(names)],
useAuthStore.getState().user?.id ?? null
),
]);
const packed = packingSummary(packing, `/trips/${trip.id}?tab=listen`);
const dayDates = new Map(days.map((d) => [d.id, (d.date ?? '').slice(0, 10)]));
return {
reservations,
extras: {
place,
cover,
placeCount: places.length,
// The members table lists the others; the owner comes on top.
memberCount: members > 0 ? members + 1 : 0,
weather,
bookings: bookingsAhead(reservations, { dayDates, today, tripPath: path, formatTime: time }),
todos: todosSummary(todos, `${path}?tab=listen`),
packing: packed && bags ? { ...packed, ...bags } : packed,
collab,
},
};
}
export default function NativeWidgetsSync(): null {
const { t, language } = useTranslation();
const { caps } = useNativeCapabilities();
const timeFormat = useSettingsStore((s) => s.settings.time_format);
const unit = useSettingsStore((s) => s.settings.temperature_unit);
useEffect(() => {
if (!widgetsActive()) return;
let cancelled = false;
/** One trip's snapshot with its day, as the widgets set to that trip show it. */
const tripSnapshot = async (trip: Trip | null, context: SyncContext, atlas: WidgetAtlas | null) => {
const days = trip ? await offlineDb.days.where('trip_id').equals(trip.id).toArray() : [];
const extra = trip ? await tripExtras(trip, days, context) : null;
const plain = trip
? todayOf(trip, days, context.today, (n, m) => t('native.widget.dayOf', { n, m }), context.time)
: null;
const day = plain ? withBookings(plain, extra?.reservations ?? [], context.today, context.time) : null;
return { day, snapshot: buildSnapshot(trip, day, labels(t), { ...extra?.extras, atlas }) };
};
const sync = async () => {
const now = new Date();
const today = localDateStr(now.getFullYear(), now.getMonth(), now.getDate());
const context: SyncContext = { today, language, unit, time: (value) => formatTime(value, language, timeFormat) };
const trips = await offlineDb.trips.toArray();
const trip = pickTrip(trips, today);
const days = trip ? await offlineDb.days.where('trip_id').equals(trip.id).toArray() : [];
const day = trip
? todayOf(
trip,
days,
today,
(n, m) => t('native.widget.dayOf', { n, m }),
(time) => formatTime(time, language, timeFormat)
)
: null;
const hero = pickTrip(trips, today);
// The countries do not depend on a trip: the atlas widget fills without one.
const atlas = await atlasSummary(language);
const main = await tripSnapshot(hero, context, atlas);
// Every trip running or ahead also gets its own, for widgets set to it in Edit Widget.
const choices: TripWidgetSnapshot[] = [];
for (const trip of widgetTripChoices(trips, today)) {
const own = trip.id === hero?.id ? main : await tripSnapshot(trip, context, null);
choices.push({
id: trip.id,
title: trip.title,
start: trip.start_date ?? '',
end: trip.end_date ?? '',
snapshot: own.snapshot,
});
}
if (cancelled) return;
const snapshot = buildSnapshot(trip, day, {
nextTrip: t('dashboard.nextTrip'),
today: t('native.widget.today'),
noTrip: t('native.widget.noTrip'),
inDays: t('native.widget.inDays'),
tomorrow: t('native.widget.tomorrow'),
nothingPlanned: t('native.widget.nothingPlanned'),
});
const live = day ? tripDayState(day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone')) : null;
await publishWidgets(snapshot, live);
const live = main.day
? tripDayState(main.day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone'))
: null;
await publishWidgets(main.snapshot, live, choices);
};
void sync();
@@ -64,7 +192,7 @@ export default function NativeWidgetsSync(): null {
window.clearInterval(timer);
void listener.then((handle) => handle.remove());
};
}, [caps, t, language, timeFormat]);
}, [caps, t, language, timeFormat, unit]);
return null;
}
+1 -1
View File
@@ -154,7 +154,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
const { size, borderWidth } = look
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
// escaping stops the attribute breakout but still permits a CSS url().
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(isSelected, safeHexColor(place.category_color, 'white'))
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(isSelected, place.category_color)
const bgColor = safeHexColor(place.category_color, '#6b7280')
// Number badges (bottom-right), or the rating where there are none: a numbered stop
+1 -1
View File
@@ -99,7 +99,7 @@ function nativeIosWithoutStore(nativeStore: boolean): boolean | undefined {
return nativeStore ? false : undefined
}
const PILL = 'absolute left-1/2 z-[5] flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]'
const PILL = 'absolute inset-x-0 z-[5] mx-auto flex w-fit items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]'
/**
* Says the map on screen is the stored offline basemap, not the user's own. With
+2 -61
View File
@@ -50,6 +50,7 @@ import { poiPinParts } from './poiMarker'
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { PLUGIN_TONE_COLORS, buildPluginLayerData } from './pluginLayerGeo'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { onRealWorld } from '../../utils/worldCopy'
@@ -128,7 +129,7 @@ function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?
const look = placeMarkerLook(place, selected)
const { size, borderWidth } = look
// See MapView: allow-listed rather than escaped, because this is a CSS context.
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(selected, safeHexColor(place.category_color, 'white'))
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(selected, place.category_color)
const bgColor = safeHexColor(place.category_color, '#6b7280')
// The visual circle is `size` + 2*border on each side. To make the
@@ -213,66 +214,6 @@ const PLUGIN_LINE_LAYER_IDS: Record<'solid' | 'dash' | 'dot', string> = {
dot: 'trek-plugin-layers-line-dot',
}
const PLUGIN_FILL_LAYER_ID = 'trek-plugin-layers-fill'
const PLUGIN_TONE_COLORS: Record<string, string> = {
default: '#4F46E5',
success: '#10b981',
warn: '#f59e0b',
danger: '#ef4444',
}
// GL circle layers size in screen pixels, so a metric circle has to become a
// polygon. Equirectangular approximation — plenty for a display-only overlay.
function circleToRing(center: [number, number], radiusM: number): [number, number][] {
const [lat, lng] = center
const dLat = radiusM / 111_320
const cos = Math.cos((lat * Math.PI) / 180)
const dLng = radiusM / (111_320 * Math.max(0.01, Math.abs(cos)))
const ring: [number, number][] = []
for (let i = 0; i <= 64; i++) {
const a = (i / 64) * 2 * Math.PI
ring.push([lng + Math.cos(a) * dLng, lat + Math.sin(a) * dLat])
}
return ring
}
interface PluginLayerGeoFeature {
type: 'Feature'
properties: { id: string; color: string; width: number; opacity: number; dash: string; fillOpacity: number; label: string }
geometry: { type: 'LineString'; coordinates: number[][] } | { type: 'Polygon'; coordinates: number[][][] }
}
function buildPluginLayerData(layers: PluginMapLayer[]) {
const features = layers.flatMap(layer => layer.features.flatMap((f, i): PluginLayerGeoFeature[] => {
const color = PLUGIN_TONE_COLORS[f.tone] ?? PLUGIN_TONE_COLORS.default
const properties = {
id: `${layer.pluginId}:${layer.id}:${i}`,
color,
width: f.width,
opacity: f.opacity,
dash: f.dash,
fillOpacity: f.fill ? Math.min(0.25, f.opacity) : 0,
label: f.label || '',
}
if (f.type === 'polyline' && f.points) {
return [{
type: 'Feature' as const,
properties,
geometry: { type: 'LineString' as const, coordinates: f.points.map(([lat, lng]) => [lng, lat]) },
}]
}
if (f.type === 'polygon' && f.points) {
const ring = f.points.map(([lat, lng]) => [lng, lat])
if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1])) ring.push(ring[0])
return [{ type: 'Feature' as const, properties, geometry: { type: 'Polygon' as const, coordinates: [ring] } }]
}
if (f.type === 'circle' && f.center && f.radiusM) {
return [{ type: 'Feature' as const, properties, geometry: { type: 'Polygon' as const, coordinates: [circleToRing(f.center, f.radiusM)] } }]
}
return []
}))
return { type: 'FeatureCollection' as const, features }
}
/**
* A place pin on the map, whatever is carrying it.
*
+5 -1
View File
@@ -4,7 +4,11 @@ import type { GlMapProvider } from './glProviders'
import type { MapViewProps } from './mapViewContract'
/** What a caller hands the GL trip map: the shared map contract and which provider's style to load. */
export type MapViewGLProps = MapViewProps & { glProvider?: GlMapProvider }
export type MapViewGLProps = MapViewProps & {
glProvider?: GlMapProvider
/** A style URL that replaces the provider's, as the offline fallback map (MapViewAuto). */
styleOverride?: string
}
/**
* The six ways into a GL map, one chunk per engine.
+6 -2
View File
@@ -1,4 +1,5 @@
import './selectedMarker.css'
import { safeHexColor } from '../../utils/safeColor'
/**
* Two ways a place marker can be told apart from the rest, set on the place object the
@@ -57,8 +58,11 @@ export const SELECTED_MARKER = {
} as const
/** The border colour and shadow of a round place marker, selected or not. */
export function placeMarkerFrame(selected: boolean, categoryBorder: string): { borderColor: string; shadow: string; className: string } {
export function placeMarkerFrame(
selected: boolean,
categoryColor: string | null | undefined
): { borderColor: string; shadow: string; className: string } {
return selected
? { borderColor: SELECTED_MARKER.border, shadow: SELECTED_MARKER.ring, className: SELECTED_MARKER.className }
: { borderColor: categoryBorder, shadow: '0 2px 8px rgba(0,0,0,0.22)', className: '' }
: { borderColor: safeHexColor(categoryColor, 'white'), shadow: '0 2px 8px rgba(0,0,0,0.22)', className: '' }
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
import { glWorksOffline, isOfflineForbiddenTemplate, leafletWorksOffline, offlineStyleFor } from './offlineBasemap';
import { isOfflineForbiddenTemplate } from '../../utils/tileUrl';
import { glWorksOffline, leafletWorksOffline, offlineStyleFor } from './offlineBasemap';
describe('offlineBasemap', () => {
it('picks the stored style for the colour scheme', () => {
+2 -14
View File
@@ -1,6 +1,6 @@
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
import { isNativeIos } from '../../native/platform';
import { isVectorStyle } from '../../utils/tileUrl';
import { isOfflineForbiddenTemplate, isVectorStyle } from '../../utils/tileUrl';
import { normalizeStyleForProvider, type GlMapProvider } from './glProviders';
/**
@@ -14,25 +14,13 @@ import { normalizeStyleForProvider, type GlMapProvider } from './glProviders';
* and glyphs. A fallback has to be exactly a style that was stored, or its
* sprite and labels are missing offline.
*/
export const OFFLINE_STYLES: readonly string[] = [OFM_POSITRON, OFM_DARK];
const OFFLINE_STYLES: readonly string[] = [OFM_POSITRON, OFM_DARK];
/** The stored style that matches the colour scheme. */
export function offlineStyleFor(dark: boolean): string {
return dark ? OFM_DARK : OFM_POSITRON;
}
/** Hosts whose tiles may not be stored for offline use at all (OSM tile usage policy). */
const NO_OFFLINE_HOSTS = ['tile.openstreetmap.org'];
export function isOfflineForbiddenTemplate(template: string): boolean {
try {
const host = new URL(template.replace(/\{s\}/g, 'a')).hostname;
return NO_OFFLINE_HOSTS.some((h) => host === h || host.endsWith(`.${h}`));
} catch {
return false;
}
}
/**
* Whether a GL map can be drawn offline as configured. Only MapLibre on one of
* the stored styles can: Mapbox has nothing stored to draw from, and another
@@ -0,0 +1,82 @@
import type { PluginMapLayer } from '../../api/client';
/** The plugin tone palette, shared by the GL layers and markers. */
export const PLUGIN_TONE_COLORS: Record<string, string> = {
default: '#4F46E5',
success: '#10b981',
warn: '#f59e0b',
danger: '#ef4444',
};
// GL circle layers size in screen pixels, so a metric circle has to become a
// polygon. Equirectangular approximation — plenty for a display-only overlay.
function circleToRing(center: [number, number], radiusM: number): [number, number][] {
const [lat, lng] = center;
const dLat = radiusM / 111_320;
const cos = Math.cos((lat * Math.PI) / 180);
const dLng = radiusM / (111_320 * Math.max(0.01, Math.abs(cos)));
const ring: [number, number][] = [];
for (let i = 0; i <= 64; i++) {
const a = (i / 64) * 2 * Math.PI;
ring.push([lng + Math.cos(a) * dLng, lat + Math.sin(a) * dLat]);
}
return ring;
}
interface PluginLayerGeoFeature {
type: 'Feature';
properties: {
id: string;
color: string;
width: number;
opacity: number;
dash: string;
fillOpacity: number;
label: string;
};
geometry: { type: 'LineString'; coordinates: number[][] } | { type: 'Polygon'; coordinates: number[][][] };
}
/** The plugins' vector layers as one GeoJSON source for the GL map. */
export function buildPluginLayerData(layers: PluginMapLayer[]) {
const features = layers.flatMap((layer) =>
layer.features.flatMap((f, i): PluginLayerGeoFeature[] => {
const color = PLUGIN_TONE_COLORS[f.tone] ?? PLUGIN_TONE_COLORS.default;
const properties = {
id: `${layer.pluginId}:${layer.id}:${i}`,
color,
width: f.width,
opacity: f.opacity,
dash: f.dash,
fillOpacity: f.fill ? Math.min(0.25, f.opacity) : 0,
label: f.label || '',
};
if (f.type === 'polyline' && f.points) {
return [
{
type: 'Feature' as const,
properties,
geometry: { type: 'LineString' as const, coordinates: f.points.map(([lat, lng]) => [lng, lat]) },
},
];
}
if (f.type === 'polygon' && f.points) {
const ring = f.points.map(([lat, lng]) => [lng, lat]);
if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1]))
ring.push(ring[0]);
return [{ type: 'Feature' as const, properties, geometry: { type: 'Polygon' as const, coordinates: [ring] } }];
}
if (f.type === 'circle' && f.center && f.radiusM) {
return [
{
type: 'Feature' as const,
properties,
geometry: { type: 'Polygon' as const, coordinates: [circleToRing(f.center, f.radiusM)] },
},
];
}
return [];
})
);
return { type: 'FeatureCollection' as const, features };
}
+6 -1
View File
@@ -610,8 +610,13 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
<meta charset="UTF-8">
<base href="${window.location.origin}/">
<title>${escHtml(trip?.title || tr('pdf.travelPlan'))}</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
<style>
/* Poppins from this server (client/public/fonts), never from Google: the print view
would otherwise send every reader's IP to Google's font servers. */
@font-face { font-family: 'Poppins'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/Poppins-Regular.ttf') format('truetype'); }
@font-face { font-family: 'Poppins'; font-style: italic; font-weight: 400; font-display: swap; src: url('/fonts/Poppins-Italic.ttf') format('truetype'); }
@font-face { font-family: 'Poppins'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/Poppins-Medium.ttf') format('truetype'); }
@font-face { font-family: 'Poppins'; font-style: normal; font-weight: 600 700; font-display: swap; src: url('/fonts/Poppins-Bold.ttf') format('truetype'); }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
@@ -36,11 +36,8 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
const mouseDownTarget = useRef<EventTarget | null>(null)
const [files, setFiles] = useState<File[]>([])
// A booking document shared to TREK lands here already chosen.
useEffect(() => {
if (!isOpen) return
const shared = useSharedImportStore.getState().file
if (shared) setFiles([useSharedImportStore.getState().take() ?? shared])
if (isOpen && useSharedImportStore.getState().file) useSharedImportStore.getState().take()
}, [isOpen])
const [isDragOver, setIsDragOver] = useState(false)
const [loading, setLoading] = useState(false)
@@ -50,6 +47,21 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
// Only a model that reads images can do anything with a photo, so photos are offered only then.
const acceptedExts: readonly string[] = readsPhotos ? [...ACCEPTED_EXTS, ...LLM_PHOTO_EXTENSIONS] : ACCEPTED_EXTS
// A booking document shared to TREK lands here already chosen: picked up in
// the render that opens the modal, and taken out of the store once shown.
const [openSeen, setOpenSeen] = useState(false)
if (isOpen !== openSeen) {
setOpenSeen(isOpen)
if (isOpen) {
// Opens clean, but with a shared document already in the list.
const shared = useSharedImportStore.getState().file
setFiles(shared ? [shared] : [])
setIsDragOver(false)
setLoading(false)
setError('')
}
}
const reset = () => {
setFiles([])
setIsDragOver(false)
@@ -57,12 +69,6 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
setError('')
}
useEffect(() => {
if (isOpen) reset()
// reset is stable — intentional
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen])
useEffect(() => {
if (!isOpen) return
healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
@@ -475,9 +475,8 @@ describe('PlaceDetailsColumn — hours and rating', () => {
* FE-PDC-027 — nothing came back for this place.
*
* It used to be a line of grey text plus, when no Google key was set, a panel
* telling the reader to go and ask an administrator for one. The mascot says the
* same thing in the shape every other empty state in TREK uses, and whose key is
* missing is not this panel's business.
* telling the reader to go and ask an administrator for one. One quiet line says
* that nothing came back, and whose key is missing is not this panel's business.
*/
describe('PlaceDetailsColumn — nothing found', () => {
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
@@ -26,7 +26,6 @@ import { mapsApi } from '../../api/client'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
import { safeHttpUrl } from '../../utils/safeUrl'
import EmptyState from '../shared/EmptyState'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { LABEL } from '../shared/dialogParts'
@@ -66,8 +65,8 @@ interface PlaceDetailsColumnProps {
fluid?: boolean
/**
* 'dialog' is the desktop add place dialog: a grey panel of white cards under
* small eyebrows, and a quiet hint card where the sheet shows the mascot. The
* default is the phone sheet's look, which this prop leaves exactly as it was.
* small eyebrows. The default is the phone sheet's look: a card in the mobile
* tokens with the same one-line hints.
*/
variant?: 'sheet' | 'dialog'
/** False on an instance with no Google key, which is most of them. */
@@ -76,13 +75,24 @@ interface PlaceDetailsColumnProps {
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
/** The phone sheet's quiet one-line state: an icon and a sentence, no mascot taking half the screen. */
function SheetHint({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<p className="flex items-center gap-2 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2.5 text-caption text-m-muted">
<span className="flex-none">{icon}</span>
{children}
</p>
)
}
/**
* Overline label, the section heading Vacay and the dashboard widgets share.
* Uppercase with wide tracking, muted, no rule underneath.
*/
function Overline({ children }: { children: React.ReactNode }): React.ReactElement {
return (
<p className="text-caption font-semibold uppercase tracking-[0.14em] text-content-faint">{children}</p>
// The phone form's eyebrow (mobile Eyebrow), so these labels match Name, Notes and the rest below.
<p className="font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.09em] text-m-faint">{children}</p>
)
}
@@ -93,7 +103,7 @@ function Overline({ children }: { children: React.ReactNode }): React.ReactEleme
function Block({ label, dialog, children }: { label: React.ReactNode; dialog: boolean; children: React.ReactNode }): React.ReactElement {
if (!dialog) {
return (
<div className="space-y-2">
<div className="space-y-[5px]">
<Overline>{label}</Overline>
{children}
</div>
@@ -126,6 +136,9 @@ export default function PlaceDetailsColumn({
const adoptBlockedId = useId()
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
const [state, setState] = useState<LoadState>('idle')
// The phone sheet folds the card away to give the form's own fields the room.
const [open, setOpen] = useState(true)
const bodyId = useId()
const abortRef = useRef<AbortController | null>(null)
const selectionKey = selection ? cacheKeyFor(selection, language) : null
@@ -225,17 +238,36 @@ export default function PlaceDetailsColumn({
// tiles, which is too small to tell a facade from a foyer. The column is
// stretched to the form's height by the row it sits in, so the extra room
// costs nothing that was being used.
<aside className={dialog ? `${SIDE_COLUMN} ${fluid ? '' : 'sm:w-80'}` : `w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch`}>
<aside className={dialog ? `${SIDE_COLUMN} ${fluid ? '' : 'sm:w-80'}` : `w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-[18px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] overflow-hidden self-stretch`}>
{dialog ? (
<ColumnHead icon={<Landmark size={13} strokeWidth={2.2} />}>{t('places.details.title')}</ColumnHead>
) : (
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
<Landmark size={15} className="text-accent" />
<span className="text-body font-semibold text-content">{t('places.details.title')}</span>
</div>
<button
type="button"
aria-expanded={open}
aria-controls={bodyId}
onClick={() => setOpen(o => !o)}
className={`flex w-full shrink-0 items-center gap-2.5 px-3 py-2.5 text-start ${open ? 'border-b border-[color:var(--m-rowbr)]' : ''}`}
>
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-ink">
<Landmark size={15} strokeWidth={2} />
</span>
<span className="min-w-0 flex-1 text-body font-semibold text-m-ink">{t('places.details.title')}</span>
<ChevronDown
size={16}
strokeWidth={2.2}
className={`flex-none text-m-muted transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
/>
</button>
)}
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : 'flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5'}>
{/* Folding on the phone: the grid row eases between its height and none. */}
<div
id={bodyId}
className={dialog ? 'contents' : `grid transition-[grid-template-rows] duration-300 ease-out ${open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'}`}
>
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : `min-h-0 overflow-hidden ${open ? '' : 'invisible'}`}>
<div className={dialog ? 'contents' : 'p-3 space-y-3'}>
{/* The place's picture first: its own ones are there whatever the sources answer. */}
<PictureBlock
photos={state === 'ready' && !data?.disabled ? (data?.photos ?? []) : []}
@@ -246,19 +278,13 @@ export default function PlaceDetailsColumn({
t={t}
/>
{/* Nothing picked yet, which is a state rather than a fault: the mascot
waits with an idle look instead of a sad one. The dialog says the
same in a quiet card, and the column keeps its width either way. */}
{/* Nothing picked yet, which is a state rather than a fault: one quiet
line, in the dialog and on the phone alike, so the form's own fields
stay in view instead of sitting under a mascot half a screen high. */}
{!selection && (dialog ? (
<HintCard icon={<ImageIcon size={15} strokeWidth={2} />}>{t('places.details.empty')}</HintCard>
) : (
<EmptyState
scene="idle"
title={t('places.details.empty')}
size={92}
fill
surface="var(--bg-secondary)"
/>
<SheetHint icon={<ImageIcon size={14} strokeWidth={2} />}>{t('places.details.empty')}</SheetHint>
))}
{selection && state === 'loading' && (dialog ? (
@@ -278,22 +304,13 @@ export default function PlaceDetailsColumn({
? <HintCard icon={<Landmark size={15} strokeWidth={2} />}>{t('places.details.disabled')}</HintCard>
: <p className="text-caption text-content-muted">{t('places.details.disabled')}</p>)}
{/* The mascot rather than a line of grey text and, under it, a paragraph
telling the reader to go and ask an administrator for a Google key.
Nothing came back for this place, which is worth saying once and
{/* Nothing came back for this place, which is worth saying once and
plainly; whose key is missing is not this panel's business, and on a
place the free sources describe fine it read as an advert. */}
{isEmpty && (dialog ? (
<HintCard icon={<SearchX size={15} strokeWidth={2} />}>{t('places.details.nothing')}</HintCard>
) : (
<EmptyState
scene="search"
mood="sad"
title={t('places.details.nothing')}
size={92}
fill
surface="var(--bg-secondary)"
/>
<SheetHint icon={<SearchX size={14} strokeWidth={2} />}>{t('places.details.nothing')}</SheetHint>
))}
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
@@ -319,6 +336,8 @@ export default function PlaceDetailsColumn({
</>
)}
</div>
</div>
</div>
</aside>
)
}
@@ -642,7 +661,7 @@ function DescriptionBlock({
return (
<Block label={aboutBrand ? t('places.details.aboutBrand') : t('places.details.description')} dialog={dialog}>
<div className={dialog ? `${COLUMN_CARD} p-3 space-y-2` : 'rounded-lg border border-edge bg-surface p-2.5 space-y-2'}>
<div className={dialog ? `${COLUMN_CARD} p-3 space-y-2` : 'rounded-[14px] bg-[color:var(--m-ic)] p-3 space-y-2'}>
{aboutBrand && (
<p className="flex items-start gap-1.5 text-caption leading-tight text-content-faint">
<Building2 className="mt-px h-3 w-3 shrink-0" />
@@ -674,7 +693,7 @@ function DescriptionBlock({
onClick={onAdopt}
disabled={adoptBlocked}
aria-describedby={adoptBlocked ? adoptBlockedId : undefined}
className={dialog ? WHITE_BUTTON : 'w-full inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-body font-medium bg-accent-subtle text-accent-on hover:bg-accent hover:text-accent-text disabled:opacity-50 disabled:hover:bg-accent-subtle disabled:hover:text-accent-on transition-colors'}
className={dialog ? WHITE_BUTTON : 'inline-flex h-8 items-center justify-center gap-1.5 rounded-full bg-m-act px-3.5 text-caption font-semibold text-m-actfg transition-transform active:scale-[.97] disabled:opacity-40'}
style={dialog ? fs(13, 'body') : undefined}
>
<ArrowRight className="w-3.5 h-3.5" />
@@ -44,6 +44,7 @@ export function SuggestionInputBox({
}: SuggestionInputBoxProps) {
return (
<div
data-field-trigger={floating ? undefined : 'pad'}
className={floating ? 'focus-within:ring-2 focus-within:ring-accent' : 'bg-surface-tertiary'}
style={
floating
@@ -20,7 +20,7 @@ import { formatMoney, formatTime } from '../../../utils/formatters'
import { copyText } from '../../../utils/clipboard'
import { fmtTransitDuration } from '../transitDisplay'
import { parseMeta, displayTitle } from './bookingsModel'
import { formatDay, type BookingFacts } from './bookingFacts'
import { stayStats, type BookingFacts } from './bookingFacts'
import { AirTrailPill, BOX, Eyebrow, Field, FileRows, ReviewPill, TravelerChips, fs, toneOf } from './bookingParts'
import { TransitLegs, transitSummary } from './transitParts'
import { BookingDialogHeader, StatusPill, TypePill } from './BookingDialogShell'
@@ -79,10 +79,7 @@ export default function BookingDetailDialog(p: BookingDetailDialogProps) {
const stats: { value: string; label: string }[] = []
if (facts.isHotel) {
if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') })
if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') })
const nights = nightsBetween(facts.startDate, facts.endDate)
if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) })
stats.push(...stayStats(facts, meta, t, locale))
} else {
const from = facts.endpoints[0]
const to = facts.endpoints[facts.endpoints.length - 1]
@@ -318,8 +315,3 @@ function DetailField({ label, children }: { label: string; children: ReactNode }
)
}
function nightsBetween(a: string | null, b: string | null): number {
if (!a || !b) return 0
const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5)
return n > 0 ? n : 0
}
@@ -133,3 +133,28 @@ export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts {
isHotel,
}
}
function nightsBetween(a: string | null, b: string | null): number {
if (!a || !b) return 0
const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5)
return n > 0 ? n : 0
}
/**
* A stay's three tiles: check-in and check-out with their dates and times, and
* the nights between. The desktop booking dialog and the phone's booking sheet
* show the same ones.
*/
export function stayStats(
facts: BookingFacts,
meta: { check_in_time?: string | null; check_out_time?: string | null },
t: FactsContext['t'],
locale: string,
): { value: string; label: string }[] {
const stats: { value: string; label: string }[] = []
if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') })
if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') })
const nights = nightsBetween(facts.startDate, facts.endDate)
if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) })
return stats
}
@@ -22,7 +22,7 @@ import {
Ruler,
Upload,
} from 'lucide-react';
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
import { createElement, useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
import Markdown from 'react-markdown';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
@@ -38,7 +38,7 @@ import { inheritedTrackColor, resolveTrackColor } from '../Map/trackColors';
import { BOX } from '../Planner/bookings/bookingParts';
import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation';
import { SoftPill, tintOf } from '../Planner/planParts';
import DetailShell from '../shared/DetailShell';
import DetailShell, { type DetailShellProps } from '../shared/DetailShell';
import { DeleteButton, DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell';
import ElevationProfile from '../shared/ElevationProfile';
import { NavigationMenu } from '../shared/NavigationMenu';
@@ -108,6 +108,8 @@ interface TourDetailDialogProps extends TourPermissionProps {
readOnly?: boolean;
/** Desktop-only opener; the map detail stays non-modal and returns focus when closed. */
desktopFocusReturnTarget?: HTMLElement | null;
/** The frame it renders into: the desktop's card over the map, or the phone's sheet. */
shell?: ComponentType<DetailShellProps>;
}
/** The pills of the head band: white like a place's, one fact each. */
@@ -518,6 +520,7 @@ export default function TourDetailDialog({
desktopNonModal = false,
readOnly = false,
desktopFocusReturnTarget = null,
shell: Shell = DetailShell,
canEdit: editPermission,
canAssign: assignPermission,
}: TourDetailDialogProps) {
@@ -701,7 +704,7 @@ export default function TourDetailDialog({
);
return (
<DetailShell
<Shell
testId="tour-detail"
header={header}
footer={footer}
@@ -797,7 +800,7 @@ export default function TourDetailDialog({
<TourProvenanceBlock tripId={place.trip_id} tour={tour} canCheck={canEdit} onReimported={onSourceReimported} />
<TourFilesSection placeId={place.id} files={placeFiles} onFileUpload={canEdit ? onFileUpload : undefined} />
</DetailShell>
</Shell>
);
}
+28 -12
View File
@@ -4,6 +4,7 @@ import { AlertTriangle, HelpCircle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useIsPhone } from '../../mobile/useIsPhone'
import { DialogTile, NEUTRAL_TINT, fs } from './DialogShell'
import { useLastOpen, usePresence } from './usePresence'
interface ConfirmDialogProps {
isOpen: boolean
@@ -28,7 +29,14 @@ function runConfirm(onConfirm: () => void): void {
}
}
export default function ConfirmDialog({
export default function ConfirmDialog(props: ConfirmDialogProps) {
const { mounted, closing } = usePresence(props.isOpen)
// The question stays readable while it fades, even when its owner clears the subject on close.
const shown = useLastOpen(props, props.isOpen)
return mounted ? <ConfirmDialogFrame {...shown} isOpen={props.isOpen} closing={closing} /> : null
}
function ConfirmDialogFrame({
isOpen,
onClose,
onConfirm,
@@ -38,7 +46,8 @@ export default function ConfirmDialog({
cancelLabel,
danger = true,
children,
}: ConfirmDialogProps) {
closing = false,
}: ConfirmDialogProps & { closing?: boolean }) {
const { t } = useTranslation()
const isPhone = useIsPhone()
@@ -57,14 +66,21 @@ export default function ConfirmDialog({
return () => document.removeEventListener('keydown', handleEsc, true)
}, [isOpen, handleEsc])
if (!isOpen) return null
if (!isOpen && !closing) return null
// Each used by both layouts, worked out once.
const heading = title || t('common.confirm')
const cancelText = cancelLabel || t('common.cancel')
const confirmText = confirmLabel || t('common.delete')
const modalExit = closing ? 'trek-modal-exit' : ''
const width = children ? 'max-w-md' : 'max-w-sm'
return createPortal(
<div
// Backdrop only — Escape (above) and the Cancel button below are the
// keyboard routes out of the dialog.
role="presentation"
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
className={`fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)] ${closing ? 'trek-backdrop-exit pointer-events-none' : ''}`}
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
>
@@ -74,7 +90,7 @@ export default function ConfirmDialog({
own, and the two buttons below it always stay in reach. */}
<div
role="presentation"
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
className={`trek-modal-enter ${modalExit} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${width} p-6 bg-surface-card`}
onClick={e => e.stopPropagation()}
>
<div className="flex min-h-0 items-start gap-4 overflow-y-auto overscroll-contain">
@@ -85,7 +101,7 @@ export default function ConfirmDialog({
)}
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-content">
{title || t('common.confirm')}
{heading}
</h3>
<p className="mt-1 text-sm text-content-secondary">
{message}
@@ -99,7 +115,7 @@ export default function ConfirmDialog({
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-content-secondary border border-edge-secondary"
>
{cancelLabel || t('common.cancel')}
{cancelText}
</button>
<button type="button"
onClick={() => { runConfirm(onConfirm); onClose() }}
@@ -107,7 +123,7 @@ export default function ConfirmDialog({
danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'
}`}
>
{confirmLabel || t('common.delete')}
{confirmText}
</button>
</div>
</div>
@@ -117,7 +133,7 @@ export default function ConfirmDialog({
// with its tile, the message below, the answers in a bar at the foot.
<div
role="presentation"
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${children ? 'max-w-md' : 'max-w-sm'}`}
className={`trek-modal-enter ${modalExit} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${width}`}
onClick={e => e.stopPropagation()}
>
<div className={`flex flex-none items-center gap-3.5 px-6 pb-4 pt-5 ${danger ? 'bg-danger-soft' : ''}`} style={danger ? undefined : { background: NEUTRAL_TINT }}>
@@ -127,7 +143,7 @@ export default function ConfirmDialog({
: <HelpCircle size={20} strokeWidth={1.9} className="text-content-muted" />}
</DialogTile>
<h3 className="m-0 min-w-0 flex-1 font-bold tracking-[-0.01em] text-content" style={fs(18, 'subtitle')}>
{title || t('common.confirm')}
{heading}
</h3>
</div>
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto overscroll-contain px-6 py-5">
@@ -137,13 +153,13 @@ export default function ConfirmDialog({
<div className="flex flex-none items-center justify-end gap-2 border-t border-edge-faint px-6 py-3.5" style={fs(13, 'body')}>
<button type="button" onClick={onClose}
className="inline-flex items-center rounded-[10px] bg-surface-tertiary px-3.5 py-2 font-medium text-content hover:bg-surface-hover">
{cancelLabel || t('common.cancel')}
{cancelText}
</button>
<button type="button"
onClick={() => { runConfirm(onConfirm); onClose() }}
className={`inline-flex items-center rounded-[10px] px-4 py-2 font-medium hover:opacity-90 ${danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'}`}
>
{confirmLabel || t('common.delete')}
{confirmText}
</button>
</div>
</div>

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