feat(native): fuller large widgets, the live activity in the widgets' look

- The large trip and today widgets put the stops right under the day and the
  next booking at their foot instead of an empty middle; the large to-dos
  widget names its two rings with their counts
- The trip and day for a travel day as a Live Activity: a progress ring, the
  next stop large, the stops as segments, the Dynamic Island in the same green
- The place shows with a location arrow, and rows link through RowLink, which
  the preview renderer draws without the Link it cannot render
This commit is contained in:
Maurice
2026-11-06 19:06:18 +01:00
parent b1037dec62
commit 71d014af95
7 changed files with 180 additions and 44 deletions
@@ -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 {
+10 -3
View File
@@ -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.
+36 -2
View File
@@ -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
@@ -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<Label: View>: 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<Label: View>: View {
let value: Double
+13 -7
View File
@@ -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))
}
}
@@ -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))
}
}
@@ -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)