mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1037dec62 | |||
| 3466556849 | |||
| b7951baf23 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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? {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Sources/WidgetPreviews/Widgets/
|
||||
.build/
|
||||
out/
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-4
@@ -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();
|
||||
});
|
||||
});
|
||||
+5
-5
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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: '' });
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {} };
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user