mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1037dec62 | |||
| 3466556849 | |||
| b7951baf23 | |||
| f81fe8cb17 | |||
| 7e8e342e29 | |||
| 445fb173fb | |||
| b317877c41 | |||
| 27c19b5ff5 | |||
| ffd848e5dc | |||
| 45361c276c | |||
| 1b24e3dcef | |||
| 69d88ffefd | |||
| 92fc8ef28f | |||
| 2aa25e3fe7 | |||
| f6bcdb8e6f | |||
| 867a52bc0d | |||
| ec87f280c3 | |||
| 6e362de3da | |||
| 654900bc50 | |||
| 75d0a6181b | |||
| 6de15a92a4 | |||
| b15dcd9730 | |||
| 821f23aed4 | |||
| 79420cd945 | |||
| e3541535e2 |
@@ -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;
|
||||
};
|
||||
|
||||
@@ -29,8 +29,20 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
return configuration
|
||||
}
|
||||
|
||||
/// Kept alive here: WKWebView holds its navigation delegate weakly.
|
||||
private var navigationProxy: TrekNavigationProxy?
|
||||
|
||||
override func capacitorDidLoad() {
|
||||
TrekTileStore.shared.prune()
|
||||
// Capacitor shows its error page for every failed navigation, a replaced
|
||||
// one included: the UI moving to /login while the first page still loads
|
||||
// cancels that load (-999), and a first-time user landed on "Server not
|
||||
// reachable" with a server that was there all along.
|
||||
if let webView, let inner = webView.navigationDelegate {
|
||||
let proxy = TrekNavigationProxy(inner: inner)
|
||||
navigationProxy = proxy
|
||||
webView.navigationDelegate = proxy
|
||||
}
|
||||
bridge?.registerPluginInstance(TrekShellPlugin())
|
||||
// WebKit's own swipe back slides away a screenshot of the whole page,
|
||||
// closes no sheet and takes every drag near the edge from the UI. The UI
|
||||
@@ -272,3 +284,39 @@ final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
task.didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands in front of Capacitor's navigation delegate and hands it everything
|
||||
/// but the failures of a navigation that another one replaced: a cancelled
|
||||
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
|
||||
/// are no fault, and Capacitor would answer them with its error page.
|
||||
final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
private let inner: WKNavigationDelegate
|
||||
|
||||
init(inner: WKNavigationDelegate) {
|
||||
self.inner = inner
|
||||
}
|
||||
|
||||
static func isReplaced(_ error: Error) -> Bool {
|
||||
let error = error as NSError
|
||||
return (error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled)
|
||||
|| (error.domain == "WebKitErrorDomain" && error.code == 102)
|
||||
}
|
||||
|
||||
override func responds(to aSelector: Selector!) -> Bool {
|
||||
super.responds(to: aSelector) || (inner as AnyObject).responds(to: aSelector)
|
||||
}
|
||||
|
||||
override func forwardingTarget(for aSelector: Selector!) -> Any? {
|
||||
(inner as AnyObject).responds(to: aSelector) ? inner : nil
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.isReplaced(error) { return }
|
||||
inner.webView?(webView, didFail: navigation, withError: error)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.isReplaced(error) { return }
|
||||
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import SwiftUI
|
||||
|
||||
/// What is booked next: flights, trains, the hotel, the table for tonight,
|
||||
/// each with its kind as a coloured tile and when it is due.
|
||||
struct BookingsContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let bookings = snapshot?.bookings ?? []
|
||||
let labels = snapshot?.labels
|
||||
if family == .rectangular {
|
||||
accessory(bookings.first, labels)
|
||||
} else if family == .circular {
|
||||
ZStack {
|
||||
Circle().fill(Color.white.opacity(0.18))
|
||||
Image(systemName: bookings.first.map { Trek.booking($0.kind).symbol } ?? "bookmark.fill")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
}
|
||||
} else if family == .inline {
|
||||
if let first = bookings.first {
|
||||
Text("\(Image(systemName: Trek.booking(first.kind).symbol)) \(first.time ?? "") \(first.title)")
|
||||
} else {
|
||||
Text(labels?.noBookings ?? "")
|
||||
}
|
||||
} else if let labels, let first = bookings.first {
|
||||
switch family {
|
||||
case .small: hero(first, labels)
|
||||
case .medium: list(Array(bookings.prefix(2)), labels, featureFirst: true)
|
||||
default: list(Array(bookings.prefix(5)), labels, featureFirst: true)
|
||||
}
|
||||
} else {
|
||||
empty(labels)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Small: the next one, large
|
||||
|
||||
private func hero(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
let look = Trek.booking(booking.kind)
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
KindBadge(kind: booking.kind, size: 34)
|
||||
Spacer(minLength: 0)
|
||||
StatusDot(confirmed: booking.confirmed)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day)
|
||||
.font(.system(size: 11, weight: .heavy))
|
||||
.tracking(0.5)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(look.color)
|
||||
}
|
||||
if let time = booking.time {
|
||||
Text(time).font(Trek.number(30, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
}
|
||||
Text(booking.title).font(.system(size: 14, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 11.5, weight: .semibold)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.background(alignment: .topTrailing) {
|
||||
Image(systemName: look.symbol)
|
||||
.font(.system(size: 92, weight: .bold))
|
||||
.foregroundStyle(look.color.opacity(scheme == .dark ? 0.1 : 0.08))
|
||||
.rotationEffect(.degrees(booking.kind == "flight" ? -20 : 0))
|
||||
.offset(x: 28, y: 12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Medium and large: a list, the first one featured
|
||||
|
||||
private func list(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels, featureFirst: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: family == .large ? 8 : 7) {
|
||||
HStack {
|
||||
Text(labels.bookings ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
if let trip = snapshot?.trip { Text(trip.title).font(.system(size: 11, weight: .semibold)).foregroundStyle(Trek.faint(scheme)).lineLimit(1) }
|
||||
}
|
||||
ForEach(Array(bookings.enumerated()), id: \.offset) { index, booking in
|
||||
Link(destination: WidgetSnapshot.link(booking.path) ?? URL(string: "com.liketrek.trek://")!) {
|
||||
if index == 0 && featureFirst {
|
||||
featured(booking, labels)
|
||||
} else {
|
||||
row(booking, labels)
|
||||
}
|
||||
}
|
||||
}
|
||||
if family == .large { Spacer(minLength: 0) }
|
||||
}
|
||||
}
|
||||
|
||||
private func featured(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
let look = Trek.booking(booking.kind)
|
||||
return HStack(spacing: 10) {
|
||||
KindBadge(kind: booking.kind, size: 38)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(booking.title).font(.system(size: 15, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day).font(.system(size: 10.5, weight: .heavy)).textCase(.uppercase).foregroundStyle(look.color)
|
||||
}
|
||||
if let time = booking.time { Text(time).font(Trek.number(20, .heavy)).foregroundStyle(Trek.ink(scheme)) }
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(LinearGradient(colors: [look.color.opacity(scheme == .dark ? 0.24 : 0.14), look.color.opacity(scheme == .dark ? 0.08 : 0.04)], startPoint: .leading, endPoint: .trailing))
|
||||
)
|
||||
.overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(look.color.opacity(0.25), lineWidth: 0.8))
|
||||
}
|
||||
|
||||
private func row(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
KindBadge(kind: booking.kind, size: 28)
|
||||
.padding(.leading, 5)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 5) {
|
||||
Text(booking.title).font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
if !booking.confirmed { StatusDot(confirmed: false, size: 6) }
|
||||
}
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 11, weight: .medium)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day).font(.system(size: 10.5, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
if let time = booking.time { Text(time).font(Trek.number(13, .heavy)).foregroundStyle(Trek.ink(scheme)) }
|
||||
}
|
||||
.padding(.trailing, 5)
|
||||
}
|
||||
}
|
||||
|
||||
private func empty(_ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(labels?.bookings ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: -8) {
|
||||
ForEach(["flight", "train", "hotel"], id: \.self) { kind in
|
||||
KindBadge(kind: kind, size: 30).opacity(0.35)
|
||||
}
|
||||
}
|
||||
Text(labels?.noBookings ?? "")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func accessory(_ booking: WidgetSnapshot.Booking?, _ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
if let booking, let labels {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: Trek.booking(booking.kind).symbol).font(.system(size: 11, weight: .bold))
|
||||
Text([RelativeDay.label(booking.date, now: now, labels: labels), booking.time].compactMap { $0 }.joined(separator: " "))
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(booking.title).font(.system(size: 14, weight: .heavy)).lineLimit(1)
|
||||
if let detail = booking.detail { Text(detail).font(.system(size: 11, weight: .medium)).opacity(0.75).lineLimit(1) }
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
Text(labels?.noBookings ?? "").font(.system(size: 12, weight: .medium)).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirmed in green, pending in amber, with a soft halo.
|
||||
struct StatusDot: View {
|
||||
let confirmed: Bool
|
||||
var size: CGFloat = 9
|
||||
|
||||
var body: some View {
|
||||
let color = confirmed ? Trek.confirmed : Trek.pending
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(Circle().stroke(color.opacity(0.3), lineWidth: size * 0.45).frame(width: size * 1.7, height: size * 1.7))
|
||||
.frame(width: size * 1.8, height: size * 1.8)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -166,31 +166,31 @@
|
||||
"mobile/screens/admin/MAdminUsersSection.tsx": 14,
|
||||
"mobile/screens/admin/MApiProviderBlocks.tsx": 10,
|
||||
"mobile/screens/atlas/MAtlas.tsx": 5,
|
||||
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 16,
|
||||
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 14,
|
||||
"mobile/screens/atlas/MAtlasCountryPopup.tsx": 11,
|
||||
"mobile/screens/atlas/MAtlasSearch.tsx": 5,
|
||||
"mobile/screens/atlas/MAtlasStatsCard.tsx": 3,
|
||||
"mobile/screens/collections/MCollAddSheet.tsx": 7,
|
||||
"mobile/screens/collections/MCollAddSheet.tsx": 6,
|
||||
"mobile/screens/collections/MCollCategoryPicker.tsx": 2,
|
||||
"mobile/screens/collections/MCollEditSheet.tsx": 5,
|
||||
"mobile/screens/collections/MCollEditSheet.tsx": 2,
|
||||
"mobile/screens/collections/MCollLabelsSheet.tsx": 6,
|
||||
"mobile/screens/collections/MCollLinksEditor.tsx": 2,
|
||||
"mobile/screens/collections/MCollListPickerSheet.tsx": 3,
|
||||
"mobile/screens/collections/MCollPlaceRow.tsx": 3,
|
||||
"mobile/screens/collections/MCollPlaceSheet.tsx": 11,
|
||||
"mobile/screens/collections/MCollShareSheet.tsx": 14,
|
||||
"mobile/screens/collections/MCollSheetKit.tsx": 6,
|
||||
"mobile/screens/collections/MCollSheetKit.tsx": 5,
|
||||
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
|
||||
"mobile/screens/collections/MCollections.tsx": 21,
|
||||
"mobile/screens/collections/MCollections.tsx": 18,
|
||||
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 36,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 20,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 31,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 12,
|
||||
"mobile/screens/dashboard/MUserMenu.tsx": 7,
|
||||
"mobile/screens/journey/MJourney.tsx": 10,
|
||||
"mobile/screens/journey/MJourneyCreateSheet.tsx": 10,
|
||||
"mobile/screens/journey/MJourneyCreateSheet.tsx": 7,
|
||||
"mobile/screens/journey/MJourneyDetail.tsx": 6,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 52,
|
||||
"mobile/screens/journey/MJourneySettingsSheet.tsx": 32,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 48,
|
||||
"mobile/screens/journey/MJourneySettingsSheet.tsx": 2,
|
||||
"mobile/screens/journey/mobileJourneyMeta.ts": 4,
|
||||
"mobile/screens/notifications/MNotificationRow.tsx": 4,
|
||||
"mobile/screens/notifications/MNotifications.tsx": 3,
|
||||
@@ -201,13 +201,13 @@
|
||||
"mobile/screens/settings/MMobileNavCustomizer.tsx": 8,
|
||||
"mobile/screens/settings/MPhotoProvidersSection.tsx": 2,
|
||||
"mobile/screens/settings/MScopeGroupPicker.tsx": 5,
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 1,
|
||||
"mobile/screens/settings/MSettings.tsx": 2,
|
||||
"mobile/screens/settings/MSettingsAbout.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 20,
|
||||
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
|
||||
"mobile/screens/settings/MSettingsMap.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsMcp.tsx": 30,
|
||||
"mobile/screens/settings/MSettingsMcp.tsx": 26,
|
||||
"mobile/screens/settings/MSettingsNotifications.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsOffline.tsx": 18,
|
||||
"mobile/screens/settings/MSettingsPlugins.tsx": 14,
|
||||
@@ -216,9 +216,9 @@
|
||||
"mobile/screens/trip/MTripLoadingSplash.tsx": 2,
|
||||
"mobile/screens/trip/MTripShell.tsx": 9,
|
||||
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
|
||||
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 4,
|
||||
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 2,
|
||||
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
|
||||
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7,
|
||||
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 6,
|
||||
"mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1,
|
||||
"mobile/screens/trip/plan/MPlanTimeline.tsx": 12,
|
||||
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 24,
|
||||
@@ -235,8 +235,8 @@
|
||||
"mobile/screens/trip/sheets/ImpFileStep.tsx": 12,
|
||||
"mobile/screens/trip/sheets/ImpListStep.tsx": 3,
|
||||
"mobile/screens/trip/sheets/MAccommodationSheet.tsx": 10,
|
||||
"mobile/screens/trip/sheets/MBookingFields.tsx": 6,
|
||||
"mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 5,
|
||||
"mobile/screens/trip/sheets/MBookingFields.tsx": 5,
|
||||
"mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 3,
|
||||
"mobile/screens/trip/sheets/MCostSheet.tsx": 33,
|
||||
"mobile/screens/trip/sheets/MDaySheet.tsx": 22,
|
||||
"mobile/screens/trip/sheets/MDaysSheet.tsx": 4,
|
||||
@@ -251,11 +251,10 @@
|
||||
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
|
||||
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
|
||||
"mobile/screens/trip/sheets/MTripSheetUi.tsx": 5,
|
||||
"mobile/screens/trip/sheets/MTripSheets.tsx": 2,
|
||||
"mobile/screens/trip/sheets/PlCategoryPicker.tsx": 2,
|
||||
"mobile/screens/trip/sheets/PlFileAttach.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlPlaceSearch.tsx": 6,
|
||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 5,
|
||||
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
|
||||
@@ -280,10 +279,10 @@
|
||||
"mobile/screens/trip/tabs/filesModel.ts": 5,
|
||||
"mobile/screens/trip/tabs/tabChrome.tsx": 12,
|
||||
"mobile/screens/vacay/MVacay.tsx": 17,
|
||||
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
|
||||
"mobile/screens/vacay/MVacayInviteSheet.tsx": 8,
|
||||
"mobile/screens/vacay/MVacayMonth.tsx": 3,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
|
||||
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 34,
|
||||
"mobile/screens/vacay/MVacayShareSheet.tsx": 10,
|
||||
"mobile/screens/vacay/vacayDayModel.ts": 1,
|
||||
"pages/AtlasPage.tsx": 32,
|
||||
"pages/DashboardPage.tsx": 1,
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
@@ -12,6 +12,8 @@ const { probeNow } = vi.hoisted(() => ({
|
||||
probeNow: vi.fn(async (): Promise<'online' | 'offline' | 'proxy-wall'> => 'offline'),
|
||||
}))
|
||||
vi.mock('../sync/connectivity', () => ({ probeNow }))
|
||||
const isNativeApp = vi.hoisted(() => vi.fn(() => false))
|
||||
vi.mock('../native/platform', () => ({ isNativeApp }))
|
||||
|
||||
const { apiClient, adminApi, mapsApi, pluginsApi, parseInDev } = await import('./client')
|
||||
|
||||
@@ -357,6 +359,24 @@ describe('client > redirect handling', () => {
|
||||
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/trips/7?tab=map#day-2'))
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-019b: in the app it waits for the first page to finish loading before it leaves', async () => {
|
||||
isNativeApp.mockReturnValue(true)
|
||||
const ready = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState')
|
||||
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true })
|
||||
const loc = setLocation('/dashboard')
|
||||
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 })))
|
||||
|
||||
await captureError(() => apiClient.get('/auth/me'))
|
||||
expect(loc.href).not.toContain('/login')
|
||||
|
||||
window.dispatchEvent(new Event('load'))
|
||||
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/dashboard'))
|
||||
|
||||
delete (document as { readyState?: unknown }).readyState
|
||||
if (ready) Object.defineProperty(Document.prototype, 'readyState', ready)
|
||||
isNativeApp.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-020: an MFA_REQUIRED 403 sends the user to the settings page', async () => {
|
||||
const loc = setLocation('/dashboard')
|
||||
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 })))
|
||||
|
||||
@@ -70,13 +70,13 @@ import {
|
||||
RoadtripVia,
|
||||
RoadtripViaBatchRequest,
|
||||
RoadtripViaCreateRequest,
|
||||
RoadtripViaReanchorRequest,
|
||||
RoadtripViaUpdateRequest,
|
||||
RoadtripViaReanchorRequest, RoadtripViaUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
import { downloadBlob } from '../utils/fileDownload'
|
||||
import { translateRateLimit } from './rateLimitMessage'
|
||||
import { hardNavigate } from './hardNavigate'
|
||||
|
||||
/**
|
||||
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||
@@ -218,7 +218,7 @@ apiClient.interceptors.response.use(
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
hardNavigate('/login?redirect=' + encodeURIComponent(currentPath))
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -226,7 +226,7 @@ apiClient.interceptors.response.use(
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
hardNavigate('/settings?mfa=required')
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { isNativeApp } from '../native/platform';
|
||||
|
||||
/**
|
||||
* A full page load to `url`. In the apps it waits for the first page to finish
|
||||
* loading: started earlier, it cancels that load, and the shell answers a
|
||||
* cancelled load with its "Server not reachable" page, which is where a
|
||||
* signed-out user opening the app for the first time ended up.
|
||||
*/
|
||||
export function hardNavigate(url: string): void {
|
||||
const go = () => {
|
||||
window.location.href = url;
|
||||
};
|
||||
if (isNativeApp() && document.readyState !== 'complete') window.addEventListener('load', go, { once: true });
|
||||
else go();
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export default function MSaveToCollectionSheet() {
|
||||
{/* Header — mirrors the place detail sheet: icon + title + target name + close */}
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-[14px] bg-[color:var(--m-ic)] text-m-muted">
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Bookmark size={18} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock('../../native/appLock', () => ({
|
||||
}));
|
||||
|
||||
function stateListener(): (state: { isActive: boolean }) => void {
|
||||
const call = vi.mocked(App.addListener).mock.calls.find(([name]) => name === 'appStateChange');
|
||||
const call = vi.mocked(App.addListener).mock.calls.find(([name]) => (name as string) === 'appStateChange');
|
||||
if (!call) throw new Error('no appStateChange listener');
|
||||
return call[1] as unknown as (state: { isActive: boolean }) => void;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -475,9 +475,8 @@ describe('PlaceDetailsColumn — hours and rating', () => {
|
||||
* FE-PDC-027 — nothing came back for this place.
|
||||
*
|
||||
* It used to be a line of grey text plus, when no Google key was set, a panel
|
||||
* telling the reader to go and ask an administrator for one. The mascot says the
|
||||
* same thing in the shape every other empty state in TREK uses, and whose key is
|
||||
* missing is not this panel's business.
|
||||
* telling the reader to go and ask an administrator for one. One quiet line says
|
||||
* that nothing came back, and whose key is missing is not this panel's business.
|
||||
*/
|
||||
describe('PlaceDetailsColumn — nothing found', () => {
|
||||
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
|
||||
|
||||
@@ -26,7 +26,6 @@ import { mapsApi } from '../../api/client'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
|
||||
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { LABEL } from '../shared/dialogParts'
|
||||
@@ -66,8 +65,8 @@ interface PlaceDetailsColumnProps {
|
||||
fluid?: boolean
|
||||
/**
|
||||
* 'dialog' is the desktop add place dialog: a grey panel of white cards under
|
||||
* small eyebrows, and a quiet hint card where the sheet shows the mascot. The
|
||||
* default is the phone sheet's look, which this prop leaves exactly as it was.
|
||||
* small eyebrows. The default is the phone sheet's look: a card in the mobile
|
||||
* tokens with the same one-line hints.
|
||||
*/
|
||||
variant?: 'sheet' | 'dialog'
|
||||
/** False on an instance with no Google key, which is most of them. */
|
||||
@@ -76,13 +75,24 @@ interface PlaceDetailsColumnProps {
|
||||
|
||||
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
/** The phone sheet's quiet one-line state: an icon and a sentence, no mascot taking half the screen. */
|
||||
function SheetHint({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="flex items-center gap-2 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2.5 text-caption text-m-muted">
|
||||
<span className="flex-none">{icon}</span>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overline label, the section heading Vacay and the dashboard widgets share.
|
||||
* Uppercase with wide tracking, muted, no rule underneath.
|
||||
*/
|
||||
function Overline({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<p className="text-caption font-semibold uppercase tracking-[0.14em] text-content-faint">{children}</p>
|
||||
// The phone form's eyebrow (mobile Eyebrow), so these labels match Name, Notes and the rest below.
|
||||
<p className="font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.09em] text-m-faint">{children}</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,7 +103,7 @@ function Overline({ children }: { children: React.ReactNode }): React.ReactEleme
|
||||
function Block({ label, dialog, children }: { label: React.ReactNode; dialog: boolean; children: React.ReactNode }): React.ReactElement {
|
||||
if (!dialog) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-[5px]">
|
||||
<Overline>{label}</Overline>
|
||||
{children}
|
||||
</div>
|
||||
@@ -126,6 +136,9 @@ export default function PlaceDetailsColumn({
|
||||
const adoptBlockedId = useId()
|
||||
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
|
||||
const [state, setState] = useState<LoadState>('idle')
|
||||
// The phone sheet folds the card away to give the form's own fields the room.
|
||||
const [open, setOpen] = useState(true)
|
||||
const bodyId = useId()
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const selectionKey = selection ? cacheKeyFor(selection, language) : null
|
||||
@@ -225,17 +238,36 @@ export default function PlaceDetailsColumn({
|
||||
// tiles, which is too small to tell a facade from a foyer. The column is
|
||||
// stretched to the form's height by the row it sits in, so the extra room
|
||||
// costs nothing that was being used.
|
||||
<aside className={dialog ? `${SIDE_COLUMN} ${fluid ? '' : 'sm:w-80'}` : `w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch`}>
|
||||
<aside className={dialog ? `${SIDE_COLUMN} ${fluid ? '' : 'sm:w-80'}` : `w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-[18px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] overflow-hidden self-stretch`}>
|
||||
{dialog ? (
|
||||
<ColumnHead icon={<Landmark size={13} strokeWidth={2.2} />}>{t('places.details.title')}</ColumnHead>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
|
||||
<Landmark size={15} className="text-accent" />
|
||||
<span className="text-body font-semibold text-content">{t('places.details.title')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={bodyId}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className={`flex w-full shrink-0 items-center gap-2.5 px-3 py-2.5 text-start ${open ? 'border-b border-[color:var(--m-rowbr)]' : ''}`}
|
||||
>
|
||||
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Landmark size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-body font-semibold text-m-ink">{t('places.details.title')}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
strokeWidth={2.2}
|
||||
className={`flex-none text-m-muted transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : 'flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5'}>
|
||||
{/* Folding on the phone: the grid row eases between its height and none. */}
|
||||
<div
|
||||
id={bodyId}
|
||||
className={dialog ? 'contents' : `grid transition-[grid-template-rows] duration-300 ease-out ${open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'}`}
|
||||
>
|
||||
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : `min-h-0 overflow-hidden ${open ? '' : 'invisible'}`}>
|
||||
<div className={dialog ? 'contents' : 'p-3 space-y-3'}>
|
||||
{/* The place's picture first: its own ones are there whatever the sources answer. */}
|
||||
<PictureBlock
|
||||
photos={state === 'ready' && !data?.disabled ? (data?.photos ?? []) : []}
|
||||
@@ -246,19 +278,13 @@ export default function PlaceDetailsColumn({
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Nothing picked yet, which is a state rather than a fault: the mascot
|
||||
waits with an idle look instead of a sad one. The dialog says the
|
||||
same in a quiet card, and the column keeps its width either way. */}
|
||||
{/* Nothing picked yet, which is a state rather than a fault: one quiet
|
||||
line, in the dialog and on the phone alike, so the form's own fields
|
||||
stay in view instead of sitting under a mascot half a screen high. */}
|
||||
{!selection && (dialog ? (
|
||||
<HintCard icon={<ImageIcon size={15} strokeWidth={2} />}>{t('places.details.empty')}</HintCard>
|
||||
) : (
|
||||
<EmptyState
|
||||
scene="idle"
|
||||
title={t('places.details.empty')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
<SheetHint icon={<ImageIcon size={14} strokeWidth={2} />}>{t('places.details.empty')}</SheetHint>
|
||||
))}
|
||||
|
||||
{selection && state === 'loading' && (dialog ? (
|
||||
@@ -278,22 +304,13 @@ export default function PlaceDetailsColumn({
|
||||
? <HintCard icon={<Landmark size={15} strokeWidth={2} />}>{t('places.details.disabled')}</HintCard>
|
||||
: <p className="text-caption text-content-muted">{t('places.details.disabled')}</p>)}
|
||||
|
||||
{/* The mascot rather than a line of grey text and, under it, a paragraph
|
||||
telling the reader to go and ask an administrator for a Google key.
|
||||
Nothing came back for this place, which is worth saying once and
|
||||
{/* Nothing came back for this place, which is worth saying once and
|
||||
plainly; whose key is missing is not this panel's business, and on a
|
||||
place the free sources describe fine it read as an advert. */}
|
||||
{isEmpty && (dialog ? (
|
||||
<HintCard icon={<SearchX size={15} strokeWidth={2} />}>{t('places.details.nothing')}</HintCard>
|
||||
) : (
|
||||
<EmptyState
|
||||
scene="search"
|
||||
mood="sad"
|
||||
title={t('places.details.nothing')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
<SheetHint icon={<SearchX size={14} strokeWidth={2} />}>{t('places.details.nothing')}</SheetHint>
|
||||
))}
|
||||
|
||||
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
|
||||
@@ -319,6 +336,8 @@ export default function PlaceDetailsColumn({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -642,7 +661,7 @@ function DescriptionBlock({
|
||||
|
||||
return (
|
||||
<Block label={aboutBrand ? t('places.details.aboutBrand') : t('places.details.description')} dialog={dialog}>
|
||||
<div className={dialog ? `${COLUMN_CARD} p-3 space-y-2` : 'rounded-lg border border-edge bg-surface p-2.5 space-y-2'}>
|
||||
<div className={dialog ? `${COLUMN_CARD} p-3 space-y-2` : 'rounded-[14px] bg-[color:var(--m-ic)] p-3 space-y-2'}>
|
||||
{aboutBrand && (
|
||||
<p className="flex items-start gap-1.5 text-caption leading-tight text-content-faint">
|
||||
<Building2 className="mt-px h-3 w-3 shrink-0" />
|
||||
@@ -674,7 +693,7 @@ function DescriptionBlock({
|
||||
onClick={onAdopt}
|
||||
disabled={adoptBlocked}
|
||||
aria-describedby={adoptBlocked ? adoptBlockedId : undefined}
|
||||
className={dialog ? WHITE_BUTTON : 'w-full inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-body font-medium bg-accent-subtle text-accent-on hover:bg-accent hover:text-accent-text disabled:opacity-50 disabled:hover:bg-accent-subtle disabled:hover:text-accent-on transition-colors'}
|
||||
className={dialog ? WHITE_BUTTON : 'inline-flex h-8 items-center justify-center gap-1.5 rounded-full bg-m-act px-3.5 text-caption font-semibold text-m-actfg transition-transform active:scale-[.97] disabled:opacity-40'}
|
||||
style={dialog ? fs(13, 'body') : undefined}
|
||||
>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -44,6 +44,7 @@ export function SuggestionInputBox({
|
||||
}: SuggestionInputBoxProps) {
|
||||
return (
|
||||
<div
|
||||
data-field-trigger={floating ? undefined : 'pad'}
|
||||
className={floating ? 'focus-within:ring-2 focus-within:ring-accent' : 'bg-surface-tertiary'}
|
||||
style={
|
||||
floating
|
||||
|
||||
@@ -20,7 +20,7 @@ import { formatMoney, formatTime } from '../../../utils/formatters'
|
||||
import { copyText } from '../../../utils/clipboard'
|
||||
import { fmtTransitDuration } from '../transitDisplay'
|
||||
import { parseMeta, displayTitle } from './bookingsModel'
|
||||
import { formatDay, type BookingFacts } from './bookingFacts'
|
||||
import { stayStats, type BookingFacts } from './bookingFacts'
|
||||
import { AirTrailPill, BOX, Eyebrow, Field, FileRows, ReviewPill, TravelerChips, fs, toneOf } from './bookingParts'
|
||||
import { TransitLegs, transitSummary } from './transitParts'
|
||||
import { BookingDialogHeader, StatusPill, TypePill } from './BookingDialogShell'
|
||||
@@ -79,10 +79,7 @@ export default function BookingDetailDialog(p: BookingDetailDialogProps) {
|
||||
|
||||
const stats: { value: string; label: string }[] = []
|
||||
if (facts.isHotel) {
|
||||
if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') })
|
||||
if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') })
|
||||
const nights = nightsBetween(facts.startDate, facts.endDate)
|
||||
if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) })
|
||||
stats.push(...stayStats(facts, meta, t, locale))
|
||||
} else {
|
||||
const from = facts.endpoints[0]
|
||||
const to = facts.endpoints[facts.endpoints.length - 1]
|
||||
@@ -318,8 +315,3 @@ function DetailField({ label, children }: { label: string; children: ReactNode }
|
||||
)
|
||||
}
|
||||
|
||||
function nightsBetween(a: string | null, b: string | null): number {
|
||||
if (!a || !b) return 0
|
||||
const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5)
|
||||
return n > 0 ? n : 0
|
||||
}
|
||||
|
||||
@@ -133,3 +133,28 @@ export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts {
|
||||
isHotel,
|
||||
}
|
||||
}
|
||||
|
||||
function nightsBetween(a: string | null, b: string | null): number {
|
||||
if (!a || !b) return 0
|
||||
const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5)
|
||||
return n > 0 ? n : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* A stay's three tiles: check-in and check-out with their dates and times, and
|
||||
* the nights between. The desktop booking dialog and the phone's booking sheet
|
||||
* show the same ones.
|
||||
*/
|
||||
export function stayStats(
|
||||
facts: BookingFacts,
|
||||
meta: { check_in_time?: string | null; check_out_time?: string | null },
|
||||
t: FactsContext['t'],
|
||||
locale: string,
|
||||
): { value: string; label: string }[] {
|
||||
const stats: { value: string; label: string }[] = []
|
||||
if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') })
|
||||
if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') })
|
||||
const nights = nightsBetween(facts.startDate, facts.endDate)
|
||||
if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) })
|
||||
return stats
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
Ruler,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createElement, useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -38,7 +38,7 @@ import { inheritedTrackColor, resolveTrackColor } from '../Map/trackColors';
|
||||
import { BOX } from '../Planner/bookings/bookingParts';
|
||||
import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation';
|
||||
import { SoftPill, tintOf } from '../Planner/planParts';
|
||||
import DetailShell from '../shared/DetailShell';
|
||||
import DetailShell, { type DetailShellProps } from '../shared/DetailShell';
|
||||
import { DeleteButton, DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell';
|
||||
import ElevationProfile from '../shared/ElevationProfile';
|
||||
import { NavigationMenu } from '../shared/NavigationMenu';
|
||||
@@ -108,6 +108,8 @@ interface TourDetailDialogProps extends TourPermissionProps {
|
||||
readOnly?: boolean;
|
||||
/** Desktop-only opener; the map detail stays non-modal and returns focus when closed. */
|
||||
desktopFocusReturnTarget?: HTMLElement | null;
|
||||
/** The frame it renders into: the desktop's card over the map, or the phone's sheet. */
|
||||
shell?: ComponentType<DetailShellProps>;
|
||||
}
|
||||
|
||||
/** The pills of the head band: white like a place's, one fact each. */
|
||||
@@ -518,6 +520,7 @@ export default function TourDetailDialog({
|
||||
desktopNonModal = false,
|
||||
readOnly = false,
|
||||
desktopFocusReturnTarget = null,
|
||||
shell: Shell = DetailShell,
|
||||
canEdit: editPermission,
|
||||
canAssign: assignPermission,
|
||||
}: TourDetailDialogProps) {
|
||||
@@ -701,7 +704,7 @@ export default function TourDetailDialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailShell
|
||||
<Shell
|
||||
testId="tour-detail"
|
||||
header={header}
|
||||
footer={footer}
|
||||
@@ -797,7 +800,7 @@ export default function TourDetailDialog({
|
||||
|
||||
<TourProvenanceBlock tripId={place.trip_id} tour={tour} canCheck={canEdit} onReimported={onSourceReimported} />
|
||||
<TourFilesSection placeId={place.id} files={placeFiles} onFileUpload={canEdit ? onFileUpload : undefined} />
|
||||
</DetailShell>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AlertTriangle, HelpCircle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { DialogTile, NEUTRAL_TINT, fs } from './DialogShell'
|
||||
import { useLastOpen, usePresence } from './usePresence'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
@@ -28,7 +29,14 @@ function runConfirm(onConfirm: () => void): void {
|
||||
}
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const { mounted, closing } = usePresence(props.isOpen)
|
||||
// The question stays readable while it fades, even when its owner clears the subject on close.
|
||||
const shown = useLastOpen(props, props.isOpen)
|
||||
return mounted ? <ConfirmDialogFrame {...shown} isOpen={props.isOpen} closing={closing} /> : null
|
||||
}
|
||||
|
||||
function ConfirmDialogFrame({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
@@ -38,7 +46,8 @@ export default function ConfirmDialog({
|
||||
cancelLabel,
|
||||
danger = true,
|
||||
children,
|
||||
}: ConfirmDialogProps) {
|
||||
closing = false,
|
||||
}: ConfirmDialogProps & { closing?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const isPhone = useIsPhone()
|
||||
|
||||
@@ -57,14 +66,21 @@ export default function ConfirmDialog({
|
||||
return () => document.removeEventListener('keydown', handleEsc, true)
|
||||
}, [isOpen, handleEsc])
|
||||
|
||||
if (!isOpen) return null
|
||||
if (!isOpen && !closing) return null
|
||||
|
||||
// Each used by both layouts, worked out once.
|
||||
const heading = title || t('common.confirm')
|
||||
const cancelText = cancelLabel || t('common.cancel')
|
||||
const confirmText = confirmLabel || t('common.delete')
|
||||
const modalExit = closing ? 'trek-modal-exit' : ''
|
||||
const width = children ? 'max-w-md' : 'max-w-sm'
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
// Backdrop only — Escape (above) and the Cancel button below are the
|
||||
// keyboard routes out of the dialog.
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
|
||||
className={`fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)] ${closing ? 'trek-backdrop-exit pointer-events-none' : ''}`}
|
||||
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
@@ -74,7 +90,7 @@ export default function ConfirmDialog({
|
||||
own, and the two buttons below it always stay in reach. */}
|
||||
<div
|
||||
role="presentation"
|
||||
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
|
||||
className={`trek-modal-enter ${modalExit} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${width} p-6 bg-surface-card`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex min-h-0 items-start gap-4 overflow-y-auto overscroll-contain">
|
||||
@@ -85,7 +101,7 @@ export default function ConfirmDialog({
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-content">
|
||||
{title || t('common.confirm')}
|
||||
{heading}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-content-secondary">
|
||||
{message}
|
||||
@@ -99,7 +115,7 @@ export default function ConfirmDialog({
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-content-secondary border border-edge-secondary"
|
||||
>
|
||||
{cancelLabel || t('common.cancel')}
|
||||
{cancelText}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => { runConfirm(onConfirm); onClose() }}
|
||||
@@ -107,7 +123,7 @@ export default function ConfirmDialog({
|
||||
danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel || t('common.delete')}
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +133,7 @@ export default function ConfirmDialog({
|
||||
// with its tile, the message below, the answers in a bar at the foot.
|
||||
<div
|
||||
role="presentation"
|
||||
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${children ? 'max-w-md' : 'max-w-sm'}`}
|
||||
className={`trek-modal-enter ${modalExit} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${width}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={`flex flex-none items-center gap-3.5 px-6 pb-4 pt-5 ${danger ? 'bg-danger-soft' : ''}`} style={danger ? undefined : { background: NEUTRAL_TINT }}>
|
||||
@@ -127,7 +143,7 @@ export default function ConfirmDialog({
|
||||
: <HelpCircle size={20} strokeWidth={1.9} className="text-content-muted" />}
|
||||
</DialogTile>
|
||||
<h3 className="m-0 min-w-0 flex-1 font-bold tracking-[-0.01em] text-content" style={fs(18, 'subtitle')}>
|
||||
{title || t('common.confirm')}
|
||||
{heading}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto overscroll-contain px-6 py-5">
|
||||
@@ -137,13 +153,13 @@ export default function ConfirmDialog({
|
||||
<div className="flex flex-none items-center justify-end gap-2 border-t border-edge-faint px-6 py-3.5" style={fs(13, 'body')}>
|
||||
<button type="button" onClick={onClose}
|
||||
className="inline-flex items-center rounded-[10px] bg-surface-tertiary px-3.5 py-2 font-medium text-content hover:bg-surface-hover">
|
||||
{cancelLabel || t('common.cancel')}
|
||||
{cancelText}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => { runConfirm(onConfirm); onClose() }}
|
||||
className={`inline-flex items-center rounded-[10px] px-4 py-2 font-medium hover:opacity-90 ${danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'}`}
|
||||
>
|
||||
{confirmLabel || t('common.delete')}
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, type ComponentType, type CSSProperties } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
import { useLastOpen, usePresence } from './usePresence'
|
||||
|
||||
interface MenuItem {
|
||||
label?: string
|
||||
@@ -50,6 +51,13 @@ interface ContextMenuProps {
|
||||
}
|
||||
|
||||
export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
const { mounted, closing } = usePresence(menu != null, 130)
|
||||
// The items stay on screen while the menu fades, after its owner set it to null.
|
||||
const shown = useLastOpen(menu, menu != null)
|
||||
return mounted && shown ? <ContextMenuFrame menu={shown} onClose={onClose} closing={closing} /> : null
|
||||
}
|
||||
|
||||
function ContextMenuFrame({ menu, onClose, closing }: { menu: MenuState; onClose: () => void; closing: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -120,10 +128,8 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
}
|
||||
}, [menu])
|
||||
|
||||
if (!menu) return null
|
||||
|
||||
return createPortal(
|
||||
<div ref={ref} role="menu" tabIndex={-1} onKeyDown={moveFocus} className="trek-popover-enter" style={{
|
||||
<div ref={ref} role="menu" tabIndex={-1} onKeyDown={moveFocus} className={`trek-popover-enter ${closing ? 'trek-popover-exit pointer-events-none' : ''}`} style={{
|
||||
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
|
||||
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
|
||||
border: '1px solid var(--border-primary)',
|
||||
|
||||
@@ -298,6 +298,7 @@ export function CustomDatePicker({
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'stretch' }}>
|
||||
{/* Calendar trigger */}
|
||||
<button
|
||||
data-field-trigger={compact || borderless ? undefined : ''}
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
disabled={disabled}
|
||||
@@ -345,6 +346,7 @@ export function CustomDatePicker({
|
||||
{/* Keyboard / text-input trigger (non-compact only; compact gets it in the popup footer) */}
|
||||
{!compact && !borderless && (
|
||||
<button
|
||||
data-field-trigger=""
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTextInput(inputValue || '');
|
||||
|
||||
@@ -186,6 +186,7 @@ export default function CustomSelect({
|
||||
<button
|
||||
ref={triggerRef}
|
||||
id={id}
|
||||
data-field-trigger=""
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
|
||||
@@ -129,7 +129,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||
<div style={{
|
||||
<div data-field-trigger="" style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 0,
|
||||
borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', overflow: 'hidden',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NEUTRAL_TINT } from './DialogShell'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { centredMapCard } from '../Map/centredMapCard'
|
||||
|
||||
interface DetailShellProps {
|
||||
export interface DetailShellProps {
|
||||
header: ReactNode
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ import { lockBodyScroll } from '../../utils/bodyScrollLock'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { focusDialog, trapTab } from './dialogFocus'
|
||||
import { useLastOpen, usePresence } from './usePresence'
|
||||
|
||||
const sizeClasses: Record<string, string> = {
|
||||
sm: 'max-w-sm',
|
||||
@@ -36,7 +37,11 @@ interface ModalProps {
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, ...frame }: ModalProps) {
|
||||
return isOpen ? <ModalFrame {...frame} /> : null
|
||||
const { mounted, closing } = usePresence(isOpen)
|
||||
// While it leaves, the dialog shows what it showed last: its owner often
|
||||
// clears the item in the same step that closes it.
|
||||
const shown = useLastOpen(frame, isOpen)
|
||||
return mounted ? <ModalFrame {...shown} closing={closing} /> : null
|
||||
}
|
||||
|
||||
function ModalFrame({
|
||||
@@ -47,7 +52,8 @@ function ModalFrame({
|
||||
footer,
|
||||
hideCloseButton = false,
|
||||
align = 'center',
|
||||
}: Omit<ModalProps, 'isOpen'>) {
|
||||
closing = false,
|
||||
}: Omit<ModalProps, 'isOpen'> & { closing?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
@@ -55,8 +61,8 @@ function ModalFrame({
|
||||
const [focusedBefore] = useState(() => document.activeElement)
|
||||
|
||||
const handleEsc = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
if (e.key === 'Escape' && !closing) onClose()
|
||||
}, [onClose, closing])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
@@ -85,7 +91,7 @@ function ModalFrame({
|
||||
// click-away, the panel only keeps that click from reaching it. Escape
|
||||
// and the header's close button are the keyboard route out.
|
||||
role="presentation"
|
||||
className={`fixed inset-0 z-[10000] flex items-start ${align === 'top' ? '' : 'sm:items-center'} justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]`}
|
||||
className={`fixed inset-0 z-[10000] flex items-start ${align === 'top' ? '' : 'sm:items-center'} justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)] ${closing ? 'trek-backdrop-exit pointer-events-none' : ''}`}
|
||||
style={{ paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
@@ -99,7 +105,7 @@ function ModalFrame({
|
||||
tabIndex={-1}
|
||||
onKeyDown={e => { if (panelRef.current) trapTab(e, panelRef.current) }}
|
||||
className={`
|
||||
trek-modal-enter outline-none
|
||||
trek-modal-enter ${closing ? 'trek-modal-exit' : ''} outline-none
|
||||
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||
flex flex-col
|
||||
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/* Closing: the mirror of each entrance, shorter and easing in, so a dialog,
|
||||
confirm or menu leaves as gently as it came (usePresence.ts, which imports
|
||||
this file, keeps it mounted for EXIT_MS). Added next to the entrance class rather than
|
||||
instead of it, because on phones the entrance class also docks the dialog. */
|
||||
@keyframes trek-modal-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
@keyframes trek-drawer-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(40%);
|
||||
}
|
||||
}
|
||||
@keyframes trek-backdrop-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.trek-modal-enter.trek-modal-exit,
|
||||
.trek-popover-enter.trek-popover-exit {
|
||||
animation: trek-modal-exit 160ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
.trek-backdrop-enter.trek-backdrop-exit {
|
||||
animation: trek-backdrop-exit 170ms ease-in forwards;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.trek-modal-enter.trek-modal-exit {
|
||||
animation: trek-drawer-exit 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-modal-enter.trek-modal-exit,
|
||||
.trek-popover-enter.trek-popover-exit,
|
||||
.trek-backdrop-enter.trek-backdrop-exit {
|
||||
animation: trek-backdrop-exit 120ms ease-out forwards;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** The hook outside the test mode, where it really lingers for the exit animation. */
|
||||
async function loadAnimated() {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('MODE', 'production');
|
||||
return import('./usePresence');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('usePresence', () => {
|
||||
it('stays mounted and closing for the exit, then unmounts', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { usePresence } = await loadAnimated();
|
||||
const hook = renderHook(({ open }) => usePresence(open, 170), { initialProps: { open: true } });
|
||||
expect(hook.result.current).toEqual({ mounted: true, closing: false });
|
||||
|
||||
hook.rerender({ open: false });
|
||||
expect(hook.result.current).toEqual({ mounted: true, closing: true });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(170);
|
||||
});
|
||||
expect(hook.result.current).toEqual({ mounted: false, closing: false });
|
||||
});
|
||||
|
||||
it('never mounts something that was never open, and reopens straight from closing', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { usePresence } = await loadAnimated();
|
||||
const hook = renderHook(({ open }) => usePresence(open, 170), { initialProps: { open: false } });
|
||||
expect(hook.result.current.mounted).toBe(false);
|
||||
|
||||
hook.rerender({ open: true });
|
||||
hook.rerender({ open: false });
|
||||
hook.rerender({ open: true });
|
||||
expect(hook.result.current).toEqual({ mounted: true, closing: false });
|
||||
});
|
||||
|
||||
it('closes at once under the unit tests', async () => {
|
||||
vi.resetModules();
|
||||
const { usePresence } = await import('./usePresence');
|
||||
const hook = renderHook(({ open }) => usePresence(open), { initialProps: { open: true } });
|
||||
hook.rerender({ open: false });
|
||||
expect(hook.result.current.mounted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLastOpen', () => {
|
||||
it('keeps the last value seen while open', async () => {
|
||||
const { useLastOpen } = await import('./usePresence');
|
||||
const hook = renderHook(({ value, open }) => useLastOpen(value, open), {
|
||||
initialProps: { value: 'Delete Paris?' as string | null, open: true },
|
||||
});
|
||||
hook.rerender({ value: null, open: false });
|
||||
expect(hook.result.current).toBe('Delete Paris?');
|
||||
hook.rerender({ value: 'Delete Rome?', open: true });
|
||||
expect(hook.result.current).toBe('Delete Rome?');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import './presence.css';
|
||||
|
||||
/**
|
||||
* How long a closing dialog, confirm or menu stays for its exit animation
|
||||
* (index.css). None under the unit tests: hundreds of them close a confirm and
|
||||
* check at once that it is gone, which is what the user sees once the fade ends.
|
||||
*/
|
||||
export const EXIT_MS = import.meta.env.MODE === 'test' ? 0 : 170;
|
||||
|
||||
/**
|
||||
* Keeps something on screen for its exit animation after it was told to close:
|
||||
* `mounted` stays true for EXIT_MS once `open` turns false, with `closing` set
|
||||
* meanwhile so the caller can add the exit class and stop taking clicks.
|
||||
*/
|
||||
export function usePresence(open: boolean, ms: number = EXIT_MS): { mounted: boolean; closing: boolean } {
|
||||
const animated = ms > 0 && EXIT_MS > 0;
|
||||
// The open state of the last render, kept in state so the render that closes
|
||||
// already knows it closed (React's "storing information from previous renders").
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
const [lingering, setLingering] = useState(false);
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
setLingering(!open && animated);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!lingering) return;
|
||||
const timer = window.setTimeout(() => setLingering(false), ms);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [lingering, ms]);
|
||||
|
||||
// The first render after closing already counts as closing, before the state update lands.
|
||||
const closing = animated && !open && (lingering || prevOpen);
|
||||
return { mounted: open || closing, closing };
|
||||
}
|
||||
|
||||
/**
|
||||
* The last value seen while open: what a closing popup shows during its exit,
|
||||
* since its owner often clears the data in the same step that closes it.
|
||||
*/
|
||||
export function useLastOpen<T>(value: T, open: boolean): T {
|
||||
const last = useRef(value);
|
||||
if (open) last.current = value;
|
||||
return open ? value : last.current;
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { tickHaptic } from '../../native/haptics';
|
||||
import type { Day } from '../../types';
|
||||
import MDayPager, { pagesFor } from './MDayPager';
|
||||
import MDayPager from './MDayPager';
|
||||
import { pagesFor } from './dayPagerPages';
|
||||
|
||||
vi.mock('../../native/haptics', () => ({ tickHaptic: vi.fn() }));
|
||||
|
||||
@@ -82,6 +83,22 @@ describe('MDayPager', () => {
|
||||
expect(screen.getByRole('status')).toHaveTextContent('day 3 of 4');
|
||||
});
|
||||
|
||||
it('previews the day a swipe heads for while the strip still moves, and takes it back', () => {
|
||||
const onPreview = vi.fn();
|
||||
const { strip, onSelectDay } = setup(2, { onPreview });
|
||||
|
||||
place(strip, 3, true);
|
||||
fireEvent.scroll(strip);
|
||||
fireEvent.scroll(strip);
|
||||
expect(onPreview).toHaveBeenCalledTimes(1);
|
||||
expect(onPreview).toHaveBeenLastCalledWith(3);
|
||||
expect(onSelectDay).not.toHaveBeenCalled();
|
||||
|
||||
place(strip, 2, true);
|
||||
fireEvent.scroll(strip);
|
||||
expect(onPreview).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('asks once, however many events report the same landing', () => {
|
||||
const { strip, onSelectDay } = setup(2);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { tickHaptic } from '../../native/haptics';
|
||||
import type { Day } from '../../types';
|
||||
import { pagesFor, type PagerItem } from './dayPagerPages';
|
||||
|
||||
/**
|
||||
* Pages through the trip's days the way a native pager does: the previous,
|
||||
@@ -28,7 +28,6 @@ const SETTLE_MS = 90;
|
||||
const REST_PX = 2;
|
||||
|
||||
/** Anything with an id pages: trip days, a leave year's months, the plan's years. */
|
||||
export type PagerItem = Pick<Day, 'id'>;
|
||||
|
||||
export interface MDayPagerProps {
|
||||
days: PagerItem[];
|
||||
@@ -47,33 +46,12 @@ export interface MDayPagerProps {
|
||||
* (Vacay's month editor and year grid) rather than over the whole screen.
|
||||
*/
|
||||
inFlow?: boolean;
|
||||
}
|
||||
|
||||
interface Page {
|
||||
key: string;
|
||||
dayId: number | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** The window of pages around the selection. A selection that points at no
|
||||
* day (the whole-trip view, or a day deleted remotely) still gets its page,
|
||||
* with the first day next to it as a way back. */
|
||||
export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[] {
|
||||
const i = days.findIndex((d) => d.id === selectedDayId);
|
||||
if (i < 0) {
|
||||
const first = days[0];
|
||||
return [
|
||||
{ key: 'none', dayId: selectedDayId, active: true },
|
||||
...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false }] : []),
|
||||
];
|
||||
}
|
||||
const pages: Page[] = [];
|
||||
const prev = days[i - 1];
|
||||
const next = days[i + 1];
|
||||
if (prev) pages.push({ key: `d${prev.id}`, dayId: prev.id, active: false });
|
||||
pages.push({ key: `d${days[i].id}`, dayId: days[i].id, active: true });
|
||||
if (next) pages.push({ key: `d${next.id}`, dayId: next.id, active: false });
|
||||
return pages;
|
||||
/**
|
||||
* The day a swipe is heading for, as soon as its page is the nearer one, and
|
||||
* null when the swipe falls back: lets a day rail follow the finger before
|
||||
* the snap lands and onSelectDay fires.
|
||||
*/
|
||||
onPreview?: (dayId: number | null) => void;
|
||||
}
|
||||
|
||||
export default function MDayPager({
|
||||
@@ -84,6 +62,7 @@ export default function MDayPager({
|
||||
locked = false,
|
||||
describeDay,
|
||||
inFlow = false,
|
||||
onPreview,
|
||||
}: MDayPagerProps) {
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
@@ -91,8 +70,10 @@ export default function MDayPager({
|
||||
const windowKey = pages.map((p) => p.key).join(',');
|
||||
|
||||
// The handlers below are attached once, so they read the props through here.
|
||||
const latest = useRef({ days, onSelectDay, describeDay, selectedDayId });
|
||||
latest.current = { days, onSelectDay, describeDay, selectedDayId };
|
||||
const latest = useRef({ days, onSelectDay, describeDay, selectedDayId, onPreview });
|
||||
useLayoutEffect(() => {
|
||||
latest.current = { days, onSelectDay, describeDay, selectedDayId, onPreview };
|
||||
});
|
||||
/** The day a landed swipe asked for, until the selection catches up. Stops a
|
||||
* late scroll event from selecting it twice. */
|
||||
const requested = useRef<number | null>(null);
|
||||
@@ -126,6 +107,31 @@ export default function MDayPager({
|
||||
if (!strip) return;
|
||||
let timer = 0;
|
||||
let touching = false;
|
||||
let previewed: number | null = null;
|
||||
|
||||
// The nearer page while the strip moves: a page past the middle is where the
|
||||
// swipe will land unless the finger turns back.
|
||||
const preview = () => {
|
||||
const notify = latest.current.onPreview;
|
||||
if (!notify) return;
|
||||
const left = strip.getBoundingClientRect().left;
|
||||
let nearest: HTMLElement | null = null;
|
||||
let distance = Infinity;
|
||||
for (const child of Array.from(strip.children) as HTMLElement[]) {
|
||||
const d = Math.abs(child.getBoundingClientRect().left - left);
|
||||
if (d < distance) {
|
||||
distance = d;
|
||||
nearest = child;
|
||||
}
|
||||
}
|
||||
if (!nearest) return;
|
||||
const raw = nearest.hasAttribute('data-page-active') ? '' : nearest.dataset.dayId;
|
||||
const dayId = raw ? Number(raw) : null;
|
||||
const next = dayId != null && Number.isFinite(dayId) ? dayId : null;
|
||||
if (next === previewed) return;
|
||||
previewed = next;
|
||||
notify(next);
|
||||
};
|
||||
|
||||
const settle = () => {
|
||||
if (touching) return;
|
||||
@@ -140,6 +146,7 @@ export default function MDayPager({
|
||||
const dayId = raw ? Number(raw) : NaN;
|
||||
if (!Number.isFinite(dayId) || requested.current === dayId) return;
|
||||
requested.current = dayId;
|
||||
previewed = null;
|
||||
const s = latest.current;
|
||||
tickHaptic();
|
||||
s.onSelectDay(dayId);
|
||||
@@ -162,14 +169,18 @@ export default function MDayPager({
|
||||
later();
|
||||
};
|
||||
|
||||
strip.addEventListener('scroll', later, { passive: true });
|
||||
const onScroll = () => {
|
||||
preview();
|
||||
later();
|
||||
};
|
||||
strip.addEventListener('scroll', onScroll, { passive: true });
|
||||
strip.addEventListener('scrollend', onScrollEnd);
|
||||
strip.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
strip.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
strip.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
strip.removeEventListener('scroll', later);
|
||||
strip.removeEventListener('scroll', onScroll);
|
||||
strip.removeEventListener('scrollend', onScrollEnd);
|
||||
strip.removeEventListener('touchstart', onTouchStart);
|
||||
strip.removeEventListener('touchend', onTouchEnd);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Check, Copy, Share as ShareIcon } from 'lucide-react';
|
||||
import { canShareLink, shareLink } from '../../native/shareLink';
|
||||
|
||||
/** The round icon buttons next to the link: copy and share, the same size. */
|
||||
const ACTION = 'flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg';
|
||||
|
||||
/**
|
||||
* A link to hand on, read-only, with copy and (where the system has a share
|
||||
* sheet) share next to it: the trip's share and invite links and a journey's
|
||||
* public link all look the same.
|
||||
*/
|
||||
export default function MLinkField({
|
||||
url,
|
||||
copied,
|
||||
onCopy,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
shareLabel,
|
||||
}: {
|
||||
url: string;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
shareLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-1 pe-1 ps-[14px]">
|
||||
<input
|
||||
readOnly
|
||||
value={url}
|
||||
aria-label={url}
|
||||
className="min-w-0 flex-1 border-none bg-transparent font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-muted outline-none"
|
||||
/>
|
||||
<button type="button" aria-label={copied ? copiedLabel : copyLabel} onClick={onCopy} className={ACTION}>
|
||||
{copied ? <Check size={15} strokeWidth={2.4} /> : <Copy size={15} strokeWidth={2.2} />}
|
||||
</button>
|
||||
{canShareLink() && (
|
||||
<button type="button" aria-label={shareLabel} onClick={() => void shareLink(url)} className={ACTION}>
|
||||
<ShareIcon size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -170,7 +170,8 @@ export default function MSheet({
|
||||
const exitClass = EXIT[variant]
|
||||
const animation = open ? (entered ? '' : enterClass) : flung ? '' : exitClass
|
||||
|
||||
const resolvedMaterial = material ?? (variant === 'bottom' ? 'bar-glass' : 'glass')
|
||||
// One material for every sheet, docked or floating, so they all read the same.
|
||||
const resolvedMaterial = material ?? 'glass'
|
||||
|
||||
return createPortal(
|
||||
// m-root on the overlay so the --m-* tokens resolve even when the portal
|
||||
|
||||
+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();
|
||||
});
|
||||
});
|
||||
+14
-13
@@ -6,10 +6,11 @@
|
||||
import { ChevronRight, FileText } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { tripsApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import MSheet from '../../mobile/components/MSheet';
|
||||
import { useSharedImportStore } from '../../native/sharedFiles';
|
||||
import { tripRepo } from '../../repo/tripRepo';
|
||||
import { TileHeader } from '../screens/trip/sheets/MTripSheetUi';
|
||||
import MSheet from './MSheet';
|
||||
|
||||
interface TripRow {
|
||||
id: number;
|
||||
@@ -31,7 +32,7 @@ export default function NativeImportSheet(): React.ReactElement | null {
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
tripsApi.list().then(
|
||||
tripRepo.list().then(
|
||||
(data: { trips?: TripRow[] }) => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const list = [...(data.trips ?? [])];
|
||||
@@ -47,21 +48,21 @@ export default function NativeImportSheet(): React.ReactElement | null {
|
||||
|
||||
const pick = (id: number) => {
|
||||
chooseTrip(id);
|
||||
navigate(`/trips/${id}`);
|
||||
void navigate(`/trips/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={() => setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}>
|
||||
<div className="px-4 pb-4 pt-6">
|
||||
<div className="mb-1 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold text-m-ink">
|
||||
{t('native.share.title')}
|
||||
<div className="px-[18px] pb-4 pt-4">
|
||||
<div className="mb-3">
|
||||
<TileHeader
|
||||
icon={<FileText size={19} strokeWidth={1.8} />}
|
||||
title={t('native.share.title')}
|
||||
sub={file?.name}
|
||||
onClose={() => setFile(null)}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
{file && (
|
||||
<div className="mb-3 flex items-center gap-2 px-1 font-geist text-[length:calc(11px*var(--fs-scale-body,1))] text-m-muted">
|
||||
<FileText size={13} aria-hidden="true" />
|
||||
<span className="truncate">{file.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex max-h-[50vh] flex-col gap-2 overflow-y-auto">
|
||||
{trips.map((trip) => (
|
||||
<button
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Day } from '../../types';
|
||||
|
||||
/** A day as the pager needs it: only its id, so the plan and Vacay can both page. */
|
||||
export type PagerItem = Pick<Day, 'id'>;
|
||||
|
||||
interface Page {
|
||||
key: string;
|
||||
dayId: number | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** The window of pages around the selection. A selection that points at no
|
||||
* day (the whole-trip view, or a day deleted remotely) still gets its page,
|
||||
* with the first day next to it as a way back. */
|
||||
export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[] {
|
||||
const i = days.findIndex((d) => d.id === selectedDayId);
|
||||
if (i < 0) {
|
||||
const first = days[0];
|
||||
return [
|
||||
{ key: 'none', dayId: selectedDayId, active: true },
|
||||
...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false }] : []),
|
||||
];
|
||||
}
|
||||
const pages: Page[] = [];
|
||||
const prev = days[i - 1];
|
||||
const next = days[i + 1];
|
||||
if (prev) pages.push({ key: `d${prev.id}`, dayId: prev.id, active: false });
|
||||
pages.push({ key: `d${days[i].id}`, dayId: days[i].id, active: true });
|
||||
if (next) pages.push({ key: `d${next.id}`, dayId: next.id, active: false });
|
||||
return pages;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* The day a swipe is heading for, before the pager has settled on it. The day
|
||||
* rail in the trip header lights that chip at once, the way a native tab strip
|
||||
* follows the finger, while the plan itself switches only when the snap lands
|
||||
* (MDayPager's onSelectDay). Null when no swipe is under way.
|
||||
*/
|
||||
interface DayPreviewState {
|
||||
dayId: number | null;
|
||||
setDayId: (dayId: number | null) => void;
|
||||
}
|
||||
|
||||
export const useDayPreviewStore = create<DayPreviewState>((set) => ({
|
||||
dayId: null,
|
||||
setDayId: (dayId) => set((s) => (s.dayId === dayId ? s : { dayId })),
|
||||
}));
|
||||
@@ -478,3 +478,24 @@
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* A scroll box under a fixed header: content fades out over the padding at its
|
||||
edges instead of being cut off in a hard line right under the header. */
|
||||
.m-scroll-fade {
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
|
||||
}
|
||||
|
||||
/* The shared pickers (select, date, time) inside the phone UI look like the
|
||||
sheets' own fields: a 42 px round field on the field surface. They style
|
||||
themselves inline for the desktop, hence the !important. */
|
||||
.m-root [data-field-trigger] {
|
||||
min-height: 42px;
|
||||
border-radius: 9999px !important;
|
||||
border: 1px solid var(--m-rowbr) !important;
|
||||
background: var(--m-ic) !important;
|
||||
}
|
||||
.m-root button[data-field-trigger],
|
||||
.m-root [data-field-trigger='pad'] {
|
||||
padding-inline: 14px !important;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -44,13 +47,19 @@ function canAnimate(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves once no route fallback is on screen, or after SETTLE_MS. */
|
||||
/**
|
||||
* Resolves once no route fallback is on screen, or after SETTLE_MS. Polled with
|
||||
* a timer, never requestAnimationFrame: while a view transition waits for its
|
||||
* update, the browser renders no frames, so a frame callback never came and the
|
||||
* transition hung until the browser gave up after four seconds, on every
|
||||
* navigation.
|
||||
*/
|
||||
function settled(): Promise<void> {
|
||||
const began = performance.now();
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (!document.querySelector('[data-route-fallback]') || performance.now() - began > SETTLE_MS) resolve();
|
||||
else requestAnimationFrame(check);
|
||||
else window.setTimeout(check, 16);
|
||||
};
|
||||
check();
|
||||
});
|
||||
@@ -79,14 +88,12 @@ export function useRouteTransition(location: Location, navigationType: Navigatio
|
||||
});
|
||||
current = transition;
|
||||
// A newer navigation skips this one: both promises then reject, which is no error.
|
||||
transition.ready.catch(() => {});
|
||||
void transition.finished
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
// Only the newest transition clears the direction, or a skipped one would
|
||||
// take it away from the one that replaced it.
|
||||
if (current === transition) delete root.dataset.routeTransition;
|
||||
});
|
||||
transition.ready.catch(skippedByNewer);
|
||||
void transition.finished.catch(skippedByNewer).finally(() => {
|
||||
// Only the newest transition clears the direction, or a skipped one would
|
||||
// take it away from the one that replaced it.
|
||||
if (current === transition) delete root.dataset.routeTransition;
|
||||
});
|
||||
}, [location, navigationType, enabled]);
|
||||
|
||||
return enabled ? shown : location;
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useFormatDate } from '../../../hooks/useDateFormat'
|
||||
import MPluginVersionDate from './MPluginVersionDate'
|
||||
import {
|
||||
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database,
|
||||
Globe, Info, History, PauseCircle, Puzzle, Bot,
|
||||
} from 'lucide-react'
|
||||
import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database, Globe, Info, History, PauseCircle, Puzzle, Bot, ListChecks } from 'lucide-react'
|
||||
import PluginIcon from '../../../components/shared/PluginIcon'
|
||||
import { type RangeWarning } from '../../../components/Admin/useRangeBypass'
|
||||
import { deriveCaps } from '../../../components/Admin/pluginCaps'
|
||||
@@ -353,9 +348,10 @@ export default function MAdminPluginsPanel() {
|
||||
|
||||
{/* Error-log sheet */}
|
||||
{errorsFor && (
|
||||
<MSheet open onClose={() => setErrorsFor(null)} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.errorLog')}>
|
||||
<MSheet open onClose={() => setErrorsFor(null)} variant="bottom" material="glass" ariaLabel={t('admin.plugins.errorLog')}>
|
||||
<MAdminSheetFrame
|
||||
title={<span className="flex items-center gap-2"><Bug size={15} /> {errorsFor.id} — {t('admin.plugins.errorLog')}</span>}
|
||||
icon={<Bug size={19} strokeWidth={1.8} />}
|
||||
title={<>{errorsFor.id} — {t('admin.plugins.errorLog')}</>}
|
||||
onClose={() => setErrorsFor(null)}
|
||||
>
|
||||
<div className="font-mono text-[0.6875rem]">
|
||||
@@ -374,9 +370,10 @@ export default function MAdminPluginsPanel() {
|
||||
|
||||
{/* Operator-supplied egress hosts */}
|
||||
{egressFor && (
|
||||
<MSheet open onClose={() => setEgressFor(null)} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.allowedHosts')}>
|
||||
<MSheet open onClose={() => setEgressFor(null)} variant="bottom" material="glass" ariaLabel={t('admin.plugins.allowedHosts')}>
|
||||
<MAdminSheetFrame
|
||||
title={<span className="flex items-center gap-2"><Globe size={15} /> {egressFor.id} — {t('admin.plugins.allowedHosts')}</span>}
|
||||
icon={<Globe size={19} strokeWidth={1.8} />}
|
||||
title={<>{egressFor.id} — {t('admin.plugins.allowedHosts')}</>}
|
||||
onClose={() => setEgressFor(null)}
|
||||
>
|
||||
{!egressFor.supported ? (
|
||||
@@ -421,9 +418,10 @@ export default function MAdminPluginsPanel() {
|
||||
|
||||
{/* Instance-wide settings (the admin-owned scope:'instance' fields) */}
|
||||
{settings.form && (
|
||||
<MSheet open onClose={settings.close} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.instanceSettings')}>
|
||||
<MSheet open onClose={settings.close} variant="bottom" material="glass" ariaLabel={t('admin.plugins.instanceSettings')}>
|
||||
<MAdminSheetFrame
|
||||
title={<span className="flex items-center gap-2"><SlidersHorizontal size={15} /> {settings.form.id} — {t('admin.plugins.instanceSettings')}</span>}
|
||||
icon={<SlidersHorizontal size={19} strokeWidth={1.8} />}
|
||||
title={<>{settings.form.id} — {t('admin.plugins.instanceSettings')}</>}
|
||||
onClose={settings.close}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -602,8 +600,8 @@ function PickerSheet({ open, onClose, title, options, value, onPick }: {
|
||||
options: Array<[string, string]>; value: string; onPick: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="bottom" material="opaque" ariaLabel={title}>
|
||||
<MAdminSheetFrame title={title} onClose={onClose}>
|
||||
<MSheet open={open} onClose={onClose} variant="bottom" material="glass" ariaLabel={title}>
|
||||
<MAdminSheetFrame icon={<ListChecks size={19} strokeWidth={1.8} />} title={title} onClose={onClose}>
|
||||
<div className="space-y-1">
|
||||
{options.map(([v, lbl]) => (
|
||||
<button key={v} type="button" onClick={() => { onPick(v); onClose() }}
|
||||
@@ -760,8 +758,8 @@ function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onSetti
|
||||
const linkable = p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link'
|
||||
const rowClass = 'flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-start text-[0.8125rem] font-semibold text-m-ink'
|
||||
return (
|
||||
<MSheet open onClose={onClose} variant="bottom" material="opaque" ariaLabel={p.name}>
|
||||
<MAdminSheetFrame title={p.name} onClose={onClose}>
|
||||
<MSheet open onClose={onClose} variant="bottom" material="glass" ariaLabel={p.name}>
|
||||
<MAdminSheetFrame icon={<Puzzle size={19} strokeWidth={1.8} />} title={p.name} onClose={onClose}>
|
||||
<div className="space-y-1">
|
||||
{p.enabled === 1 && (
|
||||
<button type="button" className={rowClass} onClick={onRestart}><RotateCw size={16} /> {t('admin.plugins.restart')}</button>
|
||||
@@ -813,8 +811,8 @@ function VersionPickerSheet({ plugin, versions, failed, busy, t, onPick, onClose
|
||||
t: T; onPick: (version: string) => void; onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<MSheet open onClose={onClose} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.versionPickerTitle', { name: plugin.name })}>
|
||||
<MAdminSheetFrame title={t('admin.plugins.versionPickerTitle', { name: plugin.name })} onClose={onClose}>
|
||||
<MSheet open onClose={onClose} variant="bottom" material="glass" ariaLabel={t('admin.plugins.versionPickerTitle', { name: plugin.name })}>
|
||||
<MAdminSheetFrame icon={<History size={19} strokeWidth={1.8} />} title={t('admin.plugins.versionPickerTitle', { name: plugin.name })} onClose={onClose}>
|
||||
<div className="space-y-0.5">
|
||||
{failed && <p className="px-2.5 py-2 text-xs text-[color:var(--m-st-danger)]">{t('admin.plugins.detailError')}</p>}
|
||||
{!failed && !versions && <p className="px-2.5 py-2 text-xs text-m-faint">{t('common.loading')}</p>}
|
||||
@@ -973,7 +971,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
|
||||
const sectionH = 'text-[11px] font-semibold uppercase tracking-wider text-m-muted'
|
||||
|
||||
return (
|
||||
<MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={item.name}>
|
||||
<MSheet open onClose={onClose} variant="card" material="glass" ariaLabel={item.name}>
|
||||
<div className="relative flex-none">
|
||||
<Screenshot url={item.screenshotUrl} className="aspect-[16/9] w-full" iconSize={36} />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
@@ -1195,7 +1193,7 @@ function SignatureBlockSheet({ data, entry, busy, t, onRetrust, onClose }: {
|
||||
const offerRetrust = retrust !== null
|
||||
|
||||
return (
|
||||
<MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={t('admin.plugins.sig.title', { name: data.subject.name })}>
|
||||
<MSheet open onClose={onClose} variant="card" material="glass" ariaLabel={t('admin.plugins.sig.title', { name: data.subject.name })}>
|
||||
<div className="flex-none px-[18px] pt-[18px]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><ShieldAlert size={18} /></div>
|
||||
@@ -1248,7 +1246,7 @@ function UpdateConsentSheet({ data, unsigned, t, onApprove, onLater }: {
|
||||
t: T; onApprove: () => void; onLater: () => void
|
||||
}) {
|
||||
return (
|
||||
<MSheet open onClose={onLater} variant="card" material="opaque" ariaLabel={t('admin.plugins.updateConsentTitle')}>
|
||||
<MSheet open onClose={onLater} variant="card" material="glass" ariaLabel={t('admin.plugins.updateConsentTitle')}>
|
||||
<div className="flex-none px-[18px] pt-[18px]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><ShieldCheck size={18} /></div>
|
||||
@@ -1305,7 +1303,7 @@ function DependencyResolveSheet({ data, t, busy, installedIds, onDownload, onClo
|
||||
}) {
|
||||
const rows = dependencyRows(data)
|
||||
return (
|
||||
<MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={t('admin.plugins.dep.resolveTitle')}>
|
||||
<MSheet open onClose={onClose} variant="card" material="glass" ariaLabel={t('admin.plugins.dep.resolveTitle')}>
|
||||
<div className="flex-none px-[18px] pt-[18px]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><Puzzle size={18} /></div>
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function MAdminResetMfaSheet({ open, onClose, user, onReset, t }:
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} ariaLabel={t('admin.mfa.reset')}>
|
||||
<MAdminSheetFrame
|
||||
icon={<ShieldOff size={19} strokeWidth={1.8} />}
|
||||
title={t('admin.mfa.reset')}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
|
||||
@@ -41,8 +41,9 @@ function AddCountrySheet({
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<MSheet open={open} onClose={close} variant="card" material="opaque" ariaLabel={t('schoolCatalog.addCountry')}>
|
||||
<MSheet open={open} onClose={close} variant="card" material="glass" ariaLabel={t('schoolCatalog.addCountry')}>
|
||||
<MAdminSheetFrame
|
||||
icon={<GraduationCap size={19} strokeWidth={1.8} />}
|
||||
title={t('schoolCatalog.addCountry')}
|
||||
onClose={close}
|
||||
footer={
|
||||
@@ -113,11 +114,12 @@ function RegionSheet({ region, busy, offline, error, onClose, onSave }: EditorPr
|
||||
if (!busy) onClose();
|
||||
}}
|
||||
variant="card"
|
||||
material="opaque"
|
||||
material="glass"
|
||||
ariaLabel={name.trim() || t('schoolCatalog.region')}
|
||||
discardGuard={body}
|
||||
>
|
||||
<MAdminSheetFrame
|
||||
icon={<GraduationCap size={19} strokeWidth={1.8} />}
|
||||
title={name.trim() || t('schoolCatalog.region')}
|
||||
onClose={() => {
|
||||
if (!busy) onClose();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, CheckCircle, ExternalLink, Fingerprint, ShieldOff, Trash2 } from 'lucide-react'
|
||||
import { AlertTriangle, CheckCircle, ExternalLink, Fingerprint, ShieldOff, Trash2, UserPlus, UserCog, Download, KeyRound, RotateCcw } from 'lucide-react'
|
||||
import { useAdminUserActions } from '../../../components/Admin/useAdminUserActions'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
import type { useAdmin } from '../../../pages/admin/useAdmin'
|
||||
@@ -63,6 +63,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
|
||||
{/* Create user */}
|
||||
<MSheet open={showCreateUser} onClose={() => setShowCreateUser(false)} ariaLabel={t('admin.createUser')}>
|
||||
<MAdminSheetFrame
|
||||
icon={<UserPlus size={19} strokeWidth={1.8} />}
|
||||
title={t('admin.createUser')}
|
||||
onClose={() => setShowCreateUser(false)}
|
||||
footer={
|
||||
@@ -115,6 +116,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
|
||||
{/* Edit user */}
|
||||
<MSheet open={!!editingUser} onClose={() => setEditingUser(null)} ariaLabel={t('admin.editUser')}>
|
||||
<MAdminSheetFrame
|
||||
icon={<UserCog size={19} strokeWidth={1.8} />}
|
||||
title={t('admin.editUser')}
|
||||
onClose={() => setEditingUser(null)}
|
||||
footer={
|
||||
@@ -205,6 +207,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
|
||||
{/* How to update */}
|
||||
<MSheet open={showUpdateModal} onClose={() => setShowUpdateModal(false)} ariaLabel={t('admin.update.howTo')}>
|
||||
<MAdminSheetFrame
|
||||
icon={<Download size={19} strokeWidth={1.8} />}
|
||||
title={t('admin.update.howTo')}
|
||||
onClose={() => setShowUpdateModal(false)}
|
||||
footer={<MAdminButton onClick={() => setShowUpdateModal(false)}>{t('common.close')}</MAdminButton>}
|
||||
@@ -256,6 +259,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
|
||||
{/* Reset passkeys confirm */}
|
||||
<MSheet open={showResetPasskeys} onClose={() => setShowResetPasskeys(false)} ariaLabel={t('admin.passkey.reset')}>
|
||||
<MAdminSheetFrame
|
||||
icon={<KeyRound size={19} strokeWidth={1.8} />}
|
||||
title={t('admin.passkey.reset')}
|
||||
onClose={() => setShowResetPasskeys(false)}
|
||||
footer={
|
||||
@@ -288,6 +292,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
|
||||
{/* Rotate JWT confirm */}
|
||||
<MSheet open={showRotateJwtModal} onClose={() => setShowRotateJwtModal(false)} ariaLabel={t('admin.rotateJwt.title')}>
|
||||
<MAdminSheetFrame
|
||||
icon={<RotateCcw size={19} strokeWidth={1.8} />}
|
||||
title={t('admin.rotateJwt.title')}
|
||||
onClose={() => setShowRotateJwtModal(false)}
|
||||
footer={
|
||||
|
||||
@@ -83,7 +83,7 @@ export function MAdminClampedText({ children, className = '' }: { children: stri
|
||||
export function MAdminField({ label, hint, children }: { label: ReactNode; hint?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-[6px] text-[0.75rem] font-semibold text-m-ink">{label}</div>
|
||||
<div className="mb-[5px] font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">{label}</div>
|
||||
{children}
|
||||
{hint && <div className="mt-[5px] font-geist text-[0.625rem] leading-relaxed text-m-muted">{hint}</div>}
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@ export function MAdminInput({ className = '', ...props }: InputHTMLAttributes<HT
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={`h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)] disabled:cursor-not-allowed disabled:opacity-60 ${className}`}
|
||||
className={`h-[42px] w-full rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)] disabled:cursor-not-allowed disabled:opacity-60 ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -161,11 +161,14 @@ export function MAdminButton({
|
||||
/** Sheet body frame: title row + close, scrollable content, optional footer. */
|
||||
export function MAdminSheetFrame({
|
||||
title,
|
||||
icon,
|
||||
onClose,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode
|
||||
/** The icon tile in front of the title, as every phone sheet has. */
|
||||
icon?: ReactNode
|
||||
onClose: () => void
|
||||
footer?: ReactNode
|
||||
children: ReactNode
|
||||
@@ -173,15 +176,22 @@ export function MAdminSheetFrame({
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-none items-center gap-2 px-4 pb-2 pt-4">
|
||||
<div className="min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">{title}</div>
|
||||
{/* TileHeader's shape: the icon tile, the title and the round close. */}
|
||||
<div className="flex flex-none items-center gap-3 px-[18px] pb-1 pt-4">
|
||||
{icon && (
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold leading-tight text-m-ink">{title}</div>
|
||||
<MIconBtn variant="neutral" size={34} ariaLabel={t('common.close')} onClick={onClose}>
|
||||
<X size={16} strokeWidth={2.2} />
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">{children}</div>
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">{children}</div>
|
||||
{footer && (
|
||||
<div className="flex flex-none items-center justify-end gap-2 border-t border-[color:var(--m-rowbr)] px-4 py-3">
|
||||
// The footer's buttons take the sheets' 42 px size; elsewhere MAdminButton stays a small pill.
|
||||
<div className="flex flex-none items-center justify-end gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3 [&>button]:h-[42px] [&>button]:px-4 [&>button]:text-[length:calc(13px*var(--fs-scale-body,1))] [&>button]:font-semibold">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Copy, Link2, Trash2, UserPlus } from 'lucide-react'
|
||||
import { Copy, Link2, Trash2, UserPlus, Ticket } from 'lucide-react'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
import type { useAdmin } from '../../../pages/admin/useAdmin'
|
||||
import { inviteMeta, inviteStatus } from '../../../pages/admin/adminModel'
|
||||
@@ -167,6 +167,7 @@ export default function MAdminUsersSection({ admin, t, locale }: MAdminUsersSect
|
||||
{/* Create invite sheet */}
|
||||
<MSheet open={showCreateInvite} onClose={() => setShowCreateInvite(false)} ariaLabel={t('admin.invite.create')}>
|
||||
<MAdminSheetFrame
|
||||
icon={<Ticket size={19} strokeWidth={1.8} />}
|
||||
title={t('admin.invite.create')}
|
||||
onClose={() => setShowCreateInvite(false)}
|
||||
footer={
|
||||
|
||||
@@ -6,6 +6,7 @@ import { A2_TO_A3 } from '../../../pages/atlas/atlasModel'
|
||||
import type { BucketItem } from '../../../pages/atlas/atlasModel'
|
||||
import type { AtlasController } from './atlasController'
|
||||
import { getIntlLanguage } from '@trek/shared'
|
||||
import { TileHeader } from '../trip/sheets/MTripSheetUi'
|
||||
|
||||
interface MAtlasBucketSheetProps {
|
||||
atlas: AtlasController
|
||||
@@ -87,18 +88,15 @@ export default function MAtlasBucketSheet({ atlas, open, onClose }: MAtlasBucket
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={close} variant="bottom" material="glass" ariaLabel={t('atlas.bucketTab')}>
|
||||
<div className="flex min-h-0 flex-col p-4">
|
||||
<div className="flex items-center gap-[10px] px-1 pb-3">
|
||||
<Star size={17} strokeWidth={2.2} className="flex-none text-[color:var(--m-st-pending)]" />
|
||||
<div className="min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">
|
||||
{t('atlas.bucketTab')}
|
||||
{bucketList.length > 0 && (
|
||||
<span className="ms-[6px] font-geist text-[0.75rem] font-semibold tabular-nums text-m-faint">{bucketList.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<MIconBtn variant="neutral" size={34} onClick={close} ariaLabel={t('common.close')}>
|
||||
<X size={16} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
<div className="flex min-h-0 flex-col px-[18px] pb-4 pt-4">
|
||||
<div className="mb-3">
|
||||
<TileHeader
|
||||
icon={<Star size={19} strokeWidth={1.8} className="text-[color:var(--m-st-pending)]" />}
|
||||
title={t('atlas.bucketTab')}
|
||||
sub={bucketList.length > 0 ? String(bucketList.length) : undefined}
|
||||
onClose={close}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, Loader2, MapPin, Search, X } from 'lucide-react'
|
||||
import { Check, Loader2, MapPin, Search, X, MapPinPlus } from 'lucide-react'
|
||||
import type { Category, TranslationFn } from '../../../types'
|
||||
import { STATUS_ORDER } from '../../../pages/collections/collectionsModel'
|
||||
import MSheet from '../../components/MSheet'
|
||||
@@ -32,9 +32,10 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
} = useAddPlaceToCollection({ variant: 'sheet', open, collectionId, collectionName, lists, t, onClose, onAdded })
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} material="opaque" ariaLabel={t('collections.addPlace')}>
|
||||
<SheetHeader title={t('collections.addPlace')} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-[14px]">
|
||||
<MSheet open={open} onClose={onClose} material="glass" ariaLabel={t('collections.addPlace')}>
|
||||
<SheetHeader
|
||||
icon={<MapPinPlus size={19} strokeWidth={1.8} />} title={t('collections.addPlace')} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<div className="m-scroll-fade min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{/* List picker — only when opened without a fixed target list */}
|
||||
{collectionId == null && (
|
||||
<div className="mb-[14px]">
|
||||
@@ -65,20 +66,20 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
</div>
|
||||
)}
|
||||
{/* Search — picking a result fills the location */}
|
||||
<div className="flex items-center gap-2 rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] py-1 ps-[13px] pe-[6px]">
|
||||
<div className="flex h-[42px] items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] pe-1 ps-[14px]">
|
||||
<Search size={15} strokeWidth={2.2} className="flex-none text-m-muted" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void search() } }}
|
||||
placeholder={t('collections.addPlaceSearch')}
|
||||
className="min-w-0 flex-1 bg-transparent py-2 font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint"
|
||||
className="min-w-0 flex-1 bg-transparent font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={search}
|
||||
disabled={!query.trim() || searching}
|
||||
className="flex h-8 flex-none items-center gap-[5px] rounded-[10px] bg-m-act px-[14px] text-[0.75rem] font-bold text-m-actfg disabled:opacity-40"
|
||||
className="flex h-[34px] flex-none items-center gap-[5px] rounded-full bg-m-act px-[14px] text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-m-actfg disabled:opacity-40"
|
||||
>
|
||||
{searching ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
{t('common.search')}
|
||||
@@ -129,7 +130,7 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
type="button"
|
||||
onClick={() => setStatus(s)}
|
||||
aria-pressed={on}
|
||||
className={`flex flex-1 items-center justify-center gap-[5px] rounded-full py-2 text-[0.71875rem] font-bold ${
|
||||
className={`flex h-[42px] min-w-0 flex-1 items-center justify-center gap-[5px] rounded-full text-[0.71875rem] font-bold ${
|
||||
on ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -32,10 +32,10 @@ export default function MCollCategoryPicker({ categories, value, onChange, t }:
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex w-full items-center gap-[9px] rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] text-start"
|
||||
className="flex h-[42px] w-full items-center gap-[9px] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] pe-[14px] ps-[8px] text-start"
|
||||
>
|
||||
<span
|
||||
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-lg"
|
||||
className="flex h-[28px] w-[28px] flex-none items-center justify-center rounded-full"
|
||||
style={{ background: tint(selectedMeta.color, '1f'), color: selectedMeta.color }}
|
||||
>
|
||||
<SelectedIcon size={14} strokeWidth={2.2} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Image, Loader2, Search, Trash2 } from 'lucide-react'
|
||||
import { Image, Loader2, Search, Trash2, Bookmark } from 'lucide-react'
|
||||
import type { Collection } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
import { useListEditor } from '../../../components/Collections/useListEditor'
|
||||
@@ -6,7 +6,7 @@ import { normalizeLinkUrl } from '../../../pages/collections/collectionsModel'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MCollLinksEditor from './MCollLinksEditor'
|
||||
import { listCoverGradient, SWATCH_COLORS } from './collectionsMobileModel'
|
||||
import { CancelPill, PrimaryPill, SheetFooter, SheetHeader, TEXTAREA_CLS } from './MCollSheetKit'
|
||||
import { CancelPill, INPUT_CLS, PrimaryPill, SheetFooter, SheetHeader, TEXTAREA_CLS } from './MCollSheetKit'
|
||||
|
||||
// Scrims over the cover photo / Unsplash thumbs — fixed dark overlays in both themes.
|
||||
const COVER_OVERLAY =
|
||||
@@ -34,22 +34,24 @@ export default function MCollEditSheet({ target, onClose, onCreated, onRequestDe
|
||||
coverPreview, coverQuery, setCoverQuery, coverResults, searchingCover, saving, close, pickCover, searchCover, pickUnsplash, save,
|
||||
} = useListEditor({ target, onClose, onCreated, t, defaultColor: SWATCH_COLORS[0], holdLastTarget: true, normalizeLinkUrl })
|
||||
|
||||
const label = 'mb-[5px] font-geist text-[0.6875rem] font-bold text-m-muted'
|
||||
// The sheets' eyebrow (Eyebrow), uppercase over each field.
|
||||
const label = 'mb-[5px] font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint'
|
||||
const canDeleteList = editing != null && editing.is_owner !== false
|
||||
|
||||
return (
|
||||
<MSheet
|
||||
open={target != null}
|
||||
onClose={close}
|
||||
material="opaque"
|
||||
material="glass"
|
||||
ariaLabel={editing ? t('collections.editListTitle') : t('collections.newList')}
|
||||
>
|
||||
<SheetHeader
|
||||
icon={<Bookmark size={19} strokeWidth={1.8} />}
|
||||
title={editing ? t('collections.editListTitle') : t('collections.newList')}
|
||||
onClose={close}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-[14px]">
|
||||
<div className="m-scroll-fade min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
<div className={label}>{t('collections.coverImage')}</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -75,13 +77,13 @@ export default function MCollEditSheet({ target, onClose, onCreated, onRequestDe
|
||||
onChange={e => setCoverQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void searchCover() } }}
|
||||
placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
||||
className="min-w-0 flex-1 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 py-[10px] font-geist text-[0.71875rem] text-m-ink outline-none placeholder:text-m-faint"
|
||||
className={`${INPUT_CLS} min-w-0 flex-1`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={searchCover}
|
||||
disabled={searchingCover || (!coverQuery.trim() && !name.trim())}
|
||||
className="flex flex-none items-center gap-[5px] rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 py-[10px] text-[0.71875rem] font-semibold text-m-ink disabled:opacity-40"
|
||||
className="flex h-[42px] flex-none items-center gap-[5px] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink disabled:opacity-40"
|
||||
>
|
||||
{searchingCover ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} strokeWidth={2} />} Unsplash
|
||||
</button>
|
||||
@@ -113,7 +115,7 @@ export default function MCollEditSheet({ target, onClose, onCreated, onRequestDe
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('collections.listNamePlaceholder')}
|
||||
className="w-full box-border rounded-[12px] border-[1.5px] border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] font-[inherit] text-[0.84375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint"
|
||||
className={`${INPUT_CLS} font-semibold`}
|
||||
/>
|
||||
|
||||
<div className={`${label} mt-[14px] mb-[6px]`}>{t('collections.listColor')}</div>
|
||||
|
||||
@@ -92,8 +92,9 @@ export default function MCollLabelsSheet({
|
||||
const title = mode === 'assign' ? t('collections.labels.assignN', { count: selectedCount }) : t('collections.labels.manage')
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} material="opaque" ariaLabel={title}>
|
||||
<SheetHeader title={title} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<MSheet open={open} onClose={onClose} material="glass" ariaLabel={title}>
|
||||
<SheetHeader
|
||||
icon={<Tag size={19} strokeWidth={1.8} />} title={title} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-4">
|
||||
{mode === 'manage' && (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Loader2, ListPlus } from 'lucide-react'
|
||||
import type { Collection } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
import MSheet from '../../components/MSheet'
|
||||
@@ -40,8 +40,9 @@ export default function MCollListPickerSheet({ mode, lists, count, onPick, onClo
|
||||
}
|
||||
|
||||
return (
|
||||
<MSheet open={mode != null} onClose={onClose} material="opaque" ariaLabel={title}>
|
||||
<SheetHeader title={title} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<MSheet open={mode != null} onClose={onClose} material="glass" ariaLabel={title}>
|
||||
<SheetHeader
|
||||
icon={<ListPlus size={19} strokeWidth={1.8} />} title={title} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{lists.length === 0 ? (
|
||||
<div className="py-8 text-center font-geist text-[0.78125rem] text-m-faint">{t('collections.noOtherLists')}</div>
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function MCollPlaceSheet({
|
||||
'flex flex-1 items-center justify-center gap-[6px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-2 py-[11px] text-[0.78125rem] font-semibold text-m-ink'
|
||||
|
||||
return (
|
||||
<MSheet open={place != null} onClose={onClose} material="opaque" className="!rounded-[24px]" ariaLabel={held?.name}>
|
||||
<MSheet open={place != null} onClose={onClose} material="glass" className="!rounded-[24px]" ariaLabel={held?.name}>
|
||||
{held && (
|
||||
<>
|
||||
{/* Hero: auto cover (photo when available, the design gradient otherwise) */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, Clock, Crown, Loader2, LogOut, UserPlus, X } from 'lucide-react'
|
||||
import { Check, ChevronDown, Clock, Crown, Loader2, LogOut, UserPlus, X, Share2 } from 'lucide-react'
|
||||
import type { CollectionMember } from '@trek/shared'
|
||||
import { COLLECTION_ROLES } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
@@ -54,8 +54,9 @@ export default function MCollShareSheet({
|
||||
const badge = 'inline-flex flex-none items-center gap-[3px] font-geist text-[0.625rem] font-extrabold'
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} material="opaque" ariaLabel={t('collections.share.title')}>
|
||||
<SheetHeader title={t('collections.share.titleNamed', { name: collectionName })} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<MSheet open={open} onClose={onClose} material="glass" ariaLabel={t('collections.share.title')}>
|
||||
<SheetHeader
|
||||
icon={<Share2 size={19} strokeWidth={1.8} />} title={t('collections.share.titleNamed', { name: collectionName })} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
<div className="mb-2 flex items-center gap-[7px]">
|
||||
<Eyebrow>{t('collections.share.members').toUpperCase()}</Eyebrow>
|
||||
@@ -140,7 +141,7 @@ export default function MCollShareSheet({
|
||||
onClick={handleInvite}
|
||||
disabled={selectedUserId == null || inviting}
|
||||
aria-label={t('collections.share.sendInvite')}
|
||||
className="flex w-11 flex-none items-center justify-center rounded-[12px] bg-m-act text-m-actfg disabled:opacity-40"
|
||||
className="flex h-[42px] w-[42px] flex-none items-center justify-center self-center rounded-full bg-m-act text-m-actfg disabled:opacity-40"
|
||||
>
|
||||
{inviting ? <Loader2 size={16} className="animate-spin" /> : <UserPlus size={16} strokeWidth={2.2} />}
|
||||
</button>
|
||||
|
||||
@@ -4,22 +4,33 @@ import { X } from 'lucide-react'
|
||||
/** Shared sheet scaffolding of the collections sheets (opaque floating cards). */
|
||||
|
||||
export const INPUT_CLS =
|
||||
'w-full box-border rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint'
|
||||
'w-full box-border rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-[10px] font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint'
|
||||
|
||||
export const TEXTAREA_CLS =
|
||||
'w-full box-border resize-none rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] font-geist text-[0.78125rem] leading-[1.5] text-m-ink outline-none placeholder:text-m-faint'
|
||||
'w-full box-border resize-none rounded-[18px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-[10px] font-geist text-[0.78125rem] leading-[1.5] text-m-ink outline-none placeholder:text-m-faint'
|
||||
|
||||
interface SheetHeaderProps {
|
||||
title: ReactNode
|
||||
/** The icon tile in front of the title, as every phone sheet has. */
|
||||
icon?: ReactNode
|
||||
sub?: ReactNode
|
||||
onClose: () => void
|
||||
closeLabel: string
|
||||
}
|
||||
|
||||
/** Sheet header: 17px/700 title + 34px round close, hairline below. */
|
||||
export function SheetHeader({ title, onClose, closeLabel }: SheetHeaderProps) {
|
||||
/** Sheet header: the icon tile, the 17px/700 title (with an optional line under it) and the 34px round close, as TileHeader. */
|
||||
export function SheetHeader({ title, icon, sub, onClose, closeLabel }: SheetHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-none items-center gap-[11px] border-b border-[color:var(--m-rowbr)] px-[18px] pb-[10px] pt-4">
|
||||
<div className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold text-m-ink">{title}</div>
|
||||
<div className="flex flex-none items-center gap-3 px-[18px] pb-1 pt-4">
|
||||
{icon && (
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[1.0625rem] font-bold leading-tight text-m-ink">{title}</div>
|
||||
{sub && <div className="truncate font-geist text-[0.71875rem] text-m-muted">{sub}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -55,7 +66,7 @@ export function CancelPill({ onClick, disabled, className = '', children }: Pill
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold text-m-ink disabled:opacity-40 ${className}`}
|
||||
className={`flex h-[42px] items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink disabled:opacity-40 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -69,7 +80,7 @@ export function PrimaryPill({ onClick, disabled, className = '', children }: Pil
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center gap-[6px] rounded-full bg-m-act px-[18px] py-[9px] text-[0.78125rem] font-bold text-m-actfg disabled:opacity-40 ${className}`}
|
||||
className={`flex h-[42px] items-center justify-center gap-[6px] rounded-full bg-m-act px-4 text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-actfg disabled:opacity-40 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
@@ -211,8 +211,13 @@ function MCollTourSheet({
|
||||
const tour = takeOver.tour;
|
||||
const title = tour?.name ?? t('tours.collection.add');
|
||||
return (
|
||||
<MSheet open={takeOver.open} onClose={takeOver.close} material="opaque" ariaLabel={title}>
|
||||
<SheetHeader title={title} onClose={takeOver.close} closeLabel={t('common.close')} />
|
||||
<MSheet open={takeOver.open} onClose={takeOver.close} material="glass" ariaLabel={title}>
|
||||
<SheetHeader
|
||||
icon={<Route size={19} strokeWidth={1.8} />}
|
||||
title={title}
|
||||
onClose={takeOver.close}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{tour && (
|
||||
<p className="mb-3 mt-0 font-geist text-m-muted" style={fs(11)}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CalendarDays, Copy, Loader2, MapPin, Search } from 'lucide-react'
|
||||
import { CalendarDays, Copy, Loader2, MapPin, Search, Luggage } from 'lucide-react'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
import { useTripCopyPicker } from '../../../components/Collections/useTripCopyPicker'
|
||||
import MSheet from '../../components/MSheet'
|
||||
@@ -23,8 +23,9 @@ export default function MCollTripPickerSheet({ open, count, onCopy, onClose, t }
|
||||
const title = t('collections.copyN', { count })
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} material="opaque" ariaLabel={title}>
|
||||
<SheetHeader title={title} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<MSheet open={open} onClose={onClose} material="glass" ariaLabel={title}>
|
||||
<SheetHeader
|
||||
icon={<Luggage size={19} strokeWidth={1.8} />} title={title} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
<div className="mb-2 flex h-9 items-center gap-2 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px]">
|
||||
<Search size={15} strokeWidth={2.2} className="flex-none text-m-muted" />
|
||||
|
||||
@@ -12,7 +12,6 @@ import { STATUS_ORDER } from '../../../pages/collections/collectionsModel'
|
||||
import type { StatusFilter } from '../../../store/collectionStore'
|
||||
import { ALL_SAVED } from '../../../store/collectionStore'
|
||||
import CollectionMap from '../../../components/Collections/CollectionMap'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MCollPlaceRow from './MCollPlaceRow'
|
||||
import MCollPlaceSheet from './MCollPlaceSheet'
|
||||
import MCollAddSheet from './MCollAddSheet'
|
||||
@@ -24,7 +23,7 @@ import MCollListPickerSheet from './MCollListPickerSheet'
|
||||
import MCollTours from './MCollTours'
|
||||
import MCollInvites from './MCollInvites'
|
||||
import { STATUS_SPEC } from './collectionsMobileModel'
|
||||
import { CancelPill, PrimaryPill } from './MCollSheetKit'
|
||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
|
||||
const HEADER_CIRCLE =
|
||||
'flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]'
|
||||
@@ -73,16 +72,25 @@ export default function MCollections() {
|
||||
// The bottom-nav "+" hands off via the ?create= contract.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const createParam = searchParams.get('create')
|
||||
const { activeId, canEdit, setShowAddPlace, ownedLists } = c
|
||||
const { activeId, canEdit, setShowAddPlace, ownedLists, setEditorTarget } = c
|
||||
const listCount = c.collections.length
|
||||
const listsLoading = c.loading
|
||||
useEffect(() => {
|
||||
if (createParam !== 'place' && createParam !== '1') return
|
||||
// Wait for the lists: whether there is one decides what "+" means.
|
||||
if (listsLoading) return
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete('create')
|
||||
setSearchParams(next, { replace: true })
|
||||
// Without a list yet, "+" starts the first one; places come after.
|
||||
if (listCount === 0) {
|
||||
setEditorTarget('new')
|
||||
return
|
||||
}
|
||||
// Open whenever the user can add somewhere: an editable active list, or at
|
||||
// least one owned list to pick as the target in the sheet.
|
||||
if ((typeof activeId === 'number' && canEdit) || ownedLists.length > 0) setShowAddPlace(true)
|
||||
}, [createParam, searchParams, setSearchParams, activeId, canEdit, ownedLists.length, setShowAddPlace])
|
||||
}, [createParam, searchParams, setSearchParams, activeId, canEdit, ownedLists.length, setShowAddPlace, listsLoading, listCount, setEditorTarget])
|
||||
|
||||
// Close transient popovers when the active list changes under them.
|
||||
useEffect(() => {
|
||||
@@ -219,12 +227,8 @@ export default function MCollections() {
|
||||
</div>
|
||||
) : noLists ? (
|
||||
<div className="mt-6">
|
||||
{/* The dock's "+" starts the first list (see the ?create= handoff above). */}
|
||||
<EmptyNote scene="collections" title={t('collections.empty.firstTitle')} />
|
||||
<div className="mt-4 flex justify-center">
|
||||
<PrimaryPill onClick={openNewList}>
|
||||
<Bookmark size={14} strokeWidth={2.2} /> {t('collections.newList')}
|
||||
</PrimaryPill>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -541,23 +545,16 @@ export default function MCollections() {
|
||||
/>
|
||||
|
||||
{/* Delete-list confirm */}
|
||||
<MSheet
|
||||
<MConfirmSheet
|
||||
open={c.confirmDeleteList != null}
|
||||
onClose={() => c.setConfirmDeleteList(null)}
|
||||
material="opaque"
|
||||
ariaLabel={t('collections.deleteList')}
|
||||
>
|
||||
<div className="px-[18px] pb-[18px] pt-4">
|
||||
<div className="text-[1.0625rem] font-bold text-m-ink">{t('collections.deleteList')}</div>
|
||||
<div className="mt-2 font-geist text-[0.78125rem] leading-[1.5] text-m-muted">{t('collections.deleteListConfirm')}</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<CancelPill className="ms-auto" onClick={() => c.setConfirmDeleteList(null)}>{t('common.cancel')}</CancelPill>
|
||||
<PrimaryPill onClick={c.handleDeleteList} className="!bg-[color:var(--m-st-danger)] !text-white">
|
||||
<Trash2 size={14} strokeWidth={2.2} /> {t('common.delete')}
|
||||
</PrimaryPill>
|
||||
</div>
|
||||
</div>
|
||||
</MSheet>
|
||||
title={t('collections.deleteList')}
|
||||
message={t('collections.deleteListConfirm')}
|
||||
confirmLabel={t('common.delete')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
onConfirm={c.handleDeleteList}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ import { entityGradient } from '../../../utils/gradients'
|
||||
import MGlassBar from '../../components/MGlassBar'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MSegmented from '../../components/MSegmented'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MUserMenu from './MUserMenu'
|
||||
import { useMobileDashOrder, useMobileDashVisibility, MobileDashWidget } from './MDashWidgets'
|
||||
import MNewTripSheet from './MNewTripSheet'
|
||||
import type { MobileDashToken } from '@trek/shared'
|
||||
import { usePullToRefresh } from '../../usePullToRefresh'
|
||||
import MPullIndicator from '../../components/MPullIndicator'
|
||||
import SharedConfirmSheet from '../settings/MConfirmSheet'
|
||||
|
||||
type TripFilter = 'planned' | 'archive' | 'completed'
|
||||
|
||||
@@ -581,36 +581,11 @@ function ListStat({ value, label }: { value: number; label: string }): React.Rea
|
||||
)
|
||||
}
|
||||
|
||||
// ── Confirm sheet (delete / copy) ────────────────────────────────────────────
|
||||
function MConfirmSheet({ open, title, message, confirmLabel, danger = false, onConfirm, onClose }: {
|
||||
// ── Confirm sheet (delete / copy): the shared one, with the common cancel ─────
|
||||
function MConfirmSheet(props: {
|
||||
open: boolean; title: string; message: string; confirmLabel: string
|
||||
danger?: boolean; onConfirm: () => void; onClose: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="card" ariaLabel={title}>
|
||||
<div className="p-5">
|
||||
<div className="text-[1.0625rem] font-bold">{title}</div>
|
||||
<div className="mt-2 text-[0.8125rem] leading-relaxed text-m-muted">{message}</div>
|
||||
<div className="mt-5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-full bg-[color:var(--m-ic)] py-[10px] text-[0.8125rem] font-semibold text-m-ink"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`flex-1 rounded-full py-[10px] text-[0.8125rem] font-semibold ${
|
||||
danger ? 'bg-[color:var(--m-st-danger)] text-white' : 'bg-m-act text-m-actfg'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
return <SharedConfirmSheet {...props} cancelLabel={t('common.cancel')} />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Archive, ArchiveRestore, Bell, Camera, Search, X } from 'lucide-react'
|
||||
import { Archive, ArchiveRestore, Bell, Camera, Luggage, Search, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useCanDo } from '../../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../../store/settingsStore'
|
||||
@@ -11,8 +11,8 @@ import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.
|
||||
import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel'
|
||||
import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MListRow from '../../components/MListRow'
|
||||
import { FIELD_AREA_CLS } from '../trip/sheets/PlSheetChrome'
|
||||
import { Eyebrow, SHEET_FIELD, SHEET_PRIMARY, SHEET_ROUND_PRIMARY, SHEET_SECONDARY, SheetSection, TileHeader } from '../trip/sheets/MTripSheetUi'
|
||||
import MDayImpactList from '../../components/MDayImpactList'
|
||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
import { useTripRangeGuard, type RangeRemoval } from '../../../hooks/useTripRangeGuard'
|
||||
@@ -31,17 +31,12 @@ interface MNewTripSheetProps {
|
||||
onArchive?: () => void
|
||||
}
|
||||
|
||||
const BOX_CLS = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px_12px]'
|
||||
const INPUT_CLS = 'w-full border-none bg-transparent pt-[2px] font-[inherit] text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint'
|
||||
/** A date picker in the sheets' round field. */
|
||||
const DATE_FIELD = 'flex h-[42px] items-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px]'
|
||||
|
||||
/** The reminder presets the desktop form offers; anything else is a custom number of days. */
|
||||
const REMINDER_PRESETS = [0, 1, 3, 9]
|
||||
|
||||
function FieldLabel({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<div className="font-geist text-[0.5625rem] font-bold uppercase tracking-[.1em] text-m-faint">{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/edit trip sheet — the mobile counterpart of TripFormModal's core flow:
|
||||
@@ -151,79 +146,78 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
|
||||
}
|
||||
}
|
||||
|
||||
const boxCls = BOX_CLS
|
||||
const inputCls = INPUT_CLS
|
||||
|
||||
const removalList = pendingRemoval && pendingRemoval.removal !== 'unknown' ? pendingRemoval.removal : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="opaque" ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
|
||||
<div className="flex items-center gap-[11px] p-[16px_16px_0]">
|
||||
<div className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold">
|
||||
{isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
|
||||
</div>
|
||||
<MIconBtn ariaLabel={t('common.cancel')} variant="neutral" size={34} onClick={onClose}>
|
||||
<X size={16} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<Luggage size={19} strokeWidth={1.8} />}
|
||||
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
|
||||
sub={isEditing ? trip?.title : undefined}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="m-scroll-fade mt-2 flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{error && (
|
||||
<div className="mb-3 rounded-[14px] bg-[color:var(--m-ic)] p-[11px_12px] text-[0.75rem] font-semibold text-[color:var(--m-st-danger)]">
|
||||
<div className="flex-none rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-caption font-semibold text-[color:var(--m-st-danger)]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={boxCls}>
|
||||
<FieldLabel>{t('dashboard.tripTitle')}</FieldLabel>
|
||||
<SheetSection title={t('dashboard.tripTitle')}>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => { if (canEditTrip) { setError(''); setTitle(e.target.value) } }}
|
||||
readOnly={!canEditTrip}
|
||||
placeholder={t('dashboard.tripTitlePlaceholder')}
|
||||
className={inputCls}
|
||||
aria-label={t('dashboard.tripTitle')}
|
||||
className={`${SHEET_FIELD} w-full flex-none font-semibold`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${boxCls} mt-2`}>
|
||||
<FieldLabel>{t('dashboard.tripDescription')}</FieldLabel>
|
||||
<Eyebrow className="mt-1 uppercase">{t('dashboard.tripDescription')}</Eyebrow>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => { if (canEditTrip) { setError(''); setDescription(e.target.value) } }}
|
||||
readOnly={!canEditTrip}
|
||||
placeholder={t('dashboard.tripDescriptionPlaceholder')}
|
||||
aria-label={t('dashboard.tripDescription')}
|
||||
rows={2}
|
||||
className={`${inputCls} resize-none text-[0.8125rem] font-medium`}
|
||||
className={FIELD_AREA_CLS}
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
<div className={`${boxCls} min-w-0 flex-1`}>
|
||||
<FieldLabel>{t('dashboard.startDate')}</FieldLabel>
|
||||
<CustomDatePicker
|
||||
value={startDate}
|
||||
onChange={v => { if (canEditTrip) changeStart(v) }}
|
||||
placeholder={t('dashboard.startDate')}
|
||||
borderless
|
||||
style={{ marginTop: 3 }}
|
||||
/>
|
||||
<SheetSection>
|
||||
<div className="flex gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('dashboard.startDate')}</Eyebrow>
|
||||
<div className={DATE_FIELD}>
|
||||
<CustomDatePicker
|
||||
value={startDate}
|
||||
onChange={v => { if (canEditTrip) changeStart(v) }}
|
||||
placeholder={t('dashboard.startDate')}
|
||||
borderless
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('dashboard.endDate')}</Eyebrow>
|
||||
<div className={DATE_FIELD}>
|
||||
<CustomDatePicker
|
||||
value={endDate}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
|
||||
placeholder={t('dashboard.endDate')}
|
||||
borderless
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${boxCls} min-w-0 flex-1`}>
|
||||
<FieldLabel>{t('dashboard.endDate')}</FieldLabel>
|
||||
<CustomDatePicker
|
||||
value={endDate}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
|
||||
placeholder={t('dashboard.endDate')}
|
||||
borderless
|
||||
style={{ marginTop: 3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!startDate && !endDate && (
|
||||
<>
|
||||
<div className={`${boxCls} mt-2`}>
|
||||
<FieldLabel>{t('dashboard.dayCount')}</FieldLabel>
|
||||
{!startDate && !endDate && (
|
||||
<>
|
||||
<Eyebrow className="mt-1 uppercase">{t('dashboard.dayCount')}</Eyebrow>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
@@ -237,15 +231,15 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
|
||||
setDayCount(raw === '' ? '' : Math.min(MAX_TRIP_DAYS, Math.max(1, Math.floor(Number(raw)) || 1)))
|
||||
}}
|
||||
readOnly={!canEditTrip}
|
||||
className={inputCls}
|
||||
aria-label={t('dashboard.dayCount')}
|
||||
className={`${SHEET_FIELD} w-full flex-none`}
|
||||
/>
|
||||
</div>
|
||||
{!isEditing && <div className="mt-[6px] px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</div>}
|
||||
</>
|
||||
)}
|
||||
{!isEditing && <p className="px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</p>}
|
||||
</>
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
<div className="mt-2">
|
||||
<FieldLabel>{t('dashboard.currency')}</FieldLabel>
|
||||
<SheetSection title={t('dashboard.currency')}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setCurrency(String(v)) } }}
|
||||
@@ -253,48 +247,46 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
|
||||
searchable
|
||||
size="sm"
|
||||
options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
style={{ width: '100%', marginTop: 5 }}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
{showReminder && (
|
||||
<ReminderField
|
||||
enabled={remindersEnabled}
|
||||
days={reminderDays}
|
||||
custom={customReminder}
|
||||
onPreset={days => { setError(''); setReminderDays(days); setCustomReminder(false) }}
|
||||
onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}
|
||||
onDays={setReminderDays}
|
||||
/>
|
||||
<SheetSection>
|
||||
<ReminderField
|
||||
enabled={remindersEnabled}
|
||||
days={reminderDays}
|
||||
custom={customReminder}
|
||||
onPreset={days => { setError(''); setReminderDays(days); setCustomReminder(false) }}
|
||||
onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}
|
||||
onDays={setReminderDays}
|
||||
/>
|
||||
</SheetSection>
|
||||
)}
|
||||
|
||||
{canUploadCover && <CoverField cover={cover} title={title} />}
|
||||
{canUploadCover && (
|
||||
<SheetSection>
|
||||
<CoverField cover={cover} title={title} />
|
||||
</SheetSection>
|
||||
)}
|
||||
|
||||
{isEditing && onArchive && (
|
||||
<div className="mt-2 rounded-[14px] bg-[color:var(--m-ic)]">
|
||||
<MListRow
|
||||
icon={trip?.is_archived ? ArchiveRestore : Archive}
|
||||
label={trip?.is_archived ? t('dashboard.restore') : t('dashboard.archive')}
|
||||
onClick={() => { onArchive(); onClose() }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onArchive(); onClose() }}
|
||||
className={`${SHEET_SECONDARY} flex-none`}
|
||||
>
|
||||
{trip?.is_archived ? <ArchiveRestore size={14} strokeWidth={2.2} /> : <Archive size={14} strokeWidth={2.2} />}
|
||||
{trip?.is_archived ? t('dashboard.restore') : t('dashboard.archive')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 p-[0_16px_16px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-full bg-[color:var(--m-ic)] py-[10px] text-[0.8125rem] font-semibold text-m-ink"
|
||||
>
|
||||
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<button type="button" onClick={onClose} className={`${SHEET_SECONDARY} ms-auto`}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={busy}
|
||||
className="flex-1 rounded-full bg-m-act py-[10px] text-[0.8125rem] font-semibold text-m-actfg disabled:opacity-50"
|
||||
>
|
||||
<button type="button" onClick={handleSave} disabled={busy} className={SHEET_PRIMARY}>
|
||||
{busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -340,10 +332,10 @@ function ReminderField({ enabled, days, custom, onPreset, onCustom, onDays }: {
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={`${BOX_CLS} mt-2`}>
|
||||
<FieldLabel>
|
||||
<div>
|
||||
<Eyebrow className="uppercase">
|
||||
<span className="inline-flex items-center gap-1"><Bell size={10} strokeWidth={2.4} />{t('trips.reminder')}</span>
|
||||
</FieldLabel>
|
||||
</Eyebrow>
|
||||
{!enabled ? (
|
||||
<p className="mt-1 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] leading-snug text-m-muted">{t('trips.reminderDisabledHint')}</p>
|
||||
) : (
|
||||
@@ -429,21 +421,20 @@ function CoverField({ cover, title }: { cover: ReturnType<typeof useTripCoverPic
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
<div className={`${BOX_CLS} min-w-0 flex-1 py-[9px]`}>
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void cover.search() } }}
|
||||
placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
||||
className={`${INPUT_CLS} pt-0 text-[0.8125rem] font-medium`}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void cover.search() } }}
|
||||
placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
||||
aria-label={t('dashboard.unsplashSearchPlaceholder')}
|
||||
className={SHEET_FIELD}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cover.search}
|
||||
disabled={searching || (!searchQuery.trim() && !title.trim())}
|
||||
aria-label={t('dashboard.searchUnsplash')}
|
||||
className="flex h-auto w-[42px] flex-none items-center justify-center rounded-[14px] bg-m-act text-m-actfg disabled:opacity-50"
|
||||
className={SHEET_ROUND_PRIMARY}
|
||||
>
|
||||
<Search size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Feather, Calendar, MapPin, Check, X } from 'lucide-react'
|
||||
import { Feather, Calendar, MapPin, Check, NotebookPen } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import { SHEET_PRIMARY, SHEET_SECONDARY, TileHeader } from '../trip/sheets/MTripSheetUi'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
|
||||
@@ -38,24 +38,27 @@ export default function MJourneyCreateSheet({
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="opaque" ariaLabel={t('journey.frontpage.createJourney')}>
|
||||
<div className="flex flex-none items-center border-b border-[color:var(--m-rowbr)] px-[18px] pb-[10px] pt-4">
|
||||
<div className="flex-1 text-[1.0625rem] font-bold">{t('journey.frontpage.createJourney')}</div>
|
||||
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.cancel')}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('journey.frontpage.createJourney')}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<NotebookPen size={19} strokeWidth={1.8} />}
|
||||
title={t('journey.frontpage.createJourney')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-3">
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
<div className="mb-[6px] font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">
|
||||
{t('journey.frontpage.journeyName')}
|
||||
</div>
|
||||
<div className="flex items-center gap-[10px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-3">
|
||||
<div className="flex h-[42px] items-center gap-[10px] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px]">
|
||||
<Feather size={16} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => onTitleChange(e.target.value)}
|
||||
placeholder={t('journey.frontpage.namePlaceholder')}
|
||||
aria-label={t('journey.frontpage.journeyName')}
|
||||
className="min-w-0 flex-1 bg-transparent text-[0.875rem] font-semibold text-m-ink outline-none placeholder:text-m-faint"
|
||||
/>
|
||||
</div>
|
||||
@@ -120,19 +123,10 @@ export default function MJourneyCreateSheet({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ms-auto rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold"
|
||||
>
|
||||
<button type="button" onClick={onClose} className={`${SHEET_SECONDARY} ms-auto`}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
disabled={!title.trim()}
|
||||
className="flex-1 rounded-full bg-m-act px-[18px] py-[9px] text-center text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-40"
|
||||
>
|
||||
<button type="button" onClick={onCreate} disabled={!title.trim()} className={SHEET_PRIMARY}>
|
||||
{t('journey.create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -528,7 +528,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
{/* Opaque, not the glass default: these two sit over a full-screen map whose
|
||||
card carousel is bright and busy, and the frosted material let the titles
|
||||
behind read straight through the menu. */}
|
||||
<MSheet open={showUploadMenu} onClose={() => setShowUploadMenu(false)} variant="bottom" material="opaque" ariaLabel={t('common.upload')}>
|
||||
<MSheet open={showUploadMenu} onClose={() => setShowUploadMenu(false)} variant="bottom" material="glass" ariaLabel={t('common.upload')}>
|
||||
<div className="flex flex-col gap-2 p-[10px]">
|
||||
<MListRow
|
||||
icon={Camera}
|
||||
@@ -547,7 +547,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
</MSheet>
|
||||
|
||||
{/* Journey actions: book export, suggestions switch, settings */}
|
||||
<MSheet open={showActionMenu} onClose={() => setShowActionMenu(false)} variant="bottom" material="opaque" ariaLabel={t('files.menu')}>
|
||||
<MSheet open={showActionMenu} onClose={() => setShowActionMenu(false)} variant="bottom" material="glass" ariaLabel={t('files.menu')}>
|
||||
<div className="flex flex-col gap-1 p-[10px]">
|
||||
<div className="flex items-center gap-[11px] px-[10px] py-[11px]">
|
||||
<EyeOff size={16} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useRef, useState, type CSSProperties } from 'react'
|
||||
import { Briefcase, Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff, Play } from 'lucide-react'
|
||||
import { Briefcase, Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff, Play, NotebookPen } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import { SHEET_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import CustomTimePicker from '../../../components/shared/CustomTimePicker'
|
||||
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
|
||||
@@ -124,11 +125,15 @@ export default function MJourneyEntrySheet({
|
||||
open
|
||||
onClose={handleClose}
|
||||
variant="card"
|
||||
material="opaque"
|
||||
material="glass"
|
||||
ariaLabel={entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
|
||||
>
|
||||
<div className="flex flex-none items-center border-b border-[color:var(--m-rowbr)] px-[18px] pb-[10px] pt-4">
|
||||
<span className="flex-1 text-[1.0625rem] font-bold">
|
||||
{/* TileHeader's shape, with the move buttons between the title and the close. */}
|
||||
<div className="flex flex-none items-center gap-3 px-[18px] pb-1 pt-4">
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<NotebookPen size={19} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold leading-tight">
|
||||
{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
|
||||
</span>
|
||||
{(onMoveEarlier || onMoveLater) && (
|
||||
@@ -146,7 +151,7 @@ export default function MJourneyEntrySheet({
|
||||
</MIconBtn>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-3">
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{/* Quick capture had no title field at all: it asked for a note and nothing
|
||||
else, so every entry caught on the move arrived nameless and the day's
|
||||
list read as a column of identical placeholders (discussion #2299). The
|
||||
@@ -713,17 +718,17 @@ export default function MJourneyEntrySheet({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="flex items-center gap-[5px] text-[0.75rem] font-bold text-[color:var(--m-st-danger)]"
|
||||
aria-label={t('common.delete')}
|
||||
className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-[color:var(--m-st-danger)]"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
{t('common.delete')}
|
||||
<Trash2 size={15} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && captureOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCaptureOnly(false)}
|
||||
className="whitespace-nowrap rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold"
|
||||
className={`${SHEET_SECONDARY} whitespace-nowrap`}
|
||||
>
|
||||
{t('journey.editor.addDetails')}
|
||||
</button>
|
||||
@@ -731,7 +736,7 @@ export default function MJourneyEntrySheet({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="ms-auto rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold"
|
||||
className={`${SHEET_SECONDARY} ms-auto`}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
@@ -740,7 +745,7 @@ export default function MJourneyEntrySheet({
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-full bg-m-act px-[18px] py-[9px] text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-50"
|
||||
className={SHEET_PRIMARY}
|
||||
>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2, ListChecks, RefreshCw, Share as ShareIcon } from 'lucide-react'
|
||||
import { canShareLink, shareLink } from '../../../native/shareLink'
|
||||
import { useRef, useState, type ReactNode } from 'react'
|
||||
import { Image, Plus, Trash2, UserPlus, UserMinus, Unlink, Settings2, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2, ListChecks, RefreshCw } from 'lucide-react'
|
||||
import MLinkField from '../../components/MLinkField'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import {
|
||||
Eyebrow,
|
||||
SHEET_FIELD,
|
||||
SHEET_PILL,
|
||||
SHEET_PRIMARY,
|
||||
SHEET_ROUND,
|
||||
SHEET_SECONDARY,
|
||||
SheetSection,
|
||||
TileHeader,
|
||||
} from '../trip/sheets/MTripSheetUi'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
@@ -58,277 +67,239 @@ export default function MJourneySettingsSheet({
|
||||
if (availableTrips.length === 0) await loadAvailableTrips()
|
||||
}
|
||||
|
||||
const eyebrow = 'font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint'
|
||||
const inputShell = 'w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[13px] py-[11px] text-m-ink outline-none placeholder:text-m-faint'
|
||||
const dashedBtn = 'flex w-full items-center justify-center gap-[6px] rounded-xl border border-dashed border-[color:var(--m-rowbr)] p-[11px] text-[0.75rem] font-semibold text-m-muted'
|
||||
const solidBtn = 'flex w-full items-center justify-center gap-[6px] rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px] text-[0.75rem] font-semibold'
|
||||
|
||||
const linkedTripIds = journey.trips.map(tr => tr.trip_id)
|
||||
const unlinkedTrips = availableTrips.filter(tr => !linkedTripIds.includes(tr.id))
|
||||
|
||||
return (
|
||||
<MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={t('journey.settings.title')}>
|
||||
<div className="flex flex-none items-center border-b border-[color:var(--m-rowbr)] px-[18px] pb-[10px] pt-4">
|
||||
<span className="flex-1 text-[1.0625rem] font-bold">{t('journey.settings.title')}</span>
|
||||
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.cancel')}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
<MSheet open onClose={onClose} variant="card" material="glass" ariaLabel={t('journey.settings.title')}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<Settings2 size={19} strokeWidth={1.8} />}
|
||||
title={t('journey.settings.title')}
|
||||
sub={journey.title}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-3">
|
||||
{/* Cover */}
|
||||
<div className={`${eyebrow} mb-[6px]`}>{t('journey.settings.coverImage')}</div>
|
||||
<input ref={coverRef} type="file" accept="image/*" className="hidden" onChange={handleCoverUpload} />
|
||||
<div className="relative h-[120px] overflow-hidden rounded-2xl">
|
||||
{coverSrc ? (
|
||||
<img src={coverSrc} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="h-full w-full" style={{ background: pickGradient(journey.id) }} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => coverRef.current?.click()}
|
||||
className="absolute inset-0 flex items-center justify-center gap-[6px] bg-black/30 text-[0.78125rem] font-bold text-white"
|
||||
>
|
||||
<Image size={14} strokeWidth={2.2} />
|
||||
{coverSrc ? t('journey.settings.changeCover') : t('journey.settings.addCover')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="m-scroll-fade mt-2 flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
<SheetSection title={t('journey.settings.coverImage')}>
|
||||
<input ref={coverRef} type="file" accept="image/*" className="hidden" onChange={handleCoverUpload} />
|
||||
<div className="relative h-[120px] overflow-hidden rounded-[14px]">
|
||||
{coverSrc ? (
|
||||
<img src={coverSrc} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="h-full w-full" style={{ background: pickGradient(journey.id) }} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => coverRef.current?.click()}
|
||||
className="absolute inset-0 flex items-center justify-center gap-[6px] bg-black/30 text-caption font-bold text-white"
|
||||
>
|
||||
<Image size={14} strokeWidth={2.2} />
|
||||
{coverSrc ? t('journey.settings.changeCover') : t('journey.settings.addCover')}
|
||||
</button>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
{/* Name + subtitle */}
|
||||
<div className={`${eyebrow} mb-[5px] mt-[14px]`}>{t('journey.settings.name')}</div>
|
||||
<input value={title} onChange={e => setTitle(e.target.value)} className={`${inputShell} text-[0.84375rem] font-semibold`} />
|
||||
<div className={`${eyebrow} mb-[5px] mt-3`}>{t('journey.settings.subtitle')}</div>
|
||||
<input
|
||||
value={subtitle}
|
||||
onChange={e => setSubtitle(e.target.value)}
|
||||
placeholder={t('journey.settings.subtitlePlaceholder')}
|
||||
className={`${inputShell} text-[0.8125rem] font-medium`}
|
||||
/>
|
||||
<SheetSection title={t('journey.settings.name')}>
|
||||
<input value={title} onChange={e => setTitle(e.target.value)} aria-label={t('journey.settings.name')} className={`${SHEET_FIELD} w-full flex-none font-semibold`} />
|
||||
<Eyebrow className="mt-1 uppercase">{t('journey.settings.subtitle')}</Eyebrow>
|
||||
<input
|
||||
value={subtitle}
|
||||
onChange={e => setSubtitle(e.target.value)}
|
||||
placeholder={t('journey.settings.subtitlePlaceholder')}
|
||||
aria-label={t('journey.settings.subtitle')}
|
||||
className={`${SHEET_FIELD} w-full flex-none`}
|
||||
/>
|
||||
</SheetSection>
|
||||
|
||||
{/* Trip GPX tracks on the journey map (#2194) */}
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.settings.tracks')}</div>
|
||||
<div className="flex items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
|
||||
<Route size={16} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[0.8125rem] font-bold">{t('journey.settings.showTripTracks')}</div>
|
||||
<div className="font-geist text-[0.625rem] text-m-muted">{t('journey.settings.showTripTracksHint')}</div>
|
||||
</div>
|
||||
<MToggle
|
||||
<SheetSection title={t('journey.settings.tracks')}>
|
||||
<ToggleRow
|
||||
icon={<Route size={16} strokeWidth={2} />}
|
||||
label={t('journey.settings.showTripTracks')}
|
||||
hint={t('journey.settings.showTripTracksHint')}
|
||||
checked={!!journey.show_trip_tracks}
|
||||
onChange={handleTracksToggle}
|
||||
disabled={savingTracks}
|
||||
ariaLabel={t('journey.settings.showTripTracks')}
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
{recordingPrep && (
|
||||
<>
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.settings.recordings')}</div>
|
||||
<SheetSection title={t('journey.settings.recordings')}>
|
||||
<MRecordingPrepRow prep={recordingPrep} enabled={!!journey.auto_prepare_recordings} />
|
||||
</>
|
||||
</SheetSection>
|
||||
)}
|
||||
|
||||
{/* The three fields a journey may put away (discussion #2299) */}
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.settings.entryFields')}</div>
|
||||
<div className="mb-[6px] font-geist text-[0.625rem] leading-[1.45] text-m-muted">{t('journey.settings.entryFieldsHint')}</div>
|
||||
{([
|
||||
['show_verdict', t('journey.settings.showVerdict')],
|
||||
['show_mood', t('journey.settings.showMood')],
|
||||
['show_weather', t('journey.settings.showWeather')],
|
||||
] as const).map(([field, label]) => (
|
||||
<div key={field} className="mb-[6px] flex items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
|
||||
<div className="min-w-0 flex-1 text-[0.8125rem] font-bold">{label}</div>
|
||||
<MToggle
|
||||
<SheetSection title={t('journey.settings.entryFields')} hint={t('journey.settings.entryFieldsHint')}>
|
||||
{([
|
||||
['show_verdict', t('journey.settings.showVerdict')],
|
||||
['show_mood', t('journey.settings.showMood')],
|
||||
['show_weather', t('journey.settings.showWeather')],
|
||||
] as const).map(([field, label]) => (
|
||||
<ToggleRow
|
||||
key={field}
|
||||
label={label}
|
||||
checked={journey[field] !== 0}
|
||||
onChange={next => handleFieldToggle(field, next)}
|
||||
disabled={savingField !== null}
|
||||
ariaLabel={label}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Only when there is something to bring back */}
|
||||
{onRestoreSuggestions && (journey.dismissed_count ?? 0) > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void onRestoreSuggestions() }}
|
||||
className="mb-[6px] flex w-full items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-start"
|
||||
>
|
||||
<Undo2 size={16} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[0.8125rem] font-bold">{t('journey.suggestions.restore')}</div>
|
||||
<div className="font-geist text-[0.625rem] text-m-muted">
|
||||
{t('journey.suggestions.restoreCount', { count: journey.dismissed_count ?? 0 })}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Synced trips */}
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.detail.syncedTrips')}</div>
|
||||
{journey.trips.map(trip => (
|
||||
<div key={trip.trip_id} className="mb-[6px] flex items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
|
||||
<span className="h-9 w-9 flex-none rounded-[10px]" style={{ background: pickGradient(trip.trip_id) }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.8125rem] font-bold">{trip.title}</div>
|
||||
<div className="font-geist text-[0.625rem] text-m-muted">
|
||||
{t('mobileJourney.placesCount', { count: trip.place_count ?? 0 })}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}
|
||||
aria-label={t('journey.trips.unlinkTrip')}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-[rgba(214,39,59,.1)] text-[color:var(--m-st-danger)]"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
))}
|
||||
{/* Only when there is something to bring back */}
|
||||
{onRestoreSuggestions && (journey.dismissed_count ?? 0) > 0 && (
|
||||
<button type="button" onClick={() => { void onRestoreSuggestions() }} className={SHEET_SECONDARY}>
|
||||
<Undo2 size={14} strokeWidth={2.2} />
|
||||
{t('journey.suggestions.restore')}
|
||||
<span className="text-m-muted">{journey.dismissed_count ?? 0}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{journey.trips.length === 0 && (
|
||||
<p className="mb-[6px] font-geist text-[0.6875rem] text-m-faint">{t('journey.trips.noTripsLinkedSettings')}</p>
|
||||
)}
|
||||
<button type="button" onClick={openAddTrip} className={`${dashedBtn} mt-2`}>
|
||||
<Plus size={13} strokeWidth={2.2} />
|
||||
{t('journey.trips.addTrip')}
|
||||
</button>
|
||||
{addTripOpen && (
|
||||
<div className="mt-2 max-h-[180px] overflow-y-auto rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-2">
|
||||
{unlinkedTrips.length === 0 && (
|
||||
<p className="py-2 text-center font-geist text-[0.6875rem] text-m-faint">{t('journey.trips.noTripsAvailable')}</p>
|
||||
)}
|
||||
{unlinkedTrips.map(trip => (
|
||||
<div key={trip.id} className="flex items-center gap-[10px] rounded-[10px] px-2 py-[7px]">
|
||||
<span className="h-7 w-7 flex-none rounded-lg" style={{ background: pickGradient(trip.id) }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold">{trip.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => linkTrip(trip.id)}
|
||||
disabled={linkingTripId === trip.id}
|
||||
className="rounded-full bg-m-act px-3 py-[5px] font-geist text-[0.65625rem] font-bold text-m-actfg disabled:opacity-50"
|
||||
>
|
||||
{t('journey.trips.link')}
|
||||
</button>
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title={t('journey.detail.syncedTrips')} count={journey.trips.length || undefined}>
|
||||
{journey.trips.map(trip => (
|
||||
<div key={trip.trip_id} className="flex items-center gap-2.5">
|
||||
<span className="h-[42px] w-[42px] flex-none rounded-[13px]" style={{ background: pickGradient(trip.trip_id) }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-body font-semibold text-m-ink">{trip.title}</div>
|
||||
<div className="font-geist text-caption text-m-muted">
|
||||
{t('mobileJourney.placesCount', { count: trip.place_count ?? 0 })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contributors */}
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.detail.contributors')}</div>
|
||||
{journey.contributors.map(c => (
|
||||
<div key={c.user_id} className="flex items-center gap-[10px] px-[2px] py-1">
|
||||
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-m-act text-[0.75rem] font-extrabold text-m-actfg">
|
||||
{(c.username || '?')[0].toUpperCase()}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-bold">{c.username}</span>
|
||||
<span
|
||||
className={`rounded-full px-[10px] py-[3px] font-geist text-[0.5625rem] font-extrabold ${
|
||||
c.role === 'owner' ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'
|
||||
}`}
|
||||
>
|
||||
{c.role}
|
||||
</span>
|
||||
{c.role !== 'owner' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveContributor(c)}
|
||||
aria-label={t('journey.contributors.remove')}
|
||||
className="flex h-7 w-7 flex-none items-center justify-center rounded-full text-m-faint"
|
||||
onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}
|
||||
aria-label={t('journey.trips.unlinkTrip')}
|
||||
className={SHEET_ROUND}
|
||||
>
|
||||
<X size={13} />
|
||||
<Unlink size={15} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={onOpenInvite} className={`${solidBtn} mt-2`}>
|
||||
<UserPlus size={13} strokeWidth={2.2} />
|
||||
{t('journey.contributors.invite')}
|
||||
</button>
|
||||
|
||||
<MTripAccessList access={journey.trip_access} eyebrow={eyebrow} />
|
||||
|
||||
{/* Public share */}
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.share.publicShare')}</div>
|
||||
{!shareLink ? (
|
||||
<button type="button" onClick={createShareLink} className={solidBtn}>
|
||||
<LinkIcon size={13} strokeWidth={2.2} />
|
||||
{t('journey.share.createLink')}
|
||||
</div>
|
||||
))}
|
||||
{journey.trips.length === 0 && (
|
||||
<p className="font-geist text-caption text-m-faint">{t('journey.trips.noTripsLinkedSettings')}</p>
|
||||
)}
|
||||
<button type="button" onClick={openAddTrip} className={SHEET_SECONDARY}>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('journey.trips.addTrip')}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
|
||||
<LinkIcon size={13} className="flex-none text-m-faint" />
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] text-m-muted">{shareUrl}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyShareUrl}
|
||||
className="flex-none rounded-full bg-m-act px-3 py-[5px] font-geist text-[0.65625rem] font-bold text-m-actfg"
|
||||
>
|
||||
{copied ? t('journey.share.copied') : t('journey.share.copy')}
|
||||
</button>
|
||||
{canShareLink() && shareUrl && (
|
||||
{addTripOpen && (
|
||||
<div className="max-h-[200px] overflow-y-auto rounded-[14px] bg-[color:var(--m-ic)] p-1.5">
|
||||
{unlinkedTrips.length === 0 && (
|
||||
<p className="py-2 text-center font-geist text-caption text-m-faint">{t('journey.trips.noTripsAvailable')}</p>
|
||||
)}
|
||||
{unlinkedTrips.map(trip => (
|
||||
<div key={trip.id} className="flex items-center gap-2.5 rounded-[12px] px-1.5 py-1.5">
|
||||
<span className="h-8 w-8 flex-none rounded-[10px]" style={{ background: pickGradient(trip.id) }} />
|
||||
<span className="min-w-0 flex-1 truncate text-body font-semibold">{trip.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => linkTrip(trip.id)}
|
||||
disabled={linkingTripId === trip.id}
|
||||
className="flex h-8 flex-none items-center rounded-full bg-m-act px-3 text-caption font-semibold text-m-actfg disabled:opacity-40"
|
||||
>
|
||||
{t('journey.trips.link')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title={t('journey.detail.contributors')} count={journey.contributors.length || undefined}>
|
||||
{journey.contributors.map(c => (
|
||||
<div key={c.user_id} className="flex items-center gap-2.5">
|
||||
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-m-act text-body font-extrabold text-m-actfg">
|
||||
{(c.username || '?')[0].toUpperCase()}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-body font-semibold text-m-ink">{c.username}</span>
|
||||
<span className={`${SHEET_PILL} ${c.role === 'owner' ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'}`}>
|
||||
{c.role}
|
||||
</span>
|
||||
{c.role !== 'owner' && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.share')}
|
||||
onClick={() => void shareLink(shareUrl)}
|
||||
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg"
|
||||
onClick={() => handleRemoveContributor(c)}
|
||||
aria-label={t('journey.contributors.remove')}
|
||||
className={SHEET_ROUND}
|
||||
>
|
||||
<ShareIcon size={12} strokeWidth={2.4} />
|
||||
<UserMinus size={15} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{([
|
||||
{ key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
|
||||
{ key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid3x3 },
|
||||
{ key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
|
||||
]).map(({ key, label, icon: Icon }) => (
|
||||
<div key={key} className="flex items-center gap-[11px] px-[2px] py-[10px]">
|
||||
<Icon size={16} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<span className="min-w-0 flex-1 text-[0.84375rem] font-bold">{label}</span>
|
||||
<MToggle checked={shareLink[key]} onChange={() => toggleSharePerm(key)} ariaLabel={label} />
|
||||
</div>
|
||||
))}
|
||||
<MShareMapExtras link={shareLink} onToggle={toggleSharePerm} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={deleteShareLink}
|
||||
className="mt-1 font-geist text-[0.6875rem] font-semibold text-[color:var(--m-st-danger)]"
|
||||
>
|
||||
{t('journey.share.removeLink')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
<button type="button" onClick={onOpenInvite} className={SHEET_SECONDARY}>
|
||||
<UserPlus size={14} strokeWidth={2.2} />
|
||||
{t('journey.contributors.invite')}
|
||||
</button>
|
||||
</SheetSection>
|
||||
|
||||
{/* Archive */}
|
||||
<button type="button" onClick={handleArchiveToggle} disabled={archiving} className={`${solidBtn} mt-[14px] disabled:opacity-40`}>
|
||||
{journey.status === 'archived' ? <ArchiveRestore size={13} strokeWidth={2.2} /> : <Archive size={13} strokeWidth={2.2} />}
|
||||
<MTripAccessList access={journey.trip_access} />
|
||||
|
||||
<SheetSection title={t('journey.share.publicShare')}>
|
||||
{!shareLink ? (
|
||||
<button type="button" onClick={createShareLink} className={`${SHEET_PRIMARY} justify-center`}>
|
||||
<LinkIcon size={14} strokeWidth={2.2} />
|
||||
{t('journey.share.createLink')}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{shareUrl && (
|
||||
<MLinkField
|
||||
url={shareUrl}
|
||||
copied={copied}
|
||||
onCopy={copyShareUrl}
|
||||
copyLabel={t('journey.share.copy')}
|
||||
copiedLabel={t('journey.share.copied')}
|
||||
shareLabel={t('common.share')}
|
||||
/>
|
||||
)}
|
||||
{([
|
||||
{ key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
|
||||
{ key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid3x3 },
|
||||
{ key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
|
||||
]).map(({ key, label, icon: Icon }) => (
|
||||
<ToggleRow
|
||||
key={key}
|
||||
icon={<Icon size={16} strokeWidth={2} />}
|
||||
label={label}
|
||||
checked={shareLink[key]}
|
||||
onChange={() => toggleSharePerm(key)}
|
||||
/>
|
||||
))}
|
||||
<MShareMapExtras link={shareLink} onToggle={toggleSharePerm} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={deleteShareLink}
|
||||
className="flex h-[38px] w-full items-center justify-center gap-1.5 rounded-full text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-[color:var(--m-st-danger)]"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2.2} /> {t('journey.share.removeLink')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
<button type="button" onClick={handleArchiveToggle} disabled={archiving} className={`${SHEET_SECONDARY} flex-none`}>
|
||||
{journey.status === 'archived' ? <ArchiveRestore size={14} strokeWidth={2.2} /> : <Archive size={14} strokeWidth={2.2} />}
|
||||
{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none items-center gap-3 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="flex items-center gap-[5px] text-[0.75rem] font-bold text-[color:var(--m-st-danger)]"
|
||||
aria-label={t('journey.settings.delete')}
|
||||
className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-[color:var(--m-st-danger)]"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
{t('journey.settings.delete')}
|
||||
<Trash2 size={15} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ms-auto rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold"
|
||||
>
|
||||
<button type="button" onClick={onClose} className={`${SHEET_SECONDARY} ms-auto px-4`}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !title.trim()}
|
||||
className="rounded-full bg-m-act px-[18px] py-[9px] text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-40"
|
||||
>
|
||||
<button type="button" onClick={handleSave} disabled={saving || !title.trim()} className={SHEET_PRIMARY}>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -357,26 +328,45 @@ export default function MJourneySettingsSheet({
|
||||
}
|
||||
|
||||
/** Who a trip's linked journal lets in (#2683): shown under the contributors, but changed in that trip. */
|
||||
function MTripAccessList({ access, eyebrow }: { access: JourneyTripAccess[] | undefined; eyebrow: string }) {
|
||||
function MTripAccessList({ access }: { access: JourneyTripAccess[] | undefined }) {
|
||||
const { t } = useTranslation()
|
||||
if (!access?.length) return null
|
||||
return (
|
||||
<>
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.contributors.viaTrips')}</div>
|
||||
<p className="m-0 mb-[6px] text-caption text-m-faint">{t('journey.contributors.viaTripsHint')}</p>
|
||||
<SheetSection title={t('journey.contributors.viaTrips')} hint={t('journey.contributors.viaTripsHint')} count={access.length}>
|
||||
{access.map(a => (
|
||||
<div key={`${a.user_id}-${a.trip_id}`} className="flex items-center gap-[10px] px-[2px] py-1">
|
||||
<Link2 size={15} className="flex-none text-m-muted" />
|
||||
<div key={`${a.user_id}-${a.trip_id}`} className="flex items-center gap-2.5">
|
||||
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted">
|
||||
<Link2 size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-caption font-bold">{a.username}</span>
|
||||
<span className="block truncate text-caption text-m-faint">{t('journey.contributors.viaTrip', { trip: a.trip_title })}</span>
|
||||
</span>
|
||||
<span className="rounded-full bg-[color:var(--m-ic)] px-[10px] py-[3px] font-geist text-caption font-extrabold text-m-muted">
|
||||
{a.role}
|
||||
<span className="block truncate text-body font-semibold text-m-ink">{a.username}</span>
|
||||
<span className="block truncate font-geist text-caption text-m-faint">{t('journey.contributors.viaTrip', { trip: a.trip_title })}</span>
|
||||
</span>
|
||||
<span className={`${SHEET_PILL} bg-[color:var(--m-ic)] text-m-muted`}>{a.role}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
</SheetSection>
|
||||
)
|
||||
}
|
||||
|
||||
/** A switch with its label, an optional icon in front and an optional line under the label. */
|
||||
function ToggleRow({ icon, label, hint, checked, onChange, disabled }: {
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
hint?: string
|
||||
checked: boolean
|
||||
onChange: (next: boolean) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
|
||||
{icon && <span className="flex-none text-m-muted">{icon}</span>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-body font-semibold text-m-ink">{label}</div>
|
||||
{hint && <div className="font-geist text-caption text-m-muted">{hint}</div>}
|
||||
</div>
|
||||
<MToggle checked={checked} onChange={onChange} disabled={disabled} ariaLabel={label} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -424,11 +414,12 @@ function MRecordingPrepRow({ prep, enabled }: { prep: JourneyRecordingPrepState;
|
||||
function MShareMapExtras({ link, onToggle }: { link: JourneyShareLink; onToggle: (key: JourneyShareMapExtra) => unknown }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<>
|
||||
// Hangs under the map row: a line from below its pin, smaller labels.
|
||||
<div className="mb-1 ms-[9px] border-s-2 border-[color:var(--m-rowbr)] ps-[16px]">
|
||||
{SHARE_MAP_EXTRAS.map(({ key, labelKey, hintKey }) => (
|
||||
<div key={key} className={`flex items-center gap-[11px] py-[8px] ps-[29px] pe-[2px] ${link.share_map ? '' : 'opacity-50'}`}>
|
||||
<div key={key} className={`flex items-center gap-[11px] py-[7px] pe-[2px] ${link.share_map ? '' : 'opacity-50'}`}>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="text-body font-bold">{t(labelKey)}</span>
|
||||
<span className="text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold">{t(labelKey)}</span>
|
||||
<span className="font-geist text-caption text-m-muted">
|
||||
{link.share_map ? t(hintKey) : t('journey.share.needsMap')}
|
||||
</span>
|
||||
@@ -441,6 +432,6 @@ function MShareMapExtras({ link, onToggle }: { link: JourneyShareLink; onToggle:
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { AlertTriangle, CircleHelp, Info } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import { MSetButton } from './MSettingsUi'
|
||||
import { SHEET_DANGER, SHEET_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi'
|
||||
|
||||
interface MConfirmSheetProps {
|
||||
open: boolean
|
||||
@@ -17,9 +18,11 @@ interface MConfirmSheetProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Small confirm dialog as a centred floating card. Without onConfirm it is a
|
||||
* plain notice. The card stops at the screen height: a long message or list
|
||||
* scrolls between the title and the buttons, which always stay in reach.
|
||||
* Small confirm dialog as a centred floating card, in the shape of every phone
|
||||
* sheet: the icon tile and title, the message, the sheets' 42 px buttons.
|
||||
* Without onConfirm it is a plain notice. The card stops at the screen height:
|
||||
* a long message or list scrolls between the title and the buttons, which
|
||||
* always stay in reach.
|
||||
*/
|
||||
export default function MConfirmSheet({
|
||||
open,
|
||||
@@ -33,25 +36,42 @@ export default function MConfirmSheet({
|
||||
onConfirm,
|
||||
children,
|
||||
}: MConfirmSheetProps) {
|
||||
const Icon = danger ? AlertTriangle : onConfirm ? CircleHelp : Info
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="opaque" ariaLabel={title}>
|
||||
<div className="flex min-h-0 flex-col p-[18px]">
|
||||
<div className="flex-none text-[0.9375rem] font-extrabold text-m-ink">{title}</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<p className="mt-2 text-[0.78125rem] leading-relaxed text-m-muted text-pretty">{message}</p>
|
||||
{children}
|
||||
</div>
|
||||
{/* A long label moves its button to a row of its own instead of wrapping inside it. */}
|
||||
<div className="mt-4 flex flex-none flex-wrap justify-end gap-2">
|
||||
<MSetButton variant="ghost" onClick={onClose} className="whitespace-nowrap">
|
||||
{cancelLabel}
|
||||
</MSetButton>
|
||||
{onConfirm && confirmLabel && (
|
||||
<MSetButton variant={danger ? 'danger' : 'primary'} onClick={onConfirm} disabled={busy} className="whitespace-nowrap">
|
||||
{confirmLabel}
|
||||
</MSetButton>
|
||||
)}
|
||||
</div>
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={title}>
|
||||
<div className="flex flex-none items-center gap-3 px-[18px] pt-4">
|
||||
<span
|
||||
className={`flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] ${
|
||||
danger ? 'text-[color:var(--m-st-danger)]' : 'text-m-ink'
|
||||
}`}
|
||||
>
|
||||
<Icon size={19} strokeWidth={1.8} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 text-[1.0625rem] font-bold leading-tight text-m-ink text-pretty">{title}</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-[18px] pt-3">
|
||||
<p className="text-[0.8125rem] leading-relaxed text-m-muted text-pretty">{message}</p>
|
||||
{children}
|
||||
</div>
|
||||
{/* A long label moves its button to a row of its own instead of wrapping inside it. */}
|
||||
<div className="flex flex-none flex-wrap justify-end gap-2 px-[18px] pb-[18px] pt-4">
|
||||
<button type="button" onClick={onClose} className={`${SHEET_SECONDARY} whitespace-nowrap`}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
{onConfirm && confirmLabel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
className={
|
||||
danger
|
||||
? `${SHEET_DANGER} whitespace-nowrap`
|
||||
: `${SHEET_PRIMARY} whitespace-nowrap`
|
||||
}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import { TileHeader } from '../trip/sheets/MTripSheetUi'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
|
||||
export interface MSetPickerOption {
|
||||
value: string
|
||||
@@ -21,10 +23,14 @@ interface MSetPickerSheetProps {
|
||||
* presets). Selecting closes the sheet.
|
||||
*/
|
||||
export default function MSetPickerSheet({ open, onClose, title, options, value, onSelect }: MSetPickerSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const closeLabel = t('common.close')
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="bottom" material="opaque" ariaLabel={title}>
|
||||
<div className="px-[14px] pb-2 pt-4 text-[0.875rem] font-extrabold text-m-ink">{title}</div>
|
||||
<div className="min-h-0 overflow-y-auto px-2 pb-3">
|
||||
<MSheet open={open} onClose={onClose} variant="bottom" material="glass" ariaLabel={title}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader title={title} onClose={onClose} closeLabel={closeLabel} />
|
||||
</div>
|
||||
<div className="m-scroll-fade mt-1 min-h-0 overflow-y-auto px-2 pb-3 pt-2">
|
||||
{options.map((opt) => {
|
||||
const active = opt.value === value
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user