Compare commits

..

73 Commits

Author SHA1 Message Date
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
Maurice 431cc226e3 feat(mobile): pull-to-refresh on the journal and notification lists
The journal list (its own scroll box, so the hook now takes an optional
scroller) and the notifications screen reload by pulling, like the
dashboard. The list stays on screen while it reloads.
2026-11-06 15:02:55 +01:00
Maurice 6835fd7bf3 feat(mobile): page transitions, pull-to-refresh, share sheet and haptics
Page transitions: on the phone, pages move like a native navigation stack,
built on the View Transitions API (mobile/routeTransition.ts). A page opened
from another slides in from the trailing edge, back slides it away, and
switching between the dock's sections cross-fades while the dock stands
still. Lazy routes do not flash their skeleton: the swap waits, behind the
frozen snapshot, until the new page rendered. The edge-swipe back gesture
already moves the page and skips the transition. RTL mirrors the motion,
reduced motion and desktop get none.

Pull-to-refresh on the mobile dashboard: the page follows the finger with
resistance, ticks once past the threshold and re-reads trips and
notifications quietly, without a loading state.

Share: trip share and invite links and the journey link get a share button
next to copy, opening the system share sheet (Capacitor Share in the apps,
Web Share in browsers that have it).

Haptics: a selection click for switches and segmented controls, a tick when
a sheet is flung closed, and a firmer bump for the chat's long press.

Theme lint: this branch's new screens use the type and z-index tokens, the
Vacay company tint is a token, and the baseline is lowered to the new
counts.
2026-11-06 15:02:55 +01:00
Maurice 3c4c880c31 feat(native): push notifications on Android through the relay
The Android app now subscribes like the iOS one: TrekShell.registerPush asks
for the notification permission (Android 13+), creates the device's P-256
Web Push keys and returns the relay endpoint for its FCM token
(push.liketrek.com/v1/fcm/<token>). TrekPushService receives the relay's
data message, decrypts the record (RFC 8291, TrekWebPush) and shows the
notification. A tap opens the linked page, also when it starts the app.

The keys are sealed with an AES key kept in the Android Keystore. Firebase
is configured from res/values/firebase.xml, so no google-services plugin is
needed. The decryption is tested against the RFC 8291 example, and the
native workflow now runs the Android unit tests. On the web side push is no
longer limited to iOS: it follows the nativePush capability on both platforms.
2026-11-06 15:02:55 +01:00
Maurice f35b189ca2 feat(native): home-screen widget and travel-day Live Activity on iOS
A WidgetKit extension (TREKWidgets) with two parts: the next trip on the home
screen, with a countdown before it starts and today's day and next stops
while it runs, and a Live Activity for the travel day on the lock screen and
in the Dynamic Island showing the next timed stop and the day's progress.

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

NativeWidgetsSync refreshes on start, on returning to the foreground and every
quarter hour. Both parts are gated behind the widgets / liveActivity
capabilities, so older binaries and the PWA are unaffected.
2026-11-06 15:02:55 +01:00
Maurice c6f91716a4 feat(native): a notification extension that decrypts TREK pushes on the device
The relay hands APNs the encrypted record with a placeholder text and
mutable-content set; the new Notification Service Extension opens it with
the device's Web Push keys from the shared keychain group (RFC 8291 on
CryptoKit) and shows the real title, text and link. Without the keys the
placeholder stays, so a notification arrives either way and nothing on the
way reads it.

The target came in through ios/scripts/add-extension.rb (kept, idempotent),
its App ID is registered, and the release lane signs every target with its
own App Store profile. Web Push payloads now stop at 2800 bytes so the
encrypted, base64-encoded record still fits Apple's 4 KB.
2026-11-06 15:02:54 +01:00
Maurice ad69bbf4a9 feat(native): push inside the iOS app, through an encrypted relay
The app subscribes to Web Push like a browser, only the app makes the
subscription: it asks for permission, registers with APNs and keeps its own
P-256 key pair and auth secret in a keychain group a notification extension
can share. The endpoint is liketrek's relay for this device's token, so the
server encrypts exactly as for a browser and nobody on the way can read the
notification. The settings card, the test push, sign-in resync and the logout
teardown all take this path where the binary has nativePush; a tapped
notification opens the place it points at, also from a cold start.

The App ID has push enabled, the app carries the aps-environment and
keychain-group entitlements.
2026-11-06 15:02:54 +01:00
Maurice 30158cddc3 feat(push): accept the TREK apps' push relay as a push service
The apps subscribe to Web Push like a browser, with their own key pair, but
with an endpoint on liketrek's relay: only liketrek holds the apps' APNs and
FCM keys. The server encrypts exactly as for a browser (RFC 8291), so the
relay hands on ciphertext it cannot read and the device decrypts it. Lookalike
hosts stay refused.
2026-11-06 15:02:54 +01:00
Maurice 825469a7f2 feat(native): share a booking document to TREK and import it into a trip
TREK now appears under "Open in" and in the share sheet for PDFs, calendar
entries, Wallet passes and mails (iOS document types, Android VIEW and SEND
filters). The native side reads the file (up to 15 MB) and hands it to the UI,
which asks which trip it belongs to, upcoming trips first, then opens that
trip's booking import with the file already chosen.
2026-11-06 15:02:53 +01:00
Maurice 56f9a92054 feat(native): the app's own location on iOS, and a status bar tap scrolls to top
WKWebView asks for the location per server origin, under the server's name,
and again across launches. Binaries with the geolocation feature answer
navigator.geolocation from CoreLocation instead: one When-In-Use prompt as
the app, a stream that runs only while something listens, at the accuracy
the most demanding caller asked for. Every caller keeps the browser API.

A tap on the status bar now scrolls the list in the middle of the screen
back to the top, as in every iOS app.
2026-11-06 15:02:53 +01:00
Maurice 017f229cfc feat(native): an app lock with Face ID, Touch ID or the device passcode
A switch in the account settings, inside the app on a device that can unlock
that way (iOS LocalAuthentication, Android BiometricPrompt with the screen
lock as fallback). Turning it on asks once, so nobody locks themselves out.
With it on, TREK asks at launch and after half a minute in the background
before showing anything, and the app switcher shows a blurred cover (iOS) or
no snapshot (Android) instead of the screen. The setting stays on the
device, not the account.
2026-11-06 15:02:53 +01:00
Maurice dec6babe1d feat(native): an iOS map tile store, so viewed maps keep working offline
WKWebView runs no service worker for a self-hosted server, so on iOS nothing
kept a map tile once it scrolled away. Binaries with the tileCache feature
answer a trek-tile:// scheme from a store on disk: a tile viewed once is
served from there for up to 30 days (Mapbox's limit for a device cache,
applied to every provider), then fetched anew; the store keeps under 250 MB,
dropping the least recently used tiles first, sends a unique app User-Agent,
and never prefetches. Mapbox's own resources (style, tiles, glyphs, sprites)
and Leaflet's raster tiles go through it, telemetry never does.

Offline, a Mapbox map in such an app now stays Mapbox where it was viewed,
and the pill offers the stored trip region instead. The offline settings
show what the store takes against its limit, with a way to empty it.
2026-11-06 15:02:53 +01:00
Maurice d859e23879 feat(native): the app's own swipe back, and a way back closes the sheet on top first
WebKit's swipe from the left edge slid away a screenshot of the whole page,
closed no sheet and took every drag near the edge from the UI. Binaries from
this one turn it off and report an edgeBack feature, and the UI draws its own:
the page follows the finger, a release past a third or a flick slides it off
and goes back, anything else springs it home, with a light detent.

Open sheets now register in one overlay stack, so a way back closes the one on
top first, through its own close path (unsaved changes still ask): the edge
swipe on iOS and Android's back gesture alike.
2026-11-06 15:02:52 +01:00
Maurice f92aea80a7 feat(native): a capability handshake between the app binary and the server's UI
The UI ships with the server and the binary through the stores, so the two
drift. TrekShell.getCapabilities now reports the binary's shell API, version
and native features by name (iOS and Android), and the UI turns each native
feature on only where the binary has it; a binary from before the handshake
counts as shell API 1 with none. The other way round, the server names the
oldest binary its UI works with (minNativeShellApi), and an app below it is
asked to update with a link to its store.
2026-11-06 15:02:52 +01:00
Maurice 7b29a5d34e feat(mobile): Vacay's month picker and month heads in glass
The editor's twelve month chips were twelve grey boxes; they sit on one glass
surface now, the picked month under a pill that glides to it across both
rows. The month heads in the year overview are glass pills too.
2026-11-06 15:02:51 +01:00
Maurice c7a369a0ca feat(mobile): Vacay's people and legend pills share the row equally 2026-11-06 15:02:51 +01:00
Maurice 117af0f9b1 feat(mobile): swipe through Vacay's months and years, people and legend in sheets
In the editor the months of the leave year page like the trip's days, the
neighbour sliding in with the finger; in the overview the plan's years do
the same (only between years the plan has, the arrows still create one).
The day pager now pages anything with an id and can sit in a page's flow.

The row of person and legend chips is two compact pills now: People opens a
sheet to pick the member and show or hide shared calendars, Legend opens one
that explains the company days and holiday calendars, which were never a
choice.
2026-11-06 15:02:51 +01:00
Maurice 496c85e6e5 feat(mobile): one shape for the atlas header buttons
Planned was a label with a switch in a pill of its own beside the full-width
wishlist button, and with Dawarich on the row grew cramped. Wishlist,
Dawarich and Planned are now buttons of one shape, equal width and the phone
shell's glass; Planned fills while the planned countries show.
2026-11-06 15:02:51 +01:00
Maurice 1c15c33cf9 chore(i18n): drop the strings the removed map and today buttons used
The jump-to-today button and the Google Maps and CoMaps tiles are gone, so
their strings reached nothing and would fail the i18n key check.
2026-11-06 15:02:50 +01:00
Maurice 50bd1bcbc1 feat(ui): a calm, native-looking card when a view fails or TREK was updated
The error and new-version fallback was a bordered box with a warning
triangle and the raw error message on show. It is now one centred card with
a round symbol (a friendly one for an update), a short title and line, and a
full-width reload button; the technical message stays for issue reports,
folded under Details. Phone, PWA and desktop alike.
2026-11-06 15:02:50 +01:00
Maurice d2ae9a87c4 fix(mobile): the trip filter fits beside its buttons again
The German Completed filter (Abgeschlossen) made the dashboard's filter pill
wider than its row, so it scrolled inside its box and showed cut off. It is
now Beendet, the help texts naming the filter follow, and the intrinsic
segments take a little less padding.
2026-11-06 15:02:50 +01:00
Maurice c5cb39c27b feat(mobile): one Maps button for every map app, actions as one glass group
The day plan offered Google Maps and CoMaps as two tiles. One Maps button now
opens the day in whichever app the traveller picks: Google Maps, Apple Maps
with the stops between as waypoints, CoMaps, Waze and on Android any other
map app, the last two to the first stop, which they say. The preferred map
app from the settings opens straight away.

The day's actions sit together on one glass surface as round icons over
their labels, and the up-next card wears the same surface as the timeline
card under it.
2026-11-06 15:02:49 +01:00
Maurice 6d093d1102 feat(mobile): one glass language across the day plan's controls
- The rename and reorder controls beside the day's name, and the day and
  weather chips on the timeline card, are now round or pill glass controls of
  one height, so they read as tappable; the stay chips follow.
- A leg between two stops is one pill, as on the desktop: the mode, the time
  in full weight and the distance beside it, larger and without a dot.
- The timeline flows under the up-next card at a fixed 12px, whatever its
  height, instead of an offset that left a gap when the stop has no address.
- The places bar on the map wears the day rail's glass at its 36px height.
- A context menu stays inside all four edges of the screen, measured at its
  real size rather than mid-animation, and scrolls when taller than it.
2026-11-06 15:02:49 +01:00
Maurice 01ddcc63bb feat(mobile): two calm round controls a side on the map, no extra buttons
The phone map stacked four controls on the right and one on the left, in
three different looks. Now each side has two at most, all in one frosted
style, 20px off the edge and 8px apart: the whole-trip overview and locate on
the right, the base layer and the Dawarich route on the left, with the compass
appearing only while the map is turned off north.

The map's own places filter button and sheet are gone: the places tab carries
the same filter, and the map's pins follow it. The jump-to-today button beside
the day rail is gone too, since today's chip is already marked.

Also fixes a leftover of the segmented-control change that broke the parse.
2026-11-06 15:02:49 +01:00
Maurice b07b38b5b7 feat(map): never leave an offline map grey, whatever the basemap
Offline, a Mapbox map, another OpenFreeMap style or a raster template the
device cannot serve now switches to the stored OpenFreeMap basemap, light or
dark with the colour scheme, keeping every marker, route and track, with a
small Offline map pill. It switches back once the connection returns.

"Prepare for offline" stores that basemap's region for every trip, whatever
the provider, and both styles' sprites and glyphs, including the scripts a
trip in East or South Asia labels its places in. The vector prefetch keeps to
OpenFreeMap's download rules (four requests at a time, 50 MB per run), raster
tiles are no longer prefetched from OpenStreetMap's own servers, which their
policy forbids, nor inside the iOS app, which has no service worker to serve
them.
2026-11-06 15:02:48 +01:00
Maurice 83f0bd2ead feat(mobile): pull sheets from anywhere with a mouse too, glide the active day
A pull on a sheet's content only answered touch, so with a mouse the sheet
could only be dragged by its top edge, and a pull that started on a button
read as a press. Mouse and pen now pull the content like a finger, a pull
that starts on a button swallows its click, and text fields keep their own
gesture.

The active day chip and the segmented controls now glide one shared pill to
the new item on the compositor, instead of repainting two buttons.
2026-11-06 15:02:48 +01:00
Maurice 7293ee1174 feat(mobile): sheets that drag, fling and rise like native ones
The docked sheet could only be pulled by an invisible 20px strip, re-rendered
React on every finger move, and a flick sprang back to the top before fading
out. A finger on the content now carries the sheet once the content is at its
top, the panel moves through direct style writes, and a dismissing release
throws it off screen from where it was let go, faster the harder the flick.
With unsaved changes it springs back and asks instead. Pulling up only
stretches a little.

The docked sheet now rises from below the screen edge and sinks back there,
and the floating card pops in place the way a native alert does.
2026-11-06 15:02:48 +01:00
Maurice 25351c3628 feat(mobile): page through the days like a native pager
The day swipe only rubber-banded the current day, then cut to the next one
after a fixed slide and a synchronous re-render of the whole planner. The
previous, current and next day now sit side by side in a horizontal
scroll-snap strip, so the finger drags the neighbour in, and momentum,
edge bounce and axis locking are the browser's own. The day changes once
the strip comes to rest, with a light haptic tick in the app.

The pager is one building block, MDayPager, used by the plan timeline and
the road trip stages alike. Each page renders its own day through the
timeline and stage hooks, which now take the day as a parameter, and keeps
its state when it slides into the middle.
2026-11-06 15:02:48 +01:00
Maurice d18fb7f63d fix(native): drop the unused yearly-savings string and a dead initial value
The plan picker no longer shows a yearly badge, so its string reached nothing
and failed the i18n key check; the shell's start also initialised a value it
always overwrote.
2026-11-06 15:02:47 +01:00
Maurice 1054f735ad i18n(native): carry the new onboarding wording into every language
The welcome headline and subtext, the demo button, the have-a-server link,
the unlimited-plan line and the claim title changed in en and de with the
redesign. All other locales now say the same, and the English headline
reads Your Trips to match the German plural.
2026-11-06 15:02:47 +01:00
Maurice 954d419671 feat(native): show the photo and a visible grid on the light welcome screen
The light scheme dropped the Kyoto band and drew the grid so faint it
vanished, leaving a bare white page. The photo now stays in both schemes
under a veil in the page colour (a dark dusk, or a bright haze in light
mode so the black wordmark still reads), and the light grid and wash are
strong enough to see.
2026-11-06 15:02:47 +01:00
Maurice 2077b9e375 ci(ios): import one stored distribution certificate instead of minting one per run 2026-11-06 15:02:46 +01:00
Maurice 5135052446 feat(native): fix the claim fields into one address box, add placeholders, shorter fields, better title 2026-11-06 15:02:46 +01:00
Maurice f9e1dfe804 feat(native): equal plan cards, mark absent features with a dash, one-line unlimited label 2026-11-06 15:02:46 +01:00
Maurice bd42661a62 fix(native): more space between price and its per-period suffix 2026-11-06 15:02:46 +01:00
Maurice 3b4d220936 feat(native): sample plans in dev preview, drop the yearly badge, plain white primary, readable subtext 2026-11-06 15:02:46 +01:00
Maurice e3ac025a59 feat(native): drop the demo button from the connect screen 2026-11-06 15:02:45 +01:00
Maurice c04864f693 feat(native): TREK Cloud wordmark lockup on the cloud screen, fix a German string 2026-11-06 15:02:45 +01:00
Maurice a9b05bba15 feat(native): make TREK Cloud the primary action, shorter calmer subtext 2026-11-06 15:02:45 +01:00
Maurice 6ce19eb4a8 feat(native): a welcome subtext that shows the whole TREK ecosystem, hosting-neutral 2026-11-06 15:02:44 +01:00
Maurice 52ddf9b4f7 feat(native): blend the photo into the grid left-to-right, slimmer demo button 2026-11-06 15:02:44 +01:00
Maurice 61a7b5bceb feat(native): set the welcome headline in lowercase MuseoModerno, full-width demo button 2026-11-06 15:02:44 +01:00
Maurice 70cfe9bfb0 feat(native): Kyoto hero band, headline tagline, tidy demo and footer 2026-11-06 15:02:44 +01:00
Maurice b63f1f01b5 feat(native): use the full TREK wordmark on the welcome screen, per scheme 2026-11-06 15:02:43 +01:00
Maurice e9d9690844 feat(native): richer onboarding — Poppins display type, a masked grid backdrop and depth 2026-11-06 15:02:43 +01:00
Maurice b1a31d4d68 ci(ios): create a passworded keychain so the distribution cert is usable for signing 2026-11-06 15:02:43 +01:00
Maurice 5cb33b2858 ci(ios): sign the release with fastlane so TestFlight uploads from a device-less runner 2026-11-06 15:02:43 +01:00
Maurice 679f394b51 fix(ios): sign the Release build for distribution so the archive uploads to TestFlight 2026-11-06 15:02:42 +01:00
Maurice 91b5b06a7f ci(native): gate the Android release job behind NATIVE_RELEASE_ANDROID 2026-11-06 15:02:42 +01:00
452 changed files with 14445 additions and 3168 deletions
+28 -38
View File
@@ -34,7 +34,10 @@ concurrency:
jobs:
android:
if: github.repository == 'liketrek/TREK'
# Gated on a repository variable so the release can ship iOS alone while the
# Google Play account and its signing secrets are not yet in place. Set the
# repository variable NATIVE_RELEASE_ANDROID to 1 to turn the Android half on.
if: github.repository == 'liketrek/TREK' && vars.NATIVE_RELEASE_ANDROID == '1'
runs-on: ubuntu-24.04
timeout-minutes: 40
environment: store
@@ -148,44 +151,31 @@ jobs:
ASC_KEY_P8_B64: ${{ secrets.ASC_KEY_P8_B64 }}
run: printf '%s' "$ASC_KEY_P8_B64" | base64 -d > "$RUNNER_TEMP/AuthKey.p8"
- name: Archive
working-directory: client/ios/App
- name: Distribution certificate
env:
APPLE_DIST_CERT_P12_B64: ${{ secrets.APPLE_DIST_CERT_P12_B64 }}
run: printf '%s' "$APPLE_DIST_CERT_P12_B64" | base64 -d > "$RUNNER_TEMP/dist.p12"
# Signs for distribution and uploads to TestFlight. Automatic signing cannot
# run on a device-less CI runner (it signs for development and then needs a
# 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.
- name: Build and upload to TestFlight
working-directory: client
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
CERT_P12_PATH: ${{ runner.temp }}/dist.p12
CERT_P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
run: >-
xcodebuild archive
-project App.xcodeproj
-scheme App
-configuration Release
-destination 'generic/platform=iOS'
-archivePath "$RUNNER_TEMP/TREK.xcarchive"
MARKETING_VERSION=${{ steps.version.outputs.version }}
CURRENT_PROJECT_VERSION=${{ steps.version.outputs.build }}
DEVELOPMENT_TEAM="$APPLE_TEAM_ID"
-allowProvisioningUpdates
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8"
-authenticationKeyID "$ASC_KEY_ID"
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
MARKETING_VERSION: ${{ steps.version.outputs.version }}
CURRENT_PROJECT_VERSION: ${{ steps.version.outputs.build }}
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: fastlane ios beta
# ExportOptions.plist says destination=upload, so this step sends the build
# to App Store Connect, where it shows up in TestFlight after processing.
- name: Export and upload to App Store Connect
working-directory: client/ios/App
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
run: >-
xcodebuild -exportArchive
-archivePath "$RUNNER_TEMP/TREK.xcarchive"
-exportOptionsPlist ../ExportOptions.plist
-exportPath "$RUNNER_TEMP/export"
-allowProvisioningUpdates
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8"
-authenticationKeyID "$ASC_KEY_ID"
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
- name: Remove the API key
- name: Remove secrets
if: always()
run: rm -f "$RUNNER_TEMP/AuthKey.p8"
run: rm -f "$RUNNER_TEMP/AuthKey.p8" "$RUNNER_TEMP/dist.p12"
+31 -2
View File
@@ -80,7 +80,7 @@ jobs:
# unsigned: the upload key only exists in native-release.yml.
- name: Assemble debug and release APKs
working-directory: client/android
run: ./gradlew assembleDebug assembleRelease lintDebug --no-daemon
run: ./gradlew assembleDebug assembleRelease lintDebug testDebugUnitTest --no-daemon
- uses: actions/upload-artifact@v6
with:
@@ -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
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements
- uses: actions/setup-node@v6
with:
@@ -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/*Content.swift Sources/WidgetPreviews/Widgets/
swift run -c release WidgetPreviews out
- uses: actions/upload-artifact@v6
with:
name: widget-previews
path: client/ios/WidgetPreviews/out
retention-days: 14
+4
View File
@@ -56,6 +56,10 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation "androidx.browser:browser:$androidxBrowserVersion"
implementation "androidx.biometric:biometric:1.1.0"
// Push through liketrek's relay. No google-services plugin: Firebase reads
// its project from res/values/firebase.xml, the strings the plugin would generate.
implementation "com.google.firebase:firebase-messaging:24.1.2"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
@@ -23,6 +23,16 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- "Open in TREK" / share a booking document into the import. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/pdf" />
<data android:mimeType="text/calendar" />
<data android:mimeType="application/vnd.apple.pkpass" />
<data android:mimeType="message/rfc822" />
</intent-filter>
<!-- Deep links.
@@ -50,6 +60,15 @@
</activity>
<!-- Push: the relay's data messages, decrypted and shown here. -->
<service
android:name=".TrekPushService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -76,5 +95,7 @@
Play hide the app from tablets and other GPS-less devices. -->
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Android 13 and later ask before an app may show notifications. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
</manifest>
@@ -26,6 +26,13 @@ public class MainActivity extends BridgeActivity {
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
// Set before any WebView callback can run: they are posted to this thread.
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen));
TrekSharedFiles.receive(this, getIntent());
}
@Override
public void onNewIntent(Intent intent) {
super.onNewIntent(intent);
TrekSharedFiles.receive(this, intent);
}
private boolean leavingServer;
@@ -0,0 +1,139 @@
package com.liketrek.trek;
import android.content.Context;
import android.content.SharedPreferences;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.util.Base64;
import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
/**
* The device's Web Push key pair (P-256) and auth secret (RFC 8291). The server
* encrypts every notification for these, and TrekPushService decrypts with
* them. They are kept in the app's preferences, sealed with an AES key that
* never leaves the Android Keystore, so a copy of the preferences alone opens
* nothing.
*/
final class TrekPushKeys {
private static final String PREFS = "trek_push";
private static final String WRAP_ALIAS = "trek_push_wrap";
private static final String PRIVATE = "private";
private static final String PUBLIC = "public";
private static final String AUTH = "auth";
final ECPrivateKey privateKey;
/** Uncompressed point, 65 bytes, as the server reads p256dh. */
final byte[] publicKey;
final byte[] auth;
TrekPushKeys(ECPrivateKey privateKey, byte[] publicKey, byte[] auth) {
this.privateKey = privateKey;
this.publicKey = publicKey;
this.auth = auth;
}
/** The stored keys, or null when the app never subscribed. */
static synchronized TrekPushKeys load(Context context) throws Exception {
SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String sealedPrivate = prefs.getString(PRIVATE, null);
String publicKey = prefs.getString(PUBLIC, null);
String sealedAuth = prefs.getString(AUTH, null);
if (sealedPrivate == null || publicKey == null || sealedAuth == null) return null;
byte[] pkcs8 = unseal(sealedPrivate);
ECPrivateKey key = (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
return new TrekPushKeys(key, Base64.decode(publicKey, Base64.NO_WRAP), unseal(sealedAuth));
}
static synchronized TrekPushKeys loadOrCreate(Context context) throws Exception {
TrekPushKeys existing;
try {
existing = load(context);
} catch (Exception unreadable) {
// The Keystore key is gone (backup restored to another device, data
// cleared): start over, the next subscription carries the new keys.
existing = null;
}
if (existing != null) return existing;
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
KeyPair pair = generator.generateKeyPair();
byte[] publicKey = uncompressed((ECPublicKey) pair.getPublic());
byte[] auth = new byte[16];
new SecureRandom().nextBytes(auth);
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(PRIVATE, seal(pair.getPrivate().getEncoded()))
.putString(PUBLIC, Base64.encodeToString(publicKey, Base64.NO_WRAP))
.putString(AUTH, seal(auth))
.apply();
return new TrekPushKeys((ECPrivateKey) pair.getPrivate(), publicKey, auth);
}
static String base64url(byte[] data) {
return Base64.encodeToString(data, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
}
static byte[] uncompressed(ECPublicKey key) {
byte[] out = new byte[65];
out[0] = 0x04;
copyFixed(key.getW().getAffineX(), out, 1);
copyFixed(key.getW().getAffineY(), out, 33);
return out;
}
/** A coordinate as exactly 32 bytes: BigInteger adds a sign byte or drops leading zeros. */
private static void copyFixed(BigInteger value, byte[] out, int offset) {
byte[] raw = value.toByteArray();
int start = Math.max(0, raw.length - 32);
int length = raw.length - start;
System.arraycopy(raw, start, out, offset + 32 - length, length);
}
private static SecretKey wrapKey() throws Exception {
KeyStore store = KeyStore.getInstance("AndroidKeyStore");
store.load(null);
if (store.containsAlias(WRAP_ALIAS)) return ((KeyStore.SecretKeyEntry) store.getEntry(WRAP_ALIAS, null)).getSecretKey();
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
generator.init(
new KeyGenParameterSpec.Builder(WRAP_ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
);
return generator.generateKey();
}
private static String seal(byte[] plain) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, wrapKey());
byte[] iv = cipher.getIV();
byte[] sealed = cipher.doFinal(plain);
byte[] out = new byte[iv.length + sealed.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(sealed, 0, out, iv.length, sealed.length);
return Base64.encodeToString(out, Base64.NO_WRAP);
}
private static byte[] unseal(String value) throws Exception {
byte[] in = Base64.decode(value, Base64.NO_WRAP);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), new GCMParameterSpec(128, in, 0, 12));
return cipher.doFinal(in, 12, in.length - 12);
}
}
@@ -0,0 +1,101 @@
package com.liketrek.trek;
import android.Manifest;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.util.Base64;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
import java.nio.charset.StandardCharsets;
import org.json.JSONObject;
/**
* Receives the relay's data messages ({enc}: the server's encrypted Web Push
* record), decrypts them with the device's keys and shows the notification.
* Without the keys, or when the record does not open, it shows the same
* neutral text the iOS extension falls back to, never the ciphertext.
*/
public class TrekPushService extends FirebaseMessagingService {
static final String CHANNEL = "trek";
static final String EXTRA_URL = "trek.pushUrl";
@Override
public void onMessageReceived(@NonNull RemoteMessage message) {
String enc = message.getData().get("enc");
if (enc == null) return;
String title = getString(R.string.push_fallback_title);
String body = getString(R.string.push_fallback_body);
String url = null;
String tag = null;
try {
TrekPushKeys keys = TrekPushKeys.load(this);
if (keys != null) {
byte[] plain = TrekWebPush.decrypt(Base64.decode(enc, Base64.DEFAULT), keys);
JSONObject payload = new JSONObject(new String(plain, StandardCharsets.UTF_8));
if (!payload.optString("title").isEmpty()) title = payload.optString("title");
body = payload.optString("body", body);
url = payload.optString("url", null);
tag = payload.optString("tag", null);
}
} catch (Exception unreadable) {
// Shown with the neutral text: the tap still opens the app.
}
show(this, title, body, url, tag);
}
/** A new token means a new endpoint; the UI re-registers on its next start (resyncPushSubscription). */
@Override
public void onNewToken(@NonNull String token) {}
static void ensureChannel(Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
NotificationManager manager = context.getSystemService(NotificationManager.class);
if (manager == null || manager.getNotificationChannel(CHANNEL) != null) return;
NotificationChannel channel = new NotificationChannel(
CHANNEL,
context.getString(R.string.push_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
);
manager.createNotificationChannel(channel);
}
private static void show(Context context, String title, String body, String url, String tag) {
ensureChannel(context);
Intent open = new Intent(context, MainActivity.class)
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
if (url != null) open.putExtra(EXTRA_URL, url);
int id = tag != null ? tag.hashCode() : (int) (System.currentTimeMillis() & 0x7fffffff);
PendingIntent tap = PendingIntent.getActivity(
context,
id,
open,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_stat_trek)
.setContentTitle(title)
.setContentText(body)
.setStyle(new NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setContentIntent(tap);
if (Build.VERSION.SDK_INT >= 33
&& ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return;
}
try {
NotificationManagerCompat.from(context).notify(tag, id, builder.build());
} catch (SecurityException notAllowed) {
// Notifications switched off for the app since it subscribed.
}
}
}
@@ -0,0 +1,76 @@
package com.liketrek.trek;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
import android.util.Base64;
import com.getcapacitor.JSObject;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
/**
* Booking documents shared to TREK (open with / share), held until the UI takes
* them (TrekShell.takeSharedFile) and announced if it is already running.
*/
final class TrekSharedFiles {
/** Above this the file is not a booking confirmation. */
private static final int MAX_BYTES = 15 * 1024 * 1024;
interface Listener {
void onFile(JSObject file);
}
private static JSObject pending;
private static Listener listener;
private TrekSharedFiles() {}
static void setListener(Listener l) {
listener = l;
}
static JSObject take() {
JSObject file = pending;
pending = null;
return file;
}
static void receive(Context context, Intent intent) {
if (intent == null) return;
Uri uri = null;
if (Intent.ACTION_VIEW.equals(intent.getAction())) uri = intent.getData();
else if (Intent.ACTION_SEND.equals(intent.getAction())) uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (uri == null) return;
try (InputStream in = context.getContentResolver().openInputStream(uri)) {
if (in == null) return;
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[16384];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
if (out.size() > MAX_BYTES) return;
}
JSObject file = new JSObject();
file.put("name", displayName(context, uri));
String type = context.getContentResolver().getType(uri);
file.put("mimeType", type == null ? intent.getType() : type);
file.put("data", Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP));
pending = file;
if (listener != null) listener.onFile(file);
} catch (Exception ignored) {
// Unreadable or gone: nothing to import.
}
}
private static String displayName(Context context, Uri uri) {
try (Cursor cursor = context.getContentResolver().query(uri, new String[] { OpenableColumns.DISPLAY_NAME }, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) return cursor.getString(0);
} catch (Exception ignored) {
// Fall through to the path.
}
String last = uri.getLastPathSegment();
return last == null ? "booking" : last;
}
}
@@ -1,19 +1,29 @@
package com.liketrek.trek;
import android.Manifest;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.NotificationManagerCompat;
import com.getcapacitor.JSObject;
import com.getcapacitor.PermissionState;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.annotation.Permission;
import com.getcapacitor.annotation.PermissionCallback;
import com.google.firebase.messaging.FirebaseMessaging;
import org.json.JSONObject;
/** The native half of client/src/native/trekShell.ts. */
@CapacitorPlugin(name = "TrekShell")
@CapacitorPlugin(
name = "TrekShell",
permissions = { @Permission(strings = { Manifest.permission.POST_NOTIFICATIONS }, alias = "notifications") }
)
public class TrekShellPlugin extends Plugin {
/** How long to wait after returning from the browser for the redirect to land. */
@@ -24,6 +34,190 @@ public class TrekShellPlugin extends Plugin {
private boolean authBrowserOpen;
private final Handler handler = new Handler(Looper.getMainLooper());
/**
* What this binary offers the server's UI, raised with every native feature
* an older binary lacks. The UI asks before it calls (native/capabilities.ts),
* and a server can require a minimum (minNativeShellApi in /api/health/features).
*/
static final int SHELL_API = 2;
/** The native features by name, so the UI can turn each on by itself. */
static final String[] FEATURES = { "capabilities", "biometricLock", "sharedFiles", "nativePush" };
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
static final String PUSH_RELAY = "https://push.liketrek.com/v1/fcm/";
/** The notification the app was started from, until the UI asks for it. */
private String openedPushUrl;
@PluginMethod
public void getCapabilities(PluginCall call) {
JSObject result = new JSObject();
result.put("shellApi", SHELL_API);
result.put("platform", "android");
String version = "";
String build = "";
try {
android.content.pm.PackageInfo info = getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0);
version = info.versionName == null ? "" : info.versionName;
build = String.valueOf(androidx.core.content.pm.PackageInfoCompat.getLongVersionCode(info));
} catch (android.content.pm.PackageManager.NameNotFoundException ignored) {
// Our own package is always there; the fields simply stay empty.
}
result.put("appVersion", version);
result.put("build", build);
com.getcapacitor.JSArray features = new com.getcapacitor.JSArray();
for (String feature : FEATURES) features.put(feature);
result.put("features", features);
call.resolve(result);
}
// ---- Shared booking documents ----
@Override
public void load() {
TrekSharedFiles.setListener(file -> notifyListeners("sharedFile", file));
if (getActivity() != null && getActivity().getIntent() != null) {
openedPushUrl = getActivity().getIntent().getStringExtra(TrekPushService.EXTRA_URL);
getActivity().getIntent().removeExtra(TrekPushService.EXTRA_URL);
}
}
// ---- Push ----
/**
* Permission, the FCM token and the device keys, as the Web Push subscription
* the server stores (native/push.ts). Rejects with DENIED when the user
* refuses notifications, as on iOS.
*/
@PluginMethod
public void registerPush(PluginCall call) {
if (Build.VERSION.SDK_INT >= 33 && getPermissionState("notifications") != PermissionState.GRANTED) {
requestPermissionForAlias("notifications", call, "pushPermissionAnswered");
return;
}
subscribePush(call);
}
@PermissionCallback
private void pushPermissionAnswered(PluginCall call) {
if (getPermissionState("notifications") != PermissionState.GRANTED) {
call.reject("Notifications are not allowed", "DENIED");
return;
}
subscribePush(call);
}
private void subscribePush(PluginCall call) {
if (!NotificationManagerCompat.from(getContext()).areNotificationsEnabled()) {
call.reject("Notifications are not allowed", "DENIED");
return;
}
TrekPushService.ensureChannel(getContext());
final TrekPushKeys keys;
try {
keys = TrekPushKeys.loadOrCreate(getContext());
} catch (Exception e) {
call.reject("Could not create the push keys", e);
return;
}
FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> {
if (!task.isSuccessful() || task.getResult() == null) {
call.reject("Could not get a push token", task.getException());
return;
}
JSObject subscription = new JSObject();
subscription.put("endpoint", PUSH_RELAY + task.getResult());
JSObject keyObject = new JSObject();
keyObject.put("p256dh", TrekPushKeys.base64url(keys.publicKey));
keyObject.put("auth", TrekPushKeys.base64url(keys.auth));
subscription.put("keys", keyObject);
call.resolve(subscription);
});
}
@PluginMethod
public void takeOpenedPush(PluginCall call) {
JSObject result = new JSObject();
result.put("url", openedPushUrl == null ? JSONObject.NULL : openedPushUrl);
openedPushUrl = null;
call.resolve(result);
}
/** The document shared to TREK before the UI was there to hear of it. */
@PluginMethod
public void takeSharedFile(PluginCall call) {
JSObject result = new JSObject();
JSObject file = TrekSharedFiles.take();
result.put("file", file == null ? JSONObject.NULL : file);
call.resolve(result);
}
// ---- App lock ----
private static final int AUTHENTICATORS =
androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
| androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL;
/** Which biometry the device offers for the app lock, if any. */
@PluginMethod
public void biometryInfo(PluginCall call) {
int status = androidx.biometric.BiometricManager.from(getContext()).canAuthenticate(AUTHENTICATORS);
JSObject result = new JSObject();
result.put("available", status == androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS);
result.put("type", "biometric");
call.resolve(result);
}
/** The system biometric prompt with the screen lock as the way out. */
@PluginMethod
public void authenticateBiometric(PluginCall call) {
String reason = call.getString("reason", "Unlock TREK");
getActivity().runOnUiThread(() -> {
androidx.fragment.app.FragmentActivity activity = (androidx.fragment.app.FragmentActivity) getActivity();
androidx.biometric.BiometricPrompt prompt = new androidx.biometric.BiometricPrompt(
activity,
androidx.core.content.ContextCompat.getMainExecutor(getContext()),
new androidx.biometric.BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationSucceeded(androidx.biometric.BiometricPrompt.AuthenticationResult result) {
JSObject ok = new JSObject();
ok.put("ok", true);
call.resolve(ok);
}
@Override
public void onAuthenticationError(int code, CharSequence message) {
boolean cancelled = code == androidx.biometric.BiometricPrompt.ERROR_USER_CANCELED
|| code == androidx.biometric.BiometricPrompt.ERROR_NEGATIVE_BUTTON
|| code == androidx.biometric.BiometricPrompt.ERROR_CANCELED;
call.reject(message.toString(), cancelled ? "CANCELLED" : "FAILED");
}
}
);
androidx.biometric.BiometricPrompt.PromptInfo info = new androidx.biometric.BiometricPrompt.PromptInfo.Builder()
.setTitle(reason)
.setAllowedAuthenticators(AUTHENTICATORS)
.build();
prompt.authenticate(info);
});
}
/** While on, the recent-apps screen shows no snapshot of the app. */
@PluginMethod
public void setPrivacyCover(PluginCall call) {
boolean enabled = Boolean.TRUE.equals(call.getBoolean("enabled", false));
getActivity().runOnUiThread(() -> {
if (android.os.Build.VERSION.SDK_INT >= 33) {
getActivity().setRecentsScreenshotEnabled(!enabled);
} else if (enabled) {
getActivity().getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
} else {
getActivity().getWindow().clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
}
});
call.resolve();
}
@PluginMethod
public void getServer(PluginCall call) {
String url = TrekServerStore.getUrl(getContext());
@@ -87,6 +281,14 @@ public class TrekShellPlugin extends Plugin {
@Override
protected void handleOnNewIntent(Intent intent) {
super.handleOnNewIntent(intent);
String pushUrl = intent.getStringExtra(TrekPushService.EXTRA_URL);
if (pushUrl != null) {
intent.removeExtra(TrekPushService.EXTRA_URL);
JSObject opened = new JSObject();
opened.put("url", pushUrl);
notifyListeners("pushOpened", opened);
return;
}
Uri data = intent.getData();
PluginCall call = pendingAuthCall();
if (call == null || data == null || !authScheme.equals(data.getScheme())) return;
@@ -0,0 +1,84 @@
package com.liketrek.trek;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECPoint;
import java.security.spec.ECPublicKeySpec;
import java.util.Arrays;
import javax.crypto.Cipher;
import javax.crypto.KeyAgreement;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* Decrypts a Web Push record (RFC 8291, aes128gcm content coding of RFC 8188):
* the same thing a browser does, and the counterpart of the iOS notification
* extension's WebPushRecord. One record, as TREK's server always sends.
*/
final class TrekWebPush {
private TrekWebPush() {}
static byte[] decrypt(byte[] record, TrekPushKeys keys) throws Exception {
if (record.length < 21) throw new IllegalArgumentException("record too short");
byte[] salt = Arrays.copyOfRange(record, 0, 16);
int idLength = record[20] & 0xff;
if (idLength != 65 || record.length < 21 + idLength + 17) throw new IllegalArgumentException("unexpected header");
byte[] senderKey = Arrays.copyOfRange(record, 21, 21 + idLength);
byte[] ciphertext = Arrays.copyOfRange(record, 21 + idLength, record.length);
KeyAgreement agreement = KeyAgreement.getInstance("ECDH");
agreement.init(keys.privateKey);
agreement.doPhase(publicKeyFrom(senderKey, keys.privateKey), true);
byte[] shared = agreement.generateSecret();
byte[] keyInfo = concat("WebPush: info\0".getBytes(StandardCharsets.US_ASCII), keys.publicKey, senderKey);
byte[] ikm = hkdf(keys.auth, shared, keyInfo, 32);
byte[] cek = hkdf(salt, ikm, "Content-Encoding: aes128gcm\0".getBytes(StandardCharsets.US_ASCII), 16);
byte[] nonce = hkdf(salt, ikm, "Content-Encoding: nonce\0".getBytes(StandardCharsets.US_ASCII), 12);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cek, "AES"), new GCMParameterSpec(128, nonce));
byte[] padded = cipher.doFinal(ciphertext);
// Padding: the content, one delimiter (2 for the last record), then zeros.
int end = padded.length - 1;
while (end >= 0 && padded[end] == 0) end--;
if (end < 0 || padded[end] != 2) throw new IllegalArgumentException("bad padding");
return Arrays.copyOf(padded, end);
}
private static ECPublicKey publicKeyFrom(byte[] point, ECPrivateKey onCurve) throws Exception {
if (point[0] != 0x04) throw new IllegalArgumentException("compressed key");
BigInteger x = new BigInteger(1, Arrays.copyOfRange(point, 1, 33));
BigInteger y = new BigInteger(1, Arrays.copyOfRange(point, 33, 65));
return (ECPublicKey) KeyFactory.getInstance("EC").generatePublic(new ECPublicKeySpec(new ECPoint(x, y), onCurve.getParams()));
}
/** HKDF-SHA256 (RFC 5869) for outputs of at most one block. */
private static byte[] hkdf(byte[] salt, byte[] ikm, byte[] info, int length) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(salt, "HmacSHA256"));
byte[] prk = mac.doFinal(ikm);
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
mac.update(info);
mac.update((byte) 1);
return Arrays.copyOf(mac.doFinal(), length);
}
private static byte[] concat(byte[]... parts) {
int length = 0;
for (byte[] part : parts) length += part.length;
byte[] out = new byte[length];
int offset = 0;
for (byte[] part : parts) {
System.arraycopy(part, 0, out, offset, part.length);
offset += part.length;
}
return out;
}
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Status bar icon for notifications: a single-colour map pin, as Android
tints it and shows only its shape. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Firebase project the app receives push through (liketrek push relay).
These are the strings the google-services Gradle plugin would generate from
google-services.json; Firebase reads them at start. None of them is a secret:
they identify the project, sending needs the relay's service account. -->
<resources>
<string name="google_app_id" translatable="false">1:1074454522965:android:3ff2fe270a27a73051b8dc</string>
<string name="gcm_defaultSenderId" translatable="false">1074454522965</string>
<string name="google_api_key" translatable="false">AIzaSyB2VYXmZ90BZuksHhHR_n2W8naNE_vJAJA</string>
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyB2VYXmZ90BZuksHhHR_n2W8naNE_vJAJA</string>
<string name="project_id" translatable="false">trek-9392e</string>
<string name="google_storage_bucket" translatable="false">trek-9392e.firebasestorage.app</string>
</resources>
@@ -4,4 +4,7 @@
<string name="title_activity_main">TREK</string>
<string name="package_name">com.liketrek.trek</string>
<string name="custom_url_scheme">com.liketrek.trek</string>
<string name="push_channel_name">Notifications</string>
<string name="push_fallback_title">TREK</string>
<string name="push_fallback_body">New notification</string>
</resources>
@@ -0,0 +1,63 @@
package com.liketrek.trek;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.KeyPairGenerator;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.ECPrivateKeySpec;
import java.util.Base64;
import org.junit.Test;
/** The decryption against the worked example in RFC 8291, section 5. */
public class TrekWebPushTest {
private static byte[] b64(String value) {
return Base64.getUrlDecoder().decode(value);
}
private static TrekPushKeys rfcKeys() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
ECPublicKey anyKey = (ECPublicKey) generator.generateKeyPair().getPublic();
BigInteger d = new BigInteger(1, b64("q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94"));
ECPrivateKey privateKey = (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(new ECPrivateKeySpec(d, anyKey.getParams()));
return new TrekPushKeys(
privateKey,
b64("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"),
b64("BTBZMqHH6r4Tts7J_aSIgg")
);
}
private static final String RECORD =
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN";
@Test
public void decryptsTheRfcExample() throws Exception {
byte[] plain = TrekWebPush.decrypt(b64(RECORD), rfcKeys());
assertEquals("When I grow up, I want to be a watermelon", new String(plain, StandardCharsets.UTF_8));
}
@Test
public void refusesATamperedRecord() throws Exception {
byte[] record = b64(RECORD);
record[record.length - 1] ^= 1;
assertThrows(Exception.class, () -> TrekWebPush.decrypt(record, rfcKeys()));
}
@Test
public void writesThePublicKeyAsAFixedLengthPoint() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
for (int i = 0; i < 20; i++) {
byte[] point = TrekPushKeys.uncompressed((ECPublicKey) generator.generateKeyPair().getPublic());
assertEquals(65, point.length);
assertEquals(4, point[0]);
}
}
}
+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.
+112
View File
@@ -0,0 +1,112 @@
# iOS release signing and TestFlight upload, driven by .github/workflows/native-release.yml.
#
# Automatic signing cannot work on a CI runner with no registered devices: it signs
# the archive for development and then needs a development provisioning profile, which
# requires a device. So this signs manually for distribution instead. The distribution
# certificate and the App Store provisioning profile are created (or reused) through the
# App Store Connect API key, which means no certificate or profile has to be stored as a
# secret: the key the workflow already holds is enough.
#
# The lane expects these in the environment (the workflow sets them):
# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH — the App Store Connect API key
# APPLE_TEAM_ID — the developer team
# MARKETING_VERSION, CURRENT_PROJECT_VERSION — the version and build number
# RUNNER_TEMP — a writable scratch directory
default_platform(:ios)
APP_IDENTIFIER = "com.liketrek.trek"
PROJECT = "ios/App/App.xcodeproj"
# Every signed target with its bundle identifier: the app and its extensions
# (ios/scripts/add-extension.rb put them in the project). Each gets its own App
# Store profile, created or reused through the API key like the app's.
SIGNED_TARGETS = {
"App" => APP_IDENTIFIER,
"NotificationService" => "com.liketrek.trek.notifications",
"TREKWidgets" => "com.liketrek.trek.widgets",
}
platform :ios do
desc "Build the signed Release app and upload it to TestFlight (internal testing)."
lane :beta do
key = app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_filepath: ENV.fetch("ASC_KEY_PATH"),
in_house: false,
)
# A throwaway keychain with a known password, so fastlane can unlock the imported
# private key for code signing (without the password it cannot set the key's access
# list and the certificate is never usable).
keychain_name = "trek_ci.keychain"
keychain_password = "trek-ci-#{ENV.fetch('GITHUB_RUN_ID', 'local')}"
create_keychain(
name: keychain_name,
password: keychain_password,
default_keychain: true,
unlock: true,
timeout: 3600,
lock_when_sleeps: false,
)
keychain_path = File.expand_path("~/Library/Keychains/#{keychain_name}-db")
# Import the one distribution certificate from its stored .p12, rather than
# minting a new one each run — Apple caps distribution certificates at two, so
# a fresh one per build would quickly hit the limit. The matching App Store
# profile is created (or reused) through the API key.
import_certificate(
certificate_path: ENV.fetch("CERT_P12_PATH"),
certificate_password: ENV.fetch("CERT_P12_PASSWORD"),
keychain_name: keychain_name,
keychain_path: keychain_path,
keychain_password: keychain_password,
)
profiles = {}
SIGNED_TARGETS.each do |target, identifier|
get_provisioning_profile(
api_key: key,
app_identifier: identifier,
development: false,
)
profiles[identifier] = lane_context[SharedValues::SIGH_NAME]
# Pin each target to its profile for this build; automatic signing stays the
# default a developer sees when they open the project in Xcode.
update_code_signing_settings(
path: PROJECT,
use_automatic_signing: false,
team_id: ENV.fetch("APPLE_TEAM_ID"),
code_sign_identity: "Apple Distribution",
bundle_identifier: identifier,
profile_name: profiles[identifier],
targets: [target],
)
end
build_app(
project: PROJECT,
scheme: "App",
configuration: "Release",
export_method: "app-store",
output_directory: File.join(ENV.fetch("RUNNER_TEMP"), "export"),
xcargs: [
"MARKETING_VERSION=#{ENV.fetch('MARKETING_VERSION')}",
"CURRENT_PROJECT_VERSION=#{ENV.fetch('CURRENT_PROJECT_VERSION')}",
].join(" "),
export_options: {
signingStyle: "manual",
provisioningProfiles: profiles,
},
)
# 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.
upload_to_testflight(
api_key: key,
skip_waiting_for_build_processing: true,
skip_submission: true,
)
end
end
+365 -3
View File
@@ -7,7 +7,14 @@
objects = {
/* Begin PBXBuildFile section */
01FD437981FC32C2C2C5BDCE /* TREKWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */; };
2162CD349AFFDF070C16A58A /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E901288FC198E4D33BE754F /* SwiftUI.framework */; };
2CFE220A73ACB585F3D4CB8F /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2EE43D236E661AA975612A4B /* UserNotifications.framework */; };
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
32797E66C09CF7579AC323FD /* TREKWidgetsBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */; };
3FB1872A25267A8E88FCBE3C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC64330AC19625949E8DC05A /* WidgetKit.framework */; };
492FA24FBB57059B0B5007CE /* TripDayAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */; };
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
@@ -15,14 +22,64 @@
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
7A1E00032C0000000000A003 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */; };
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */; };
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00062C0000000000A006 /* TrekShellPlugin.swift */; };
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
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 */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
3BB8A38B2B2C8614B24CF2AD /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 9480DFBC2C2A112973DA468A;
remoteInfo = NotificationService;
};
B8BBE7F7A64678D780A7F3D1 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = A3BE5653156025C97CA62C68;
remoteInfo = TREKWidgets;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */,
01FD437981FC32C2C2C5BDCE /* TREKWidgets.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
192E69C7E66646BC39566925 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
1E7522315D47F086145DC4B8 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
2EE43D236E661AA975612A4B /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayLiveActivity.swift; sourceTree = "<group>"; };
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayAttributes.swift; sourceTree = "<group>"; };
3DF4F4305878F949DD09193F /* NotificationService.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -31,15 +88,41 @@
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
6E901288FC198E4D33BE754F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TREKWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
7A1E00012C0000000000A001 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekBridgeViewController.swift; sourceTree = "<group>"; };
7A1E00062C0000000000A006 /* TrekShellPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekShellPlugin.swift; sourceTree = "<group>"; };
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWidgetsBundle.swift; sourceTree = "<group>"; };
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
BBB2E19CD540AB30B44BECCA /* TREKWidgets.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWidgets.entitlements; sourceTree = "<group>"; };
BC64330AC19625949E8DC05A /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
BE41D7212DCB0C78E00A8462 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NextTripWidget.swift; sourceTree = "<group>"; };
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = "<group>"; };
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>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
0A579FBACE2CF30B89370F8E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
3FB1872A25267A8E88FCBE3C /* WidgetKit.framework in Frameworks */,
2162CD349AFFDF070C16A58A /* SwiftUI.framework in Frameworks */,
B65B7CFC62EEFF443491EA64 /* ActivityKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -48,15 +131,33 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C9AA4ADC0D397C620A35B6A3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
2CFE220A73ACB585F3D4CB8F /* UserNotifications.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0FC8613857BD976EB58ECE0B /* iOS */ = {
isa = PBXGroup;
children = (
);
name = iOS;
sourceTree = "<group>";
};
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
5198C9768EB9762A60701C92 /* Frameworks */,
74C1FC6022E562372A71EEC9 /* NotificationService */,
FB9226BC82835538BCE3DC0A /* TREKWidgets */,
);
sourceTree = "<group>";
};
@@ -64,6 +165,8 @@
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */,
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -88,6 +191,50 @@
path = App;
sourceTree = "<group>";
};
5198C9768EB9762A60701C92 /* Frameworks */ = {
isa = PBXGroup;
children = (
0FC8613857BD976EB58ECE0B /* iOS */,
2EE43D236E661AA975612A4B /* UserNotifications.framework */,
BC64330AC19625949E8DC05A /* WidgetKit.framework */,
6E901288FC198E4D33BE754F /* SwiftUI.framework */,
192E69C7E66646BC39566925 /* ActivityKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
74C1FC6022E562372A71EEC9 /* NotificationService */ = {
isa = PBXGroup;
children = (
1E7522315D47F086145DC4B8 /* Info.plist */,
3DF4F4305878F949DD09193F /* NotificationService.entitlements */,
FFA8802204810ED580DE46F3 /* NotificationService.swift */,
);
name = NotificationService;
path = NotificationService;
sourceTree = "<group>";
};
FB9226BC82835538BCE3DC0A /* TREKWidgets */ = {
isa = PBXGroup;
children = (
BE41D7212DCB0C78E00A8462 /* Info.plist */,
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */,
BBB2E19CD540AB30B44BECCA /* TREKWidgets.entitlements */,
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */,
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */,
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */,
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */,
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */,
A030A06726542A2D7F61C54B /* TrekFamily.swift */,
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */,
24B175DE91E1E6E850DB0285 /* TodayContent.swift */,
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */,
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */,
);
name = TREKWidgets;
path = TREKWidgets;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -98,10 +245,13 @@
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */,
4CD7F5AF02CB806E4189489A /* PBXTargetDependency */,
);
name = App;
packageProductDependencies = (
@@ -111,6 +261,40 @@
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
9480DFBC2C2A112973DA468A /* NotificationService */ = {
isa = PBXNativeTarget;
buildConfigurationList = 4E3E5FA93A15425251E22D48 /* Build configuration list for PBXNativeTarget "NotificationService" */;
buildPhases = (
1EA0B85A2E70AC56F83C09C9 /* Sources */,
C9AA4ADC0D397C620A35B6A3 /* Frameworks */,
9706E0B1D8DD6E0542723B5D /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = NotificationService;
productName = NotificationService;
productReference = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */;
productType = "com.apple.product-type.app-extension";
};
A3BE5653156025C97CA62C68 /* TREKWidgets */ = {
isa = PBXNativeTarget;
buildConfigurationList = 9DE86AC1D6A95987925D7982 /* Build configuration list for PBXNativeTarget "TREKWidgets" */;
buildPhases = (
92D923C146596E50655B451D /* Sources */,
0A579FBACE2CF30B89370F8E /* Frameworks */,
04A03741C8AD60E63D4B11E7 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = TREKWidgets;
productName = TREKWidgets;
productReference = 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -149,11 +333,20 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
9480DFBC2C2A112973DA468A /* NotificationService */,
A3BE5653156025C97CA62C68 /* TREKWidgets */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
04A03741C8AD60E63D4B11E7 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -168,9 +361,24 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
9706E0B1D8DD6E0542723B5D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
1EA0B85A2E70AC56F83C09C9 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A8FDD4DBE3F078BF935CC4B9 /* NotificationService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -179,11 +387,45 @@
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */,
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */,
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
92D923C146596E50655B451D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B4C0FC386F71D849E141C91E /* NextTripWidget.swift in Sources */,
32797E66C09CF7579AC323FD /* TREKWidgetsBundle.swift in Sources */,
492FA24FBB57059B0B5007CE /* TripDayAttributes.swift in Sources */,
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */,
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */,
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;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
4CD7F5AF02CB806E4189489A /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = TREKWidgets;
target = A3BE5653156025C97CA62C68 /* TREKWidgets */;
targetProxy = B8BBE7F7A64678D780A7F3D1 /* PBXContainerItemProxy */;
};
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = NotificationService;
target = 9480DFBC2C2A112973DA468A /* NotificationService */;
targetProxy = 3BB8A38B2B2C8614B24CF2AD /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
@@ -204,6 +446,31 @@
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
3ECF3537A139DB5CAC23E502 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = TREKWidgets/TREKWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
@@ -359,9 +626,95 @@
};
name = Release;
};
686FA31AEC6FE5BA3EF95E99 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = NotificationService/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.notifications;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
A475CD70019910612C4D523B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = NotificationService/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.notifications;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
C5621F5D75100AAEBB1BD22A /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = TREKWidgets/TREKWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
4E3E5FA93A15425251E22D48 /* Build configuration list for PBXNativeTarget "NotificationService" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A475CD70019910612C4D523B /* Release */,
686FA31AEC6FE5BA3EF95E99 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -380,6 +733,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
9DE86AC1D6A95987925D7982 /* Build configuration list for PBXNativeTarget "TREKWidgets" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C5621F5D75100AAEBB1BD22A /* Release */,
3ECF3537A139DB5CAC23E502 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
+6
View File
@@ -2,5 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>production</string>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
</array>
</dict>
</plist>
+27 -2
View File
@@ -1,16 +1,41 @@
import UIKit
import Capacitor
import UserNotifications
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
// Taps on a TREK notification, and banners while the app is open.
UNUserNotificationCenter.current().delegate = self
return true
}
// MARK: - Push (TrekPush in TrekShellPlugin.swift)
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
TrekPush.shared.didRegister(token: deviceToken)
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
TrekPush.shared.didFail(error)
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner, .list, .sound])
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
if let url = response.notification.request.content.userInfo["url"] as? String {
TrekPush.shared.opened(url: url)
}
completionHandler()
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
+25
View File
@@ -71,6 +71,10 @@
<key>NSPhotoLibraryAddUsageDescription</key>
<string>TREK saves photos and exports you download from your trips back to your photo library.</string>
<!-- navigator.geolocation in the WebView ("locate me" on the map). -->
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>TREK uses Face ID to unlock the app when you turn on the app lock.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>TREK uses your location to centre the map on you and to find places near you.</string>
<!-- Deep links (@capacitor/app appUrlOpen), matching the intent-filter in
@@ -82,6 +86,27 @@
apple-app-site-association, and a self-hosted app has no single domain to
verify against. SceneDelegate already forwards openURLContexts to
Capacitor, so no Swift change is needed. -->
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Booking document</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.adobe.pdf</string>
<string>com.apple.ical.ics</string>
<string>com.apple.pkpass</string>
<string>com.apple.mail.email</string>
<string>public.email-message</string>
</array>
</dict>
</array>
<key>LSSupportsOpeningDocumentsInPlace</key>
<false/>
<key>CFBundleURLTypes</key>
<array>
<dict>
+84 -1
View File
@@ -11,14 +11,97 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
window?.rootViewController = TrekBridgeViewController()
window?.makeKeyAndVisible()
// Launched by "Open in TREK" on a booking document.
for context in connectionOptions.urlContexts where context.url.isFileURL {
TrekSharedFiles.receive(context.url)
}
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
}
// The app switcher takes its snapshot as the scene resigns active, so the
// cover goes on there and comes off when the app is back.
func sceneWillResignActive(_ scene: UIScene) {
TrekPrivacyCover.show(in: window)
}
func sceneDidBecomeActive(_ scene: UIScene) {
TrekPrivacyCover.hide()
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts)
// A booking document shared to TREK goes to the UI's import; anything
// else (the sign-in callback) stays with Capacitor.
let files = URLContexts.filter { $0.url.isFileURL }
for context in files { TrekSharedFiles.receive(context.url) }
let others = URLContexts.subtracting(files)
if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) }
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
}
}
/// The blur over the app while it sits in the app switcher, when the app lock
/// is on (TrekShell.setPrivacyCover from the UI's lock setting).
enum TrekPrivacyCover {
private static let key = "TrekPrivacyCover"
private static weak var cover: UIView?
static var enabled: Bool {
get { UserDefaults.standard.bool(forKey: key) }
set { UserDefaults.standard.set(newValue, forKey: key) }
}
static func show(in window: UIWindow?) {
guard enabled, cover == nil, let window else { return }
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemThickMaterial))
view.frame = window.bounds
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
window.addSubview(view)
cover = view
}
static func hide() {
cover?.removeFromSuperview()
cover = nil
}
}
/// Booking documents shared to TREK ("Open in TREK"), held until the UI takes
/// them (TrekShell.takeSharedFile) and announced if it is already running.
enum TrekSharedFiles {
/// Above this the file is not a booking confirmation, and base64 over the
/// bridge would only stall the app.
static let maxBytes = 15 * 1024 * 1024
private(set) static var pending: [String: Any]?
static var onReceive: (([String: Any]) -> Void)?
static func receive(_ url: URL) {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
guard let data = try? Data(contentsOf: url), data.count <= maxBytes else { return }
let file: [String: Any] = [
"name": url.lastPathComponent,
"mimeType": mimeType(for: url.pathExtension.lowercased()),
"data": data.base64EncodedString(),
]
pending = file
onReceive?(file)
}
static func take() -> [String: Any]? {
defer { pending = nil }
return pending
}
private static func mimeType(for ext: String) -> String {
switch ext {
case "pdf": return "application/pdf"
case "ics": return "text/calendar"
case "pkpass": return "application/vnd.apple.pkpass"
case "eml": return "message/rfc822"
default: return "application/octet-stream"
}
}
}
@@ -1,5 +1,7 @@
import UIKit
import Capacitor
import CryptoKit
import WebKit
/// Points the WebView at the TREK server the user picked instead of the bundled
/// address screen. The UI then comes from that server, in the version it runs,
@@ -17,11 +19,36 @@ class TrekBridgeViewController: CAPBridgeViewController {
return descriptor
}
/// The map tile store answers trek-tile:// (TrekTileSchemeHandler below). A
/// scheme handler can only be set on the configuration the WebView is built from.
private let tileHandler = TrekTileSchemeHandler()
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
let configuration = super.webViewConfiguration(for: instanceConfiguration)
configuration.setURLSchemeHandler(tileHandler, forURLScheme: TrekTileSchemeHandler.scheme)
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())
// The swipe from the left edge that goes back in every iOS app. It walks
// the SPA's history, the same entries Android's back button uses.
webView?.allowsBackForwardNavigationGestures = true
// 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
// draws its own (mobile/components/MEdgeBack.tsx, feature "edgeBack"):
// the page follows the finger and an open sheet closes first.
webView?.allowsBackForwardNavigationGestures = false
}
}
@@ -42,3 +69,254 @@ enum TrekServerStore {
return support.appendingPathComponent("offline-start-page.html")
}
}
// MARK: - Tile store (trek-tile://)
/// Map tiles the user looked at, on disk, for the WebView's maps
/// (client/src/native/tileCache.ts). WKWebView runs no service worker for a
/// self-hosted server, so without this nothing kept a tile once the app closed.
///
/// Each tile lives as `<sha256>.bin` with its content type in `<sha256>.type`.
/// A tile older than 30 days is never served again (Mapbox's limit for a device
/// cache, applied to every provider) and is fetched anew. The store keeps under
/// 250 MB: past that, the tiles used least recently go first. It sits in Caches,
/// which iOS may empty under storage pressure and never backs up.
final class TrekTileStore {
static let shared = TrekTileStore()
static let maxAge: TimeInterval = 30 * 24 * 60 * 60
static let maxBytes: Int64 = 250 * 1024 * 1024
/// Pruning brings the store down to this share of the limit, so it does not
/// run again on the very next tile.
static let pruneTarget = 0.8
let directory: URL
let queue = DispatchQueue(label: "com.liketrek.trek.tiles", qos: .utility)
private var writesSincePrune = 0
private init() {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
directory = caches.appendingPathComponent("trek-tiles", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
/// The tile's identity without what changes per user or session: tokens and
/// keys come out, the rest of the query is sorted.
static func key(for url: URL) -> String {
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let dropped: Set<String> = ["access_token", "sku", "key", "api_key", "apikey"]
let kept = components?.queryItems?
.filter { !dropped.contains($0.name.lowercased()) }
.sorted { $0.name < $1.name }
components?.queryItems = (kept?.isEmpty ?? true) ? nil : kept
let identity = components?.string ?? url.absoluteString
return SHA256.hash(data: Data(identity.utf8)).map { String(format: "%02x", $0) }.joined()
}
private func dataURL(_ key: String) -> URL { directory.appendingPathComponent(key + ".bin") }
private func typeURL(_ key: String) -> URL { directory.appendingPathComponent(key + ".type") }
/// A stored tile younger than 30 days, marked as just used. Call on `queue`.
func read(_ key: String) -> (data: Data, type: String)? {
let file = dataURL(key)
guard let attributes = try? FileManager.default.attributesOfItem(atPath: file.path),
let created = attributes[.creationDate] as? Date else { return nil }
if Date().timeIntervalSince(created) > TrekTileStore.maxAge {
remove(key)
return nil
}
guard let data = try? Data(contentsOf: file) else { return nil }
let type = (try? String(contentsOf: typeURL(key), encoding: .utf8)) ?? "application/octet-stream"
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: file.path)
return (data, type)
}
func write(_ key: String, data: Data, type: String) {
queue.async {
try? data.write(to: self.dataURL(key), options: .atomic)
try? type.write(to: self.typeURL(key), atomically: true, encoding: .utf8)
self.writesSincePrune += 1
if self.writesSincePrune >= 200 { self.pruneNow() }
}
}
private func remove(_ key: String) {
try? FileManager.default.removeItem(at: dataURL(key))
try? FileManager.default.removeItem(at: typeURL(key))
}
private struct Entry {
let key: String
let size: Int64
let used: Date
let created: Date
}
private func entries() -> [Entry] {
let keys: [URLResourceKey] = [.fileSizeKey, .contentModificationDateKey, .creationDateKey]
let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: keys)) ?? []
return files.filter { $0.pathExtension == "bin" }.compactMap { file in
guard let values = try? file.resourceValues(forKeys: Set(keys)) else { return nil }
return Entry(
key: file.deletingPathExtension().lastPathComponent,
size: Int64(values.fileSize ?? 0),
used: values.contentModificationDate ?? .distantPast,
created: values.creationDate ?? .distantPast
)
}
}
/// Drops what is past 30 days, then the least recently used tiles while the
/// store is over its limit.
func prune() { queue.async { self.pruneNow() } }
private func pruneNow() {
writesSincePrune = 0
let now = Date()
var live: [Entry] = []
for entry in entries() {
if now.timeIntervalSince(entry.created) > TrekTileStore.maxAge { remove(entry.key) } else { live.append(entry) }
}
var total = live.reduce(Int64(0)) { $0 + $1.size }
guard total > TrekTileStore.maxBytes else { return }
let target = Int64(Double(TrekTileStore.maxBytes) * TrekTileStore.pruneTarget)
for entry in live.sorted(by: { $0.used < $1.used }) {
if total <= target { break }
remove(entry.key)
total -= entry.size
}
}
func usage(_ done: @escaping (Int64) -> Void) {
queue.async { done(self.entries().reduce(Int64(0)) { $0 + $1.size }) }
}
func clear(_ done: @escaping () -> Void) {
queue.async {
try? FileManager.default.removeItem(at: self.directory)
try? FileManager.default.createDirectory(at: self.directory, withIntermediateDirectories: true)
done()
}
}
}
/// Serves trek-tile://host/path from the store, or from https://host/path when
/// it has no fresh copy, storing what comes back. Offline and not stored, the
/// map gets an error and draws nothing there, as it would for a missing tile.
final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
static let scheme = "trek-tile"
/// Tasks WebKit still wants an answer for. Touched on the main thread only:
/// start and stop arrive there, and every answer is posted back there.
private var active = Set<ObjectIdentifier>()
private let session: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.timeoutIntervalForRequest = 20
configuration.httpMaximumConnectionsPerHost = 4
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0"
// A unique app User-Agent, as OpenFreeMap and OpenStreetMap ask of apps.
configuration.httpAdditionalHeaders = ["User-Agent": "TREK-App/\(version) (iOS; +https://liketrek.com)"]
return URLSession(configuration: configuration)
}()
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
let id = ObjectIdentifier(urlSchemeTask)
guard let url = urlSchemeTask.request.url,
var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
urlSchemeTask.didFailWithError(URLError(.badURL))
return
}
components.scheme = "https"
guard let remote = components.url else {
urlSchemeTask.didFailWithError(URLError(.badURL))
return
}
active.insert(id)
let key = TrekTileStore.key(for: remote)
let store = TrekTileStore.shared
let accept = urlSchemeTask.request.value(forHTTPHeaderField: "Accept")
store.queue.async {
if let hit = store.read(key) {
DispatchQueue.main.async {
self.respond(urlSchemeTask, id: id, url: url, status: 200, type: hit.type, data: hit.data)
}
return
}
var request = URLRequest(url: remote)
if let accept { request.setValue(accept, forHTTPHeaderField: "Accept") }
self.session.dataTask(with: request) { data, response, _ in
guard let data, let http = response as? HTTPURLResponse else {
DispatchQueue.main.async {
self.respond(urlSchemeTask, id: id, url: url, status: 504, type: "text/plain", data: Data())
}
return
}
let type = http.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream"
if http.statusCode == 200, !data.isEmpty { store.write(key, data: data, type: type) }
DispatchQueue.main.async {
self.respond(urlSchemeTask, id: id, url: url, status: http.statusCode, type: type, data: data)
}
}.resume()
}
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
active.remove(ObjectIdentifier(urlSchemeTask))
}
/// Answers once, and never after WebKit stopped the task: that would throw.
private func respond(_ task: WKURLSchemeTask, id: ObjectIdentifier, url: URL, status: Int, type: String, data: Data) {
guard active.remove(id) != nil else { return }
let headers = [
"Content-Type": type,
"Content-Length": String(data.count),
// The page's origin is the user's server; WebGL reads tiles cross-origin.
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-store",
]
guard let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headers) else {
task.didFailWithError(URLError(.badServerResponse))
return
}
task.didReceive(response)
task.didReceive(data)
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)
}
}
+444
View File
@@ -1,5 +1,14 @@
import AuthenticationServices
import Capacitor
import CoreLocation
import CryptoKit
import Security
import UserNotifications
import WidgetKit
#if canImport(ActivityKit)
import ActivityKit
#endif
import LocalAuthentication
import UIKit
import WebKit
@@ -16,8 +25,224 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "refreshOfflineCopy", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "openOfflineCopy", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getCapabilities", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getTileCacheUsage", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "clearTileCache", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "biometryInfo", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "authenticateBiometric", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setPrivacyCover", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "startLocation", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "stopLocation", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "takeSharedFile", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "registerPush", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "takeOpenedPush", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setWidgetSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "syncTripDay", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "endTripDay", returnType: CAPPluginReturnPromise),
]
/// What this binary offers the server's UI, raised with every native feature
/// an older binary lacks. The UI asks before it calls (native/capabilities.ts),
/// 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"]
// MARK: - Shared booking documents
override public func load() {
TrekSharedFiles.onReceive = { [weak self] file in
DispatchQueue.main.async { self?.notifyListeners("sharedFile", data: file) }
}
TrekPush.shared.onOpen = { [weak self] url in
DispatchQueue.main.async { self?.notifyListeners("pushOpened", data: ["url": url]) }
}
}
// MARK: - Widgets and the travel-day Live Activity
/// Stores what the home-screen widget shows (native/widgets.ts builds it, texts
/// already translated) where the widget extension reads it, and redraws it.
@objc func setWidgetSnapshot(_ call: CAPPluginCall) {
guard let json = call.getString("json"), let data = json.data(using: .utf8) else {
call.reject("json is required")
return
}
TrekSharedKeychain.write("widget.snapshot", data)
WidgetCenter.shared.reloadAllTimelines()
call.resolve()
}
/// Starts the travel day's Live Activity, or updates the one running.
@objc func syncTripDay(_ call: CAPPluginCall) {
guard let title = call.getString("tripTitle"), let path = call.getString("path"),
let dayLabel = call.getString("dayLabel"), let nextName = call.getString("nextName") else {
call.reject("tripTitle, path, dayLabel and nextName are required")
return
}
#if canImport(ActivityKit)
if #available(iOS 16.2, *) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
call.resolve(["running": false])
return
}
let state = TripDayAttributes.ContentState(
dayLabel: dayLabel,
nextName: nextName,
nextTime: call.getString("nextTime"),
done: call.getInt("done") ?? 0,
total: call.getInt("total") ?? 0
)
// Stale at the end of the day, so a forgotten one fades out on its own.
let stale = Calendar.current.startOfDay(for: Date().addingTimeInterval(86_400))
let content = ActivityContent(state: state, staleDate: stale)
if let running = Activity<TripDayAttributes>.activities.first(where: { $0.attributes.path == path }) {
Task { await running.update(content) }
} else {
for other in Activity<TripDayAttributes>.activities {
Task { await other.end(nil, dismissalPolicy: .immediate) }
}
_ = try? Activity.request(attributes: TripDayAttributes(tripTitle: title, path: path), content: content)
}
call.resolve(["running": true])
return
}
#endif
call.resolve(["running": false])
}
@objc func endTripDay(_ call: CAPPluginCall) {
#if canImport(ActivityKit)
if #available(iOS 16.2, *) {
for activity in Activity<TripDayAttributes>.activities {
Task { await activity.end(nil, dismissalPolicy: .immediate) }
}
}
#endif
call.resolve()
}
// MARK: - Push
/// Asks for permission, registers with APNs and answers with a Web Push
/// subscription the server stores like a browser's (native/push.ts): the
/// relay endpoint for this device's token, and this device's keys, which the
/// notification extension uses to decrypt.
@objc func registerPush(_ call: CAPPluginCall) {
TrekPush.shared.register { result in
switch result {
case .success(let subscription): call.resolve(subscription)
case .failure(let error):
let code = (error as? TrekPush.PushError) == .denied ? "DENIED" : "FAILED"
call.reject(error.localizedDescription, code)
}
}
}
/// The notification the app was opened from, before the UI was there to hear of it.
@objc func takeOpenedPush(_ call: CAPPluginCall) {
if let url = TrekPush.shared.takeOpened() {
call.resolve(["url": url])
} else {
call.resolve(["url": NSNull()])
}
}
/// The document shared to TREK before the UI was there to hear of it.
@objc func takeSharedFile(_ call: CAPPluginCall) {
if let file = TrekSharedFiles.take() {
call.resolve(["file": file])
} else {
call.resolve(["file": NSNull()])
}
}
// MARK: - Location
/// One CoreLocation stream for the UI's navigator.geolocation (native/geolocation.ts).
/// The WebView's own asks for every server origin, and again and again; this
/// is the one system prompt the app's location string describes.
private lazy var location = TrekLocation { [weak self] payload in
self?.notifyListeners("location", data: payload)
}
@objc func startLocation(_ call: CAPPluginCall) {
DispatchQueue.main.async {
self.location.start(highAccuracy: call.getBool("highAccuracy") ?? false)
call.resolve()
}
}
@objc func stopLocation(_ call: CAPPluginCall) {
DispatchQueue.main.async {
self.location.stop()
call.resolve()
}
}
// MARK: - App lock
/// Which biometry the device offers for the app lock, if any.
@objc func biometryInfo(_ call: CAPPluginCall) {
let context = LAContext()
var error: NSError?
let available = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
let type: String
switch context.biometryType {
case .faceID: type = "faceID"
case .touchID: type = "touchID"
default: type = "none"
}
call.resolve(["available": available, "type": type])
}
/// Face ID / Touch ID with the passcode as the way out, the way system apps
/// lock. Rejects with CANCELLED when the user backs out.
@objc func authenticateBiometric(_ call: CAPPluginCall) {
let reason = call.getString("reason") ?? "Unlock TREK"
let context = LAContext()
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { ok, error in
if ok {
call.resolve(["ok": true])
} else if let laError = error as? LAError, [.userCancel, .appCancel, .systemCancel].contains(laError.code) {
call.reject("Unlock cancelled", "CANCELLED")
} else {
call.reject(error?.localizedDescription ?? "Unlock failed", "FAILED")
}
}
}
/// While on, the app switcher shows a blurred cover instead of the screen
/// (SceneDelegate), so a locked app does not show its content there.
@objc func setPrivacyCover(_ call: CAPPluginCall) {
TrekPrivacyCover.enabled = call.getBool("enabled") ?? false
call.resolve()
}
// MARK: - Map tile store
/// How much the stored map tiles take on disk, for the offline settings.
@objc func getTileCacheUsage(_ call: CAPPluginCall) {
TrekTileStore.shared.usage { bytes in
call.resolve(["bytes": Int(bytes), "limit": Int(TrekTileStore.maxBytes)])
}
}
@objc func clearTileCache(_ call: CAPPluginCall) {
TrekTileStore.shared.clear { call.resolve() }
}
@objc func getCapabilities(_ call: CAPPluginCall) {
let info = Bundle.main.infoDictionary
call.resolve([
"shellApi": TrekShellPlugin.shellApi,
"platform": "ios",
"appVersion": info?["CFBundleShortVersionString"] as? String ?? "",
"build": info?["CFBundleVersion"] as? String ?? "",
"features": TrekShellPlugin.features,
])
}
private var authSession: ASWebAuthenticationSession?
@objc func getServer(_ call: CAPPluginCall) {
@@ -140,3 +365,222 @@ 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.
final class TrekLocation: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private let emit: ([String: Any]) -> Void
private var running = false
init(emit: @escaping ([String: Any]) -> Void) {
self.emit = emit
super.init()
manager.delegate = self
}
func start(highAccuracy: Bool) {
manager.desiredAccuracy = highAccuracy ? kCLLocationAccuracyBest : kCLLocationAccuracyHundredMeters
running = true
switch manager.authorizationStatus {
case .notDetermined:
manager.requestWhenInUseAuthorization()
case .denied, .restricted:
emitError(code: 1, message: "Location permission denied")
default:
manager.startUpdatingLocation()
manager.startUpdatingHeading()
}
}
func stop() {
running = false
manager.stopUpdatingLocation()
manager.stopUpdatingHeading()
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard running else { return }
switch manager.authorizationStatus {
case .authorizedWhenInUse, .authorizedAlways:
manager.startUpdatingLocation()
manager.startUpdatingHeading()
case .denied, .restricted:
emitError(code: 1, message: "Location permission denied")
default:
break
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let last = locations.last else { return }
emit([
"latitude": last.coordinate.latitude,
"longitude": last.coordinate.longitude,
"accuracy": last.horizontalAccuracy,
"altitude": last.verticalAccuracy >= 0 ? last.altitude as Any : NSNull(),
"speed": last.speed >= 0 ? last.speed as Any : NSNull(),
"heading": last.course >= 0 ? last.course as Any : NSNull(),
"timestamp": last.timestamp.timeIntervalSince1970 * 1000,
])
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
let code = (error as? CLError)?.code == .denied ? 1 : 2
emitError(code: code, message: error.localizedDescription)
}
private func emitError(code: Int, message: String) {
emit(["error": ["code": code, "message": message]])
}
}
/// The app's side of TREK push: an APNs token, the device's Web Push keys in the
/// keychain group the notification extension shares, and the relay endpoint
/// that ties them together.
final class TrekPush {
static let shared = TrekPush()
static let relay = "https://push.liketrek.com/v1/apns"
/// The keychain group the notification extension reads the keys from.
static let keychainGroup = "com.liketrek.trek.shared"
enum PushError: Error, Equatable { case denied, failed }
private var pending: [(Result<[String: Any], Error>) -> Void] = []
private var openedURL: String?
var onOpen: ((String) -> Void)?
func register(_ done: @escaping (Result<[String: Any], Error>) -> Void) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
guard granted else {
done(.failure(PushError.denied))
return
}
DispatchQueue.main.async {
self.pending.append(done)
UIApplication.shared.registerForRemoteNotifications()
}
}
}
func didRegister(token: Data) {
let hex = token.map { String(format: "%02x", $0) }.joined()
#if DEBUG
let environment = "sandbox"
#else
let environment = "production"
#endif
let result: Result<[String: Any], Error>
if let keys = TrekPushKeys.loadOrCreate() {
result = .success([
"endpoint": "\(TrekPush.relay)/\(environment)/\(hex)",
"keys": ["p256dh": keys.publicKey, "auth": keys.auth],
])
} else {
result = .failure(PushError.failed)
}
let waiting = pending
pending.removeAll()
waiting.forEach { $0(result) }
}
func didFail(_ error: Error) {
let waiting = pending
pending.removeAll()
waiting.forEach { $0(.failure(error)) }
}
func opened(url: String) {
if let onOpen { onOpen(url) } else { openedURL = url }
}
func takeOpened() -> String? {
defer { openedURL = nil }
return openedURL
}
}
/// The device's Web Push key pair (P-256) and auth secret, kept in the shared
/// keychain group so the notification extension can decrypt what the server
/// encrypted for them (RFC 8291). Base64url without padding, as the server reads them.
enum TrekPushKeys {
private static let privateKeyAccount = "push.privateKey"
private static let authAccount = "push.auth"
static func loadOrCreate() -> (publicKey: String, auth: String)? {
if let raw = TrekSharedKeychain.read(privateKeyAccount), let auth = TrekSharedKeychain.read(authAccount),
let key = try? P256.KeyAgreement.PrivateKey(rawRepresentation: raw) {
return (base64url(key.publicKey.x963Representation), base64url(auth))
}
let key = P256.KeyAgreement.PrivateKey()
var auth = Data(count: 16)
let status = auth.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
guard status == errSecSuccess,
TrekSharedKeychain.write(privateKeyAccount, key.rawRepresentation),
TrekSharedKeychain.write(authAccount, auth) else { return nil }
return (base64url(key.publicKey.x963Representation), base64url(auth))
}
static func base64url(_ data: Data) -> String {
data.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
/// Items the app shares with its extensions (the notification extension, the
/// widgets) through one keychain group, so no App Group is needed: the push keys
/// and the widget snapshot.
enum TrekSharedKeychain {
static let service = "com.liketrek.trek.push"
private static func query(_ account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
]
}
/// The team-prefixed shared group, read off a probe item in the default group.
static var accessGroup: String = {
let probe: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "push.groupProbe",
kSecAttrService as String: service,
kSecReturnAttributes as String: true,
]
var result: CFTypeRef?
var status = SecItemCopyMatching(probe as CFDictionary, &result)
if status == errSecItemNotFound {
var add = probe
add.removeValue(forKey: kSecReturnAttributes as String)
add[kSecValueData as String] = Data()
SecItemAdd(add as CFDictionary, nil)
status = SecItemCopyMatching(probe as CFDictionary, &result)
}
// The default group is the first one in the entitlement: "TEAMID.com.liketrek.trek.shared".
if status == errSecSuccess, let attributes = result as? [String: Any],
let group = attributes[kSecAttrAccessGroup as String] as? String,
let prefix = group.split(separator: ".").first {
return "\(prefix).\(TrekPush.keychainGroup)"
}
return TrekPush.keychainGroup
}()
static func read(_ account: String) -> Data? {
var q = query(account)
q[kSecReturnData as String] = true
var result: CFTypeRef?
return SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess ? result as? Data : nil
}
@discardableResult
static func write(_ account: String, _ data: Data) -> Bool {
SecItemDelete(query(account) as CFDictionary)
var q = query(account)
q[kSecValueData as String] = data
q[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
return SecItemAdd(q as CFDictionary, nil) == errSecSuccess
}
}
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>TREK Notifications</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
</array>
</dict>
</plist>
@@ -0,0 +1,109 @@
import CryptoKit
import Security
import UserNotifications
/// Decrypts a TREK push before iOS shows it.
///
/// The user's server encrypts each notification for this device exactly as Web
/// Push does for a browser (RFC 8291, aes128gcm), and liketrek's relay hands the
/// ciphertext to APNs as `enc`, with a placeholder text and mutable-content set.
/// Here the device's keys (the app made them, TrekPushKeys in the app target)
/// open it, and the real title, text and link replace the placeholder. Without
/// the keys, or if anything does not add up, the placeholder stays: a
/// notification arrives either way, and nothing on the way could read it.
final class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var content: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
let content = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent()
self.content = content
if let encoded = request.content.userInfo["enc"] as? String,
let record = Data(base64Encoded: encoded),
let keys = PushKeys.load(),
let plaintext = WebPushRecord.open(record, keys: keys),
let payload = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] {
if let title = payload["title"] as? String, !title.isEmpty { content.title = title }
if let body = payload["body"] as? String { content.body = body }
if let url = payload["url"] as? String { content.userInfo["url"] = url }
if let tag = payload["tag"] as? String { content.threadIdentifier = tag }
}
contentHandler(content)
}
override func serviceExtensionTimeWillExpire() {
if let contentHandler, let content { contentHandler(content) }
}
}
/// The device's Web Push keys, from the keychain group it shares with the app.
struct PushKeys {
let privateKey: P256.KeyAgreement.PrivateKey
let auth: Data
static func load() -> PushKeys? {
guard let raw = read("push.privateKey"), let auth = read("push.auth"),
let key = try? P256.KeyAgreement.PrivateKey(rawRepresentation: raw) else { return nil }
return PushKeys(privateKey: key, auth: auth)
}
private static func read(_ account: String) -> Data? {
// No access group named: the extension's first keychain group is the
// shared one (its entitlements list only that), where the app stored them.
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.liketrek.trek.push",
kSecAttrAccount as String: account,
kSecReturnData as String: true,
]
var result: CFTypeRef?
return SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess ? result as? Data : nil
}
}
/// One aes128gcm record (RFC 8188) with Web Push keys (RFC 8291), opened.
enum WebPushRecord {
static func open(_ record: Data, keys: PushKeys) -> Data? {
let bytes = [UInt8](record)
// salt (16) | record size (4) | key id length (1) | key id (the sender's public key)
guard bytes.count > 21 else { return nil }
let salt = Data(bytes[0..<16])
let idLength = Int(bytes[20])
guard bytes.count > 21 + idLength + 16 else { return nil }
let senderKeyBytes = Data(bytes[21..<(21 + idLength)])
let ciphertext = Data(bytes[(21 + idLength)...])
guard let senderKey = try? P256.KeyAgreement.PublicKey(x963Representation: senderKeyBytes),
let shared = try? keys.privateKey.sharedSecretFromKeyAgreement(with: senderKey) else { return nil }
let sharedBytes = shared.withUnsafeBytes { Data($0) }
let receiverKey = keys.privateKey.publicKey.x963Representation
// IKM = HKDF(auth, ecdh, "WebPush: info" 0 receiver sender, 32)
var keyInfo = Data("WebPush: info".utf8)
keyInfo.append(0)
keyInfo.append(receiverKey)
keyInfo.append(senderKeyBytes)
let prkKey = HKDF<SHA256>.extract(inputKeyMaterial: SymmetricKey(data: sharedBytes), salt: keys.auth)
let ikm = HKDF<SHA256>.expand(pseudoRandomKey: prkKey, info: keyInfo, outputByteCount: 32)
let prk = HKDF<SHA256>.extract(inputKeyMaterial: ikm, salt: salt)
var cekInfo = Data("Content-Encoding: aes128gcm".utf8)
cekInfo.append(0)
var nonceInfo = Data("Content-Encoding: nonce".utf8)
nonceInfo.append(0)
let cek = HKDF<SHA256>.expand(pseudoRandomKey: prk, info: cekInfo, outputByteCount: 16)
let nonceKey = HKDF<SHA256>.expand(pseudoRandomKey: prk, info: nonceInfo, outputByteCount: 12)
let nonceBytes = nonceKey.withUnsafeBytes { Data($0) }
guard let nonce = try? AES.GCM.Nonce(data: nonceBytes),
let box = try? AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext.dropLast(16), tag: ciphertext.suffix(16)),
let padded = try? AES.GCM.open(box, using: cek) else { return nil }
// The last record ends in 0x02 and optional zero padding.
var end = padded.count
while end > 0 && padded[padded.startIndex + end - 1] == 0 { end -= 1 }
guard end > 0, padded[padded.startIndex + end - 1] == 2 else { return nil }
return padded.prefix(end - 1)
}
}
@@ -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
Link(destination: WidgetSnapshot.link(booking.path) ?? URL(string: "com.liketrek.trek://")!) {
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)
}
}
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>TREK</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,175 @@
import SwiftUI
import WidgetKit
/// The home-screen and lock-screen widgets. 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 TrekProvider: TimelineProvider {
func placeholder(in context: Context) -> TrekEntry {
TrekEntry(date: Date(), snapshot: .sample(running: false))
}
/// 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))
}
/// 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) {
let snapshot = WidgetSnapshot.load()
let now = Date()
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))
}
}
completion(Timeline(entries: entries, policy: .atEnd))
}
}
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 link: URL?
@ViewBuilder var content: () -> Content
@ViewBuilder var background: () -> Background
var body: some View {
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)
}
}
// MARK: - Trip
struct NextTripWidget: Widget {
var body: some WidgetConfiguration {
// The kind stays "NextTrip" so widgets placed before the redesign keep working.
StaticConfiguration(kind: "NextTrip", provider: TrekProvider()) { entry in
TripWidgetView(entry: entry)
}
.configurationDisplayName("Trip")
.description("Counts down to your next trip, then shows the day and what comes next.")
.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 {
StaticConfiguration(kind: "TripToday", provider: TrekProvider()) { entry in
TrekContainer(link: entry.snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) }) {
TodayFamilyView(entry: entry)
} background: {
TrekPaper()
}
}
.configurationDisplayName("Today")
.description("Today's plan as a timeline, with the weather and what is next.")
.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 {
StaticConfiguration(kind: "TripBookings", provider: TrekProvider()) { entry in
TrekContainer(link: entry.snapshot?.bookings?.first.flatMap { WidgetSnapshot.link($0.path) }) {
BookingsFamilyView(entry: entry)
} background: {
TrekPaper()
}
}
.configurationDisplayName("Bookings")
.description("Your next flights, trains, hotels and tables, and when they are due.")
.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 {
StaticConfiguration(kind: "TripTodos", provider: TrekProvider()) { entry in
TrekContainer(link: entry.snapshot?.todos.flatMap { WidgetSnapshot.link($0.path) }) {
TodosFamilyView(entry: entry)
} background: {
TrekPaper()
}
}
.configurationDisplayName("To-dos")
.description("What is still open for the trip, and how far the packing has come.")
.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) }
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
</array>
</dict>
</plist>
@@ -0,0 +1,15 @@
import SwiftUI
import WidgetKit
/// The TREK widgets: the trip, today's plan, the bookings ahead and the
/// to-dos on the home and lock screen, and the travel day as a Live Activity.
@main
struct TREKWidgetsBundle: WidgetBundle {
var body: some Widget {
NextTripWidget()
TodayWidget()
BookingsWidget()
TodosWidget()
TripDayLiveActivity()
}
}
@@ -0,0 +1,137 @@
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)
}
Spacer(minLength: family == .large ? 12 : 8)
StopList(
stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 8 : 3,
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
)
if family == .large { Spacer(minLength: 0) }
}
} 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,196 @@
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 || snapshot?.packing != nil {
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)
if let packing = snapshot?.packing { PackingBar(packing: packing, labels: labels) }
}
}
Divider().overlay(Trek.track(scheme)).padding(.vertical, 10)
items(Array(todos.items.prefix(6)), labels)
Spacer(minLength: 0)
}
}
} 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 to-dos as the outer ring and the packing list as the inner one.
private func rings(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels, size: CGFloat) -> some View {
let done = todos.total > 0 ? Double(todos.total - todos.open) / Double(todos.total) : 1
let packing = snapshot?.packing
let packed = packing.map { $0.total > 0 ? Double($0.packed) / Double($0.total) : 1 }
let width = size * 0.085
return ZStack {
ProgressRing(value: done, lineWidth: width, colors: [Color(hex: 0x4A7DDB), Color(hex: 0x6A5BE0)], trackColor: Trek.track(scheme)) { EmptyView() }
if let packed {
ProgressRing(value: packed, lineWidth: width, colors: [Color(hex: 0x2FA37A), Color(hex: 0x7BC96F)], trackColor: Trek.track(scheme)) { EmptyView() }
.padding(width * 1.45)
}
VStack(spacing: -2) {
Text("\(todos.open)").font(Trek.number(size * 0.3, .heavy)).foregroundStyle(Trek.ink(scheme))
Image(systemName: "checklist").font(.system(size: size * 0.11, weight: .bold)).foregroundStyle(Trek.muted(scheme))
}
}
.frame(width: size, height: size)
}
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 ?? "")")
}
}
}
/// 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)
}
}
}
@@ -0,0 +1,95 @@
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
return 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"
)
)
}
/// 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
return snapshot
}
}
+382
View File
@@ -0,0 +1,382 @@
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
/// 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,558 @@
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: "mappin.and.ellipse")
.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: "mappin.and.ellipse")
.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)
Spacer(minLength: 10)
GlassPanel {
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 5, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.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 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(.white)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(Color.black.opacity(0.3)))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Color.white.opacity(0.16), 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")
}
}
}
}
@@ -0,0 +1,24 @@
import ActivityKit
import Foundation
/// The Live Activity for a travel day: which trip and day, and the next stop.
/// Compiled into both the app (which starts and updates it, TrekShellPlugin)
/// and the widget extension (which draws it, TripDayLiveActivity).
@available(iOS 16.1, *)
struct TripDayAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
/// "Day 2 of 5", already in the user's language.
var dayLabel: String
/// The next stop, or the label for "all done" once the day's stops are behind.
var nextName: String
/// The next stop's time as the user formats it, if it has one.
var nextTime: String?
/// Stops behind, out of the day's stops.
var done: Int
var total: Int
}
var tripTitle: String
/// The app path a tap opens.
var path: String
}
@@ -0,0 +1,55 @@
import ActivityKit
import SwiftUI
import WidgetKit
/// A travel day on the lock screen and in the Dynamic Island: the next stop and
/// its time, and how much of the day is behind.
struct TripDayLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TripDayAttributes.self) { context in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(context.attributes.tripTitle).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
Spacer()
Text(context.state.dayLabel).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
}
HStack(alignment: .firstTextBaseline) {
Image(systemName: "mappin.circle.fill")
Text(context.state.nextName).font(.headline).lineLimit(1)
Spacer()
if let time = context.state.nextTime {
Text(time).font(.headline.monospacedDigit())
}
}
ProgressView(value: Double(context.state.done), total: Double(max(context.state.total, 1)))
}
.padding()
.widgetURL(WidgetSnapshot.link(context.attributes.path))
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Label(context.state.dayLabel, systemImage: "map").font(.caption)
}
DynamicIslandExpandedRegion(.trailing) {
if let time = context.state.nextTime {
Text(time).font(.caption.monospacedDigit())
}
}
DynamicIslandExpandedRegion(.bottom) {
VStack(alignment: .leading, spacing: 4) {
Text(context.state.nextName).font(.headline).lineLimit(1)
ProgressView(value: Double(context.state.done), total: Double(max(context.state.total, 1)))
}
}
} compactLeading: {
Image(systemName: "mappin.circle.fill")
} compactTrailing: {
Text(context.state.nextTime ?? "\(context.state.done)/\(context.state.total)")
.font(.caption2.monospacedDigit())
} minimal: {
Image(systemName: "mappin.circle.fill")
}
.widgetURL(WidgetSnapshot.link(context.attributes.path))
}
}
}
@@ -0,0 +1,168 @@
import Foundation
import Security
/// What the app last told the widgets (native/widgets.ts writes it through
/// TrekShell.setWidgetSnapshot into the keychain group the app and its
/// extensions share). Texts arrive already translated, so the widget needs no
/// localisation of its own and always speaks the language the app does.
///
/// 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
}
struct Labels: Codable {
var nextTrip: String
var today: String
var noTrip: String
/// Contains "{n}".
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 trip: Trip?
var today: Today?
var weather: Weather?
var bookings: [Booking]?
var todos: Todos?
var packing: Packing?
var labels: Labels
static func load() -> WidgetSnapshot? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.liketrek.trek.push",
kSecAttrAccount as String: "widget.snapshot",
kSecReturnData as String: true,
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
}
/// Whole days from today to the trip's start, in the device's calendar.
func daysUntilStart(from now: Date = Date()) -> Int? {
guard let trip, let start = WidgetSnapshot.day(trip.start) else { return nil }
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: from), to: calendar.startOfDay(for: to)).day
}
static func day(_ iso: String) -> Date? {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: String(iso.prefix(10)))
}
/// The app link a tap opens: com.liketrek.trek://navigate<path>.
static func link(_ path: String) -> URL? {
URL(string: "com.liketrek.trek://navigate\(path.hasPrefix("/") ? path : "/" + path)")
}
}
+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,177 @@
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()
let states: [(String, WidgetSnapshot)] = [
("upcoming", .sample(running: false, now: now)),
("running", .sample(running: true, now: now)),
("empty", .sampleEmpty),
]
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)
try write(view, to: out.appendingPathComponent("\(kind.rawValue)-\(family.rawValue)-\(stateName)-\(schemeName).png"))
}
}
}
for (stateName, snapshot) in states.prefix(2) {
let sheet = HomeScreen(snapshot: snapshot, now: now).environment(\.colorScheme, scheme)
try write(sheet, to: out.appendingPathComponent("home-\(stateName)-\(schemeName).png"))
}
let lock = LockScreen(snapshots: states.map(\.1), now: now).environment(\.colorScheme, scheme)
try write(lock, to: out.appendingPathComponent("lock-\(schemeName).png"))
}
print("wrote \(try FileManager.default.contentsOfDirectory(atPath: out.path).count) images to \(out.path)")
}
@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
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]
}
}
}
/// 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 {
content
.foregroundStyle(.white)
.frame(width: size.width, height: size.height)
.background(
RoundedRectangle(cornerRadius: family == .circular ? size.width / 2 : 12, style: .continuous)
.fill(Color.white.opacity(family == .inline ? 0 : 0.14))
)
.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)
}
}
@ViewBuilder
private var background: some View {
if kind == .trip && snapshot.trip != nil {
TripBackdrop(trip: snapshot.trip)
} else {
TrekPaper()
}
}
}
/// 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))
}
}
+74
View File
@@ -0,0 +1,74 @@
# Adds an app extension target to App.xcodeproj and embeds it in the app.
#
# ruby ios/scripts/add-extension.rb <name> <bundle-id> <type> [extra frameworks...]
#
# type is "notification" (Notification Service Extension) or "widget" (WidgetKit,
# which also carries the Live Activities). The extension's sources live in
# ios/App/<name>/ with an Info.plist and <name>.entitlements next to them. The
# project file stays checked in, so the targets show in Xcode like any other;
# this script is how they got there, and running it again changes nothing.
require 'xcodeproj'
name, bundle_id, type, *frameworks = ARGV
abort 'usage: add-extension.rb <name> <bundle-id> <notification|widget> [frameworks...]' unless name && bundle_id && type
project_path = File.expand_path('../App/App.xcodeproj', __dir__)
project = Xcodeproj::Project.open(project_path)
app = project.targets.find { |t| t.name == 'App' } or abort 'no App target'
if project.targets.any? { |t| t.name == name }
puts "#{name} is already in the project"
exit 0
end
target = project.new_target(:app_extension, name, :ios, '15.0', nil, :swift)
# new_target links Foundation by a path into one SDK version; it is linked
# implicitly anyway, and a pinned SDK path breaks on the next Xcode.
target.frameworks_build_phase.files.select { |f| f.display_name.to_s.include?('Foundation') }.each do |f|
ref = f.file_ref
f.remove_from_project
ref&.remove_from_project
end
group = project.main_group.find_subpath(name, true)
group.set_source_tree('<group>')
group.set_path(name)
sources = Dir.children(File.expand_path("../App/#{name}", __dir__)).sort
sources.each do |file|
ref = group.new_reference(file)
target.add_file_references([ref]) if file.end_with?('.swift')
end
frameworks.each do |framework|
ref = project.frameworks_group.new_file("System/Library/Frameworks/#{framework}.framework", :sdk_root)
target.frameworks_build_phase.add_file_reference(ref, true)
end
app_settings = app.build_configurations.first.build_settings
target.build_configurations.each do |config|
s = config.build_settings
s['PRODUCT_BUNDLE_IDENTIFIER'] = bundle_id
s['PRODUCT_NAME'] = '$(TARGET_NAME)'
s['INFOPLIST_FILE'] = "#{name}/Info.plist"
s['GENERATE_INFOPLIST_FILE'] = 'NO'
s['CODE_SIGN_ENTITLEMENTS'] = "#{name}/#{name}.entitlements"
s['CODE_SIGN_STYLE'] = 'Automatic'
s['DEVELOPMENT_TEAM'] = app_settings['DEVELOPMENT_TEAM'] if app_settings['DEVELOPMENT_TEAM']
s['SWIFT_VERSION'] = '5.0'
s['TARGETED_DEVICE_FAMILY'] = '1,2'
s['IPHONEOS_DEPLOYMENT_TARGET'] = type == 'widget' ? '16.2' : '15.0'
s['MARKETING_VERSION'] = app_settings['MARKETING_VERSION'] || '1.0'
s['CURRENT_PROJECT_VERSION'] = app_settings['CURRENT_PROJECT_VERSION'] || '1'
s['SKIP_INSTALL'] = 'YES'
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks', '@executable_path/../../Frameworks']
end
embed = app.copy_files_build_phases.find { |p| p.name == 'Embed Foundation Extensions' } ||
app.new_copy_files_build_phase('Embed Foundation Extensions')
embed.symbol_dst_subfolder_spec = :plug_ins
build_file = embed.add_file_reference(target.product_reference, true)
build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }
app.add_dependency(target)
project.save
puts "added #{name} (#{bundle_id})"
+24
View File
@@ -0,0 +1,24 @@
# Compiles a source file of an extension into the App target too.
#
# ruby ios/scripts/share-with-app.rb <extension-group> <file.swift>
#
# For types both sides need: the Live Activity's attributes are started by the
# app and drawn by the widget extension, so both must know them. Running it
# again changes nothing.
require 'xcodeproj'
group_name, file = ARGV
abort 'usage: share-with-app.rb <extension-group> <file.swift>' unless group_name && file
project = Xcodeproj::Project.open(File.expand_path('../App/App.xcodeproj', __dir__))
app = project.targets.find { |t| t.name == 'App' } or abort 'no App target'
group = project.main_group[group_name] or abort "no group #{group_name}"
ref = group.files.find { |f| f.path == file } or abort "no #{file} in #{group_name}"
if app.source_build_phase.files_references.include?(ref)
puts "#{file} is already in App"
else
app.add_file_references([ref])
project.save
puts "#{file} now compiles into App too"
end
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+62 -23
View File
@@ -3,6 +3,8 @@ import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
import { SplashScreen } from '@capacitor/splash-screen';
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 { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
@@ -147,11 +149,17 @@ function topbar(onBack: () => void): HTMLElement {
// ── Welcome ─────────────────────────────────────────────────────────────────
function showWelcome(opts: { back?: boolean } = {}): void {
const hero = el('img', { src: logoUrl, alt: '', className: 'hero-mark' });
// The full TREK wordmark, in the variant that reads on the current scheme.
const hero = el(
'span',
{ className: 'hero-logo' },
el('img', { src: logoWhite, alt: 'TREK', className: 'on-dark' }),
el('img', { src: logoBlack, alt: '', className: 'on-light' })
);
const connect = el(
'button',
{ type: 'button', className: 'btn' },
{ type: 'button', className: 'btn btn-ghost' },
el(
'span',
{ className: 'btn-row' },
@@ -167,7 +175,7 @@ function showWelcome(opts: { back?: boolean } = {}): void {
const cloud = el(
'button',
{ type: 'button', className: 'btn btn-ghost' },
{ type: 'button', className: 'btn' },
el(
'span',
{ className: 'btn-row' },
@@ -181,7 +189,7 @@ function showWelcome(opts: { back?: boolean } = {}): void {
showCloud();
});
const demo = el('button', { type: 'button', className: 'linkbtn' }, t('native.connect.tryDemo'));
const demo = el('button', { type: 'button', className: 'demo-link' }, t('native.connect.tryDemo'));
demo.addEventListener('click', () => runDemo(demo));
show(
@@ -189,10 +197,10 @@ function showWelcome(opts: { back?: boolean } = {}): void {
'div',
{},
hero,
el('h1', { className: 'title' }, t('native.welcome.headline')),
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
el('p', { className: 'sub' }, t('native.welcome.sub')),
connect,
cloud,
connect,
demo,
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
),
@@ -248,9 +256,6 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
fail(result.reason);
});
const demo = el('button', { type: 'button', className: 'linkbtn' }, t('native.connect.tryDemo'));
demo.addEventListener('click', () => runDemo(demo, error));
show(
el(
'div',
@@ -259,7 +264,6 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
el('h1', { className: 'title-sm' }, t('native.connect.title')),
el('p', { className: 'sub' }, t('native.connect.hint')),
form,
demo,
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
),
opts
@@ -295,7 +299,13 @@ function showCloud(opts: { back?: boolean } = {}): void {
'div',
{},
topbar(() => showWelcome({ back: true })),
el('h1', { className: 'title-sm' }, t('native.cloud.title')),
el(
'div',
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title') },
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
el('span', { className: 'cloud-tag' }, 'cloud')
),
el('p', { className: 'sub' }, t('native.cloud.sub')),
el(
'div',
@@ -326,15 +336,27 @@ function money(cents: number): string {
return new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(cents / 100);
}
// A sample catalogue for the dev preview only: the cloud site is behind a gate
// during development, so its /api/plans is unreachable from a local build. Never
// used in a real build (see the import.meta.env.DEV guard below).
const SAMPLE_PLANS: CloudPlan[] = [
{ id: 'start', name: 'Start', monthlyCents: 599, yearlyCents: 5990, aiDocuments: 0, storageGb: 10 },
{ id: 'plus', name: 'Plus', monthlyCents: 999, yearlyCents: 9990, aiDocuments: 100, storageGb: 50 },
{ id: 'pro', name: 'Pro', monthlyCents: 1599, yearlyCents: 15990, aiDocuments: 500, storageGb: 200 },
{ id: 'studio', name: 'Studio', monthlyCents: 2399, yearlyCents: 23990, aiDocuments: 2000, storageGb: 1000 },
];
async function fetchPlans(): Promise<CloudPlan[] | null> {
try {
const res = await get(`${CLOUD_URL}/api/plans`);
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
const plans = (data as { plans?: CloudPlan[] })?.plans;
return Array.isArray(plans) && plans.length ? plans : null;
if (Array.isArray(plans) && plans.length) return plans;
} catch {
return null;
// Fall through to the preview fallback.
}
if (import.meta.env?.DEV) return SAMPLE_PLANS;
return null;
}
function subscribe(plan: CloudPlan, interval: 'month' | 'year'): void {
@@ -352,6 +374,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
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));
return el(
@@ -368,7 +393,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean
{ className: 'plan-feats' },
feat(t('native.plans.unlimited')),
feat(t('native.plans.storage', { n: String(plan.storageGb) })),
feat(plan.aiDocuments > 0 ? t('native.plans.aiDocs', { n: String(plan.aiDocuments) }) : t('native.plans.noAi'))
plan.aiDocuments > 0
? feat(t('native.plans.aiDocs', { n: String(plan.aiDocuments) }))
: featOff(t('native.plans.noAi'))
),
sub
);
@@ -381,12 +408,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
const content = el('div', { className: 'loading' }, el('span', { className: 'spinner' }));
const monthBtn = el('button', { type: 'button' }, t('native.plans.monthly'));
const yearBtn = el(
'button',
{ type: 'button' },
t('native.plans.yearly'),
el('span', { className: 'save' }, t('native.plans.save'))
);
const yearBtn = el('button', { type: 'button' }, t('native.plans.yearly'));
const seg = el('div', { className: 'segmented' }, monthBtn, yearBtn);
const paintSeg = () => {
@@ -397,7 +419,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
const list = plans;
if (!list) return;
content.className = 'plans';
content.replaceChildren(...list.map((p, i) => planCard(p, interval, i === list.length - 1)));
content.replaceChildren(...list.map((p) => planCard(p, interval, false)));
};
const choose = (next: 'month' | 'year') => {
if (interval === next) return;
@@ -494,6 +516,7 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
autocapitalize: 'off',
spellcheck: false,
value: '',
placeholder: t('native.claim.addressLabel'),
});
slugInput.setAttribute('inputmode', 'url');
slugInput.setAttribute('autocomplete', 'off');
@@ -507,7 +530,13 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
);
const slugNote = el('p', { className: 'hint' });
const emailInput = el('input', { type: 'email', value: '', autocapitalize: 'off', spellcheck: false });
const emailInput = el('input', {
type: 'email',
value: '',
autocapitalize: 'off',
spellcheck: false,
placeholder: t('native.claim.emailLabel'),
});
emailInput.setAttribute('inputmode', 'email');
emailInput.setAttribute('autocomplete', 'email');
emailInput.setAttribute('enterkeyhint', 'go');
@@ -782,7 +811,17 @@ async function start(): Promise<void> {
iap.isAvailable().then((ok) => {
iapAvailable = ok;
});
const { url } = await TrekShell.getServer();
let url: string | null;
try {
({ url } = await TrekShell.getServer());
} catch {
// No native shell under us (a web preview, or a simulator without the
// plugin): there is no remembered server, so start at the welcome screen
// rather than leaving a blank page.
showWelcome();
await SplashScreen.hide().catch(() => {});
return;
}
if (!url) {
showWelcome();
} else {
+279 -27
View File
@@ -4,6 +4,49 @@
* background and never flashes. Everything past this screen is the UI the
* chosen server delivers.
*/
/*
* TREK's own type, self-hosted (latin subset, bundled — the shell loads offline
* and hotlinks nothing). Poppins is the display face across the app and
* liketrek.com; MuseoModerno draws the "cloud" of the TREK Cloud wordmark.
*/
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./fonts/poppins-latin-500.woff2') format('woff2');
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('./fonts/poppins-latin-600.woff2') format('woff2');
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./fonts/poppins-latin-700.woff2') format('woff2');
}
@font-face {
font-family: 'MuseoModerno';
font-style: normal;
font-weight: 600 700;
font-display: swap;
src: url('./fonts/museomoderno-latin-600.woff2') format('woff2');
}
/* Italic, subset to the letters of "cloud" — the wordmark's badge, nothing else. */
@font-face {
font-family: 'MuseoModerno';
font-style: italic;
font-weight: 600;
font-display: swap;
src: url('./fonts/museomoderno-italic-600.woff2') format('woff2');
}
:root {
--bg: #09090b;
--bg-2: #101013;
@@ -18,6 +61,13 @@
--accent-ink: #09090b;
--danger: #f87171;
--ok: #4ade80;
/* The hairline the ambient grid is drawn in, and a travel-dusk wash for depth. */
--grid: rgba(255, 255, 255, 0.055);
--wash-1: rgba(129, 140, 248, 0.12);
--wash-2: rgba(244, 114, 182, 0.08);
--veil-top: rgba(9, 9, 11, 0.4);
--veil-mid: rgba(9, 9, 11, 0.72);
--font: 'Poppins', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, system-ui, sans-serif;
--radius: 16px;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
@@ -39,6 +89,11 @@
--accent-ink: #ffffff;
--danger: #dc2626;
--ok: #16a34a;
--grid: rgba(17, 24, 39, 0.075);
--wash-1: rgba(79, 70, 229, 0.1);
--wash-2: rgba(219, 39, 119, 0.07);
--veil-top: rgba(255, 255, 255, 0.12);
--veil-mid: rgba(255, 255, 255, 0.74);
color-scheme: light;
}
.shell-logo,
@@ -56,9 +111,11 @@ html,
body {
margin: 0;
min-height: 100%;
background: var(--bg);
background:
radial-gradient(90% 55% at 50% -12%, var(--wash-1), transparent 62%),
radial-gradient(80% 50% at 50% 112%, var(--wash-2), transparent 64%), var(--bg);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, system-ui, sans-serif;
font-family: var(--font);
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
@@ -66,14 +123,47 @@ body {
-webkit-user-select: none;
}
/* An ambient glow behind the content, so the flat dark gains a little depth. */
/*
* The backdrop, in two fixed layers behind the content. First a soft dusk wash
* at top and bottom — a trace of the colour the app's photos carry, never loud
* enough to read as decoration. Then a quiet grid that fades out through a
* radial mask: depth, the way liketrek.com's hero carries it.
*/
/* The grid, strongest at the top-right and fading left and down — the half the
photo leaves open, so the two meet in a soft seam down the middle. */
body::before {
content: '';
position: fixed;
inset: 0;
inset: 0 0 auto;
height: 440px;
background-image:
linear-gradient(var(--grid) 1px, transparent 1px), linear-gradient(90deg, var(--grid) 1px, transparent 1px);
background-size: 54px 54px;
-webkit-mask-image: radial-gradient(135% 95% at 100% 0%, #000 34%, transparent 80%);
mask-image: radial-gradient(135% 95% at 100% 0%, #000 34%, transparent 80%);
pointer-events: none;
z-index: 0;
}
/*
* A travel photo as a top band (Kyoto at dusk, the same image liketrek.com uses
* in its pricing section), under a dark gradient and faded out at the bottom so
* the headline stays legible. Dark-first; in light mode it steps back to the
* quiet grid instead, which reads better on a white page.
*/
/* The travel photo (Kyoto at dusk, as on liketrek.com), anchored left and faded
out toward the right so it hands over to the grid, and down into the page.
The veil over it takes the scheme's page colour: a dark dusk in dark mode, a
bright morning haze in light mode, so the black wordmark still reads on it. */
body::after {
content: '';
position: fixed;
inset: 0 0 auto;
height: 440px;
background:
radial-gradient(60% 50% at 50% -5%, color-mix(in srgb, var(--ink) 7%, transparent), transparent 70%),
radial-gradient(50% 40% at 50% 110%, color-mix(in srgb, var(--ink) 4%, transparent), transparent 70%);
linear-gradient(180deg, var(--veil-top) 0%, var(--veil-mid) 62%, var(--bg) 100%),
url('./img/kyoto.webp') left 28% / cover no-repeat;
-webkit-mask-image: linear-gradient(90deg, #000 0%, #000 40%, transparent 80%);
mask-image: linear-gradient(90deg, #000 0%, #000 40%, transparent 80%);
pointer-events: none;
z-index: 0;
}
@@ -213,11 +303,47 @@ body::before {
/* The big hero mark on the welcome screen, with a gentle breathing float. */
.hero-mark {
width: 76px;
height: 76px;
margin-bottom: 18px;
width: 80px;
height: 80px;
margin-bottom: 20px;
animation: floaty 6s ease-in-out infinite;
filter: drop-shadow(0 10px 30px color-mix(in srgb, var(--ink) 18%, transparent));
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
}
/* The full TREK wordmark on the welcome screen, in the variant that reads on the scheme. */
.hero-logo {
display: block;
margin-bottom: 22px;
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
animation: logoRise 0.7s var(--ease) both;
}
.hero-logo img {
display: block;
height: 62px;
width: auto;
}
.hero-logo .on-light {
display: none;
}
@media (prefers-color-scheme: light) {
.hero-logo .on-dark {
display: none;
}
.hero-logo .on-light {
display: block;
}
}
@keyframes logoRise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes floaty {
0%,
@@ -231,24 +357,69 @@ body::before {
/* ── Type ────────────────────────────────────────────────────────────────── */
.title {
font-size: 1.9rem;
line-height: 1.12;
font-size: 2.05rem;
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.02em;
letter-spacing: -0.03em;
margin: 0;
/* Honour the newline in the headline string (e.g. "Deine Reisen.\nDein Plan."). */
white-space: pre-line;
}
/* The welcome headline as the brand says it: MuseoModerno, lower case. */
.brand-title {
font-family: 'MuseoModerno', 'Poppins', sans-serif;
font-weight: 700;
text-transform: lowercase;
letter-spacing: -0.01em;
line-height: 1.08;
}
/* The TREK Cloud lockup (cloud intro screen): the wordmark plus the "cloud"
badge in italic MuseoModerno, an inverted pill, exactly as on liketrek.com. */
.cloud-wordmark {
display: inline-flex;
align-items: center;
gap: 9px;
}
.cloud-wordmark img {
display: block;
height: 38px;
width: auto;
}
.cloud-wordmark .on-light {
display: none;
}
@media (prefers-color-scheme: light) {
.cloud-wordmark .on-dark {
display: none;
}
.cloud-wordmark .on-light {
display: block;
}
}
.cloud-tag {
font-family: 'MuseoModerno', system-ui, sans-serif;
font-style: italic;
font-weight: 600;
font-size: 0.95rem;
line-height: 1.35;
padding: 2px 11px;
border-radius: 999px;
color: var(--bg);
background: var(--ink);
}
.title-sm {
font-size: 1.45rem;
line-height: 1.2;
font-size: 1.5rem;
line-height: 1.18;
font-weight: 700;
letter-spacing: -0.015em;
letter-spacing: -0.025em;
margin: 0;
}
.sub {
color: var(--muted);
margin: 0;
line-height: 1.55;
font-size: 1.02rem;
font-size: 0.9rem;
overflow-wrap: anywhere;
}
@@ -263,10 +434,14 @@ body::before {
background: var(--accent);
color: var(--accent-ink);
cursor: pointer;
box-shadow:
0 8px 22px -14px rgba(0, 0, 0, 0.55),
0 1px 2px rgba(0, 0, 0, 0.25);
transition:
transform 0.14s var(--ease),
opacity 0.2s,
background 0.2s,
box-shadow 0.2s,
border-color 0.2s;
display: inline-flex;
align-items: center;
@@ -284,6 +459,9 @@ body::before {
background: var(--surface);
color: var(--ink);
border-color: var(--edge);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.22),
inset 0 0 0 1px color-mix(in srgb, var(--ink) 4%, transparent);
}
.btn-ghost:active {
background: var(--surface-2);
@@ -318,6 +496,34 @@ body::before {
align-self: center;
transition: color 0.2s;
}
/* "Try the demo": a tertiary action, but a tidy pill so it reads as a choice, not stray text. */
.demo-link {
font: inherit;
font-weight: 600;
font-size: 0.95rem;
align-self: stretch;
width: 100%;
margin-top: 2px;
padding: 9px 18px;
min-height: 40px;
border-radius: var(--radius);
border: 1px solid var(--edge);
background: transparent;
color: var(--muted);
cursor: pointer;
white-space: nowrap;
transition:
color 0.2s,
background 0.2s,
border-color 0.2s,
transform 0.14s var(--ease);
}
.demo-link:active {
transform: scale(0.97);
background: var(--surface-2);
color: var(--ink);
}
.linkbtn:active {
color: var(--ink);
}
@@ -329,14 +535,21 @@ a.linkbtn {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2px 18px;
gap: 4px 22px;
margin-top: 6px;
padding-top: 16px;
border-top: 1px solid var(--edge);
font-size: 0.86rem;
}
.foot-links a {
color: var(--faint);
color: var(--muted);
font-weight: 500;
text-decoration: none;
padding: 8px 2px;
padding: 6px 2px;
transition: color 0.2s;
}
.foot-links a:active {
color: var(--ink);
}
/* ── Address form ────────────────────────────────────────────────────────── */
@@ -351,13 +564,16 @@ a.linkbtn {
.field input {
width: 100%;
font: inherit;
font-size: 1.05rem;
padding: 16px 18px;
font-size: 1rem;
padding: 13px 16px;
border-radius: var(--radius);
border: 1px solid var(--edge);
background: var(--surface);
color: var(--ink);
outline: none;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
transition:
border-color 0.2s,
box-shadow 0.25s;
@@ -574,10 +790,19 @@ a.linkbtn {
border-radius: var(--radius);
padding: 16px;
background: var(--surface);
box-shadow:
0 2px 10px -4px rgba(0, 0, 0, 0.3),
inset 0 0 0 1px color-mix(in srgb, var(--ink) 3%, transparent);
transition:
border-color 0.2s,
box-shadow 0.2s,
transform 0.14s var(--ease);
}
.plan.featured {
box-shadow:
0 10px 30px -10px color-mix(in srgb, var(--wash-1) 85%, transparent),
0 2px 8px -3px rgba(0, 0, 0, 0.35);
}
.plan:active {
transform: scale(0.99);
}
@@ -604,7 +829,7 @@ a.linkbtn {
font-weight: 500;
font-size: 0.8rem;
color: var(--muted);
margin-left: 3px;
margin-left: 7px;
}
.plan-feats {
margin: 10px 0 14px;
@@ -623,6 +848,10 @@ a.linkbtn {
flex: none;
color: var(--ok);
}
.plan-feats .off,
.plan-feats .off svg {
color: var(--faint);
}
.plan .btn {
width: 100%;
min-height: 48px;
@@ -654,21 +883,44 @@ a.linkbtn {
}
/* ── Claim: address + email, and the provisioning wait ───────────────────── */
/* One rounded field holding the address input and its fixed ".liketrek.com"
suffix — the input itself is seamless, the box and its states live here. */
.slug-field {
display: flex;
align-items: center;
padding-right: 14px;
padding-right: 16px;
border-radius: var(--radius);
border: 1px solid var(--edge);
background: var(--surface);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
transition:
border-color 0.2s,
box-shadow 0.25s;
}
.slug-field .slug-input {
flex: 1;
min-width: 0;
text-align: right;
text-align: left;
border: 0;
background: transparent;
box-shadow: none;
}
.slug-field:focus-within {
border-color: var(--edge-strong);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
}
.slug-field.bad {
border-color: var(--danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
animation: shake 0.3s;
}
.slug-suffix {
flex: none;
color: var(--muted);
font-size: 0.98rem;
padding-left: 2px;
font-size: 0.95rem;
padding-left: 4px;
user-select: none;
}
.hint {
+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": 1913,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+7 -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"
]
},
{
@@ -1002,6 +1001,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 +1010,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 +1040,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,
+24 -27
View File
@@ -125,7 +125,6 @@
"components/shared/CustomSelect.tsx": 1,
"components/shared/CustomTimePicker.tsx": 1,
"components/shared/EmptyState.tsx": 1,
"components/shared/ErrorBoundary.tsx": 2,
"components/shared/Modal.tsx": 3,
"components/shared/NavigationMenu.tsx": 1,
"components/shared/NoteColorPicker.tsx": 2,
@@ -166,32 +165,32 @@
"mobile/screens/admin/MAdminUi.tsx": 10,
"mobile/screens/admin/MAdminUsersSection.tsx": 14,
"mobile/screens/admin/MApiProviderBlocks.tsx": 10,
"mobile/screens/atlas/MAtlas.tsx": 7,
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 16,
"mobile/screens/atlas/MAtlas.tsx": 5,
"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,
@@ -202,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,
@@ -217,13 +216,12 @@
"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/MPlacesFilterSheet.tsx": 3,
"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": 14,
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 26,
"mobile/screens/trip/plan/MPlanTimeline.tsx": 12,
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 24,
"mobile/screens/trip/roadtrip/MRoadtripRows.tsx": 31,
"mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 16,
"mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 8,
@@ -237,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,
@@ -253,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,
@@ -281,11 +278,11 @@
"mobile/screens/trip/tabs/MTransportsTab.tsx": 2,
"mobile/screens/trip/tabs/filesModel.ts": 5,
"mobile/screens/trip/tabs/tabChrome.tsx": 12,
"mobile/screens/vacay/MVacay.tsx": 23,
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
"mobile/screens/vacay/MVacay.tsx": 17,
"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,
+14 -2
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef, ReactNode, Suspense } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router'
import { Routes, Route, Navigate, useLocation, useNavigationType } from 'react-router'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { applyAppearance } from './theme/applyAppearance'
@@ -21,6 +21,7 @@ import { lazyWithRetry } from './utils/lazyWithRetry'
import { reconcileAppVersion } from './utils/versionHandover'
import { useServerVersionStore } from './store/serverVersionStore'
import { useIsPhone } from './mobile/useIsPhone'
import { useRouteTransition } from './mobile/routeTransition'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi, isAuthPublicPath } from './api/client'
import { tripRepo } from './repo/tripRepo'
@@ -32,6 +33,10 @@ import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
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 './mobile/components/NativeImportSheet'
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel'
// Notice action registrations (side-effect imports):
@@ -407,6 +412,9 @@ export default function App() {
}, [settings.dark_mode, settings.appearance, isSharedPage])
const isPhone = useIsPhone()
// On the phone, pages slide like a native navigation stack (mobile/routeTransition.ts).
const navigationType = useNavigationType()
const routedLocation = useRouteTransition(location, navigationType, isPhone)
const keepingTour = useSaveToCollectionStore(s => s.tour !== null)
const isAuthPage = location.pathname.startsWith('/login')
|| location.pathname.startsWith('/register')
@@ -442,12 +450,16 @@ export default function App() {
)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:native-update" fallback={null}><NativeUpdateNotice /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:app-lock" fallback={null}><AppLockGate /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:native-import" fallback={null}><NativeImportSheet /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:native-widgets" fallback={null}><NativeWidgetsSync /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
so a mounted boundary keeps the current page on screen instead of flashing
a spinner on every jump — the spinner is for the first paint of a deep link. */}
<Suspense fallback={<RouteFallback />}>
<Routes>
<Routes location={routedLocation}>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
<Route path="/shared/:token" element={<PublicRoute><SharedTripPage /></PublicRoute>} />
+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">
@@ -4,6 +4,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
import { buildGlMap, initialMapStyle } from '../Map/glSupport'
import { mapboxTileCacheRequest, tileCacheActive } from '../../native/tileCache'
import type { JourneyTrack } from '@trek/shared'
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
@@ -395,6 +396,9 @@ function JourneyMapGL(
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
// In the iOS app, Mapbox's own resources go through the app's tile store, so
// what was viewed stays on the device for up to 30 days (native/tileCache.ts).
if (!isMapLibre && tileCacheActive()) mapOptions.transformRequest = mapboxTileCacheRequest
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
if (isMapLibre) mapOptions.aroundCenter = false
@@ -0,0 +1,63 @@
import { App } from '@capacitor/app';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { isAppLockEnabled, unlockApp } from '../../native/appLock';
import AppLockGate from './AppLockGate';
vi.mock('@capacitor/app', () => ({ App: { addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() })) } }));
vi.mock('../../native/appLock', () => ({
RELOCK_AFTER_MS: 30_000,
isAppLockEnabled: vi.fn(),
unlockApp: vi.fn(),
}));
function stateListener(): (state: { isActive: boolean }) => void {
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;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isAppLockEnabled).mockReturnValue(true);
vi.mocked(unlockApp).mockResolvedValue(false);
});
describe('AppLockGate', () => {
it('covers the app at launch and asks at once; a successful unlock lifts it', async () => {
render(<AppLockGate />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
await waitFor(() => expect(unlockApp).toHaveBeenCalledTimes(1));
vi.mocked(unlockApp).mockResolvedValue(true);
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});
it('locks again after half a minute in the background, not after a glance away', async () => {
vi.mocked(unlockApp).mockResolvedValue(true);
const now = vi.spyOn(Date, 'now');
render(<AppLockGate />);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
vi.mocked(unlockApp).mockResolvedValue(false);
now.mockReturnValue(1_000);
act(() => stateListener()({ isActive: false }));
now.mockReturnValue(10_000);
act(() => stateListener()({ isActive: true }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
act(() => stateListener()({ isActive: false }));
now.mockReturnValue(60_000);
act(() => stateListener()({ isActive: true }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
now.mockRestore();
});
it('is nothing while the lock is off', () => {
vi.mocked(isAppLockEnabled).mockReturnValue(false);
const { container } = render(<AppLockGate />);
expect(container).toBeEmptyDOMElement();
expect(unlockApp).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,85 @@
/**
* AppLockGate: covers TREK until Face ID, Touch ID or the passcode unlocks it,
* when the app lock is on (native/appLock.ts). It locks at launch and again
* after the app spent more than half a minute in the background, and asks at
* once rather than waiting for a tap; the button is there for a second try.
* Rendered once in App; outside the app, or with the lock off, it is nothing.
*/
import { App } from '@capacitor/app';
import { Lock } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../i18n';
import { RELOCK_AFTER_MS, isAppLockEnabled, unlockApp } from '../../native/appLock';
import { useNativeCapabilities } from '../../native/capabilities';
export default function AppLockGate(): React.ReactElement | null {
const { t } = useTranslation();
// Re-read once the binary has said what it can do.
const { caps } = useNativeCapabilities();
const [locked, setLocked] = useState(false);
const asking = useRef(false);
const backgroundSince = useRef<number | null>(null);
const ask = useCallback(async () => {
if (asking.current) return;
asking.current = true;
const ok = await unlockApp(t('native.lock.reason'));
asking.current = false;
if (ok) setLocked(false);
}, [t]);
// 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(() => {
// 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.
useEffect(() => {
if (!isAppLockEnabled()) return;
const listener = App.addListener('appStateChange', ({ isActive }) => {
if (!isActive) {
backgroundSince.current = Date.now();
return;
}
const since = backgroundSince.current;
backgroundSince.current = null;
if (since != null && Date.now() - since > RELOCK_AFTER_MS) {
setLocked(true);
void ask();
}
});
return () => {
void listener.then((handle) => handle.remove());
};
}, [caps, ask]);
if (!locked) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('native.lock.title')}
className="fixed inset-0 z-[calc(var(--z-toast)+1)] flex flex-col items-center justify-center gap-5 bg-surface p-8 text-center backdrop-blur-2xl"
>
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-surface-card text-content shadow-lg">
<Lock size={26} strokeWidth={2} aria-hidden="true" />
</span>
<p className="text-title font-semibold text-content">{t('native.lock.title')}</p>
<button
type="button"
onClick={() => void ask()}
className="rounded-full bg-accent px-6 py-3 text-body font-semibold text-accent-text transition-transform active:scale-[.98]"
>
{t('native.lock.unlock')}
</button>
</div>
);
}
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
import { _resetNativeCapabilities, checkAppUpdate, loadNativeCapabilities } from '../../native/capabilities';
import { isNativeApp } from '../../native/platform';
import { TrekShell } from '../../native/trekShell';
import NativeUpdateNotice from './NativeUpdateNotice';
vi.mock('../../native/platform', () => ({ isNativeApp: vi.fn(), isNativeIos: vi.fn(() => false) }));
vi.mock('../../native/trekShell', () => ({
TrekShell: { getCapabilities: vi.fn() },
isUnimplemented: () => false,
}));
beforeEach(() => {
_resetNativeCapabilities();
vi.mocked(isNativeApp).mockReturnValue(true);
vi.mocked(TrekShell.getCapabilities).mockResolvedValue({
shellApi: 2,
platform: 'android',
appVersion: '5.2.0',
build: '3',
features: [],
} as never);
});
describe('NativeUpdateNotice', () => {
it('stays away while the binary is new enough', async () => {
render(<NativeUpdateNotice />);
await act(async () => {
await checkAppUpdate(async () => ({ minNativeShellApi: 2 }));
});
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('leads to the store when the server needs a newer binary, and can be dismissed', async () => {
render(<NativeUpdateNotice />);
await act(async () => {
await loadNativeCapabilities();
await checkAppUpdate(async () => ({ minNativeShellApi: 3 }));
});
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', 'https://play.google.com/store/apps/details?id=com.liketrek.trek');
fireEvent.click(screen.getByRole('button'));
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,54 @@
/**
* NativeUpdateNotice: this server's UI needs a newer app binary than the one
* installed (native/capabilities.ts, minNativeShellApi). The UI updates with
* the server, the binary only through the store, so the two can drift; the
* notice says so and leads to the store instead of leaving native calls to
* fail one by one. Rendered once in App, inside the app only.
*/
import { Download, X } from 'lucide-react';
import React, { useState } from 'react';
import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
const STORE_URL = {
ios: 'https://apps.apple.com/app/id6831237775',
android: 'https://play.google.com/store/apps/details?id=com.liketrek.trek',
} as const;
export default function NativeUpdateNotice(): React.ReactElement | null {
const { t } = useTranslation();
const { caps, updateRequired } = useNativeCapabilities();
const [dismissed, setDismissed] = useState(false);
if (!updateRequired || dismissed || caps.platform === 'web') return null;
const store = caps.platform === 'android' ? STORE_URL.android : STORE_URL.ios;
return (
<div
role="status"
style={{ bottom: 'calc(var(--bottom-nav-h) + 56px)' }}
className="fixed inset-x-4 z-[var(--z-notice)] flex items-center gap-3 rounded-2xl border border-edge bg-surface-elevated px-4 py-3 text-content shadow-lg"
>
<Download size={18} className="shrink-0 text-content-muted" aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="text-body font-semibold">{t('native.update.title')}</div>
<div className="text-caption text-content-secondary">{t('native.update.body')}</div>
</div>
<a
href={store}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 rounded-full bg-accent px-3 py-1.5 text-caption font-semibold text-accent-text"
>
{t('native.update.open')}
</a>
<button
type="button"
onClick={() => setDismissed(true)}
aria-label={t('common.close')}
className="shrink-0 rounded-full p-1 text-content-muted"
>
<X size={14} aria-hidden="true" />
</button>
</div>
);
}
@@ -0,0 +1,152 @@
/**
* 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 { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
import {
buildSnapshot,
pickTrip,
publishWidgets,
todayOf,
tripDayState,
widgetsActive,
withBookings,
type WidgetLabels,
} from '../../native/widgets';
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'),
};
}
/** 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, members] = await Promise.all([
offlineDb.places.where('trip_id').equals(trip.id).toArray(),
offlineDb.reservations.where('trip_id').equals(trip.id).toArray(),
offlineDb.todoItems.where('trip_id').equals(trip.id).toArray(),
offlineDb.packingItems.where('trip_id').equals(trip.id).toArray(),
offlineDb.tripMembers.where('tripId').equals(trip.id).count(),
]);
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] = await Promise.all([
spot && inRange ? widgetWeather(spot, { date: weatherDate, lang: language, unit, place }) : Promise.resolve(null),
widgetCover(trip.cover_image),
]);
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: packingSummary(packing, `${path}?tab=listen`),
},
};
}
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;
const sync = async () => {
const now = new Date();
const today = localDateStr(now.getFullYear(), now.getMonth(), now.getDate());
const time = (value: string) => formatTime(value, language, timeFormat);
const trips = await offlineDb.trips.toArray();
const trip = pickTrip(trips, today);
const days = trip ? await offlineDb.days.where('trip_id').equals(trip.id).toArray() : [];
const extra = trip ? await tripExtras(trip, days, { today, language, unit, time }) : null;
const plain = trip ? todayOf(trip, days, today, (n, m) => t('native.widget.dayOf', { n, m }), time) : null;
const day = plain ? withBookings(plain, extra?.reservations ?? [], today, time) : null;
if (cancelled) return;
const snapshot = buildSnapshot(trip, day, labels(t), extra?.extras);
const live = day ? tripDayState(day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone')) : null;
await publishWidgets(snapshot, live);
};
void sync();
const timer = window.setInterval(() => void sync(), REFRESH_MS);
const listener = App.addListener('appStateChange', ({ isActive }) => {
if (isActive) void sync();
});
return () => {
cancelled = true;
window.clearInterval(timer);
void listener.then((handle) => handle.remove());
};
}, [caps, t, language, timeFormat, unit]);
return null;
}
+7 -2
View File
@@ -69,14 +69,19 @@ export default function LocationButton({ mode, error, errorCode, onClick, bottom
style={{
position: 'absolute',
bottom: bottomOffset,
right: 12,
// The map's base-layer switcher sits 20px off the left edge; this mirrors it.
right: 20,
zIndex: 1000,
width: 42,
height: 42,
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
// Off, it wears the same frosted glass as the map's other round controls;
// on, the location blue says it is tracking.
background: isActive ? '#3b82f6' : 'var(--sidebar-bg)',
backdropFilter: isActive ? undefined : 'blur(20px) saturate(180%)',
WebkitBackdropFilter: isActive ? undefined : 'blur(20px) saturate(180%)',
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
boxShadow: MAP_CONTROL_SHADOW,
display: 'flex',
@@ -1,4 +1,4 @@
// FE-COMP-MAPCOMPASS-001 to FE-COMP-MAPCOMPASS-006
// FE-COMP-MAPCOMPASS-001 to FE-COMP-MAPCOMPASS-007
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
@@ -86,3 +86,17 @@ describe('MapCompassPill', () => {
expect(button.style.background).toBe('transparent')
})
})
describe('MapCompassPill on the phone map', () => {
it('FE-COMP-MAPCOMPASS-007: appears only while the map is turned off north', () => {
const { map, rotateTo } = fakeCompassMap(0)
render(<MapCompassPill map={map} onlyWhenRotated />)
expect(screen.queryByRole('button', { name: 'Reset north' })).not.toBeInTheDocument()
rotateTo(30)
expect(screen.getByRole('button', { name: 'Reset north' })).toBeInTheDocument()
rotateTo(0)
expect(screen.queryByRole('button', { name: 'Reset north' })).not.toBeInTheDocument()
})
})
+8 -1
View File
@@ -15,7 +15,12 @@ export interface CompassMap {
* (GL only) and built as the SAME frosted shell (padding 4 around a 34px button)
* so its height and transparency match the POI pill exactly.
*/
export function MapCompassPill({ map }: { map: CompassMap }) {
export function MapCompassPill({ map, onlyWhenRotated = false }: {
map: CompassMap
/** Shown only while the map is turned off north, the way a native map's compass
* appears when it has something to undo (the phone map, where room is short). */
onlyWhenRotated?: boolean
}) {
const [bearing, setBearing] = useState(() => map.getBearing())
useEffect(() => {
@@ -25,6 +30,8 @@ export function MapCompassPill({ map }: { map: CompassMap }) {
return () => { map.off('rotate', update) }
}, [map])
if (onlyWhenRotated && Math.abs(bearing) < 0.5) return null
return (
<div style={{
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
+3 -2
View File
@@ -68,6 +68,7 @@ import { tileHealthHandlers, tourRasterBaseOf } from './tourRasterBases'
import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { nativeRasterTemplate } from '../../native/tileCache'
import { useSettingsStore } from '../../store/settingsStore'
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
@@ -153,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
@@ -938,7 +939,7 @@ export const MapView = memo(function MapView({
) : (
<TileLayer
key="raster"
url={basemap.url}
url={nativeRasterTemplate(basemap.url)}
attribution={attributionForTile(basemap.url)}
maxZoom={19}
keepBuffer={8}
@@ -0,0 +1,151 @@
/**
* The trip map offline (FE-COMP-MAPAUTO-OFFLINE-*).
*
* Whatever basemap the user picked, offline the map never goes grey: a basemap
* with nothing stored switches to the stored OpenFreeMap one, with the same
* markers and routes, and says so.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
import { useSettingsStore } from '../../store/settingsStore';
import { resetWebGLProbe } from '../../utils/webgl';
import { MapViewAuto } from './MapViewAuto';
const env = vi.hoisted(() => ({ offline: false, dark: false, tileStore: false }));
const seen = vi.hoisted(() => ({
leaflet: null as null | { tileUrl?: string },
gl: null as null | { engine: string; glProvider?: string; styleOverride?: string },
}));
vi.mock('./MapView', () => ({
MapView: (props: { tileUrl?: string }) => {
seen.leaflet = props;
return <div data-testid="leaflet-map" />;
},
}));
vi.mock('./glLazy', () => ({
MapViewGLMaplibre: (props: { glProvider?: string; styleOverride?: string }) => {
seen.gl = { engine: 'maplibre', ...props };
return <div data-testid="gl-map" />;
},
MapViewGLMapbox: (props: { glProvider?: string; styleOverride?: string }) => {
seen.gl = { engine: 'mapbox', ...props };
return <div data-testid="gl-map" />;
},
}));
vi.mock('./useRoadtripHazards', () => ({ useRoadtripHazards: () => ({ feed: null }) }));
vi.mock('../../hooks/useNetworkMode', () => ({
useNetworkMode: () => ({ offline: env.offline, forced: false, setForced: () => {} }),
}));
vi.mock('../../hooks/useIsDark', () => ({ useIsDark: () => env.dark }));
vi.mock('../../native/tileCache', () => ({ tileCacheActive: () => env.tileStore }));
vi.mock('../../native/platform', async (importOriginal) => ({
...(await importOriginal<typeof import('../../native/platform')>()),
isNativeIos: () => false,
}));
const realGetContext = HTMLCanvasElement.prototype.getContext;
beforeEach(() => {
resetAllStores();
resetWebGLProbe();
env.offline = false;
env.dark = false;
env.tileStore = false;
seen.leaflet = null;
seen.gl = null;
const ctx = { getExtension: () => ({ loseContext: () => {} }) };
HTMLCanvasElement.prototype.getContext = vi.fn((type: string) =>
type === 'webgl2' ? ctx : null
) as unknown as typeof realGetContext;
});
afterEach(() => {
HTMLCanvasElement.prototype.getContext = realGetContext;
resetWebGLProbe();
});
const pill = () => screen.queryByRole('status');
describe('MapViewAuto offline', () => {
it('FE-COMP-MAPAUTO-OFFLINE-001: a Mapbox map online stays Mapbox, with no pill', async () => {
seedStore(useSettingsStore, { settings: { map_provider: 'mapbox-gl', mapbox_access_token: 'pk.test' } });
render(<MapViewAuto places={[]} />);
await screen.findByTestId('gl-map');
expect(seen.gl).toMatchObject({ engine: 'mapbox', glProvider: 'mapbox-gl', styleOverride: undefined });
expect(pill()).not.toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-OFFLINE-002: offline, a Mapbox map draws the stored basemap with MapLibre and says so', async () => {
seedStore(useSettingsStore, { settings: { map_provider: 'mapbox-gl', mapbox_access_token: 'pk.test' } });
env.offline = true;
env.dark = true;
render(<MapViewAuto places={[]} />);
await screen.findByTestId('gl-map');
expect(seen.gl).toMatchObject({ engine: 'maplibre', glProvider: 'maplibre-gl', styleOverride: OFM_DARK });
expect(pill()).toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-OFFLINE-003: a MapLibre map already on a stored style stays as it is', async () => {
seedStore(useSettingsStore, { settings: { map_provider: 'maplibre-gl', maplibre_style: OFM_POSITRON } });
env.offline = true;
render(<MapViewAuto places={[]} />);
await screen.findByTestId('gl-map');
expect(seen.gl?.styleOverride).toBeUndefined();
expect(pill()).not.toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-OFFLINE-004: offline, Leaflet on OpenStreetMap tiles switches to the stored basemap', () => {
seedStore(useSettingsStore, { settings: { map_provider: 'leaflet' } });
env.offline = true;
render(<MapViewAuto places={[]} tileUrl="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />);
expect(seen.leaflet?.tileUrl).toBe(OFM_POSITRON);
expect(pill()).toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-OFFLINE-005: offline, a raster template the service worker stored stays', () => {
seedStore(useSettingsStore, { settings: { map_provider: 'leaflet' } });
env.offline = true;
render(<MapViewAuto places={[]} tileUrl="https://tiles.example.com/{z}/{x}/{y}.png" />);
expect(seen.leaflet?.tileUrl).toBe('https://tiles.example.com/{z}/{x}/{y}.png');
expect(pill()).not.toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-OFFLINE-006: in the iOS app with its tile store, Mapbox stays offline and the pill switches to the stored map', async () => {
seedStore(useSettingsStore, { settings: { map_provider: 'mapbox-gl', mapbox_access_token: 'pk.test' } });
env.offline = true;
env.tileStore = true;
render(<MapViewAuto places={[]} />);
await screen.findByTestId('gl-map');
expect(seen.gl).toMatchObject({ engine: 'mapbox', styleOverride: undefined });
const toggle = screen.getByRole('button', { pressed: false });
fireEvent.click(toggle);
await screen.findByTestId('gl-map');
expect(seen.gl).toMatchObject({ engine: 'maplibre', styleOverride: OFM_POSITRON });
expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-OFFLINE-007: with the tile store, a raster template stays offline in the app too', () => {
seedStore(useSettingsStore, { settings: { map_provider: 'leaflet' } });
env.offline = true;
env.tileStore = true;
render(<MapViewAuto places={[]} tileUrl="https://tiles.example.com/{z}/{x}/{y}.png" />);
expect(seen.leaflet?.tileUrl).toBe('https://tiles.example.com/{z}/{x}/{y}.png');
});
});
+81 -13
View File
@@ -1,5 +1,5 @@
import { Suspense } from 'react'
import { AlertTriangle, Clock, Info } from 'lucide-react'
import { Suspense, useState } from 'react'
import { AlertTriangle, Clock, CloudOff, Info } from 'lucide-react'
import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
import { MapView } from './MapView'
@@ -9,15 +9,22 @@ import { useGlRenderer } from './glSupport'
import { useRoadtripHazards } from './useRoadtripHazards'
import { useTranslation } from '../../i18n/TranslationContext'
import type { MapViewAutoProps, MapViewProps } from './mapViewContract'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { useIsDark } from '../../hooks/useIsDark'
import { OFM_POSITRON } from '../../constants/mapDefaults'
import { glWorksOffline, leafletWorksOffline, offlineStyleFor } from './offlineBasemap'
import { tileCacheActive } from '../../native/tileCache'
// Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
//
// Offline maps: only the Leaflet renderer supports full pre-download (raster
// tiles via sync/tilePrefetcher.ts). GL maps are best-effort offline — their
// vector tiles are cached opportunistically by the Service Worker as you view
// them online (see the GL tile rules in vite.config.js), not prefetched.
// Offline maps: "prepare for offline" stores the OpenFreeMap basemap for every
// trip, whatever the provider (sync/tilePrefetcher.ts). Offline, a map whose own
// basemap cannot be drawn from storage (Mapbox, another OpenFreeMap style, a
// raster template the device cannot serve) switches to that stored basemap,
// with the same markers, routes and tracks, and says so with a small pill. It
// switches back once the connection returns. See offlineBasemap.ts.
export function MapViewAuto(props: MapViewAutoProps) {
const { t } = useTranslation()
const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely)
@@ -32,7 +39,31 @@ export function MapViewAuto(props: MapViewAutoProps) {
// Fall back to Leaflet when Mapbox is selected but no token is set, or when the
// browser has no WebGL2 (both GL engines need it), so the trip planner never
// shows an empty map or one that fails to start.
const glProvider = useGlRenderer(provider, token)
const configuredGl = useGlRenderer(provider, token)
const maplibreStyle = useSettingsStore(s => s.settings.maplibre_style)
const { offline } = useNetworkMode()
const dark = useIsDark()
const offlineStyle = offlineStyleFor(dark)
// In the iOS app with its tile store, a Mapbox map keeps what was viewed for 30
// days, so offline it stays Mapbox, and the pill offers the stored trip region
// instead for the places never looked at. Everywhere else a basemap with
// nothing stored is swapped for the stored one straight away.
const nativeStore = tileCacheActive()
const [preferStored, setPreferStored] = useState(false)
const mapboxKept = configuredGl === 'mapbox-gl' && nativeStore
const glFallback = !!configuredGl && offline && !glWorksOffline(configuredGl, maplibreStyle) && (!mapboxKept || preferStored)
const glProvider = glFallback ? 'maplibre-gl' : configuredGl
const leafletFallback = !configuredGl && offline && !leafletWorksOffline(mapProps.tileUrl ?? OFM_POSITRON, nativeIosWithoutStore(nativeStore))
if (leafletFallback) mapProps.tileUrl = offlineStyle
const pill = offline && mapboxKept
? (
<OfflineBasemapPill
label={preferStored ? t('map.offlineBasemap') : t('map.offlineShowStored')}
pressed={preferStored}
onToggle={() => setPreferStored(v => !v)}
/>
)
: (glFallback || leafletFallback) && <OfflineBasemapPill label={t('map.offlineBasemap')} />
// One chunk per engine: picking the binding here is what keeps mapbox-gl and
// maplibre-gl out of each other's downloads.
const MapViewGL = glProvider === 'maplibre-gl' ? MapViewGLMaplibre : MapViewGLMapbox
@@ -45,12 +76,49 @@ export function MapViewAuto(props: MapViewAutoProps) {
// to Leaflet keeps a usable map instead of an error card.
// resetKeys: with two engine chunks, a failure under one provider must not
// keep showing Leaflet after the user switches to the other.
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...mapProps} />}>
<Suspense fallback={<MapView {...mapProps} />}>
<MapViewGL {...mapProps} glProvider={glProvider} />
</Suspense>
</ErrorBoundary>
<>
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...mapProps} />}>
<Suspense fallback={<MapView {...mapProps} />}>
<MapViewGL {...mapProps} glProvider={glProvider} styleOverride={glFallback ? offlineStyle : undefined} />
</Suspense>
</ErrorBoundary>
{pill}
</>
)
}
return <MapView {...mapProps} />
return (
<>
<MapView {...mapProps} />
{pill}
</>
)
}
/** A raster template cannot be served offline in the iOS app unless it has its tile store. */
function nativeIosWithoutStore(nativeStore: boolean): boolean | undefined {
return nativeStore ? false : undefined
}
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
* onToggle it is a switch between the user's map (as far as it was viewed and
* stored) and the stored trip region.
*/
function OfflineBasemapPill({ label, pressed, onToggle }: { label: string; pressed?: boolean; onToggle?: () => void }) {
if (onToggle) {
return (
<button type="button" onClick={onToggle} aria-pressed={pressed} className={`${PILL} pointer-events-auto`}>
<CloudOff size={13} strokeWidth={2.2} aria-hidden="true" />
{label}
</button>
)
}
return (
<div role="status" className={`${PILL} pointer-events-none`}>
<CloudOff size={13} strokeWidth={2.2} aria-hidden="true" />
{label}
</div>
)
}
+10 -62
View File
@@ -27,6 +27,7 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import { buildGlMap, initialMapStyle } from './glSupport'
import { mapboxTileCacheRequest, tileCacheActive } from '../../native/tileCache'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useGeolocation } from '../../hooks/useGeolocation'
@@ -49,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'
@@ -100,6 +102,8 @@ const NO_DAYS: Day[] = []
/** The GL renderer: the shared map contract plus the engine it is bound to. */
interface Props extends MapViewProps {
glProvider?: GlMapProvider
/** Draws this style instead of the user's: the offline basemap (offlineBasemap.ts). */
styleOverride?: string
/**
* The GL engine, injected instead of imported. Both SDKs used to be pulled in
* statically here, so a single 2.8 MB chunk carried mapbox-gl and maplibre-gl
@@ -125,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
@@ -210,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.
*
@@ -482,6 +426,7 @@ export function MapViewGL({
onRemoveVia,
onViewportChange,
glProvider = 'mapbox-gl',
styleOverride,
gl,
onMapReady,
...assistant
@@ -506,7 +451,7 @@ export function MapViewGL({
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
const mapLang = useSettingsStore(s => s.settings.language)
const isMapLibre = glProvider === 'maplibre-gl'
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
const glStyle = styleOverride ?? styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
const enableMapbox3d = !isMapLibre && mapbox3d
const [mapReady, setMapReady] = useState(false)
const [readyMap, setReadyMap] = useState<FerrataGlMap | null>(null) // the ready map, read without touching mapRef
@@ -1015,6 +960,9 @@ export function MapViewGL({
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
// In the iOS app, Mapbox's own resources go through the app's tile store, so
// what was viewed stays on the device for up to 30 days (native/tileCache.ts).
if (!isMapLibre && tileCacheActive()) mapOptions.transformRequest = mapboxTileCacheRequest
// MapLibre's around-center rotate ping-pongs near mid-screen (#1545); false is mapbox-gl's dx rotate.
if (isMapLibre) mapOptions.aroundCenter = false
@@ -146,6 +146,17 @@ describe('PoiCategoryPill', () => {
}
})
it('FE-COMP-POIPILL-013b: in the phone chrome the bar wears the day rail glass at its 36px height', () => {
pill({ categories: WITH_PLUGINS, fullWidth: true, phoneChrome: true })
for (const button of screen.getAllByRole('button')) {
expect(button.style.minWidth).toBe('34px')
expect(button.style.height).toBe('28px')
}
const bar = screen.getAllByRole('button')[0].closest('[style*="overflow"]') as HTMLElement
expect(bar.style.padding).toBe('3px')
expect(bar.style.background).toBe('var(--m-glass)')
})
it('FE-COMP-POIPILL-014: a desktop segment keeps its 34px instead of shrinking in a narrow corridor', () => {
pill({ categories: WITH_PLUGINS })
for (const button of screen.getAllByRole('button')) {
+20 -5
View File
@@ -18,10 +18,23 @@ interface Props {
* The phone map gives it the full width between the screen margins; on
* desktop it floats, so it stays content-width there. */
fullWidth?: boolean
/** The phone shell's chrome: the glass, hairline and 36px height of the day rail
* and the tab switch above it, so the three bars read as one set. */
phoneChrome?: boolean
}
// The touch target every segment keeps, however many categories there are.
const SEGMENT = 34
// On the phone the segment is as tall as a day chip; it keeps its width.
const PHONE_SEGMENT_HEIGHT = 28
/** The phone shell's glass, as the day rail draws it (bg/border --m-glass/--m-gbr). */
const PHONE_GLASS: React.CSSProperties = {
background: 'var(--m-glass)',
border: '1px solid var(--m-gbr)',
backdropFilter: 'blur(24px) saturate(170%)',
WebkitBackdropFilter: 'blur(24px) saturate(170%)',
}
// How far one wheel step moves the bar. Firefox counts a mouse wheel in lines and
// some drivers in pages; a line moves about one segment, a page one bar width.
@@ -50,10 +63,11 @@ interface SegmentProps {
loading: boolean
failed: boolean
fullWidth?: boolean
phoneChrome?: boolean
onToggle: (key: string) => void
}
function Segment({ cat, on, loading, failed, fullWidth, onToggle }: Readonly<SegmentProps>) {
function Segment({ cat, on, loading, failed, fullWidth, phoneChrome, onToggle }: Readonly<SegmentProps>) {
return (
<Tooltip label={cat.label} placement="bottom">
<button
@@ -71,7 +85,7 @@ function Segment({ cat, on, loading, failed, fullWidth, onToggle }: Readonly<Seg
flexShrink: fullWidth ? 1 : 0,
flexBasis: fullWidth ? 0 : undefined,
minWidth: SEGMENT,
width: fullWidth ? 'auto' : SEGMENT, height: SEGMENT, borderRadius: 999, border: 'none', cursor: 'pointer',
width: fullWidth ? 'auto' : SEGMENT, height: phoneChrome ? PHONE_SEGMENT_HEIGHT : SEGMENT, borderRadius: 999, border: 'none', cursor: 'pointer',
background: on ? cat.color : 'transparent',
color: on ? '#fff' : undefined,
transition: 'background 0.14s, color 0.14s',
@@ -108,11 +122,11 @@ function Segment({ cat, on, loading, failed, fullWidth, onToggle }: Readonly<Seg
// and whatever plugins add follows a thin divider (#1781); once that is more than the
// width holds, the bar scrolls sideways (by swipe, trackpad or mouse wheel) rather than
// shrinking a segment below the touch target.
export default function PoiCategoryPill({ categories, active, onToggle, loadingKeys, errorKeys, moved, onSearchArea, fullWidth }: Readonly<Props>) {
export default function PoiCategoryPill({ categories, active, onToggle, loadingKeys, errorKeys, moved, onSearchArea, fullWidth, phoneChrome }: Readonly<Props>) {
const { t } = useTranslation()
const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
const frosted: React.CSSProperties = {
const frosted: React.CSSProperties = phoneChrome ? PHONE_GLASS : {
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
@@ -132,6 +146,7 @@ export default function PoiCategoryPill({ categories, active, onToggle, loadingK
loading={loading}
failed={!!errorKeys?.has(cat.key)}
fullWidth={fullWidth}
phoneChrome={phoneChrome}
onToggle={onToggle}
/>
)
@@ -142,7 +157,7 @@ export default function PoiCategoryPill({ categories, active, onToggle, loadingK
<div onWheel={wheelSideways} style={{
display: fullWidth ? 'flex' : 'inline-flex',
alignSelf: fullWidth ? 'stretch' : undefined,
alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted,
alignItems: 'center', gap: 2, padding: phoneChrome ? 3 : 4, borderRadius: 999, pointerEvents: 'auto', ...frosted,
maxWidth: '100%', overflowX: 'auto', scrollbarWidth: 'none',
}}>
{categories.core.map(segment)}
+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: '' }
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
import { isOfflineForbiddenTemplate } from '../../utils/tileUrl';
import { glWorksOffline, leafletWorksOffline, offlineStyleFor } from './offlineBasemap';
describe('offlineBasemap', () => {
it('picks the stored style for the colour scheme', () => {
expect(offlineStyleFor(false)).toBe(OFM_POSITRON);
expect(offlineStyleFor(true)).toBe(OFM_DARK);
});
it('knows OpenStreetMap standard tiles may not be stored, sharded or not', () => {
expect(isOfflineForbiddenTemplate('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')).toBe(true);
expect(isOfflineForbiddenTemplate('https://tile.openstreetmap.org/{z}/{x}/{y}.png')).toBe(true);
expect(isOfflineForbiddenTemplate('https://tile.openstreetmap.de/{z}/{x}/{y}.png')).toBe(false);
expect(isOfflineForbiddenTemplate('not a url')).toBe(false);
});
it('lets only MapLibre on a stored style draw offline as configured', () => {
expect(glWorksOffline('maplibre-gl', OFM_POSITRON)).toBe(true);
expect(glWorksOffline('maplibre-gl', OFM_DARK)).toBe(true);
// The default MapLibre style is Liberty, whose sprite and glyphs are not stored.
expect(glWorksOffline('maplibre-gl', '')).toBe(false);
expect(glWorksOffline('mapbox-gl', OFM_POSITRON)).toBe(false);
});
it('keeps a stored raster template on the web, never in the iOS app or for OSM', () => {
expect(leafletWorksOffline(OFM_POSITRON, true)).toBe(true);
expect(leafletWorksOffline('https://tiles.openfreemap.org/styles/liberty', false)).toBe(false);
expect(leafletWorksOffline('https://tiles.example.com/{z}/{x}/{y}.png', false)).toBe(true);
expect(leafletWorksOffline('https://tiles.example.com/{z}/{x}/{y}.png', true)).toBe(false);
expect(leafletWorksOffline('https://tile.openstreetmap.org/{z}/{x}/{y}.png', false)).toBe(false);
});
});
@@ -0,0 +1,42 @@
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
import { isNativeIos } from '../../native/platform';
import { isOfflineForbiddenTemplate, isVectorStyle } from '../../utils/tileUrl';
import { normalizeStyleForProvider, type GlMapProvider } from './glProviders';
/**
* The basemap a map falls back to offline, so that whatever the user picked
* online (Mapbox, a raster template, another OpenFreeMap style) they never
* face a grey map once the connection goes.
*
* The two OpenFreeMap styles below are the ones "prepare for offline" stores
* for every trip, whatever the configured provider (sync/tilePrefetcher.ts):
* both share one planet tile set, so the dark one only adds its style, sprite
* and glyphs. A fallback has to be exactly a style that was stored, or its
* sprite and labels are missing offline.
*/
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;
}
/**
* 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
* OpenFreeMap style lacks its sprite and glyphs offline.
*/
export function glWorksOffline(provider: GlMapProvider, maplibreStyle: string | null | undefined): boolean {
return provider === 'maplibre-gl' && OFFLINE_STYLES.includes(normalizeStyleForProvider('maplibre-gl', maplibreStyle));
}
/**
* Whether the Leaflet map's basemap can be drawn offline as configured. A
* stored vector style can. A raster template can where the service worker
* serves the prefetched tiles, which the iOS app's WebView does not run for a
* self-hosted server, and never for a host whose tiles may not be stored.
*/
export function leafletWorksOffline(tileUrl: string, nativeIos: boolean = isNativeIos()): boolean {
if (isVectorStyle(tileUrl)) return OFFLINE_STYLES.includes(tileUrl);
return !nativeIos && !isOfflineForbiddenTemplate(tileUrl);
}
@@ -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 };
}
@@ -1,4 +1,5 @@
import { createPortal } from 'react-dom'
import { useSharedImportStore } from '../../native/sharedFiles'
import { useState, useRef, useEffect } from 'react'
import { Upload, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
@@ -35,6 +36,9 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
const mouseDownTarget = useRef<EventTarget | null>(null)
const [files, setFiles] = useState<File[]>([])
useEffect(() => {
if (isOpen && useSharedImportStore.getState().file) useSharedImportStore.getState().take()
}, [isOpen])
const [isDragOver, setIsDragOver] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -43,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)
@@ -50,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
}
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { dayRouteTargets, generateAppleMapsUrl, generateGeoUrl, generateWazeUrl } from './dayRouteApps';
const A = { lat: 35.71, lng: 139.79, name: 'Senso-ji' };
const B = { lat: 35.72, lng: 139.77, name: 'Ueno Park' };
const C = { lat: 35.66, lng: 139.7, name: 'Shibuya' };
function userAgent(ua: string) {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(ua);
}
afterEach(() => vi.restoreAllMocks());
describe('dayRouteApps', () => {
it('opens one stop in Apple Maps as a named pin', () => {
expect(generateAppleMapsUrl([A])).toBe('https://maps.apple.com/?ll=35.71,139.79&q=Senso-ji');
});
it('hands Apple Maps the whole day, the stops between as waypoints, in the travel mode', () => {
expect(generateAppleMapsUrl([A, B, C], 'walking')).toBe(
'https://maps.apple.com/directions?source=35.71,139.79&destination=35.66,139.7&waypoint=35.72,139.77&mode=walking'
);
});
it('sends Waze and geo: to the first stop, since they take one place', () => {
expect(generateWazeUrl([A, B])).toBe('https://waze.com/ul?ll=35.71,139.79&navigate=yes');
expect(generateGeoUrl([A, B])).toBe('geo:35.71,139.79?q=35.71,139.79(Senso-ji)');
expect(generateWazeUrl([])).toBeNull();
});
it('offers Apple Maps on an iPhone and marks the one-place apps for a multi-stop day', () => {
userAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)');
const targets = dayRouteTargets([A, B], 'driving');
expect(targets.map((t) => t.id)).toEqual(['google', 'apple', 'comaps', 'waze']);
expect(targets.find((t) => t.id === 'waze')?.firstStopOnly).toBe(true);
expect(targets.find((t) => t.id === 'google')?.firstStopOnly).toBeUndefined();
});
it('offers the generic map-app link on Android instead of Apple Maps', () => {
userAgent('Mozilla/5.0 (Linux; Android 15)');
expect(dayRouteTargets([A], 'driving').map((t) => t.id)).toEqual(['google', 'comaps', 'waze', 'geo']);
// A single stop is the whole day, so nothing is cut short.
expect(dayRouteTargets([A], 'driving').some((t) => t.firstStopOnly)).toBe(false);
});
it('offers nothing for a day without a located stop', () => {
expect(dayRouteTargets([], 'driving')).toEqual([]);
});
});
@@ -0,0 +1,87 @@
import {
generateCoMapsUrl,
generateGoogleMapsUrl,
type NamedWaypoint,
type RouteProfileKey,
} from '../Map/RouteCalculator';
import { showsAppleMaps, showsGeoUri, type NavigationAppId } from './placeNavigation';
/**
* The map apps a day's route can be handed to, behind the plan's one Maps
* button. Same stops as every other export (dayExportStops), so the app shows
* the day the plan shows.
*
* Not every app takes a whole route. Google Maps, Apple Maps and CoMaps do (each
* in its own way, see the builders); Waze and the generic geo: link navigate to
* one place only, so they lead to the first stop and say so rather than quietly
* dropping the rest of the day.
*/
export interface DayRouteTarget {
id: NavigationAppId;
/** Product name, never translated; the generic entry carries labelKey instead. */
label: string;
labelKey?: string;
url: string;
/** True when the app only gets the first stop, not the whole route. */
firstStopOnly?: boolean;
}
/** Apple Maps' directions modes. Cycling exists there; anything else drives. */
function appleMode(profile: RouteProfileKey): string {
if (profile === 'walking') return 'walking';
if (profile === 'cycling') return 'cycling';
return 'driving';
}
const point = (p: NamedWaypoint) => `${p.lat},${p.lng}`;
const label = (p: NamedWaypoint) => encodeURIComponent(p.name?.trim() || point(p));
/**
* Apple Maps for a day: one stop opens as a pin with its name, more go as
* directions with the stops between as waypoints (Apple's unified Maps URLs,
* which iOS, macOS and the web version all read).
*/
export function generateAppleMapsUrl(places: NamedWaypoint[], profile: RouteProfileKey = 'driving'): string | null {
const valid = places.filter((p) => p.lat != null && p.lng != null);
if (valid.length === 0) return null;
if (valid.length === 1) return `https://maps.apple.com/?ll=${point(valid[0])}&q=${label(valid[0])}`;
const [first, ...rest] = valid;
const last = rest[rest.length - 1];
const between = rest
.slice(0, -1)
.map((p) => `&waypoint=${point(p)}`)
.join('');
return `https://maps.apple.com/directions?source=${point(first)}&destination=${point(last)}${between}&mode=${appleMode(profile)}`;
}
/** Waze navigates to one place; the day's first stop is where the drive starts. */
export function generateWazeUrl(places: NamedWaypoint[]): string | null {
const first = places.find((p) => p.lat != null && p.lng != null);
return first ? `https://waze.com/ul?ll=${point(first)}&navigate=yes` : null;
}
/** The generic Android link, handed to whichever map app is installed; one place too. */
export function generateGeoUrl(places: NamedWaypoint[]): string | null {
const first = places.find((p) => p.lat != null && p.lng != null);
return first ? `geo:${point(first)}?q=${point(first)}(${label(first)})` : null;
}
/** Every app the day can be opened in on this device, in the order they are offered. */
export function dayRouteTargets(stops: NamedWaypoint[], profile: RouteProfileKey): DayRouteTarget[] {
const out: DayRouteTarget[] = [];
const push = (target: Omit<DayRouteTarget, 'url'>, url: string | null) => {
if (url) out.push({ ...target, url });
};
push({ id: 'google', label: 'Google Maps' }, generateGoogleMapsUrl(stops));
if (showsAppleMaps()) push({ id: 'apple', label: 'Apple Maps' }, generateAppleMapsUrl(stops, profile));
push({ id: 'comaps', label: 'CoMaps' }, generateCoMapsUrl(stops, profile));
const located = stops.filter((p) => p.lat != null && p.lng != null).length;
push({ id: 'waze', label: 'Waze', firstStopOnly: located > 1 }, generateWazeUrl(stops));
if (showsGeoUri()) {
push(
{ id: 'geo', label: 'geo:', labelKey: 'inspector.otherMapApp', firstStopOnly: located > 1 },
generateGeoUrl(stops)
);
}
return out;
}
@@ -1,4 +1,5 @@
import React from 'react'
import { selectionHaptic } from '../../native/haptics'
/** The track and the knob glide with the same easing the planner's panels use. */
const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
@@ -10,7 +11,7 @@ const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
*/
export default function ToggleSwitch({ on, onToggle, label, describedBy, disabled = false }: { on: boolean; onToggle: () => void; label?: string; describedBy?: string; disabled?: boolean }) {
return (
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label} aria-describedby={describedBy}
<button type="button" onClick={() => { selectionHaptic(); onToggle() }} aria-pressed={on} aria-label={label} aria-describedby={describedBy}
disabled={disabled} aria-disabled={disabled || undefined}
// A keyboard focus ring in the text colour, like the fields; the pointer gets none.
className="group outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)]"
@@ -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>
@@ -101,12 +101,15 @@ describe('ContextMenu', () => {
expect(onClose).toHaveBeenCalledTimes(2)
})
it('FE-W4CTX-010: flips the menu back inside the viewport when it would overflow', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
width: 200, height: 120, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}),
} as DOMRect)
function layoutSize(width: number, height: number) {
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(width)
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(height)
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 400 })
Object.defineProperty(window, 'innerHeight', { writable: true, configurable: true, value: 300 })
}
it('FE-W4CTX-010: flips the menu back inside the viewport when it would overflow', () => {
layoutSize(200, 120)
const { baseElement } = render(<ContextMenu menu={{ x: 380, y: 290, items: [{ label: 'Open' }] }} onClose={() => {}} />)
@@ -115,6 +118,18 @@ describe('ContextMenu', () => {
expect(menu.style.top).toBe('172px')
})
it('FE-W4CTX-010b: keeps an end-aligned menu off the left edge, and caps one taller than the screen', () => {
layoutSize(200, 500)
const { baseElement } = render(<ContextMenu menu={{ x: 40, y: 20, alignEnd: true, items: [{ label: 'Open' }] }} onClose={() => {}} />)
const menu = baseElement.querySelector('.trek-popover-enter') as HTMLElement
expect(menu.style.left).toBe('8px')
expect(menu.style.top).toBe('8px')
expect(menu.style.maxHeight).toBe('284px')
expect(menu.style.overflowY).toBe('auto')
})
it('FE-W4CTX-011: Escape, a scroll and a resize close it', () => {
const onClose = vi.fn()
render(<ContextMenu menu={{ x: 0, y: 0, items: [{ label: 'Open' }] }} onClose={onClose} />)
+27 -9
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useLayoutEffect, useRef, type ComponentType, type CSSProperties } from 'react'
import { createPortal } from 'react-dom'
import { LucideIcon } from 'lucide-react'
import { useLastOpen, usePresence } from './usePresence'
interface MenuItem {
label?: string
@@ -50,6 +51,13 @@ interface ContextMenuProps {
}
export function ContextMenu({ menu, onClose }: ContextMenuProps) {
const { mounted, closing } = usePresence(menu != null, 130)
// The items stay on screen while the menu fades, after its owner set it to null.
const shown = useLastOpen(menu, menu != null)
return mounted && shown ? <ContextMenuFrame menu={shown} onClose={onClose} closing={closing} /> : null
}
function ContextMenuFrame({ menu, onClose, closing }: { menu: MenuState; onClose: () => void; closing: boolean }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -97,21 +105,31 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
useLayoutEffect(() => {
if (!menu || !ref.current) return
const el = ref.current
const rect = el.getBoundingClientRect()
// The layout size, not getBoundingClientRect: the menu is still mid enter
// animation (scaled down) here, and the smaller box let a menu opened near an
// edge end up partly off screen.
const width = el.offsetWidth
const viewW = window.innerWidth
const viewH = window.innerHeight
// Taller than the screen (a long list on a phone): cap it and let it scroll.
if (el.offsetHeight > viewH - 16) {
el.style.maxHeight = `${viewH - 16}px`
el.style.overflowY = 'auto'
}
const height = Math.min(el.offsetHeight, viewH - 16)
let { x, y } = menu
if (menu.alignEnd) x = Math.max(8, x - rect.width)
if (x + rect.width > window.innerWidth - 8) x = window.innerWidth - rect.width - 8
if (y + rect.height > window.innerHeight - 8) y = window.innerHeight - rect.height - 8
if (menu.alignEnd) x = x - width
// Kept inside all four edges with 8px to spare.
x = Math.min(Math.max(8, x), viewW - width - 8)
y = Math.min(Math.max(8, y), viewH - height - 8)
if (x !== menu.x || y !== menu.y) {
el.style.left = `${x}px`
el.style.top = `${y}px`
el.style.left = `${Math.max(8, x)}px`
el.style.top = `${Math.max(8, y)}px`
}
}, [menu])
if (!menu) return null
return createPortal(
<div ref={ref} role="menu" tabIndex={-1} onKeyDown={moveFocus} className="trek-popover-enter" style={{
<div ref={ref} role="menu" tabIndex={-1} onKeyDown={moveFocus} className={`trek-popover-enter ${closing ? 'trek-popover-exit pointer-events-none' : ''}`} style={{
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
border: '1px solid var(--border-primary)',
@@ -298,6 +298,7 @@ export function CustomDatePicker({
<div style={{ display: 'flex', gap: 4, alignItems: 'stretch' }}>
{/* Calendar trigger */}
<button
data-field-trigger={compact || borderless ? undefined : ''}
type="button"
onClick={() => setOpen((o) => !o)}
disabled={disabled}
@@ -345,6 +346,7 @@ export function CustomDatePicker({
{/* Keyboard / text-input trigger (non-compact only; compact gets it in the popup footer) */}
{!compact && !borderless && (
<button
data-field-trigger=""
type="button"
onClick={() => {
setTextInput(inputValue || '');

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