Compare commits

...

25 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
199 changed files with 5212 additions and 1453 deletions
+29
View File
@@ -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
+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.
@@ -26,6 +26,12 @@
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 */; };
@@ -97,6 +103,12 @@
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 */
@@ -212,6 +224,12 @@
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;
@@ -382,6 +400,12 @@
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;
};
@@ -29,8 +29,20 @@ class TrekBridgeViewController: CAPBridgeViewController {
return configuration
}
/// Kept alive here: WKWebView holds its navigation delegate weakly.
private var navigationProxy: TrekNavigationProxy?
override func capacitorDidLoad() {
TrekTileStore.shared.prune()
// Capacitor shows its error page for every failed navigation, a replaced
// one included: the UI moving to /login while the first page still loads
// cancels that load (-999), and a first-time user landed on "Server not
// reachable" with a server that was there all along.
if let webView, let inner = webView.navigationDelegate {
let proxy = TrekNavigationProxy(inner: inner)
navigationProxy = proxy
webView.navigationDelegate = proxy
}
bridge?.registerPluginInstance(TrekShellPlugin())
// WebKit's own swipe back slides away a screenshot of the whole page,
// closes no sheet and takes every drag near the edge from the UI. The UI
@@ -272,3 +284,39 @@ final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
task.didFinish()
}
}
/// Stands in front of Capacitor's navigation delegate and hands it everything
/// but the failures of a navigation that another one replaced: a cancelled
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
/// are no fault, and Capacitor would answer them with its error page.
final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
private let inner: WKNavigationDelegate
init(inner: WKNavigationDelegate) {
self.inner = inner
}
static func isReplaced(_ error: Error) -> Bool {
let error = error as NSError
return (error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled)
|| (error.domain == "WebKitErrorDomain" && error.code == 102)
}
override func responds(to aSelector: Selector!) -> Bool {
super.responds(to: aSelector) || (inner as AnyObject).responds(to: aSelector)
}
override func forwardingTarget(for aSelector: Selector!) -> Any? {
(inner as AnyObject).responds(to: aSelector) ? inner : nil
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
if Self.isReplaced(error) { return }
inner.webView?(webView, didFail: navigation, withError: error)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
if Self.isReplaced(error) { return }
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
}
}
@@ -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)
}
}
+145 -88
View File
@@ -1,118 +1,175 @@
import SwiftUI
import WidgetKit
/// "Next trip" on the home screen: the trip and how far off it is, or while it
/// runs, today's day and the next stops (medium size).
struct NextTripEntry: TimelineEntry {
/// The home-screen and lock-screen widgets. Each reads the snapshot the app
/// last wrote (WidgetSnapshot) and draws it with the views in *Content.swift,
/// which know nothing of WidgetKit so the preview renderer can draw them too.
struct TrekEntry: TimelineEntry {
let date: Date
let snapshot: WidgetSnapshot?
}
struct NextTripProvider: TimelineProvider {
func placeholder(in context: Context) -> NextTripEntry {
NextTripEntry(date: Date(), snapshot: nil)
struct TrekProvider: TimelineProvider {
func placeholder(in context: Context) -> TrekEntry {
TrekEntry(date: Date(), snapshot: .sample(running: false))
}
func getSnapshot(in context: Context, completion: @escaping (NextTripEntry) -> Void) {
completion(NextTripEntry(date: Date(), snapshot: WidgetSnapshot.load()))
/// The gallery shows the user's own trip when the app has written one, a
/// sample trip before that.
func getSnapshot(in context: Context, completion: @escaping (TrekEntry) -> Void) {
let stored = WidgetSnapshot.load()
let snapshot: WidgetSnapshot? = (stored?.trip != nil || !context.isPreview) ? stored : WidgetSnapshot.sample(running: false)
completion(TrekEntry(date: Date(), snapshot: snapshot))
}
/// The app reloads the timeline whenever it writes a new snapshot; past
/// midnight the countdown moves on by itself.
func getTimeline(in context: Context, completion: @escaping (Timeline<NextTripEntry>) -> Void) {
/// An entry every quarter hour for the next six hours, so the now line and
/// the next stop move on without the app; the app reloads the timeline
/// whenever it writes a new snapshot.
func getTimeline(in context: Context, completion: @escaping (Timeline<TrekEntry>) -> Void) {
let snapshot = WidgetSnapshot.load()
let now = Date()
let midnight = Calendar.current.startOfDay(for: now.addingTimeInterval(86_400))
let entry = NextTripEntry(date: now, snapshot: WidgetSnapshot.load())
completion(Timeline(entries: [entry], policy: .after(midnight)))
let calendar = Calendar.current
let minute = calendar.component(.minute, from: now)
let firstStep = calendar.date(byAdding: .minute, value: 15 - minute % 15, to: now) ?? now
var entries = [TrekEntry(date: now, snapshot: snapshot)]
for step in 0..<24 {
if let date = calendar.date(byAdding: .minute, value: step * 15, to: firstStep) {
entries.append(TrekEntry(date: calendar.date(bySetting: .second, value: 0, of: date) ?? date, snapshot: snapshot))
}
}
completion(Timeline(entries: entries, policy: .atEnd))
}
}
struct NextTripWidgetView: View {
extension TrekFamily {
init(_ family: WidgetFamily) {
switch family {
case .systemSmall: self = .small
case .systemMedium: self = .medium
case .accessoryCircular: self = .circular
case .accessoryRectangular: self = .rectangular
case .accessoryInline: self = .inline
default: self = .large
}
}
}
/// The widget's ground: the content on its background, through the container
/// background on iOS 17 (which the system can tint and remove), the plain way before.
struct TrekContainer<Content: View, Background: View>: View {
@Environment(\.widgetFamily) private var family
let entry: NextTripEntry
let link: URL?
@ViewBuilder var content: () -> Content
@ViewBuilder var background: () -> Background
var body: some View {
content
.widgetURL(entry.snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) })
.modifier(WidgetBackground())
}
@ViewBuilder
private var content: some View {
if let snapshot = entry.snapshot, let trip = snapshot.trip {
VStack(alignment: .leading, spacing: 4) {
Text(snapshot.today == nil ? snapshot.labels.nextTrip : snapshot.labels.today)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(trip.title)
.font(.headline)
.lineLimit(2)
if let today = snapshot.today {
Text(today.dayLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
if family != .systemSmall {
stops(today.stops, nothing: snapshot.labels.nothingPlanned)
}
} else if let days = snapshot.daysUntilStart(from: entry.date), days > 0 {
Spacer(minLength: 0)
Text(days == 1 ? snapshot.labels.tomorrow : snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: String(days)))
.font(.title3.weight(.bold))
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading, spacing: 6) {
Text("TREK").font(.headline)
Text(entry.snapshot?.labels.noTrip ?? "")
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder
private func stops(_ stops: [WidgetSnapshot.Stop], nothing: String) -> some View {
if stops.isEmpty {
Text(nothing).font(.caption).foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(stops.prefix(3).enumerated()), id: \.offset) { _, stop in
HStack(spacing: 6) {
Text(stop.time ?? "")
.font(.caption2.monospacedDigit().weight(.semibold))
.foregroundStyle(.secondary)
.frame(minWidth: 36, alignment: .leading)
Text(stop.name).font(.caption).lineLimit(1)
}
let accessory = TrekFamily(family).isAccessory
Group {
if #available(iOSApplicationExtension 17.0, *) {
content().containerBackground(for: .widget) {
if accessory { Color.clear } else { background() }
}
} else {
content().padding(accessory ? 0 : 16).background(accessory ? nil : background())
}
}
.widgetURL(link)
}
}
/// The system background on iOS 17 and later, the plain one before.
struct WidgetBackground: ViewModifier {
func body(content: Content) -> some View {
if #available(iOSApplicationExtension 17.0, *) {
content.containerBackground(.fill.tertiary, for: .widget)
} else {
content.padding()
}
}
}
// MARK: - Trip
struct NextTripWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "NextTrip", provider: NextTripProvider()) { entry in
NextTripWidgetView(entry: entry)
// The kind stays "NextTrip" so widgets placed before the redesign keep working.
StaticConfiguration(kind: "NextTrip", provider: TrekProvider()) { entry in
TripWidgetView(entry: entry)
}
.configurationDisplayName("TREK")
.description("Your next trip, and today's plan while you travel.")
.supportedFamilies([.systemSmall, .systemMedium])
.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) }
}
@@ -1,12 +1,15 @@
import SwiftUI
import WidgetKit
/// The TREK widgets: the next trip on the home screen, and the travel day as a
/// Live Activity.
/// The TREK widgets: the trip, today's plan, the bookings ahead 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")
}
}
}
}
@@ -5,22 +5,86 @@ import Security
/// TrekShell.setWidgetSnapshot into the keychain group the app and its
/// extensions share). Texts arrive already translated, so the widget needs no
/// localisation of its own and always speaks the language the app does.
///
/// Everything past `trip`, `today` and `labels` is optional: a binary that is
/// newer than the server's UI shows what it gets, an older one ignores the rest.
struct WidgetSnapshot: Codable {
struct Trip: Codable {
var title: String
var start: String
var end: String
var path: String
/// The destination in a few words ("Tokyo, Japan").
var place: String?
/// The trip's cover, a small JPEG as base64, already cropped for a widget.
var cover: String?
var dayCount: Int?
var placeCount: Int?
var memberCount: Int?
}
struct Stop: Codable, Hashable {
var time: String?
var name: String
/// Minutes after midnight, for the now line.
var minutes: Int?
/// The category colour as #rrggbb.
var color: String?
/// An SF Symbol name for the stop's kind.
var symbol: String?
}
struct Today: Codable {
var dayLabel: String
var stops: [Stop]
/// 1-based position of today in the trip.
var index: Int?
var total: Int?
}
struct Weather: Codable {
var temp: Int
var high: Int?
var low: Int?
/// The provider's main condition ("Clear", "Clouds", "Rain", ...).
var main: String
var label: String
var place: String?
/// Chance of rain in percent.
var rain: Int?
}
struct Booking: Codable, Hashable {
var title: String
/// flight, train, bus, car, cruise, hotel, restaurant, event, tour, other
var kind: String
/// yyyy-MM-dd, for the relative day.
var date: String?
var time: String?
/// "FRA → HND", the location or the hotel's nights.
var detail: String?
var confirmed: Bool
var path: String
}
struct Todo: Codable, Hashable {
var name: String
var due: String?
/// 0 none, 1 low, 2 medium, 3 high.
var priority: Int
}
struct Todos: Codable {
var open: Int
var total: Int
var items: [Todo]
var path: String
}
struct Packing: Codable {
var packed: Int
var total: Int
var path: String
}
struct Labels: Codable {
@@ -31,10 +95,31 @@ struct WidgetSnapshot: Codable {
var inDays: String
var tomorrow: String
var nothingPlanned: String
var days: String?
var dayOf: String?
var bookings: String?
var noBookings: String?
var todos: String?
var noTodos: String?
var packing: String?
var packed: String?
var confirmed: String?
var pending: String?
var todayShort: String?
var tomorrowShort: String?
var now: String?
var places: String?
var travellers: String?
var allDone: String?
var openTrek: String?
}
var trip: Trip?
var today: Today?
var weather: Weather?
var bookings: [Booking]?
var todos: Todos?
var packing: Packing?
var labels: Labels
static func load() -> WidgetSnapshot? {
@@ -52,8 +137,20 @@ struct WidgetSnapshot: Codable {
/// Whole days from today to the trip's start, in the device's calendar.
func daysUntilStart(from now: Date = Date()) -> Int? {
guard let trip, let start = WidgetSnapshot.day(trip.start) else { return nil }
return WidgetSnapshot.daysBetween(now, start)
}
/// The trip's length in days, from its dates when the app sent no count.
var tripLength: Int? {
if let count = trip?.dayCount, count > 0 { return count }
guard let trip, let start = WidgetSnapshot.day(trip.start), let end = WidgetSnapshot.day(trip.end),
let days = WidgetSnapshot.daysBetween(start, end) else { return nil }
return days + 1
}
static func daysBetween(_ from: Date, _ to: Date) -> Int? {
let calendar = Calendar.current
return calendar.dateComponents([.day], from: calendar.startOfDay(for: now), to: start).day
return calendar.dateComponents([.day], from: calendar.startOfDay(for: from), to: calendar.startOfDay(for: to)).day
}
static func day(_ iso: String) -> Date? {
+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))
}
}
+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,
+20 -21
View File
@@ -166,31 +166,31 @@
"mobile/screens/admin/MAdminUsersSection.tsx": 14,
"mobile/screens/admin/MApiProviderBlocks.tsx": 10,
"mobile/screens/atlas/MAtlas.tsx": 5,
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 16,
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 14,
"mobile/screens/atlas/MAtlasCountryPopup.tsx": 11,
"mobile/screens/atlas/MAtlasSearch.tsx": 5,
"mobile/screens/atlas/MAtlasStatsCard.tsx": 3,
"mobile/screens/collections/MCollAddSheet.tsx": 7,
"mobile/screens/collections/MCollAddSheet.tsx": 6,
"mobile/screens/collections/MCollCategoryPicker.tsx": 2,
"mobile/screens/collections/MCollEditSheet.tsx": 5,
"mobile/screens/collections/MCollEditSheet.tsx": 2,
"mobile/screens/collections/MCollLabelsSheet.tsx": 6,
"mobile/screens/collections/MCollLinksEditor.tsx": 2,
"mobile/screens/collections/MCollListPickerSheet.tsx": 3,
"mobile/screens/collections/MCollPlaceRow.tsx": 3,
"mobile/screens/collections/MCollPlaceSheet.tsx": 11,
"mobile/screens/collections/MCollShareSheet.tsx": 14,
"mobile/screens/collections/MCollSheetKit.tsx": 6,
"mobile/screens/collections/MCollSheetKit.tsx": 5,
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
"mobile/screens/collections/MCollections.tsx": 21,
"mobile/screens/collections/MCollections.tsx": 18,
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
"mobile/screens/dashboard/MDashboard.tsx": 36,
"mobile/screens/dashboard/MNewTripSheet.tsx": 20,
"mobile/screens/dashboard/MDashboard.tsx": 31,
"mobile/screens/dashboard/MNewTripSheet.tsx": 12,
"mobile/screens/dashboard/MUserMenu.tsx": 7,
"mobile/screens/journey/MJourney.tsx": 10,
"mobile/screens/journey/MJourneyCreateSheet.tsx": 10,
"mobile/screens/journey/MJourneyCreateSheet.tsx": 7,
"mobile/screens/journey/MJourneyDetail.tsx": 6,
"mobile/screens/journey/MJourneyEntrySheet.tsx": 52,
"mobile/screens/journey/MJourneySettingsSheet.tsx": 32,
"mobile/screens/journey/MJourneyEntrySheet.tsx": 48,
"mobile/screens/journey/MJourneySettingsSheet.tsx": 2,
"mobile/screens/journey/mobileJourneyMeta.ts": 4,
"mobile/screens/notifications/MNotificationRow.tsx": 4,
"mobile/screens/notifications/MNotifications.tsx": 3,
@@ -201,13 +201,13 @@
"mobile/screens/settings/MMobileNavCustomizer.tsx": 8,
"mobile/screens/settings/MPhotoProvidersSection.tsx": 2,
"mobile/screens/settings/MScopeGroupPicker.tsx": 5,
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
"mobile/screens/settings/MSetPickerSheet.tsx": 1,
"mobile/screens/settings/MSettings.tsx": 2,
"mobile/screens/settings/MSettingsAbout.tsx": 5,
"mobile/screens/settings/MSettingsAccount.tsx": 20,
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
"mobile/screens/settings/MSettingsMap.tsx": 5,
"mobile/screens/settings/MSettingsMcp.tsx": 30,
"mobile/screens/settings/MSettingsMcp.tsx": 26,
"mobile/screens/settings/MSettingsNotifications.tsx": 5,
"mobile/screens/settings/MSettingsOffline.tsx": 18,
"mobile/screens/settings/MSettingsPlugins.tsx": 14,
@@ -216,9 +216,9 @@
"mobile/screens/trip/MTripLoadingSplash.tsx": 2,
"mobile/screens/trip/MTripShell.tsx": 9,
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 4,
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 2,
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7,
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 6,
"mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1,
"mobile/screens/trip/plan/MPlanTimeline.tsx": 12,
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 24,
@@ -235,8 +235,8 @@
"mobile/screens/trip/sheets/ImpFileStep.tsx": 12,
"mobile/screens/trip/sheets/ImpListStep.tsx": 3,
"mobile/screens/trip/sheets/MAccommodationSheet.tsx": 10,
"mobile/screens/trip/sheets/MBookingFields.tsx": 6,
"mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 5,
"mobile/screens/trip/sheets/MBookingFields.tsx": 5,
"mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 3,
"mobile/screens/trip/sheets/MCostSheet.tsx": 33,
"mobile/screens/trip/sheets/MDaySheet.tsx": 22,
"mobile/screens/trip/sheets/MDaysSheet.tsx": 4,
@@ -251,11 +251,10 @@
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
"mobile/screens/trip/sheets/MTripSheetUi.tsx": 5,
"mobile/screens/trip/sheets/MTripSheets.tsx": 2,
"mobile/screens/trip/sheets/PlCategoryPicker.tsx": 2,
"mobile/screens/trip/sheets/PlFileAttach.tsx": 7,
"mobile/screens/trip/sheets/PlPlaceSearch.tsx": 6,
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 5,
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
@@ -280,10 +279,10 @@
"mobile/screens/trip/tabs/filesModel.ts": 5,
"mobile/screens/trip/tabs/tabChrome.tsx": 12,
"mobile/screens/vacay/MVacay.tsx": 17,
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
"mobile/screens/vacay/MVacayInviteSheet.tsx": 8,
"mobile/screens/vacay/MVacayMonth.tsx": 3,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 34,
"mobile/screens/vacay/MVacayShareSheet.tsx": 10,
"mobile/screens/vacay/vacayDayModel.ts": 1,
"pages/AtlasPage.tsx": 32,
"pages/DashboardPage.tsx": 1,
+1 -1
View File
@@ -35,7 +35,7 @@ import OfflineBanner from './components/Layout/OfflineBanner'
import NewVersionNotice from './components/Layout/NewVersionNotice'
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
import AppLockGate from './components/Layout/AppLockGate'
import NativeImportSheet from './components/Layout/NativeImportSheet'
import NativeImportSheet from './mobile/components/NativeImportSheet'
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel'
+20
View File
@@ -12,6 +12,8 @@ const { probeNow } = vi.hoisted(() => ({
probeNow: vi.fn(async (): Promise<'online' | 'offline' | 'proxy-wall'> => 'offline'),
}))
vi.mock('../sync/connectivity', () => ({ probeNow }))
const isNativeApp = vi.hoisted(() => vi.fn(() => false))
vi.mock('../native/platform', () => ({ isNativeApp }))
const { apiClient, adminApi, mapsApi, pluginsApi, parseInDev } = await import('./client')
@@ -357,6 +359,24 @@ describe('client > redirect handling', () => {
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/trips/7?tab=map#day-2'))
})
it('FE-APIWIRE-019b: in the app it waits for the first page to finish loading before it leaves', async () => {
isNativeApp.mockReturnValue(true)
const ready = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState')
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true })
const loc = setLocation('/dashboard')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 })))
await captureError(() => apiClient.get('/auth/me'))
expect(loc.href).not.toContain('/login')
window.dispatchEvent(new Event('load'))
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/dashboard'))
delete (document as { readyState?: unknown }).readyState
if (ready) Object.defineProperty(Document.prototype, 'readyState', ready)
isNativeApp.mockReturnValue(false)
})
it('FE-APIWIRE-020: an MFA_REQUIRED 403 sends the user to the settings page', async () => {
const loc = setLocation('/dashboard')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 })))
+4 -4
View File
@@ -70,13 +70,13 @@ import {
RoadtripVia,
RoadtripViaBatchRequest,
RoadtripViaCreateRequest,
RoadtripViaReanchorRequest,
RoadtripViaUpdateRequest,
RoadtripViaReanchorRequest, RoadtripViaUpdateRequest,
} from '@trek/shared'
import { getSocketId } from './websocket'
import { probeNow } from '../sync/connectivity'
import { downloadBlob } from '../utils/fileDownload'
import { translateRateLimit } from './rateLimitMessage'
import { hardNavigate } from './hardNavigate'
/**
* Validate a response payload against its @trek/shared Zod schema — but only in
@@ -218,7 +218,7 @@ apiClient.interceptors.response.use(
const { pathname } = window.location
if (!isAuthPublicPath(pathname)) {
const currentPath = pathname + window.location.search + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
hardNavigate('/login?redirect=' + encodeURIComponent(currentPath))
}
}
if (
@@ -226,7 +226,7 @@ apiClient.interceptors.response.use(
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
!window.location.pathname.startsWith('/settings')
) {
window.location.href = '/settings?mfa=required'
hardNavigate('/settings?mfa=required')
}
if (error.response?.status === 429) {
const translated = translateRateLimit()
+15
View File
@@ -0,0 +1,15 @@
import { isNativeApp } from '../native/platform';
/**
* A full page load to `url`. In the apps it waits for the first page to finish
* loading: started earlier, it cancels that load, and the shell answers a
* cancelled load with its "Server not reachable" page, which is where a
* signed-out user opening the app for the first time ended up.
*/
export function hardNavigate(url: string): void {
const go = () => {
window.location.href = url;
};
if (isNativeApp() && document.readyState !== 'complete') window.addEventListener('load', go, { once: true });
else go();
}
@@ -20,7 +20,7 @@ export default function MSaveToCollectionSheet() {
{/* Header — mirrors the place detail sheet: icon + title + target name + close */}
<div className="flex-none px-[18px] pt-4">
<div className="flex items-start gap-3">
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-[14px] bg-[color:var(--m-ic)] text-m-muted">
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
<Bookmark size={18} strokeWidth={2} />
</span>
<div className="min-w-0 flex-1">
@@ -12,7 +12,7 @@ vi.mock('../../native/appLock', () => ({
}));
function stateListener(): (state: { isActive: boolean }) => void {
const call = vi.mocked(App.addListener).mock.calls.find(([name]) => name === 'appStateChange');
const call = vi.mocked(App.addListener).mock.calls.find(([name]) => (name as string) === 'appStateChange');
if (!call) throw new Error('no appStateChange listener');
return call[1] as unknown as (state: { isActive: boolean }) => void;
}
+9 -4
View File
@@ -28,11 +28,16 @@ export default function AppLockGate(): React.ReactElement | null {
if (ok) setLocked(false);
}, [t]);
// At launch, once the capabilities are known.
// At launch, once the capabilities are known: locked in the same render the
// capabilities arrive in, so nothing of the app shows before the prompt.
const [capsSeen, setCapsSeen] = useState<typeof caps | undefined>(undefined);
if (caps !== capsSeen) {
setCapsSeen(caps);
if (isAppLockEnabled()) setLocked(true);
}
useEffect(() => {
if (!isAppLockEnabled()) return;
setLocked(true);
void ask();
// Asked after the effect, not inside it: the prompt's answer sets state.
if (isAppLockEnabled()) void Promise.resolve().then(ask);
}, [caps, ask]);
// Back from the background after a while.
@@ -1,26 +1,120 @@
/**
* NativeWidgetsSync: keeps the iOS home-screen widget and the travel-day Live
* Activity in step with the trips stored on this device (native/widgets.ts).
* Runs at start, whenever the app comes back to the foreground and every
* quarter hour while it is open, so the next stop moves on through the day.
* Renders nothing; outside an iOS binary with the widgets it does nothing.
* NativeWidgetsSync: keeps the iOS widgets and the travel-day Live Activity in
* step with the trips stored on this device (native/widgets.ts). Runs at start,
* whenever the app comes back to the foreground and every quarter hour while it
* is open, so the next stop moves on through the day. Renders nothing; outside
* an iOS binary with the widgets it does nothing.
*/
import { App } from '@capacitor/app';
import { useEffect } from 'react';
import { offlineDb } from '../../db/offlineDb';
import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState, widgetsActive } from '../../native/widgets';
import { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
import {
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;
@@ -29,27 +123,15 @@ export default function NativeWidgetsSync(): null {
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 day = trip
? todayOf(
trip,
days,
today,
(n, m) => t('native.widget.dayOf', { n, m }),
(time) => formatTime(time, language, timeFormat)
)
: null;
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, {
nextTrip: t('dashboard.nextTrip'),
today: t('native.widget.today'),
noTrip: t('native.widget.noTrip'),
inDays: t('native.widget.inDays'),
tomorrow: t('native.widget.tomorrow'),
nothingPlanned: t('native.widget.nothingPlanned'),
});
const 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);
};
@@ -64,7 +146,7 @@ export default function NativeWidgetsSync(): null {
window.clearInterval(timer);
void listener.then((handle) => handle.remove());
};
}, [caps, t, language, timeFormat]);
}, [caps, t, language, timeFormat, unit]);
return null;
}
+1 -1
View File
@@ -154,7 +154,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
const { size, borderWidth } = look
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
// escaping stops the attribute breakout but still permits a CSS url().
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(isSelected, safeHexColor(place.category_color, 'white'))
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(isSelected, place.category_color)
const bgColor = safeHexColor(place.category_color, '#6b7280')
// Number badges (bottom-right), or the rating where there are none: a numbered stop
+1 -1
View File
@@ -99,7 +99,7 @@ function nativeIosWithoutStore(nativeStore: boolean): boolean | undefined {
return nativeStore ? false : undefined
}
const PILL = 'absolute left-1/2 z-[5] flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]'
const PILL = 'absolute inset-x-0 z-[5] mx-auto flex w-fit items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]'
/**
* Says the map on screen is the stored offline basemap, not the user's own. With
+2 -61
View File
@@ -50,6 +50,7 @@ import { poiPinParts } from './poiMarker'
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { PLUGIN_TONE_COLORS, buildPluginLayerData } from './pluginLayerGeo'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { onRealWorld } from '../../utils/worldCopy'
@@ -128,7 +129,7 @@ function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?
const look = placeMarkerLook(place, selected)
const { size, borderWidth } = look
// See MapView: allow-listed rather than escaped, because this is a CSS context.
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(selected, safeHexColor(place.category_color, 'white'))
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(selected, place.category_color)
const bgColor = safeHexColor(place.category_color, '#6b7280')
// The visual circle is `size` + 2*border on each side. To make the
@@ -213,66 +214,6 @@ const PLUGIN_LINE_LAYER_IDS: Record<'solid' | 'dash' | 'dot', string> = {
dot: 'trek-plugin-layers-line-dot',
}
const PLUGIN_FILL_LAYER_ID = 'trek-plugin-layers-fill'
const PLUGIN_TONE_COLORS: Record<string, string> = {
default: '#4F46E5',
success: '#10b981',
warn: '#f59e0b',
danger: '#ef4444',
}
// GL circle layers size in screen pixels, so a metric circle has to become a
// polygon. Equirectangular approximation — plenty for a display-only overlay.
function circleToRing(center: [number, number], radiusM: number): [number, number][] {
const [lat, lng] = center
const dLat = radiusM / 111_320
const cos = Math.cos((lat * Math.PI) / 180)
const dLng = radiusM / (111_320 * Math.max(0.01, Math.abs(cos)))
const ring: [number, number][] = []
for (let i = 0; i <= 64; i++) {
const a = (i / 64) * 2 * Math.PI
ring.push([lng + Math.cos(a) * dLng, lat + Math.sin(a) * dLat])
}
return ring
}
interface PluginLayerGeoFeature {
type: 'Feature'
properties: { id: string; color: string; width: number; opacity: number; dash: string; fillOpacity: number; label: string }
geometry: { type: 'LineString'; coordinates: number[][] } | { type: 'Polygon'; coordinates: number[][][] }
}
function buildPluginLayerData(layers: PluginMapLayer[]) {
const features = layers.flatMap(layer => layer.features.flatMap((f, i): PluginLayerGeoFeature[] => {
const color = PLUGIN_TONE_COLORS[f.tone] ?? PLUGIN_TONE_COLORS.default
const properties = {
id: `${layer.pluginId}:${layer.id}:${i}`,
color,
width: f.width,
opacity: f.opacity,
dash: f.dash,
fillOpacity: f.fill ? Math.min(0.25, f.opacity) : 0,
label: f.label || '',
}
if (f.type === 'polyline' && f.points) {
return [{
type: 'Feature' as const,
properties,
geometry: { type: 'LineString' as const, coordinates: f.points.map(([lat, lng]) => [lng, lat]) },
}]
}
if (f.type === 'polygon' && f.points) {
const ring = f.points.map(([lat, lng]) => [lng, lat])
if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1])) ring.push(ring[0])
return [{ type: 'Feature' as const, properties, geometry: { type: 'Polygon' as const, coordinates: [ring] } }]
}
if (f.type === 'circle' && f.center && f.radiusM) {
return [{ type: 'Feature' as const, properties, geometry: { type: 'Polygon' as const, coordinates: [circleToRing(f.center, f.radiusM)] } }]
}
return []
}))
return { type: 'FeatureCollection' as const, features }
}
/**
* A place pin on the map, whatever is carrying it.
*
+5 -1
View File
@@ -4,7 +4,11 @@ import type { GlMapProvider } from './glProviders'
import type { MapViewProps } from './mapViewContract'
/** What a caller hands the GL trip map: the shared map contract and which provider's style to load. */
export type MapViewGLProps = MapViewProps & { glProvider?: GlMapProvider }
export type MapViewGLProps = MapViewProps & {
glProvider?: GlMapProvider
/** A style URL that replaces the provider's, as the offline fallback map (MapViewAuto). */
styleOverride?: string
}
/**
* The six ways into a GL map, one chunk per engine.
+6 -2
View File
@@ -1,4 +1,5 @@
import './selectedMarker.css'
import { safeHexColor } from '../../utils/safeColor'
/**
* Two ways a place marker can be told apart from the rest, set on the place object the
@@ -57,8 +58,11 @@ export const SELECTED_MARKER = {
} as const
/** The border colour and shadow of a round place marker, selected or not. */
export function placeMarkerFrame(selected: boolean, categoryBorder: string): { borderColor: string; shadow: string; className: string } {
export function placeMarkerFrame(
selected: boolean,
categoryColor: string | null | undefined
): { borderColor: string; shadow: string; className: string } {
return selected
? { borderColor: SELECTED_MARKER.border, shadow: SELECTED_MARKER.ring, className: SELECTED_MARKER.className }
: { borderColor: categoryBorder, shadow: '0 2px 8px rgba(0,0,0,0.22)', className: '' }
: { borderColor: safeHexColor(categoryColor, 'white'), shadow: '0 2px 8px rgba(0,0,0,0.22)', className: '' }
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
import { glWorksOffline, isOfflineForbiddenTemplate, leafletWorksOffline, offlineStyleFor } from './offlineBasemap';
import { isOfflineForbiddenTemplate } from '../../utils/tileUrl';
import { glWorksOffline, leafletWorksOffline, offlineStyleFor } from './offlineBasemap';
describe('offlineBasemap', () => {
it('picks the stored style for the colour scheme', () => {
+2 -14
View File
@@ -1,6 +1,6 @@
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
import { isNativeIos } from '../../native/platform';
import { isVectorStyle } from '../../utils/tileUrl';
import { isOfflineForbiddenTemplate, isVectorStyle } from '../../utils/tileUrl';
import { normalizeStyleForProvider, type GlMapProvider } from './glProviders';
/**
@@ -14,25 +14,13 @@ import { normalizeStyleForProvider, type GlMapProvider } from './glProviders';
* and glyphs. A fallback has to be exactly a style that was stored, or its
* sprite and labels are missing offline.
*/
export const OFFLINE_STYLES: readonly string[] = [OFM_POSITRON, OFM_DARK];
const OFFLINE_STYLES: readonly string[] = [OFM_POSITRON, OFM_DARK];
/** The stored style that matches the colour scheme. */
export function offlineStyleFor(dark: boolean): string {
return dark ? OFM_DARK : OFM_POSITRON;
}
/** Hosts whose tiles may not be stored for offline use at all (OSM tile usage policy). */
const NO_OFFLINE_HOSTS = ['tile.openstreetmap.org'];
export function isOfflineForbiddenTemplate(template: string): boolean {
try {
const host = new URL(template.replace(/\{s\}/g, 'a')).hostname;
return NO_OFFLINE_HOSTS.some((h) => host === h || host.endsWith(`.${h}`));
} catch {
return false;
}
}
/**
* Whether a GL map can be drawn offline as configured. Only MapLibre on one of
* the stored styles can: Mapbox has nothing stored to draw from, and another
@@ -0,0 +1,82 @@
import type { PluginMapLayer } from '../../api/client';
/** The plugin tone palette, shared by the GL layers and markers. */
export const PLUGIN_TONE_COLORS: Record<string, string> = {
default: '#4F46E5',
success: '#10b981',
warn: '#f59e0b',
danger: '#ef4444',
};
// GL circle layers size in screen pixels, so a metric circle has to become a
// polygon. Equirectangular approximation — plenty for a display-only overlay.
function circleToRing(center: [number, number], radiusM: number): [number, number][] {
const [lat, lng] = center;
const dLat = radiusM / 111_320;
const cos = Math.cos((lat * Math.PI) / 180);
const dLng = radiusM / (111_320 * Math.max(0.01, Math.abs(cos)));
const ring: [number, number][] = [];
for (let i = 0; i <= 64; i++) {
const a = (i / 64) * 2 * Math.PI;
ring.push([lng + Math.cos(a) * dLng, lat + Math.sin(a) * dLat]);
}
return ring;
}
interface PluginLayerGeoFeature {
type: 'Feature';
properties: {
id: string;
color: string;
width: number;
opacity: number;
dash: string;
fillOpacity: number;
label: string;
};
geometry: { type: 'LineString'; coordinates: number[][] } | { type: 'Polygon'; coordinates: number[][][] };
}
/** The plugins' vector layers as one GeoJSON source for the GL map. */
export function buildPluginLayerData(layers: PluginMapLayer[]) {
const features = layers.flatMap((layer) =>
layer.features.flatMap((f, i): PluginLayerGeoFeature[] => {
const color = PLUGIN_TONE_COLORS[f.tone] ?? PLUGIN_TONE_COLORS.default;
const properties = {
id: `${layer.pluginId}:${layer.id}:${i}`,
color,
width: f.width,
opacity: f.opacity,
dash: f.dash,
fillOpacity: f.fill ? Math.min(0.25, f.opacity) : 0,
label: f.label || '',
};
if (f.type === 'polyline' && f.points) {
return [
{
type: 'Feature' as const,
properties,
geometry: { type: 'LineString' as const, coordinates: f.points.map(([lat, lng]) => [lng, lat]) },
},
];
}
if (f.type === 'polygon' && f.points) {
const ring = f.points.map(([lat, lng]) => [lng, lat]);
if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1]))
ring.push(ring[0]);
return [{ type: 'Feature' as const, properties, geometry: { type: 'Polygon' as const, coordinates: [ring] } }];
}
if (f.type === 'circle' && f.center && f.radiusM) {
return [
{
type: 'Feature' as const,
properties,
geometry: { type: 'Polygon' as const, coordinates: [circleToRing(f.center, f.radiusM)] },
},
];
}
return [];
})
);
return { type: 'FeatureCollection' as const, features };
}
@@ -36,11 +36,8 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
const mouseDownTarget = useRef<EventTarget | null>(null)
const [files, setFiles] = useState<File[]>([])
// A booking document shared to TREK lands here already chosen.
useEffect(() => {
if (!isOpen) return
const shared = useSharedImportStore.getState().file
if (shared) setFiles([useSharedImportStore.getState().take() ?? shared])
if (isOpen && useSharedImportStore.getState().file) useSharedImportStore.getState().take()
}, [isOpen])
const [isDragOver, setIsDragOver] = useState(false)
const [loading, setLoading] = useState(false)
@@ -50,6 +47,21 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
// Only a model that reads images can do anything with a photo, so photos are offered only then.
const acceptedExts: readonly string[] = readsPhotos ? [...ACCEPTED_EXTS, ...LLM_PHOTO_EXTENSIONS] : ACCEPTED_EXTS
// A booking document shared to TREK lands here already chosen: picked up in
// the render that opens the modal, and taken out of the store once shown.
const [openSeen, setOpenSeen] = useState(false)
if (isOpen !== openSeen) {
setOpenSeen(isOpen)
if (isOpen) {
// Opens clean, but with a shared document already in the list.
const shared = useSharedImportStore.getState().file
setFiles(shared ? [shared] : [])
setIsDragOver(false)
setLoading(false)
setError('')
}
}
const reset = () => {
setFiles([])
setIsDragOver(false)
@@ -57,12 +69,6 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
setError('')
}
useEffect(() => {
if (isOpen) reset()
// reset is stable — intentional
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen])
useEffect(() => {
if (!isOpen) return
healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
@@ -475,9 +475,8 @@ describe('PlaceDetailsColumn — hours and rating', () => {
* FE-PDC-027 — nothing came back for this place.
*
* It used to be a line of grey text plus, when no Google key was set, a panel
* telling the reader to go and ask an administrator for one. The mascot says the
* same thing in the shape every other empty state in TREK uses, and whose key is
* missing is not this panel's business.
* telling the reader to go and ask an administrator for one. One quiet line says
* that nothing came back, and whose key is missing is not this panel's business.
*/
describe('PlaceDetailsColumn — nothing found', () => {
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
@@ -26,7 +26,6 @@ import { mapsApi } from '../../api/client'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
import { safeHttpUrl } from '../../utils/safeUrl'
import EmptyState from '../shared/EmptyState'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { LABEL } from '../shared/dialogParts'
@@ -66,8 +65,8 @@ interface PlaceDetailsColumnProps {
fluid?: boolean
/**
* 'dialog' is the desktop add place dialog: a grey panel of white cards under
* small eyebrows, and a quiet hint card where the sheet shows the mascot. The
* default is the phone sheet's look, which this prop leaves exactly as it was.
* small eyebrows. The default is the phone sheet's look: a card in the mobile
* tokens with the same one-line hints.
*/
variant?: 'sheet' | 'dialog'
/** False on an instance with no Google key, which is most of them. */
@@ -76,13 +75,24 @@ interface PlaceDetailsColumnProps {
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
/** The phone sheet's quiet one-line state: an icon and a sentence, no mascot taking half the screen. */
function SheetHint({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<p className="flex items-center gap-2 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2.5 text-caption text-m-muted">
<span className="flex-none">{icon}</span>
{children}
</p>
)
}
/**
* Overline label, the section heading Vacay and the dashboard widgets share.
* Uppercase with wide tracking, muted, no rule underneath.
*/
function Overline({ children }: { children: React.ReactNode }): React.ReactElement {
return (
<p className="text-caption font-semibold uppercase tracking-[0.14em] text-content-faint">{children}</p>
// The phone form's eyebrow (mobile Eyebrow), so these labels match Name, Notes and the rest below.
<p className="font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.09em] text-m-faint">{children}</p>
)
}
@@ -93,7 +103,7 @@ function Overline({ children }: { children: React.ReactNode }): React.ReactEleme
function Block({ label, dialog, children }: { label: React.ReactNode; dialog: boolean; children: React.ReactNode }): React.ReactElement {
if (!dialog) {
return (
<div className="space-y-2">
<div className="space-y-[5px]">
<Overline>{label}</Overline>
{children}
</div>
@@ -126,6 +136,9 @@ export default function PlaceDetailsColumn({
const adoptBlockedId = useId()
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
const [state, setState] = useState<LoadState>('idle')
// The phone sheet folds the card away to give the form's own fields the room.
const [open, setOpen] = useState(true)
const bodyId = useId()
const abortRef = useRef<AbortController | null>(null)
const selectionKey = selection ? cacheKeyFor(selection, language) : null
@@ -225,17 +238,36 @@ export default function PlaceDetailsColumn({
// tiles, which is too small to tell a facade from a foyer. The column is
// stretched to the form's height by the row it sits in, so the extra room
// costs nothing that was being used.
<aside className={dialog ? `${SIDE_COLUMN} ${fluid ? '' : 'sm:w-80'}` : `w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch`}>
<aside className={dialog ? `${SIDE_COLUMN} ${fluid ? '' : 'sm:w-80'}` : `w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-[18px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] overflow-hidden self-stretch`}>
{dialog ? (
<ColumnHead icon={<Landmark size={13} strokeWidth={2.2} />}>{t('places.details.title')}</ColumnHead>
) : (
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
<Landmark size={15} className="text-accent" />
<span className="text-body font-semibold text-content">{t('places.details.title')}</span>
</div>
<button
type="button"
aria-expanded={open}
aria-controls={bodyId}
onClick={() => setOpen(o => !o)}
className={`flex w-full shrink-0 items-center gap-2.5 px-3 py-2.5 text-start ${open ? 'border-b border-[color:var(--m-rowbr)]' : ''}`}
>
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-ink">
<Landmark size={15} strokeWidth={2} />
</span>
<span className="min-w-0 flex-1 text-body font-semibold text-m-ink">{t('places.details.title')}</span>
<ChevronDown
size={16}
strokeWidth={2.2}
className={`flex-none text-m-muted transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
/>
</button>
)}
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : 'flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5'}>
{/* Folding on the phone: the grid row eases between its height and none. */}
<div
id={bodyId}
className={dialog ? 'contents' : `grid transition-[grid-template-rows] duration-300 ease-out ${open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'}`}
>
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : `min-h-0 overflow-hidden ${open ? '' : 'invisible'}`}>
<div className={dialog ? 'contents' : 'p-3 space-y-3'}>
{/* The place's picture first: its own ones are there whatever the sources answer. */}
<PictureBlock
photos={state === 'ready' && !data?.disabled ? (data?.photos ?? []) : []}
@@ -246,19 +278,13 @@ export default function PlaceDetailsColumn({
t={t}
/>
{/* Nothing picked yet, which is a state rather than a fault: the mascot
waits with an idle look instead of a sad one. The dialog says the
same in a quiet card, and the column keeps its width either way. */}
{/* Nothing picked yet, which is a state rather than a fault: one quiet
line, in the dialog and on the phone alike, so the form's own fields
stay in view instead of sitting under a mascot half a screen high. */}
{!selection && (dialog ? (
<HintCard icon={<ImageIcon size={15} strokeWidth={2} />}>{t('places.details.empty')}</HintCard>
) : (
<EmptyState
scene="idle"
title={t('places.details.empty')}
size={92}
fill
surface="var(--bg-secondary)"
/>
<SheetHint icon={<ImageIcon size={14} strokeWidth={2} />}>{t('places.details.empty')}</SheetHint>
))}
{selection && state === 'loading' && (dialog ? (
@@ -278,22 +304,13 @@ export default function PlaceDetailsColumn({
? <HintCard icon={<Landmark size={15} strokeWidth={2} />}>{t('places.details.disabled')}</HintCard>
: <p className="text-caption text-content-muted">{t('places.details.disabled')}</p>)}
{/* The mascot rather than a line of grey text and, under it, a paragraph
telling the reader to go and ask an administrator for a Google key.
Nothing came back for this place, which is worth saying once and
{/* Nothing came back for this place, which is worth saying once and
plainly; whose key is missing is not this panel's business, and on a
place the free sources describe fine it read as an advert. */}
{isEmpty && (dialog ? (
<HintCard icon={<SearchX size={15} strokeWidth={2} />}>{t('places.details.nothing')}</HintCard>
) : (
<EmptyState
scene="search"
mood="sad"
title={t('places.details.nothing')}
size={92}
fill
surface="var(--bg-secondary)"
/>
<SheetHint icon={<SearchX size={14} strokeWidth={2} />}>{t('places.details.nothing')}</SheetHint>
))}
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
@@ -319,6 +336,8 @@ export default function PlaceDetailsColumn({
</>
)}
</div>
</div>
</div>
</aside>
)
}
@@ -642,7 +661,7 @@ function DescriptionBlock({
return (
<Block label={aboutBrand ? t('places.details.aboutBrand') : t('places.details.description')} dialog={dialog}>
<div className={dialog ? `${COLUMN_CARD} p-3 space-y-2` : 'rounded-lg border border-edge bg-surface p-2.5 space-y-2'}>
<div className={dialog ? `${COLUMN_CARD} p-3 space-y-2` : 'rounded-[14px] bg-[color:var(--m-ic)] p-3 space-y-2'}>
{aboutBrand && (
<p className="flex items-start gap-1.5 text-caption leading-tight text-content-faint">
<Building2 className="mt-px h-3 w-3 shrink-0" />
@@ -674,7 +693,7 @@ function DescriptionBlock({
onClick={onAdopt}
disabled={adoptBlocked}
aria-describedby={adoptBlocked ? adoptBlockedId : undefined}
className={dialog ? WHITE_BUTTON : 'w-full inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-body font-medium bg-accent-subtle text-accent-on hover:bg-accent hover:text-accent-text disabled:opacity-50 disabled:hover:bg-accent-subtle disabled:hover:text-accent-on transition-colors'}
className={dialog ? WHITE_BUTTON : 'inline-flex h-8 items-center justify-center gap-1.5 rounded-full bg-m-act px-3.5 text-caption font-semibold text-m-actfg transition-transform active:scale-[.97] disabled:opacity-40'}
style={dialog ? fs(13, 'body') : undefined}
>
<ArrowRight className="w-3.5 h-3.5" />
@@ -44,6 +44,7 @@ export function SuggestionInputBox({
}: SuggestionInputBoxProps) {
return (
<div
data-field-trigger={floating ? undefined : 'pad'}
className={floating ? 'focus-within:ring-2 focus-within:ring-accent' : 'bg-surface-tertiary'}
style={
floating
@@ -20,7 +20,7 @@ import { formatMoney, formatTime } from '../../../utils/formatters'
import { copyText } from '../../../utils/clipboard'
import { fmtTransitDuration } from '../transitDisplay'
import { parseMeta, displayTitle } from './bookingsModel'
import { formatDay, type BookingFacts } from './bookingFacts'
import { stayStats, type BookingFacts } from './bookingFacts'
import { AirTrailPill, BOX, Eyebrow, Field, FileRows, ReviewPill, TravelerChips, fs, toneOf } from './bookingParts'
import { TransitLegs, transitSummary } from './transitParts'
import { BookingDialogHeader, StatusPill, TypePill } from './BookingDialogShell'
@@ -79,10 +79,7 @@ export default function BookingDetailDialog(p: BookingDetailDialogProps) {
const stats: { value: string; label: string }[] = []
if (facts.isHotel) {
if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') })
if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') })
const nights = nightsBetween(facts.startDate, facts.endDate)
if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) })
stats.push(...stayStats(facts, meta, t, locale))
} else {
const from = facts.endpoints[0]
const to = facts.endpoints[facts.endpoints.length - 1]
@@ -318,8 +315,3 @@ function DetailField({ label, children }: { label: string; children: ReactNode }
)
}
function nightsBetween(a: string | null, b: string | null): number {
if (!a || !b) return 0
const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5)
return n > 0 ? n : 0
}
@@ -133,3 +133,28 @@ export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts {
isHotel,
}
}
function nightsBetween(a: string | null, b: string | null): number {
if (!a || !b) return 0
const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5)
return n > 0 ? n : 0
}
/**
* A stay's three tiles: check-in and check-out with their dates and times, and
* the nights between. The desktop booking dialog and the phone's booking sheet
* show the same ones.
*/
export function stayStats(
facts: BookingFacts,
meta: { check_in_time?: string | null; check_out_time?: string | null },
t: FactsContext['t'],
locale: string,
): { value: string; label: string }[] {
const stats: { value: string; label: string }[] = []
if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') })
if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') })
const nights = nightsBetween(facts.startDate, facts.endDate)
if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) })
return stats
}
@@ -22,7 +22,7 @@ import {
Ruler,
Upload,
} from 'lucide-react';
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
import { createElement, useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
import Markdown from 'react-markdown';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
@@ -38,7 +38,7 @@ import { inheritedTrackColor, resolveTrackColor } from '../Map/trackColors';
import { BOX } from '../Planner/bookings/bookingParts';
import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation';
import { SoftPill, tintOf } from '../Planner/planParts';
import DetailShell from '../shared/DetailShell';
import DetailShell, { type DetailShellProps } from '../shared/DetailShell';
import { DeleteButton, DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell';
import ElevationProfile from '../shared/ElevationProfile';
import { NavigationMenu } from '../shared/NavigationMenu';
@@ -108,6 +108,8 @@ interface TourDetailDialogProps extends TourPermissionProps {
readOnly?: boolean;
/** Desktop-only opener; the map detail stays non-modal and returns focus when closed. */
desktopFocusReturnTarget?: HTMLElement | null;
/** The frame it renders into: the desktop's card over the map, or the phone's sheet. */
shell?: ComponentType<DetailShellProps>;
}
/** The pills of the head band: white like a place's, one fact each. */
@@ -518,6 +520,7 @@ export default function TourDetailDialog({
desktopNonModal = false,
readOnly = false,
desktopFocusReturnTarget = null,
shell: Shell = DetailShell,
canEdit: editPermission,
canAssign: assignPermission,
}: TourDetailDialogProps) {
@@ -701,7 +704,7 @@ export default function TourDetailDialog({
);
return (
<DetailShell
<Shell
testId="tour-detail"
header={header}
footer={footer}
@@ -797,7 +800,7 @@ export default function TourDetailDialog({
<TourProvenanceBlock tripId={place.trip_id} tour={tour} canCheck={canEdit} onReimported={onSourceReimported} />
<TourFilesSection placeId={place.id} files={placeFiles} onFileUpload={canEdit ? onFileUpload : undefined} />
</DetailShell>
</Shell>
);
}
+28 -12
View File
@@ -4,6 +4,7 @@ import { AlertTriangle, HelpCircle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useIsPhone } from '../../mobile/useIsPhone'
import { DialogTile, NEUTRAL_TINT, fs } from './DialogShell'
import { useLastOpen, usePresence } from './usePresence'
interface ConfirmDialogProps {
isOpen: boolean
@@ -28,7 +29,14 @@ function runConfirm(onConfirm: () => void): void {
}
}
export default function ConfirmDialog({
export default function ConfirmDialog(props: ConfirmDialogProps) {
const { mounted, closing } = usePresence(props.isOpen)
// The question stays readable while it fades, even when its owner clears the subject on close.
const shown = useLastOpen(props, props.isOpen)
return mounted ? <ConfirmDialogFrame {...shown} isOpen={props.isOpen} closing={closing} /> : null
}
function ConfirmDialogFrame({
isOpen,
onClose,
onConfirm,
@@ -38,7 +46,8 @@ export default function ConfirmDialog({
cancelLabel,
danger = true,
children,
}: ConfirmDialogProps) {
closing = false,
}: ConfirmDialogProps & { closing?: boolean }) {
const { t } = useTranslation()
const isPhone = useIsPhone()
@@ -57,14 +66,21 @@ export default function ConfirmDialog({
return () => document.removeEventListener('keydown', handleEsc, true)
}, [isOpen, handleEsc])
if (!isOpen) return null
if (!isOpen && !closing) return null
// Each used by both layouts, worked out once.
const heading = title || t('common.confirm')
const cancelText = cancelLabel || t('common.cancel')
const confirmText = confirmLabel || t('common.delete')
const modalExit = closing ? 'trek-modal-exit' : ''
const width = children ? 'max-w-md' : 'max-w-sm'
return createPortal(
<div
// Backdrop only — Escape (above) and the Cancel button below are the
// keyboard routes out of the dialog.
role="presentation"
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
className={`fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)] ${closing ? 'trek-backdrop-exit pointer-events-none' : ''}`}
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
>
@@ -74,7 +90,7 @@ export default function ConfirmDialog({
own, and the two buttons below it always stay in reach. */}
<div
role="presentation"
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
className={`trek-modal-enter ${modalExit} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${width} p-6 bg-surface-card`}
onClick={e => e.stopPropagation()}
>
<div className="flex min-h-0 items-start gap-4 overflow-y-auto overscroll-contain">
@@ -85,7 +101,7 @@ export default function ConfirmDialog({
)}
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-content">
{title || t('common.confirm')}
{heading}
</h3>
<p className="mt-1 text-sm text-content-secondary">
{message}
@@ -99,7 +115,7 @@ export default function ConfirmDialog({
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-content-secondary border border-edge-secondary"
>
{cancelLabel || t('common.cancel')}
{cancelText}
</button>
<button type="button"
onClick={() => { runConfirm(onConfirm); onClose() }}
@@ -107,7 +123,7 @@ export default function ConfirmDialog({
danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'
}`}
>
{confirmLabel || t('common.delete')}
{confirmText}
</button>
</div>
</div>
@@ -117,7 +133,7 @@ export default function ConfirmDialog({
// with its tile, the message below, the answers in a bar at the foot.
<div
role="presentation"
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${children ? 'max-w-md' : 'max-w-sm'}`}
className={`trek-modal-enter ${modalExit} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${width}`}
onClick={e => e.stopPropagation()}
>
<div className={`flex flex-none items-center gap-3.5 px-6 pb-4 pt-5 ${danger ? 'bg-danger-soft' : ''}`} style={danger ? undefined : { background: NEUTRAL_TINT }}>
@@ -127,7 +143,7 @@ export default function ConfirmDialog({
: <HelpCircle size={20} strokeWidth={1.9} className="text-content-muted" />}
</DialogTile>
<h3 className="m-0 min-w-0 flex-1 font-bold tracking-[-0.01em] text-content" style={fs(18, 'subtitle')}>
{title || t('common.confirm')}
{heading}
</h3>
</div>
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto overscroll-contain px-6 py-5">
@@ -137,13 +153,13 @@ export default function ConfirmDialog({
<div className="flex flex-none items-center justify-end gap-2 border-t border-edge-faint px-6 py-3.5" style={fs(13, 'body')}>
<button type="button" onClick={onClose}
className="inline-flex items-center rounded-[10px] bg-surface-tertiary px-3.5 py-2 font-medium text-content hover:bg-surface-hover">
{cancelLabel || t('common.cancel')}
{cancelText}
</button>
<button type="button"
onClick={() => { runConfirm(onConfirm); onClose() }}
className={`inline-flex items-center rounded-[10px] px-4 py-2 font-medium hover:opacity-90 ${danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'}`}
>
{confirmLabel || t('common.delete')}
{confirmText}
</button>
</div>
</div>
+9 -3
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(() => {
@@ -120,10 +128,8 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
}
}, [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 || '');
@@ -186,6 +186,7 @@ export default function CustomSelect({
<button
ref={triggerRef}
id={id}
data-field-trigger=""
type="button"
aria-label={ariaLabel}
aria-expanded={open}
@@ -129,7 +129,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
<div style={{
<div data-field-trigger="" style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 0,
borderRadius: 10, border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', overflow: 'hidden',
+1 -1
View File
@@ -4,7 +4,7 @@ import { NEUTRAL_TINT } from './DialogShell'
import { Tooltip } from './Tooltip'
import { centredMapCard } from '../Map/centredMapCard'
interface DetailShellProps {
export interface DetailShellProps {
header: ReactNode
children: ReactNode
footer?: ReactNode
+14 -8
View File
@@ -111,6 +111,19 @@ interface FallbackProps extends FallbackState {
label?: string
}
/** What the fallback says: an update, a plugin, a panel or the whole page that failed. */
function fallbackText(
t: (key: string) => string,
isChunkError: boolean,
isPlugin: boolean,
isPanel: boolean
): { title: string; body: string } {
if (isChunkError) return { title: t('common.errorUpdateTitle'), body: t('common.errorUpdateBody') }
const body = isPanel ? t('common.errorPanelBody') : t('common.errorBody')
if (isPlugin) return { title: t('common.errorPluginTitle'), body }
return { title: isPanel ? t('common.errorPanelTitle') : t('common.errorTitle'), body }
}
/**
* The visible half. A function component so it can translate; anything mounted
* above TranslationProvider gets RootErrorFallback instead, which has no keys to
@@ -121,14 +134,7 @@ export function ErrorFallback({ error, reset, isChunkError, level, variant, labe
const isPanel = level === 'panel'
const mobile = variant === 'mobile'
const title = isChunkError
? t('common.errorUpdateTitle')
: label
? t('common.errorPluginTitle')
: isPanel ? t('common.errorPanelTitle') : t('common.errorTitle')
const body = isChunkError
? t('common.errorUpdateBody')
: isPanel ? t('common.errorPanelBody') : t('common.errorBody')
const { title, body } = fallbackText(t, isChunkError, !!label, isPanel)
const message = error instanceof Error ? error.message : null
const Icon = isChunkError ? Sparkles : AlertTriangle
+12 -6
View File
@@ -5,6 +5,7 @@ import { lockBodyScroll } from '../../utils/bodyScrollLock'
import { useTranslation } from '../../i18n'
import { Tooltip } from './Tooltip'
import { focusDialog, trapTab } from './dialogFocus'
import { useLastOpen, usePresence } from './usePresence'
const sizeClasses: Record<string, string> = {
sm: 'max-w-sm',
@@ -36,7 +37,11 @@ interface ModalProps {
}
export default function Modal({ isOpen, ...frame }: ModalProps) {
return isOpen ? <ModalFrame {...frame} /> : null
const { mounted, closing } = usePresence(isOpen)
// While it leaves, the dialog shows what it showed last: its owner often
// clears the item in the same step that closes it.
const shown = useLastOpen(frame, isOpen)
return mounted ? <ModalFrame {...shown} closing={closing} /> : null
}
function ModalFrame({
@@ -47,7 +52,8 @@ function ModalFrame({
footer,
hideCloseButton = false,
align = 'center',
}: Omit<ModalProps, 'isOpen'>) {
closing = false,
}: Omit<ModalProps, 'isOpen'> & { closing?: boolean }) {
const { t } = useTranslation()
const panelRef = useRef<HTMLDivElement>(null)
const mouseDownTarget = useRef<EventTarget | null>(null)
@@ -55,8 +61,8 @@ function ModalFrame({
const [focusedBefore] = useState(() => document.activeElement)
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
if (e.key === 'Escape' && !closing) onClose()
}, [onClose, closing])
useEffect(() => {
document.addEventListener('keydown', handleEsc)
@@ -85,7 +91,7 @@ function ModalFrame({
// click-away, the panel only keeps that click from reaching it. Escape
// and the header's close button are the keyboard route out.
role="presentation"
className={`fixed inset-0 z-[10000] flex items-start ${align === 'top' ? '' : 'sm:items-center'} justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]`}
className={`fixed inset-0 z-[10000] flex items-start ${align === 'top' ? '' : 'sm:items-center'} justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)] ${closing ? 'trek-backdrop-exit pointer-events-none' : ''}`}
style={{ paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
@@ -99,7 +105,7 @@ function ModalFrame({
tabIndex={-1}
onKeyDown={e => { if (panelRef.current) trapTab(e, panelRef.current) }}
className={`
trek-modal-enter outline-none
trek-modal-enter ${closing ? 'trek-modal-exit' : ''} outline-none
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
+51
View File
@@ -0,0 +1,51 @@
/* Closing: the mirror of each entrance, shorter and easing in, so a dialog,
confirm or menu leaves as gently as it came (usePresence.ts, which imports
this file, keeps it mounted for EXIT_MS). Added next to the entrance class rather than
instead of it, because on phones the entrance class also docks the dialog. */
@keyframes trek-modal-exit {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.97);
}
}
@keyframes trek-drawer-exit {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(40%);
}
}
@keyframes trek-backdrop-exit {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.trek-modal-enter.trek-modal-exit,
.trek-popover-enter.trek-popover-exit {
animation: trek-modal-exit 160ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
.trek-backdrop-enter.trek-backdrop-exit {
animation: trek-backdrop-exit 170ms ease-in forwards;
}
@media (max-width: 639px) {
.trek-modal-enter.trek-modal-exit {
animation: trek-drawer-exit 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
}
@media (prefers-reduced-motion: reduce) {
.trek-modal-enter.trek-modal-exit,
.trek-popover-enter.trek-popover-exit,
.trek-backdrop-enter.trek-backdrop-exit {
animation: trek-backdrop-exit 120ms ease-out forwards;
}
}
@@ -0,0 +1,64 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
/** The hook outside the test mode, where it really lingers for the exit animation. */
async function loadAnimated() {
vi.resetModules();
vi.stubEnv('MODE', 'production');
return import('./usePresence');
}
afterEach(() => {
vi.unstubAllEnvs();
vi.useRealTimers();
});
describe('usePresence', () => {
it('stays mounted and closing for the exit, then unmounts', async () => {
vi.useFakeTimers();
const { usePresence } = await loadAnimated();
const hook = renderHook(({ open }) => usePresence(open, 170), { initialProps: { open: true } });
expect(hook.result.current).toEqual({ mounted: true, closing: false });
hook.rerender({ open: false });
expect(hook.result.current).toEqual({ mounted: true, closing: true });
act(() => {
vi.advanceTimersByTime(170);
});
expect(hook.result.current).toEqual({ mounted: false, closing: false });
});
it('never mounts something that was never open, and reopens straight from closing', async () => {
vi.useFakeTimers();
const { usePresence } = await loadAnimated();
const hook = renderHook(({ open }) => usePresence(open, 170), { initialProps: { open: false } });
expect(hook.result.current.mounted).toBe(false);
hook.rerender({ open: true });
hook.rerender({ open: false });
hook.rerender({ open: true });
expect(hook.result.current).toEqual({ mounted: true, closing: false });
});
it('closes at once under the unit tests', async () => {
vi.resetModules();
const { usePresence } = await import('./usePresence');
const hook = renderHook(({ open }) => usePresence(open), { initialProps: { open: true } });
hook.rerender({ open: false });
expect(hook.result.current.mounted).toBe(false);
});
});
describe('useLastOpen', () => {
it('keeps the last value seen while open', async () => {
const { useLastOpen } = await import('./usePresence');
const hook = renderHook(({ value, open }) => useLastOpen(value, open), {
initialProps: { value: 'Delete Paris?' as string | null, open: true },
});
hook.rerender({ value: null, open: false });
expect(hook.result.current).toBe('Delete Paris?');
hook.rerender({ value: 'Delete Rome?', open: true });
expect(hook.result.current).toBe('Delete Rome?');
});
});
@@ -0,0 +1,46 @@
import { useEffect, useRef, useState } from 'react';
import './presence.css';
/**
* How long a closing dialog, confirm or menu stays for its exit animation
* (index.css). None under the unit tests: hundreds of them close a confirm and
* check at once that it is gone, which is what the user sees once the fade ends.
*/
export const EXIT_MS = import.meta.env.MODE === 'test' ? 0 : 170;
/**
* Keeps something on screen for its exit animation after it was told to close:
* `mounted` stays true for EXIT_MS once `open` turns false, with `closing` set
* meanwhile so the caller can add the exit class and stop taking clicks.
*/
export function usePresence(open: boolean, ms: number = EXIT_MS): { mounted: boolean; closing: boolean } {
const animated = ms > 0 && EXIT_MS > 0;
// The open state of the last render, kept in state so the render that closes
// already knows it closed (React's "storing information from previous renders").
const [prevOpen, setPrevOpen] = useState(open);
const [lingering, setLingering] = useState(false);
if (open !== prevOpen) {
setPrevOpen(open);
setLingering(!open && animated);
}
useEffect(() => {
if (!lingering) return;
const timer = window.setTimeout(() => setLingering(false), ms);
return () => window.clearTimeout(timer);
}, [lingering, ms]);
// The first render after closing already counts as closing, before the state update lands.
const closing = animated && !open && (lingering || prevOpen);
return { mounted: open || closing, closing };
}
/**
* The last value seen while open: what a closing popup shows during its exit,
* since its owner often clears the data in the same step that closes it.
*/
export function useLastOpen<T>(value: T, open: boolean): T {
const last = useRef(value);
if (open) last.current = value;
return open ? value : last.current;
}
@@ -2,7 +2,8 @@ import { act, fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { tickHaptic } from '../../native/haptics';
import type { Day } from '../../types';
import MDayPager, { pagesFor } from './MDayPager';
import MDayPager from './MDayPager';
import { pagesFor } from './dayPagerPages';
vi.mock('../../native/haptics', () => ({ tickHaptic: vi.fn() }));
@@ -82,6 +83,22 @@ describe('MDayPager', () => {
expect(screen.getByRole('status')).toHaveTextContent('day 3 of 4');
});
it('previews the day a swipe heads for while the strip still moves, and takes it back', () => {
const onPreview = vi.fn();
const { strip, onSelectDay } = setup(2, { onPreview });
place(strip, 3, true);
fireEvent.scroll(strip);
fireEvent.scroll(strip);
expect(onPreview).toHaveBeenCalledTimes(1);
expect(onPreview).toHaveBeenLastCalledWith(3);
expect(onSelectDay).not.toHaveBeenCalled();
place(strip, 2, true);
fireEvent.scroll(strip);
expect(onPreview).toHaveBeenLastCalledWith(null);
});
it('asks once, however many events report the same landing', () => {
const { strip, onSelectDay } = setup(2);
+44 -33
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { tickHaptic } from '../../native/haptics';
import type { Day } from '../../types';
import { pagesFor, type PagerItem } from './dayPagerPages';
/**
* Pages through the trip's days the way a native pager does: the previous,
@@ -28,7 +28,6 @@ const SETTLE_MS = 90;
const REST_PX = 2;
/** Anything with an id pages: trip days, a leave year's months, the plan's years. */
export type PagerItem = Pick<Day, 'id'>;
export interface MDayPagerProps {
days: PagerItem[];
@@ -47,33 +46,12 @@ export interface MDayPagerProps {
* (Vacay's month editor and year grid) rather than over the whole screen.
*/
inFlow?: boolean;
}
interface Page {
key: string;
dayId: number | null;
active: boolean;
}
/** The window of pages around the selection. A selection that points at no
* day (the whole-trip view, or a day deleted remotely) still gets its page,
* with the first day next to it as a way back. */
export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[] {
const i = days.findIndex((d) => d.id === selectedDayId);
if (i < 0) {
const first = days[0];
return [
{ key: 'none', dayId: selectedDayId, active: true },
...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false }] : []),
];
}
const pages: Page[] = [];
const prev = days[i - 1];
const next = days[i + 1];
if (prev) pages.push({ key: `d${prev.id}`, dayId: prev.id, active: false });
pages.push({ key: `d${days[i].id}`, dayId: days[i].id, active: true });
if (next) pages.push({ key: `d${next.id}`, dayId: next.id, active: false });
return pages;
/**
* The day a swipe is heading for, as soon as its page is the nearer one, and
* null when the swipe falls back: lets a day rail follow the finger before
* the snap lands and onSelectDay fires.
*/
onPreview?: (dayId: number | null) => void;
}
export default function MDayPager({
@@ -84,6 +62,7 @@ export default function MDayPager({
locked = false,
describeDay,
inFlow = false,
onPreview,
}: MDayPagerProps) {
const stripRef = useRef<HTMLDivElement>(null);
const [announcement, setAnnouncement] = useState('');
@@ -91,8 +70,10 @@ export default function MDayPager({
const windowKey = pages.map((p) => p.key).join(',');
// The handlers below are attached once, so they read the props through here.
const latest = useRef({ days, onSelectDay, describeDay, selectedDayId });
latest.current = { days, onSelectDay, describeDay, selectedDayId };
const latest = useRef({ days, onSelectDay, describeDay, selectedDayId, onPreview });
useLayoutEffect(() => {
latest.current = { days, onSelectDay, describeDay, selectedDayId, onPreview };
});
/** The day a landed swipe asked for, until the selection catches up. Stops a
* late scroll event from selecting it twice. */
const requested = useRef<number | null>(null);
@@ -126,6 +107,31 @@ export default function MDayPager({
if (!strip) return;
let timer = 0;
let touching = false;
let previewed: number | null = null;
// The nearer page while the strip moves: a page past the middle is where the
// swipe will land unless the finger turns back.
const preview = () => {
const notify = latest.current.onPreview;
if (!notify) return;
const left = strip.getBoundingClientRect().left;
let nearest: HTMLElement | null = null;
let distance = Infinity;
for (const child of Array.from(strip.children) as HTMLElement[]) {
const d = Math.abs(child.getBoundingClientRect().left - left);
if (d < distance) {
distance = d;
nearest = child;
}
}
if (!nearest) return;
const raw = nearest.hasAttribute('data-page-active') ? '' : nearest.dataset.dayId;
const dayId = raw ? Number(raw) : null;
const next = dayId != null && Number.isFinite(dayId) ? dayId : null;
if (next === previewed) return;
previewed = next;
notify(next);
};
const settle = () => {
if (touching) return;
@@ -140,6 +146,7 @@ export default function MDayPager({
const dayId = raw ? Number(raw) : NaN;
if (!Number.isFinite(dayId) || requested.current === dayId) return;
requested.current = dayId;
previewed = null;
const s = latest.current;
tickHaptic();
s.onSelectDay(dayId);
@@ -162,14 +169,18 @@ export default function MDayPager({
later();
};
strip.addEventListener('scroll', later, { passive: true });
const onScroll = () => {
preview();
later();
};
strip.addEventListener('scroll', onScroll, { passive: true });
strip.addEventListener('scrollend', onScrollEnd);
strip.addEventListener('touchstart', onTouchStart, { passive: true });
strip.addEventListener('touchend', onTouchEnd, { passive: true });
strip.addEventListener('touchcancel', onTouchEnd, { passive: true });
return () => {
window.clearTimeout(timer);
strip.removeEventListener('scroll', later);
strip.removeEventListener('scroll', onScroll);
strip.removeEventListener('scrollend', onScrollEnd);
strip.removeEventListener('touchstart', onTouchStart);
strip.removeEventListener('touchend', onTouchEnd);
@@ -0,0 +1,45 @@
import { Check, Copy, Share as ShareIcon } from 'lucide-react';
import { canShareLink, shareLink } from '../../native/shareLink';
/** The round icon buttons next to the link: copy and share, the same size. */
const ACTION = 'flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg';
/**
* A link to hand on, read-only, with copy and (where the system has a share
* sheet) share next to it: the trip's share and invite links and a journey's
* public link all look the same.
*/
export default function MLinkField({
url,
copied,
onCopy,
copyLabel,
copiedLabel,
shareLabel,
}: {
url: string;
copied: boolean;
onCopy: () => void;
copyLabel: string;
copiedLabel: string;
shareLabel: string;
}) {
return (
<div className="flex items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-1 pe-1 ps-[14px]">
<input
readOnly
value={url}
aria-label={url}
className="min-w-0 flex-1 border-none bg-transparent font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-muted outline-none"
/>
<button type="button" aria-label={copied ? copiedLabel : copyLabel} onClick={onCopy} className={ACTION}>
{copied ? <Check size={15} strokeWidth={2.4} /> : <Copy size={15} strokeWidth={2.2} />}
</button>
{canShareLink() && (
<button type="button" aria-label={shareLabel} onClick={() => void shareLink(url)} className={ACTION}>
<ShareIcon size={15} strokeWidth={2.2} />
</button>
)}
</div>
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ export default function MSegmented<T extends string = string>({
className = '',
}: MSegmentedProps<T>) {
const trackRef = useRef<HTMLDivElement>(null)
const pill = useActivePill(trackRef, '[data-active="true"]', [value, options.length])
const pill = useActivePill(trackRef, '[data-active="true"]', `${String(value)}:${options.length}`)
return (
<div
+2 -1
View File
@@ -170,7 +170,8 @@ export default function MSheet({
const exitClass = EXIT[variant]
const animation = open ? (entered ? '' : enterClass) : flung ? '' : exitClass
const resolvedMaterial = material ?? (variant === 'bottom' ? 'bar-glass' : 'glass')
// One material for every sheet, docked or floating, so they all read the same.
const resolvedMaterial = material ?? 'glass'
return createPortal(
// m-root on the overlay so the --m-* tokens resolve even when the portal
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
import { tripsApi } from '../../api/client';
import { useSharedImportStore } from '../../native/sharedFiles';
import { tripRepo } from '../../repo/tripRepo';
import NativeImportSheet from './NativeImportSheet';
const navigate = vi.hoisted(() => vi.fn());
@@ -11,14 +11,17 @@ vi.mock('react-router', async (importOriginal) => ({
}));
beforeEach(() => {
// A second spyOn returns the first spy with its calls; start every test clean.
vi.restoreAllMocks();
navigate.mockReset();
useSharedImportStore.setState({ file: null, tripId: null });
vi.spyOn(tripsApi, 'list').mockResolvedValue({
vi.spyOn(tripRepo, 'list').mockResolvedValue({
trips: [
{ id: 1, title: 'Past trip', start_date: '2020-01-01', end_date: '2020-01-05' },
{ id: 2, title: 'Japan', start_date: '2099-03-01', end_date: '2099-03-10' },
],
});
archivedTrips: [],
} as unknown as Awaited<ReturnType<typeof tripRepo.list>>);
});
describe('NativeImportSheet', () => {
@@ -38,6 +41,6 @@ describe('NativeImportSheet', () => {
it('stays closed with nothing shared', () => {
render(<NativeImportSheet />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(tripsApi.list).not.toHaveBeenCalled();
expect(tripRepo.list).not.toHaveBeenCalled();
});
});
@@ -6,10 +6,11 @@
import { ChevronRight, FileText } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { tripsApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import MSheet from '../../mobile/components/MSheet';
import { useSharedImportStore } from '../../native/sharedFiles';
import { tripRepo } from '../../repo/tripRepo';
import { TileHeader } from '../screens/trip/sheets/MTripSheetUi';
import MSheet from './MSheet';
interface TripRow {
id: number;
@@ -31,7 +32,7 @@ export default function NativeImportSheet(): React.ReactElement | null {
useEffect(() => {
if (!open) return;
tripsApi.list().then(
tripRepo.list().then(
(data: { trips?: TripRow[] }) => {
const today = new Date().toISOString().slice(0, 10);
const list = [...(data.trips ?? [])];
@@ -47,21 +48,21 @@ export default function NativeImportSheet(): React.ReactElement | null {
const pick = (id: number) => {
chooseTrip(id);
navigate(`/trips/${id}`);
void navigate(`/trips/${id}`);
};
return (
<MSheet open={open} onClose={() => setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}>
<div className="px-4 pb-4 pt-6">
<div className="mb-1 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold text-m-ink">
{t('native.share.title')}
<div className="px-[18px] pb-4 pt-4">
<div className="mb-3">
<TileHeader
icon={<FileText size={19} strokeWidth={1.8} />}
title={t('native.share.title')}
sub={file?.name}
onClose={() => setFile(null)}
closeLabel={t('common.close')}
/>
</div>
{file && (
<div className="mb-3 flex items-center gap-2 px-1 font-geist text-[length:calc(11px*var(--fs-scale-body,1))] text-m-muted">
<FileText size={13} aria-hidden="true" />
<span className="truncate">{file.name}</span>
</div>
)}
<div className="flex max-h-[50vh] flex-col gap-2 overflow-y-auto">
{trips.map((trip) => (
<button
@@ -0,0 +1,31 @@
import type { Day } from '../../types';
/** A day as the pager needs it: only its id, so the plan and Vacay can both page. */
export type PagerItem = Pick<Day, 'id'>;
interface Page {
key: string;
dayId: number | null;
active: boolean;
}
/** The window of pages around the selection. A selection that points at no
* day (the whole-trip view, or a day deleted remotely) still gets its page,
* with the first day next to it as a way back. */
export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[] {
const i = days.findIndex((d) => d.id === selectedDayId);
if (i < 0) {
const first = days[0];
return [
{ key: 'none', dayId: selectedDayId, active: true },
...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false }] : []),
];
}
const pages: Page[] = [];
const prev = days[i - 1];
const next = days[i + 1];
if (prev) pages.push({ key: `d${prev.id}`, dayId: prev.id, active: false });
pages.push({ key: `d${days[i].id}`, dayId: days[i].id, active: true });
if (next) pages.push({ key: `d${next.id}`, dayId: next.id, active: false });
return pages;
}
+12 -11
View File
@@ -1,4 +1,4 @@
import type { CSSProperties, DependencyList, RefObject } from 'react';
import type { CSSProperties, RefObject } from 'react';
import { useLayoutEffect, useState } from 'react';
/**
@@ -17,15 +17,18 @@ export function useActivePill(
/** The track, as a ref or as the element itself (a callback-ref'd rail). */
trackRef: RefObject<HTMLElement | null> | HTMLElement | null,
selector: string,
deps: DependencyList,
/** What moves the active item, as one value: a change re-measures the pill. */
key: string | number,
/** Follow the item across rows too (a grid of chips): the pill then also takes
* the item's top and height, and the caller leaves top/bottom unset. */
rows = false
): CSSProperties | null {
const [box, setBox] = useState<{ left: number; top: number; width: number; height: number; animate: boolean } | null>(null);
const [box, setBox] = useState<{ x: number; y: number; width: number; height: number; animate: boolean } | null>(
null
);
useLayoutEffect(() => {
const track = trackRef && 'current' in trackRef ? trackRef.current : trackRef;
const track = trackRef instanceof HTMLElement ? trackRef : (trackRef?.current ?? null);
if (!track) return;
const measure = () => {
const active = track.querySelector<HTMLElement>(selector);
@@ -34,8 +37,8 @@ export function useActivePill(
return;
}
setBox((prev) => ({
left: active.offsetLeft,
top: active.offsetTop,
x: active.offsetLeft,
y: active.offsetTop,
width: active.offsetWidth,
height: active.offsetHeight,
animate: prev !== null,
@@ -46,14 +49,12 @@ export function useActivePill(
const observer = new ResizeObserver(measure);
observer.observe(track);
return () => observer.disconnect();
// The caller names what moves the active item; the ref and selector are stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}, [trackRef, selector, key]);
if (!box) return null;
const transition = box.animate ? 'transform 320ms var(--ease-drawer), width 320ms var(--ease-drawer)' : 'none';
if (rows) {
return { top: 0, width: box.width, height: box.height, transform: `translate(${box.left}px, ${box.top}px)`, transition };
return { top: 0, width: box.width, height: box.height, transform: `translate(${box.x}px, ${box.y}px)`, transition };
}
return { width: box.width, transform: `translateX(${box.left}px)`, transition };
return { width: box.width, transform: `translateX(${box.x}px)`, transition };
}
+125 -109
View File
@@ -1,5 +1,5 @@
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import type { MutableRefObject, PointerEvent as ReactPointerEvent, RefObject } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
/**
* Drag-to-dismiss for the docked bottom sheet, the way a native sheet takes it.
@@ -32,7 +32,7 @@ const SAMPLE_MS = 80;
/** Upward pull: a fraction of the travel, capped, as a stretch rather than a move. */
const STRETCH = 0.2;
const STRETCH_MAX = 20;
export const SPRING_BACK = 'transform 280ms var(--ease-drawer)';
const SPRING_BACK = 'transform 280ms var(--ease-drawer)';
/** Controls that own a downward finger themselves. */
const OWNS_GESTURE =
@@ -77,7 +77,9 @@ export function useSheetDrag({ enabled, panelRef, onDismiss }: SheetDragOptions)
const drag = useRef<Drag | null>(null);
const frame = useRef(0);
const latest = useRef(onDismiss);
latest.current = onDismiss;
useLayoutEffect(() => {
latest.current = onDismiss;
});
const write = useCallback(
(y: number, transition: string) => {
@@ -163,111 +165,7 @@ export function useSheetDrag({ enabled, panelRef, onDismiss }: SheetDragOptions)
useEffect(() => {
const panel = panelRef.current;
if (!enabled || !panel) return;
/** Set by a claimed drag: the click the browser sends after it is not a tap. */
let swallowClick = false;
const begin = (x: number, y: number, t: number, target: EventTarget | null): boolean => {
const el = target as Element | null;
if (el?.closest?.('[data-sheet-grab]') || el?.closest?.(OWNS_GESTURE)) return false;
swallowClick = false;
drag.current = { y0: y, x0: x, samples: [{ y, t }], claimed: false, scroller: nearestScroller(target, panel) };
return true;
};
/** False once the gesture is not ours, so the caller lets it go. */
const advance = (x: number, y: number, t: number): boolean => {
const d = drag.current;
if (!d) return false;
if (!d.claimed) {
const dy = y - d.y0;
const dx = x - d.x0;
if (Math.abs(dy) <= CLASSIFY_PX && Math.abs(dx) <= CLASSIFY_PX) return true;
const atTop = !d.scroller || d.scroller.scrollTop <= 0;
if (!(dy > 0 && Math.abs(dy) > Math.abs(dx) * AXIS_BIAS && atTop)) {
drag.current = null;
return false;
}
d.claimed = true;
// Measured from here, so the sheet does not jump by the classify slop.
d.y0 = y;
d.samples = [{ y, t }];
}
sample(d, y, t);
follow(y - d.y0);
return true;
};
const finish = (y: number | undefined) => {
const d = drag.current;
drag.current = null;
if (!d?.claimed) return;
swallowClick = true;
const last = d.samples[d.samples.length - 1];
release(d, y ?? last.y);
};
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) {
drag.current = null;
return;
}
const t = e.touches[0];
begin(t.clientX, t.clientY, e.timeStamp, e.target);
};
const onTouchMove = (e: TouchEvent) => {
if (!drag.current || e.touches.length !== 1) return;
const t = e.touches[0];
if (advance(t.clientX, t.clientY, e.timeStamp) && drag.current?.claimed && e.cancelable) e.preventDefault();
};
const onTouchEnd = (e: TouchEvent) => finish(e.changedTouches[0]?.clientY);
// Touch has its own path above: a touch pointer is cancelled the moment the
// browser starts scrolling, so only mouse and pen come through here.
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType === 'touch' || e.button !== 0) return;
if (!begin(e.clientX, e.clientY, e.timeStamp, e.target)) return;
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
};
const onPointerMove = (e: PointerEvent) => {
if (!advance(e.clientX, e.clientY, e.timeStamp)) {
stopPointer();
return;
}
// No text selection while the sheet is being pulled.
if (drag.current?.claimed) e.preventDefault();
};
const onPointerUp = (e: PointerEvent) => {
stopPointer();
finish(e.type === 'pointercancel' ? undefined : e.clientY);
};
const stopPointer = () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
};
const onClickCapture = (e: MouseEvent) => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopPropagation();
};
panel.addEventListener('touchstart', onTouchStart, { passive: true });
panel.addEventListener('touchmove', onTouchMove, { passive: false });
panel.addEventListener('touchend', onTouchEnd, { passive: true });
panel.addEventListener('touchcancel', onTouchEnd, { passive: true });
panel.addEventListener('pointerdown', onPointerDown);
panel.addEventListener('click', onClickCapture, true);
return () => {
cancelAnimationFrame(frame.current);
stopPointer();
panel.removeEventListener('touchstart', onTouchStart);
panel.removeEventListener('touchmove', onTouchMove);
panel.removeEventListener('touchend', onTouchEnd);
panel.removeEventListener('touchcancel', onTouchEnd);
panel.removeEventListener('pointerdown', onPointerDown);
panel.removeEventListener('click', onClickCapture, true);
};
return attachContentDrag(panel, drag, frame, follow, release);
}, [enabled, panelRef, follow, release]);
return {
@@ -280,3 +178,121 @@ export function useSheetDrag({ enabled, panelRef, onDismiss }: SheetDragOptions)
},
};
}
/**
* The content's half of the drag: a finger (touch events) or a mouse or pen
* (pointer events), claimed on the first move or let go. Returns the detach.
*/
function attachContentDrag(
panel: HTMLElement,
drag: MutableRefObject<Drag | null>,
frame: MutableRefObject<number>,
follow: (dy: number) => void,
release: (d: Drag, lastY: number) => void
): () => void {
/** Set by a claimed drag: the click the browser sends after it is not a tap. */
let swallowClick = false;
const begin = (x: number, y: number, t: number, target: EventTarget | null): boolean => {
const el = target as Element | null;
if (el?.closest?.('[data-sheet-grab]') || el?.closest?.(OWNS_GESTURE)) return false;
swallowClick = false;
drag.current = { y0: y, x0: x, samples: [{ y, t }], claimed: false, scroller: nearestScroller(target, panel) };
return true;
};
/** False once the gesture is not ours, so the caller lets it go. */
const advance = (x: number, y: number, t: number): boolean => {
const d = drag.current;
if (!d) return false;
if (!d.claimed) {
const dy = y - d.y0;
const dx = x - d.x0;
if (Math.abs(dy) <= CLASSIFY_PX && Math.abs(dx) <= CLASSIFY_PX) return true;
const atTop = !d.scroller || d.scroller.scrollTop <= 0;
if (!(dy > 0 && Math.abs(dy) > Math.abs(dx) * AXIS_BIAS && atTop)) {
drag.current = null;
return false;
}
d.claimed = true;
// Measured from here, so the sheet does not jump by the classify slop.
d.y0 = y;
d.samples = [{ y, t }];
}
sample(d, y, t);
follow(y - d.y0);
return true;
};
const finish = (y: number | undefined) => {
const d = drag.current;
drag.current = null;
if (!d?.claimed) return;
swallowClick = true;
const last = d.samples[d.samples.length - 1];
release(d, y ?? last.y);
};
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) {
drag.current = null;
return;
}
const t = e.touches[0];
begin(t.clientX, t.clientY, e.timeStamp, e.target);
};
const onTouchMove = (e: TouchEvent) => {
if (!drag.current || e.touches.length !== 1) return;
const t = e.touches[0];
if (advance(t.clientX, t.clientY, e.timeStamp) && drag.current?.claimed && e.cancelable) e.preventDefault();
};
const onTouchEnd = (e: TouchEvent) => finish(e.changedTouches[0]?.clientY);
// Touch has its own path above: a touch pointer is cancelled the moment the
// browser starts scrolling, so only mouse and pen come through here.
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType === 'touch' || e.button !== 0) return;
if (!begin(e.clientX, e.clientY, e.timeStamp, e.target)) return;
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
};
const onPointerMove = (e: PointerEvent) => {
if (!advance(e.clientX, e.clientY, e.timeStamp)) {
stopPointer();
return;
}
// No text selection while the sheet is being pulled.
if (drag.current?.claimed) e.preventDefault();
};
const onPointerUp = (e: PointerEvent) => {
stopPointer();
finish(e.type === 'pointercancel' ? undefined : e.clientY);
};
const stopPointer = () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
};
const onClickCapture = (e: MouseEvent) => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopPropagation();
};
panel.addEventListener('touchstart', onTouchStart, { passive: true });
panel.addEventListener('touchmove', onTouchMove, { passive: false });
panel.addEventListener('touchend', onTouchEnd, { passive: true });
panel.addEventListener('touchcancel', onTouchEnd, { passive: true });
panel.addEventListener('pointerdown', onPointerDown);
panel.addEventListener('click', onClickCapture, true);
return () => {
cancelAnimationFrame(frame.current);
stopPointer();
panel.removeEventListener('touchstart', onTouchStart);
panel.removeEventListener('touchmove', onTouchMove);
panel.removeEventListener('touchend', onTouchEnd);
panel.removeEventListener('touchcancel', onTouchEnd);
panel.removeEventListener('pointerdown', onPointerDown);
panel.removeEventListener('click', onClickCapture, true);
};
}
+17
View File
@@ -0,0 +1,17 @@
import { create } from 'zustand';
/**
* The day a swipe is heading for, before the pager has settled on it. The day
* rail in the trip header lights that chip at once, the way a native tab strip
* follows the finger, while the plan itself switches only when the snap lands
* (MDayPager's onSelectDay). Null when no swipe is under way.
*/
interface DayPreviewState {
dayId: number | null;
setDayId: (dayId: number | null) => void;
}
export const useDayPreviewStore = create<DayPreviewState>((set) => ({
dayId: null,
setDayId: (dayId) => set((s) => (s.dayId === dayId ? s : { dayId })),
}));
+21
View File
@@ -478,3 +478,24 @@
animation: none !important;
}
}
/* A scroll box under a fixed header: content fades out over the padding at its
edges instead of being cut off in a hard line right under the header. */
.m-scroll-fade {
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
}
/* The shared pickers (select, date, time) inside the phone UI look like the
sheets' own fields: a 42 px round field on the field surface. They style
themselves inline for the desktop, hence the !important. */
.m-root [data-field-trigger] {
min-height: 42px;
border-radius: 9999px !important;
border: 1px solid var(--m-rowbr) !important;
background: var(--m-ic) !important;
}
.m-root button[data-field-trigger],
.m-root [data-field-trigger='pad'] {
padding-inline: 14px !important;
}
+4 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect, useLayoutEffect, useRef } from 'react';
/**
* The overlays open on screen, newest last, so a way back closes the one on
@@ -25,7 +25,9 @@ export function hasOpenOverlay(): boolean {
/** Registers an overlay while `open`, with the way it closes. */
export function useOverlay(open: boolean, close: Close): void {
const closeRef = useRef(close);
closeRef.current = close;
useLayoutEffect(() => {
closeRef.current = close;
});
useEffect(() => {
if (!open) return;
const entry = { id: nextId++, close: () => closeRef.current() };
+11 -11
View File
@@ -1,5 +1,5 @@
import { act, renderHook } from '@testing-library/react';
import type { Location, NavigationType } from 'react-router';
import { NavigationType, type Location } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { skipNextRouteTransition, transitionKind, useRouteTransition } from './routeTransition';
@@ -7,11 +7,11 @@ const loc = (pathname: string, key = pathname): Location => ({ pathname, key, se
describe('transitionKind', () => {
it('slides deeper pages forward and back, cross-fades between sections', () => {
expect(transitionKind(loc('/dashboard'), loc('/trips/4'), 'PUSH')).toBe('forward');
expect(transitionKind(loc('/trips/4'), loc('/dashboard'), 'POP')).toBe('back');
expect(transitionKind(loc('/trips/4'), loc('/trips/4/files'), 'PUSH')).toBe('forward');
expect(transitionKind(loc('/dashboard'), loc('/atlas'), 'PUSH')).toBe('tab');
expect(transitionKind(loc('/vacay'), loc('/dashboard'), 'POP')).toBe('tab');
expect(transitionKind(loc('/dashboard'), loc('/trips/4'), NavigationType.Push)).toBe('forward');
expect(transitionKind(loc('/trips/4'), loc('/dashboard'), NavigationType.Pop)).toBe('back');
expect(transitionKind(loc('/trips/4'), loc('/trips/4/files'), NavigationType.Push)).toBe('forward');
expect(transitionKind(loc('/dashboard'), loc('/atlas'), NavigationType.Push)).toBe('tab');
expect(transitionKind(loc('/vacay'), loc('/dashboard'), NavigationType.Pop)).toBe('tab');
});
});
@@ -37,14 +37,14 @@ describe('useRouteTransition', () => {
function render(initial: Location, enabled = true) {
return renderHook(
({ location, type }: { location: Location; type: NavigationType }) => useRouteTransition(location, type, enabled),
{ initialProps: { location: initial, type: 'PUSH' as NavigationType } }
{ initialProps: { location: initial, type: NavigationType.Push } }
);
}
it('swaps the page inside a view transition and marks its direction', async () => {
const hook = render(loc('/dashboard'));
await act(async () => {
hook.rerender({ location: loc('/trips/1'), type: 'PUSH' });
hook.rerender({ location: loc('/trips/1'), type: NavigationType.Push });
});
expect(start).toHaveBeenCalledTimes(1);
expect(hook.result.current.pathname).toBe('/trips/1');
@@ -54,16 +54,16 @@ describe('useRouteTransition', () => {
const hook = render(loc('/trips/1'));
skipNextRouteTransition();
await act(async () => {
hook.rerender({ location: loc('/dashboard'), type: 'POP' });
hook.rerender({ location: loc('/dashboard'), type: NavigationType.Pop });
});
await act(async () => {
hook.rerender({ location: { ...loc('/dashboard', 'k2'), search: '?f=1' }, type: 'PUSH' });
hook.rerender({ location: { ...loc('/dashboard', 'k2'), search: '?f=1' }, type: NavigationType.Push });
});
expect(start).not.toHaveBeenCalled();
expect(hook.result.current.key).toBe('k2');
const off = render(loc('/dashboard'), false);
off.rerender({ location: loc('/trips/2'), type: 'PUSH' });
off.rerender({ location: loc('/trips/2'), type: NavigationType.Push });
expect(off.result.current.pathname).toBe('/trips/2');
expect(start).not.toHaveBeenCalled();
});
+17 -10
View File
@@ -22,6 +22,9 @@ const SETTLE_MS = 800;
let skipNext = false;
let current: ViewTransition | null = null;
/** A newer navigation skipped this transition: its promises reject, and that is no error. */
function skippedByNewer(): void {}
/** The edge-swipe back gesture already slid the page away: the next change goes without a transition. */
export function skipNextRouteTransition(): void {
skipNext = true;
@@ -44,13 +47,19 @@ function canAnimate(): boolean {
);
}
/** Resolves once no route fallback is on screen, or after SETTLE_MS. */
/**
* Resolves once no route fallback is on screen, or after SETTLE_MS. Polled with
* a timer, never requestAnimationFrame: while a view transition waits for its
* update, the browser renders no frames, so a frame callback never came and the
* transition hung until the browser gave up after four seconds, on every
* navigation.
*/
function settled(): Promise<void> {
const began = performance.now();
return new Promise((resolve) => {
const check = () => {
if (!document.querySelector('[data-route-fallback]') || performance.now() - began > SETTLE_MS) resolve();
else requestAnimationFrame(check);
else window.setTimeout(check, 16);
};
check();
});
@@ -79,14 +88,12 @@ export function useRouteTransition(location: Location, navigationType: Navigatio
});
current = transition;
// A newer navigation skips this one: both promises then reject, which is no error.
transition.ready.catch(() => {});
void transition.finished
.catch(() => {})
.finally(() => {
// Only the newest transition clears the direction, or a skipped one would
// take it away from the one that replaced it.
if (current === transition) delete root.dataset.routeTransition;
});
transition.ready.catch(skippedByNewer);
void transition.finished.catch(skippedByNewer).finally(() => {
// Only the newest transition clears the direction, or a skipped one would
// take it away from the one that replaced it.
if (current === transition) delete root.dataset.routeTransition;
});
}, [location, navigationType, enabled]);
return enabled ? shown : location;
@@ -1,12 +1,7 @@
import { useState } from 'react'
import { useFormatDate } from '../../../hooks/useDateFormat'
import MPluginVersionDate from './MPluginVersionDate'
import {
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database,
Globe, Info, History, PauseCircle, Puzzle, Bot,
} from 'lucide-react'
import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database, Globe, Info, History, PauseCircle, Puzzle, Bot, ListChecks } from 'lucide-react'
import PluginIcon from '../../../components/shared/PluginIcon'
import { type RangeWarning } from '../../../components/Admin/useRangeBypass'
import { deriveCaps } from '../../../components/Admin/pluginCaps'
@@ -353,9 +348,10 @@ export default function MAdminPluginsPanel() {
{/* Error-log sheet */}
{errorsFor && (
<MSheet open onClose={() => setErrorsFor(null)} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.errorLog')}>
<MSheet open onClose={() => setErrorsFor(null)} variant="bottom" material="glass" ariaLabel={t('admin.plugins.errorLog')}>
<MAdminSheetFrame
title={<span className="flex items-center gap-2"><Bug size={15} /> {errorsFor.id} — {t('admin.plugins.errorLog')}</span>}
icon={<Bug size={19} strokeWidth={1.8} />}
title={<>{errorsFor.id} — {t('admin.plugins.errorLog')}</>}
onClose={() => setErrorsFor(null)}
>
<div className="font-mono text-[0.6875rem]">
@@ -374,9 +370,10 @@ export default function MAdminPluginsPanel() {
{/* Operator-supplied egress hosts */}
{egressFor && (
<MSheet open onClose={() => setEgressFor(null)} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.allowedHosts')}>
<MSheet open onClose={() => setEgressFor(null)} variant="bottom" material="glass" ariaLabel={t('admin.plugins.allowedHosts')}>
<MAdminSheetFrame
title={<span className="flex items-center gap-2"><Globe size={15} /> {egressFor.id} — {t('admin.plugins.allowedHosts')}</span>}
icon={<Globe size={19} strokeWidth={1.8} />}
title={<>{egressFor.id} — {t('admin.plugins.allowedHosts')}</>}
onClose={() => setEgressFor(null)}
>
{!egressFor.supported ? (
@@ -421,9 +418,10 @@ export default function MAdminPluginsPanel() {
{/* Instance-wide settings (the admin-owned scope:'instance' fields) */}
{settings.form && (
<MSheet open onClose={settings.close} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.instanceSettings')}>
<MSheet open onClose={settings.close} variant="bottom" material="glass" ariaLabel={t('admin.plugins.instanceSettings')}>
<MAdminSheetFrame
title={<span className="flex items-center gap-2"><SlidersHorizontal size={15} /> {settings.form.id} — {t('admin.plugins.instanceSettings')}</span>}
icon={<SlidersHorizontal size={19} strokeWidth={1.8} />}
title={<>{settings.form.id} — {t('admin.plugins.instanceSettings')}</>}
onClose={settings.close}
>
<div className="space-y-4">
@@ -602,8 +600,8 @@ function PickerSheet({ open, onClose, title, options, value, onPick }: {
options: Array<[string, string]>; value: string; onPick: (v: string) => void
}) {
return (
<MSheet open={open} onClose={onClose} variant="bottom" material="opaque" ariaLabel={title}>
<MAdminSheetFrame title={title} onClose={onClose}>
<MSheet open={open} onClose={onClose} variant="bottom" material="glass" ariaLabel={title}>
<MAdminSheetFrame icon={<ListChecks size={19} strokeWidth={1.8} />} title={title} onClose={onClose}>
<div className="space-y-1">
{options.map(([v, lbl]) => (
<button key={v} type="button" onClick={() => { onPick(v); onClose() }}
@@ -760,8 +758,8 @@ function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onSetti
const linkable = p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link'
const rowClass = 'flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-start text-[0.8125rem] font-semibold text-m-ink'
return (
<MSheet open onClose={onClose} variant="bottom" material="opaque" ariaLabel={p.name}>
<MAdminSheetFrame title={p.name} onClose={onClose}>
<MSheet open onClose={onClose} variant="bottom" material="glass" ariaLabel={p.name}>
<MAdminSheetFrame icon={<Puzzle size={19} strokeWidth={1.8} />} title={p.name} onClose={onClose}>
<div className="space-y-1">
{p.enabled === 1 && (
<button type="button" className={rowClass} onClick={onRestart}><RotateCw size={16} /> {t('admin.plugins.restart')}</button>
@@ -813,8 +811,8 @@ function VersionPickerSheet({ plugin, versions, failed, busy, t, onPick, onClose
t: T; onPick: (version: string) => void; onClose: () => void
}) {
return (
<MSheet open onClose={onClose} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.versionPickerTitle', { name: plugin.name })}>
<MAdminSheetFrame title={t('admin.plugins.versionPickerTitle', { name: plugin.name })} onClose={onClose}>
<MSheet open onClose={onClose} variant="bottom" material="glass" ariaLabel={t('admin.plugins.versionPickerTitle', { name: plugin.name })}>
<MAdminSheetFrame icon={<History size={19} strokeWidth={1.8} />} title={t('admin.plugins.versionPickerTitle', { name: plugin.name })} onClose={onClose}>
<div className="space-y-0.5">
{failed && <p className="px-2.5 py-2 text-xs text-[color:var(--m-st-danger)]">{t('admin.plugins.detailError')}</p>}
{!failed && !versions && <p className="px-2.5 py-2 text-xs text-m-faint">{t('common.loading')}</p>}
@@ -973,7 +971,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
const sectionH = 'text-[11px] font-semibold uppercase tracking-wider text-m-muted'
return (
<MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={item.name}>
<MSheet open onClose={onClose} variant="card" material="glass" ariaLabel={item.name}>
<div className="relative flex-none">
<Screenshot url={item.screenshotUrl} className="aspect-[16/9] w-full" iconSize={36} />
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
@@ -1195,7 +1193,7 @@ function SignatureBlockSheet({ data, entry, busy, t, onRetrust, onClose }: {
const offerRetrust = retrust !== null
return (
<MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={t('admin.plugins.sig.title', { name: data.subject.name })}>
<MSheet open onClose={onClose} variant="card" material="glass" ariaLabel={t('admin.plugins.sig.title', { name: data.subject.name })}>
<div className="flex-none px-[18px] pt-[18px]">
<div className="flex items-start gap-3">
<div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><ShieldAlert size={18} /></div>
@@ -1248,7 +1246,7 @@ function UpdateConsentSheet({ data, unsigned, t, onApprove, onLater }: {
t: T; onApprove: () => void; onLater: () => void
}) {
return (
<MSheet open onClose={onLater} variant="card" material="opaque" ariaLabel={t('admin.plugins.updateConsentTitle')}>
<MSheet open onClose={onLater} variant="card" material="glass" ariaLabel={t('admin.plugins.updateConsentTitle')}>
<div className="flex-none px-[18px] pt-[18px]">
<div className="flex items-start gap-3">
<div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><ShieldCheck size={18} /></div>
@@ -1305,7 +1303,7 @@ function DependencyResolveSheet({ data, t, busy, installedIds, onDownload, onClo
}) {
const rows = dependencyRows(data)
return (
<MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={t('admin.plugins.dep.resolveTitle')}>
<MSheet open onClose={onClose} variant="card" material="glass" ariaLabel={t('admin.plugins.dep.resolveTitle')}>
<div className="flex-none px-[18px] pt-[18px]">
<div className="flex items-start gap-3">
<div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><Puzzle size={18} /></div>
@@ -32,6 +32,7 @@ export default function MAdminResetMfaSheet({ open, onClose, user, onReset, t }:
return (
<MSheet open={open} onClose={onClose} ariaLabel={t('admin.mfa.reset')}>
<MAdminSheetFrame
icon={<ShieldOff size={19} strokeWidth={1.8} />}
title={t('admin.mfa.reset')}
onClose={onClose}
footer={
@@ -41,8 +41,9 @@ function AddCountrySheet({
onClose();
};
return (
<MSheet open={open} onClose={close} variant="card" material="opaque" ariaLabel={t('schoolCatalog.addCountry')}>
<MSheet open={open} onClose={close} variant="card" material="glass" ariaLabel={t('schoolCatalog.addCountry')}>
<MAdminSheetFrame
icon={<GraduationCap size={19} strokeWidth={1.8} />}
title={t('schoolCatalog.addCountry')}
onClose={close}
footer={
@@ -113,11 +114,12 @@ function RegionSheet({ region, busy, offline, error, onClose, onSave }: EditorPr
if (!busy) onClose();
}}
variant="card"
material="opaque"
material="glass"
ariaLabel={name.trim() || t('schoolCatalog.region')}
discardGuard={body}
>
<MAdminSheetFrame
icon={<GraduationCap size={19} strokeWidth={1.8} />}
title={name.trim() || t('schoolCatalog.region')}
onClose={() => {
if (!busy) onClose();
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { AlertTriangle, CheckCircle, ExternalLink, Fingerprint, ShieldOff, Trash2 } from 'lucide-react'
import { AlertTriangle, CheckCircle, ExternalLink, Fingerprint, ShieldOff, Trash2, UserPlus, UserCog, Download, KeyRound, RotateCcw } from 'lucide-react'
import { useAdminUserActions } from '../../../components/Admin/useAdminUserActions'
import type { TranslationFn } from '../../../types'
import type { useAdmin } from '../../../pages/admin/useAdmin'
@@ -63,6 +63,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
{/* Create user */}
<MSheet open={showCreateUser} onClose={() => setShowCreateUser(false)} ariaLabel={t('admin.createUser')}>
<MAdminSheetFrame
icon={<UserPlus size={19} strokeWidth={1.8} />}
title={t('admin.createUser')}
onClose={() => setShowCreateUser(false)}
footer={
@@ -115,6 +116,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
{/* Edit user */}
<MSheet open={!!editingUser} onClose={() => setEditingUser(null)} ariaLabel={t('admin.editUser')}>
<MAdminSheetFrame
icon={<UserCog size={19} strokeWidth={1.8} />}
title={t('admin.editUser')}
onClose={() => setEditingUser(null)}
footer={
@@ -205,6 +207,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
{/* How to update */}
<MSheet open={showUpdateModal} onClose={() => setShowUpdateModal(false)} ariaLabel={t('admin.update.howTo')}>
<MAdminSheetFrame
icon={<Download size={19} strokeWidth={1.8} />}
title={t('admin.update.howTo')}
onClose={() => setShowUpdateModal(false)}
footer={<MAdminButton onClick={() => setShowUpdateModal(false)}>{t('common.close')}</MAdminButton>}
@@ -256,6 +259,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
{/* Reset passkeys confirm */}
<MSheet open={showResetPasskeys} onClose={() => setShowResetPasskeys(false)} ariaLabel={t('admin.passkey.reset')}>
<MAdminSheetFrame
icon={<KeyRound size={19} strokeWidth={1.8} />}
title={t('admin.passkey.reset')}
onClose={() => setShowResetPasskeys(false)}
footer={
@@ -288,6 +292,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
{/* Rotate JWT confirm */}
<MSheet open={showRotateJwtModal} onClose={() => setShowRotateJwtModal(false)} ariaLabel={t('admin.rotateJwt.title')}>
<MAdminSheetFrame
icon={<RotateCcw size={19} strokeWidth={1.8} />}
title={t('admin.rotateJwt.title')}
onClose={() => setShowRotateJwtModal(false)}
footer={
+17 -7
View File
@@ -83,7 +83,7 @@ export function MAdminClampedText({ children, className = '' }: { children: stri
export function MAdminField({ label, hint, children }: { label: ReactNode; hint?: ReactNode; children: ReactNode }) {
return (
<div>
<div className="mb-[6px] text-[0.75rem] font-semibold text-m-ink">{label}</div>
<div className="mb-[5px] font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">{label}</div>
{children}
{hint && <div className="mt-[5px] font-geist text-[0.625rem] leading-relaxed text-m-muted">{hint}</div>}
</div>
@@ -94,7 +94,7 @@ export function MAdminInput({ className = '', ...props }: InputHTMLAttributes<HT
return (
<input
{...props}
className={`h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)] disabled:cursor-not-allowed disabled:opacity-60 ${className}`}
className={`h-[42px] w-full rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)] disabled:cursor-not-allowed disabled:opacity-60 ${className}`}
/>
)
}
@@ -161,11 +161,14 @@ export function MAdminButton({
/** Sheet body frame: title row + close, scrollable content, optional footer. */
export function MAdminSheetFrame({
title,
icon,
onClose,
footer,
children,
}: {
title: ReactNode
/** The icon tile in front of the title, as every phone sheet has. */
icon?: ReactNode
onClose: () => void
footer?: ReactNode
children: ReactNode
@@ -173,15 +176,22 @@ export function MAdminSheetFrame({
const { t } = useTranslation()
return (
<>
<div className="flex flex-none items-center gap-2 px-4 pb-2 pt-4">
<div className="min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">{title}</div>
{/* TileHeader's shape: the icon tile, the title and the round close. */}
<div className="flex flex-none items-center gap-3 px-[18px] pb-1 pt-4">
{icon && (
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
{icon}
</span>
)}
<div className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold leading-tight text-m-ink">{title}</div>
<MIconBtn variant="neutral" size={34} ariaLabel={t('common.close')} onClick={onClose}>
<X size={16} strokeWidth={2.2} />
<X size={15} strokeWidth={2.2} />
</MIconBtn>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">{children}</div>
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">{children}</div>
{footer && (
<div className="flex flex-none items-center justify-end gap-2 border-t border-[color:var(--m-rowbr)] px-4 py-3">
// The footer's buttons take the sheets' 42 px size; elsewhere MAdminButton stays a small pill.
<div className="flex flex-none items-center justify-end gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3 [&>button]:h-[42px] [&>button]:px-4 [&>button]:text-[length:calc(13px*var(--fs-scale-body,1))] [&>button]:font-semibold">
{footer}
</div>
)}
@@ -1,4 +1,4 @@
import { Copy, Link2, Trash2, UserPlus } from 'lucide-react'
import { Copy, Link2, Trash2, UserPlus, Ticket } from 'lucide-react'
import type { TranslationFn } from '../../../types'
import type { useAdmin } from '../../../pages/admin/useAdmin'
import { inviteMeta, inviteStatus } from '../../../pages/admin/adminModel'
@@ -167,6 +167,7 @@ export default function MAdminUsersSection({ admin, t, locale }: MAdminUsersSect
{/* Create invite sheet */}
<MSheet open={showCreateInvite} onClose={() => setShowCreateInvite(false)} ariaLabel={t('admin.invite.create')}>
<MAdminSheetFrame
icon={<Ticket size={19} strokeWidth={1.8} />}
title={t('admin.invite.create')}
onClose={() => setShowCreateInvite(false)}
footer={
@@ -6,6 +6,7 @@ import { A2_TO_A3 } from '../../../pages/atlas/atlasModel'
import type { BucketItem } from '../../../pages/atlas/atlasModel'
import type { AtlasController } from './atlasController'
import { getIntlLanguage } from '@trek/shared'
import { TileHeader } from '../trip/sheets/MTripSheetUi'
interface MAtlasBucketSheetProps {
atlas: AtlasController
@@ -87,18 +88,15 @@ export default function MAtlasBucketSheet({ atlas, open, onClose }: MAtlasBucket
return (
<MSheet open={open} onClose={close} variant="bottom" material="glass" ariaLabel={t('atlas.bucketTab')}>
<div className="flex min-h-0 flex-col p-4">
<div className="flex items-center gap-[10px] px-1 pb-3">
<Star size={17} strokeWidth={2.2} className="flex-none text-[color:var(--m-st-pending)]" />
<div className="min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">
{t('atlas.bucketTab')}
{bucketList.length > 0 && (
<span className="ms-[6px] font-geist text-[0.75rem] font-semibold tabular-nums text-m-faint">{bucketList.length}</span>
)}
</div>
<MIconBtn variant="neutral" size={34} onClick={close} ariaLabel={t('common.close')}>
<X size={16} strokeWidth={2.2} />
</MIconBtn>
<div className="flex min-h-0 flex-col px-[18px] pb-4 pt-4">
<div className="mb-3">
<TileHeader
icon={<Star size={19} strokeWidth={1.8} className="text-[color:var(--m-st-pending)]" />}
title={t('atlas.bucketTab')}
sub={bucketList.length > 0 ? String(bucketList.length) : undefined}
onClose={close}
closeLabel={t('common.close')}
/>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto">
@@ -1,4 +1,4 @@
import { Check, Loader2, MapPin, Search, X } from 'lucide-react'
import { Check, Loader2, MapPin, Search, X, MapPinPlus } from 'lucide-react'
import type { Category, TranslationFn } from '../../../types'
import { STATUS_ORDER } from '../../../pages/collections/collectionsModel'
import MSheet from '../../components/MSheet'
@@ -32,9 +32,10 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
} = useAddPlaceToCollection({ variant: 'sheet', open, collectionId, collectionName, lists, t, onClose, onAdded })
return (
<MSheet open={open} onClose={onClose} material="opaque" ariaLabel={t('collections.addPlace')}>
<SheetHeader title={t('collections.addPlace')} onClose={onClose} closeLabel={t('common.close')} />
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-[14px]">
<MSheet open={open} onClose={onClose} material="glass" ariaLabel={t('collections.addPlace')}>
<SheetHeader
icon={<MapPinPlus size={19} strokeWidth={1.8} />} title={t('collections.addPlace')} onClose={onClose} closeLabel={t('common.close')} />
<div className="m-scroll-fade min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
{/* List picker — only when opened without a fixed target list */}
{collectionId == null && (
<div className="mb-[14px]">
@@ -65,20 +66,20 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
</div>
)}
{/* Search — picking a result fills the location */}
<div className="flex items-center gap-2 rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] py-1 ps-[13px] pe-[6px]">
<div className="flex h-[42px] items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] pe-1 ps-[14px]">
<Search size={15} strokeWidth={2.2} className="flex-none text-m-muted" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void search() } }}
placeholder={t('collections.addPlaceSearch')}
className="min-w-0 flex-1 bg-transparent py-2 font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint"
className="min-w-0 flex-1 bg-transparent font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint"
/>
<button
type="button"
onClick={search}
disabled={!query.trim() || searching}
className="flex h-8 flex-none items-center gap-[5px] rounded-[10px] bg-m-act px-[14px] text-[0.75rem] font-bold text-m-actfg disabled:opacity-40"
className="flex h-[34px] flex-none items-center gap-[5px] rounded-full bg-m-act px-[14px] text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-m-actfg disabled:opacity-40"
>
{searching ? <Loader2 size={13} className="animate-spin" /> : null}
{t('common.search')}
@@ -129,7 +130,7 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
type="button"
onClick={() => setStatus(s)}
aria-pressed={on}
className={`flex flex-1 items-center justify-center gap-[5px] rounded-full py-2 text-[0.71875rem] font-bold ${
className={`flex h-[42px] min-w-0 flex-1 items-center justify-center gap-[5px] rounded-full text-[0.71875rem] font-bold ${
on ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'
}`}
>
@@ -32,10 +32,10 @@ export default function MCollCategoryPicker({ categories, value, onChange, t }:
<button
type="button"
onClick={() => setOpen(v => !v)}
className="flex w-full items-center gap-[9px] rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] text-start"
className="flex h-[42px] w-full items-center gap-[9px] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] pe-[14px] ps-[8px] text-start"
>
<span
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-lg"
className="flex h-[28px] w-[28px] flex-none items-center justify-center rounded-full"
style={{ background: tint(selectedMeta.color, '1f'), color: selectedMeta.color }}
>
<SelectedIcon size={14} strokeWidth={2.2} />
@@ -1,4 +1,4 @@
import { Image, Loader2, Search, Trash2 } from 'lucide-react'
import { Image, Loader2, Search, Trash2, Bookmark } from 'lucide-react'
import type { Collection } from '@trek/shared'
import type { TranslationFn } from '../../../types'
import { useListEditor } from '../../../components/Collections/useListEditor'
@@ -6,7 +6,7 @@ import { normalizeLinkUrl } from '../../../pages/collections/collectionsModel'
import MSheet from '../../components/MSheet'
import MCollLinksEditor from './MCollLinksEditor'
import { listCoverGradient, SWATCH_COLORS } from './collectionsMobileModel'
import { CancelPill, PrimaryPill, SheetFooter, SheetHeader, TEXTAREA_CLS } from './MCollSheetKit'
import { CancelPill, INPUT_CLS, PrimaryPill, SheetFooter, SheetHeader, TEXTAREA_CLS } from './MCollSheetKit'
// Scrims over the cover photo / Unsplash thumbs — fixed dark overlays in both themes.
const COVER_OVERLAY =
@@ -34,22 +34,24 @@ export default function MCollEditSheet({ target, onClose, onCreated, onRequestDe
coverPreview, coverQuery, setCoverQuery, coverResults, searchingCover, saving, close, pickCover, searchCover, pickUnsplash, save,
} = useListEditor({ target, onClose, onCreated, t, defaultColor: SWATCH_COLORS[0], holdLastTarget: true, normalizeLinkUrl })
const label = 'mb-[5px] font-geist text-[0.6875rem] font-bold text-m-muted'
// The sheets' eyebrow (Eyebrow), uppercase over each field.
const label = 'mb-[5px] font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint'
const canDeleteList = editing != null && editing.is_owner !== false
return (
<MSheet
open={target != null}
onClose={close}
material="opaque"
material="glass"
ariaLabel={editing ? t('collections.editListTitle') : t('collections.newList')}
>
<SheetHeader
icon={<Bookmark size={19} strokeWidth={1.8} />}
title={editing ? t('collections.editListTitle') : t('collections.newList')}
onClose={close}
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-[14px]">
<div className="m-scroll-fade min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
<div className={label}>{t('collections.coverImage')}</div>
<button
type="button"
@@ -75,13 +77,13 @@ export default function MCollEditSheet({ target, onClose, onCreated, onRequestDe
onChange={e => setCoverQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void searchCover() } }}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
className="min-w-0 flex-1 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 py-[10px] font-geist text-[0.71875rem] text-m-ink outline-none placeholder:text-m-faint"
className={`${INPUT_CLS} min-w-0 flex-1`}
/>
<button
type="button"
onClick={searchCover}
disabled={searchingCover || (!coverQuery.trim() && !name.trim())}
className="flex flex-none items-center gap-[5px] rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 py-[10px] text-[0.71875rem] font-semibold text-m-ink disabled:opacity-40"
className="flex h-[42px] flex-none items-center gap-[5px] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink disabled:opacity-40"
>
{searchingCover ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} strokeWidth={2} />} Unsplash
</button>
@@ -113,7 +115,7 @@ export default function MCollEditSheet({ target, onClose, onCreated, onRequestDe
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('collections.listNamePlaceholder')}
className="w-full box-border rounded-[12px] border-[1.5px] border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] font-[inherit] text-[0.84375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint"
className={`${INPUT_CLS} font-semibold`}
/>
<div className={`${label} mt-[14px] mb-[6px]`}>{t('collections.listColor')}</div>
@@ -92,8 +92,9 @@ export default function MCollLabelsSheet({
const title = mode === 'assign' ? t('collections.labels.assignN', { count: selectedCount }) : t('collections.labels.manage')
return (
<MSheet open={open} onClose={onClose} material="opaque" ariaLabel={title}>
<SheetHeader title={title} onClose={onClose} closeLabel={t('common.close')} />
<MSheet open={open} onClose={onClose} material="glass" ariaLabel={title}>
<SheetHeader
icon={<Tag size={19} strokeWidth={1.8} />} title={title} onClose={onClose} closeLabel={t('common.close')} />
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-4">
{mode === 'manage' && (
<>
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Loader2, ListPlus } from 'lucide-react'
import type { Collection } from '@trek/shared'
import type { TranslationFn } from '../../../types'
import MSheet from '../../components/MSheet'
@@ -40,8 +40,9 @@ export default function MCollListPickerSheet({ mode, lists, count, onPick, onClo
}
return (
<MSheet open={mode != null} onClose={onClose} material="opaque" ariaLabel={title}>
<SheetHeader title={title} onClose={onClose} closeLabel={t('common.close')} />
<MSheet open={mode != null} onClose={onClose} material="glass" ariaLabel={title}>
<SheetHeader
icon={<ListPlus size={19} strokeWidth={1.8} />} title={title} onClose={onClose} closeLabel={t('common.close')} />
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
{lists.length === 0 ? (
<div className="py-8 text-center font-geist text-[0.78125rem] text-m-faint">{t('collections.noOtherLists')}</div>
@@ -68,7 +68,7 @@ export default function MCollPlaceSheet({
'flex flex-1 items-center justify-center gap-[6px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-2 py-[11px] text-[0.78125rem] font-semibold text-m-ink'
return (
<MSheet open={place != null} onClose={onClose} material="opaque" className="!rounded-[24px]" ariaLabel={held?.name}>
<MSheet open={place != null} onClose={onClose} material="glass" className="!rounded-[24px]" ariaLabel={held?.name}>
{held && (
<>
{/* Hero: auto cover (photo when available, the design gradient otherwise) */}
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Check, ChevronDown, Clock, Crown, Loader2, LogOut, UserPlus, X } from 'lucide-react'
import { Check, ChevronDown, Clock, Crown, Loader2, LogOut, UserPlus, X, Share2 } from 'lucide-react'
import type { CollectionMember } from '@trek/shared'
import { COLLECTION_ROLES } from '@trek/shared'
import type { TranslationFn } from '../../../types'
@@ -54,8 +54,9 @@ export default function MCollShareSheet({
const badge = 'inline-flex flex-none items-center gap-[3px] font-geist text-[0.625rem] font-extrabold'
return (
<MSheet open={open} onClose={onClose} material="opaque" ariaLabel={t('collections.share.title')}>
<SheetHeader title={t('collections.share.titleNamed', { name: collectionName })} onClose={onClose} closeLabel={t('common.close')} />
<MSheet open={open} onClose={onClose} material="glass" ariaLabel={t('collections.share.title')}>
<SheetHeader
icon={<Share2 size={19} strokeWidth={1.8} />} title={t('collections.share.titleNamed', { name: collectionName })} onClose={onClose} closeLabel={t('common.close')} />
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
<div className="mb-2 flex items-center gap-[7px]">
<Eyebrow>{t('collections.share.members').toUpperCase()}</Eyebrow>
@@ -140,7 +141,7 @@ export default function MCollShareSheet({
onClick={handleInvite}
disabled={selectedUserId == null || inviting}
aria-label={t('collections.share.sendInvite')}
className="flex w-11 flex-none items-center justify-center rounded-[12px] bg-m-act text-m-actfg disabled:opacity-40"
className="flex h-[42px] w-[42px] flex-none items-center justify-center self-center rounded-full bg-m-act text-m-actfg disabled:opacity-40"
>
{inviting ? <Loader2 size={16} className="animate-spin" /> : <UserPlus size={16} strokeWidth={2.2} />}
</button>
@@ -4,22 +4,33 @@ import { X } from 'lucide-react'
/** Shared sheet scaffolding of the collections sheets (opaque floating cards). */
export const INPUT_CLS =
'w-full box-border rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint'
'w-full box-border rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-[10px] font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint'
export const TEXTAREA_CLS =
'w-full box-border resize-none rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] font-geist text-[0.78125rem] leading-[1.5] text-m-ink outline-none placeholder:text-m-faint'
'w-full box-border resize-none rounded-[18px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-[10px] font-geist text-[0.78125rem] leading-[1.5] text-m-ink outline-none placeholder:text-m-faint'
interface SheetHeaderProps {
title: ReactNode
/** The icon tile in front of the title, as every phone sheet has. */
icon?: ReactNode
sub?: ReactNode
onClose: () => void
closeLabel: string
}
/** Sheet header: 17px/700 title + 34px round close, hairline below. */
export function SheetHeader({ title, onClose, closeLabel }: SheetHeaderProps) {
/** Sheet header: the icon tile, the 17px/700 title (with an optional line under it) and the 34px round close, as TileHeader. */
export function SheetHeader({ title, icon, sub, onClose, closeLabel }: SheetHeaderProps) {
return (
<div className="flex flex-none items-center gap-[11px] border-b border-[color:var(--m-rowbr)] px-[18px] pb-[10px] pt-4">
<div className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold text-m-ink">{title}</div>
<div className="flex flex-none items-center gap-3 px-[18px] pb-1 pt-4">
{icon && (
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
{icon}
</div>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-[1.0625rem] font-bold leading-tight text-m-ink">{title}</div>
{sub && <div className="truncate font-geist text-[0.71875rem] text-m-muted">{sub}</div>}
</div>
<button
type="button"
onClick={onClose}
@@ -55,7 +66,7 @@ export function CancelPill({ onClick, disabled, className = '', children }: Pill
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold text-m-ink disabled:opacity-40 ${className}`}
className={`flex h-[42px] items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink disabled:opacity-40 ${className}`}
>
{children}
</button>
@@ -69,7 +80,7 @@ export function PrimaryPill({ onClick, disabled, className = '', children }: Pil
type="button"
onClick={onClick}
disabled={disabled}
className={`flex items-center gap-[6px] rounded-full bg-m-act px-[18px] py-[9px] text-[0.78125rem] font-bold text-m-actfg disabled:opacity-40 ${className}`}
className={`flex h-[42px] items-center justify-center gap-[6px] rounded-full bg-m-act px-4 text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-actfg disabled:opacity-40 ${className}`}
>
{children}
</button>
@@ -211,8 +211,13 @@ function MCollTourSheet({
const tour = takeOver.tour;
const title = tour?.name ?? t('tours.collection.add');
return (
<MSheet open={takeOver.open} onClose={takeOver.close} material="opaque" ariaLabel={title}>
<SheetHeader title={title} onClose={takeOver.close} closeLabel={t('common.close')} />
<MSheet open={takeOver.open} onClose={takeOver.close} material="glass" ariaLabel={title}>
<SheetHeader
icon={<Route size={19} strokeWidth={1.8} />}
title={title}
onClose={takeOver.close}
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
{tour && (
<p className="mb-3 mt-0 font-geist text-m-muted" style={fs(11)}>
@@ -1,4 +1,4 @@
import { CalendarDays, Copy, Loader2, MapPin, Search } from 'lucide-react'
import { CalendarDays, Copy, Loader2, MapPin, Search, Luggage } from 'lucide-react'
import type { TranslationFn } from '../../../types'
import { useTripCopyPicker } from '../../../components/Collections/useTripCopyPicker'
import MSheet from '../../components/MSheet'
@@ -23,8 +23,9 @@ export default function MCollTripPickerSheet({ open, count, onCopy, onClose, t }
const title = t('collections.copyN', { count })
return (
<MSheet open={open} onClose={onClose} material="opaque" ariaLabel={title}>
<SheetHeader title={title} onClose={onClose} closeLabel={t('common.close')} />
<MSheet open={open} onClose={onClose} material="glass" ariaLabel={title}>
<SheetHeader
icon={<Luggage size={19} strokeWidth={1.8} />} title={title} onClose={onClose} closeLabel={t('common.close')} />
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
<div className="mb-2 flex h-9 items-center gap-2 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px]">
<Search size={15} strokeWidth={2.2} className="flex-none text-m-muted" />
@@ -12,7 +12,6 @@ import { STATUS_ORDER } from '../../../pages/collections/collectionsModel'
import type { StatusFilter } from '../../../store/collectionStore'
import { ALL_SAVED } from '../../../store/collectionStore'
import CollectionMap from '../../../components/Collections/CollectionMap'
import MSheet from '../../components/MSheet'
import MCollPlaceRow from './MCollPlaceRow'
import MCollPlaceSheet from './MCollPlaceSheet'
import MCollAddSheet from './MCollAddSheet'
@@ -24,7 +23,7 @@ import MCollListPickerSheet from './MCollListPickerSheet'
import MCollTours from './MCollTours'
import MCollInvites from './MCollInvites'
import { STATUS_SPEC } from './collectionsMobileModel'
import { CancelPill, PrimaryPill } from './MCollSheetKit'
import MConfirmSheet from '../settings/MConfirmSheet'
const HEADER_CIRCLE =
'flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]'
@@ -73,16 +72,25 @@ export default function MCollections() {
// The bottom-nav "+" hands off via the ?create= contract.
const [searchParams, setSearchParams] = useSearchParams()
const createParam = searchParams.get('create')
const { activeId, canEdit, setShowAddPlace, ownedLists } = c
const { activeId, canEdit, setShowAddPlace, ownedLists, setEditorTarget } = c
const listCount = c.collections.length
const listsLoading = c.loading
useEffect(() => {
if (createParam !== 'place' && createParam !== '1') return
// Wait for the lists: whether there is one decides what "+" means.
if (listsLoading) return
const next = new URLSearchParams(searchParams)
next.delete('create')
setSearchParams(next, { replace: true })
// Without a list yet, "+" starts the first one; places come after.
if (listCount === 0) {
setEditorTarget('new')
return
}
// Open whenever the user can add somewhere: an editable active list, or at
// least one owned list to pick as the target in the sheet.
if ((typeof activeId === 'number' && canEdit) || ownedLists.length > 0) setShowAddPlace(true)
}, [createParam, searchParams, setSearchParams, activeId, canEdit, ownedLists.length, setShowAddPlace])
}, [createParam, searchParams, setSearchParams, activeId, canEdit, ownedLists.length, setShowAddPlace, listsLoading, listCount, setEditorTarget])
// Close transient popovers when the active list changes under them.
useEffect(() => {
@@ -219,12 +227,8 @@ export default function MCollections() {
</div>
) : noLists ? (
<div className="mt-6">
{/* The dock's "+" starts the first list (see the ?create= handoff above). */}
<EmptyNote scene="collections" title={t('collections.empty.firstTitle')} />
<div className="mt-4 flex justify-center">
<PrimaryPill onClick={openNewList}>
<Bookmark size={14} strokeWidth={2.2} /> {t('collections.newList')}
</PrimaryPill>
</div>
</div>
) : (
<>
@@ -541,23 +545,16 @@ export default function MCollections() {
/>
{/* Delete-list confirm */}
<MSheet
<MConfirmSheet
open={c.confirmDeleteList != null}
onClose={() => c.setConfirmDeleteList(null)}
material="opaque"
ariaLabel={t('collections.deleteList')}
>
<div className="px-[18px] pb-[18px] pt-4">
<div className="text-[1.0625rem] font-bold text-m-ink">{t('collections.deleteList')}</div>
<div className="mt-2 font-geist text-[0.78125rem] leading-[1.5] text-m-muted">{t('collections.deleteListConfirm')}</div>
<div className="mt-4 flex items-center gap-2">
<CancelPill className="ms-auto" onClick={() => c.setConfirmDeleteList(null)}>{t('common.cancel')}</CancelPill>
<PrimaryPill onClick={c.handleDeleteList} className="!bg-[color:var(--m-st-danger)] !text-white">
<Trash2 size={14} strokeWidth={2.2} /> {t('common.delete')}
</PrimaryPill>
</div>
</div>
</MSheet>
title={t('collections.deleteList')}
message={t('collections.deleteListConfirm')}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
danger
onConfirm={c.handleDeleteList}
/>
</div>
)
}
@@ -21,13 +21,13 @@ import { entityGradient } from '../../../utils/gradients'
import MGlassBar from '../../components/MGlassBar'
import MIconBtn from '../../components/MIconBtn'
import MSegmented from '../../components/MSegmented'
import MSheet from '../../components/MSheet'
import MUserMenu from './MUserMenu'
import { useMobileDashOrder, useMobileDashVisibility, MobileDashWidget } from './MDashWidgets'
import MNewTripSheet from './MNewTripSheet'
import type { MobileDashToken } from '@trek/shared'
import { usePullToRefresh } from '../../usePullToRefresh'
import MPullIndicator from '../../components/MPullIndicator'
import SharedConfirmSheet from '../settings/MConfirmSheet'
type TripFilter = 'planned' | 'archive' | 'completed'
@@ -581,36 +581,11 @@ function ListStat({ value, label }: { value: number; label: string }): React.Rea
)
}
// ── Confirm sheet (delete / copy) ────────────────────────────────────────────
function MConfirmSheet({ open, title, message, confirmLabel, danger = false, onConfirm, onClose }: {
// ── Confirm sheet (delete / copy): the shared one, with the common cancel ─────
function MConfirmSheet(props: {
open: boolean; title: string; message: string; confirmLabel: string
danger?: boolean; onConfirm: () => void; onClose: () => void
}): React.ReactElement {
const { t } = useTranslation()
return (
<MSheet open={open} onClose={onClose} variant="card" ariaLabel={title}>
<div className="p-5">
<div className="text-[1.0625rem] font-bold">{title}</div>
<div className="mt-2 text-[0.8125rem] leading-relaxed text-m-muted">{message}</div>
<div className="mt-5 flex gap-2">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-full bg-[color:var(--m-ic)] py-[10px] text-[0.8125rem] font-semibold text-m-ink"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={onConfirm}
className={`flex-1 rounded-full py-[10px] text-[0.8125rem] font-semibold ${
danger ? 'bg-[color:var(--m-st-danger)] text-white' : 'bg-m-act text-m-actfg'
}`}
>
{confirmLabel}
</button>
</div>
</div>
</MSheet>
)
return <SharedConfirmSheet {...props} cancelLabel={t('common.cancel')} />
}
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import { Archive, ArchiveRestore, Bell, Camera, Search, X } from 'lucide-react'
import { Archive, ArchiveRestore, Bell, Camera, Luggage, Search, X } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { useCanDo } from '../../../store/permissionsStore'
import { useSettingsStore } from '../../../store/settingsStore'
@@ -11,8 +11,8 @@ import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.
import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel'
import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MListRow from '../../components/MListRow'
import { FIELD_AREA_CLS } from '../trip/sheets/PlSheetChrome'
import { Eyebrow, SHEET_FIELD, SHEET_PRIMARY, SHEET_ROUND_PRIMARY, SHEET_SECONDARY, SheetSection, TileHeader } from '../trip/sheets/MTripSheetUi'
import MDayImpactList from '../../components/MDayImpactList'
import MConfirmSheet from '../settings/MConfirmSheet'
import { useTripRangeGuard, type RangeRemoval } from '../../../hooks/useTripRangeGuard'
@@ -31,17 +31,12 @@ interface MNewTripSheetProps {
onArchive?: () => void
}
const BOX_CLS = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px_12px]'
const INPUT_CLS = 'w-full border-none bg-transparent pt-[2px] font-[inherit] text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint'
/** A date picker in the sheets' round field. */
const DATE_FIELD = 'flex h-[42px] items-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px]'
/** The reminder presets the desktop form offers; anything else is a custom number of days. */
const REMINDER_PRESETS = [0, 1, 3, 9]
function FieldLabel({ children }: { children: React.ReactNode }): React.ReactElement {
return (
<div className="font-geist text-[0.5625rem] font-bold uppercase tracking-[.1em] text-m-faint">{children}</div>
)
}
/**
* Create/edit trip sheet — the mobile counterpart of TripFormModal's core flow:
@@ -151,79 +146,78 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
}
}
const boxCls = BOX_CLS
const inputCls = INPUT_CLS
const removalList = pendingRemoval && pendingRemoval.removal !== 'unknown' ? pendingRemoval.removal : null
return (
<>
<MSheet open={open} onClose={onClose} variant="card" material="opaque" ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
<div className="flex items-center gap-[11px] p-[16px_16px_0]">
<div className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold">
{isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
</div>
<MIconBtn ariaLabel={t('common.cancel')} variant="neutral" size={34} onClick={onClose}>
<X size={16} strokeWidth={2.2} />
</MIconBtn>
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
<div className="flex-none px-[18px] pt-4">
<TileHeader
icon={<Luggage size={19} strokeWidth={1.8} />}
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
sub={isEditing ? trip?.title : undefined}
onClose={onClose}
closeLabel={t('common.cancel')}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div className="m-scroll-fade mt-2 flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
{error && (
<div className="mb-3 rounded-[14px] bg-[color:var(--m-ic)] p-[11px_12px] text-[0.75rem] font-semibold text-[color:var(--m-st-danger)]">
<div className="flex-none rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-caption font-semibold text-[color:var(--m-st-danger)]">
{error}
</div>
)}
<div className={boxCls}>
<FieldLabel>{t('dashboard.tripTitle')}</FieldLabel>
<SheetSection title={t('dashboard.tripTitle')}>
<input
value={title}
onChange={e => { if (canEditTrip) { setError(''); setTitle(e.target.value) } }}
readOnly={!canEditTrip}
placeholder={t('dashboard.tripTitlePlaceholder')}
className={inputCls}
aria-label={t('dashboard.tripTitle')}
className={`${SHEET_FIELD} w-full flex-none font-semibold`}
/>
</div>
<div className={`${boxCls} mt-2`}>
<FieldLabel>{t('dashboard.tripDescription')}</FieldLabel>
<Eyebrow className="mt-1 uppercase">{t('dashboard.tripDescription')}</Eyebrow>
<textarea
value={description}
onChange={e => { if (canEditTrip) { setError(''); setDescription(e.target.value) } }}
readOnly={!canEditTrip}
placeholder={t('dashboard.tripDescriptionPlaceholder')}
aria-label={t('dashboard.tripDescription')}
rows={2}
className={`${inputCls} resize-none text-[0.8125rem] font-medium`}
className={FIELD_AREA_CLS}
/>
</div>
</SheetSection>
<div className="mt-2 flex gap-2">
<div className={`${boxCls} min-w-0 flex-1`}>
<FieldLabel>{t('dashboard.startDate')}</FieldLabel>
<CustomDatePicker
value={startDate}
onChange={v => { if (canEditTrip) changeStart(v) }}
placeholder={t('dashboard.startDate')}
borderless
style={{ marginTop: 3 }}
/>
<SheetSection>
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('dashboard.startDate')}</Eyebrow>
<div className={DATE_FIELD}>
<CustomDatePicker
value={startDate}
onChange={v => { if (canEditTrip) changeStart(v) }}
placeholder={t('dashboard.startDate')}
borderless
/>
</div>
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('dashboard.endDate')}</Eyebrow>
<div className={DATE_FIELD}>
<CustomDatePicker
value={endDate}
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
placeholder={t('dashboard.endDate')}
borderless
/>
</div>
</div>
</div>
<div className={`${boxCls} min-w-0 flex-1`}>
<FieldLabel>{t('dashboard.endDate')}</FieldLabel>
<CustomDatePicker
value={endDate}
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
placeholder={t('dashboard.endDate')}
borderless
style={{ marginTop: 3 }}
/>
</div>
</div>
{!startDate && !endDate && (
<>
<div className={`${boxCls} mt-2`}>
<FieldLabel>{t('dashboard.dayCount')}</FieldLabel>
{!startDate && !endDate && (
<>
<Eyebrow className="mt-1 uppercase">{t('dashboard.dayCount')}</Eyebrow>
<input
type="number"
inputMode="numeric"
@@ -237,15 +231,15 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
setDayCount(raw === '' ? '' : Math.min(MAX_TRIP_DAYS, Math.max(1, Math.floor(Number(raw)) || 1)))
}}
readOnly={!canEditTrip}
className={inputCls}
aria-label={t('dashboard.dayCount')}
className={`${SHEET_FIELD} w-full flex-none`}
/>
</div>
{!isEditing && <div className="mt-[6px] px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</div>}
</>
)}
{!isEditing && <p className="px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</p>}
</>
)}
</SheetSection>
<div className="mt-2">
<FieldLabel>{t('dashboard.currency')}</FieldLabel>
<SheetSection title={t('dashboard.currency')}>
<CustomSelect
value={currency}
onChange={v => { if (canEditTrip) { setError(''); setCurrency(String(v)) } }}
@@ -253,48 +247,46 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
searchable
size="sm"
options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
style={{ width: '100%', marginTop: 5 }}
style={{ width: '100%' }}
/>
</div>
</SheetSection>
{showReminder && (
<ReminderField
enabled={remindersEnabled}
days={reminderDays}
custom={customReminder}
onPreset={days => { setError(''); setReminderDays(days); setCustomReminder(false) }}
onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}
onDays={setReminderDays}
/>
<SheetSection>
<ReminderField
enabled={remindersEnabled}
days={reminderDays}
custom={customReminder}
onPreset={days => { setError(''); setReminderDays(days); setCustomReminder(false) }}
onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}
onDays={setReminderDays}
/>
</SheetSection>
)}
{canUploadCover && <CoverField cover={cover} title={title} />}
{canUploadCover && (
<SheetSection>
<CoverField cover={cover} title={title} />
</SheetSection>
)}
{isEditing && onArchive && (
<div className="mt-2 rounded-[14px] bg-[color:var(--m-ic)]">
<MListRow
icon={trip?.is_archived ? ArchiveRestore : Archive}
label={trip?.is_archived ? t('dashboard.restore') : t('dashboard.archive')}
onClick={() => { onArchive(); onClose() }}
/>
</div>
<button
type="button"
onClick={() => { onArchive(); onClose() }}
className={`${SHEET_SECONDARY} flex-none`}
>
{trip?.is_archived ? <ArchiveRestore size={14} strokeWidth={2.2} /> : <Archive size={14} strokeWidth={2.2} />}
{trip?.is_archived ? t('dashboard.restore') : t('dashboard.archive')}
</button>
)}
</div>
<div className="flex gap-2 p-[0_16px_16px]">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-full bg-[color:var(--m-ic)] py-[10px] text-[0.8125rem] font-semibold text-m-ink"
>
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<button type="button" onClick={onClose} className={`${SHEET_SECONDARY} ms-auto`}>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSave}
disabled={busy}
className="flex-1 rounded-full bg-m-act py-[10px] text-[0.8125rem] font-semibold text-m-actfg disabled:opacity-50"
>
<button type="button" onClick={handleSave} disabled={busy} className={SHEET_PRIMARY}>
{busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
</button>
</div>
@@ -340,10 +332,10 @@ function ReminderField({ enabled, days, custom, onPreset, onCustom, onDays }: {
}): React.ReactElement {
const { t } = useTranslation()
return (
<div className={`${BOX_CLS} mt-2`}>
<FieldLabel>
<div>
<Eyebrow className="uppercase">
<span className="inline-flex items-center gap-1"><Bell size={10} strokeWidth={2.4} />{t('trips.reminder')}</span>
</FieldLabel>
</Eyebrow>
{!enabled ? (
<p className="mt-1 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] leading-snug text-m-muted">{t('trips.reminderDisabledHint')}</p>
) : (
@@ -429,21 +421,20 @@ function CoverField({ cover, title }: { cover: ReturnType<typeof useTripCoverPic
)}
<div className="mt-2 flex gap-2">
<div className={`${BOX_CLS} min-w-0 flex-1 py-[9px]`}>
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void cover.search() } }}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
className={`${INPUT_CLS} pt-0 text-[0.8125rem] font-medium`}
/>
</div>
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void cover.search() } }}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
aria-label={t('dashboard.unsplashSearchPlaceholder')}
className={SHEET_FIELD}
/>
<button
type="button"
onClick={cover.search}
disabled={searching || (!searchQuery.trim() && !title.trim())}
aria-label={t('dashboard.searchUnsplash')}
className="flex h-auto w-[42px] flex-none items-center justify-center rounded-[14px] bg-m-act text-m-actfg disabled:opacity-50"
className={SHEET_ROUND_PRIMARY}
>
<Search size={15} strokeWidth={2.2} />
</button>
@@ -1,6 +1,6 @@
import { Feather, Calendar, MapPin, Check, X } from 'lucide-react'
import { Feather, Calendar, MapPin, Check, NotebookPen } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import { SHEET_PRIMARY, SHEET_SECONDARY, TileHeader } from '../trip/sheets/MTripSheetUi'
import { useTranslation } from '../../../i18n'
import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
@@ -38,24 +38,27 @@ export default function MJourneyCreateSheet({
const { t } = useTranslation()
return (
<MSheet open={open} onClose={onClose} variant="card" material="opaque" ariaLabel={t('journey.frontpage.createJourney')}>
<div className="flex flex-none items-center border-b border-[color:var(--m-rowbr)] px-[18px] pb-[10px] pt-4">
<div className="flex-1 text-[1.0625rem] font-bold">{t('journey.frontpage.createJourney')}</div>
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.cancel')}>
<X size={15} strokeWidth={2.2} />
</MIconBtn>
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('journey.frontpage.createJourney')}>
<div className="flex-none px-[18px] pt-4">
<TileHeader
icon={<NotebookPen size={19} strokeWidth={1.8} />}
title={t('journey.frontpage.createJourney')}
onClose={onClose}
closeLabel={t('common.cancel')}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-3">
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
<div className="mb-[6px] font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">
{t('journey.frontpage.journeyName')}
</div>
<div className="flex items-center gap-[10px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-3">
<div className="flex h-[42px] items-center gap-[10px] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px]">
<Feather size={16} strokeWidth={2} className="flex-none text-m-muted" />
<input
value={title}
onChange={e => onTitleChange(e.target.value)}
placeholder={t('journey.frontpage.namePlaceholder')}
aria-label={t('journey.frontpage.journeyName')}
className="min-w-0 flex-1 bg-transparent text-[0.875rem] font-semibold text-m-ink outline-none placeholder:text-m-faint"
/>
</div>
@@ -120,19 +123,10 @@ export default function MJourneyCreateSheet({
</div>
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<button
type="button"
onClick={onClose}
className="ms-auto rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold"
>
<button type="button" onClick={onClose} className={`${SHEET_SECONDARY} ms-auto`}>
{t('common.cancel')}
</button>
<button
type="button"
onClick={onCreate}
disabled={!title.trim()}
className="flex-1 rounded-full bg-m-act px-[18px] py-[9px] text-center text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-40"
>
<button type="button" onClick={onCreate} disabled={!title.trim()} className={SHEET_PRIMARY}>
{t('journey.create')}
</button>
</div>
@@ -528,7 +528,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
{/* Opaque, not the glass default: these two sit over a full-screen map whose
card carousel is bright and busy, and the frosted material let the titles
behind read straight through the menu. */}
<MSheet open={showUploadMenu} onClose={() => setShowUploadMenu(false)} variant="bottom" material="opaque" ariaLabel={t('common.upload')}>
<MSheet open={showUploadMenu} onClose={() => setShowUploadMenu(false)} variant="bottom" material="glass" ariaLabel={t('common.upload')}>
<div className="flex flex-col gap-2 p-[10px]">
<MListRow
icon={Camera}
@@ -547,7 +547,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
</MSheet>
{/* Journey actions: book export, suggestions switch, settings */}
<MSheet open={showActionMenu} onClose={() => setShowActionMenu(false)} variant="bottom" material="opaque" ariaLabel={t('files.menu')}>
<MSheet open={showActionMenu} onClose={() => setShowActionMenu(false)} variant="bottom" material="glass" ariaLabel={t('files.menu')}>
<div className="flex flex-col gap-1 p-[10px]">
<div className="flex items-center gap-[11px] px-[10px] py-[11px]">
<EyeOff size={16} strokeWidth={2} className="flex-none text-m-muted" />
@@ -1,8 +1,9 @@
import { useRef, useState, type CSSProperties } from 'react'
import { Briefcase, Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff, Play } from 'lucide-react'
import { Briefcase, Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff, Play, NotebookPen } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
import { SHEET_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi'
import { useTranslation } from '../../../i18n'
import CustomTimePicker from '../../../components/shared/CustomTimePicker'
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
@@ -124,11 +125,15 @@ export default function MJourneyEntrySheet({
open
onClose={handleClose}
variant="card"
material="opaque"
material="glass"
ariaLabel={entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
>
<div className="flex flex-none items-center border-b border-[color:var(--m-rowbr)] px-[18px] pb-[10px] pt-4">
<span className="flex-1 text-[1.0625rem] font-bold">
{/* TileHeader's shape, with the move buttons between the title and the close. */}
<div className="flex flex-none items-center gap-3 px-[18px] pb-1 pt-4">
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
<NotebookPen size={19} strokeWidth={1.8} />
</span>
<span className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold leading-tight">
{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
</span>
{(onMoveEarlier || onMoveLater) && (
@@ -146,7 +151,7 @@ export default function MJourneyEntrySheet({
</MIconBtn>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-3">
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
{/* Quick capture had no title field at all: it asked for a note and nothing
else, so every entry caught on the move arrived nameless and the day's
list read as a column of identical placeholders (discussion #2299). The
@@ -713,17 +718,17 @@ export default function MJourneyEntrySheet({
<button
type="button"
onClick={onDelete}
className="flex items-center gap-[5px] text-[0.75rem] font-bold text-[color:var(--m-st-danger)]"
aria-label={t('common.delete')}
className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-[color:var(--m-st-danger)]"
>
<Trash2 size={13} strokeWidth={2} />
{t('common.delete')}
<Trash2 size={15} strokeWidth={2} />
</button>
)}
{!readOnly && captureOnly && (
<button
type="button"
onClick={() => setCaptureOnly(false)}
className="whitespace-nowrap rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold"
className={`${SHEET_SECONDARY} whitespace-nowrap`}
>
{t('journey.editor.addDetails')}
</button>
@@ -731,7 +736,7 @@ export default function MJourneyEntrySheet({
<button
type="button"
onClick={handleClose}
className="ms-auto rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold"
className={`${SHEET_SECONDARY} ms-auto`}
>
{t('common.cancel')}
</button>
@@ -740,7 +745,7 @@ export default function MJourneyEntrySheet({
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-full bg-m-act px-[18px] py-[9px] text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-50"
className={SHEET_PRIMARY}
>
{saving ? t('common.saving') : t('common.save')}
</button>
@@ -1,8 +1,17 @@
import { useRef, useState } from 'react'
import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2, ListChecks, RefreshCw, Share as ShareIcon } from 'lucide-react'
import { canShareLink, shareLink } from '../../../native/shareLink'
import { useRef, useState, type ReactNode } from 'react'
import { Image, Plus, Trash2, UserPlus, UserMinus, Unlink, Settings2, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2, ListChecks, RefreshCw } from 'lucide-react'
import MLinkField from '../../components/MLinkField'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import {
Eyebrow,
SHEET_FIELD,
SHEET_PILL,
SHEET_PRIMARY,
SHEET_ROUND,
SHEET_SECONDARY,
SheetSection,
TileHeader,
} from '../trip/sheets/MTripSheetUi'
import MToggle from '../../components/MToggle'
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
import { useTranslation } from '../../../i18n'
@@ -58,277 +67,239 @@ export default function MJourneySettingsSheet({
if (availableTrips.length === 0) await loadAvailableTrips()
}
const eyebrow = 'font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint'
const inputShell = 'w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[13px] py-[11px] text-m-ink outline-none placeholder:text-m-faint'
const dashedBtn = 'flex w-full items-center justify-center gap-[6px] rounded-xl border border-dashed border-[color:var(--m-rowbr)] p-[11px] text-[0.75rem] font-semibold text-m-muted'
const solidBtn = 'flex w-full items-center justify-center gap-[6px] rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px] text-[0.75rem] font-semibold'
const linkedTripIds = journey.trips.map(tr => tr.trip_id)
const unlinkedTrips = availableTrips.filter(tr => !linkedTripIds.includes(tr.id))
return (
<MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={t('journey.settings.title')}>
<div className="flex flex-none items-center border-b border-[color:var(--m-rowbr)] px-[18px] pb-[10px] pt-4">
<span className="flex-1 text-[1.0625rem] font-bold">{t('journey.settings.title')}</span>
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.cancel')}>
<X size={15} strokeWidth={2.2} />
</MIconBtn>
<MSheet open onClose={onClose} variant="card" material="glass" ariaLabel={t('journey.settings.title')}>
<div className="flex-none px-[18px] pt-4">
<TileHeader
icon={<Settings2 size={19} strokeWidth={1.8} />}
title={t('journey.settings.title')}
sub={journey.title}
onClose={onClose}
closeLabel={t('common.close')}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-3">
{/* Cover */}
<div className={`${eyebrow} mb-[6px]`}>{t('journey.settings.coverImage')}</div>
<input ref={coverRef} type="file" accept="image/*" className="hidden" onChange={handleCoverUpload} />
<div className="relative h-[120px] overflow-hidden rounded-2xl">
{coverSrc ? (
<img src={coverSrc} alt="" className="h-full w-full object-cover" />
) : (
<div className="h-full w-full" style={{ background: pickGradient(journey.id) }} />
)}
<button
type="button"
onClick={() => coverRef.current?.click()}
className="absolute inset-0 flex items-center justify-center gap-[6px] bg-black/30 text-[0.78125rem] font-bold text-white"
>
<Image size={14} strokeWidth={2.2} />
{coverSrc ? t('journey.settings.changeCover') : t('journey.settings.addCover')}
</button>
</div>
<div className="m-scroll-fade mt-2 flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
<SheetSection title={t('journey.settings.coverImage')}>
<input ref={coverRef} type="file" accept="image/*" className="hidden" onChange={handleCoverUpload} />
<div className="relative h-[120px] overflow-hidden rounded-[14px]">
{coverSrc ? (
<img src={coverSrc} alt="" className="h-full w-full object-cover" />
) : (
<div className="h-full w-full" style={{ background: pickGradient(journey.id) }} />
)}
<button
type="button"
onClick={() => coverRef.current?.click()}
className="absolute inset-0 flex items-center justify-center gap-[6px] bg-black/30 text-caption font-bold text-white"
>
<Image size={14} strokeWidth={2.2} />
{coverSrc ? t('journey.settings.changeCover') : t('journey.settings.addCover')}
</button>
</div>
</SheetSection>
{/* Name + subtitle */}
<div className={`${eyebrow} mb-[5px] mt-[14px]`}>{t('journey.settings.name')}</div>
<input value={title} onChange={e => setTitle(e.target.value)} className={`${inputShell} text-[0.84375rem] font-semibold`} />
<div className={`${eyebrow} mb-[5px] mt-3`}>{t('journey.settings.subtitle')}</div>
<input
value={subtitle}
onChange={e => setSubtitle(e.target.value)}
placeholder={t('journey.settings.subtitlePlaceholder')}
className={`${inputShell} text-[0.8125rem] font-medium`}
/>
<SheetSection title={t('journey.settings.name')}>
<input value={title} onChange={e => setTitle(e.target.value)} aria-label={t('journey.settings.name')} className={`${SHEET_FIELD} w-full flex-none font-semibold`} />
<Eyebrow className="mt-1 uppercase">{t('journey.settings.subtitle')}</Eyebrow>
<input
value={subtitle}
onChange={e => setSubtitle(e.target.value)}
placeholder={t('journey.settings.subtitlePlaceholder')}
aria-label={t('journey.settings.subtitle')}
className={`${SHEET_FIELD} w-full flex-none`}
/>
</SheetSection>
{/* Trip GPX tracks on the journey map (#2194) */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.settings.tracks')}</div>
<div className="flex items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
<Route size={16} strokeWidth={2} className="flex-none text-m-muted" />
<div className="min-w-0 flex-1">
<div className="text-[0.8125rem] font-bold">{t('journey.settings.showTripTracks')}</div>
<div className="font-geist text-[0.625rem] text-m-muted">{t('journey.settings.showTripTracksHint')}</div>
</div>
<MToggle
<SheetSection title={t('journey.settings.tracks')}>
<ToggleRow
icon={<Route size={16} strokeWidth={2} />}
label={t('journey.settings.showTripTracks')}
hint={t('journey.settings.showTripTracksHint')}
checked={!!journey.show_trip_tracks}
onChange={handleTracksToggle}
disabled={savingTracks}
ariaLabel={t('journey.settings.showTripTracks')}
/>
</div>
</SheetSection>
{recordingPrep && (
<>
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.settings.recordings')}</div>
<SheetSection title={t('journey.settings.recordings')}>
<MRecordingPrepRow prep={recordingPrep} enabled={!!journey.auto_prepare_recordings} />
</>
</SheetSection>
)}
{/* The three fields a journey may put away (discussion #2299) */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.settings.entryFields')}</div>
<div className="mb-[6px] font-geist text-[0.625rem] leading-[1.45] text-m-muted">{t('journey.settings.entryFieldsHint')}</div>
{([
['show_verdict', t('journey.settings.showVerdict')],
['show_mood', t('journey.settings.showMood')],
['show_weather', t('journey.settings.showWeather')],
] as const).map(([field, label]) => (
<div key={field} className="mb-[6px] flex items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
<div className="min-w-0 flex-1 text-[0.8125rem] font-bold">{label}</div>
<MToggle
<SheetSection title={t('journey.settings.entryFields')} hint={t('journey.settings.entryFieldsHint')}>
{([
['show_verdict', t('journey.settings.showVerdict')],
['show_mood', t('journey.settings.showMood')],
['show_weather', t('journey.settings.showWeather')],
] as const).map(([field, label]) => (
<ToggleRow
key={field}
label={label}
checked={journey[field] !== 0}
onChange={next => handleFieldToggle(field, next)}
disabled={savingField !== null}
ariaLabel={label}
/>
</div>
))}
{/* Only when there is something to bring back */}
{onRestoreSuggestions && (journey.dismissed_count ?? 0) > 0 && (
<button
type="button"
onClick={() => { void onRestoreSuggestions() }}
className="mb-[6px] flex w-full items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-start"
>
<Undo2 size={16} strokeWidth={2} className="flex-none text-m-muted" />
<div className="min-w-0 flex-1">
<div className="text-[0.8125rem] font-bold">{t('journey.suggestions.restore')}</div>
<div className="font-geist text-[0.625rem] text-m-muted">
{t('journey.suggestions.restoreCount', { count: journey.dismissed_count ?? 0 })}
</div>
</div>
</button>
)}
{/* Synced trips */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.detail.syncedTrips')}</div>
{journey.trips.map(trip => (
<div key={trip.trip_id} className="mb-[6px] flex items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
<span className="h-9 w-9 flex-none rounded-[10px]" style={{ background: pickGradient(trip.trip_id) }} />
<div className="min-w-0 flex-1">
<div className="truncate text-[0.8125rem] font-bold">{trip.title}</div>
<div className="font-geist text-[0.625rem] text-m-muted">
{t('mobileJourney.placesCount', { count: trip.place_count ?? 0 })}
</div>
</div>
<button
type="button"
onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}
aria-label={t('journey.trips.unlinkTrip')}
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-[rgba(214,39,59,.1)] text-[color:var(--m-st-danger)]"
>
<Trash2 size={13} strokeWidth={2} />
))}
{/* Only when there is something to bring back */}
{onRestoreSuggestions && (journey.dismissed_count ?? 0) > 0 && (
<button type="button" onClick={() => { void onRestoreSuggestions() }} className={SHEET_SECONDARY}>
<Undo2 size={14} strokeWidth={2.2} />
{t('journey.suggestions.restore')}
<span className="text-m-muted">{journey.dismissed_count ?? 0}</span>
</button>
</div>
))}
{journey.trips.length === 0 && (
<p className="mb-[6px] font-geist text-[0.6875rem] text-m-faint">{t('journey.trips.noTripsLinkedSettings')}</p>
)}
<button type="button" onClick={openAddTrip} className={`${dashedBtn} mt-2`}>
<Plus size={13} strokeWidth={2.2} />
{t('journey.trips.addTrip')}
</button>
{addTripOpen && (
<div className="mt-2 max-h-[180px] overflow-y-auto rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-2">
{unlinkedTrips.length === 0 && (
<p className="py-2 text-center font-geist text-[0.6875rem] text-m-faint">{t('journey.trips.noTripsAvailable')}</p>
)}
{unlinkedTrips.map(trip => (
<div key={trip.id} className="flex items-center gap-[10px] rounded-[10px] px-2 py-[7px]">
<span className="h-7 w-7 flex-none rounded-lg" style={{ background: pickGradient(trip.id) }} />
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold">{trip.title}</span>
<button
type="button"
onClick={() => linkTrip(trip.id)}
disabled={linkingTripId === trip.id}
className="rounded-full bg-m-act px-3 py-[5px] font-geist text-[0.65625rem] font-bold text-m-actfg disabled:opacity-50"
>
{t('journey.trips.link')}
</button>
)}
</SheetSection>
<SheetSection title={t('journey.detail.syncedTrips')} count={journey.trips.length || undefined}>
{journey.trips.map(trip => (
<div key={trip.trip_id} className="flex items-center gap-2.5">
<span className="h-[42px] w-[42px] flex-none rounded-[13px]" style={{ background: pickGradient(trip.trip_id) }} />
<div className="min-w-0 flex-1">
<div className="truncate text-body font-semibold text-m-ink">{trip.title}</div>
<div className="font-geist text-caption text-m-muted">
{t('mobileJourney.placesCount', { count: trip.place_count ?? 0 })}
</div>
</div>
))}
</div>
)}
{/* Contributors */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.detail.contributors')}</div>
{journey.contributors.map(c => (
<div key={c.user_id} className="flex items-center gap-[10px] px-[2px] py-1">
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-m-act text-[0.75rem] font-extrabold text-m-actfg">
{(c.username || '?')[0].toUpperCase()}
</span>
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-bold">{c.username}</span>
<span
className={`rounded-full px-[10px] py-[3px] font-geist text-[0.5625rem] font-extrabold ${
c.role === 'owner' ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'
}`}
>
{c.role}
</span>
{c.role !== 'owner' && (
<button
type="button"
onClick={() => handleRemoveContributor(c)}
aria-label={t('journey.contributors.remove')}
className="flex h-7 w-7 flex-none items-center justify-center rounded-full text-m-faint"
onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}
aria-label={t('journey.trips.unlinkTrip')}
className={SHEET_ROUND}
>
<X size={13} />
<Unlink size={15} strokeWidth={2} />
</button>
)}
</div>
))}
<button type="button" onClick={onOpenInvite} className={`${solidBtn} mt-2`}>
<UserPlus size={13} strokeWidth={2.2} />
{t('journey.contributors.invite')}
</button>
<MTripAccessList access={journey.trip_access} eyebrow={eyebrow} />
{/* Public share */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.share.publicShare')}</div>
{!shareLink ? (
<button type="button" onClick={createShareLink} className={solidBtn}>
<LinkIcon size={13} strokeWidth={2.2} />
{t('journey.share.createLink')}
</div>
))}
{journey.trips.length === 0 && (
<p className="font-geist text-caption text-m-faint">{t('journey.trips.noTripsLinkedSettings')}</p>
)}
<button type="button" onClick={openAddTrip} className={SHEET_SECONDARY}>
<Plus size={14} strokeWidth={2.2} />
{t('journey.trips.addTrip')}
</button>
) : (
<>
<div className="flex items-center gap-2 rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
<LinkIcon size={13} className="flex-none text-m-faint" />
<span className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] text-m-muted">{shareUrl}</span>
<button
type="button"
onClick={copyShareUrl}
className="flex-none rounded-full bg-m-act px-3 py-[5px] font-geist text-[0.65625rem] font-bold text-m-actfg"
>
{copied ? t('journey.share.copied') : t('journey.share.copy')}
</button>
{canShareLink() && shareUrl && (
{addTripOpen && (
<div className="max-h-[200px] overflow-y-auto rounded-[14px] bg-[color:var(--m-ic)] p-1.5">
{unlinkedTrips.length === 0 && (
<p className="py-2 text-center font-geist text-caption text-m-faint">{t('journey.trips.noTripsAvailable')}</p>
)}
{unlinkedTrips.map(trip => (
<div key={trip.id} className="flex items-center gap-2.5 rounded-[12px] px-1.5 py-1.5">
<span className="h-8 w-8 flex-none rounded-[10px]" style={{ background: pickGradient(trip.id) }} />
<span className="min-w-0 flex-1 truncate text-body font-semibold">{trip.title}</span>
<button
type="button"
onClick={() => linkTrip(trip.id)}
disabled={linkingTripId === trip.id}
className="flex h-8 flex-none items-center rounded-full bg-m-act px-3 text-caption font-semibold text-m-actfg disabled:opacity-40"
>
{t('journey.trips.link')}
</button>
</div>
))}
</div>
)}
</SheetSection>
<SheetSection title={t('journey.detail.contributors')} count={journey.contributors.length || undefined}>
{journey.contributors.map(c => (
<div key={c.user_id} className="flex items-center gap-2.5">
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-m-act text-body font-extrabold text-m-actfg">
{(c.username || '?')[0].toUpperCase()}
</span>
<span className="min-w-0 flex-1 truncate text-body font-semibold text-m-ink">{c.username}</span>
<span className={`${SHEET_PILL} ${c.role === 'owner' ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'}`}>
{c.role}
</span>
{c.role !== 'owner' && (
<button
type="button"
aria-label={t('common.share')}
onClick={() => void shareLink(shareUrl)}
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg"
onClick={() => handleRemoveContributor(c)}
aria-label={t('journey.contributors.remove')}
className={SHEET_ROUND}
>
<ShareIcon size={12} strokeWidth={2.4} />
<UserMinus size={15} strokeWidth={2} />
</button>
)}
</div>
{([
{ key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
{ key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid3x3 },
{ key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
]).map(({ key, label, icon: Icon }) => (
<div key={key} className="flex items-center gap-[11px] px-[2px] py-[10px]">
<Icon size={16} strokeWidth={2} className="flex-none text-m-muted" />
<span className="min-w-0 flex-1 text-[0.84375rem] font-bold">{label}</span>
<MToggle checked={shareLink[key]} onChange={() => toggleSharePerm(key)} ariaLabel={label} />
</div>
))}
<MShareMapExtras link={shareLink} onToggle={toggleSharePerm} />
<button
type="button"
onClick={deleteShareLink}
className="mt-1 font-geist text-[0.6875rem] font-semibold text-[color:var(--m-st-danger)]"
>
{t('journey.share.removeLink')}
</button>
</>
)}
))}
<button type="button" onClick={onOpenInvite} className={SHEET_SECONDARY}>
<UserPlus size={14} strokeWidth={2.2} />
{t('journey.contributors.invite')}
</button>
</SheetSection>
{/* Archive */}
<button type="button" onClick={handleArchiveToggle} disabled={archiving} className={`${solidBtn} mt-[14px] disabled:opacity-40`}>
{journey.status === 'archived' ? <ArchiveRestore size={13} strokeWidth={2.2} /> : <Archive size={13} strokeWidth={2.2} />}
<MTripAccessList access={journey.trip_access} />
<SheetSection title={t('journey.share.publicShare')}>
{!shareLink ? (
<button type="button" onClick={createShareLink} className={`${SHEET_PRIMARY} justify-center`}>
<LinkIcon size={14} strokeWidth={2.2} />
{t('journey.share.createLink')}
</button>
) : (
<>
{shareUrl && (
<MLinkField
url={shareUrl}
copied={copied}
onCopy={copyShareUrl}
copyLabel={t('journey.share.copy')}
copiedLabel={t('journey.share.copied')}
shareLabel={t('common.share')}
/>
)}
{([
{ key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
{ key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid3x3 },
{ key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
]).map(({ key, label, icon: Icon }) => (
<ToggleRow
key={key}
icon={<Icon size={16} strokeWidth={2} />}
label={label}
checked={shareLink[key]}
onChange={() => toggleSharePerm(key)}
/>
))}
<MShareMapExtras link={shareLink} onToggle={toggleSharePerm} />
<button
type="button"
onClick={deleteShareLink}
className="flex h-[38px] w-full items-center justify-center gap-1.5 rounded-full text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-[color:var(--m-st-danger)]"
>
<Trash2 size={13} strokeWidth={2.2} /> {t('journey.share.removeLink')}
</button>
</>
)}
</SheetSection>
<button type="button" onClick={handleArchiveToggle} disabled={archiving} className={`${SHEET_SECONDARY} flex-none`}>
{journey.status === 'archived' ? <ArchiveRestore size={14} strokeWidth={2.2} /> : <Archive size={14} strokeWidth={2.2} />}
{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
</button>
</div>
<div className="flex flex-none items-center gap-3 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-[5px] text-[0.75rem] font-bold text-[color:var(--m-st-danger)]"
aria-label={t('journey.settings.delete')}
className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-[color:var(--m-st-danger)]"
>
<Trash2 size={13} strokeWidth={2} />
{t('journey.settings.delete')}
<Trash2 size={15} strokeWidth={2} />
</button>
<button
type="button"
onClick={onClose}
className="ms-auto rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold"
>
<button type="button" onClick={onClose} className={`${SHEET_SECONDARY} ms-auto px-4`}>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSave}
disabled={saving || !title.trim()}
className="rounded-full bg-m-act px-[18px] py-[9px] text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-40"
>
<button type="button" onClick={handleSave} disabled={saving || !title.trim()} className={SHEET_PRIMARY}>
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
@@ -357,26 +328,45 @@ export default function MJourneySettingsSheet({
}
/** Who a trip's linked journal lets in (#2683): shown under the contributors, but changed in that trip. */
function MTripAccessList({ access, eyebrow }: { access: JourneyTripAccess[] | undefined; eyebrow: string }) {
function MTripAccessList({ access }: { access: JourneyTripAccess[] | undefined }) {
const { t } = useTranslation()
if (!access?.length) return null
return (
<>
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.contributors.viaTrips')}</div>
<p className="m-0 mb-[6px] text-caption text-m-faint">{t('journey.contributors.viaTripsHint')}</p>
<SheetSection title={t('journey.contributors.viaTrips')} hint={t('journey.contributors.viaTripsHint')} count={access.length}>
{access.map(a => (
<div key={`${a.user_id}-${a.trip_id}`} className="flex items-center gap-[10px] px-[2px] py-1">
<Link2 size={15} className="flex-none text-m-muted" />
<div key={`${a.user_id}-${a.trip_id}`} className="flex items-center gap-2.5">
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted">
<Link2 size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-caption font-bold">{a.username}</span>
<span className="block truncate text-caption text-m-faint">{t('journey.contributors.viaTrip', { trip: a.trip_title })}</span>
</span>
<span className="rounded-full bg-[color:var(--m-ic)] px-[10px] py-[3px] font-geist text-caption font-extrabold text-m-muted">
{a.role}
<span className="block truncate text-body font-semibold text-m-ink">{a.username}</span>
<span className="block truncate font-geist text-caption text-m-faint">{t('journey.contributors.viaTrip', { trip: a.trip_title })}</span>
</span>
<span className={`${SHEET_PILL} bg-[color:var(--m-ic)] text-m-muted`}>{a.role}</span>
</div>
))}
</>
</SheetSection>
)
}
/** A switch with its label, an optional icon in front and an optional line under the label. */
function ToggleRow({ icon, label, hint, checked, onChange, disabled }: {
icon?: ReactNode
label: string
hint?: string
checked: boolean
onChange: (next: boolean) => void
disabled?: boolean
}) {
return (
<div className="flex items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
{icon && <span className="flex-none text-m-muted">{icon}</span>}
<div className="min-w-0 flex-1">
<div className="text-body font-semibold text-m-ink">{label}</div>
{hint && <div className="font-geist text-caption text-m-muted">{hint}</div>}
</div>
<MToggle checked={checked} onChange={onChange} disabled={disabled} ariaLabel={label} />
</div>
)
}
@@ -424,11 +414,12 @@ function MRecordingPrepRow({ prep, enabled }: { prep: JourneyRecordingPrepState;
function MShareMapExtras({ link, onToggle }: { link: JourneyShareLink; onToggle: (key: JourneyShareMapExtra) => unknown }) {
const { t } = useTranslation()
return (
<>
// Hangs under the map row: a line from below its pin, smaller labels.
<div className="mb-1 ms-[9px] border-s-2 border-[color:var(--m-rowbr)] ps-[16px]">
{SHARE_MAP_EXTRAS.map(({ key, labelKey, hintKey }) => (
<div key={key} className={`flex items-center gap-[11px] py-[8px] ps-[29px] pe-[2px] ${link.share_map ? '' : 'opacity-50'}`}>
<div key={key} className={`flex items-center gap-[11px] py-[7px] pe-[2px] ${link.share_map ? '' : 'opacity-50'}`}>
<span className="flex min-w-0 flex-1 flex-col">
<span className="text-body font-bold">{t(labelKey)}</span>
<span className="text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold">{t(labelKey)}</span>
<span className="font-geist text-caption text-m-muted">
{link.share_map ? t(hintKey) : t('journey.share.needsMap')}
</span>
@@ -441,6 +432,6 @@ function MShareMapExtras({ link, onToggle }: { link: JourneyShareLink; onToggle:
/>
</div>
))}
</>
</div>
)
}
@@ -1,6 +1,7 @@
import { ReactNode } from 'react'
import { AlertTriangle, CircleHelp, Info } from 'lucide-react'
import MSheet from '../../components/MSheet'
import { MSetButton } from './MSettingsUi'
import { SHEET_DANGER, SHEET_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi'
interface MConfirmSheetProps {
open: boolean
@@ -17,9 +18,11 @@ interface MConfirmSheetProps {
}
/**
* Small confirm dialog as a centred floating card. Without onConfirm it is a
* plain notice. The card stops at the screen height: a long message or list
* scrolls between the title and the buttons, which always stay in reach.
* Small confirm dialog as a centred floating card, in the shape of every phone
* sheet: the icon tile and title, the message, the sheets' 42 px buttons.
* Without onConfirm it is a plain notice. The card stops at the screen height:
* a long message or list scrolls between the title and the buttons, which
* always stay in reach.
*/
export default function MConfirmSheet({
open,
@@ -33,25 +36,42 @@ export default function MConfirmSheet({
onConfirm,
children,
}: MConfirmSheetProps) {
const Icon = danger ? AlertTriangle : onConfirm ? CircleHelp : Info
return (
<MSheet open={open} onClose={onClose} variant="card" material="opaque" ariaLabel={title}>
<div className="flex min-h-0 flex-col p-[18px]">
<div className="flex-none text-[0.9375rem] font-extrabold text-m-ink">{title}</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<p className="mt-2 text-[0.78125rem] leading-relaxed text-m-muted text-pretty">{message}</p>
{children}
</div>
{/* A long label moves its button to a row of its own instead of wrapping inside it. */}
<div className="mt-4 flex flex-none flex-wrap justify-end gap-2">
<MSetButton variant="ghost" onClick={onClose} className="whitespace-nowrap">
{cancelLabel}
</MSetButton>
{onConfirm && confirmLabel && (
<MSetButton variant={danger ? 'danger' : 'primary'} onClick={onConfirm} disabled={busy} className="whitespace-nowrap">
{confirmLabel}
</MSetButton>
)}
</div>
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={title}>
<div className="flex flex-none items-center gap-3 px-[18px] pt-4">
<span
className={`flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] ${
danger ? 'text-[color:var(--m-st-danger)]' : 'text-m-ink'
}`}
>
<Icon size={19} strokeWidth={1.8} />
</span>
<div className="min-w-0 flex-1 text-[1.0625rem] font-bold leading-tight text-m-ink text-pretty">{title}</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-[18px] pt-3">
<p className="text-[0.8125rem] leading-relaxed text-m-muted text-pretty">{message}</p>
{children}
</div>
{/* A long label moves its button to a row of its own instead of wrapping inside it. */}
<div className="flex flex-none flex-wrap justify-end gap-2 px-[18px] pb-[18px] pt-4">
<button type="button" onClick={onClose} className={`${SHEET_SECONDARY} whitespace-nowrap`}>
{cancelLabel}
</button>
{onConfirm && confirmLabel && (
<button
type="button"
onClick={onConfirm}
disabled={busy}
className={
danger
? `${SHEET_DANGER} whitespace-nowrap`
: `${SHEET_PRIMARY} whitespace-nowrap`
}
>
{confirmLabel}
</button>
)}
</div>
</MSheet>
)
@@ -1,6 +1,8 @@
import { ReactNode } from 'react'
import { Check } from 'lucide-react'
import MSheet from '../../components/MSheet'
import { TileHeader } from '../trip/sheets/MTripSheetUi'
import { useTranslation } from '../../../i18n'
export interface MSetPickerOption {
value: string
@@ -21,10 +23,14 @@ interface MSetPickerSheetProps {
* presets). Selecting closes the sheet.
*/
export default function MSetPickerSheet({ open, onClose, title, options, value, onSelect }: MSetPickerSheetProps) {
const { t } = useTranslation()
const closeLabel = t('common.close')
return (
<MSheet open={open} onClose={onClose} variant="bottom" material="opaque" ariaLabel={title}>
<div className="px-[14px] pb-2 pt-4 text-[0.875rem] font-extrabold text-m-ink">{title}</div>
<div className="min-h-0 overflow-y-auto px-2 pb-3">
<MSheet open={open} onClose={onClose} variant="bottom" material="glass" ariaLabel={title}>
<div className="flex-none px-[18px] pt-4">
<TileHeader title={title} onClose={onClose} closeLabel={closeLabel} />
</div>
<div className="m-scroll-fade mt-1 min-h-0 overflow-y-auto px-2 pb-3 pt-2">
{options.map((opt) => {
const active = opt.value === value
return (

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