diff --git a/client/ios/App/TREKWidgets/BookingsContent.swift b/client/ios/App/TREKWidgets/BookingsContent.swift index fb20f9ec5..d5a52f083 100644 --- a/client/ios/App/TREKWidgets/BookingsContent.swift +++ b/client/ios/App/TREKWidgets/BookingsContent.swift @@ -81,7 +81,7 @@ struct BookingsContent: View { 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://")!) { + RowLink(path: booking.path) { if index == 0 && featureFirst { featured(booking, labels) } else { diff --git a/client/ios/App/TREKWidgets/TodayContent.swift b/client/ios/App/TREKWidgets/TodayContent.swift index 36d94e910..b5d3f6238 100644 --- a/client/ios/App/TREKWidgets/TodayContent.swift +++ b/client/ios/App/TREKWidgets/TodayContent.swift @@ -36,13 +36,20 @@ struct TodayContent: View { if family == .large { DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone) .padding(.top, 12) + .padding(.bottom, 14) + } else { + Spacer(minLength: 8) } - Spacer(minLength: family == .large ? 12 : 8) StopList( - stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 8 : 3, + stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 7 : 3, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now ) - if family == .large { Spacer(minLength: 0) } + if family == .large { + Spacer(minLength: 8) + if let booking = snapshot.bookings?.first { + NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false) + } + } } } else if let snapshot, let trip = snapshot.trip { // Before the trip there is no day yet: count down to it instead. diff --git a/client/ios/App/TREKWidgets/TodosContent.swift b/client/ios/App/TREKWidgets/TodosContent.swift index 293290ac9..dba69fd37 100644 --- a/client/ios/App/TREKWidgets/TodosContent.swift +++ b/client/ios/App/TREKWidgets/TodosContent.swift @@ -35,8 +35,9 @@ struct TodosContent: View { } } Divider().overlay(Trek.track(scheme)).padding(.vertical, 10) - items(Array(todos.items.prefix(6)), labels) - Spacer(minLength: 0) + items(Array(todos.items.prefix(7)), labels) + Spacer(minLength: 8) + legend(todos, labels) } } } else { @@ -94,6 +95,17 @@ struct TodosContent: View { .frame(width: size, height: size) } + /// What the two rings stand for, with their counts. + private func legend(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels) -> some View { + HStack(spacing: 8) { + LegendPill(color: Color(hex: 0x5A6CDE), symbol: "checklist", text: labels.todos ?? "", count: "\(todos.total - todos.open)/\(todos.total)") + if let packing = snapshot?.packing { + LegendPill(color: Trek.confirmed, symbol: "suitcase.rolling.fill", text: labels.packing ?? "", count: "\(packing.packed)/\(packing.total)") + } + Spacer(minLength: 0) + } + } + private func header(_ labels: WidgetSnapshot.Labels, _ todos: WidgetSnapshot.Todos) -> some View { VStack(alignment: .leading, spacing: 1) { Text(labels.todos ?? "").trekEyebrow(scheme) @@ -167,6 +179,28 @@ struct TodosContent: View { } } +/// A ring's colour, what it counts, and how far it is. +struct LegendPill: View { + let color: Color + let symbol: String + let text: String + let count: String + @Environment(\.colorScheme) private var scheme + + var body: some View { + HStack(spacing: 5) { + Circle().fill(color).frame(width: 7, height: 7) + Image(systemName: symbol).font(.system(size: 10, weight: .bold)).foregroundStyle(color) + Text(text).font(.system(size: 11, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1) + Text(count).font(Trek.number(11, .heavy)).foregroundStyle(Trek.muted(scheme)) + } + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Capsule().fill(Trek.card(scheme))) + .overlay(Capsule().stroke(Trek.cardBorder(scheme), lineWidth: 0.6)) + } +} + /// The packing list as a bar with the suitcase and the count. struct PackingBar: View { let packing: WidgetSnapshot.Packing diff --git a/client/ios/App/TREKWidgets/TrekStyle.swift b/client/ios/App/TREKWidgets/TrekStyle.swift index b97d5f5e6..901609336 100644 --- a/client/ios/App/TREKWidgets/TrekStyle.swift +++ b/client/ios/App/TREKWidgets/TrekStyle.swift @@ -234,6 +234,35 @@ struct RouteArc: View { // MARK: - Components +private struct StaticPreviewKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + /// Set by the preview renderer, which cannot draw a Link. + var trekStaticPreview: Bool { + get { self[StaticPreviewKey.self] } + set { self[StaticPreviewKey.self] = newValue } + } +} + +/// A row that opens its own place in the app, or just the row in a preview. +struct RowLink: View { + let path: String + @ViewBuilder var label: () -> Label + @Environment(\.trekStaticPreview) private var staticPreview + + var body: some View { + if staticPreview { + label() + } else if let url = WidgetSnapshot.link(path) { + Link(destination: url) { label() } + } else { + label() + } + } +} + /// A ring that fills with progress, a soft track under it. struct ProgressRing: View { let value: Double diff --git a/client/ios/App/TREKWidgets/TripContent.swift b/client/ios/App/TREKWidgets/TripContent.swift index e665fca19..4a0c72bb1 100644 --- a/client/ios/App/TREKWidgets/TripContent.swift +++ b/client/ios/App/TREKWidgets/TripContent.swift @@ -62,7 +62,7 @@ struct TripContent: View { .minimumScaleFactor(0.8) .padding(.top, 3) if let place = trip.place { - Label(place, systemImage: "mappin.and.ellipse") + Label(place, systemImage: "location.fill") .font(.system(size: 11.5, weight: .semibold)) .foregroundStyle(.white.opacity(0.82)) .lineLimit(1) @@ -101,7 +101,7 @@ struct TripContent: View { .lineLimit(2) .minimumScaleFactor(0.75) if let place = trip.place { - Label(place, systemImage: "mappin.and.ellipse") + Label(place, systemImage: "location.fill") .font(.system(size: 12.5, weight: .semibold)) .foregroundStyle(.white.opacity(0.85)) } @@ -212,9 +212,13 @@ struct TripContent: View { 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) + StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 6, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now) + } + .padding(.top, 14) + Spacer(minLength: 8) + if let booking = snapshot.bookings?.first { + NextBookingStrip(booking: booking, labels: snapshot.labels, now: now) } } } @@ -325,6 +329,8 @@ struct NextBookingStrip: View { let booking: WidgetSnapshot.Booking let labels: WidgetSnapshot.Labels let now: Date + var onPhoto = true + @Environment(\.colorScheme) private var scheme var body: some View { HStack(spacing: 9) { @@ -343,11 +349,11 @@ struct NextBookingStrip: View { if let time = booking.time { Text(time).font(Trek.number(14, .heavy)) } } } - .foregroundStyle(.white) + .foregroundStyle(onPhoto ? .white : Trek.ink(scheme)) .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)) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(onPhoto ? Color.black.opacity(0.3) : Trek.card(scheme))) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(onPhoto ? Color.white.opacity(0.16) : Trek.cardBorder(scheme), lineWidth: 0.7)) } } diff --git a/client/ios/App/TREKWidgets/TripDayLiveActivity.swift b/client/ios/App/TREKWidgets/TripDayLiveActivity.swift index 4d8d97a7d..88987be3e 100644 --- a/client/ios/App/TREKWidgets/TripDayLiveActivity.swift +++ b/client/ios/App/TREKWidgets/TripDayLiveActivity.swift @@ -3,53 +3,110 @@ import SwiftUI import WidgetKit /// A travel day on the lock screen and in the Dynamic Island: the next stop and -/// its time, and how much of the day is behind. +/// its time, and how much of the day is behind, in the widgets' look. struct TripDayLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: TripDayAttributes.self) { context in - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(context.attributes.tripTitle).font(.caption.weight(.semibold)).foregroundStyle(.secondary) - Spacer() - Text(context.state.dayLabel).font(.caption.weight(.semibold)).foregroundStyle(.secondary) - } - HStack(alignment: .firstTextBaseline) { - Image(systemName: "mappin.circle.fill") - Text(context.state.nextName).font(.headline).lineLimit(1) - Spacer() - if let time = context.state.nextTime { - Text(time).font(.headline.monospacedDigit()) - } - } - ProgressView(value: Double(context.state.done), total: Double(max(context.state.total, 1))) - } - .padding() - .widgetURL(WidgetSnapshot.link(context.attributes.path)) + TripDayBanner(attributes: context.attributes, state: context.state) + .activityBackgroundTint(Color(hex: 0x0C0C0F).opacity(0.92)) + .activitySystemActionForegroundColor(.white) + .widgetURL(WidgetSnapshot.link(context.attributes.path)) } dynamicIsland: { context in - DynamicIsland { + let state = context.state + return DynamicIsland { DynamicIslandExpandedRegion(.leading) { - Label(context.state.dayLabel, systemImage: "map").font(.caption) + HStack(spacing: 6) { + Image(systemName: "mappin.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(Trek.confirmed) + Text(state.dayLabel).font(.system(size: 13, weight: .bold)).lineLimit(1) + } + .padding(.leading, 4) } DynamicIslandExpandedRegion(.trailing) { - if let time = context.state.nextTime { - Text(time).font(.caption.monospacedDigit()) + if let time = state.nextTime { + Text(time).font(Trek.number(20, .heavy)).padding(.trailing, 4) } } DynamicIslandExpandedRegion(.bottom) { - VStack(alignment: .leading, spacing: 4) { - Text(context.state.nextName).font(.headline).lineLimit(1) - ProgressView(value: Double(context.state.done), total: Double(max(context.state.total, 1))) + VStack(alignment: .leading, spacing: 8) { + Text(state.nextName).font(.system(size: 17, weight: .heavy)).lineLimit(1) + StopProgress(done: state.done, total: state.total) } + .padding(.horizontal, 4) } } compactLeading: { - Image(systemName: "mappin.circle.fill") + Image(systemName: "mappin.circle.fill").foregroundStyle(Trek.confirmed) } compactTrailing: { - Text(context.state.nextTime ?? "\(context.state.done)/\(context.state.total)") - .font(.caption2.monospacedDigit()) + Text(state.nextTime ?? "\(state.done)/\(state.total)") + .font(Trek.number(13, .heavy)) } minimal: { - Image(systemName: "mappin.circle.fill") + ZStack { + Circle().stroke(Color.white.opacity(0.25), lineWidth: 2.5) + Circle() + .trim(from: 0, to: state.total > 0 ? Double(state.done) / Double(state.total) : 0) + .stroke(Trek.confirmed, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .padding(2) } .widgetURL(WidgetSnapshot.link(context.attributes.path)) + .keylineTint(Trek.confirmed) } } } + +/// The lock-screen banner: trip and day on top, the next stop large, the +/// day's stops as segments. +private struct TripDayBanner: View { + let attributes: TripDayAttributes + let state: TripDayAttributes.ContentState + + var body: some View { + HStack(alignment: .center, spacing: 14) { + ZStack { + Circle().stroke(Color.white.opacity(0.18), lineWidth: 5) + Circle() + .trim(from: 0, to: state.total > 0 ? max(0.02, Double(state.done) / Double(state.total)) : 0) + .stroke( + AngularGradient(colors: [Trek.confirmed.opacity(0.7), Trek.confirmed], center: .center), + style: StrokeStyle(lineWidth: 5, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white) + } + .frame(width: 52, height: 52) + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(attributes.tripTitle) + .font(.system(size: 11, weight: .bold)) + .tracking(0.5) + .textCase(.uppercase) + .foregroundStyle(.white.opacity(0.65)) + .lineLimit(1) + Spacer(minLength: 4) + Text(state.dayLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)).lineLimit(1) + } + HStack(alignment: .firstTextBaseline) { + Text(state.nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1) + Spacer(minLength: 4) + if let time = state.nextTime { + Text(time).font(Trek.number(18, .heavy)).foregroundStyle(.white) + } + } + StopProgress(done: state.done, total: state.total).padding(.top, 3) + } + } + .padding(16) + } +} + +/// The day's timed stops as segments, those behind filled. +private struct StopProgress: View { + let done: Int + let total: Int + + var body: some View { + DaySegments(index: done + 1, total: max(total, 1), color: Trek.confirmed, track: Color.white.opacity(0.2)) + } +} diff --git a/client/ios/WidgetPreviews/Sources/WidgetPreviews/Render.swift b/client/ios/WidgetPreviews/Sources/WidgetPreviews/Render.swift index 30569887b..1026d3fdc 100644 --- a/client/ios/WidgetPreviews/Sources/WidgetPreviews/Render.swift +++ b/client/ios/WidgetPreviews/Sources/WidgetPreviews/Render.swift @@ -28,12 +28,15 @@ struct Render { for family in kind.families { let view = WidgetFrame(kind: kind, family: family, snapshot: snapshot, now: now) .environment(\.colorScheme, scheme) + .environment(\.trekStaticPreview, true) 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) + let sheet = HomeScreen(snapshot: snapshot, now: now) + .environment(\.colorScheme, scheme) + .environment(\.trekStaticPreview, true) try write(sheet, to: out.appendingPathComponent("home-\(stateName)-\(schemeName).png")) } let lock = LockScreen(snapshots: states.map(\.1), now: now).environment(\.colorScheme, scheme)