diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index bb2fe5a13..09f91df91 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -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 diff --git a/client/ios/App/App.xcodeproj/project.pbxproj b/client/ios/App/App.xcodeproj/project.pbxproj index 92edde628..2f4b1d126 100644 --- a/client/ios/App/App.xcodeproj/project.pbxproj +++ b/client/ios/App/App.xcodeproj/project.pbxproj @@ -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 = ""; }; C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NextTripWidget.swift; sourceTree = ""; }; DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = ""; }; + 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekStyle.swift; sourceTree = ""; }; + A030A06726542A2D7F61C54B /* TrekFamily.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekFamily.swift; sourceTree = ""; }; + DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripContent.swift; sourceTree = ""; }; + 24B175DE91E1E6E850DB0285 /* TodayContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayContent.swift; sourceTree = ""; }; + E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = ""; }; + AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodosContent.swift; sourceTree = ""; }; FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; /* 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; }; diff --git a/client/ios/App/TREKWidgets/BookingsContent.swift b/client/ios/App/TREKWidgets/BookingsContent.swift new file mode 100644 index 000000000..fb20f9ec5 --- /dev/null +++ b/client/ios/App/TREKWidgets/BookingsContent.swift @@ -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) + } +} diff --git a/client/ios/App/TREKWidgets/NextTripWidget.swift b/client/ios/App/TREKWidgets/NextTripWidget.swift index e77f3615f..3512f5105 100644 --- a/client/ios/App/TREKWidgets/NextTripWidget.swift +++ b/client/ios/App/TREKWidgets/NextTripWidget.swift @@ -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) -> 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) -> 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: 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) } +} diff --git a/client/ios/App/TREKWidgets/TREKWidgetsBundle.swift b/client/ios/App/TREKWidgets/TREKWidgetsBundle.swift index 3db6222e0..18bc9a271 100644 --- a/client/ios/App/TREKWidgets/TREKWidgetsBundle.swift +++ b/client/ios/App/TREKWidgets/TREKWidgetsBundle.swift @@ -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() } } diff --git a/client/ios/App/TREKWidgets/TodayContent.swift b/client/ios/App/TREKWidgets/TodayContent.swift new file mode 100644 index 000000000..36d94e910 --- /dev/null +++ b/client/ios/App/TREKWidgets/TodayContent.swift @@ -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() + } + } +} diff --git a/client/ios/App/TREKWidgets/TodosContent.swift b/client/ios/App/TREKWidgets/TodosContent.swift new file mode 100644 index 000000000..293290ac9 --- /dev/null +++ b/client/ios/App/TREKWidgets/TodosContent.swift @@ -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) + } + } +} diff --git a/client/ios/App/TREKWidgets/TrekFamily.swift b/client/ios/App/TREKWidgets/TrekFamily.swift new file mode 100644 index 000000000..6b3f3cc25 --- /dev/null +++ b/client/ios/App/TREKWidgets/TrekFamily.swift @@ -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 + } +} diff --git a/client/ios/App/TREKWidgets/TrekStyle.swift b/client/ios/App/TREKWidgets/TrekStyle.swift new file mode 100644 index 000000000..b97d5f5e6 --- /dev/null +++ b/client/ios/App/TREKWidgets/TrekStyle.swift @@ -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: 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) + } +} diff --git a/client/ios/App/TREKWidgets/TripContent.swift b/client/ios/App/TREKWidgets/TripContent.swift new file mode 100644 index 000000000..e665fca19 --- /dev/null +++ b/client/ios/App/TREKWidgets/TripContent.swift @@ -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: 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.. 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") + } + } + } +} diff --git a/client/ios/App/TREKWidgets/WidgetSnapshot.swift b/client/ios/App/TREKWidgets/WidgetSnapshot.swift index 54789ee8f..45a4d90c2 100644 --- a/client/ios/App/TREKWidgets/WidgetSnapshot.swift +++ b/client/ios/App/TREKWidgets/WidgetSnapshot.swift @@ -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? { diff --git a/client/ios/WidgetPreviews/.gitignore b/client/ios/WidgetPreviews/.gitignore new file mode 100644 index 000000000..2b041ee47 --- /dev/null +++ b/client/ios/WidgetPreviews/.gitignore @@ -0,0 +1,3 @@ +Sources/WidgetPreviews/Widgets/ +.build/ +out/ diff --git a/client/ios/WidgetPreviews/Package.swift b/client/ios/WidgetPreviews/Package.swift new file mode 100644 index 000000000..ee7d84a78 --- /dev/null +++ b/client/ios/WidgetPreviews/Package.swift @@ -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"), + ] +) diff --git a/client/ios/WidgetPreviews/Sources/WidgetPreviews/Render.swift b/client/ios/WidgetPreviews/Sources/WidgetPreviews/Render.swift new file mode 100644 index 000000000..30569887b --- /dev/null +++ b/client/ios/WidgetPreviews/Sources/WidgetPreviews/Render.swift @@ -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 +@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(_ 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)) + } +} diff --git a/client/src/components/Layout/NativeWidgetsSync.tsx b/client/src/components/Layout/NativeWidgetsSync.tsx index 3062c21f6..ff7e125d9 100644 --- a/client/src/components/Layout/NativeWidgetsSync.tsx +++ b/client/src/components/Layout/NativeWidgetsSync.tsx @@ -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; } diff --git a/client/src/native/widgetExtras.test.ts b/client/src/native/widgetExtras.test.ts new file mode 100644 index 000000000..30bc1bcb4 --- /dev/null +++ b/client/src/native/widgetExtras.test.ts @@ -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 => + ({ id: 1, trip_id: 1, title: 'Booking', status: 'confirmed', type: 'other', ...over }) as Reservation; + +const todo = (over: Partial): 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); + }); +}); diff --git a/client/src/native/widgetExtras.ts b/client/src/native/widgetExtras.ts new file mode 100644 index 000000000..710c60c0b --- /dev/null +++ b/client/src/native/widgetExtras.ts @@ -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 = { + 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 = { + 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; + 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[]): string | null { + const counts = new Map(); + 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); +} diff --git a/client/src/native/widgetMedia.ts b/client/src/native/widgetMedia.ts new file mode 100644 index 000000000..3ea289216 --- /dev/null +++ b/client/src/native/widgetMedia.ts @@ -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(); + +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 { + 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 { + 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; + } +} diff --git a/client/src/native/widgets.test.ts b/client/src/native/widgets.test.ts index d47b25df8..9fc79cbf4 100644 --- a/client/src/native/widgets.test.ts +++ b/client/src/native/widgets.test.ts @@ -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'); + }); +}); diff --git a/client/src/native/widgets.ts b/client/src/native/widgets.ts index 6590ea16a..95e5996ae 100644 --- a/client/src/native/widgets.ts +++ b/client/src/native/widgets.ts @@ -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 = { + 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. */ diff --git a/shared/src/i18n/ar/native.ts b/shared/src/i18n/ar/native.ts index 3f010b3c7..9a9bcbef5 100644 --- a/shared/src/i18n/ar/native.ts +++ b/shared/src/i18n/ar/native.ts @@ -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; diff --git a/shared/src/i18n/az/native.ts b/shared/src/i18n/az/native.ts index e341a8638..14aed3523 100644 --- a/shared/src/i18n/az/native.ts +++ b/shared/src/i18n/az/native.ts @@ -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; diff --git a/shared/src/i18n/br/native.ts b/shared/src/i18n/br/native.ts index 1fefd02e2..be792d42e 100644 --- a/shared/src/i18n/br/native.ts +++ b/shared/src/i18n/br/native.ts @@ -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; diff --git a/shared/src/i18n/ca/native.ts b/shared/src/i18n/ca/native.ts index e83fea149..0eb8f33ce 100644 --- a/shared/src/i18n/ca/native.ts +++ b/shared/src/i18n/ca/native.ts @@ -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; diff --git a/shared/src/i18n/cs/native.ts b/shared/src/i18n/cs/native.ts index 71e0feaf2..b14f97830 100644 --- a/shared/src/i18n/cs/native.ts +++ b/shared/src/i18n/cs/native.ts @@ -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; diff --git a/shared/src/i18n/de/native.ts b/shared/src/i18n/de/native.ts index 2ede2c96e..845b9f736 100644 --- a/shared/src/i18n/de/native.ts +++ b/shared/src/i18n/de/native.ts @@ -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; diff --git a/shared/src/i18n/en/native.ts b/shared/src/i18n/en/native.ts index a18ea2194..0f322baa3 100644 --- a/shared/src/i18n/en/native.ts +++ b/shared/src/i18n/en/native.ts @@ -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; diff --git a/shared/src/i18n/es/native.ts b/shared/src/i18n/es/native.ts index 3e362472b..a014d51f8 100644 --- a/shared/src/i18n/es/native.ts +++ b/shared/src/i18n/es/native.ts @@ -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; diff --git a/shared/src/i18n/et/native.ts b/shared/src/i18n/et/native.ts index 5df69255f..f840644a5 100644 --- a/shared/src/i18n/et/native.ts +++ b/shared/src/i18n/et/native.ts @@ -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; diff --git a/shared/src/i18n/fr/native.ts b/shared/src/i18n/fr/native.ts index ce7c71376..355ad8f10 100644 --- a/shared/src/i18n/fr/native.ts +++ b/shared/src/i18n/fr/native.ts @@ -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; diff --git a/shared/src/i18n/gr/native.ts b/shared/src/i18n/gr/native.ts index 0b814fa2e..79b2cc350 100644 --- a/shared/src/i18n/gr/native.ts +++ b/shared/src/i18n/gr/native.ts @@ -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; diff --git a/shared/src/i18n/he/native.ts b/shared/src/i18n/he/native.ts index 4d3aacec9..1f1767074 100644 --- a/shared/src/i18n/he/native.ts +++ b/shared/src/i18n/he/native.ts @@ -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; diff --git a/shared/src/i18n/hu/native.ts b/shared/src/i18n/hu/native.ts index 6094fecfc..2f080b442 100644 --- a/shared/src/i18n/hu/native.ts +++ b/shared/src/i18n/hu/native.ts @@ -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; diff --git a/shared/src/i18n/id/native.ts b/shared/src/i18n/id/native.ts index b67667526..3a981cbb4 100644 --- a/shared/src/i18n/id/native.ts +++ b/shared/src/i18n/id/native.ts @@ -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; diff --git a/shared/src/i18n/it/native.ts b/shared/src/i18n/it/native.ts index 6e50651b6..55604762f 100644 --- a/shared/src/i18n/it/native.ts +++ b/shared/src/i18n/it/native.ts @@ -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; diff --git a/shared/src/i18n/ja/native.ts b/shared/src/i18n/ja/native.ts index aa9ba5d2f..8e175afbd 100644 --- a/shared/src/i18n/ja/native.ts +++ b/shared/src/i18n/ja/native.ts @@ -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; diff --git a/shared/src/i18n/ko/native.ts b/shared/src/i18n/ko/native.ts index 5dc34ca2b..89f40acf5 100644 --- a/shared/src/i18n/ko/native.ts +++ b/shared/src/i18n/ko/native.ts @@ -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; diff --git a/shared/src/i18n/nl/native.ts b/shared/src/i18n/nl/native.ts index 0157d5b64..88fe84a6b 100644 --- a/shared/src/i18n/nl/native.ts +++ b/shared/src/i18n/nl/native.ts @@ -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; diff --git a/shared/src/i18n/pl/native.ts b/shared/src/i18n/pl/native.ts index febfdb2aa..32e28638c 100644 --- a/shared/src/i18n/pl/native.ts +++ b/shared/src/i18n/pl/native.ts @@ -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; diff --git a/shared/src/i18n/ru/native.ts b/shared/src/i18n/ru/native.ts index 0e9fbf726..54edac2ef 100644 --- a/shared/src/i18n/ru/native.ts +++ b/shared/src/i18n/ru/native.ts @@ -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; diff --git a/shared/src/i18n/sk/native.ts b/shared/src/i18n/sk/native.ts index cc1e2aa81..914eb531b 100644 --- a/shared/src/i18n/sk/native.ts +++ b/shared/src/i18n/sk/native.ts @@ -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; diff --git a/shared/src/i18n/sv/native.ts b/shared/src/i18n/sv/native.ts index ad4b3bfad..9f342c095 100644 --- a/shared/src/i18n/sv/native.ts +++ b/shared/src/i18n/sv/native.ts @@ -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; diff --git a/shared/src/i18n/th/native.ts b/shared/src/i18n/th/native.ts index 43969865a..5b61dbf9b 100644 --- a/shared/src/i18n/th/native.ts +++ b/shared/src/i18n/th/native.ts @@ -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; diff --git a/shared/src/i18n/tr/native.ts b/shared/src/i18n/tr/native.ts index b73212b1d..c846f0118 100644 --- a/shared/src/i18n/tr/native.ts +++ b/shared/src/i18n/tr/native.ts @@ -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; diff --git a/shared/src/i18n/uk/native.ts b/shared/src/i18n/uk/native.ts index f9842f27c..6a34ee6ac 100644 --- a/shared/src/i18n/uk/native.ts +++ b/shared/src/i18n/uk/native.ts @@ -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; diff --git a/shared/src/i18n/vi/native.ts b/shared/src/i18n/vi/native.ts index 3d307bcf1..e2ce2ea60 100644 --- a/shared/src/i18n/vi/native.ts +++ b/shared/src/i18n/vi/native.ts @@ -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; diff --git a/shared/src/i18n/zh-TW/native.ts b/shared/src/i18n/zh-TW/native.ts index 879e28189..7481ebfe4 100644 --- a/shared/src/i18n/zh-TW/native.ts +++ b/shared/src/i18n/zh-TW/native.ts @@ -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; diff --git a/shared/src/i18n/zh/native.ts b/shared/src/i18n/zh/native.ts index 07c2e2ee9..c4bfb4ce6 100644 --- a/shared/src/i18n/zh/native.ts +++ b/shared/src/i18n/zh/native.ts @@ -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;