From deabaf73983d113cd0c1a53e0143532826ff54cc Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 6 Nov 2026 20:54:16 +0100 Subject: [PATCH] chore(native): home and lock screen mockups of the widgets in the preview renderer --- .github/workflows/native.yml | 2 +- .../Sources/WidgetPreviews/Mockups.swift | 363 ++++++++++++++++++ .../Sources/WidgetPreviews/Render.swift | 2 + 3 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 client/ios/WidgetPreviews/Sources/WidgetPreviews/Mockups.swift diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 6b04eaf55..326bc99d6 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -152,7 +152,7 @@ jobs: mkdir -p Sources/WidgetPreviews/Widgets cp ../App/TREKWidgets/WidgetSnapshot.swift ../App/TREKWidgets/TrekStyle.swift ../App/TREKWidgets/TrekFamily.swift ../App/TREKWidgets/WorldGrid.swift \ ../App/TREKWidgets/*Content.swift Sources/WidgetPreviews/Widgets/ - swift run -c release WidgetPreviews out + swift run -c release WidgetPreviews out "$PWD/../App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png" - uses: actions/upload-artifact@v6 with: diff --git a/client/ios/WidgetPreviews/Sources/WidgetPreviews/Mockups.swift b/client/ios/WidgetPreviews/Sources/WidgetPreviews/Mockups.swift new file mode 100644 index 000000000..6f191e2f2 --- /dev/null +++ b/client/ios/WidgetPreviews/Sources/WidgetPreviews/Mockups.swift @@ -0,0 +1,363 @@ +import AppKit +import SwiftUI + +/// Home and lock screens of an iPhone filled with the widgets, at the size of +/// an iPhone 16 Pro (402 by 874 points), for showing what the widgets look like +/// where they live. The app icon comes from the asset catalogue. +enum Mockups { + @MainActor + static func render(to out: URL, iconPath: String?, now: Date) throws { + let icon = iconPath.flatMap { NSImage(contentsOfFile: $0) }.map { Image(nsImage: $0) } + var upcoming = WidgetSnapshot.sample(running: false, now: now) + upcoming.trip?.cover = Render.sampleCover() + var running = WidgetSnapshot.sample(running: true, now: now) + running.trip?.cover = upcoming.trip?.cover + + let pages: [(String, ColorScheme, AnyView)] = [ + ("1-travelling", .dark, AnyView(PhonePage(wallpaper: .night, icon: icon, page: 0) { + WidgetFrame(kind: .trip, family: .large, snapshot: running, now: now) + WidgetFrame(kind: .today, family: .medium, snapshot: running, now: now) + AppRow(icon: icon, apps: [.maps, .photos, .camera, .weather]) + })), + ("2-countdown", .light, AnyView(PhonePage(wallpaper: .dawn, icon: icon, page: 1) { + WidgetFrame(kind: .trip, family: .medium, snapshot: upcoming, now: now) + HStack(spacing: 24) { + WidgetFrame(kind: .bookings, family: .small, snapshot: upcoming, now: now) + WidgetFrame(kind: .packing, family: .small, snapshot: upcoming, now: now) + } + WidgetFrame(kind: .todos, family: .medium, snapshot: upcoming, now: now) + AppRow(icon: icon, apps: [.calendar, .trek, .mail, .notes]) + })), + ("3-explorer", .dark, AnyView(PhonePage(wallpaper: .ocean, icon: icon, page: 2) { + WidgetFrame(kind: .atlas, family: .large, snapshot: upcoming, now: now) + WidgetFrame(kind: .collab, family: .medium, snapshot: running, now: now) + AppRow(icon: icon, apps: [.photos, .trek, .music, .settings]) + })), + ("4-getting-ready", .light, AnyView(PhonePage(wallpaper: .sand, icon: icon, page: 3) { + WidgetFrame(kind: .packing, family: .large, snapshot: upcoming, now: now) + HStack(spacing: 24) { + WidgetFrame(kind: .todos, family: .small, snapshot: upcoming, now: now) + WidgetFrame(kind: .atlas, family: .small, snapshot: upcoming, now: now) + } + AppRow(icon: icon, apps: [.wallet, .trek, .maps, .clock]) + })), + ("5-on-the-road", .dark, AnyView(PhonePage(wallpaper: .night, icon: icon, page: 4) { + HStack(spacing: 24) { + WidgetFrame(kind: .trip, family: .small, snapshot: running, now: now) + WidgetFrame(kind: .collab, family: .small, snapshot: running, now: now) + } + WidgetFrame(kind: .bookings, family: .medium, snapshot: running, now: now) + HStack(spacing: 24) { + WidgetFrame(kind: .bookings, family: .small, snapshot: running, now: now) + WidgetFrame(kind: .atlas, family: .small, snapshot: running, now: now) + } + AppRow(icon: icon, apps: [.maps, .trek, .camera, .weather]) + })), + ("6-lock-screen", .dark, AnyView(LockPage(snapshot: running, now: now))), + ] + for (name, scheme, view) in pages { + try Render.write( + view.environment(\.colorScheme, scheme).environment(\.trekStaticPreview, true), + to: out.appendingPathComponent("mockup-\(name).png") + ) + } + } +} + +// MARK: - The phone + +enum Wallpaper { + case night, dawn, ocean, sand + + @ViewBuilder var view: some View { + switch self { + case .night: + ZStack { + LinearGradient(colors: [Color(hex: 0x0E1230), Color(hex: 0x1B1340), Color(hex: 0x05060C)], startPoint: .top, endPoint: .bottom) + RadialGradient(colors: [Color(hex: 0x6A3FD8).opacity(0.75), .clear], center: UnitPoint(x: 0.15, y: 0.25), startRadius: 0, endRadius: 360) + RadialGradient(colors: [Color(hex: 0x1E8FD8).opacity(0.6), .clear], center: UnitPoint(x: 0.9, y: 0.7), startRadius: 0, endRadius: 380) + } + case .dawn: + ZStack { + LinearGradient(colors: [Color(hex: 0xF6D5C3), Color(hex: 0xE9B8C9), Color(hex: 0xB9C6EE)], startPoint: .top, endPoint: .bottom) + RadialGradient(colors: [Color(hex: 0xFFE2A8).opacity(0.9), .clear], center: UnitPoint(x: 0.8, y: 0.15), startRadius: 0, endRadius: 320) + RadialGradient(colors: [Color(hex: 0x8FA8F0).opacity(0.7), .clear], center: UnitPoint(x: 0.1, y: 0.85), startRadius: 0, endRadius: 360) + } + case .ocean: + ZStack { + LinearGradient(colors: [Color(hex: 0x03263A), Color(hex: 0x064D5C), Color(hex: 0x021018)], startPoint: .topLeading, endPoint: .bottomTrailing) + RadialGradient(colors: [Color(hex: 0x22C7A9).opacity(0.55), .clear], center: UnitPoint(x: 0.85, y: 0.2), startRadius: 0, endRadius: 340) + RadialGradient(colors: [Color(hex: 0x2E5BD8).opacity(0.55), .clear], center: UnitPoint(x: 0.1, y: 0.75), startRadius: 0, endRadius: 360) + } + case .sand: + ZStack { + LinearGradient(colors: [Color(hex: 0xEFE6D8), Color(hex: 0xE3D2BC), Color(hex: 0xCFD9D4)], startPoint: .top, endPoint: .bottom) + RadialGradient(colors: [Color(hex: 0xF4B183).opacity(0.65), .clear], center: UnitPoint(x: 0.2, y: 0.2), startRadius: 0, endRadius: 330) + RadialGradient(colors: [Color(hex: 0x9CC5B8).opacity(0.7), .clear], center: UnitPoint(x: 0.9, y: 0.8), startRadius: 0, endRadius: 360) + } + } + } +} + +/// One page of the home screen: status bar, the widgets and apps, the page +/// dots and the dock, on the wallpaper, with the screen's rounded corners. +struct PhonePage: View { + let wallpaper: Wallpaper + let icon: Image? + let page: Int + @ViewBuilder var content: () -> Content + + var body: some View { + ZStack(alignment: .top) { + wallpaper.view + VStack(spacing: 0) { + StatusBar(dark: wallpaper == .night || wallpaper == .ocean) + VStack(spacing: 22) { content() } + .padding(.top, 14) + Spacer(minLength: 0) + PageDots(count: 5, current: page) + .padding(.bottom, 12) + Dock(icon: icon) + .padding(.horizontal, 12) + .padding(.bottom, 14) + } + } + .frame(width: 402, height: 874) + .clipShape(RoundedRectangle(cornerRadius: 56, style: .continuous)) + .padding(30) + .background(Color(hex: 0x111214)) + } +} + +struct StatusBar: View { + let dark: Bool + + var body: some View { + HStack { + Text("9:41").font(.system(size: 17, weight: .semibold)) + .frame(width: 120) + Spacer() + Capsule().fill(.black).frame(width: 124, height: 36) + Spacer() + HStack(spacing: 6) { + Image(systemName: "cellularbars") + Image(systemName: "wifi") + Image(systemName: "battery.100percent") + } + .font(.system(size: 15, weight: .semibold)) + .frame(width: 120) + } + .foregroundStyle(dark ? .white : .black) + .padding(.top, 12) + .frame(height: 58) + } +} + +struct PageDots: View { + let count: Int + let current: Int + + var body: some View { + HStack(spacing: 8) { + ForEach(0..= minutes } ?? stops.last + let done = stops.filter { ($0.minutes ?? Int.max) < minutes }.count + HStack(spacing: 14) { + ZStack { + Circle().stroke(Color.white.opacity(0.18), lineWidth: 5) + Circle().trim(from: 0, to: stops.isEmpty ? 0 : Double(done) / Double(stops.count)) + .stroke(Trek.confirmed, 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(snapshot.trip?.title ?? "").font(.system(size: 11, weight: .bold)).tracking(0.5).textCase(.uppercase).foregroundStyle(.white.opacity(0.65)).lineLimit(1) + Spacer(minLength: 4) + Text(today?.dayLabel ?? "").font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)) + } + HStack(alignment: .firstTextBaseline) { + Text(next?.name ?? "").font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1) + Spacer(minLength: 4) + Text(next?.time ?? "").font(Trek.number(18, .heavy)).foregroundStyle(.white) + } + DaySegments(index: done + 1, total: max(stops.count, 1), color: Trek.confirmed, track: Color.white.opacity(0.2)) + .padding(.top, 3) + } + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 26, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.78))) + } +} diff --git a/client/ios/WidgetPreviews/Sources/WidgetPreviews/Render.swift b/client/ios/WidgetPreviews/Sources/WidgetPreviews/Render.swift index b9a196423..d83c05a84 100644 --- a/client/ios/WidgetPreviews/Sources/WidgetPreviews/Render.swift +++ b/client/ios/WidgetPreviews/Sources/WidgetPreviews/Render.swift @@ -52,6 +52,8 @@ struct Render { let lock = LockScreen(snapshots: states.prefix(3).map(\.1), now: now).environment(\.colorScheme, scheme) try write(lock, to: out.appendingPathComponent("lock-\(schemeName).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)") }