Compare commits

...

3 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
122 changed files with 3689 additions and 609 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;
};
@@ -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,
+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'
+2 -17
View File
@@ -70,14 +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 { isNativeApp } from '../native/platform'
import { hardNavigate } from './hardNavigate'
/**
* Validate a response payload against its @trek/shared Zod schema — but only in
@@ -151,20 +150,6 @@ export function isAuthPublicPath(pathname: string): boolean {
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
}
/**
* 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.
*/
function hardNavigate(url: string): void {
const go = () => {
window.location.href = url
}
if (isNativeApp() && document.readyState !== 'complete') window.addEventListener('load', go, { once: true })
else go()
}
// Unregisters the SW before reloading so the navigation reaches the network.
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
+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();
}
@@ -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))
@@ -134,7 +134,7 @@ export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts {
}
}
export function nightsBetween(a: string | null, b: string | null): number {
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
+15 -8
View File
@@ -68,6 +68,13 @@ function ConfirmDialogFrame({
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
@@ -83,7 +90,7 @@ function ConfirmDialogFrame({
own, and the two buttons below it always stay in reach. */}
<div
role="presentation"
className={`trek-modal-enter ${closing ? 'trek-modal-exit' : ''} 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">
@@ -94,7 +101,7 @@ function ConfirmDialogFrame({
)}
<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}
@@ -108,7 +115,7 @@ function ConfirmDialogFrame({
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() }}
@@ -116,7 +123,7 @@ function ConfirmDialogFrame({
danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'
}`}
>
{confirmLabel || t('common.delete')}
{confirmText}
</button>
</div>
</div>
@@ -126,7 +133,7 @@ function ConfirmDialogFrame({
// with its tile, the message below, the answers in a bar at the foot.
<div
role="presentation"
className={`trek-modal-enter ${closing ? 'trek-modal-exit' : ''} 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 }}>
@@ -136,7 +143,7 @@ function ConfirmDialogFrame({
: <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">
@@ -146,13 +153,13 @@ function ConfirmDialogFrame({
<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>
+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
+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;
}
}
+12 -13
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import './presence.css';
/**
* How long a closing dialog, confirm or menu stays for its exit animation
@@ -14,25 +15,23 @@ export const EXIT_MS = import.meta.env.MODE === 'test' ? 0 : 170;
*/
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);
const wasOpen = useRef(open);
if (open !== prevOpen) {
setPrevOpen(open);
setLingering(!open && animated);
}
useEffect(() => {
if (open) {
wasOpen.current = true;
setLingering(false);
return;
}
if (!wasOpen.current) return;
wasOpen.current = false;
if (!animated) return;
setLingering(true);
if (!lingering) return;
const timer = window.setTimeout(() => setLingering(false), ms);
return () => window.clearTimeout(timer);
}, [open, ms, animated]);
}, [lingering, ms]);
// The first render after closing already counts as closing, before the effect runs.
const closing = animated && !open && (lingering || wasOpen.current);
// 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 };
}
-36
View File
@@ -568,42 +568,6 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
.trek-backdrop-enter {
animation: trek-backdrop-enter 180ms cubic-bezier(0.23, 1, 0.32, 1);
}
/* Closing: the mirror of each entrance, shorter and easing in, so a dialog,
confirm or menu leaves as gently as it came (components/shared/usePresence.ts
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;
}
}
.trek-toast-enter {
animation: trek-toast-enter 260ms cubic-bezier(0.23, 1, 0.32, 1);
will-change: transform, opacity;
@@ -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() }));
+4 -30
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[];
@@ -55,33 +54,6 @@ export interface MDayPagerProps {
onPreview?: (dayId: number | null) => void;
}
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;
}
export default function MDayPager({
days,
selectedDayId,
@@ -99,7 +71,9 @@ export default function MDayPager({
// The handlers below are attached once, so they read the props through here.
const latest = useRef({ days, onSelectDay, describeDay, selectedDayId, onPreview });
latest.current = { 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);
+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
@@ -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,11 +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 { TileHeader } from '../../mobile/screens/trip/sheets/MTripSheetUi'
import { tripRepo } from '../../repo/tripRepo';
import { TileHeader } from '../screens/trip/sheets/MTripSheetUi';
import MSheet from './MSheet';
interface TripRow {
id: number;
@@ -32,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 ?? [])];
@@ -48,7 +48,7 @@ export default function NativeImportSheet(): React.ReactElement | null {
const pick = (id: number) => {
chooseTrip(id);
navigate(`/trips/${id}`);
void navigate(`/trips/${id}`);
};
return (
@@ -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);
};
}
+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();
});
+9 -8
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;
@@ -85,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;
@@ -33,7 +33,7 @@ export default function MAdminResetMfaSheet({ open, onClose, user, onReset, t }:
<MSheet open={open} onClose={onClose} ariaLabel={t('admin.mfa.reset')}>
<MAdminSheetFrame
icon={<ShieldOff size={19} strokeWidth={1.8} />}
title={t('admin.mfa.reset')}
title={t('admin.mfa.reset')}
onClose={onClose}
footer={
<>
@@ -44,7 +44,7 @@ function AddCountrySheet({
<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')}
title={t('schoolCatalog.addCountry')}
onClose={close}
footer={
<>
@@ -120,7 +120,7 @@ function RegionSheet({ region, busy, offline, error, onClose, onSave }: EditorPr
>
<MAdminSheetFrame
icon={<GraduationCap size={19} strokeWidth={1.8} />}
title={name.trim() || t('schoolCatalog.region')}
title={name.trim() || t('schoolCatalog.region')}
onClose={() => {
if (!busy) onClose();
}}
@@ -213,7 +213,11 @@ function MCollTourSheet({
return (
<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')} />
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)}>
@@ -300,13 +300,13 @@ export default function MTripShell({
// The active day's pill glides to the next chip rather than jumping there.
// A booking document shared to TREK, and this trip picked for it: open the import.
const sharedTripId = useSharedImportStore(s => s.tripId)
const { setBookingImportKind, setShowBookingImport } = planner
useEffect(() => {
if (sharedTripId == null || sharedTripId !== Number(tripId)) return
planner.setBookingImportKind('bookings')
planner.setShowBookingImport(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sharedTripId, tripId])
const dayPill = useActivePill(dayRail, '[aria-current="true"]', [railDayId, days, dayRail, language])
setBookingImportKind('bookings')
setShowBookingImport(true)
}, [sharedTripId, tripId, setBookingImportKind, setShowBookingImport])
const dayPill = useActivePill(dayRail, '[aria-current="true"]', `${railDayId}:${days.map(d => d.id).join(',')}:${language}`)
const trTab = planner.activeTab
@@ -516,7 +516,7 @@ export default function MTripShell({
{dayPill && (
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-[3px] left-0 top-[3px] rounded-full bg-m-act shadow-[0_6px_16px_-6px_rgba(0,0,0,.4)]"
className="pointer-events-none absolute bottom-[3px] left-0 top-[3px] rounded-full bg-m-act shadow-[0_6px_16px_-6px_rgba(0,0,0,.4)]" // rtl-lint-disable: moved by the measured offsetLeft (useActivePill)
style={dayPill}
/>
)}
@@ -271,7 +271,9 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
* store write and on every sheet it opens, this handler's own included.
*/
const latest = useRef({ planner, shell, stage })
latest.current = { planner, shell, stage }
useLayoutEffect(() => {
latest.current = { planner, shell, stage }
})
const openStagePin = useCallback((placeId?: number) => {
const { planner: now, shell: chrome, stage: card } = latest.current
const stop = placeId == null ? null : firstStopOfPlace(card ? [card] : now.roadtripRoutes.days, placeId)
@@ -24,7 +24,8 @@ import type { RouteSegment } from '../../../../types'
import type { Assignment } from '../../../../types'
import type { ReactNode } from 'react'
import { useSettingsStore } from '../../../../store/settingsStore'
import MRouteAppsSheet, { openRouteTarget } from './MRouteAppsSheet'
import MRouteAppsSheet from './MRouteAppsSheet'
import { openRouteTarget } from './openRouteTarget'
import MDayPager from '../../../components/MDayPager'
import { useDayPreviewStore } from '../../../dayPreviewStore'
@@ -2,13 +2,8 @@ import { ChevronRight, MapPinned } from 'lucide-react';
import type { DayRouteTarget } from '../../../../components/Planner/dayRouteApps';
import type { TranslationFn } from '../../../../types';
import MSheet from '../../../components/MSheet';
import { TileHeader } from '../sheets/MTripSheetUi'
/** Opens a target: a web link in a new tab (the app takes it over), geo: in place. */
export function openRouteTarget(target: DayRouteTarget): void {
if (target.url.startsWith('geo:')) window.location.assign(target.url);
else window.open(target.url, '_blank', 'noopener,noreferrer');
}
import { TileHeader } from '../sheets/MTripSheetUi';
import { openRouteTarget } from './openRouteTarget';
/**
* The plan's one Maps button, asked: which app should get the day. A docked
@@ -0,0 +1,7 @@
import type { DayRouteTarget } from '../../../../components/Planner/dayRouteApps';
/** Opens a target: a web link in a new tab (the app takes it over), geo: in place. */
export function openRouteTarget(target: DayRouteTarget): void {
if (target.url.startsWith('geo:')) window.location.assign(target.url);
else window.open(target.url, '_blank', 'noopener,noreferrer');
}
@@ -126,7 +126,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
* ones either side. Each page reads its own stage, so the one that slides in is
* exactly the one that lands.
*/
function RoadtripStagePage({ planner, shell, dayId: pageDayId, alts, chrome }: MTripTabPanelProps & {
function RoadtripStagePage({ planner, shell, dayId: pageDayId, alts, chrome }: Omit<MTripTabPanelProps, 'tab'> & {
dayId: number | null
alts: ReturnType<typeof useMRtAlternatives>
chrome: RowChrome
@@ -1,14 +1,9 @@
import { X } from 'lucide-react';
import { createContext, useContext } from 'react';
import { useContext } from 'react';
import type { DetailShellProps } from '../../../../components/shared/DetailShell';
import MIconBtn from '../../../components/MIconBtn';
import MSheet from '../../../components/MSheet';
/**
* Whether the tour sheet is open (false while it slides away with the tour it
* showed last) and the tour's name, which names the dialog.
*/
export const TourSheetOpen = createContext<{ open: boolean; label: string }>({ open: true, label: '' });
import { TourSheetOpen } from './tourSheetOpen';
/**
* The phone's frame for the tour detail: the desktop dialog's head, sections
@@ -16,6 +16,7 @@ import { useSearchParams } from 'react-router'
import { isTransportReservationType } from '@trek/shared'
import { useBookingLink } from '../../../../hooks/useBookingLink'
import { bookingFacts, stayStats } from '../../../../components/Planner/bookings/bookingFacts'
import { parseMeta } from '../../../../components/Planner/bookings/bookingsModel'
import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripSheetUi'
interface TransportSheetPayload {
@@ -178,7 +179,7 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
const hotelStats = res.type === 'hotel'
? stayStats(
bookingFacts(res, { t, locale, timeFormat, days: planner.days, assignmentLookup: {}, hasLinkedCost: false }),
meta,
parseMeta(res),
t,
locale,
)
@@ -32,7 +32,8 @@ import MRtInfoSheet from '../roadtrip/MRtInfoSheet'
import MRtCorridorSheet from '../roadtrip/MRtCorridorSheet'
import MRtDraftSheet from '../roadtrip/MRtDraftSheet'
import type { MTripSheetsProps } from '../MTripShell'
import MTourDetailShell, { TourSheetOpen } from './MTourDetailShell'
import MTourDetailShell from './MTourDetailShell'
import { TourSheetOpen } from './tourSheetOpen'
import { useLastOpen } from '../../../../components/shared/usePresence'
/**
@@ -0,0 +1,7 @@
import { createContext } from 'react';
/**
* Whether the tour sheet is open (false while it slides away with the tour it
* showed last) and the tour's name, which names the dialog.
*/
export const TourSheetOpen = createContext<{ open: boolean; label: string }>({ open: true, label: '' });
+3 -3
View File
@@ -150,7 +150,7 @@ export default function MVacay() {
aria-haspopup="dialog"
className={GLASS_PILL_BUTTON}
>
<span className="flex -space-x-1">
<span className="flex [&>*+*]:-ms-1">
{v.users.slice(0, 4).map(u => (
<span
key={u.id}
@@ -509,11 +509,11 @@ function FuseInfo({ icon: Icon, text }: { icon: LucideIcon; text: string }) {
/** The editor's month picker: the window's twelve months on one glass surface. */
function MonthQuickNav({ names, slot, onPick }: { names: string[]; slot: number; onPick: (slot: number) => void }) {
const trackRef = useRef<HTMLDivElement>(null)
const pill = useActivePill(trackRef, '[data-active="true"]', [slot, names.length], true)
const pill = useActivePill(trackRef, '[data-active="true"]', `${slot}:${names.length}`, true)
return (
<div className="mb-[10px] rounded-[22px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-1.5 shadow-[0_8px_24px_-16px_rgba(0,0,0,.3)] backdrop-blur-[24px] backdrop-saturate-[1.7]">
<div ref={trackRef} className="relative grid grid-cols-6 gap-1">
{pill && <span aria-hidden="true" className="pointer-events-none absolute left-0 rounded-full bg-m-act" style={pill} />}
{/* rtl-lint-disable: the pill moves by the measured offsetLeft (useActivePill) */}{pill && <span aria-hidden="true" className="pointer-events-none absolute left-0 rounded-full bg-m-act" style={pill} />}
{names.map((name, i) => (
<button
key={i}
+7 -8
View File
@@ -12,6 +12,11 @@ import {
useDefaultVacayPerson, useVacayCalendarLogic, vacayWindowShape, type VacayMode,
} from '../../../components/Vacay/useVacayCalendarLogic'
/** The full name of a month in the user's language ("März"). */
function longMonthName(locale: string, m: { year: number; month: number }): string {
return new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(m.year, m.month, 1))
}
export type MVacayView = 'grid' | 'edit'
export type MVacayMode = VacayMode
export type MVacaySheet = 'invite' | 'settings' | 'share' | 'people' | 'legend' | null
@@ -91,17 +96,11 @@ export function useMVacay() {
return months.map(({ year, month }) => fmt.format(new Date(year, month, 1)))
}, [locale, months])
const monthNameLong = useMemo(
() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(activeMonth.year, activeMonth.month, 1)),
[locale, activeMonth],
)
const monthNameLong = useMemo(() => longMonthName(locale, activeMonth), [locale, activeMonth])
// The pagers draw the neighbours of the open month and year too: the long name
// of any slot of the window, and the twelve months of any year.
const monthNameLongFor = useCallback((slot: number) => {
const m = months[slot] ?? months[0]
return new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(m.year, m.month, 1))
}, [locale, months])
const monthNameLongFor = useCallback((slot: number) => longMonthName(locale, months[slot] ?? months[0]), [locale, months])
const monthsFor = useCallback((year: number) => windowMonths(year, yearSettings), [yearSettings])
const selectedUser = users.find(u => u.id === selectedUserId)
+8 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';
import { tickHaptic } from '../native/haptics';
import { hasOpenOverlay } from './overlayStack';
@@ -10,6 +10,9 @@ const MIN_SPIN_MS = 450;
export type PullState = { distance: number; refreshing: boolean; armed: boolean };
/** The screen's own refresh reports its failures (a toast, its error state); the pull only lets go. */
function refreshFailed(): void {}
/**
* Pull-to-refresh on a page whose scroller is the window, as the mobile
* dashboard is. A downward drag that starts at the very top moves `contentRef`
@@ -29,7 +32,9 @@ export function usePullToRefresh(
): PullState {
const [state, setState] = useState<PullState>({ distance: 0, refreshing: false, armed: false });
const refreshRef = useRef(onRefresh);
refreshRef.current = onRefresh;
useLayoutEffect(() => {
refreshRef.current = onRefresh;
});
useEffect(() => {
if (!enabled) return;
@@ -98,7 +103,7 @@ export function usePullToRefresh(
const began = Date.now();
void Promise.resolve()
.then(() => refreshRef.current())
.catch(() => {})
.catch(refreshFailed)
.then(() => new Promise((resolve) => setTimeout(resolve, Math.max(0, MIN_SPIN_MS - (Date.now() - began)))))
.then(() => {
busy = false;
+4 -1
View File
@@ -70,7 +70,10 @@ export function nativeCapabilities(): NativeCapabilities {
*/
export async function checkAppUpdate(fetchFeatures: () => Promise<{ minNativeShellApi?: unknown }>): Promise<boolean> {
if (!isNativeApp()) return false;
const [caps, features] = await Promise.all([loadNativeCapabilities(), fetchFeatures().catch(() => ({}))]);
const [caps, features] = await Promise.all([
loadNativeCapabilities(),
fetchFeatures().catch((): { minNativeShellApi?: unknown } => ({})),
]);
const min = typeof features.minNativeShellApi === 'number' ? features.minNativeShellApi : 0;
updateRequired = caps.shellApi < min;
emit();
+1 -1
View File
@@ -23,7 +23,7 @@ beforeEach(() => {
vi.mocked(TrekShell.addListener)
.mockReset()
.mockImplementation(async (_event, listener) => {
emit = listener;
emit = listener as unknown as typeof emit;
return { remove: async () => {} };
});
});
+1 -1
View File
@@ -57,7 +57,7 @@ function toError(e: { code: number; message: string }): GeolocationPositionError
function dispatch(payload: NativeLocationPayload): void {
for (const [id, w] of [...watchers]) {
if (payload.error) w.failure?.(toError(payload.error));
else w.success(toPosition(payload));
else w.success(toPosition(payload as Extract<NativeLocationPayload, { latitude: number }>));
if (w.once) watchers.delete(id);
}
syncStream();
+1 -1
View File
@@ -30,7 +30,7 @@ describe('shared booking documents', () => {
await startSharedFiles();
expect(useSharedImportStore.getState().file?.name).toBe('booking.pdf');
const listener = vi.mocked(TrekShell.addListener).mock.calls[0][1] as (p: typeof PDF) => void;
const listener = vi.mocked(TrekShell.addListener).mock.calls[0][1] as unknown as (p: typeof PDF) => void;
listener({ ...PDF, name: 'train.ics', mimeType: 'text/calendar' });
expect(useSharedImportStore.getState().file?.name).toBe('train.ics');
});
+1 -1
View File
@@ -16,7 +16,7 @@ import { isNativeIos } from './platform';
* Nothing is prefetched through here: Mapbox forbids bulk downloads, and the
* OpenFreeMap region a trip stores goes through Cache Storage (offlineTiles.ts).
*/
export const TILE_CACHE_SCHEME = 'trek-tile';
const TILE_CACHE_SCHEME = 'trek-tile';
export function tileCacheActive(): boolean {
return isNativeIos() && hasNativeFeature('tileCache');
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest';
import type { PackingItem, Reservation, TodoItem } from '../types';
import { bookingsAhead, packingSummary, stopLook, temperature, todosSummary, tripPlace } from './widgetExtras';
const reservation = (over: Partial<Reservation>): Reservation =>
({ id: 1, trip_id: 1, title: 'Booking', status: 'confirmed', type: 'other', ...over }) as Reservation;
const todo = (over: Partial<TodoItem>): TodoItem =>
({
id: 1,
trip_id: 1,
name: 'Task',
category: null,
checked: 0,
sort_order: 0,
due_date: null,
description: null,
assigned_user_id: null,
priority: 0,
...over,
}) as TodoItem;
describe('bookingsAhead', () => {
const days = new Map([[7, '2026-11-09']]);
const time = (t: string) => `T${t}`;
it('lists the bookings from today on, soonest first, with their kind, legs and status', () => {
const list = bookingsAhead(
[
reservation({ id: 1, title: 'Old', reservation_time: '2026-11-01T10:00' }),
reservation({
id: 2,
title: 'Dinner',
type: 'restaurant',
reservation_time: '2026-11-08T19:30',
status: 'pending',
location: 'Pontocho',
}),
reservation({
id: 3,
title: 'LH 716',
type: 'flight',
reservation_time: '2026-11-08T13:05',
endpoints: [
{
role: 'to',
sequence: 1,
name: 'Tokyo Haneda',
code: 'HND',
lat: 0,
lng: 0,
timezone: null,
local_time: null,
local_date: null,
},
{
role: 'from',
sequence: 0,
name: 'Frankfurt',
code: 'FRA',
lat: 0,
lng: 0,
timezone: null,
local_time: null,
local_date: null,
},
],
}),
reservation({ id: 4, title: 'Museum', type: 'activity', day_id: 7 }),
reservation({ id: 5, title: 'Undated' }),
reservation({ id: 6, title: 'Cancelled', reservation_time: '2026-11-08T08:00', status: 'cancelled' }),
reservation({ id: 7, title: 'Staged', reservation_time: '2026-11-08T08:00', ingest_state: 'staged' }),
],
{ dayDates: days, today: '2026-11-06', tripPath: '/trips/1', formatTime: time }
);
expect(list.map((b) => b.title)).toEqual(['LH 716', 'Dinner', 'Museum', 'Undated']);
expect(list[0]).toEqual({
title: 'LH 716',
kind: 'flight',
date: '2026-11-08',
time: 'T13:05',
detail: 'FRA → HND',
confirmed: true,
path: '/trips/1?tab=transports',
});
expect(list[1]).toMatchObject({
kind: 'restaurant',
detail: 'Pontocho',
confirmed: false,
path: '/trips/1?tab=buchungen',
});
expect(list[2]).toMatchObject({ kind: 'tour', date: '2026-11-09', time: null });
expect(list[3]).toMatchObject({ kind: 'other', date: null });
});
it('stops at the limit', () => {
const many = Array.from({ length: 8 }, (_, i) => reservation({ id: i, reservation_time: `2026-11-1${i}T10:00` }));
expect(
bookingsAhead(many, { dayDates: new Map(), today: '2026-11-06', tripPath: '/trips/1', formatTime: (t) => t })
).toHaveLength(5);
});
});
describe('todosSummary', () => {
it('counts the open ones and orders them by due date, then priority', () => {
const summary = todosSummary(
[
todo({ id: 1, name: 'Done', checked: 1 }),
todo({ id: 2, name: 'Someday', priority: 3 }),
todo({ id: 3, name: 'Soon', due_date: '2026-11-08' }),
todo({ id: 4, name: 'First', due_date: '2026-11-07T00:00:00' }),
todo({ id: 5, name: 'Low', priority: 1 }),
],
'/trips/1?tab=listen'
);
expect(summary.open).toBe(4);
expect(summary.total).toBe(5);
expect(summary.items.map((i) => i.name)).toEqual(['First', 'Soon', 'Someday', 'Low']);
expect(summary.items[0]).toEqual({ name: 'First', due: '2026-11-07', priority: 0 });
});
});
describe('packingSummary', () => {
it('counts packed against all, and is null without a list', () => {
const items = [{ checked: 1 }, { checked: 0 }, { checked: 1 }] as PackingItem[];
expect(packingSummary(items, '/p')).toEqual({ packed: 2, total: 3, path: '/p' });
expect(packingSummary([], '/p')).toBeNull();
});
});
describe('tripPlace', () => {
it('names the town and country most places share, without postcodes', () => {
expect(
tripPlace([
{ address: '68 Fukakusa Yabunouchicho, Fushimi Ward, 612-0882 Kyoto, Japan' },
{ address: 'Nakagyo Ward, 604-8054 Kyoto, Japan' },
{ address: '1-1 Chiyoda, 100-8111 Tokyo, Japan' },
{ address: null },
{ address: 'Nowhere' },
])
).toBe('Kyoto, Japan');
});
it('is null without usable addresses', () => {
expect(tripPlace([{ address: 'Main Street' }, { address: '' }])).toBeNull();
});
});
describe('stopLook and temperature', () => {
it('maps a category to a colour and an SF Symbol, ignoring what it cannot use', () => {
expect(stopLook({ color: '#E8A13A', icon: 'Coffee' })).toEqual({ color: '#E8A13A', symbol: 'cup.and.saucer.fill' });
expect(stopLook({ color: 'red', icon: 'Unknown' })).toEqual({ color: null, symbol: null });
expect(stopLook(null)).toEqual({ color: null, symbol: null });
});
it('converts to Fahrenheit only when asked, rounded', () => {
expect(temperature(18.4, 'celsius')).toBe(18);
expect(temperature(20, 'fahrenheit')).toBe(68);
expect(temperature(-0.6, undefined)).toBe(-1);
});
});
+238
View File
@@ -0,0 +1,238 @@
import type { PackingItem, Place, Reservation, TodoItem } from '../types';
import { splitReservationDateTime } from '../utils/formatters';
/**
* The parts of the widget snapshot beyond the trip and today's stops: the
* bookings ahead, the to-dos, the packing list, the destination's name, and
* how a stop looks (its category's colour and an SF Symbol for its icon).
* Pure, so the shapes the widgets get are tested here; the Swift side is in
* client/ios/App/TREKWidgets/WidgetSnapshot.swift.
*/
export interface WidgetBooking {
title: string;
kind: string;
date: string | null;
time: string | null;
detail: string | null;
confirmed: boolean;
path: string;
}
export interface WidgetTodos {
open: number;
total: number;
items: { name: string; due: string | null; priority: number }[];
path: string;
}
export interface WidgetPacking {
packed: number;
total: number;
path: string;
}
/** The lucide icon names categories use (components/shared/categoryIcons.ts), as SF Symbols. */
const SYMBOLS: Record<string, string> = {
MapPin: 'mappin',
Building2: 'building.2.fill',
BedDouble: 'bed.double.fill',
UtensilsCrossed: 'fork.knife',
Utensils: 'fork.knife',
Landmark: 'building.columns.fill',
ShoppingBag: 'bag.fill',
Store: 'storefront.fill',
Bus: 'bus.fill',
Train: 'tram.fill',
Car: 'car.fill',
Plane: 'airplane',
Ship: 'ferry.fill',
Bike: 'bicycle',
Activity: 'figure.run',
Dumbbell: 'dumbbell.fill',
Mountain: 'mountain.2.fill',
Tent: 'tent.fill',
Anchor: 'sailboat.fill',
Coffee: 'cup.and.saucer.fill',
Beer: 'mug.fill',
Wine: 'wineglass.fill',
Camera: 'camera.fill',
Music: 'music.note',
Theater: 'theatermasks.fill',
Ticket: 'ticket.fill',
TreePine: 'tree.fill',
Waves: 'water.waves',
Leaf: 'leaf.fill',
Flower2: 'camera.macro',
Sun: 'sun.max.fill',
Globe: 'globe.europe.africa.fill',
Compass: 'safari.fill',
Flag: 'flag.fill',
Navigation: 'location.north.fill',
Map: 'map.fill',
Church: 'building.columns.fill',
Library: 'books.vertical.fill',
Home: 'house.fill',
Cross: 'cross.case.fill',
Heart: 'heart.fill',
Star: 'star.fill',
CreditCard: 'creditcard.fill',
Wifi: 'wifi',
Luggage: 'suitcase.rolling.fill',
Backpack: 'backpack.fill',
Zap: 'bolt.fill',
};
const HEX = /^#[0-9a-f]{6}$/i;
/** A stop's colour and symbol from its place's category; nulls when it has none. */
export function stopLook(category: { color?: string | null; icon?: string | null } | null | undefined): {
color: string | null;
symbol: string | null;
} {
return {
color: category?.color && HEX.test(category.color) ? category.color : null,
symbol: (category?.icon && SYMBOLS[category.icon]) || null,
};
}
/** The booking kinds the widget draws; the rest of RESERVATION_TYPES fold into these. */
const KINDS: Record<string, string> = {
flight: 'flight',
train: 'train',
transit: 'train',
cable_car: 'train',
bus: 'bus',
car: 'car',
taxi: 'car',
bicycle: 'car',
cruise: 'cruise',
ferry: 'cruise',
hotel: 'hotel',
restaurant: 'restaurant',
event: 'event',
tour: 'tour',
activity: 'tour',
};
function bookingDetail(r: Reservation): string | null {
const legs = [...(r.endpoints ?? [])].sort((a, b) => a.sequence - b.sequence);
const from = legs.find((e) => e.role === 'from');
const to = [...legs].reverse().find((e) => e.role === 'to');
if (from && to) return `${from.code || from.name} → ${to.code || to.name}`;
return r.location?.trim() || r.accommodation_name || r.place_name || null;
}
/** Transport bookings open on the trip's transports tab, the rest on its bookings tab. */
const TRANSPORT_KINDS = new Set(['flight', 'train', 'bus', 'car', 'cruise']);
/**
* The trip's bookings from today on, soonest first: those without a date at
* the end, cancelled ones and drafts an import still waits on left out.
*/
export function bookingsAhead(
reservations: Reservation[],
{
dayDates,
today,
tripPath,
formatTime,
limit = 5,
}: {
/** The trip's day ids to their dates, for bookings placed on a day without a date of their own. */
dayDates: Map<number, string>;
today: string;
tripPath: string;
formatTime: (time: string) => string;
limit?: number;
}
): WidgetBooking[] {
return reservations
.filter((r) => r.status !== 'cancelled' && (r.ingest_state ?? 'live') === 'live')
.map((r) => {
const { date, time } = splitReservationDateTime(r.reservation_time);
const day = date ?? (r.day_id != null ? (dayDates.get(r.day_id) ?? null) : null);
return { r, day, time };
})
.filter(({ day }) => day == null || day >= today)
.sort((a, b) => {
if (a.day !== b.day) return a.day == null ? 1 : b.day == null ? -1 : a.day.localeCompare(b.day);
return (a.time ?? '99:99').localeCompare(b.time ?? '99:99');
})
.slice(0, limit)
.map(({ r, day, time }) => {
const kind = KINDS[r.type] ?? 'other';
return {
title: r.title,
kind,
date: day,
time: time ? formatTime(time) : null,
detail: bookingDetail(r),
confirmed: r.status === 'confirmed',
path: `${tripPath}?tab=${TRANSPORT_KINDS.has(kind) ? 'transports' : 'buchungen'}`,
};
});
}
/** Open to-dos first by due date (undated last), then by priority, highest first. */
export function todosSummary(items: TodoItem[], path: string, limit = 6): WidgetTodos {
const open = items.filter((i) => !i.checked);
const sorted = [...open].sort((a, b) => {
if (a.due_date !== b.due_date) {
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return a.due_date.localeCompare(b.due_date);
}
return (b.priority ?? 0) - (a.priority ?? 0) || a.sort_order - b.sort_order;
});
return {
open: open.length,
total: items.length,
items: sorted.slice(0, limit).map((i) => ({
name: i.name,
due: i.due_date ? i.due_date.slice(0, 10) : null,
priority: Math.max(0, Math.min(3, i.priority ?? 0)),
})),
path,
};
}
/** Packed items against all of them; null without a packing list. */
export function packingSummary(items: PackingItem[], path: string): WidgetPacking | null {
if (items.length === 0) return null;
return { packed: items.filter((i) => i.checked).length, total: items.length, path };
}
/**
* Where the trip goes, in a few words: the town and country most of its
* places share, read off their addresses ("…, 600-8216 Kyoto, Japan").
*/
export function tripPlace(places: Pick<Place, 'address'>[]): string | null {
const counts = new Map<string, number>();
for (const { address } of places) {
const parts = (address ?? '')
.split(',')
.map((p) => p.trim())
.filter(Boolean);
if (parts.length < 2) continue;
const country = parts[parts.length - 1];
const town = parts[parts.length - 2].replace(/\b[\dA-Z]{0,4}[\d][\d\s-]*\b/g, '').trim();
if (!town || /\d/.test(country)) continue;
const key = `${town}, ${country}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
let best: string | null = null;
let most = 0;
for (const [key, count] of counts) {
if (count > most) {
best = key;
most = count;
}
}
return best;
}
/** Celsius to the user's unit, rounded. */
export function temperature(celsius: number, unit: string | undefined): number {
return Math.round(unit === 'fahrenheit' ? (celsius * 9) / 5 + 32 : celsius);
}
+99
View File
@@ -0,0 +1,99 @@
import { fetchWeather } from '../services/weatherQueue';
import { temperature } from './widgetExtras';
import type { WidgetWeather } from './widgets';
/**
* The two things the widget snapshot needs from the network: the weather where
* the trip is, and the trip's cover as a small JPEG the widget can draw without
* a session of its own. Both are best effort: offline or failing, the widget
* simply goes without them, and both are kept so a quarter-hourly sync does not
* fetch them again.
*/
const WEATHER_TTL_MS = 60 * 60 * 1000;
const weatherCache = new Map<string, { at: number; value: WidgetWeather | null }>();
export async function widgetWeather(
{ lat, lng }: { lat: number; lng: number },
{ date, lang, unit, place }: { date: string; lang: string; unit: string | undefined; place?: string | null }
): Promise<WidgetWeather | null> {
const key = `${lat.toFixed(2)},${lng.toFixed(2)},${date},${lang},${unit ?? ''}`;
const hit = weatherCache.get(key);
if (hit && Date.now() - hit.at < WEATHER_TTL_MS) return hit.value;
let value: WidgetWeather | null = null;
try {
const w = await fetchWeather(lat, lng, date, lang);
if (!w.error && Number.isFinite(w.temp)) {
value = {
temp: temperature(w.temp, unit),
...(w.temp_max != null ? { high: temperature(w.temp_max, unit) } : {}),
...(w.temp_min != null ? { low: temperature(w.temp_min, unit) } : {}),
main: w.main,
label: w.description,
...(place ? { place } : {}),
...(w.precipitation_probability_max != null ? { rain: Math.round(w.precipitation_probability_max) } : {}),
};
}
} catch {
// Offline or the provider failed: the widget shows no weather until the next sync.
value = null;
}
weatherCache.set(key, { at: Date.now(), value });
return value;
}
const COVER_KEY = 'trek.widgetCover';
const COVER_MAX_PX = 720;
const COVER_MAX_BYTES = 15 * 1024 * 1024;
/** Kept under this so the keychain item the widget reads stays small. */
const COVER_MAX_BASE64 = 160_000;
function storedCover(url: string): string | null {
try {
const raw = localStorage.getItem(COVER_KEY);
const parsed = raw ? (JSON.parse(raw) as { url?: string; data?: string }) : null;
return parsed?.url === url && parsed.data ? parsed.data : null;
} catch {
return null;
}
}
function storeCover(url: string, data: string): void {
try {
localStorage.setItem(COVER_KEY, JSON.stringify({ url, data }));
} catch {
// Storage full: the cover is fetched again next time, nothing else depends on it.
}
}
/**
* The cover at `url` scaled to fit COVER_MAX_PX, as base64 JPEG without the
* data: prefix. Null without a cover, offline, or for anything not an image.
*/
export async function widgetCover(url: string | null | undefined): Promise<string | null> {
if (!url) return null;
const stored = storedCover(url);
if (stored) return stored;
try {
const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(10_000) });
const length = Number(res.headers.get('content-length') ?? 0);
if (!res.ok || length > COVER_MAX_BYTES) return null;
const blob = await res.blob();
if (!blob.type.startsWith('image/') || blob.size > COVER_MAX_BYTES) return null;
const bitmap = await createImageBitmap(blob);
const scale = Math.min(1, COVER_MAX_PX / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.round(bitmap.width * scale);
canvas.height = Math.round(bitmap.height * scale);
canvas.getContext('2d')?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
let data = canvas.toDataURL('image/jpeg', 0.74).split(',')[1] ?? '';
if (data.length > COVER_MAX_BASE64) data = canvas.toDataURL('image/jpeg', 0.5).split(',')[1] ?? '';
if (!data || data.length > COVER_MAX_BASE64) return null;
storeCover(url, data);
return data;
} catch {
// Offline, a timeout or an image the WebView cannot decode: no cover this time.
return null;
}
}
+86 -2
View File
@@ -13,7 +13,7 @@ vi.mock('./trekShell', () => ({
}));
import { TrekShell } from './trekShell';
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState } from './widgets';
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState, withBookings } from './widgets';
const trip = (id: number, start: string | null, end: string | null, archived = 0) =>
({ id, title: `Trip ${id}`, start_date: start, end_date: end, is_archived: archived }) as unknown as Trip;
@@ -99,7 +99,8 @@ describe('todayOf and tripDayState', () => {
'2026-11-06',
() => 'D',
(t) => t
)!;
);
if (!today) throw new Error('the trip runs today');
expect(tripDayState(today, 8 * 60, 'done')).toMatchObject({ nextName: 'Museum', done: 0, total: 2 });
expect(tripDayState(today, 12 * 60, 'done')).toMatchObject({ nextName: 'Dinner', nextTime: '19:30', done: 1 });
expect(tripDayState(today, 22 * 60, 'done')).toMatchObject({ nextName: 'done', done: 2, nextTime: undefined });
@@ -119,6 +120,7 @@ describe('publishWidgets', () => {
path: '/trips/1',
dayLabel: 'D',
nextName: 'x',
nextTime: undefined,
done: 0,
total: 0,
});
@@ -127,3 +129,85 @@ describe('publishWidgets', () => {
expect(TrekShell.syncTripDay).not.toHaveBeenCalled();
});
});
describe('the richer snapshot', () => {
it('puts the timed bookings of the day between the stops and gives the day its place in the trip', () => {
const days = [
day(1, 1, '2026-11-06', [{ name: 'Museum', time: '09:00' }, { name: 'Walk' }, { name: 'Dinner', time: '19:30' }]),
day(2, 2, '2026-11-07', []),
];
const plain = todayOf(
trip(1, '2026-11-06', '2026-11-07'),
days,
'2026-11-06',
() => 'D',
(t) => t
);
if (!plain) throw new Error('the trip runs today');
const today = withBookings(
plain,
[
{ id: 1, trip_id: 1, title: 'Train', type: 'train', status: 'confirmed', reservation_time: '2026-11-06T12:15' },
{
id: 2,
trip_id: 1,
title: 'Tomorrow',
type: 'flight',
status: 'confirmed',
reservation_time: '2026-11-07T08:00',
},
{ id: 3, trip_id: 1, title: 'Gone', type: 'event', status: 'cancelled', reservation_time: '2026-11-06T10:00' },
] as never,
'2026-11-06',
(t) => t
);
expect(today.stops.map((s) => s.name)).toEqual(['Museum', 'Walk', 'Train', 'Dinner']);
expect(today.stops[2]).toMatchObject({ time: '12:15', minutes: 735, symbol: 'tram.fill' });
expect(today).toMatchObject({ index: 1, total: 2 });
expect(withBookings(plain, [], '2026-11-06', (t) => t)).toBe(plain);
});
it('carries the extras and leaves out what is empty', () => {
const snapshot = buildSnapshot(
{ ...trip(1, '2026-11-06', '2026-11-07'), day_count: 2 } as Trip,
{
tripTitle: 'Trip 1',
path: '/trips/1',
dayLabel: 'D',
index: 1,
total: 2,
stops: [{ name: 'Tea', time: '10:00', minutes: 600, color: '#2FA37A', symbol: 'leaf.fill' }],
},
labels,
{
place: 'Kyoto, Japan',
cover: null,
placeCount: 12,
memberCount: 0,
weather: { temp: 18, main: 'Clear', label: 'Sunny' },
bookings: [],
todos: { open: 1, total: 2, items: [], path: '/trips/1?tab=listen' },
packing: null,
}
);
expect(snapshot.trip).toEqual({
title: 'Trip 1',
start: '2026-11-06',
end: '2026-11-07',
path: '/trips/1',
place: 'Kyoto, Japan',
dayCount: 2,
placeCount: 12,
});
expect(snapshot.today).toEqual({
dayLabel: 'D',
index: 1,
total: 2,
stops: [{ name: 'Tea', time: '10:00', minutes: 600, color: '#2FA37A', symbol: 'leaf.fill' }],
});
expect(snapshot.weather).toEqual({ temp: 18, main: 'Clear', label: 'Sunny' });
expect(snapshot.bookings).toEqual([]);
expect(snapshot.todos?.open).toBe(1);
expect(snapshot).not.toHaveProperty('packing');
});
});
+195 -24
View File
@@ -1,14 +1,19 @@
import type { Day, Trip } from '../types';
import type { Day, Reservation, Trip } from '../types';
import { splitReservationDateTime } from '../utils/formatters';
import { hasNativeFeature } from './capabilities';
import { isNativeIos } from './platform';
import { reportNativeFailure } from './reportFailure';
import { TrekShell } from './trekShell';
import { stopLook, type WidgetBooking, type WidgetPacking, type WidgetTodos } from './widgetExtras';
/**
* What the home-screen widget and the travel-day Live Activity show, built from
* What the home-screen widgets and the travel-day Live Activity show, built from
* the trips this device keeps offline (db/offlineDb) and handed to the app
* (TrekShell.setWidgetSnapshot, syncTripDay). The texts go along translated, so
* the widget speaks the app's language without localising anything itself.
* the widgets speak the app's language without localising anything themselves.
* Everything past trip, today and the first six labels is optional on the Swift
* side (client/ios/App/TREKWidgets/WidgetSnapshot.swift), so an older app
* binary ignores it and a newer one copes without it.
*/
export interface WidgetLabels {
nextTrip: string;
@@ -18,26 +23,92 @@ export interface WidgetLabels {
inDays: string;
tomorrow: string;
nothingPlanned: string;
days?: string;
bookings?: string;
noBookings?: string;
todos?: string;
noTodos?: string;
packing?: string;
confirmed?: string;
pending?: string;
todayShort?: string;
tomorrowShort?: string;
now?: string;
places?: string;
travellers?: string;
allDone?: string;
}
export interface WidgetStop {
interface WidgetStop {
time: string | null;
name: string;
/** Minutes after midnight, for picking the next stop; not sent to the widget. */
/** Minutes after midnight, for picking the next stop and the widget's now line. */
minutes: number | null;
color?: string | null;
symbol?: string | null;
}
interface SnapshotStop {
time: string | null;
name: string;
minutes?: number;
color?: string;
symbol?: string;
}
export interface WidgetSnapshot {
trip: { title: string; start: string; end: string; path: string } | null;
today: { dayLabel: string; stops: { time: string | null; name: string }[] } | null;
trip: {
title: string;
start: string;
end: string;
path: string;
place?: string;
/** A small JPEG of the cover, base64. */
cover?: string;
dayCount?: number;
placeCount?: number;
memberCount?: number;
} | null;
today: { dayLabel: string; stops: SnapshotStop[]; index?: number; total?: number } | null;
weather?: WidgetWeather;
bookings?: WidgetBooking[];
todos?: WidgetTodos;
packing?: WidgetPacking;
labels: WidgetLabels;
}
export interface WidgetWeather {
temp: number;
high?: number;
low?: number;
/** The provider's main condition ("Clear", "Rain", ...), which the widget turns into a symbol. */
main: string;
label: string;
place?: string;
/** Chance of rain in percent. */
rain?: number;
}
/** What goes along with the trip beyond its day; each part optional. */
interface SnapshotExtras {
place?: string | null;
cover?: string | null;
placeCount?: number;
memberCount?: number;
weather?: WidgetWeather | null;
bookings?: WidgetBooking[];
todos?: WidgetTodos;
packing?: WidgetPacking | null;
}
export interface TripDay {
tripTitle: string;
path: string;
dayLabel: string;
stops: WidgetStop[];
/** 1-based position of the day in the trip, and the trip's day count. */
index?: number;
total?: number;
}
export function widgetsActive(): boolean {
@@ -54,13 +125,38 @@ function minutesOf(time: string | null | undefined): number | null {
* and trips already over are not candidates.
*/
export function pickTrip(trips: Trip[], today: string): Trip | null {
const dated = trips.filter((t) => t.start_date && t.end_date && !t.is_archived);
const running = dated.find((t) => t.start_date! <= today && t.end_date! >= today);
if (running) return running;
const upcoming = dated
.filter((t) => t.start_date! > today)
.sort((a, b) => a.start_date!.localeCompare(b.start_date!));
return upcoming[0] ?? null;
const dated = trips.flatMap((t) =>
t.start_date && t.end_date && !t.is_archived ? [{ trip: t, start: t.start_date, end: t.end_date }] : []
);
const running = dated.find((d) => d.start <= today && d.end >= today);
if (running) return running.trip;
const upcoming = dated.filter((d) => d.start > today).sort((a, b) => a.start.localeCompare(b.start));
return upcoming[0]?.trip ?? null;
}
/** The symbols of the booking kinds a day's timeline shows next to its stops. */
const RESERVATION_SYMBOLS: Record<string, string> = {
flight: 'airplane',
train: 'tram.fill',
bus: 'bus.fill',
car: 'car.fill',
cruise: 'ferry.fill',
ferry: 'ferry.fill',
hotel: 'bed.double.fill',
restaurant: 'fork.knife',
event: 'ticket.fill',
tour: 'figure.hiking',
};
/** Puts each timed booking before the first stop that comes later; untimed stops keep their place. */
function mergeByTime(stops: WidgetStop[], booked: WidgetStop[]): WidgetStop[] {
const out = [...stops];
for (const b of [...booked].sort((x, y) => (x.minutes ?? 0) - (y.minutes ?? 0))) {
const at = out.findIndex((s) => s.minutes != null && s.minutes > (b.minutes ?? 0));
if (at < 0) out.push(b);
else out.splice(at, 0, b);
}
return out;
}
/** Today's day of the trip with its stops in plan order, if the trip runs today. */
@@ -77,21 +173,96 @@ export function todayOf(
const day = ordered[index];
const stops = [...(day.assignments ?? [])]
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.filter((a) => a.place?.name)
.map((a) => ({
name: a.place!.name,
time: a.place?.place_time ? formatTime(a.place.place_time.slice(0, 5)) : null,
minutes: minutesOf(a.place?.place_time),
}));
return { tripTitle: trip.title, path: `/trips/${trip.id}`, dayLabel: dayLabel(index + 1, ordered.length), stops };
.flatMap((a): WidgetStop[] =>
a.place?.name
? [
{
name: a.place.name,
time: a.place.place_time ? formatTime(a.place.place_time.slice(0, 5)) : null,
minutes: minutesOf(a.place.place_time),
...stopLook(a.place.category),
},
]
: []
);
return {
tripTitle: trip.title,
path: `/trips/${trip.id}`,
dayLabel: dayLabel(index + 1, ordered.length),
stops,
index: index + 1,
total: ordered.length,
};
}
export function buildSnapshot(trip: Trip | null, day: TripDay | null, labels: WidgetLabels): WidgetSnapshot {
/** The day with its timed bookings (a flight, the table tonight) joined to the stops in time order. */
export function withBookings(
day: TripDay,
reservations: Reservation[],
today: string,
formatTime: (time: string) => string
): TripDay {
const booked = reservations.flatMap((r): WidgetStop[] => {
const { date, time } = splitReservationDateTime(r.reservation_time);
if (r.status === 'cancelled' || !time || date !== today) return [];
return [
{
name: r.title,
time: formatTime(time),
minutes: minutesOf(time),
color: null,
symbol: RESERVATION_SYMBOLS[r.type] ?? 'bookmark.fill',
},
];
});
return booked.length ? { ...day, stops: mergeByTime(day.stops, booked) } : day;
}
/** A stop as the widget gets it: the empty fields left out rather than sent as null. */
function snapshotStop({ time, name, minutes, color, symbol }: WidgetStop): SnapshotStop {
return {
trip: trip ? { title: trip.title, start: trip.start_date!, end: trip.end_date!, path: `/trips/${trip.id}` } : null,
today: day ? { dayLabel: day.dayLabel, stops: day.stops.map(({ time, name }) => ({ time, name })) } : null,
time,
name,
...(minutes != null ? { minutes } : {}),
...(color ? { color } : {}),
...(symbol ? { symbol } : {}),
};
}
export function buildSnapshot(
trip: Trip | null,
day: TripDay | null,
labels: WidgetLabels,
extras: SnapshotExtras = {}
): WidgetSnapshot {
const snapshot: WidgetSnapshot = {
trip: trip
? {
title: trip.title,
start: trip.start_date ?? '',
end: trip.end_date ?? '',
path: `/trips/${trip.id}`,
...(extras.place ? { place: extras.place } : {}),
...(extras.cover ? { cover: extras.cover } : {}),
...(trip.day_count ? { dayCount: trip.day_count } : {}),
...(extras.placeCount ? { placeCount: extras.placeCount } : {}),
...(extras.memberCount ? { memberCount: extras.memberCount } : {}),
}
: null,
today: day
? {
dayLabel: day.dayLabel,
stops: day.stops.map(snapshotStop),
...(day.index ? { index: day.index, total: day.total } : {}),
}
: null,
labels,
};
if (extras.weather) snapshot.weather = extras.weather;
if (extras.bookings) snapshot.bookings = extras.bookings;
if (extras.todos) snapshot.todos = extras.todos;
if (extras.packing) snapshot.packing = extras.packing;
return snapshot;
}
/** The Live Activity's state: the first stop whose time is still ahead, else the last. */
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useLayoutEffect } from 'react'
import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore'
import { placeActions } from '../../components/Planner/usePlaceActions'
import { tourRouteStyle } from '../../components/Tours/planner/tourRouteStyle'
@@ -105,7 +105,9 @@ export function useTripPlannerPage() {
// The place card's height over the desktop map, so the selection pans its pin above it (#2991).
const inspectorCard = useMeasuredHeight()
const poiViewportChangeRef = useRef(poi.onViewportChange)
poiViewportChangeRef.current = poi.onViewportChange
useLayoutEffect(() => {
poiViewportChangeRef.current = poi.onViewportChange
})
// The place search looks where the plan map looks, and only while it is on screen.
const reportViewport = usePlanMapViewport(tripId, activeTab === 'plan')
const captureViewport = useCallback((bounds: Bbox) => {
+21 -8
View File
@@ -80,7 +80,7 @@ const REGIONAL_GLYPHS: { box: [number, number, number, number]; ranges: string[]
]
/** The glyph ranges a map of `bbox` needs: the common ones plus its region's scripts. */
export function glyphRangesFor(bbox: TileBbox | null): string[] {
function glyphRangesFor(bbox: TileBbox | null): string[] {
const out = new Set(GLYPH_RANGES)
if (bbox) {
for (const { box, ranges } of REGIONAL_GLYPHS) {
@@ -148,11 +148,8 @@ async function warm(cache: Cache | null, url: string): Promise<number> {
if (cache && (await cache.match(url))) return 0
const res = await fetch(url, { mode: 'cors' })
if (!res.ok) return 0
// The length header when the server sends one; otherwise the body itself.
const header = Number(res.headers.get('content-length'))
const size = header > 0 ? header : (await res.clone().blob()).size || 1
await cache?.put(url, res)
return size
await cache?.put(url, res.clone())
return await sizeOf(res)
} catch {
return 0
}
@@ -248,12 +245,13 @@ async function prefetchStyle(styleUrl: string, bbox: TileBbox | null = null): Pr
])
}
if (style.glyphs) {
const glyphs = style.glyphs
if (glyphs) {
// Through the same pool as the tiles: a trip in East Asia asks for a few
// hundred glyph ranges, and OpenFreeMap allows four requests at a time.
const urls = fontStacks(style).flatMap(stack =>
glyphRangesFor(bbox).map(range =>
style.glyphs!.replace('{fontstack}', encodeURIComponent(stack)).replace('{range}', range),
glyphs.replace('{fontstack}', encodeURIComponent(stack)).replace('{range}', range),
),
)
await inPool(urls, url => warm(cache, url))
@@ -311,6 +309,21 @@ export async function prefetchVectorForPlaces(
return { tiles: fetched, template }
}
/**
* The bytes a response brought, for the run's budget: the length header when
* the server sends one, else the body itself, and at least 1 so a fetched tile
* always counts as fetched.
*/
async function sizeOf(res: Response): Promise<number> {
const header = Number(res.headers?.get('content-length'))
if (header > 0) return header
try {
return (await res.blob()).size || 1
} catch {
return 1
}
}
/** Runs `task` over `items`, CONCURRENCY at a time. */
async function inPool<T>(items: T[], task: (item: T) => Promise<unknown>): Promise<void> {
let cursor = 0
+10 -4
View File
@@ -27,10 +27,9 @@ import type { Place } from '../types'
import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
import { isAuthed } from './authGate'
import { isStoragePersisted } from './persistentStorage'
import { isVectorStyle, normalizeTileUrl, resolveTileUrl, withTileApiKey } from '../utils/tileUrl'
import { isOfflineForbiddenTemplate, isVectorStyle, normalizeTileUrl, resolveTileUrl, withTileApiKey } from '../utils/tileUrl'
import { OFM_DARK, OFM_POSITRON } from '../constants/mapDefaults'
import { clearVectorCache, prefetchStyleAssets, prefetchVectorForPlaces } from './glPrefetcher'
import { isOfflineForbiddenTemplate } from '../components/Map/offlineBasemap'
import { isNativeIos } from '../native/platform'
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -390,6 +389,13 @@ function sameBbox(a: [number, number, number, number], b: TileBbox): boolean {
/** The OpenFreeMap styles the offline fallback draws; they share one tile set. */
const OFFLINE_BASEMAPS: readonly string[] = [OFM_POSITRON, OFM_DARK]
/**
* The offline basemap is a best-effort extra: a failed request leaves its tile
* uncached and the next sync tries again, so it stops neither the raster run
* nor the sync.
*/
function basemapFailed(): void {}
/**
* The trip's region of the offline basemap: the light style's tiles, style,
* sprite and glyphs, and the dark style's own style, sprite and glyphs on top,
@@ -398,8 +404,8 @@ const OFFLINE_BASEMAPS: readonly string[] = [OFM_POSITRON, OFM_DARK]
async function prefetchOfflineBasemap(places: Place[], template: string, cancelled: () => boolean): Promise<void> {
const light = template === OFM_DARK ? OFM_DARK : OFM_POSITRON
const other = light === OFM_DARK ? OFM_POSITRON : OFM_DARK
await prefetchVectorForPlaces(places, light, cancelled).catch(console.error)
if (!cancelled()) await prefetchStyleAssets(other, computeBbox(places)).catch(console.error)
await prefetchVectorForPlaces(places, light, cancelled).catch(basemapFailed)
if (!cancelled()) await prefetchStyleAssets(other, computeBbox(places)).catch(basemapFailed)
}
/**
+13
View File
@@ -132,3 +132,16 @@ export function resolveBasemap(
if (isVectorStyle(chosen)) return { kind: 'vector', style: chosen }
return { kind: 'raster', url: chosen }
}
/** Hosts whose tiles may not be stored for offline use at all (OSM tile usage policy). */
const NO_OFFLINE_HOSTS = ['tile.openstreetmap.org'];
/** Whether a raster template's host forbids storing its tiles (the offline basemap stands in). */
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;
}
}
+4
View File
@@ -1,4 +1,6 @@
import type { StoreApi } from 'zustand';
import { useDayPreviewStore } from '../../src/mobile/dayPreviewStore';
import { useSharedImportStore } from '../../src/native/sharedFiles';
import { useAddonStore } from '../../src/store/addonStore';
import { useAssistantHintsStore } from '../../src/store/assistantHintsStore';
import { useAuthStore } from '../../src/store/authStore';
@@ -33,6 +35,8 @@ type ResettableStore = Pick<StoreApi<object>, 'getState' | 'setState'>;
export const RESET_STORES: Record<string, ResettableStore> = {
useAuthStore,
useDayPreviewStore,
useSharedImportStore,
useTripStore,
useSettingsStore,
useVacayStore,
@@ -63,8 +63,8 @@ describe('MCollSheetKit', () => {
render(<SheetFooter><span>footer slot</span></SheetFooter>)
expect(screen.getByText('footer slot')).toBeInTheDocument()
// Both field presets have to sit on the sheet surface token, not a raw colour.
expect(INPUT_CLS).toContain('bg-[color:var(--m-sheet)]')
expect(TEXTAREA_CLS).toContain('bg-[color:var(--m-sheet)]')
// Both field presets sit on the field surface token, as every phone sheet's fields, not a raw colour.
expect(INPUT_CLS).toContain('bg-[color:var(--m-ic)]')
expect(TEXTAREA_CLS).toContain('bg-[color:var(--m-ic)]')
})
})
@@ -214,13 +214,14 @@ describe('MCollections', () => {
expect(document.querySelector('.animate-spin')).not.toBeNull()
})
it('FE-MOB-COLSCR-002: with no lists at all it offers the first-list CTA', () => {
it('FE-MOB-COLSCR-002: with no lists at all the dock + button starts the first one; the screen has no button of its own', async () => {
mocks.coll = makeHook({ collections: [], ownedLists: [], activeCollection: null, activeId: null, canShare: false })
render(<MCollections />)
render(<MCollections />, { initialEntries: ['/collections?create=place'] })
expect(screen.getByText('collections.empty.firstTitle')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /collections.newList/ }))
expect(fn('setEditorTarget')).toHaveBeenCalledWith('new')
expect(screen.queryByRole('button', { name: /^collections.newList$/ })).not.toBeInTheDocument()
await waitFor(() => expect(fn('setEditorTarget')).toHaveBeenCalledWith('new'))
expect(fn('setShowAddPlace')).not.toHaveBeenCalled()
expect(screen.queryByPlaceholderText('collections.search')).not.toBeInTheDocument()
})
@@ -262,7 +262,7 @@ describe('MJourneySettingsSheet', () => {
setup();
fireEvent.click(await screen.findByRole('button', { name: 'Create share link' }));
expect(await screen.findByText(`${window.location.origin}/public/journey/tok-1`)).toBeInTheDocument();
expect(await screen.findByDisplayValue(`${window.location.origin}/public/journey/tok-1`)).toBeInTheDocument();
expect(addToast).toHaveBeenCalledWith('Share link created', 'success', undefined);
for (const section of ['Timeline', 'Gallery', 'Map']) {
expect(screen.getByRole('switch', { name: section })).toBeInTheDocument();
@@ -286,7 +286,7 @@ describe('MJourneySettingsSheet', () => {
Object.defineProperty(window, 'isSecureContext', { configurable: true, writable: true, value: true });
setup();
expect(await screen.findByText(`${window.location.origin}/public/journey/tok-9`)).toBeInTheDocument();
expect(await screen.findByDisplayValue(`${window.location.origin}/public/journey/tok-9`)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/public/journey/tok-9`);
expect(await screen.findByRole('button', { name: 'Copied!' })).toBeInTheDocument();
@@ -230,9 +230,20 @@ describe('MPlanTimeline', () => {
expect(screen.getByTitle('Shibuya')).toBeInTheDocument()
})
it('FE-MOB-PLTL-006: a stay chip opens the stay editor for members who may edit days (#2210)', () => {
it('FE-MOB-PLTL-006: a stay chip opens the overview of its booking, not the editor (#2210)', () => {
const { shell } = renderTimeline(
{ hotelChips: [{ key: 'in-3', variant: 'checkin', name: 'Capsule Tokyo', time: '15:00:00', accId: 3, placeId: 301 }] },
{ reservations: [{ id: 44, type: 'hotel', accommodation_id: 3 }] as unknown as TripPlanner['reservations'] },
)
fireEvent.click(screen.getByRole('button', { name: 'day.checkIn · Capsule Tokyo · 15:00' }))
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 44 })
})
it('FE-MOB-PLTL-006d: a stay with neither booking nor place opens the editor for members who may edit days', () => {
const { shell } = renderTimeline({
hotelChips: [{ key: 'in-3', variant: 'checkin', name: 'Capsule Tokyo', time: '15:00:00', accId: 3, placeId: 301 }],
hotelChips: [{ key: 'in-3', variant: 'checkin', name: 'Capsule Tokyo', time: '15:00:00', accId: 3, placeId: null }],
})
fireEvent.click(screen.getByRole('button', { name: 'day.checkIn · Capsule Tokyo · 15:00' }))
@@ -350,7 +350,7 @@ describe('MReservationSheet', () => {
it('FE-MOB-RESSH-008: status, code, link and notes all reach the payload', async () => {
const { planner } = setup()
type(titleField(), 'Opera')
fireEvent.click(screen.getByRole('button', { name: 'reservations.confirmed' }))
fireEvent.click(screen.getByRole('radio', { name: 'reservations.confirmed' }))
type(screen.getByPlaceholderText('reservations.confirmationPlaceholder'), 'OP-7')
type(screen.getByPlaceholderText('reservations.urlPlaceholder'), 'https://opera.test')
type(screen.getByPlaceholderText('reservations.notesPlaceholder'), 'balcony')
@@ -236,7 +236,7 @@ describe('MTransportFormSheet', () => {
setValue(timeInputs()[1], '11:00')
setValue(screen.getByPlaceholderText('reservations.confirmationPlaceholder'), 'BUS-4711')
setValue(screen.getByPlaceholderText('reservations.notesPlaceholder'), 'Seat 4 reserved')
fireEvent.click(screen.getByRole('button', { name: 'reservations.confirmed' }))
fireEvent.click(screen.getByRole('radio', { name: 'reservations.confirmed' }))
await submit()
@@ -618,7 +618,7 @@ describe('MTransportFormSheet', () => {
expect(screen.getByPlaceholderText('reservations.titlePlaceholder')).toHaveValue('LH 716')
expect(screen.getByPlaceholderText('reservations.confirmationPlaceholder')).toHaveValue('ABC123')
expect(screen.getByPlaceholderText('reservations.notesPlaceholder')).toHaveValue('Exit row')
expect(screen.getByRole('button', { name: 'reservations.confirmed' }).className).toContain('bg-m-act')
expect(screen.getByRole('radio', { name: 'reservations.confirmed' }).className).toContain('bg-m-act')
// Endpoints are read in sequence order, not array order.
expect(airportInputs().map(i => i.value)).toEqual(['FRA', 'HND'])
expect(daySelects().map(s => s.value)).toEqual(['11', '12'])
@@ -45,6 +45,8 @@ vi.mock('../../../src/native/platform', async importOriginal => ({
beforeEach(async () => {
await clearAll();
setAuthed(true);
// restoreAllMocks leaves a vi.fn's return value as a test set it; the iOS case below sets true.
vi.mocked(isNativeIos).mockReturnValue(false);
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
// Stub fetch + serviceWorker so prefetch path is exercised
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
+2
View File
@@ -95,6 +95,8 @@ export const NOT_PLURAL = [
{ key: 'help.center.step', because: 'the position of a step in a guide' },
{ key: 'help.center.stepOf', because: 'the position of a step in a guide' },
{ key: 'help.center.imageAlt', because: 'the position of a step in a guide' },
{ key: 'native.widget.dayOf', because: 'the position of a day in the trip ("Day 2 of 5")' },
{ key: 'native.widget.inDays', because: 'filled in by the native widget, which says "Tomorrow" for one day, so the number is always two or more' },
{ key: 'system_notice.pager.goto', because: 'the position of a notice in the pager' },
{ key: 'tours.addedToDay', because: 'the number of the day a tour was added to' },
{ key: 'tours.planner.waypointLabel', because: 'the position of a waypoint on the tour' },
+4
View File
@@ -89,6 +89,10 @@ const native: TranslationStrings = {
'native.widget.tomorrow': 'غدًا',
'native.widget.nothingPlanned': 'لا شيء مخطط لليوم',
'native.widget.dayDone': 'انتهى كل شيء لليوم',
'native.widget.noBookings': 'لا توجد حجوزات قادمة',
'native.widget.noTodos': 'لم يتبق شيء',
'native.widget.now': 'الآن',
'native.widget.travellers': 'مسافرون',
};
export default native;
+4
View File
@@ -91,6 +91,10 @@ const native: TranslationStrings = {
'native.widget.tomorrow': 'Sabah',
'native.widget.nothingPlanned': 'Bu gün üçün plan yoxdur',
'native.widget.dayDone': 'Bu gün üçün hər şey bitdi',
'native.widget.noBookings': 'Qarşıda bron yoxdur',
'native.widget.noTodos': 'Görüləsi iş qalmayıb',
'native.widget.now': 'İndi',
'native.widget.travellers': 'səyyah',
};
export default native;
+4
View File
@@ -91,6 +91,10 @@ const native: TranslationStrings = {
'native.widget.tomorrow': 'Amanhã',
'native.widget.nothingPlanned': 'Nada planejado para hoje',
'native.widget.dayDone': 'Tudo feito por hoje',
'native.widget.noBookings': 'Nenhuma reserva pela frente',
'native.widget.noTodos': 'Nada pendente',
'native.widget.now': 'Agora',
'native.widget.travellers': 'viajantes',
};
export default native;
+4
View File
@@ -91,6 +91,10 @@ const native: TranslationStrings = {
'native.widget.tomorrow': 'Demà',
'native.widget.nothingPlanned': 'Res planificat per avui',
'native.widget.dayDone': 'Tot fet per avui',
'native.widget.noBookings': 'Cap reserva pendent',
'native.widget.noTodos': 'No queda res per fer',
'native.widget.now': 'Ara',
'native.widget.travellers': 'viatgers',
};
export default native;
+4
View File
@@ -91,6 +91,10 @@ const native: TranslationStrings = {
'native.widget.tomorrow': 'Zítra',
'native.widget.nothingPlanned': 'Na dnešek nic naplánováno',
'native.widget.dayDone': 'Na dnešek hotovo',
'native.widget.noBookings': 'Žádné nadcházející rezervace',
'native.widget.noTodos': 'Vše hotovo',
'native.widget.now': 'Teď',
'native.widget.travellers': 'cestovatelů',
};
export default native;
+4
View File
@@ -92,6 +92,10 @@ const native: TranslationStrings = {
'native.widget.tomorrow': 'Morgen',
'native.widget.nothingPlanned': 'Für heute nichts geplant',
'native.widget.dayDone': 'Für heute alles geschafft',
'native.widget.noBookings': 'Keine anstehenden Buchungen',
'native.widget.noTodos': 'Alles erledigt',
'native.widget.now': 'Jetzt',
'native.widget.travellers': 'Reisende',
};
export default native;

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