mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
feat(native): home and lock screen widgets in the TREK look
Four widgets instead of one plain text block, drawn in the phone tokens (warm paper by day, near black by night) with contour lines and a route arc as the travel motif: - Trip (small, medium, large, lock screen): the cover photo, a countdown ring and a plane on its arc before the trip; the day of the trip as segments, the weather and the next stops on frosted glass while it runs - Today (medium, large): the day as a timeline in its category colours, with a now line, the day's progress and the weather - Bookings (small, medium, large, lock screen): flights, trains, hotels and tables ahead as coloured tiles, the next one featured, status dots - To-dos (small, medium, large, lock screen): to-dos and packing as nested rings, the most pressing open to-dos with priority and due dates The app now sends the destination, a small cover JPEG, the weather where the trip is, the bookings ahead, the open to-dos and the packing count; every new field is optional on both sides, so older binaries and servers keep working. Timelines step every quarter hour so the now line moves without the app. client/ios/WidgetPreviews renders every widget in every size, light and dark, on the CI's Mac, uploaded as the widget-previews artifact.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */; };
|
||||
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00062C0000000000A006 /* TrekShellPlugin.swift */; };
|
||||
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
|
||||
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
|
||||
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = A030A06726542A2D7F61C54B /* TrekFamily.swift */; };
|
||||
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
|
||||
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B175DE91E1E6E850DB0285 /* TodayContent.swift */; };
|
||||
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */; };
|
||||
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */; };
|
||||
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
|
||||
A8FDD4DBE3F078BF935CC4B9 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA8802204810ED580DE46F3 /* NotificationService.swift */; };
|
||||
@@ -97,6 +103,12 @@
|
||||
BE41D7212DCB0C78E00A8462 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NextTripWidget.swift; sourceTree = "<group>"; };
|
||||
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = "<group>"; };
|
||||
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekStyle.swift; sourceTree = "<group>"; };
|
||||
A030A06726542A2D7F61C54B /* TrekFamily.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekFamily.swift; sourceTree = "<group>"; };
|
||||
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripContent.swift; sourceTree = "<group>"; };
|
||||
24B175DE91E1E6E850DB0285 /* TodayContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayContent.swift; sourceTree = "<group>"; };
|
||||
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = "<group>"; };
|
||||
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodosContent.swift; sourceTree = "<group>"; };
|
||||
FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -212,6 +224,12 @@
|
||||
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */,
|
||||
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */,
|
||||
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */,
|
||||
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */,
|
||||
A030A06726542A2D7F61C54B /* TrekFamily.swift */,
|
||||
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */,
|
||||
24B175DE91E1E6E850DB0285 /* TodayContent.swift */,
|
||||
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */,
|
||||
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */,
|
||||
);
|
||||
name = TREKWidgets;
|
||||
path = TREKWidgets;
|
||||
@@ -382,6 +400,12 @@
|
||||
492FA24FBB57059B0B5007CE /* TripDayAttributes.swift in Sources */,
|
||||
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */,
|
||||
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */,
|
||||
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */,
|
||||
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */,
|
||||
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */,
|
||||
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */,
|
||||
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */,
|
||||
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import SwiftUI
|
||||
|
||||
/// What is booked next: flights, trains, the hotel, the table for tonight,
|
||||
/// each with its kind as a coloured tile and when it is due.
|
||||
struct BookingsContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let bookings = snapshot?.bookings ?? []
|
||||
let labels = snapshot?.labels
|
||||
if family == .rectangular {
|
||||
accessory(bookings.first, labels)
|
||||
} else if family == .circular {
|
||||
ZStack {
|
||||
Circle().fill(Color.white.opacity(0.18))
|
||||
Image(systemName: bookings.first.map { Trek.booking($0.kind).symbol } ?? "bookmark.fill")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
}
|
||||
} else if family == .inline {
|
||||
if let first = bookings.first {
|
||||
Text("\(Image(systemName: Trek.booking(first.kind).symbol)) \(first.time ?? "") \(first.title)")
|
||||
} else {
|
||||
Text(labels?.noBookings ?? "")
|
||||
}
|
||||
} else if let labels, let first = bookings.first {
|
||||
switch family {
|
||||
case .small: hero(first, labels)
|
||||
case .medium: list(Array(bookings.prefix(2)), labels, featureFirst: true)
|
||||
default: list(Array(bookings.prefix(5)), labels, featureFirst: true)
|
||||
}
|
||||
} else {
|
||||
empty(labels)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Small: the next one, large
|
||||
|
||||
private func hero(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
let look = Trek.booking(booking.kind)
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
KindBadge(kind: booking.kind, size: 34)
|
||||
Spacer(minLength: 0)
|
||||
StatusDot(confirmed: booking.confirmed)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day)
|
||||
.font(.system(size: 11, weight: .heavy))
|
||||
.tracking(0.5)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(look.color)
|
||||
}
|
||||
if let time = booking.time {
|
||||
Text(time).font(Trek.number(30, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
}
|
||||
Text(booking.title).font(.system(size: 14, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 11.5, weight: .semibold)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.background(alignment: .topTrailing) {
|
||||
Image(systemName: look.symbol)
|
||||
.font(.system(size: 92, weight: .bold))
|
||||
.foregroundStyle(look.color.opacity(scheme == .dark ? 0.1 : 0.08))
|
||||
.rotationEffect(.degrees(booking.kind == "flight" ? -20 : 0))
|
||||
.offset(x: 28, y: 12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Medium and large: a list, the first one featured
|
||||
|
||||
private func list(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels, featureFirst: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: family == .large ? 8 : 7) {
|
||||
HStack {
|
||||
Text(labels.bookings ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
if let trip = snapshot?.trip { Text(trip.title).font(.system(size: 11, weight: .semibold)).foregroundStyle(Trek.faint(scheme)).lineLimit(1) }
|
||||
}
|
||||
ForEach(Array(bookings.enumerated()), id: \.offset) { index, booking in
|
||||
Link(destination: WidgetSnapshot.link(booking.path) ?? URL(string: "com.liketrek.trek://")!) {
|
||||
if index == 0 && featureFirst {
|
||||
featured(booking, labels)
|
||||
} else {
|
||||
row(booking, labels)
|
||||
}
|
||||
}
|
||||
}
|
||||
if family == .large { Spacer(minLength: 0) }
|
||||
}
|
||||
}
|
||||
|
||||
private func featured(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
let look = Trek.booking(booking.kind)
|
||||
return HStack(spacing: 10) {
|
||||
KindBadge(kind: booking.kind, size: 38)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(booking.title).font(.system(size: 15, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day).font(.system(size: 10.5, weight: .heavy)).textCase(.uppercase).foregroundStyle(look.color)
|
||||
}
|
||||
if let time = booking.time { Text(time).font(Trek.number(20, .heavy)).foregroundStyle(Trek.ink(scheme)) }
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(LinearGradient(colors: [look.color.opacity(scheme == .dark ? 0.24 : 0.14), look.color.opacity(scheme == .dark ? 0.08 : 0.04)], startPoint: .leading, endPoint: .trailing))
|
||||
)
|
||||
.overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(look.color.opacity(0.25), lineWidth: 0.8))
|
||||
}
|
||||
|
||||
private func row(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
KindBadge(kind: booking.kind, size: 28)
|
||||
.padding(.leading, 5)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 5) {
|
||||
Text(booking.title).font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
if !booking.confirmed { StatusDot(confirmed: false, size: 6) }
|
||||
}
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 11, weight: .medium)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day).font(.system(size: 10.5, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
if let time = booking.time { Text(time).font(Trek.number(13, .heavy)).foregroundStyle(Trek.ink(scheme)) }
|
||||
}
|
||||
.padding(.trailing, 5)
|
||||
}
|
||||
}
|
||||
|
||||
private func empty(_ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(labels?.bookings ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: -8) {
|
||||
ForEach(["flight", "train", "hotel"], id: \.self) { kind in
|
||||
KindBadge(kind: kind, size: 30).opacity(0.35)
|
||||
}
|
||||
}
|
||||
Text(labels?.noBookings ?? "")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func accessory(_ booking: WidgetSnapshot.Booking?, _ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
if let booking, let labels {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: Trek.booking(booking.kind).symbol).font(.system(size: 11, weight: .bold))
|
||||
Text([RelativeDay.label(booking.date, now: now, labels: labels), booking.time].compactMap { $0 }.joined(separator: " "))
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(booking.title).font(.system(size: 14, weight: .heavy)).lineLimit(1)
|
||||
if let detail = booking.detail { Text(detail).font(.system(size: 11, weight: .medium)).opacity(0.75).lineLimit(1) }
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
Text(labels?.noBookings ?? "").font(.system(size: 12, weight: .medium)).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirmed in green, pending in amber, with a soft halo.
|
||||
struct StatusDot: View {
|
||||
let confirmed: Bool
|
||||
var size: CGFloat = 9
|
||||
|
||||
var body: some View {
|
||||
let color = confirmed ? Trek.confirmed : Trek.pending
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(Circle().stroke(color.opacity(0.3), lineWidth: size * 0.45).frame(width: size * 1.7, height: size * 1.7))
|
||||
.frame(width: size * 1.8, height: size * 1.8)
|
||||
}
|
||||
}
|
||||
@@ -1,118 +1,175 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// "Next trip" on the home screen: the trip and how far off it is, or while it
|
||||
/// runs, today's day and the next stops (medium size).
|
||||
struct NextTripEntry: TimelineEntry {
|
||||
/// The home-screen and lock-screen widgets. Each reads the snapshot the app
|
||||
/// last wrote (WidgetSnapshot) and draws it with the views in *Content.swift,
|
||||
/// which know nothing of WidgetKit so the preview renderer can draw them too.
|
||||
struct TrekEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let snapshot: WidgetSnapshot?
|
||||
}
|
||||
|
||||
struct NextTripProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> NextTripEntry {
|
||||
NextTripEntry(date: Date(), snapshot: nil)
|
||||
struct TrekProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> TrekEntry {
|
||||
TrekEntry(date: Date(), snapshot: .sample(running: false))
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (NextTripEntry) -> Void) {
|
||||
completion(NextTripEntry(date: Date(), snapshot: WidgetSnapshot.load()))
|
||||
/// The gallery shows the user's own trip when the app has written one, a
|
||||
/// sample trip before that.
|
||||
func getSnapshot(in context: Context, completion: @escaping (TrekEntry) -> Void) {
|
||||
let stored = WidgetSnapshot.load()
|
||||
let snapshot: WidgetSnapshot? = (stored?.trip != nil || !context.isPreview) ? stored : WidgetSnapshot.sample(running: false)
|
||||
completion(TrekEntry(date: Date(), snapshot: snapshot))
|
||||
}
|
||||
|
||||
/// The app reloads the timeline whenever it writes a new snapshot; past
|
||||
/// midnight the countdown moves on by itself.
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<NextTripEntry>) -> Void) {
|
||||
/// An entry every quarter hour for the next six hours, so the now line and
|
||||
/// the next stop move on without the app; the app reloads the timeline
|
||||
/// whenever it writes a new snapshot.
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<TrekEntry>) -> Void) {
|
||||
let snapshot = WidgetSnapshot.load()
|
||||
let now = Date()
|
||||
let midnight = Calendar.current.startOfDay(for: now.addingTimeInterval(86_400))
|
||||
let entry = NextTripEntry(date: now, snapshot: WidgetSnapshot.load())
|
||||
completion(Timeline(entries: [entry], policy: .after(midnight)))
|
||||
let calendar = Calendar.current
|
||||
let minute = calendar.component(.minute, from: now)
|
||||
let firstStep = calendar.date(byAdding: .minute, value: 15 - minute % 15, to: now) ?? now
|
||||
var entries = [TrekEntry(date: now, snapshot: snapshot)]
|
||||
for step in 0..<24 {
|
||||
if let date = calendar.date(byAdding: .minute, value: step * 15, to: firstStep) {
|
||||
entries.append(TrekEntry(date: calendar.date(bySetting: .second, value: 0, of: date) ?? date, snapshot: snapshot))
|
||||
}
|
||||
}
|
||||
completion(Timeline(entries: entries, policy: .atEnd))
|
||||
}
|
||||
}
|
||||
|
||||
struct NextTripWidgetView: View {
|
||||
extension TrekFamily {
|
||||
init(_ family: WidgetFamily) {
|
||||
switch family {
|
||||
case .systemSmall: self = .small
|
||||
case .systemMedium: self = .medium
|
||||
case .accessoryCircular: self = .circular
|
||||
case .accessoryRectangular: self = .rectangular
|
||||
case .accessoryInline: self = .inline
|
||||
default: self = .large
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The widget's ground: the content on its background, through the container
|
||||
/// background on iOS 17 (which the system can tint and remove), the plain way before.
|
||||
struct TrekContainer<Content: View, Background: View>: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: NextTripEntry
|
||||
let link: URL?
|
||||
@ViewBuilder var content: () -> Content
|
||||
@ViewBuilder var background: () -> Background
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.widgetURL(entry.snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) })
|
||||
.modifier(WidgetBackground())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let snapshot = entry.snapshot, let trip = snapshot.trip {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(snapshot.today == nil ? snapshot.labels.nextTrip : snapshot.labels.today)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
Text(trip.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
if let today = snapshot.today {
|
||||
Text(today.dayLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
if family != .systemSmall {
|
||||
stops(today.stops, nothing: snapshot.labels.nothingPlanned)
|
||||
}
|
||||
} else if let days = snapshot.daysUntilStart(from: entry.date), days > 0 {
|
||||
Spacer(minLength: 0)
|
||||
Text(days == 1 ? snapshot.labels.tomorrow : snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: String(days)))
|
||||
.font(.title3.weight(.bold))
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("TREK").font(.headline)
|
||||
Text(entry.snapshot?.labels.noTrip ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func stops(_ stops: [WidgetSnapshot.Stop], nothing: String) -> some View {
|
||||
if stops.isEmpty {
|
||||
Text(nothing).font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(Array(stops.prefix(3).enumerated()), id: \.offset) { _, stop in
|
||||
HStack(spacing: 6) {
|
||||
Text(stop.time ?? "")
|
||||
.font(.caption2.monospacedDigit().weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 36, alignment: .leading)
|
||||
Text(stop.name).font(.caption).lineLimit(1)
|
||||
}
|
||||
let accessory = TrekFamily(family).isAccessory
|
||||
Group {
|
||||
if #available(iOSApplicationExtension 17.0, *) {
|
||||
content().containerBackground(for: .widget) {
|
||||
if accessory { Color.clear } else { background() }
|
||||
}
|
||||
} else {
|
||||
content().padding(accessory ? 0 : 16).background(accessory ? nil : background())
|
||||
}
|
||||
}
|
||||
.widgetURL(link)
|
||||
}
|
||||
}
|
||||
|
||||
/// The system background on iOS 17 and later, the plain one before.
|
||||
struct WidgetBackground: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOSApplicationExtension 17.0, *) {
|
||||
content.containerBackground(.fill.tertiary, for: .widget)
|
||||
} else {
|
||||
content.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
// MARK: - Trip
|
||||
|
||||
struct NextTripWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "NextTrip", provider: NextTripProvider()) { entry in
|
||||
NextTripWidgetView(entry: entry)
|
||||
// The kind stays "NextTrip" so widgets placed before the redesign keep working.
|
||||
StaticConfiguration(kind: "NextTrip", provider: TrekProvider()) { entry in
|
||||
TripWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("TREK")
|
||||
.description("Your next trip, and today's plan while you travel.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
.configurationDisplayName("Trip")
|
||||
.description("Counts down to your next trip, then shows the day and what comes next.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
struct TripWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
|
||||
var body: some View {
|
||||
let snapshot = entry.snapshot
|
||||
TrekContainer(link: snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
TripContent(snapshot: snapshot, family: TrekFamily(family), now: entry.date)
|
||||
} background: {
|
||||
if snapshot?.trip != nil { TripBackdrop(trip: snapshot?.trip) } else { TrekPaper() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Today
|
||||
|
||||
struct TodayWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "TripToday", provider: TrekProvider()) { entry in
|
||||
TrekContainer(link: entry.snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
TodayFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper()
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("Today")
|
||||
.description("Today's plan as a timeline, with the weather and what is next.")
|
||||
.supportedFamilies([.systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
|
||||
private struct TodayFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { TodayContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
|
||||
}
|
||||
|
||||
// MARK: - Bookings
|
||||
|
||||
struct BookingsWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "TripBookings", provider: TrekProvider()) { entry in
|
||||
TrekContainer(link: entry.snapshot?.bookings?.first.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
BookingsFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper()
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("Bookings")
|
||||
.description("Your next flights, trains, hotels and tables, and when they are due.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
private struct BookingsFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { BookingsContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
|
||||
}
|
||||
|
||||
// MARK: - To-dos
|
||||
|
||||
struct TodosWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "TripTodos", provider: TrekProvider()) { entry in
|
||||
TrekContainer(link: entry.snapshot?.todos.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
TodosFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper()
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("To-dos")
|
||||
.description("What is still open for the trip, and how far the packing has come.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular])
|
||||
}
|
||||
}
|
||||
|
||||
private struct TodosFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { TodosContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// The TREK widgets: the next trip on the home screen, and the travel day as a
|
||||
/// Live Activity.
|
||||
/// The TREK widgets: the trip, today's plan, the bookings ahead and the
|
||||
/// to-dos on the home and lock screen, and the travel day as a Live Activity.
|
||||
@main
|
||||
struct TREKWidgetsBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
NextTripWidget()
|
||||
TodayWidget()
|
||||
BookingsWidget()
|
||||
TodosWidget()
|
||||
TripDayLiveActivity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Today's plan on paper: the day, the weather, and the stops as a timeline
|
||||
/// with a now line, so a glance says what is next and what is behind.
|
||||
struct TodayContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let nowMinutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
|
||||
if let snapshot, let trip = snapshot.trip, let today = snapshot.today {
|
||||
let total = today.total ?? snapshot.tripLength ?? 1
|
||||
let timed = today.stops.filter { $0.minutes != nil }
|
||||
let done = timed.filter { ($0.minutes ?? 0) < nowMinutes }.count
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
DayBadge(index: today.index ?? 1, total: total)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(trip.title).trekEyebrow(scheme)
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: family == .large ? 19 : 16, weight: .heavy))
|
||||
.foregroundStyle(Trek.ink(scheme))
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather {
|
||||
if family == .large {
|
||||
PaperWeather(weather: weather)
|
||||
} else {
|
||||
WeatherChip(weather: weather)
|
||||
}
|
||||
}
|
||||
}
|
||||
if family == .large {
|
||||
DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
Spacer(minLength: family == .large ? 12 : 8)
|
||||
StopList(
|
||||
stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 8 : 3,
|
||||
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
|
||||
)
|
||||
if family == .large { Spacer(minLength: 0) }
|
||||
}
|
||||
} else if let snapshot, let trip = snapshot.trip {
|
||||
// Before the trip there is no day yet: count down to it instead.
|
||||
let days = max(snapshot.daysUntilStart(from: now) ?? 0, 0)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(snapshot.labels.nextTrip).trekEyebrow(scheme)
|
||||
Text(trip.title).font(.system(size: 17, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(days)").font(Trek.number(40, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
Text(snapshot.labels.days ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: Trek.ink(scheme)).frame(height: 24)
|
||||
}
|
||||
} else {
|
||||
EmptyTrip(text: snapshot?.labels.noTrip ?? "", family: family)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's number in a dark tile with the trip's length under it.
|
||||
struct DayBadge: View {
|
||||
let index: Int
|
||||
let total: Int
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: -2) {
|
||||
Text("\(index)").font(Trek.number(19, .heavy))
|
||||
Text("/\(total)").font(Trek.number(9, .bold)).opacity(0.7)
|
||||
}
|
||||
.foregroundStyle(scheme == .dark ? Color(hex: 0x101013) : .white)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Trek.ink(scheme))
|
||||
)
|
||||
.shadow(color: .black.opacity(scheme == .dark ? 0 : 0.18), radius: 5, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// The weather on paper: symbol, temperature, and the day's range and rain.
|
||||
struct PaperWeather: View {
|
||||
let weather: WidgetSnapshot.Weather
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: Trek.weatherSymbol(weather.main))
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.font(.system(size: 24))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("\(weather.temp)°").font(Trek.number(20, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
HStack(spacing: 4) {
|
||||
if let high = weather.high, let low = weather.low { Text("\(high)°/\(low)°") }
|
||||
if let rain = weather.rain, rain > 0 {
|
||||
Text("\(Image(systemName: "drop.fill"))\(rain)%")
|
||||
}
|
||||
}
|
||||
.font(Trek.number(10, .semibold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of the day is behind: a bar of the stops passed.
|
||||
struct DayProgress: View {
|
||||
let done: Int
|
||||
let total: Int
|
||||
let scheme: ColorScheme
|
||||
let label: String?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Trek.track(scheme))
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: [Trek.confirmed.opacity(0.8), Trek.confirmed], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(width: total > 0 ? max(6, geo.size.width * CGFloat(done) / CGFloat(total)) : 0)
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
Text(done >= total && total > 0 ? (label ?? "\(done)/\(total)") : "\(done)/\(total)")
|
||||
.font(Trek.number(11, .bold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The trip's open to-dos and the packing list: how far along both are, as
|
||||
/// rings and a bar, and the to-dos that matter most, by due date and priority.
|
||||
struct TodosContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let todos = snapshot?.todos
|
||||
let labels = snapshot?.labels
|
||||
if family.isAccessory {
|
||||
accessory(todos, labels)
|
||||
} else if let labels, let todos, todos.total > 0 || snapshot?.packing != nil {
|
||||
switch family {
|
||||
case .small: small(todos, labels)
|
||||
case .medium:
|
||||
HStack(spacing: 14) {
|
||||
rings(todos, labels, size: 104)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header(labels, todos)
|
||||
items(Array(todos.items.prefix(3)), labels).padding(.top, 6)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
rings(todos, labels, size: 96)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
header(labels, todos)
|
||||
if let packing = snapshot?.packing { PackingBar(packing: packing, labels: labels) }
|
||||
}
|
||||
}
|
||||
Divider().overlay(Trek.track(scheme)).padding(.vertical, 10)
|
||||
items(Array(todos.items.prefix(6)), labels)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(labels?.todos ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 34))
|
||||
.foregroundStyle(Trek.confirmed.opacity(0.85))
|
||||
Text(labels?.noTodos ?? "").font(.system(size: 14, weight: .bold)).foregroundStyle(Trek.ink(scheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func small(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
Text(labels.todos ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
rings(todos, labels, size: 92)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let first = todos.items.first {
|
||||
HStack(spacing: 5) {
|
||||
Circle().stroke(Trek.priority(first.priority) ?? Trek.faint(scheme), lineWidth: 1.6).frame(width: 10, height: 10)
|
||||
Text(first.name).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The to-dos as the outer ring and the packing list as the inner one.
|
||||
private func rings(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels, size: CGFloat) -> some View {
|
||||
let done = todos.total > 0 ? Double(todos.total - todos.open) / Double(todos.total) : 1
|
||||
let packing = snapshot?.packing
|
||||
let packed = packing.map { $0.total > 0 ? Double($0.packed) / Double($0.total) : 1 }
|
||||
let width = size * 0.085
|
||||
return ZStack {
|
||||
ProgressRing(value: done, lineWidth: width, colors: [Color(hex: 0x4A7DDB), Color(hex: 0x6A5BE0)], trackColor: Trek.track(scheme)) { EmptyView() }
|
||||
if let packed {
|
||||
ProgressRing(value: packed, lineWidth: width, colors: [Color(hex: 0x2FA37A), Color(hex: 0x7BC96F)], trackColor: Trek.track(scheme)) { EmptyView() }
|
||||
.padding(width * 1.45)
|
||||
}
|
||||
VStack(spacing: -2) {
|
||||
Text("\(todos.open)").font(Trek.number(size * 0.3, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
Image(systemName: "checklist").font(.system(size: size * 0.11, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
|
||||
private func header(_ labels: WidgetSnapshot.Labels, _ todos: WidgetSnapshot.Todos) -> some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(labels.todos ?? "").trekEyebrow(scheme)
|
||||
if let trip = snapshot?.trip {
|
||||
Text(trip.title).font(.system(size: 15, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func items(_ items: [WidgetSnapshot.Todo], _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.stroke(Trek.priority(item.priority) ?? Trek.faint(scheme), lineWidth: 1.7)
|
||||
.frame(width: 14, height: 14)
|
||||
Text(item.name)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Trek.ink(scheme))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if let due = RelativeDay.label(item.due, now: now, labels: labels) {
|
||||
Text(due)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(overdue(item.due) ? Trek.danger : Trek.muted(scheme))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(overdue(item.due) ? Trek.danger.opacity(0.12) : Trek.card(scheme)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func overdue(_ due: String?) -> Bool {
|
||||
guard let due, let date = WidgetSnapshot.day(due), let days = WidgetSnapshot.daysBetween(now, date) else { return false }
|
||||
return days <= 0
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func accessory(_ todos: WidgetSnapshot.Todos?, _ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
let open = todos?.open ?? 0
|
||||
let total = todos?.total ?? 0
|
||||
switch family {
|
||||
case .circular:
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
|
||||
Circle().trim(from: 0, to: total > 0 ? Double(total - open) / Double(total) : 1)
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: -2) {
|
||||
Image(systemName: "checklist").font(.system(size: 9, weight: .bold))
|
||||
Text("\(open)").font(Trek.number(18, .heavy))
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checklist").font(.system(size: 11, weight: .bold))
|
||||
Text("\(labels?.todos ?? "") \(open)/\(total)").font(.system(size: 12, weight: .bold))
|
||||
}
|
||||
ForEach(Array((todos?.items ?? []).prefix(2).enumerated()), id: \.offset) { _, item in
|
||||
Text("\(Image(systemName: "circle")) \(item.name)").font(.system(size: 12, weight: .medium)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
default:
|
||||
Text("\(Image(systemName: "checklist")) \(open) \(labels?.todos ?? "")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The packing list as a bar with the suitcase and the count.
|
||||
struct PackingBar: View {
|
||||
let packing: WidgetSnapshot.Packing
|
||||
let labels: WidgetSnapshot.Labels
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let share = packing.total > 0 ? Double(packing.packed) / Double(packing.total) : 1
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "suitcase.rolling.fill").font(.system(size: 11, weight: .bold)).foregroundStyle(Trek.confirmed)
|
||||
Text(labels.packing ?? "").font(.system(size: 11.5, weight: .bold)).foregroundStyle(Trek.ink(scheme))
|
||||
Spacer(minLength: 0)
|
||||
Text("\(packing.packed)/\(packing.total)").font(Trek.number(11.5, .heavy)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Trek.track(scheme))
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: [Color(hex: 0x2FA37A), Color(hex: 0x7BC96F)], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(width: max(6, geo.size.width * CGFloat(share)))
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
/// The sizes the widget views draw for. Their own enum rather than WidgetKit's,
|
||||
/// so the views also render outside a widget (the preview renderer in
|
||||
/// client/ios/WidgetPreviews draws every size to a PNG on the CI's Mac).
|
||||
enum TrekFamily: String, CaseIterable {
|
||||
case small, medium, large, circular, rectangular, inline
|
||||
|
||||
var isAccessory: Bool { self == .circular || self == .rectangular || self == .inline }
|
||||
|
||||
/// The point size of the family on a 6.1" iPhone, for the renderer.
|
||||
var previewSize: CGSize {
|
||||
switch self {
|
||||
case .small: return CGSize(width: 170, height: 170)
|
||||
case .medium: return CGSize(width: 364, height: 170)
|
||||
case .large: return CGSize(width: 364, height: 382)
|
||||
case .circular: return CGSize(width: 76, height: 76)
|
||||
case .rectangular: return CGSize(width: 172, height: 76)
|
||||
case .inline: return CGSize(width: 250, height: 26)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WidgetSnapshot {
|
||||
/// What the widget gallery shows before the app ever wrote a snapshot, and
|
||||
/// what the renderer draws: a trip to Kyoto with a full day, bookings and to-dos.
|
||||
static func sample(running: Bool, now: Date = Date()) -> WidgetSnapshot {
|
||||
let calendar = Calendar.current
|
||||
let iso: (Int) -> String = { offset in
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: calendar.date(byAdding: .day, value: offset, to: now) ?? now)
|
||||
}
|
||||
let start = running ? -2 : 12
|
||||
return WidgetSnapshot(
|
||||
trip: Trip(
|
||||
title: "Autumn in Kyoto", start: iso(start), end: iso(start + 8), path: "/trips/1",
|
||||
place: "Kyoto, Japan", cover: nil, dayCount: 9, placeCount: 34, memberCount: 3
|
||||
),
|
||||
today: running
|
||||
? Today(
|
||||
dayLabel: "Day 3 of 9",
|
||||
stops: [
|
||||
Stop(time: "08:30", name: "Fushimi Inari Taisha", minutes: 510, color: "#D6273B", symbol: "building.columns.fill"),
|
||||
Stop(time: "11:00", name: "Nishiki Market", minutes: 660, color: "#E8A13A", symbol: "basket.fill"),
|
||||
Stop(time: "13:30", name: "Kiyomizu-dera", minutes: 810, color: "#2FA37A", symbol: "leaf.fill"),
|
||||
Stop(time: "16:00", name: "Gion walk", minutes: 960, color: "#4A7DDB", symbol: "figure.walk"),
|
||||
Stop(time: "19:30", name: "Dinner at Pontocho", minutes: 1170, color: "#8E6CD9", symbol: "fork.knife"),
|
||||
],
|
||||
index: 3, total: 9
|
||||
)
|
||||
: nil,
|
||||
weather: Weather(temp: 18, high: 21, low: 11, main: running ? "Clear" : "Clouds", label: "Mostly sunny", place: "Kyoto", rain: 10),
|
||||
bookings: [
|
||||
Booking(title: "LH 716", kind: "flight", date: iso(running ? 6 : 12), time: "13:05", detail: "FRA → HND", confirmed: true, path: "/trips/1"),
|
||||
Booking(title: "Shinkansen Nozomi 21", kind: "train", date: iso(running ? 0 : 13), time: "09:12", detail: "Tokyo → Kyoto", confirmed: true, path: "/trips/1"),
|
||||
Booking(title: "Hotel Kanra Kyoto", kind: "hotel", date: iso(running ? 0 : 13), time: "15:00", detail: "6 nights", confirmed: true, path: "/trips/1"),
|
||||
Booking(title: "Kaiseki at Kikunoi", kind: "restaurant", date: iso(running ? 1 : 15), time: "19:00", detail: "Higashiyama", confirmed: false, path: "/trips/1"),
|
||||
Booking(title: "Tea ceremony", kind: "event", date: iso(running ? 2 : 16), time: "10:30", detail: "Camellia Flower", confirmed: true, path: "/trips/1"),
|
||||
],
|
||||
todos: Todos(
|
||||
open: 4, total: 11,
|
||||
items: [
|
||||
Todo(name: "Order JR Pass", due: iso(running ? 0 : 3), priority: 3),
|
||||
Todo(name: "Book Ghibli Museum tickets", due: iso(running ? 1 : 5), priority: 2),
|
||||
Todo(name: "Get yen in cash", due: nil, priority: 1),
|
||||
Todo(name: "Download offline maps", due: nil, priority: 0),
|
||||
],
|
||||
path: "/trips/1/todos"
|
||||
),
|
||||
packing: Packing(packed: 23, total: 31, path: "/trips/1/packing"),
|
||||
labels: Labels(
|
||||
nextTrip: "Next trip", today: "Today", noTrip: "No trip coming up. Plan the next one in TREK.",
|
||||
inDays: "in {n} days", tomorrow: "Tomorrow", nothingPlanned: "Nothing planned today",
|
||||
days: "days", dayOf: "Day {n} of {m}", bookings: "Coming up", noBookings: "No bookings ahead",
|
||||
todos: "To-dos", noTodos: "All done", packing: "Packing", packed: "packed",
|
||||
confirmed: "Confirmed", pending: "Pending", todayShort: "Today", tomorrowShort: "Tomorrow",
|
||||
now: "Now", places: "places", travellers: "travellers", allDone: "Day done", openTrek: "Open TREK"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// An empty state: no trip at all.
|
||||
static var sampleEmpty: WidgetSnapshot {
|
||||
var snapshot = sample(running: false)
|
||||
snapshot.trip = nil
|
||||
snapshot.today = nil
|
||||
snapshot.weather = nil
|
||||
snapshot.bookings = []
|
||||
snapshot.todos = Todos(open: 0, total: 0, items: [], path: "/")
|
||||
snapshot.packing = nil
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// The widgets' look, from the app's phone tokens (client/src/mobile/mobile.css):
|
||||
/// warm paper by day, near black by night, one ink colour, the status colours,
|
||||
/// and the topographic lines and route arcs that stand for travel throughout.
|
||||
enum Trek {
|
||||
static func ink(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0xF5F5F7) : Color(hex: 0x101013) }
|
||||
static func muted(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0x9C9CA3) : Color(hex: 0x68686F) }
|
||||
static func faint(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0x6E6E76) : Color(hex: 0x9A9AA1) }
|
||||
static func card(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.07) : Color.white.opacity(0.62) }
|
||||
static func cardBorder(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.09) : Color.white.opacity(0.9) }
|
||||
static func track(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.12) : Color(hex: 0x101013).opacity(0.09) }
|
||||
|
||||
static let confirmed = Color(hex: 0x2FA37A)
|
||||
static let pending = Color(hex: 0xE8A13A)
|
||||
static let info = Color(hex: 0x4A7DDB)
|
||||
static let danger = Color(hex: 0xD6273B)
|
||||
|
||||
/// A stable number for a text (FNV-1a), unlike hashValue, which changes per launch.
|
||||
static func seed(_ text: String) -> UInt64 {
|
||||
var hash: UInt64 = 1469598103934665603
|
||||
for byte in text.utf8 { hash = (hash ^ UInt64(byte)) &* 1099511628211 }
|
||||
return hash
|
||||
}
|
||||
|
||||
/// Two colours of the trip's own, from its title: the same trip always gets
|
||||
/// the same sky, and two trips side by side rarely the same one.
|
||||
static func tripHues(_ title: String) -> (Color, Color) {
|
||||
let base = Double(seed(title) % 360) / 360
|
||||
let first = Color(hue: base, saturation: 0.62, brightness: 0.78)
|
||||
let second = Color(hue: (base + 0.11).truncatingRemainder(dividingBy: 1), saturation: 0.7, brightness: 0.42)
|
||||
return (first, second)
|
||||
}
|
||||
|
||||
static func number(_ size: CGFloat, _ weight: Font.Weight = .bold) -> Font {
|
||||
.system(size: size, weight: weight, design: .rounded).monospacedDigit()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct Eyebrow: ViewModifier {
|
||||
let scheme: ColorScheme
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(.system(size: 10.5, weight: .bold))
|
||||
.tracking(0.6)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func trekEyebrow(_ scheme: ColorScheme) -> some View { modifier(Eyebrow(scheme: scheme)) }
|
||||
}
|
||||
|
||||
extension Color {
|
||||
init(hex: UInt32, opacity: Double = 1) {
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double((hex >> 16) & 0xFF) / 255,
|
||||
green: Double((hex >> 8) & 0xFF) / 255,
|
||||
blue: Double(hex & 0xFF) / 255,
|
||||
opacity: opacity
|
||||
)
|
||||
}
|
||||
|
||||
/// "#rrggbb" or "rrggbb"; nil for anything else.
|
||||
init?(css: String?) {
|
||||
guard let css else { return nil }
|
||||
let hex = css.hasPrefix("#") ? String(css.dropFirst()) : css
|
||||
guard hex.count == 6, let value = UInt32(hex, radix: 16) else { return nil }
|
||||
self.init(hex: value)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Backgrounds
|
||||
|
||||
/// The paper the widgets sit on: the app's screen gradient, with a faint
|
||||
/// contour map over it so even an empty widget reads as TREK.
|
||||
struct TrekPaper: View {
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
var contours = true
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if scheme == .dark {
|
||||
RadialGradient(
|
||||
colors: [Color(hex: 0x1B1B21), Color(hex: 0x0C0C0F), Color(hex: 0x070709)],
|
||||
center: UnitPoint(x: 0.5, y: -0.1), startRadius: 0, endRadius: 360
|
||||
)
|
||||
} else {
|
||||
RadialGradient(
|
||||
colors: [Color(hex: 0xFDFCF9), Color(hex: 0xF1EFEA), Color(hex: 0xE7E5DF)],
|
||||
center: UnitPoint(x: 0.5, y: -0.1), startRadius: 0, endRadius: 360
|
||||
)
|
||||
}
|
||||
if contours {
|
||||
ContourLines(seed: 7, rings: 9)
|
||||
.stroke(Trek.ink(scheme).opacity(scheme == .dark ? 0.05 : 0.045), lineWidth: 0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A trip's own backdrop: its cover photo when the app sent one, else its two
|
||||
/// colours as a sky with contour lines, both darkened towards the text.
|
||||
struct TripBackdrop: View {
|
||||
let trip: WidgetSnapshot.Trip?
|
||||
var shade: Double = 0.62
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let image = coverImage {
|
||||
image.resizable().scaledToFill()
|
||||
} else {
|
||||
let (first, second) = Trek.tripHues(trip?.title ?? "TREK")
|
||||
LinearGradient(colors: [first, second], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
RadialGradient(colors: [Color.white.opacity(0.28), .clear], center: UnitPoint(x: 0.85, y: 0.05), startRadius: 0, endRadius: 220)
|
||||
ContourLines(seed: Trek.seed(trip?.title ?? "") % 997, rings: 11)
|
||||
.stroke(Color.white.opacity(0.14), lineWidth: 0.9)
|
||||
}
|
||||
LinearGradient(
|
||||
colors: [Color.black.opacity(shade * 0.35), Color.black.opacity(0.05), Color.black.opacity(shade)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var coverImage: Image? {
|
||||
guard let base64 = trip?.cover, let data = Data(base64Encoded: base64) else { return nil }
|
||||
#if canImport(UIKit)
|
||||
guard let image = UIImage(data: data) else { return nil }
|
||||
return Image(uiImage: image)
|
||||
#elseif canImport(AppKit)
|
||||
guard let image = NSImage(data: data) else { return nil }
|
||||
return Image(nsImage: image)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Wobbly closed rings around an off-centre summit: a contour map in a few
|
||||
/// lines, the same for the same seed.
|
||||
struct ContourLines: Shape {
|
||||
var seed: UInt64
|
||||
var rings: Int
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let phaseA = CGFloat(seed % 13) * 0.7
|
||||
let phaseB = CGFloat(seed % 7) * 1.3
|
||||
let offset: CGFloat = 0.62 + CGFloat(seed % 5) * 0.04
|
||||
let centerX: CGFloat = rect.minX + rect.width * offset
|
||||
let centerY: CGFloat = rect.minY + rect.height * 0.32
|
||||
let maxRadius: CGFloat = max(rect.width, rect.height) * 1.05
|
||||
let count = max(rings, 1)
|
||||
let steps = 72
|
||||
for ring in 1...count {
|
||||
let base: CGFloat = maxRadius * CGFloat(ring) / CGFloat(count)
|
||||
let twist: CGFloat = CGFloat(ring) * 0.35
|
||||
for step in 0...steps {
|
||||
let angle: CGFloat = CGFloat(step) / CGFloat(steps) * 2 * .pi
|
||||
let first: CGFloat = 0.09 * sin(angle * 3 + phaseA + twist)
|
||||
let second: CGFloat = 0.05 * sin(angle * 5 + phaseB)
|
||||
let radius: CGFloat = base * (1 + first + second)
|
||||
let point = CGPoint(x: centerX + cos(angle) * radius * 1.15, y: centerY + sin(angle) * radius * 0.82)
|
||||
if step == 0 { path.move(to: point) } else { path.addLine(to: point) }
|
||||
}
|
||||
path.closeSubpath()
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/// A dotted flight path from home to the trip, with the plane on it as far as
|
||||
/// the countdown has come.
|
||||
struct RouteArc: View {
|
||||
/// 0 at home, 1 arrived.
|
||||
let progress: Double
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let start = CGPoint(x: 6, y: geo.size.height - 6)
|
||||
let end = CGPoint(x: geo.size.width - 6, y: geo.size.height - 6)
|
||||
let control = CGPoint(x: geo.size.width / 2, y: geo.size.height * -0.55)
|
||||
ZStack {
|
||||
Path { path in
|
||||
path.move(to: start)
|
||||
path.addQuadCurve(to: end, control: control)
|
||||
}
|
||||
.stroke(color.opacity(0.45), style: StrokeStyle(lineWidth: 1.4, lineCap: .round, dash: [1.5, 4.5]))
|
||||
Path { path in
|
||||
path.move(to: start)
|
||||
path.addQuadCurve(to: end, control: control)
|
||||
}
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(color, style: StrokeStyle(lineWidth: 1.8, lineCap: .round))
|
||||
Circle().fill(color).frame(width: 6, height: 6).position(start)
|
||||
Circle().stroke(color, lineWidth: 1.6).frame(width: 9, height: 9).position(end)
|
||||
let point = quadPoint(start, control, end, progress)
|
||||
let tangent = quadTangent(start, control, end, progress)
|
||||
Image(systemName: "airplane")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
.rotationEffect(.radians(Double(atan2(tangent.y, tangent.x))))
|
||||
.position(point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func quadPoint(_ a: CGPoint, _ c: CGPoint, _ b: CGPoint, _ progress: Double) -> CGPoint {
|
||||
let t = CGFloat(progress)
|
||||
let u: CGFloat = 1 - t
|
||||
let x: CGFloat = u * u * a.x + 2 * u * t * c.x + t * t * b.x
|
||||
let y: CGFloat = u * u * a.y + 2 * u * t * c.y + t * t * b.y
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
private func quadTangent(_ a: CGPoint, _ c: CGPoint, _ b: CGPoint, _ progress: Double) -> CGPoint {
|
||||
let t = CGFloat(progress)
|
||||
let x: CGFloat = 2 * (1 - t) * (c.x - a.x) + 2 * t * (b.x - c.x)
|
||||
let y: CGFloat = 2 * (1 - t) * (c.y - a.y) + 2 * t * (b.y - c.y)
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Components
|
||||
|
||||
/// A ring that fills with progress, a soft track under it.
|
||||
struct ProgressRing<Label: View>: View {
|
||||
let value: Double
|
||||
var lineWidth: CGFloat = 6
|
||||
var colors: [Color]
|
||||
var trackColor: Color
|
||||
@ViewBuilder var label: () -> Label
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle().stroke(trackColor, lineWidth: lineWidth)
|
||||
Circle()
|
||||
.trim(from: 0, to: max(0.001, min(1, value)))
|
||||
.stroke(
|
||||
AngularGradient(colors: colors + [colors.first ?? .white], center: .center, startAngle: .degrees(-90), endAngle: .degrees(270)),
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
label()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The trip's days as a row of segments, those behind filled, today glowing.
|
||||
struct DaySegments: View {
|
||||
let index: Int
|
||||
let total: Int
|
||||
let color: Color
|
||||
let track: Color
|
||||
|
||||
var body: some View {
|
||||
let count = min(max(total, 1), 21)
|
||||
let current = total > 21 ? Int((Double(index) / Double(total) * 21).rounded(.up)) : index
|
||||
HStack(spacing: count > 12 ? 2 : 3) {
|
||||
ForEach(1...count, id: \.self) { day in
|
||||
Capsule()
|
||||
.fill(day < current ? color : day == current ? color : track)
|
||||
.opacity(day < current ? 0.55 : 1)
|
||||
.shadow(color: day == current ? color.opacity(0.7) : .clear, radius: 3)
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's weather in a pill: the multicolour symbol and the temperature.
|
||||
struct WeatherChip: View {
|
||||
let weather: WidgetSnapshot.Weather
|
||||
var onPhoto = false
|
||||
var compact = false
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: Trek.weatherSymbol(weather.main))
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.font(.system(size: compact ? 12 : 14, weight: .semibold))
|
||||
Text("\(weather.temp)°")
|
||||
.font(Trek.number(compact ? 12 : 14, .semibold))
|
||||
.foregroundStyle(onPhoto ? .white : Trek.ink(scheme))
|
||||
}
|
||||
.padding(.horizontal, compact ? 7 : 9)
|
||||
.padding(.vertical, compact ? 3 : 4)
|
||||
.background(
|
||||
Capsule().fill(onPhoto ? Color.black.opacity(0.28) : Trek.card(scheme))
|
||||
)
|
||||
.overlay(Capsule().stroke(onPhoto ? Color.white.opacity(0.22) : Trek.cardBorder(scheme), lineWidth: 0.6))
|
||||
}
|
||||
}
|
||||
|
||||
extension Trek {
|
||||
static func weatherSymbol(_ main: String) -> String {
|
||||
switch main.lowercased() {
|
||||
case "clear": return "sun.max.fill"
|
||||
case "clouds": return "cloud.sun.fill"
|
||||
case "rain", "drizzle": return "cloud.rain.fill"
|
||||
case "thunderstorm": return "cloud.bolt.rain.fill"
|
||||
case "snow": return "cloud.snow.fill"
|
||||
case "mist", "fog", "haze": return "cloud.fog.fill"
|
||||
default: return "cloud.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbol and colour of a booking's kind.
|
||||
static func booking(_ kind: String) -> (symbol: String, color: Color) {
|
||||
switch kind {
|
||||
case "flight": return ("airplane", Color(hex: 0x4A7DDB))
|
||||
case "train": return ("tram.fill", Color(hex: 0x2FA37A))
|
||||
case "bus": return ("bus.fill", Color(hex: 0x2BA6A6))
|
||||
case "car": return ("car.fill", Color(hex: 0xD6673B))
|
||||
case "cruise", "ferry": return ("ferry.fill", Color(hex: 0x2E8BC0))
|
||||
case "hotel": return ("bed.double.fill", Color(hex: 0x8E6CD9))
|
||||
case "restaurant": return ("fork.knife", Color(hex: 0xE8A13A))
|
||||
case "event": return ("ticket.fill", Color(hex: 0xD6408B))
|
||||
case "tour": return ("figure.hiking", Color(hex: 0x4E9F3D))
|
||||
default: return ("bookmark.fill", Color(hex: 0x8A8A93))
|
||||
}
|
||||
}
|
||||
|
||||
static func priority(_ value: Int) -> Color? {
|
||||
switch value {
|
||||
case 3: return danger
|
||||
case 2: return pending
|
||||
case 1: return info
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A booking's kind as a tinted rounded square.
|
||||
struct KindBadge: View {
|
||||
let kind: String
|
||||
var size: CGFloat = 30
|
||||
|
||||
var body: some View {
|
||||
let look = Trek.booking(kind)
|
||||
RoundedRectangle(cornerRadius: size * 0.32, style: .continuous)
|
||||
.fill(LinearGradient(colors: [look.color, look.color.opacity(0.78)], startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Image(systemName: look.symbol)
|
||||
.font(.system(size: size * 0.46, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
)
|
||||
.shadow(color: look.color.opacity(0.35), radius: 4, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Today", "Tomorrow" or the short weekday and date of a yyyy-MM-dd.
|
||||
enum RelativeDay {
|
||||
static func label(_ iso: String?, now: Date, labels: WidgetSnapshot.Labels) -> String? {
|
||||
guard let iso, let date = WidgetSnapshot.day(iso), let days = WidgetSnapshot.daysBetween(now, date) else { return nil }
|
||||
if days == 0 { return labels.todayShort ?? labels.today }
|
||||
if days == 1 { return labels.tomorrowShort ?? labels.tomorrow }
|
||||
let formatter = DateFormatter()
|
||||
formatter.setLocalizedDateFormatFromTemplate(days < 7 ? "EEE" : "EEEdMMM")
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
static func range(_ start: String, _ end: String) -> String? {
|
||||
guard let from = WidgetSnapshot.day(start), let to = WidgetSnapshot.day(end) else { return nil }
|
||||
let formatter = DateIntervalFormatter()
|
||||
formatter.dateTemplate = "dMMM"
|
||||
return formatter.string(from: from, to: to)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The trip widget: a countdown on the trip's own cover before it starts, the
|
||||
/// day and what comes next while it runs, an invitation when there is none.
|
||||
struct TripContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
private var labels: WidgetSnapshot.Labels? { snapshot?.labels }
|
||||
|
||||
var body: some View {
|
||||
if family.isAccessory {
|
||||
TripAccessory(snapshot: snapshot, family: family, now: now)
|
||||
} else if let snapshot, let trip = snapshot.trip {
|
||||
if let today = snapshot.today {
|
||||
running(snapshot, trip, today)
|
||||
} else {
|
||||
upcoming(snapshot, trip)
|
||||
}
|
||||
} else {
|
||||
EmptyTrip(text: snapshot?.labels.noTrip ?? "", family: family)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Before the trip
|
||||
|
||||
@ViewBuilder
|
||||
private func upcoming(_ snapshot: WidgetSnapshot, _ trip: WidgetSnapshot.Trip) -> some View {
|
||||
let days = max(snapshot.daysUntilStart(from: now) ?? 0, 0)
|
||||
// The ring and the plane cover the last 60 days before departure.
|
||||
let progress = 1 - min(Double(days), 60) / 60
|
||||
switch family {
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
PlaneMark()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
countdown(days, snapshot.labels, size: 46)
|
||||
Text(trip.title)
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
.padding(.top, 1)
|
||||
if let range = RelativeDay.range(trip.start, trip.end) {
|
||||
Text(range).font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.75)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
case .medium:
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(snapshot.labels.nextTrip).trekEyebrowOnPhoto()
|
||||
Text(trip.title)
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.top, 3)
|
||||
if let place = trip.place {
|
||||
Label(place, systemImage: "mappin.and.ellipse")
|
||||
.font(.system(size: 11.5, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
.lineLimit(1)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
RouteArc(progress: progress, color: .white)
|
||||
.frame(height: 26)
|
||||
.padding(.bottom, 8)
|
||||
HStack(spacing: 6) {
|
||||
if let range = RelativeDay.range(trip.start, trip.end) { PhotoPill(symbol: "calendar", text: range) }
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
|
||||
}
|
||||
}
|
||||
ProgressRing(value: progress, lineWidth: 7, colors: [.white.opacity(0.75), .white], trackColor: .white.opacity(0.2)) {
|
||||
VStack(spacing: -2) {
|
||||
Text("\(days)").font(Trek.number(days > 99 ? 26 : 32, .heavy)).foregroundStyle(.white)
|
||||
Text(snapshot.labels.days ?? "").font(.system(size: 10, weight: .bold)).foregroundStyle(.white.opacity(0.75)).textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
.frame(width: 96, height: 96)
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
Text(snapshot.labels.nextTrip).trekEyebrowOnPhoto()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true) }
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
HStack(alignment: .bottom, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(trip.title)
|
||||
.font(.system(size: 26, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.75)
|
||||
if let place = trip.place {
|
||||
Label(place, systemImage: "mappin.and.ellipse")
|
||||
.font(.system(size: 12.5, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
countdown(days, snapshot.labels, size: 52)
|
||||
}
|
||||
RouteArc(progress: progress, color: .white)
|
||||
.frame(height: 34)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
TripFacts(snapshot: snapshot, onPhoto: true)
|
||||
if let booking = snapshot.bookings?.first {
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func countdown(_ days: Int, _ labels: WidgetSnapshot.Labels, size: CGFloat) -> some View {
|
||||
Group {
|
||||
if days == 0 {
|
||||
Text(labels.todayShort ?? labels.today)
|
||||
.font(.system(size: size * 0.62, weight: .heavy, design: .rounded))
|
||||
} else if days == 1 {
|
||||
Text(labels.tomorrow)
|
||||
.font(.system(size: size * 0.52, weight: .heavy, design: .rounded))
|
||||
.minimumScaleFactor(0.6)
|
||||
} else {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(days)").font(Trek.number(size, .heavy))
|
||||
Text(labels.days ?? "").font(.system(size: size * 0.28, weight: .bold)).opacity(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.shadow(color: .black.opacity(0.25), radius: 6, y: 2)
|
||||
}
|
||||
|
||||
// MARK: While travelling
|
||||
|
||||
@ViewBuilder
|
||||
private func running(_ snapshot: WidgetSnapshot, _ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today) -> some View {
|
||||
let nowMinutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
|
||||
let next = today.stops.first { ($0.minutes ?? -1) >= nowMinutes } ?? today.stops.last
|
||||
let index = today.index ?? 1
|
||||
let total = today.total ?? snapshot.tripLength ?? 1
|
||||
switch family {
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
Text(today.dayLabel).trekEyebrowOnPhoto()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
|
||||
}
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 7)
|
||||
Spacer(minLength: 0)
|
||||
if let next {
|
||||
Text(next.time ?? snapshot.labels.today)
|
||||
.font(Trek.number(28, .heavy))
|
||||
.foregroundStyle(.white)
|
||||
Text(next.name)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text(snapshot.labels.nothingPlanned).font(.system(size: 14, weight: .bold)).foregroundStyle(.white).lineLimit(3)
|
||||
}
|
||||
Text(trip.title).font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)).lineLimit(1).padding(.top, 2)
|
||||
}
|
||||
case .medium:
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(trip.title).trekEyebrowOnPhoto()
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: 21, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.top, 3)
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8)
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather {
|
||||
HStack(spacing: 6) {
|
||||
WeatherChip(weather: weather, onPhoto: true)
|
||||
if let high = weather.high, let low = weather.low {
|
||||
Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
GlassPanel {
|
||||
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
|
||||
}
|
||||
.frame(width: 176)
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(trip.title).trekEyebrowOnPhoto()
|
||||
Text(today.dayLabel).font(.system(size: 24, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { BigWeather(weather: weather) }
|
||||
}
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 10)
|
||||
Spacer(minLength: 10)
|
||||
GlassPanel {
|
||||
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 5, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pieces
|
||||
|
||||
private extension Text {
|
||||
func trekEyebrowOnPhoto() -> some View {
|
||||
self.font(.system(size: 10.5, weight: .bold))
|
||||
.tracking(0.7)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(.white.opacity(0.78))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// The plane in a frosted circle that marks a trip widget.
|
||||
struct PlaneMark: View {
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(.white.opacity(0.22))
|
||||
.overlay(Circle().stroke(.white.opacity(0.35), lineWidth: 0.6))
|
||||
.frame(width: 26, height: 26)
|
||||
.overlay(Image(systemName: "airplane").font(.system(size: 12, weight: .bold)).foregroundStyle(.white).rotationEffect(.degrees(-35)))
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotoPill: View {
|
||||
let symbol: String
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: symbol).font(.system(size: 10, weight: .bold))
|
||||
Text(text).font(.system(size: 11, weight: .semibold)).lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(Color.black.opacity(0.28)))
|
||||
.overlay(Capsule().stroke(Color.white.opacity(0.22), lineWidth: 0.6))
|
||||
}
|
||||
}
|
||||
|
||||
/// Frosted glass over the photo, for lists that need a calm ground.
|
||||
struct GlassPanel<Content: View>: View {
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.black.opacity(0.32))
|
||||
)
|
||||
.overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(Color.white.opacity(0.16), lineWidth: 0.7))
|
||||
}
|
||||
}
|
||||
|
||||
/// Days, places and travellers as three small columns.
|
||||
struct TripFacts: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
var onPhoto = false
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let labels = snapshot.labels
|
||||
HStack(spacing: 0) {
|
||||
if let length = snapshot.tripLength {
|
||||
fact("\(length)", labels.days ?? "", "calendar")
|
||||
}
|
||||
if let places = snapshot.trip?.placeCount, places > 0 {
|
||||
divider
|
||||
fact("\(places)", labels.places ?? "", "mappin")
|
||||
}
|
||||
if let members = snapshot.trip?.memberCount, members > 0 {
|
||||
divider
|
||||
fact("\(members)", labels.travellers ?? "", "person.2.fill")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(onPhoto ? Color.black.opacity(0.28) : Trek.card(scheme)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(onPhoto ? Color.white.opacity(0.18) : Trek.cardBorder(scheme), lineWidth: 0.7))
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
Rectangle().fill(onPhoto ? Color.white.opacity(0.2) : Trek.track(scheme)).frame(width: 0.7, height: 26)
|
||||
}
|
||||
|
||||
private func fact(_ value: String, _ label: String, _ symbol: String) -> some View {
|
||||
VStack(spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: symbol).font(.system(size: 10, weight: .bold)).opacity(0.75)
|
||||
Text(value).font(Trek.number(17, .heavy))
|
||||
}
|
||||
Text(label).font(.system(size: 9.5, weight: .bold)).textCase(.uppercase).tracking(0.4).opacity(0.7).lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(onPhoto ? .white : Trek.ink(scheme))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// The first booking ahead, as a strip under the trip.
|
||||
struct NextBookingStrip: View {
|
||||
let booking: WidgetSnapshot.Booking
|
||||
let labels: WidgetSnapshot.Labels
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 9) {
|
||||
KindBadge(kind: booking.kind, size: 28)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(booking.title).font(.system(size: 13, weight: .bold)).lineLimit(1)
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 11, weight: .medium)).opacity(0.75).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 1) {
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day).font(.system(size: 11, weight: .bold)).opacity(0.8)
|
||||
}
|
||||
if let time = booking.time { Text(time).font(Trek.number(14, .heavy)) }
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(Color.black.opacity(0.3)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Color.white.opacity(0.16), lineWidth: 0.7))
|
||||
}
|
||||
}
|
||||
|
||||
/// The weather as the large widget shows it: symbol, temperature, high and low.
|
||||
struct BigWeather: View {
|
||||
let weather: WidgetSnapshot.Weather
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: Trek.weatherSymbol(weather.main))
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.font(.system(size: 26))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("\(weather.temp)°").font(Trek.number(22, .heavy))
|
||||
if let high = weather.high, let low = weather.low {
|
||||
Text("\(high)° / \(low)°").font(Trek.number(10.5, .semibold)).opacity(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's stops as a timeline: a dot in each stop's colour on a thread, the
|
||||
/// ones behind faded, a now marker between the past and the next.
|
||||
struct StopList: View {
|
||||
let stops: [WidgetSnapshot.Stop]
|
||||
let nowMinutes: Int
|
||||
let limit: Int
|
||||
var onPhoto = false
|
||||
let nothing: String
|
||||
var nowLabel: String?
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
if stops.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles").font(.system(size: 15, weight: .semibold))
|
||||
Text(nothing).font(.system(size: 13, weight: .semibold)).lineLimit(2)
|
||||
}
|
||||
.foregroundStyle(ink.opacity(0.85))
|
||||
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
|
||||
} else {
|
||||
let window = visible
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(window.enumerated()), id: \.offset) { offset, stop in
|
||||
let past = (stop.minutes ?? Int.max) < nowMinutes
|
||||
let isNext = !past && (offset == 0 || (window[offset - 1].minutes ?? Int.max) < nowMinutes)
|
||||
if isNext && offset > 0, let nowLabel { nowLine(nowLabel) }
|
||||
row(stop, past: past, isNext: isNext, last: offset == window.count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stops around now: the last one behind, then the ones ahead.
|
||||
private var visible: [WidgetSnapshot.Stop] {
|
||||
let nextIndex = stops.firstIndex { ($0.minutes ?? Int.max) >= nowMinutes } ?? max(stops.count - limit, 0)
|
||||
let first = max(0, min(nextIndex - 1, stops.count - limit))
|
||||
return Array(stops[first..<min(stops.count, first + limit)])
|
||||
}
|
||||
|
||||
private var ink: Color { onPhoto ? .white : Trek.ink(scheme) }
|
||||
|
||||
private func row(_ stop: WidgetSnapshot.Stop, past: Bool, isNext: Bool, last: Bool) -> some View {
|
||||
let color = Color(css: stop.color) ?? (onPhoto ? .white : Trek.info)
|
||||
return HStack(alignment: .top, spacing: 8) {
|
||||
Text(stop.time ?? "–")
|
||||
.font(Trek.number(11.5, isNext ? .heavy : .semibold))
|
||||
.frame(width: 38, alignment: .leading)
|
||||
.padding(.top, 1)
|
||||
VStack(spacing: 0) {
|
||||
ZStack {
|
||||
Circle().fill(color).frame(width: isNext ? 11 : 8, height: isNext ? 11 : 8)
|
||||
if isNext { Circle().stroke(color.opacity(0.4), lineWidth: 3).frame(width: 15, height: 15) }
|
||||
}
|
||||
.frame(width: 15, height: 15)
|
||||
if !last {
|
||||
Rectangle().fill(ink.opacity(0.22)).frame(width: 1.2).frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(width: 15)
|
||||
HStack(spacing: 5) {
|
||||
if let symbol = stop.symbol {
|
||||
Image(systemName: symbol).font(.system(size: 10, weight: .semibold)).foregroundStyle(color)
|
||||
}
|
||||
Text(stop.name)
|
||||
.font(.system(size: 12.5, weight: isNext ? .bold : .semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.top, 0.5)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.foregroundStyle(ink)
|
||||
.opacity(past ? 0.5 : 1)
|
||||
.frame(height: 24, alignment: .top)
|
||||
}
|
||||
|
||||
private func nowLine(_ label: String) -> some View {
|
||||
HStack(spacing: 5) {
|
||||
Text(label.uppercased()).font(.system(size: 8.5, weight: .heavy)).tracking(0.5).foregroundStyle(Trek.danger)
|
||||
.frame(width: 38, alignment: .leading)
|
||||
Circle().fill(Trek.danger).frame(width: 5, height: 5).frame(width: 15)
|
||||
Rectangle().fill(Trek.danger).frame(height: 1.2)
|
||||
}
|
||||
.frame(height: 9)
|
||||
.padding(.bottom, 3)
|
||||
}
|
||||
}
|
||||
|
||||
/// No trip: the paper, the contour lines, and an invitation.
|
||||
struct EmptyTrip: View {
|
||||
let text: String
|
||||
let family: TrekFamily
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "map.fill").font(.system(size: 13, weight: .bold))
|
||||
Text("TREK").font(.system(size: 15, weight: .heavy)).tracking(1.2)
|
||||
}
|
||||
.foregroundStyle(Trek.ink(scheme))
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "globe.europe.africa.fill")
|
||||
.font(.system(size: family == .small ? 30 : 38, weight: .light))
|
||||
.foregroundStyle(Trek.muted(scheme).opacity(0.6))
|
||||
Text(text)
|
||||
.font(.system(size: family == .small ? 12.5 : 14, weight: .semibold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(3)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
/// The trip on the lock screen: a ring with the days left or the day of the
|
||||
/// trip, a line with the next stop, a word in the status row.
|
||||
struct TripAccessory: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
let trip = snapshot?.trip
|
||||
let today = snapshot?.today
|
||||
let days = max(snapshot?.daysUntilStart(from: now) ?? 0, 0)
|
||||
let nowMinutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
|
||||
let next = today?.stops.first { ($0.minutes ?? -1) >= nowMinutes }
|
||||
switch family {
|
||||
case .circular:
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
|
||||
if let today, let index = today.index, let total = today.total ?? snapshot?.tripLength {
|
||||
Circle().trim(from: 0, to: Double(index) / Double(max(total, 1)))
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: -3) {
|
||||
Text("\(index)").font(Trek.number(20, .heavy))
|
||||
Text("/\(total)").font(Trek.number(9, .bold)).opacity(0.75)
|
||||
}
|
||||
} else if trip != nil {
|
||||
Circle().trim(from: 0, to: 1 - min(Double(days), 60) / 60)
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: -3) {
|
||||
Image(systemName: "airplane").font(.system(size: 9, weight: .bold))
|
||||
Text("\(days)").font(Trek.number(days > 99 ? 15 : 19, .heavy))
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "map.fill").font(.system(size: 20))
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: today == nil ? "airplane" : "mappin.circle.fill").font(.system(size: 11, weight: .bold))
|
||||
Text(trip?.title ?? "TREK").font(.system(size: 13, weight: .bold)).lineLimit(1)
|
||||
}
|
||||
if let today {
|
||||
Text(next.map { "\($0.time ?? "") \($0.name)" } ?? snapshot?.labels.allDone ?? today.dayLabel)
|
||||
.font(.system(size: 13, weight: .semibold)).lineLimit(1)
|
||||
Text(today.dayLabel).font(.system(size: 11, weight: .medium)).opacity(0.75).lineLimit(1)
|
||||
} else if let snapshot, trip != nil {
|
||||
Text(days == 1 ? snapshot.labels.tomorrow : snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: "\(days)"))
|
||||
.font(.system(size: 15, weight: .heavy)).lineLimit(1)
|
||||
if let range = trip.flatMap({ RelativeDay.range($0.start, $0.end) }) {
|
||||
Text(range).font(.system(size: 11, weight: .medium)).opacity(0.75)
|
||||
}
|
||||
} else {
|
||||
Text(snapshot?.labels.noTrip ?? "").font(.system(size: 12, weight: .medium)).lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
default:
|
||||
if let trip, let snapshot {
|
||||
if today != nil, let next {
|
||||
Text("\(Image(systemName: "mappin.circle.fill")) \(next.time ?? "") \(next.name)")
|
||||
} else {
|
||||
Text("\(Image(systemName: "airplane")) \(trip.title), \(snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: "\(days)"))")
|
||||
}
|
||||
} else {
|
||||
Text("\(Image(systemName: "map.fill")) TREK")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,86 @@ import Security
|
||||
/// TrekShell.setWidgetSnapshot into the keychain group the app and its
|
||||
/// extensions share). Texts arrive already translated, so the widget needs no
|
||||
/// localisation of its own and always speaks the language the app does.
|
||||
///
|
||||
/// Everything past `trip`, `today` and `labels` is optional: a binary that is
|
||||
/// newer than the server's UI shows what it gets, an older one ignores the rest.
|
||||
struct WidgetSnapshot: Codable {
|
||||
struct Trip: Codable {
|
||||
var title: String
|
||||
var start: String
|
||||
var end: String
|
||||
var path: String
|
||||
/// The destination in a few words ("Tokyo, Japan").
|
||||
var place: String?
|
||||
/// The trip's cover, a small JPEG as base64, already cropped for a widget.
|
||||
var cover: String?
|
||||
var dayCount: Int?
|
||||
var placeCount: Int?
|
||||
var memberCount: Int?
|
||||
}
|
||||
|
||||
struct Stop: Codable, Hashable {
|
||||
var time: String?
|
||||
var name: String
|
||||
/// Minutes after midnight, for the now line.
|
||||
var minutes: Int?
|
||||
/// The category colour as #rrggbb.
|
||||
var color: String?
|
||||
/// An SF Symbol name for the stop's kind.
|
||||
var symbol: String?
|
||||
}
|
||||
|
||||
struct Today: Codable {
|
||||
var dayLabel: String
|
||||
var stops: [Stop]
|
||||
/// 1-based position of today in the trip.
|
||||
var index: Int?
|
||||
var total: Int?
|
||||
}
|
||||
|
||||
struct Weather: Codable {
|
||||
var temp: Int
|
||||
var high: Int?
|
||||
var low: Int?
|
||||
/// The provider's main condition ("Clear", "Clouds", "Rain", ...).
|
||||
var main: String
|
||||
var label: String
|
||||
var place: String?
|
||||
/// Chance of rain in percent.
|
||||
var rain: Int?
|
||||
}
|
||||
|
||||
struct Booking: Codable, Hashable {
|
||||
var title: String
|
||||
/// flight, train, bus, car, cruise, hotel, restaurant, event, tour, other
|
||||
var kind: String
|
||||
/// yyyy-MM-dd, for the relative day.
|
||||
var date: String?
|
||||
var time: String?
|
||||
/// "FRA → HND", the location or the hotel's nights.
|
||||
var detail: String?
|
||||
var confirmed: Bool
|
||||
var path: String
|
||||
}
|
||||
|
||||
struct Todo: Codable, Hashable {
|
||||
var name: String
|
||||
var due: String?
|
||||
/// 0 none, 1 low, 2 medium, 3 high.
|
||||
var priority: Int
|
||||
}
|
||||
|
||||
struct Todos: Codable {
|
||||
var open: Int
|
||||
var total: Int
|
||||
var items: [Todo]
|
||||
var path: String
|
||||
}
|
||||
|
||||
struct Packing: Codable {
|
||||
var packed: Int
|
||||
var total: Int
|
||||
var path: String
|
||||
}
|
||||
|
||||
struct Labels: Codable {
|
||||
@@ -31,10 +95,31 @@ struct WidgetSnapshot: Codable {
|
||||
var inDays: String
|
||||
var tomorrow: String
|
||||
var nothingPlanned: String
|
||||
var days: String?
|
||||
var dayOf: String?
|
||||
var bookings: String?
|
||||
var noBookings: String?
|
||||
var todos: String?
|
||||
var noTodos: String?
|
||||
var packing: String?
|
||||
var packed: String?
|
||||
var confirmed: String?
|
||||
var pending: String?
|
||||
var todayShort: String?
|
||||
var tomorrowShort: String?
|
||||
var now: String?
|
||||
var places: String?
|
||||
var travellers: String?
|
||||
var allDone: String?
|
||||
var openTrek: String?
|
||||
}
|
||||
|
||||
var trip: Trip?
|
||||
var today: Today?
|
||||
var weather: Weather?
|
||||
var bookings: [Booking]?
|
||||
var todos: Todos?
|
||||
var packing: Packing?
|
||||
var labels: Labels
|
||||
|
||||
static func load() -> WidgetSnapshot? {
|
||||
@@ -52,8 +137,20 @@ struct WidgetSnapshot: Codable {
|
||||
/// Whole days from today to the trip's start, in the device's calendar.
|
||||
func daysUntilStart(from now: Date = Date()) -> Int? {
|
||||
guard let trip, let start = WidgetSnapshot.day(trip.start) else { return nil }
|
||||
return WidgetSnapshot.daysBetween(now, start)
|
||||
}
|
||||
|
||||
/// The trip's length in days, from its dates when the app sent no count.
|
||||
var tripLength: Int? {
|
||||
if let count = trip?.dayCount, count > 0 { return count }
|
||||
guard let trip, let start = WidgetSnapshot.day(trip.start), let end = WidgetSnapshot.day(trip.end),
|
||||
let days = WidgetSnapshot.daysBetween(start, end) else { return nil }
|
||||
return days + 1
|
||||
}
|
||||
|
||||
static func daysBetween(_ from: Date, _ to: Date) -> Int? {
|
||||
let calendar = Calendar.current
|
||||
return calendar.dateComponents([.day], from: calendar.startOfDay(for: now), to: start).day
|
||||
return calendar.dateComponents([.day], from: calendar.startOfDay(for: from), to: calendar.startOfDay(for: to)).day
|
||||
}
|
||||
|
||||
static func day(_ iso: String) -> Date? {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Sources/WidgetPreviews/Widgets/
|
||||
.build/
|
||||
out/
|
||||
@@ -0,0 +1,14 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
// Renders the widget views (client/ios/App/TREKWidgets/*Content.swift and the
|
||||
// files they use) to PNGs on a Mac, so a change to a widget can be looked at
|
||||
// without a phone: .github/workflows/native.yml copies them into Sources/ and
|
||||
// uploads what this writes. Not part of the app.
|
||||
let package = Package(
|
||||
name: "WidgetPreviews",
|
||||
platforms: [.macOS(.v14)],
|
||||
targets: [
|
||||
.executableTarget(name: "WidgetPreviews", path: "Sources/WidgetPreviews"),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Draws every widget in every size, light and dark, for a trip ahead, a trip
|
||||
/// under way and no trip, plus a home screen of them on a wallpaper per scheme.
|
||||
/// Usage: WidgetPreviews <output directory>
|
||||
@main
|
||||
struct Render {
|
||||
@MainActor
|
||||
static func main() throws {
|
||||
let out = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "out", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: out, withIntermediateDirectories: true)
|
||||
// A fixed afternoon, so the now line sits between the stops.
|
||||
var components = Calendar.current.dateComponents([.year, .month, .day], from: Date())
|
||||
components.hour = 12
|
||||
components.minute = 10
|
||||
let now = Calendar.current.date(from: components) ?? Date()
|
||||
|
||||
let states: [(String, WidgetSnapshot)] = [
|
||||
("upcoming", .sample(running: false, now: now)),
|
||||
("running", .sample(running: true, now: now)),
|
||||
("empty", .sampleEmpty),
|
||||
]
|
||||
for scheme in [ColorScheme.light, .dark] {
|
||||
let schemeName = scheme == .dark ? "dark" : "light"
|
||||
for (stateName, snapshot) in states {
|
||||
for kind in WidgetKind.allCases {
|
||||
for family in kind.families {
|
||||
let view = WidgetFrame(kind: kind, family: family, snapshot: snapshot, now: now)
|
||||
.environment(\.colorScheme, scheme)
|
||||
try write(view, to: out.appendingPathComponent("\(kind.rawValue)-\(family.rawValue)-\(stateName)-\(schemeName).png"))
|
||||
}
|
||||
}
|
||||
}
|
||||
for (stateName, snapshot) in states.prefix(2) {
|
||||
let sheet = HomeScreen(snapshot: snapshot, now: now).environment(\.colorScheme, scheme)
|
||||
try write(sheet, to: out.appendingPathComponent("home-\(stateName)-\(schemeName).png"))
|
||||
}
|
||||
let lock = LockScreen(snapshots: states.map(\.1), now: now).environment(\.colorScheme, scheme)
|
||||
try write(lock, to: out.appendingPathComponent("lock-\(schemeName).png"))
|
||||
}
|
||||
print("wrote \(try FileManager.default.contentsOfDirectory(atPath: out.path).count) images to \(out.path)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func write<V: View>(_ view: V, to url: URL) throws {
|
||||
let renderer = ImageRenderer(content: view)
|
||||
renderer.scale = 3
|
||||
guard let image = renderer.nsImage,
|
||||
let tiff = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiff),
|
||||
let png = bitmap.representation(using: .png, properties: [:]) else {
|
||||
throw NSError(domain: "WidgetPreviews", code: 1, userInfo: [NSLocalizedDescriptionKey: "could not render \(url.lastPathComponent)"])
|
||||
}
|
||||
try png.write(to: url)
|
||||
}
|
||||
}
|
||||
|
||||
enum WidgetKind: String, CaseIterable {
|
||||
case trip, today, bookings, todos
|
||||
|
||||
var families: [TrekFamily] {
|
||||
switch self {
|
||||
case .trip: return [.small, .medium, .large, .circular, .rectangular, .inline]
|
||||
case .today: return [.medium, .large]
|
||||
case .bookings: return [.small, .medium, .large, .rectangular, .inline]
|
||||
case .todos: return [.small, .medium, .large, .circular, .rectangular]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One widget as the home screen draws it: its background, the 16 pt content
|
||||
/// margin, the rounded corners; accessories white on a lock-screen tint.
|
||||
struct WidgetFrame: View {
|
||||
let kind: WidgetKind
|
||||
let family: TrekFamily
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
let size = family.previewSize
|
||||
if family.isAccessory {
|
||||
content
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: size.width, height: size.height)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: family == .circular ? size.width / 2 : 12, style: .continuous)
|
||||
.fill(Color.white.opacity(family == .inline ? 0 : 0.14))
|
||||
)
|
||||
.padding(6)
|
||||
.background(Color(hex: 0x1E2A3A))
|
||||
} else {
|
||||
content
|
||||
.padding(16)
|
||||
.frame(width: size.width, height: size.height)
|
||||
.background(background)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch kind {
|
||||
case .trip: TripContent(snapshot: snapshot, family: family, now: now)
|
||||
case .today: TodayContent(snapshot: snapshot, family: family, now: now)
|
||||
case .bookings: BookingsContent(snapshot: snapshot, family: family, now: now)
|
||||
case .todos: TodosContent(snapshot: snapshot, family: family, now: now)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var background: some View {
|
||||
if kind == .trip && snapshot.trip != nil {
|
||||
TripBackdrop(trip: snapshot.trip)
|
||||
} else {
|
||||
TrekPaper()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A home screen of the widgets on an iPhone-sized wallpaper.
|
||||
struct HomeScreen: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 22) {
|
||||
HStack(spacing: 22) {
|
||||
WidgetFrame(kind: .trip, family: .small, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .small, snapshot: snapshot, now: now)
|
||||
}
|
||||
WidgetFrame(kind: snapshot.today == nil ? .trip : .today, family: .medium, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .bookings, family: .medium, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .trip, family: .large, snapshot: snapshot, now: now)
|
||||
HStack(spacing: 22) {
|
||||
WidgetFrame(kind: .bookings, family: .small, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .small, snapshot: snapshot, now: now)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
.padding(.horizontal, 24)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: scheme == .dark ? [Color(hex: 0x1D2440), Color(hex: 0x0B0F1E)] : [Color(hex: 0xC9D8F0), Color(hex: 0xF3E3D3)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The lock-screen accessories in a row per state.
|
||||
struct LockScreen: View {
|
||||
let snapshots: [WidgetSnapshot]
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
ForEach(Array(snapshots.enumerated()), id: \.offset) { _, snapshot in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
WidgetFrame(kind: .trip, family: .inline, snapshot: snapshot, now: now)
|
||||
HStack(spacing: 10) {
|
||||
WidgetFrame(kind: .trip, family: .circular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .circular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .trip, family: .rectangular, snapshot: snapshot, now: now)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
WidgetFrame(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .rectangular, snapshot: snapshot, now: now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(Color(hex: 0x1E2A3A))
|
||||
}
|
||||
}
|
||||
@@ -1,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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PackingItem, Reservation, TodoItem } from '../types';
|
||||
import { bookingsAhead, packingSummary, stopLook, temperature, todosSummary, tripPlace } from './widgetExtras';
|
||||
|
||||
const reservation = (over: Partial<Reservation>): Reservation =>
|
||||
({ id: 1, trip_id: 1, title: 'Booking', status: 'confirmed', type: 'other', ...over }) as Reservation;
|
||||
|
||||
const todo = (over: Partial<TodoItem>): TodoItem =>
|
||||
({
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: 'Task',
|
||||
category: null,
|
||||
checked: 0,
|
||||
sort_order: 0,
|
||||
due_date: null,
|
||||
description: null,
|
||||
assigned_user_id: null,
|
||||
priority: 0,
|
||||
...over,
|
||||
}) as TodoItem;
|
||||
|
||||
describe('bookingsAhead', () => {
|
||||
const days = new Map([[7, '2026-11-09']]);
|
||||
const time = (t: string) => `T${t}`;
|
||||
|
||||
it('lists the bookings from today on, soonest first, with their kind, legs and status', () => {
|
||||
const list = bookingsAhead(
|
||||
[
|
||||
reservation({ id: 1, title: 'Old', reservation_time: '2026-11-01T10:00' }),
|
||||
reservation({
|
||||
id: 2,
|
||||
title: 'Dinner',
|
||||
type: 'restaurant',
|
||||
reservation_time: '2026-11-08T19:30',
|
||||
status: 'pending',
|
||||
location: 'Pontocho',
|
||||
}),
|
||||
reservation({
|
||||
id: 3,
|
||||
title: 'LH 716',
|
||||
type: 'flight',
|
||||
reservation_time: '2026-11-08T13:05',
|
||||
endpoints: [
|
||||
{
|
||||
role: 'to',
|
||||
sequence: 1,
|
||||
name: 'Tokyo Haneda',
|
||||
code: 'HND',
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
timezone: null,
|
||||
local_time: null,
|
||||
local_date: null,
|
||||
},
|
||||
{
|
||||
role: 'from',
|
||||
sequence: 0,
|
||||
name: 'Frankfurt',
|
||||
code: 'FRA',
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
timezone: null,
|
||||
local_time: null,
|
||||
local_date: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
reservation({ id: 4, title: 'Museum', type: 'activity', day_id: 7 }),
|
||||
reservation({ id: 5, title: 'Undated' }),
|
||||
reservation({ id: 6, title: 'Cancelled', reservation_time: '2026-11-08T08:00', status: 'cancelled' }),
|
||||
reservation({ id: 7, title: 'Staged', reservation_time: '2026-11-08T08:00', ingest_state: 'staged' }),
|
||||
],
|
||||
{ dayDates: days, today: '2026-11-06', tripPath: '/trips/1', formatTime: time }
|
||||
);
|
||||
expect(list.map((b) => b.title)).toEqual(['LH 716', 'Dinner', 'Museum', 'Undated']);
|
||||
expect(list[0]).toEqual({
|
||||
title: 'LH 716',
|
||||
kind: 'flight',
|
||||
date: '2026-11-08',
|
||||
time: 'T13:05',
|
||||
detail: 'FRA → HND',
|
||||
confirmed: true,
|
||||
path: '/trips/1?tab=transports',
|
||||
});
|
||||
expect(list[1]).toMatchObject({
|
||||
kind: 'restaurant',
|
||||
detail: 'Pontocho',
|
||||
confirmed: false,
|
||||
path: '/trips/1?tab=buchungen',
|
||||
});
|
||||
expect(list[2]).toMatchObject({ kind: 'tour', date: '2026-11-09', time: null });
|
||||
expect(list[3]).toMatchObject({ kind: 'other', date: null });
|
||||
});
|
||||
|
||||
it('stops at the limit', () => {
|
||||
const many = Array.from({ length: 8 }, (_, i) => reservation({ id: i, reservation_time: `2026-11-1${i}T10:00` }));
|
||||
expect(
|
||||
bookingsAhead(many, { dayDates: new Map(), today: '2026-11-06', tripPath: '/trips/1', formatTime: (t) => t })
|
||||
).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('todosSummary', () => {
|
||||
it('counts the open ones and orders them by due date, then priority', () => {
|
||||
const summary = todosSummary(
|
||||
[
|
||||
todo({ id: 1, name: 'Done', checked: 1 }),
|
||||
todo({ id: 2, name: 'Someday', priority: 3 }),
|
||||
todo({ id: 3, name: 'Soon', due_date: '2026-11-08' }),
|
||||
todo({ id: 4, name: 'First', due_date: '2026-11-07T00:00:00' }),
|
||||
todo({ id: 5, name: 'Low', priority: 1 }),
|
||||
],
|
||||
'/trips/1?tab=listen'
|
||||
);
|
||||
expect(summary.open).toBe(4);
|
||||
expect(summary.total).toBe(5);
|
||||
expect(summary.items.map((i) => i.name)).toEqual(['First', 'Soon', 'Someday', 'Low']);
|
||||
expect(summary.items[0]).toEqual({ name: 'First', due: '2026-11-07', priority: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('packingSummary', () => {
|
||||
it('counts packed against all, and is null without a list', () => {
|
||||
const items = [{ checked: 1 }, { checked: 0 }, { checked: 1 }] as PackingItem[];
|
||||
expect(packingSummary(items, '/p')).toEqual({ packed: 2, total: 3, path: '/p' });
|
||||
expect(packingSummary([], '/p')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tripPlace', () => {
|
||||
it('names the town and country most places share, without postcodes', () => {
|
||||
expect(
|
||||
tripPlace([
|
||||
{ address: '68 Fukakusa Yabunouchicho, Fushimi Ward, 612-0882 Kyoto, Japan' },
|
||||
{ address: 'Nakagyo Ward, 604-8054 Kyoto, Japan' },
|
||||
{ address: '1-1 Chiyoda, 100-8111 Tokyo, Japan' },
|
||||
{ address: null },
|
||||
{ address: 'Nowhere' },
|
||||
])
|
||||
).toBe('Kyoto, Japan');
|
||||
});
|
||||
|
||||
it('is null without usable addresses', () => {
|
||||
expect(tripPlace([{ address: 'Main Street' }, { address: '' }])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopLook and temperature', () => {
|
||||
it('maps a category to a colour and an SF Symbol, ignoring what it cannot use', () => {
|
||||
expect(stopLook({ color: '#E8A13A', icon: 'Coffee' })).toEqual({ color: '#E8A13A', symbol: 'cup.and.saucer.fill' });
|
||||
expect(stopLook({ color: 'red', icon: 'Unknown' })).toEqual({ color: null, symbol: null });
|
||||
expect(stopLook(null)).toEqual({ color: null, symbol: null });
|
||||
});
|
||||
|
||||
it('converts to Fahrenheit only when asked, rounded', () => {
|
||||
expect(temperature(18.4, 'celsius')).toBe(18);
|
||||
expect(temperature(20, 'fahrenheit')).toBe(68);
|
||||
expect(temperature(-0.6, undefined)).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { PackingItem, Place, Reservation, TodoItem } from '../types';
|
||||
import { splitReservationDateTime } from '../utils/formatters';
|
||||
|
||||
/**
|
||||
* The parts of the widget snapshot beyond the trip and today's stops: the
|
||||
* bookings ahead, the to-dos, the packing list, the destination's name, and
|
||||
* how a stop looks (its category's colour and an SF Symbol for its icon).
|
||||
* Pure, so the shapes the widgets get are tested here; the Swift side is in
|
||||
* client/ios/App/TREKWidgets/WidgetSnapshot.swift.
|
||||
*/
|
||||
|
||||
export interface WidgetBooking {
|
||||
title: string;
|
||||
kind: string;
|
||||
date: string | null;
|
||||
time: string | null;
|
||||
detail: string | null;
|
||||
confirmed: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface WidgetTodos {
|
||||
open: number;
|
||||
total: number;
|
||||
items: { name: string; due: string | null; priority: number }[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface WidgetPacking {
|
||||
packed: number;
|
||||
total: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** The lucide icon names categories use (components/shared/categoryIcons.ts), as SF Symbols. */
|
||||
const SYMBOLS: Record<string, string> = {
|
||||
MapPin: 'mappin',
|
||||
Building2: 'building.2.fill',
|
||||
BedDouble: 'bed.double.fill',
|
||||
UtensilsCrossed: 'fork.knife',
|
||||
Utensils: 'fork.knife',
|
||||
Landmark: 'building.columns.fill',
|
||||
ShoppingBag: 'bag.fill',
|
||||
Store: 'storefront.fill',
|
||||
Bus: 'bus.fill',
|
||||
Train: 'tram.fill',
|
||||
Car: 'car.fill',
|
||||
Plane: 'airplane',
|
||||
Ship: 'ferry.fill',
|
||||
Bike: 'bicycle',
|
||||
Activity: 'figure.run',
|
||||
Dumbbell: 'dumbbell.fill',
|
||||
Mountain: 'mountain.2.fill',
|
||||
Tent: 'tent.fill',
|
||||
Anchor: 'sailboat.fill',
|
||||
Coffee: 'cup.and.saucer.fill',
|
||||
Beer: 'mug.fill',
|
||||
Wine: 'wineglass.fill',
|
||||
Camera: 'camera.fill',
|
||||
Music: 'music.note',
|
||||
Theater: 'theatermasks.fill',
|
||||
Ticket: 'ticket.fill',
|
||||
TreePine: 'tree.fill',
|
||||
Waves: 'water.waves',
|
||||
Leaf: 'leaf.fill',
|
||||
Flower2: 'camera.macro',
|
||||
Sun: 'sun.max.fill',
|
||||
Globe: 'globe.europe.africa.fill',
|
||||
Compass: 'safari.fill',
|
||||
Flag: 'flag.fill',
|
||||
Navigation: 'location.north.fill',
|
||||
Map: 'map.fill',
|
||||
Church: 'building.columns.fill',
|
||||
Library: 'books.vertical.fill',
|
||||
Home: 'house.fill',
|
||||
Cross: 'cross.case.fill',
|
||||
Heart: 'heart.fill',
|
||||
Star: 'star.fill',
|
||||
CreditCard: 'creditcard.fill',
|
||||
Wifi: 'wifi',
|
||||
Luggage: 'suitcase.rolling.fill',
|
||||
Backpack: 'backpack.fill',
|
||||
Zap: 'bolt.fill',
|
||||
};
|
||||
|
||||
const HEX = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
/** A stop's colour and symbol from its place's category; nulls when it has none. */
|
||||
export function stopLook(category: { color?: string | null; icon?: string | null } | null | undefined): {
|
||||
color: string | null;
|
||||
symbol: string | null;
|
||||
} {
|
||||
return {
|
||||
color: category?.color && HEX.test(category.color) ? category.color : null,
|
||||
symbol: (category?.icon && SYMBOLS[category.icon]) || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The booking kinds the widget draws; the rest of RESERVATION_TYPES fold into these. */
|
||||
const KINDS: Record<string, string> = {
|
||||
flight: 'flight',
|
||||
train: 'train',
|
||||
transit: 'train',
|
||||
cable_car: 'train',
|
||||
bus: 'bus',
|
||||
car: 'car',
|
||||
taxi: 'car',
|
||||
bicycle: 'car',
|
||||
cruise: 'cruise',
|
||||
ferry: 'cruise',
|
||||
hotel: 'hotel',
|
||||
restaurant: 'restaurant',
|
||||
event: 'event',
|
||||
tour: 'tour',
|
||||
activity: 'tour',
|
||||
};
|
||||
|
||||
function bookingDetail(r: Reservation): string | null {
|
||||
const legs = [...(r.endpoints ?? [])].sort((a, b) => a.sequence - b.sequence);
|
||||
const from = legs.find((e) => e.role === 'from');
|
||||
const to = [...legs].reverse().find((e) => e.role === 'to');
|
||||
if (from && to) return `${from.code || from.name} → ${to.code || to.name}`;
|
||||
return r.location?.trim() || r.accommodation_name || r.place_name || null;
|
||||
}
|
||||
|
||||
/** Transport bookings open on the trip's transports tab, the rest on its bookings tab. */
|
||||
const TRANSPORT_KINDS = new Set(['flight', 'train', 'bus', 'car', 'cruise']);
|
||||
|
||||
/**
|
||||
* The trip's bookings from today on, soonest first: those without a date at
|
||||
* the end, cancelled ones and drafts an import still waits on left out.
|
||||
*/
|
||||
export function bookingsAhead(
|
||||
reservations: Reservation[],
|
||||
{
|
||||
dayDates,
|
||||
today,
|
||||
tripPath,
|
||||
formatTime,
|
||||
limit = 5,
|
||||
}: {
|
||||
/** The trip's day ids to their dates, for bookings placed on a day without a date of their own. */
|
||||
dayDates: Map<number, string>;
|
||||
today: string;
|
||||
tripPath: string;
|
||||
formatTime: (time: string) => string;
|
||||
limit?: number;
|
||||
}
|
||||
): WidgetBooking[] {
|
||||
return reservations
|
||||
.filter((r) => r.status !== 'cancelled' && (r.ingest_state ?? 'live') === 'live')
|
||||
.map((r) => {
|
||||
const { date, time } = splitReservationDateTime(r.reservation_time);
|
||||
const day = date ?? (r.day_id != null ? (dayDates.get(r.day_id) ?? null) : null);
|
||||
return { r, day, time };
|
||||
})
|
||||
.filter(({ day }) => day == null || day >= today)
|
||||
.sort((a, b) => {
|
||||
if (a.day !== b.day) return a.day == null ? 1 : b.day == null ? -1 : a.day.localeCompare(b.day);
|
||||
return (a.time ?? '99:99').localeCompare(b.time ?? '99:99');
|
||||
})
|
||||
.slice(0, limit)
|
||||
.map(({ r, day, time }) => {
|
||||
const kind = KINDS[r.type] ?? 'other';
|
||||
return {
|
||||
title: r.title,
|
||||
kind,
|
||||
date: day,
|
||||
time: time ? formatTime(time) : null,
|
||||
detail: bookingDetail(r),
|
||||
confirmed: r.status === 'confirmed',
|
||||
path: `${tripPath}?tab=${TRANSPORT_KINDS.has(kind) ? 'transports' : 'buchungen'}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Open to-dos first by due date (undated last), then by priority, highest first. */
|
||||
export function todosSummary(items: TodoItem[], path: string, limit = 6): WidgetTodos {
|
||||
const open = items.filter((i) => !i.checked);
|
||||
const sorted = [...open].sort((a, b) => {
|
||||
if (a.due_date !== b.due_date) {
|
||||
if (!a.due_date) return 1;
|
||||
if (!b.due_date) return -1;
|
||||
return a.due_date.localeCompare(b.due_date);
|
||||
}
|
||||
return (b.priority ?? 0) - (a.priority ?? 0) || a.sort_order - b.sort_order;
|
||||
});
|
||||
return {
|
||||
open: open.length,
|
||||
total: items.length,
|
||||
items: sorted.slice(0, limit).map((i) => ({
|
||||
name: i.name,
|
||||
due: i.due_date ? i.due_date.slice(0, 10) : null,
|
||||
priority: Math.max(0, Math.min(3, i.priority ?? 0)),
|
||||
})),
|
||||
path,
|
||||
};
|
||||
}
|
||||
|
||||
/** Packed items against all of them; null without a packing list. */
|
||||
export function packingSummary(items: PackingItem[], path: string): WidgetPacking | null {
|
||||
if (items.length === 0) return null;
|
||||
return { packed: items.filter((i) => i.checked).length, total: items.length, path };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the trip goes, in a few words: the town and country most of its
|
||||
* places share, read off their addresses ("…, 600-8216 Kyoto, Japan").
|
||||
*/
|
||||
export function tripPlace(places: Pick<Place, 'address'>[]): string | null {
|
||||
const counts = new Map<string, number>();
|
||||
for (const { address } of places) {
|
||||
const parts = (address ?? '')
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length < 2) continue;
|
||||
const country = parts[parts.length - 1];
|
||||
const town = parts[parts.length - 2].replace(/\b[\dA-Z]{0,4}[\d][\d\s-]*\b/g, '').trim();
|
||||
if (!town || /\d/.test(country)) continue;
|
||||
const key = `${town}, ${country}`;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
let best: string | null = null;
|
||||
let most = 0;
|
||||
for (const [key, count] of counts) {
|
||||
if (count > most) {
|
||||
best = key;
|
||||
most = count;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Celsius to the user's unit, rounded. */
|
||||
export function temperature(celsius: number, unit: string | undefined): number {
|
||||
return Math.round(unit === 'fahrenheit' ? (celsius * 9) / 5 + 32 : celsius);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { fetchWeather } from '../services/weatherQueue';
|
||||
import { temperature } from './widgetExtras';
|
||||
import type { WidgetWeather } from './widgets';
|
||||
|
||||
/**
|
||||
* The two things the widget snapshot needs from the network: the weather where
|
||||
* the trip is, and the trip's cover as a small JPEG the widget can draw without
|
||||
* a session of its own. Both are best effort: offline or failing, the widget
|
||||
* simply goes without them, and both are kept so a quarter-hourly sync does not
|
||||
* fetch them again.
|
||||
*/
|
||||
|
||||
const WEATHER_TTL_MS = 60 * 60 * 1000;
|
||||
const weatherCache = new Map<string, { at: number; value: WidgetWeather | null }>();
|
||||
|
||||
export async function widgetWeather(
|
||||
{ lat, lng }: { lat: number; lng: number },
|
||||
{ date, lang, unit, place }: { date: string; lang: string; unit: string | undefined; place?: string | null }
|
||||
): Promise<WidgetWeather | null> {
|
||||
const key = `${lat.toFixed(2)},${lng.toFixed(2)},${date},${lang},${unit ?? ''}`;
|
||||
const hit = weatherCache.get(key);
|
||||
if (hit && Date.now() - hit.at < WEATHER_TTL_MS) return hit.value;
|
||||
let value: WidgetWeather | null = null;
|
||||
try {
|
||||
const w = await fetchWeather(lat, lng, date, lang);
|
||||
if (!w.error && Number.isFinite(w.temp)) {
|
||||
value = {
|
||||
temp: temperature(w.temp, unit),
|
||||
...(w.temp_max != null ? { high: temperature(w.temp_max, unit) } : {}),
|
||||
...(w.temp_min != null ? { low: temperature(w.temp_min, unit) } : {}),
|
||||
main: w.main,
|
||||
label: w.description,
|
||||
...(place ? { place } : {}),
|
||||
...(w.precipitation_probability_max != null ? { rain: Math.round(w.precipitation_probability_max) } : {}),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Offline or the provider failed: the widget shows no weather until the next sync.
|
||||
value = null;
|
||||
}
|
||||
weatherCache.set(key, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
|
||||
const COVER_KEY = 'trek.widgetCover';
|
||||
const COVER_MAX_PX = 720;
|
||||
const COVER_MAX_BYTES = 15 * 1024 * 1024;
|
||||
/** Kept under this so the keychain item the widget reads stays small. */
|
||||
const COVER_MAX_BASE64 = 160_000;
|
||||
|
||||
function storedCover(url: string): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(COVER_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as { url?: string; data?: string }) : null;
|
||||
return parsed?.url === url && parsed.data ? parsed.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function storeCover(url: string, data: string): void {
|
||||
try {
|
||||
localStorage.setItem(COVER_KEY, JSON.stringify({ url, data }));
|
||||
} catch {
|
||||
// Storage full: the cover is fetched again next time, nothing else depends on it.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cover at `url` scaled to fit COVER_MAX_PX, as base64 JPEG without the
|
||||
* data: prefix. Null without a cover, offline, or for anything not an image.
|
||||
*/
|
||||
export async function widgetCover(url: string | null | undefined): Promise<string | null> {
|
||||
if (!url) return null;
|
||||
const stored = storedCover(url);
|
||||
if (stored) return stored;
|
||||
try {
|
||||
const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(10_000) });
|
||||
const length = Number(res.headers.get('content-length') ?? 0);
|
||||
if (!res.ok || length > COVER_MAX_BYTES) return null;
|
||||
const blob = await res.blob();
|
||||
if (!blob.type.startsWith('image/') || blob.size > COVER_MAX_BYTES) return null;
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const scale = Math.min(1, COVER_MAX_PX / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(bitmap.width * scale);
|
||||
canvas.height = Math.round(bitmap.height * scale);
|
||||
canvas.getContext('2d')?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
bitmap.close();
|
||||
let data = canvas.toDataURL('image/jpeg', 0.74).split(',')[1] ?? '';
|
||||
if (data.length > COVER_MAX_BASE64) data = canvas.toDataURL('image/jpeg', 0.5).split(',')[1] ?? '';
|
||||
if (!data || data.length > COVER_MAX_BASE64) return null;
|
||||
storeCover(url, data);
|
||||
return data;
|
||||
} catch {
|
||||
// Offline, a timeout or an image the WebView cannot decode: no cover this time.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ vi.mock('./trekShell', () => ({
|
||||
}));
|
||||
|
||||
import { TrekShell } from './trekShell';
|
||||
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState } from './widgets';
|
||||
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState, withBookings } from './widgets';
|
||||
|
||||
const trip = (id: number, start: string | null, end: string | null, archived = 0) =>
|
||||
({ id, title: `Trip ${id}`, start_date: start, end_date: end, is_archived: archived }) as unknown as Trip;
|
||||
@@ -129,3 +129,85 @@ describe('publishWidgets', () => {
|
||||
expect(TrekShell.syncTripDay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the richer snapshot', () => {
|
||||
it('puts the timed bookings of the day between the stops and gives the day its place in the trip', () => {
|
||||
const days = [
|
||||
day(1, 1, '2026-11-06', [{ name: 'Museum', time: '09:00' }, { name: 'Walk' }, { name: 'Dinner', time: '19:30' }]),
|
||||
day(2, 2, '2026-11-07', []),
|
||||
];
|
||||
const plain = todayOf(
|
||||
trip(1, '2026-11-06', '2026-11-07'),
|
||||
days,
|
||||
'2026-11-06',
|
||||
() => 'D',
|
||||
(t) => t
|
||||
);
|
||||
if (!plain) throw new Error('the trip runs today');
|
||||
const today = withBookings(
|
||||
plain,
|
||||
[
|
||||
{ id: 1, trip_id: 1, title: 'Train', type: 'train', status: 'confirmed', reservation_time: '2026-11-06T12:15' },
|
||||
{
|
||||
id: 2,
|
||||
trip_id: 1,
|
||||
title: 'Tomorrow',
|
||||
type: 'flight',
|
||||
status: 'confirmed',
|
||||
reservation_time: '2026-11-07T08:00',
|
||||
},
|
||||
{ id: 3, trip_id: 1, title: 'Gone', type: 'event', status: 'cancelled', reservation_time: '2026-11-06T10:00' },
|
||||
] as never,
|
||||
'2026-11-06',
|
||||
(t) => t
|
||||
);
|
||||
expect(today.stops.map((s) => s.name)).toEqual(['Museum', 'Walk', 'Train', 'Dinner']);
|
||||
expect(today.stops[2]).toMatchObject({ time: '12:15', minutes: 735, symbol: 'tram.fill' });
|
||||
expect(today).toMatchObject({ index: 1, total: 2 });
|
||||
expect(withBookings(plain, [], '2026-11-06', (t) => t)).toBe(plain);
|
||||
});
|
||||
|
||||
it('carries the extras and leaves out what is empty', () => {
|
||||
const snapshot = buildSnapshot(
|
||||
{ ...trip(1, '2026-11-06', '2026-11-07'), day_count: 2 } as Trip,
|
||||
{
|
||||
tripTitle: 'Trip 1',
|
||||
path: '/trips/1',
|
||||
dayLabel: 'D',
|
||||
index: 1,
|
||||
total: 2,
|
||||
stops: [{ name: 'Tea', time: '10:00', minutes: 600, color: '#2FA37A', symbol: 'leaf.fill' }],
|
||||
},
|
||||
labels,
|
||||
{
|
||||
place: 'Kyoto, Japan',
|
||||
cover: null,
|
||||
placeCount: 12,
|
||||
memberCount: 0,
|
||||
weather: { temp: 18, main: 'Clear', label: 'Sunny' },
|
||||
bookings: [],
|
||||
todos: { open: 1, total: 2, items: [], path: '/trips/1?tab=listen' },
|
||||
packing: null,
|
||||
}
|
||||
);
|
||||
expect(snapshot.trip).toEqual({
|
||||
title: 'Trip 1',
|
||||
start: '2026-11-06',
|
||||
end: '2026-11-07',
|
||||
path: '/trips/1',
|
||||
place: 'Kyoto, Japan',
|
||||
dayCount: 2,
|
||||
placeCount: 12,
|
||||
});
|
||||
expect(snapshot.today).toEqual({
|
||||
dayLabel: 'D',
|
||||
index: 1,
|
||||
total: 2,
|
||||
stops: [{ name: 'Tea', time: '10:00', minutes: 600, color: '#2FA37A', symbol: 'leaf.fill' }],
|
||||
});
|
||||
expect(snapshot.weather).toEqual({ temp: 18, main: 'Clear', label: 'Sunny' });
|
||||
expect(snapshot.bookings).toEqual([]);
|
||||
expect(snapshot.todos?.open).toBe(1);
|
||||
expect(snapshot).not.toHaveProperty('packing');
|
||||
});
|
||||
});
|
||||
|
||||
+175
-11
@@ -1,14 +1,19 @@
|
||||
import type { Day, Trip } from '../types';
|
||||
import type { Day, Reservation, Trip } from '../types';
|
||||
import { splitReservationDateTime } from '../utils/formatters';
|
||||
import { hasNativeFeature } from './capabilities';
|
||||
import { isNativeIos } from './platform';
|
||||
import { reportNativeFailure } from './reportFailure';
|
||||
import { TrekShell } from './trekShell';
|
||||
import { stopLook, type WidgetBooking, type WidgetPacking, type WidgetTodos } from './widgetExtras';
|
||||
|
||||
/**
|
||||
* What the home-screen widget and the travel-day Live Activity show, built from
|
||||
* What the home-screen widgets and the travel-day Live Activity show, built from
|
||||
* the trips this device keeps offline (db/offlineDb) and handed to the app
|
||||
* (TrekShell.setWidgetSnapshot, syncTripDay). The texts go along translated, so
|
||||
* the widget speaks the app's language without localising anything itself.
|
||||
* the widgets speak the app's language without localising anything themselves.
|
||||
* Everything past trip, today and the first six labels is optional on the Swift
|
||||
* side (client/ios/App/TREKWidgets/WidgetSnapshot.swift), so an older app
|
||||
* binary ignores it and a newer one copes without it.
|
||||
*/
|
||||
export interface WidgetLabels {
|
||||
nextTrip: string;
|
||||
@@ -18,26 +23,92 @@ export interface WidgetLabels {
|
||||
inDays: string;
|
||||
tomorrow: string;
|
||||
nothingPlanned: string;
|
||||
days?: string;
|
||||
bookings?: string;
|
||||
noBookings?: string;
|
||||
todos?: string;
|
||||
noTodos?: string;
|
||||
packing?: string;
|
||||
confirmed?: string;
|
||||
pending?: string;
|
||||
todayShort?: string;
|
||||
tomorrowShort?: string;
|
||||
now?: string;
|
||||
places?: string;
|
||||
travellers?: string;
|
||||
allDone?: string;
|
||||
}
|
||||
|
||||
interface WidgetStop {
|
||||
time: string | null;
|
||||
name: string;
|
||||
/** Minutes after midnight, for picking the next stop; not sent to the widget. */
|
||||
/** Minutes after midnight, for picking the next stop and the widget's now line. */
|
||||
minutes: number | null;
|
||||
color?: string | null;
|
||||
symbol?: string | null;
|
||||
}
|
||||
|
||||
interface SnapshotStop {
|
||||
time: string | null;
|
||||
name: string;
|
||||
minutes?: number;
|
||||
color?: string;
|
||||
symbol?: string;
|
||||
}
|
||||
|
||||
export interface WidgetSnapshot {
|
||||
trip: { title: string; start: string; end: string; path: string } | null;
|
||||
today: { dayLabel: string; stops: { time: string | null; name: string }[] } | null;
|
||||
trip: {
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
path: string;
|
||||
place?: string;
|
||||
/** A small JPEG of the cover, base64. */
|
||||
cover?: string;
|
||||
dayCount?: number;
|
||||
placeCount?: number;
|
||||
memberCount?: number;
|
||||
} | null;
|
||||
today: { dayLabel: string; stops: SnapshotStop[]; index?: number; total?: number } | null;
|
||||
weather?: WidgetWeather;
|
||||
bookings?: WidgetBooking[];
|
||||
todos?: WidgetTodos;
|
||||
packing?: WidgetPacking;
|
||||
labels: WidgetLabels;
|
||||
}
|
||||
|
||||
export interface WidgetWeather {
|
||||
temp: number;
|
||||
high?: number;
|
||||
low?: number;
|
||||
/** The provider's main condition ("Clear", "Rain", ...), which the widget turns into a symbol. */
|
||||
main: string;
|
||||
label: string;
|
||||
place?: string;
|
||||
/** Chance of rain in percent. */
|
||||
rain?: number;
|
||||
}
|
||||
|
||||
/** What goes along with the trip beyond its day; each part optional. */
|
||||
interface SnapshotExtras {
|
||||
place?: string | null;
|
||||
cover?: string | null;
|
||||
placeCount?: number;
|
||||
memberCount?: number;
|
||||
weather?: WidgetWeather | null;
|
||||
bookings?: WidgetBooking[];
|
||||
todos?: WidgetTodos;
|
||||
packing?: WidgetPacking | null;
|
||||
}
|
||||
|
||||
export interface TripDay {
|
||||
tripTitle: string;
|
||||
path: string;
|
||||
dayLabel: string;
|
||||
stops: WidgetStop[];
|
||||
/** 1-based position of the day in the trip, and the trip's day count. */
|
||||
index?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export function widgetsActive(): boolean {
|
||||
@@ -63,6 +134,31 @@ export function pickTrip(trips: Trip[], today: string): Trip | null {
|
||||
return upcoming[0]?.trip ?? null;
|
||||
}
|
||||
|
||||
/** The symbols of the booking kinds a day's timeline shows next to its stops. */
|
||||
const RESERVATION_SYMBOLS: Record<string, string> = {
|
||||
flight: 'airplane',
|
||||
train: 'tram.fill',
|
||||
bus: 'bus.fill',
|
||||
car: 'car.fill',
|
||||
cruise: 'ferry.fill',
|
||||
ferry: 'ferry.fill',
|
||||
hotel: 'bed.double.fill',
|
||||
restaurant: 'fork.knife',
|
||||
event: 'ticket.fill',
|
||||
tour: 'figure.hiking',
|
||||
};
|
||||
|
||||
/** Puts each timed booking before the first stop that comes later; untimed stops keep their place. */
|
||||
function mergeByTime(stops: WidgetStop[], booked: WidgetStop[]): WidgetStop[] {
|
||||
const out = [...stops];
|
||||
for (const b of [...booked].sort((x, y) => (x.minutes ?? 0) - (y.minutes ?? 0))) {
|
||||
const at = out.findIndex((s) => s.minutes != null && s.minutes > (b.minutes ?? 0));
|
||||
if (at < 0) out.push(b);
|
||||
else out.splice(at, 0, b);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Today's day of the trip with its stops in plan order, if the trip runs today. */
|
||||
export function todayOf(
|
||||
trip: Trip,
|
||||
@@ -77,28 +173,96 @@ export function todayOf(
|
||||
const day = ordered[index];
|
||||
const stops = [...(day.assignments ?? [])]
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.flatMap((a) =>
|
||||
.flatMap((a): WidgetStop[] =>
|
||||
a.place?.name
|
||||
? [
|
||||
{
|
||||
name: a.place.name,
|
||||
time: a.place.place_time ? formatTime(a.place.place_time.slice(0, 5)) : null,
|
||||
minutes: minutesOf(a.place.place_time),
|
||||
...stopLook(a.place.category),
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
return { tripTitle: trip.title, path: `/trips/${trip.id}`, dayLabel: dayLabel(index + 1, ordered.length), stops };
|
||||
return {
|
||||
tripTitle: trip.title,
|
||||
path: `/trips/${trip.id}`,
|
||||
dayLabel: dayLabel(index + 1, ordered.length),
|
||||
stops,
|
||||
index: index + 1,
|
||||
total: ordered.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSnapshot(trip: Trip | null, day: TripDay | null, labels: WidgetLabels): WidgetSnapshot {
|
||||
/** The day with its timed bookings (a flight, the table tonight) joined to the stops in time order. */
|
||||
export function withBookings(
|
||||
day: TripDay,
|
||||
reservations: Reservation[],
|
||||
today: string,
|
||||
formatTime: (time: string) => string
|
||||
): TripDay {
|
||||
const booked = reservations.flatMap((r): WidgetStop[] => {
|
||||
const { date, time } = splitReservationDateTime(r.reservation_time);
|
||||
if (r.status === 'cancelled' || !time || date !== today) return [];
|
||||
return [
|
||||
{
|
||||
name: r.title,
|
||||
time: formatTime(time),
|
||||
minutes: minutesOf(time),
|
||||
color: null,
|
||||
symbol: RESERVATION_SYMBOLS[r.type] ?? 'bookmark.fill',
|
||||
},
|
||||
];
|
||||
});
|
||||
return booked.length ? { ...day, stops: mergeByTime(day.stops, booked) } : day;
|
||||
}
|
||||
|
||||
/** A stop as the widget gets it: the empty fields left out rather than sent as null. */
|
||||
function snapshotStop({ time, name, minutes, color, symbol }: WidgetStop): SnapshotStop {
|
||||
return {
|
||||
time,
|
||||
name,
|
||||
...(minutes != null ? { minutes } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(symbol ? { symbol } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSnapshot(
|
||||
trip: Trip | null,
|
||||
day: TripDay | null,
|
||||
labels: WidgetLabels,
|
||||
extras: SnapshotExtras = {}
|
||||
): WidgetSnapshot {
|
||||
const snapshot: WidgetSnapshot = {
|
||||
trip: trip
|
||||
? { title: trip.title, start: trip.start_date ?? '', end: trip.end_date ?? '', path: `/trips/${trip.id}` }
|
||||
? {
|
||||
title: trip.title,
|
||||
start: trip.start_date ?? '',
|
||||
end: trip.end_date ?? '',
|
||||
path: `/trips/${trip.id}`,
|
||||
...(extras.place ? { place: extras.place } : {}),
|
||||
...(extras.cover ? { cover: extras.cover } : {}),
|
||||
...(trip.day_count ? { dayCount: trip.day_count } : {}),
|
||||
...(extras.placeCount ? { placeCount: extras.placeCount } : {}),
|
||||
...(extras.memberCount ? { memberCount: extras.memberCount } : {}),
|
||||
}
|
||||
: null,
|
||||
today: day
|
||||
? {
|
||||
dayLabel: day.dayLabel,
|
||||
stops: day.stops.map(snapshotStop),
|
||||
...(day.index ? { index: day.index, total: day.total } : {}),
|
||||
}
|
||||
: null,
|
||||
today: day ? { dayLabel: day.dayLabel, stops: day.stops.map(({ time, name }) => ({ time, name })) } : null,
|
||||
labels,
|
||||
};
|
||||
if (extras.weather) snapshot.weather = extras.weather;
|
||||
if (extras.bookings) snapshot.bookings = extras.bookings;
|
||||
if (extras.todos) snapshot.todos = extras.todos;
|
||||
if (extras.packing) snapshot.packing = extras.packing;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** The Live Activity's state: the first stop whose time is still ahead, else the last. */
|
||||
|
||||
@@ -89,6 +89,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'غدًا',
|
||||
'native.widget.nothingPlanned': 'لا شيء مخطط لليوم',
|
||||
'native.widget.dayDone': 'انتهى كل شيء لليوم',
|
||||
'native.widget.noBookings': 'لا توجد حجوزات قادمة',
|
||||
'native.widget.noTodos': 'لم يتبق شيء',
|
||||
'native.widget.now': 'الآن',
|
||||
'native.widget.travellers': 'مسافرون',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -91,6 +91,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Sabah',
|
||||
'native.widget.nothingPlanned': 'Bu gün üçün plan yoxdur',
|
||||
'native.widget.dayDone': 'Bu gün üçün hər şey bitdi',
|
||||
'native.widget.noBookings': 'Qarşıda bron yoxdur',
|
||||
'native.widget.noTodos': 'Görüləsi iş qalmayıb',
|
||||
'native.widget.now': 'İndi',
|
||||
'native.widget.travellers': 'səyyah',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -91,6 +91,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Amanhã',
|
||||
'native.widget.nothingPlanned': 'Nada planejado para hoje',
|
||||
'native.widget.dayDone': 'Tudo feito por hoje',
|
||||
'native.widget.noBookings': 'Nenhuma reserva pela frente',
|
||||
'native.widget.noTodos': 'Nada pendente',
|
||||
'native.widget.now': 'Agora',
|
||||
'native.widget.travellers': 'viajantes',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -91,6 +91,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Demà',
|
||||
'native.widget.nothingPlanned': 'Res planificat per avui',
|
||||
'native.widget.dayDone': 'Tot fet per avui',
|
||||
'native.widget.noBookings': 'Cap reserva pendent',
|
||||
'native.widget.noTodos': 'No queda res per fer',
|
||||
'native.widget.now': 'Ara',
|
||||
'native.widget.travellers': 'viatgers',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -91,6 +91,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Zítra',
|
||||
'native.widget.nothingPlanned': 'Na dnešek nic naplánováno',
|
||||
'native.widget.dayDone': 'Na dnešek hotovo',
|
||||
'native.widget.noBookings': 'Žádné nadcházející rezervace',
|
||||
'native.widget.noTodos': 'Vše hotovo',
|
||||
'native.widget.now': 'Teď',
|
||||
'native.widget.travellers': 'cestovatelů',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -92,6 +92,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Morgen',
|
||||
'native.widget.nothingPlanned': 'Für heute nichts geplant',
|
||||
'native.widget.dayDone': 'Für heute alles geschafft',
|
||||
'native.widget.noBookings': 'Keine anstehenden Buchungen',
|
||||
'native.widget.noTodos': 'Alles erledigt',
|
||||
'native.widget.now': 'Jetzt',
|
||||
'native.widget.travellers': 'Reisende',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -91,6 +91,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Tomorrow',
|
||||
'native.widget.nothingPlanned': 'Nothing planned for today',
|
||||
'native.widget.dayDone': 'All done for today',
|
||||
'native.widget.noBookings': 'No bookings ahead',
|
||||
'native.widget.noTodos': 'Nothing left to do',
|
||||
'native.widget.now': 'Now',
|
||||
'native.widget.travellers': 'travellers',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -92,6 +92,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Mañana',
|
||||
'native.widget.nothingPlanned': 'Nada planeado para hoy',
|
||||
'native.widget.dayDone': 'Todo hecho por hoy',
|
||||
'native.widget.noBookings': 'No hay reservas próximas',
|
||||
'native.widget.noTodos': 'No queda nada pendiente',
|
||||
'native.widget.now': 'Ahora',
|
||||
'native.widget.travellers': 'viajeros',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -91,6 +91,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Homme',
|
||||
'native.widget.nothingPlanned': 'Tänaseks pole midagi planeeritud',
|
||||
'native.widget.dayDone': 'Tänaseks kõik tehtud',
|
||||
'native.widget.noBookings': 'Eesootavaid broneeringuid pole',
|
||||
'native.widget.noTodos': 'Kõik on tehtud',
|
||||
'native.widget.now': 'Praegu',
|
||||
'native.widget.travellers': 'reisijat',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -92,6 +92,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Demain',
|
||||
'native.widget.nothingPlanned': 'Rien de prévu aujourd’hui',
|
||||
'native.widget.dayDone': 'Tout est fait pour aujourd’hui',
|
||||
'native.widget.noBookings': 'Aucune réservation à venir',
|
||||
'native.widget.noTodos': 'Plus rien à faire',
|
||||
'native.widget.now': 'Maintenant',
|
||||
'native.widget.travellers': 'voyageurs',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -94,6 +94,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Αύριο',
|
||||
'native.widget.nothingPlanned': 'Τίποτα προγραμματισμένο για σήμερα',
|
||||
'native.widget.dayDone': 'Όλα έγιναν για σήμερα',
|
||||
'native.widget.noBookings': 'Καμία επερχόμενη κράτηση',
|
||||
'native.widget.noTodos': 'Δεν απομένει τίποτα',
|
||||
'native.widget.now': 'Τώρα',
|
||||
'native.widget.travellers': 'ταξιδιώτες',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -89,6 +89,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'מחר',
|
||||
'native.widget.nothingPlanned': 'אין תוכניות להיום',
|
||||
'native.widget.dayDone': 'הכול הושלם להיום',
|
||||
'native.widget.noBookings': 'אין הזמנות קרובות',
|
||||
'native.widget.noTodos': 'לא נשאר מה לעשות',
|
||||
'native.widget.now': 'עכשיו',
|
||||
'native.widget.travellers': 'מטיילים',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -93,6 +93,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Holnap',
|
||||
'native.widget.nothingPlanned': 'Mára nincs semmi tervezve',
|
||||
'native.widget.dayDone': 'Mára minden kész',
|
||||
'native.widget.noBookings': 'Nincs közelgő foglalás',
|
||||
'native.widget.noTodos': 'Minden kész',
|
||||
'native.widget.now': 'Most',
|
||||
'native.widget.travellers': 'utazó',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -92,6 +92,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Besok',
|
||||
'native.widget.nothingPlanned': 'Tidak ada rencana hari ini',
|
||||
'native.widget.dayDone': 'Semua selesai hari ini',
|
||||
'native.widget.noBookings': 'Tidak ada pemesanan mendatang',
|
||||
'native.widget.noTodos': 'Semua beres',
|
||||
'native.widget.now': 'Sekarang',
|
||||
'native.widget.travellers': 'pelancong',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -92,6 +92,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Domani',
|
||||
'native.widget.nothingPlanned': 'Niente in programma oggi',
|
||||
'native.widget.dayDone': 'Tutto fatto per oggi',
|
||||
'native.widget.noBookings': 'Nessuna prenotazione in arrivo',
|
||||
'native.widget.noTodos': 'Niente da fare',
|
||||
'native.widget.now': 'Adesso',
|
||||
'native.widget.travellers': 'viaggiatori',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -92,6 +92,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': '明日',
|
||||
'native.widget.nothingPlanned': '今日の予定はありません',
|
||||
'native.widget.dayDone': '今日の予定はすべて完了',
|
||||
'native.widget.noBookings': '今後の予約はありません',
|
||||
'native.widget.noTodos': 'やることはありません',
|
||||
'native.widget.now': '現在',
|
||||
'native.widget.travellers': '旅行者',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -90,6 +90,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': '내일',
|
||||
'native.widget.nothingPlanned': '오늘은 계획이 없습니다',
|
||||
'native.widget.dayDone': '오늘 일정 완료',
|
||||
'native.widget.noBookings': '예정된 예약이 없습니다',
|
||||
'native.widget.noTodos': '남은 할 일이 없습니다',
|
||||
'native.widget.now': '지금',
|
||||
'native.widget.travellers': '여행자',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -91,6 +91,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Morgen',
|
||||
'native.widget.nothingPlanned': 'Niets gepland voor vandaag',
|
||||
'native.widget.dayDone': 'Alles gedaan voor vandaag',
|
||||
'native.widget.noBookings': 'Geen komende boekingen',
|
||||
'native.widget.noTodos': 'Niets meer te doen',
|
||||
'native.widget.now': 'Nu',
|
||||
'native.widget.travellers': 'reizigers',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -91,6 +91,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Jutro',
|
||||
'native.widget.nothingPlanned': 'Na dziś nic nie zaplanowano',
|
||||
'native.widget.dayDone': 'Na dziś wszystko zrobione',
|
||||
'native.widget.noBookings': 'Brak nadchodzących rezerwacji',
|
||||
'native.widget.noTodos': 'Wszystko zrobione',
|
||||
'native.widget.now': 'Teraz',
|
||||
'native.widget.travellers': 'podróżnych',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -92,6 +92,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Завтра',
|
||||
'native.widget.nothingPlanned': 'На сегодня ничего не запланировано',
|
||||
'native.widget.dayDone': 'На сегодня всё',
|
||||
'native.widget.noBookings': 'Предстоящих бронирований нет',
|
||||
'native.widget.noTodos': 'Всё сделано',
|
||||
'native.widget.now': 'Сейчас',
|
||||
'native.widget.travellers': 'путешественников',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -91,6 +91,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Zajtra',
|
||||
'native.widget.nothingPlanned': 'Na dnes nič naplánované',
|
||||
'native.widget.dayDone': 'Na dnes hotovo',
|
||||
'native.widget.noBookings': 'Žiadne nadchádzajúce rezervácie',
|
||||
'native.widget.noTodos': 'Všetko hotové',
|
||||
'native.widget.now': 'Teraz',
|
||||
'native.widget.travellers': 'cestovateľov',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -92,6 +92,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'I morgon',
|
||||
'native.widget.nothingPlanned': 'Inget planerat i dag',
|
||||
'native.widget.dayDone': 'Allt klart för i dag',
|
||||
'native.widget.noBookings': 'Inga kommande bokningar',
|
||||
'native.widget.noTodos': 'Inget kvar att göra',
|
||||
'native.widget.now': 'Nu',
|
||||
'native.widget.travellers': 'resenärer',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -89,6 +89,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'พรุ่งนี้',
|
||||
'native.widget.nothingPlanned': 'วันนี้ไม่มีแผน',
|
||||
'native.widget.dayDone': 'วันนี้เสร็จหมดแล้ว',
|
||||
'native.widget.noBookings': 'ไม่มีการจองที่กำลังจะมาถึง',
|
||||
'native.widget.noTodos': 'ไม่มีอะไรค้างแล้ว',
|
||||
'native.widget.now': 'ตอนนี้',
|
||||
'native.widget.travellers': 'ผู้เดินทาง',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -92,6 +92,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Yarın',
|
||||
'native.widget.nothingPlanned': 'Bugün için plan yok',
|
||||
'native.widget.dayDone': 'Bugünlük her şey tamam',
|
||||
'native.widget.noBookings': 'Yaklaşan rezervasyon yok',
|
||||
'native.widget.noTodos': 'Yapılacak bir şey kalmadı',
|
||||
'native.widget.now': 'Şimdi',
|
||||
'native.widget.travellers': 'gezgin',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -91,6 +91,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Завтра',
|
||||
'native.widget.nothingPlanned': 'На сьогодні нічого не заплановано',
|
||||
'native.widget.dayDone': 'На сьогодні все',
|
||||
'native.widget.noBookings': 'Майбутніх бронювань немає',
|
||||
'native.widget.noTodos': 'Усе зроблено',
|
||||
'native.widget.now': 'Зараз',
|
||||
'native.widget.travellers': 'мандрівників',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -92,6 +92,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': 'Ngày mai',
|
||||
'native.widget.nothingPlanned': 'Hôm nay không có kế hoạch',
|
||||
'native.widget.dayDone': 'Hôm nay đã xong',
|
||||
'native.widget.noBookings': 'Không có đặt chỗ sắp tới',
|
||||
'native.widget.noTodos': 'Không còn việc gì',
|
||||
'native.widget.now': 'Bây giờ',
|
||||
'native.widget.travellers': 'du khách',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -89,6 +89,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': '明天',
|
||||
'native.widget.nothingPlanned': '今天沒有安排',
|
||||
'native.widget.dayDone': '今天的行程已全部完成',
|
||||
'native.widget.noBookings': '沒有即將到來的預訂',
|
||||
'native.widget.noTodos': '沒有待辦事項',
|
||||
'native.widget.now': '現在',
|
||||
'native.widget.travellers': '旅客',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
@@ -89,6 +89,10 @@ const native: TranslationStrings = {
|
||||
'native.widget.tomorrow': '明天',
|
||||
'native.widget.nothingPlanned': '今天没有安排',
|
||||
'native.widget.dayDone': '今天的行程已全部完成',
|
||||
'native.widget.noBookings': '没有即将到来的预订',
|
||||
'native.widget.noTodos': '没有待办事项',
|
||||
'native.widget.now': '现在',
|
||||
'native.widget.travellers': '旅行者',
|
||||
};
|
||||
|
||||
export default native;
|
||||
|
||||
Reference in New Issue
Block a user