mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user