Files
TREK/client/ios/WidgetPreviews/Sources/WidgetPreviews/Render.swift
T
Maurice 5d05363f36 feat(ios): the iPad gets the extra large widgets, no Wallet button, a centred share sheet
The app is one binary for iPhone and iPad, so what was built for the
iPhone has to hold on an iPad too:

- Widgets: the trip, today and bookings widgets offer the iPad's extra large
  size. The trip sets its countdown or its day beside the bookings or the
  day's stops, today sets the day, its progress and weather beside the
  timeline, bookings sets the next one as a card beside the rest. The other
  widgets stay at the sizes they were designed for.
- The widget renderer draws every home-screen size of each widget as a 13"
  iPad, an 11" iPad and an iPad mini give it (a mini's small widget is
  smaller than an iPhone's), an iPad lock screen with the accessories and
  the travel day's Live Activity, and that Live Activity at the iPhone's and
  the iPad's widths. To draw it there, its lock-screen banner takes plain
  values (TripDayBanner, now beside the today widget).
- Apple Wallet: an iPad cannot take a pass. The shell reports the wallet
  feature only where PKPassLibrary and PKAddPassesViewController can add
  one, so the UI shows no "Add to Apple Wallet" button there and hands a
  .pkpass to the share sheet like any other file.
- Share extension: on an iPad it is presented over the full screen instead
  of in a form sheet, and in a window of regular width its card stands
  centred over the dimmed app, without the grabber, instead of rising from
  the bottom edge. A narrow Split View window keeps the iPhone's card.
2026-11-07 23:25:13 +01:00

386 lines
17 KiB
Swift

import AppKit
import SwiftUI
/// Draws every widget in every size, light and dark, for a trip ahead, a trip
/// under way and no trip, plus a home screen of them on a wallpaper per scheme.
/// Usage: WidgetPreviews <output directory>
@main
struct Render {
@MainActor
static func main() throws {
let out = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "out", isDirectory: true)
try FileManager.default.createDirectory(at: out, withIntermediateDirectories: true)
// A fixed afternoon, so the now line sits between the stops.
var components = Calendar.current.dateComponents([.year, .month, .day], from: Date())
components.hour = 12
components.minute = 10
let now = Calendar.current.date(from: components) ?? Date()
var withCover = WidgetSnapshot.sample(running: false, now: now)
withCover.trip?.cover = sampleCover()
var runningCover = WidgetSnapshot.sample(running: true, now: now)
runningCover.trip?.cover = withCover.trip?.cover
let states: [(String, WidgetSnapshot)] = [
("upcoming", .sample(running: false, now: now)),
("running", .sample(running: true, now: now)),
("empty", .sampleEmpty),
("cover", withCover),
("coverrunning", runningCover),
]
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)
.environment(\.trekStaticPreview, true)
try write(view, to: out.appendingPathComponent("\(kind.rawValue)-\(family.rawValue)-\(stateName)-\(schemeName).png"))
}
}
}
let showcase = NewWidgets(snapshot: states[0].1, now: now)
.environment(\.colorScheme, scheme)
.environment(\.trekStaticPreview, true)
try write(showcase, to: out.appendingPathComponent("new-widgets-\(schemeName).png"))
for (stateName, snapshot) in states where stateName != "empty" {
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.prefix(3).map(\.1), now: now).environment(\.colorScheme, scheme)
try write(lock, to: out.appendingPathComponent("lock-\(schemeName).png"))
// The iPad: every home-screen size of each widget as that iPad gives
// them, the extra large included, per kind in one strip.
for pad in TrekPad.allCases {
for (stateName, snapshot) in states where ["upcoming", "coverrunning", "empty"].contains(stateName) {
for kind in WidgetKind.allCases {
let strip = PadStrip(kind: kind, pad: pad, snapshot: snapshot, now: now)
.environment(\.colorScheme, scheme)
.environment(\.trekStaticPreview, true)
try write(strip, to: out.appendingPathComponent("pad-\(pad.rawValue)-\(kind.rawValue)-\(stateName)-\(schemeName).png"), scale: 2)
}
}
}
let padLock = PadLockScreen(snapshot: states[1].1, now: now).environment(\.colorScheme, scheme)
try write(padLock, to: out.appendingPathComponent("pad-lock-\(schemeName).png"), scale: 2)
}
try write(LiveActivityWidths(), to: out.appendingPathComponent("liveactivity-widths.png"))
let args = Array(CommandLine.arguments.dropFirst())
try Mockups.render(to: out, iconPath: args.count > 1 ? args[1] : nil, now: now)
print("wrote \(try FileManager.default.contentsOfDirectory(atPath: out.path).count) images to \(out.path)")
}
/// A stand-in for a trip's cover photo: dusk over layered mountains, as the
/// small JPEG the app would send.
@MainActor
static func sampleCover() -> String? {
let scene = ZStack(alignment: .bottom) {
LinearGradient(colors: [Color(hex: 0x2B3A67), Color(hex: 0xC0617A), Color(hex: 0xF3A65A)], startPoint: .top, endPoint: .bottom)
Circle().fill(Color(hex: 0xFFE2A8)).frame(width: 120, height: 120).offset(x: 140, y: -170).blur(radius: 2)
Ridge(height: 0.55, seed: 3).fill(Color(hex: 0x6B4A6E))
Ridge(height: 0.42, seed: 7).fill(Color(hex: 0x3E2E4F))
Ridge(height: 0.26, seed: 11).fill(Color(hex: 0x1C1726))
}
.frame(width: 720, height: 480)
let renderer = ImageRenderer(content: scene)
renderer.scale = 1
guard let image = renderer.nsImage, let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff),
let jpeg = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.74]) else { return nil }
return jpeg.base64EncodedString()
}
@MainActor
static func write<V: View>(_ view: V, to url: URL, scale: CGFloat = 3) throws {
let renderer = ImageRenderer(content: view)
renderer.scale = scale
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, packing, atlas, collab
var families: [TrekFamily] {
switch self {
case .trip: return [.small, .medium, .large, .extraLarge, .circular, .rectangular, .inline]
case .today: return [.medium, .large, .extraLarge]
case .bookings: return [.small, .medium, .large, .extraLarge, .rectangular, .inline]
case .todos: return [.small, .medium, .large, .circular, .rectangular]
case .packing: return [.small, .medium, .large, .circular, .rectangular]
case .atlas: return [.small, .medium, .large, .circular, .rectangular, .inline]
case .collab: return [.small, .medium, .large, .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
/// The device's size for the family, when it is not the 6.1" iPhone's.
var size: CGSize? = nil
var body: some View {
let size = self.size ?? family.previewSize
if family.isAccessory {
// As the lock screen draws them: white on the wallpaper, no box of their own
// (the circular one gets the system's soft disc).
content
.foregroundStyle(.white)
.frame(width: size.width, height: size.height)
.background(Circle().fill(Color.white.opacity(family == .circular ? 0.14 : 0)))
.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)
case .packing: PackingContent(snapshot: snapshot, family: family, now: now)
case .atlas: AtlasContent(snapshot: snapshot, family: family)
case .collab: CollabContent(snapshot: snapshot, family: family)
}
}
@ViewBuilder
private var background: some View {
if kind == .trip && snapshot.trip != nil {
TripBackdrop(trip: snapshot.trip)
} else {
TrekPaper(contours: kind != .atlas)
}
}
}
/// 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))
}
}
/// A mountain ridge across the bottom of its frame.
struct Ridge: Shape {
let height: CGFloat
let seed: Int
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
let steps = 48
for step in 0...steps {
let x: CGFloat = rect.minX + rect.width * CGFloat(step) / CGFloat(steps)
let phase: CGFloat = CGFloat(step) * 0.35 + CGFloat(seed)
let wave: CGFloat = sin(phase) * 0.06 + sin(phase * 2.7) * 0.035
let y: CGFloat = rect.maxY - rect.height * (height + wave)
path.addLine(to: CGPoint(x: x, y: y))
}
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.closeSubpath()
return path
}
}
/// The packing, atlas and group widgets side by side, the sheet to judge them by.
struct NewWidgets: View {
let snapshot: WidgetSnapshot
let now: Date
@Environment(\.colorScheme) private var scheme
var body: some View {
VStack(spacing: 22) {
HStack(spacing: 22) {
WidgetFrame(kind: .packing, family: .small, snapshot: snapshot, now: now)
WidgetFrame(kind: .atlas, family: .small, snapshot: snapshot, now: now)
}
WidgetFrame(kind: .atlas, family: .medium, snapshot: snapshot, now: now)
WidgetFrame(kind: .packing, family: .medium, snapshot: snapshot, now: now)
WidgetFrame(kind: .collab, family: .medium, snapshot: snapshot, now: now)
HStack(spacing: 22) {
WidgetFrame(kind: .collab, family: .small, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .small, snapshot: snapshot, now: now)
}
WidgetFrame(kind: .atlas, family: .large, snapshot: snapshot, now: now)
WidgetFrame(kind: .packing, family: .large, snapshot: snapshot, now: now)
WidgetFrame(kind: .collab, family: .large, 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
)
)
}
}
/// One widget kind in every home-screen size an iPad gives it, side by side on
/// that iPad's wallpaper, the extra large last.
struct PadStrip: View {
let kind: WidgetKind
let pad: TrekPad
let snapshot: WidgetSnapshot
let now: Date
@Environment(\.colorScheme) private var scheme
var body: some View {
HStack(alignment: .top, spacing: 24) {
ForEach(kind.families.filter { !$0.isAccessory }, id: \.self) { family in
WidgetFrame(kind: kind, family: family, snapshot: snapshot, now: now, size: pad.size(family))
}
}
.padding(28)
.background(
LinearGradient(
colors: scheme == .dark ? [Color(hex: 0x1D2440), Color(hex: 0x0B0F1E)] : [Color(hex: 0xC9D8F0), Color(hex: 0xF3E3D3)],
startPoint: .topLeading, endPoint: .bottomTrailing
)
)
}
}
/// An 11" iPad's lock screen on its side: the clock with the accessories under
/// it on the left, as iPadOS places them, and the travel day's Live Activity.
struct PadLockScreen: View {
let snapshot: WidgetSnapshot
let now: Date
var body: some View {
HStack(alignment: .top, spacing: 0) {
VStack(alignment: .leading, spacing: 14) {
WidgetFrame(kind: .trip, family: .inline, snapshot: snapshot, now: now)
Text(now, format: .dateTime.hour().minute())
.font(.system(size: 96, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
HStack(spacing: 10) {
WidgetFrame(kind: .trip, family: .circular, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .circular, snapshot: snapshot, now: now)
WidgetFrame(kind: .packing, family: .circular, snapshot: snapshot, now: now)
}
WidgetFrame(kind: .trip, family: .rectangular, snapshot: snapshot, now: now)
WidgetFrame(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .rectangular, snapshot: snapshot, now: now)
}
.padding(.leading, 64)
.padding(.top, 70)
Spacer(minLength: 0)
}
.frame(width: 1194, height: 834, alignment: .topLeading)
.overlay(alignment: .bottom) {
LiveActivityCard(width: 500).padding(.bottom, 40)
}
.background(
LinearGradient(colors: [Color(hex: 0x2B3A67), Color(hex: 0x6B4A6E), Color(hex: 0x1C1726)], startPoint: .top, endPoint: .bottom)
)
}
}
/// The travel day's Live Activity as the lock screen tints it.
struct LiveActivityCard: View {
let width: CGFloat
var body: some View {
TripDayBanner(
tripTitle: "Autumn in Kyoto", dayLabel: "Day 3 of 9",
nextName: "Kiyomizu-dera", nextTime: "13:30", done: 2, total: 5
)
.frame(width: width)
.background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.92)))
}
}
/// The Live Activity at the widths an iPhone's and an iPad's lock screen give
/// it, and with a long German day and stop, to see it hold at each.
struct LiveActivityWidths: View {
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ForEach([364, 500, 640] as [CGFloat], id: \.self) { width in
LiveActivityCard(width: width)
}
TripDayBanner(
tripTitle: "Herbst in Kyoto und Nara", dayLabel: "Tag 3 von 9",
nextName: "Kiyomizu-dera und Sannenzaka-Spaziergang", nextTime: "13:30", done: 2, total: 5
)
.frame(width: 364)
.background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.92)))
}
.padding(28)
.background(Color(hex: 0x1E2A3A))
}
}