mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
chore(native): home and lock screen mockups of the widgets in the preview renderer
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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<Content: View>: 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..<count, id: \.self) { index in
|
||||
Circle().fill(Color.white.opacity(index == current ? 0.95 : 0.4)).frame(width: 7, height: 7)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Color.black.opacity(0.18)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic app, drawn as a symbol on a gradient, never a real app's icon.
|
||||
enum MockApp {
|
||||
case trek, maps, photos, camera, weather, calendar, mail, notes, music, settings, wallet, clock, phone, messages, browser
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .trek: return "TREK"
|
||||
case .maps: return "Maps"
|
||||
case .photos: return "Photos"
|
||||
case .camera: return "Camera"
|
||||
case .weather: return "Weather"
|
||||
case .calendar: return "Calendar"
|
||||
case .mail: return "Mail"
|
||||
case .notes: return "Notes"
|
||||
case .music: return "Music"
|
||||
case .settings: return "Settings"
|
||||
case .wallet: return "Wallet"
|
||||
case .clock: return "Clock"
|
||||
case .phone: return "Phone"
|
||||
case .messages: return "Messages"
|
||||
case .browser: return "Browser"
|
||||
}
|
||||
}
|
||||
|
||||
var look: (String, [Color], Color) {
|
||||
switch self {
|
||||
case .trek: return ("map.fill", [Color(hex: 0x202024), Color(hex: 0x0C0C0F)], .white)
|
||||
case .maps: return ("map", [Color(hex: 0x6FD08C), Color(hex: 0x2FA37A)], .white)
|
||||
case .photos: return ("camera.macro", [Color(hex: 0xFFFFFF), Color(hex: 0xF2F2F2)], Color(hex: 0xE8A13A))
|
||||
case .camera: return ("camera.fill", [Color(hex: 0xC8C8CC), Color(hex: 0x8E8E93)], Color(hex: 0x2B2B2E))
|
||||
case .weather: return ("cloud.sun.fill", [Color(hex: 0x5AB3F5), Color(hex: 0x2E6FD8)], .white)
|
||||
case .calendar: return ("calendar", [Color(hex: 0xFFFFFF), Color(hex: 0xF4F4F4)], Color(hex: 0xE5484D))
|
||||
case .mail: return ("envelope.fill", [Color(hex: 0x56B4FF), Color(hex: 0x1D7AF2)], .white)
|
||||
case .notes: return ("note.text", [Color(hex: 0xFFE27A), Color(hex: 0xF5C542)], Color(hex: 0x5A4A12))
|
||||
case .music: return ("music.note", [Color(hex: 0xFF6B81), Color(hex: 0xF0304F)], .white)
|
||||
case .settings: return ("gearshape.fill", [Color(hex: 0xA6A6AB), Color(hex: 0x6E6E73)], .white)
|
||||
case .wallet: return ("wallet.pass.fill", [Color(hex: 0x2B2B2E), Color(hex: 0x111113)], Color(hex: 0x7BC96F))
|
||||
case .clock: return ("clock.fill", [Color(hex: 0x2B2B2E), Color(hex: 0x111113)], .white)
|
||||
case .phone: return ("phone.fill", [Color(hex: 0x6FE38A), Color(hex: 0x34C759)], .white)
|
||||
case .messages: return ("message.fill", [Color(hex: 0x6FE38A), Color(hex: 0x34C759)], .white)
|
||||
case .browser: return ("safari.fill", [Color(hex: 0x56B4FF), Color(hex: 0x1D7AF2)], .white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppIcon: View {
|
||||
let app: MockApp
|
||||
let icon: Image?
|
||||
var label = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 5) {
|
||||
Group {
|
||||
if app == .trek, let icon {
|
||||
icon.resizable().scaledToFill()
|
||||
} else {
|
||||
let (symbol, colors, ink) = app.look
|
||||
LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
|
||||
.overlay(Image(systemName: symbol).font(.system(size: 28, weight: .medium)).foregroundStyle(ink))
|
||||
}
|
||||
}
|
||||
.frame(width: 64, height: 64)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.18), radius: 3, y: 1)
|
||||
if label {
|
||||
Text(app.name).font(.system(size: 12, weight: .medium)).foregroundStyle(.white).shadow(color: .black.opacity(0.35), radius: 2)
|
||||
}
|
||||
}
|
||||
.frame(width: 80)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppRow: View {
|
||||
let icon: Image?
|
||||
let apps: [MockApp]
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 13) {
|
||||
ForEach(Array(apps.enumerated()), id: \.offset) { _, app in AppIcon(app: app, icon: icon) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Dock: View {
|
||||
let icon: Image?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 13) {
|
||||
ForEach([MockApp.phone, .browser, .messages, .trek], id: \.name) { app in
|
||||
AppIcon(app: app, icon: icon, label: false)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(RoundedRectangle(cornerRadius: 38, style: .continuous).fill(Color.white.opacity(0.22)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 38, style: .continuous).stroke(Color.white.opacity(0.25), lineWidth: 0.6))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The lock screen
|
||||
|
||||
struct LockPage: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
TripBackdrop(trip: snapshot.trip, shade: 0.35)
|
||||
VStack(spacing: 0) {
|
||||
StatusBar(dark: true)
|
||||
Image(systemName: "lock.fill").font(.system(size: 18, weight: .semibold)).foregroundStyle(.white).padding(.top, 6)
|
||||
WidgetFrame(kind: .trip, family: .inline, snapshot: snapshot, now: now)
|
||||
.padding(.top, 10)
|
||||
Text("9:41")
|
||||
.font(.system(size: 104, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
.padding(.top, -6)
|
||||
HStack(spacing: 10) {
|
||||
WidgetFrame(kind: .trip, family: .circular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .circular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
Spacer(minLength: 0)
|
||||
LiveActivityMock(snapshot: snapshot, now: now)
|
||||
.padding(.horizontal, 12)
|
||||
HStack {
|
||||
LockButton(symbol: "flashlight.off.fill")
|
||||
Spacer()
|
||||
LockButton(symbol: "camera.fill")
|
||||
}
|
||||
.padding(.horizontal, 46)
|
||||
.padding(.top, 22)
|
||||
Capsule().fill(.white).frame(width: 140, height: 5).padding(.top, 20).padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
.frame(width: 402, height: 874)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 56, style: .continuous))
|
||||
.padding(30)
|
||||
.background(Color(hex: 0x111214))
|
||||
}
|
||||
}
|
||||
|
||||
private struct LockButton: View {
|
||||
let symbol: String
|
||||
|
||||
var body: some View {
|
||||
Circle().fill(Color.black.opacity(0.35)).frame(width: 50, height: 50)
|
||||
.overlay(Image(systemName: symbol).font(.system(size: 19)).foregroundStyle(.white))
|
||||
}
|
||||
}
|
||||
|
||||
/// The travel day's Live Activity as the lock screen shows it (the real one is
|
||||
/// TripDayLiveActivity, which needs ActivityKit).
|
||||
struct LiveActivityMock: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
let today = snapshot.today
|
||||
let minutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
|
||||
let stops = today?.stops ?? []
|
||||
let next = stops.first { ($0.minutes ?? -1) >= 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)))
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user