fix(native): watch screens fit every size and language

Countdowns use the system's short relative style in every language
("через 2 ч", "in 5 Min."), and durations take the watch's language;
a stop on now shows its range, not a countdown. The top-level pages drop
the titles that collided with the scroll indicator, the pages keep the
standard margins, the day header carries a chevron instead of a crowded
icon, the booking page fits on one screen with its reference and seat,
the day timeline opens at the stop that is on, the map is drawn from
above without labels, the bookings list has the trip's light, and the
complication preview adapts to the case size. The sample trip speaks
German and Russian in those languages.
This commit is contained in:
Maurice
2026-11-07 13:44:18 +01:00
parent 92f10531d0
commit 6b5b2f614d
7 changed files with 269 additions and 143 deletions
+14 -4
View File
@@ -40,7 +40,7 @@ struct DaysListView: View {
private func summary(_ day: WatchSnapshot.Day) -> String {
guard let first = day.stops.first else { return L("day.empty") }
return day.stops.count > 1 ? "\(first.name) +\(day.stops.count - 1)" : first.name
return first.booking.map { "\(WatchLook.kindName($0)) \(first.name)" } ?? first.name
}
private func isPast(_ day: WatchSnapshot.Day) -> Bool {
@@ -60,6 +60,10 @@ struct DayView: View {
let day = snapshot.days.indices.contains(index) ? snapshot.days[index] : nil
let isToday = day?.date == TrekDate.iso(now)
let nowMinutes = isToday ? TrekDate.minutesOfDay(now) : (isPastDay(day) ? 24 * 60 : -1)
let stopsOfDay = day?.stops ?? []
// Today opens at the stop that is on or next, with the one before it for context.
let focus = isToday ? stopsOfDay.firstIndex { ($0.endMinutes ?? $0.minutes ?? -1) > nowMinutes } : nil
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 1) {
@@ -87,9 +91,14 @@ struct DayView: View {
)
}
.buttonStyle(.plain)
.id(offset)
}
}
}
.onAppear {
if let focus, focus > 1 { proxy.scrollTo(focus - 1, anchor: .top) }
}
}
.navigationTitle(day?.dateValue.map { WatchFormat.weekday($0) } ?? L("trip.days"))
.containerBackground(WatchLook.tripGradient(snapshot.trip?.title ?? "TREK"), for: .navigation)
}
@@ -219,8 +228,8 @@ struct MiniMap: View {
var body: some View {
let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lng)
// Flat and muted, without points of interest, so the stop's own pin is
// what stands out; Apple's attribution stays in its corner, untouched.
// From above, without street names or points of interest, so the stop's
// own pin is what stands out; Apple's attribution stays in its corner.
Map(initialPosition: .region(MKCoordinateRegion(center: coordinate, latitudinalMeters: 700, longitudinalMeters: 700)), interactionModes: []) {
Annotation(name, coordinate: coordinate, anchor: .center) {
Circle()
@@ -231,7 +240,7 @@ struct MiniMap: View {
}
.annotationTitles(.hidden)
}
.mapStyle(.standard(elevation: .flat, emphasis: .muted, pointsOfInterest: .excludingAll, showsTraffic: false))
.mapStyle(.imagery(elevation: .flat))
.frame(height: 104)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.accessibilityHidden(true)
@@ -358,6 +367,7 @@ struct BookingsListView: View {
}
}
.navigationTitle(L("booking.all"))
.containerBackground(WatchLook.tripGradient(snapshot.trip?.title ?? "TREK"), for: .navigation)
}
private func when(_ booking: WatchSnapshot.Booking) -> String {
+45 -23
View File
@@ -1,42 +1,64 @@
import SwiftUI
/// Screenshot mode only (-TrekDemo faces): every complication in every
/// family, on a dark dial, to review them without setting up a watch face.
/// Screenshot mode only (-TrekDemo faces): every complication in every family
/// it ships in, at the sizes a watch face gives them on this watch, to review
/// them without setting up a face. Circular and corner ones three to a row,
/// rectangular and inline ones across the screen's width.
struct FacesView: View {
let snapshot: WatchSnapshot?
let now: Date
/// A circular complication's diameter, from the screen: 42 pt on 40 mm, 50 on 49 mm.
@State private var dial: CGFloat = 44
var body: some View {
let state = ComplicationState(snapshot, now: now)
ScrollView {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 8) {
caption("circular")
HStack(spacing: 8) {
dial(.circular) { NextStopComplication(state: state, family: .circular, now: now) }
dial(.circular) { CountdownComplication(state: state, family: .circular, now: now) }
dial(.circular) { TripDayComplication(state: state, family: .circular) }
round { NextStopComplication(state: state, family: .circular, now: now) }
round { CountdownComplication(state: state, family: .circular, now: now) }
round { TripDayComplication(state: state, family: .circular) }
}
dial(.rectangular) { NextStopComplication(state: state, family: .rectangular, now: now) }
dial(.rectangular) { CountdownComplication(state: state, family: .rectangular, now: now) }
dial(.rectangular) { TripDayComplication(state: state, family: .rectangular) }
dial(.inline) { NextStopComplication(state: state, family: .inline, now: now) }
dial(.inline) { CountdownComplication(state: state, family: .inline, now: now) }
dial(.inline) { TripDayComplication(state: state, family: .inline) }
caption("corner")
HStack(spacing: 8) {
round { NextStopComplication(state: state, family: .corner, now: now) }
round { CountdownComplication(state: state, family: .corner, now: now) }
round { TripDayComplication(state: state, family: .corner) }
}
caption("rectangular")
wide(56) { NextStopComplication(state: state, family: .rectangular, now: now) }
wide(56) { CountdownComplication(state: state, family: .rectangular, now: now) }
wide(56) { TripDayComplication(state: state, family: .rectangular) }
caption("inline")
wide(18) { NextStopComplication(state: state, family: .inline, now: now) }
wide(18) { CountdownComplication(state: state, family: .inline, now: now) }
wide(18) { TripDayComplication(state: state, family: .inline) }
}
.foregroundStyle(.white)
.tint(Color(hex: 0xFF9F0A))
.frame(maxWidth: .infinity, alignment: .leading)
.onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in
dial = min(50, max(38, (width - 16) / 3))
}
}
}
private func dial<Content: View>(_ family: ComplicationFamily, @ViewBuilder _ content: () -> Content) -> some View {
let size: CGSize = switch family {
case .circular: CGSize(width: 50, height: 50)
case .rectangular: CGSize(width: 170, height: 64)
case .corner: CGSize(width: 40, height: 40)
case .inline: CGSize(width: 170, height: 18)
}
return content()
.frame(width: size.width, height: size.height)
.clipped()
.overlay(RoundedRectangle(cornerRadius: family == .circular ? 25 : 6).stroke(Color.white.opacity(0.08)))
private func caption(_ text: String) -> some View {
Text(text).watchEyebrow(WatchLook.faint)
}
private func round<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
content()
.frame(width: dial, height: dial)
.clipShape(Circle())
}
private func wide<Content: View>(_ height: CGFloat, @ViewBuilder _ content: () -> Content) -> some View {
content()
.frame(maxWidth: .infinity, minHeight: height, maxHeight: height, alignment: .leading)
.padding(.horizontal, 6)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.06)))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
+69 -48
View File
@@ -39,21 +39,11 @@ struct TodayPage: View {
DoneCard(title: L("today.free"), detail: L("today.freeBody"), symbol: "sun.max.fill")
} else {
DoneCard(title: L("today.done"), detail: L("today.doneBody"), symbol: "checkmark.circle.fill")
if let todayIndex {
NavigationLink(value: Route.day(todayIndex)) {
Label(L("today.plan"), systemImage: "list.bullet")
.font(.system(.footnote, design: .rounded).weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.watchCard(cornerRadius: 14)
}
.buttonStyle(.plain)
}
}
}
}
.contentMargins(.horizontal, 5, for: .scrollContent)
.containerBackground(WatchLook.tripGradient(snapshot.trip?.title ?? "TREK"), for: .tabView)
.navigationTitle(L("today.title"))
.toolbar {
if let weather = snapshot.weather {
ToolbarItem(placement: .topBarTrailing) { WeatherBadge(weather: weather) }
@@ -66,19 +56,19 @@ struct TodayPage: View {
private var header: some View {
if case .running(let index, let total) = snapshot.phase(at: now), let todayIndex = snapshot.todayIndex(at: now) {
NavigationLink(value: Route.day(todayIndex)) {
HStack(alignment: .center, spacing: 4) {
VStack(alignment: .leading, spacing: 1) {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 3) {
Text(WatchFormat.dayOf(index, total)).watchEyebrow(.white.opacity(0.85))
Text(snapshot.trip?.title ?? "")
.font(.system(.footnote, design: .rounded).weight(.semibold))
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .heavy))
.foregroundStyle(WatchLook.muted)
.lineLimit(1)
}
Spacer(minLength: 0)
Image(systemName: "list.bullet")
Text(snapshot.trip?.title ?? "")
.font(.system(.footnote, design: .rounded).weight(.semibold))
.foregroundStyle(WatchLook.muted)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -104,23 +94,28 @@ struct FocusCard: View {
Text(L(isNow ? "today.now" : "today.next"))
.watchEyebrow(isNow ? Trek.confirmed : WatchLook.muted)
Spacer(minLength: 0)
if let target = countdownTarget {
Text(WatchFormat.relative(target, now: now))
.font(.system(.caption, design: .rounded).weight(.bold))
.foregroundStyle(isNow ? WatchLook.muted : Trek.confirmed)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
}
Text(stop.name)
.font(.system(.title3, design: .rounded).weight(.bold))
.foregroundStyle(WatchLook.ink)
.lineLimit(2)
.minimumScaleFactor(0.75)
if let times = timeRange {
Text(times)
.font(.system(.footnote, design: .rounded).weight(.medium).monospacedDigit())
.foregroundStyle(WatchLook.muted)
HStack(alignment: .firstTextBaseline, spacing: 4) {
if let times = timeRange {
Text(times)
.font(.system(.footnote, design: .rounded).weight(.medium).monospacedDigit())
.foregroundStyle(WatchLook.muted)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
Spacer(minLength: 2)
if let target = countdownTarget, target > now {
Text(WatchFormat.relative(target, now: now))
.font(.system(.footnote, design: .rounded).weight(.bold))
.foregroundStyle(isNow ? WatchLook.muted : Trek.confirmed)
.lineLimit(1)
.layoutPriority(1)
}
}
if let then {
Divider().overlay(Color.white.opacity(0.12)).padding(.vertical, 2)
@@ -148,8 +143,9 @@ struct FocusCard: View {
/// Next: until it starts. Now: until it ends.
private var countdownTarget: Date? {
let minutes = isNow ? stop.endMinutes : stop.minutes
return minutes.map { TrekDate.at($0, on: now) }
guard !isNow, let minutes = stop.minutes else { return nil }
let start = TrekDate.at(minutes, on: now)
return start.timeIntervalSince(now) >= 60 ? start : nil
}
private var timeRange: String? {
@@ -285,6 +281,7 @@ struct CountdownPage: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
.padding(.horizontal, 5)
.containerBackground(for: .tabView) {
TripBackdrop(trip: widgetTrip(trip), shade: 0.7)
}
@@ -311,12 +308,13 @@ struct BookingPage: View {
var body: some View {
ScrollView {
NavigationLink(value: Route.booking(booking.id)) {
BookingSummary(booking: booking, hour12: hour12)
BookingSummary(booking: booking, hour12: hour12, compactFacts: true)
}
.buttonStyle(.plain)
}
.contentMargins(.horizontal, 5, for: .scrollContent)
.containerBackground(WatchLook.tint(Trek.booking(booking.kind).color), for: .tabView)
.navigationTitle(L("booking.next"))
}
}
@@ -325,6 +323,8 @@ struct BookingSummary: View {
let booking: WatchSnapshot.Booking
let hour12: Bool
var showFacts = true
/// The page's single row of facts rather than all of them.
var compactFacts = false
@Environment(\.trekNow) private var now
var body: some View {
@@ -364,31 +364,34 @@ struct BookingSummary: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
/// The time it starts (or, for a stay under way, ends) and how long until then.
/// The time it starts (or, for a stay under way, ends), its day, and how long until then, in one line.
@ViewBuilder
private var timing: some View {
let running = booking.kind == "hotel" && (booking.start ?? .distantFuture) < now
let moment = running ? booking.end : booking.start
let clock = running ? booking.checkOut ?? booking.endTime : booking.time
if let moment {
HStack(alignment: .firstTextBaseline, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 5) {
if let clock, let minutes = TrekDate.minutes(clock) {
Text(WatchFormat.time(minutes: minutes, hour12: hour12))
.font(.system(.title3, design: .rounded).weight(.bold).monospacedDigit())
.layoutPriority(2)
}
Text(dayLabel(moment))
Text(running ? L("booking.checkOut") : dayLabel(moment))
.font(.system(.caption2, design: .rounded).weight(.semibold))
.foregroundStyle(WatchLook.muted)
Spacer(minLength: 0)
}
if running {
Text(L("booking.checkOut") + " · " + WatchFormat.relative(moment, now: now))
.font(.system(.caption, design: .rounded).weight(.bold))
.foregroundStyle(Trek.booking(booking.kind).color)
} else if moment > now {
Text(WatchFormat.relative(moment, now: now))
.font(.system(.caption, design: .rounded).weight(.bold))
.foregroundStyle(Trek.booking(booking.kind).color.mix(with: .white, by: 0.35))
.lineLimit(1)
Spacer(minLength: 2)
if moment > now {
Text(WatchFormat.relative(moment, now: now))
.font(.system(.caption2, design: .rounded).weight(.bold))
.foregroundStyle(.white)
.lineLimit(1)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(Trek.booking(booking.kind).color.opacity(0.85)))
.layoutPriority(1)
}
}
}
}
@@ -410,9 +413,25 @@ struct BookingSummary: View {
return WatchFormat.day(date)
}
/// On the page one row, the reference and the seat; in the detail all of them.
@ViewBuilder
private var facts: some View {
let tiles = factTiles
if compactFacts {
HStack(spacing: 6) {
if let code = booking.code { FactTile(label: L("booking.ref"), value: code, mono: true) }
if let first = tiles.first {
FactTile(label: first.label, value: first.value)
.frame(maxWidth: booking.code == nil ? .infinity : 64)
}
}
} else {
factsInFull(tiles)
}
}
@ViewBuilder
private func factsInFull(_ tiles: [(label: String, value: String)]) -> some View {
if !tiles.isEmpty {
HStack(spacing: 6) {
ForEach(tiles.prefix(2), id: \.label) { tile in
@@ -492,8 +511,9 @@ struct TripPage: View {
.buttonStyle(.plain)
.foregroundStyle(WatchLook.ink)
}
.contentMargins(.horizontal, 5, for: .scrollContent)
.containerBackground(WatchLook.tripGradient(trip?.title ?? "TREK"), for: .tabView)
.navigationTitle(L("trip.title"))
}
}
@@ -567,8 +587,9 @@ struct ListsPage: View {
}
}
.frame(maxHeight: .infinity)
.padding(.horizontal, 5)
.containerBackground(WatchLook.tint(WatchLook.packing), for: .tabView)
.navigationTitle(L("lists.title"))
}
}
@@ -199,7 +199,7 @@ struct CountdownComplication: View {
/// The single biggest unit: "2h", "35m", "12d".
private func short(_ target: Date) -> String {
let formatter = DateComponentsFormatter()
let formatter = WatchFormat.components()
formatter.unitsStyle = .abbreviated
formatter.maximumUnitCount = 1
let seconds = max(target.timeIntervalSince(now), 60)
+84 -29
View File
@@ -7,7 +7,7 @@ enum WatchSample {
enum Scenario: String {
/// Day 3 of 9, early afternoon.
case running
/// Day 1, the flight out leaves in a few hours.
/// Day 1, the flight out leaves in the afternoon.
case travelday
/// Twelve days before the trip.
case upcoming
@@ -22,52 +22,61 @@ enum WatchSample {
}
let calendar = Calendar.current
let iso: (Int) -> String = { TrekDate.iso(calendar.date(byAdding: .day, value: startOffset + $0, to: now) ?? now) }
// The flight out leaves in the afternoon, so the screenshot mode can run
// every scenario at about one o'clock, the hour the capture sets its clock to.
let stops: [[WatchSnapshot.Stop]] = [
[
stop("LH 716", 13 * 60 + 5, symbol: "airplane", color: nil, booking: "flight", lat: 50.0379, lng: 8.5622, address: "Frankfurt Airport"),
stop("Hotel Kanra Kyoto", 15 * 60, symbol: "bed.double.fill", color: "#8E6CD9", booking: "hotel", lat: 34.9922, lng: 135.7585, address: "190 Kitamachi, Shimogyo Ward, Kyoto"),
stop("LH 716", 15 * 60 + 35, symbol: "airplane", color: nil, booking: "flight", lat: 50.0379, lng: 8.5622, address: "Frankfurt Airport"),
],
[
stop("Arashiyama Bamboo Grove", 9 * 60, symbol: "tree.fill", color: "#2FA37A", lat: 35.0170, lng: 135.6713, address: "Sagaogurayama Tabuchiyamacho, Kyoto"),
stop("Tenryu-ji", 10 * 60 + 30, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0157, lng: 135.6738, address: "68 Sagatenryuji Susukinobabacho, Kyoto"),
stop("Kinkaku-ji", 14 * 60, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0394, lng: 135.7292, address: "1 Kinkakujicho, Kita Ward, Kyoto"),
stop("Shinkansen Nozomi 21", 13 * 60 + 33, symbol: "tram.fill", color: nil, booking: "train", lat: 35.6812, lng: 139.7671, address: "Tokyo Station"),
stop("Hotel Kanra Kyoto", 16 * 60 + 30, symbol: "bed.double.fill", color: nil, booking: "hotel", lat: 34.9922, lng: 135.7585, address: "190 Kitamachi, Shimogyo Ward, Kyoto"),
],
[
stop("Fushimi Inari Taisha", 8 * 60 + 30, end: 10 * 60 + 30, symbol: "building.columns.fill", color: "#D6273B", lat: 34.9671, lng: 135.7727, address: "68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto"),
stop("Nishiki Market", 11 * 60, end: 12 * 60 + 30, symbol: "basket.fill", color: "#E8A13A", lat: 35.0050, lng: 135.7649, address: "Nishikikoji-dori, Nakagyo Ward, Kyoto"),
stop("Kiyomizu-dera", 13 * 60 + 30, end: 15 * 60 + 30, symbol: "leaf.fill", color: "#2FA37A", lat: 34.9949, lng: 135.7850, address: "1-294 Kiyomizu, Higashiyama Ward, Kyoto", note: "Go up the Sannenzaka steps for the view."),
stop("Gion walk", 16 * 60, symbol: "figure.walk", color: "#4A7DDB", lat: 35.0037, lng: 135.7750, address: "Hanamikoji-dori, Higashiyama Ward, Kyoto"),
stop("Kaiseki at Kikunoi", 19 * 60, symbol: "fork.knife", color: nil, booking: "restaurant", lat: 34.9989, lng: 135.7812, address: "459 Shimokawaracho, Higashiyama Ward, Kyoto"),
stop(t("Nishiki Market"), 11 * 60, end: 12 * 60 + 30, symbol: "basket.fill", color: "#E8A13A", lat: 35.0050, lng: 135.7649, address: "Nishikikoji-dori, Nakagyo Ward, Kyoto"),
stop("Kiyomizu-dera", 13 * 60 + 30, end: 15 * 60 + 30, symbol: "leaf.fill", color: "#2FA37A", lat: 34.9949, lng: 135.7850, address: "1-294 Kiyomizu, Higashiyama Ward, Kyoto", note: t("Go up the Sannenzaka steps for the view.")),
stop(t("Gion walk"), 16 * 60, symbol: "figure.walk", color: "#4A7DDB", lat: 35.0037, lng: 135.7750, address: "Hanamikoji-dori, Higashiyama Ward, Kyoto"),
stop(t("Kaiseki at Kikunoi"), 19 * 60, symbol: "fork.knife", color: nil, booking: "restaurant", lat: 34.9989, lng: 135.7812, address: "459 Shimokawaracho, Higashiyama Ward, Kyoto"),
],
[stop("Nara Park", 10 * 60, symbol: "leaf.fill", color: "#2FA37A", lat: 34.6851, lng: 135.8430, address: "Nara"), stop("Todai-ji", 11 * 60 + 30, symbol: "building.columns.fill", color: "#D6273B", lat: 34.6890, lng: 135.8398, address: "406-1 Zoshicho, Nara")],
[stop("Philosopher's Path", 9 * 60 + 30, symbol: "figure.walk", color: "#4A7DDB", lat: 35.0266, lng: 135.7948, address: "Sakyo Ward, Kyoto"), stop("Ginkaku-ji", 11 * 60, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0270, lng: 135.7982, address: "2 Ginkakujicho, Sakyo Ward, Kyoto")],
[stop("Tea ceremony", 10 * 60 + 30, symbol: "ticket.fill", color: nil, booking: "event", lat: 35.0036, lng: 135.7788, address: "Camellia Flower, Higashiyama")],
[stop("Fushimi sake district", 14 * 60, symbol: "wineglass.fill", color: "#8E6CD9", lat: 34.9318, lng: 135.7601, address: "Fushimi Ward, Kyoto")],
[stop("Pontocho alley", 18 * 60 + 30, symbol: "fork.knife", color: "#E8A13A", lat: 35.0050, lng: 135.7708, address: "Pontocho, Nakagyo Ward, Kyoto")],
[stop("Shinkansen to Osaka", 9 * 60 + 12, symbol: "tram.fill", color: nil, booking: "train", lat: 34.9858, lng: 135.7588, address: "Kyoto Station"), stop("LH 741", 13 * 60 + 25, symbol: "airplane", color: nil, booking: "flight", lat: 34.4320, lng: 135.2304, address: "Kansai International Airport")],
[
stop(t("Arashiyama Bamboo Grove"), 9 * 60, symbol: "tree.fill", color: "#2FA37A", lat: 35.0170, lng: 135.6713, address: "Sagaogurayama Tabuchiyamacho, Kyoto"),
stop("Tenryu-ji", 10 * 60 + 30, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0157, lng: 135.6738, address: "68 Sagatenryuji Susukinobabacho, Kyoto"),
stop("Kinkaku-ji", 14 * 60, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0394, lng: 135.7292, address: "1 Kinkakujicho, Kita Ward, Kyoto"),
],
[stop(t("Nara Park"), 10 * 60, symbol: "leaf.fill", color: "#2FA37A", lat: 34.6851, lng: 135.8430, address: "Nara"), stop("Todai-ji", 11 * 60 + 30, symbol: "building.columns.fill", color: "#D6273B", lat: 34.6890, lng: 135.8398, address: "406-1 Zoshicho, Nara")],
[stop(t("Philosopher's Path"), 9 * 60 + 30, symbol: "figure.walk", color: "#4A7DDB", lat: 35.0266, lng: 135.7948, address: "Sakyo Ward, Kyoto"), stop("Ginkaku-ji", 11 * 60, symbol: "building.columns.fill", color: "#D6273B", lat: 35.0270, lng: 135.7982, address: "2 Ginkakujicho, Sakyo Ward, Kyoto")],
[stop(t("Tea ceremony"), 10 * 60 + 30, symbol: "ticket.fill", color: nil, booking: "event", lat: 35.0036, lng: 135.7788, address: "Camellia Flower, Higashiyama")],
[stop(t("Fushimi sake district"), 14 * 60, symbol: "wineglass.fill", color: "#8E6CD9", lat: 34.9318, lng: 135.7601, address: "Fushimi Ward, Kyoto"), stop(t("Pontocho alley"), 18 * 60 + 30, symbol: "fork.knife", color: "#E8A13A", lat: 35.0050, lng: 135.7708, address: "Pontocho, Nakagyo Ward, Kyoto")],
[stop(t("Shinkansen to Osaka"), 9 * 60 + 12, symbol: "tram.fill", color: nil, booking: "train", lat: 34.9858, lng: 135.7588, address: "Kyoto Station"), stop("LH 741", 13 * 60 + 25, symbol: "airplane", color: nil, booking: "flight", lat: 34.4320, lng: 135.2304, address: "Kansai International Airport")],
]
let days = stops.enumerated().map { index, stops in
WatchSnapshot.Day(date: iso(index), number: index + 1, title: nil, stops: stops)
}
let bookings: [WatchSnapshot.Booking] = [
WatchSnapshot.Booking(
id: 1, title: "LH 716", kind: "flight", confirmed: true, date: iso(0), time: "13:05", endDate: iso(1), endTime: "08:50",
id: 1, title: "LH 716", kind: "flight", confirmed: true, date: iso(0), time: "15:35", endDate: iso(1), endTime: "11:20",
zone: nil, from: .init(name: "Frankfurt", code: "FRA", lat: 50.0379, lng: 8.5622),
to: .init(name: "Tokyo Haneda", code: "HND", lat: 35.5494, lng: 139.7798), code: "K7Q2XD",
carrier: "Lufthansa LH 716", seat: "42A", platform: nil, location: nil, lat: nil, lng: nil, checkIn: nil, checkOut: nil
),
WatchSnapshot.Booking(
id: 2, title: "Hotel Kanra Kyoto", kind: "hotel", confirmed: true, date: iso(0), time: "15:00", endDate: iso(6), endTime: "11:00",
id: 7, title: "Shinkansen Nozomi 21", kind: "train", confirmed: true, date: iso(1), time: "13:33", endDate: iso(1), endTime: "15:48",
zone: nil, from: .init(name: "Tokyo", code: nil, lat: 35.6812, lng: 139.7671), to: .init(name: "Kyoto", code: nil, lat: 34.9858, lng: 135.7588),
code: "JR-5521", carrier: "JR Nozomi 21", seat: "7D", platform: "14", location: nil, lat: nil, lng: nil, checkIn: nil, checkOut: nil
),
WatchSnapshot.Booking(
id: 2, title: "Hotel Kanra Kyoto", kind: "hotel", confirmed: true, date: iso(1), time: "16:30", endDate: iso(7), endTime: "11:00",
zone: nil, from: nil, to: nil, code: "HK-208431", carrier: nil, seat: nil, platform: nil,
location: "Hotel Kanra Kyoto", lat: 34.9922, lng: 135.7585, checkIn: "15:00", checkOut: "11:00"
),
WatchSnapshot.Booking(
id: 3, title: "Kaiseki at Kikunoi", kind: "restaurant", confirmed: false, date: iso(2), time: "19:00", endDate: nil, endTime: nil,
id: 3, title: t("Kaiseki at Kikunoi"), kind: "restaurant", confirmed: false, date: iso(2), time: "19:00", endDate: nil, endTime: nil,
zone: nil, from: nil, to: nil, code: "KK-77", carrier: nil, seat: nil, platform: nil,
location: "459 Shimokawaracho, Higashiyama", lat: 34.9989, lng: 135.7812, checkIn: nil, checkOut: nil
),
WatchSnapshot.Booking(
id: 4, title: "Tea ceremony", kind: "event", confirmed: true, date: iso(5), time: "10:30", endDate: nil, endTime: nil,
id: 4, title: t("Tea ceremony"), kind: "event", confirmed: true, date: iso(5), time: "10:30", endDate: nil, endTime: nil,
zone: nil, from: nil, to: nil, code: nil, carrier: nil, seat: nil, platform: nil,
location: "Camellia Flower", lat: 35.0036, lng: 135.7788, checkIn: nil, checkOut: nil
),
@@ -84,10 +93,10 @@ enum WatchSample {
),
]
let packingItems: [(String, String, Bool)] = [
("Passport", "Documents", true), ("JR Pass voucher", "Documents", true), ("Travel insurance", "Documents", true),
("Rain jacket", "Clothes", false), ("Walking shoes", "Clothes", true), ("Warm sweater", "Clothes", false), ("Socks", "Clothes", true),
("Camera", "Tech", true), ("Power bank", "Tech", false), ("Type A adapter", "Tech", false),
("Toothbrush", "Toiletries", false), ("Sunscreen", "Toiletries", true),
(t("Passport"), t("Documents"), true), (t("JR Pass voucher"), t("Documents"), true), (t("Travel insurance"), t("Documents"), true),
(t("Rain jacket"), t("Clothes"), false), (t("Walking shoes"), t("Clothes"), true), (t("Warm sweater"), t("Clothes"), false), (t("Socks"), t("Clothes"), true),
(t("Camera"), t("Tech"), true), (t("Power bank"), t("Tech"), false), (t("Type A adapter"), t("Tech"), false),
(t("Toothbrush"), t("Toiletries"), false), (t("Sunscreen"), t("Toiletries"), true),
]
var packingList: [WatchSnapshot.Item] = []
for (offset, entry) in packingItems.enumerated() {
@@ -100,9 +109,9 @@ enum WatchSample {
)
// Due dates in days from now, priority, done.
let todoItems: [(String, Int?, Int, Bool)] = [
("Reserve the Ghibli Museum", 2, 3, false), ("Buy a Suica card", nil, 2, false),
("Book the tea ceremony", nil, 1, false), ("Print the hotel voucher", nil, 0, false),
("Order yen", -1, 0, true), ("Renew passport", -20, 0, true),
(t("Reserve the Ghibli Museum"), 2, 3, false), (t("Buy a Suica card"), nil, 2, false),
(t("Book the tea ceremony"), nil, 1, false), (t("Print the hotel voucher"), nil, 0, false),
(t("Order yen"), -1, 0, true), (t("Renew passport"), -20, 0, true),
]
var todoList: [WatchSnapshot.Item] = []
for (offset, entry) in todoItems.enumerated() {
@@ -113,16 +122,62 @@ enum WatchSample {
return WatchSnapshot(
generatedAt: ISO8601DateFormatter().string(from: now.addingTimeInterval(-4 * 60)),
hour12: false,
trip: .init(id: 1, title: "Autumn in Kyoto", start: iso(0), end: iso(8), place: "Kyoto, Japan", cover: nil),
trip: .init(id: 1, title: t("Autumn in Kyoto"), start: iso(0), end: iso(8), place: t("Kyoto, Japan"), cover: nil),
days: days,
bookings: bookings,
weather: .init(temp: 18, high: 21, low: 11, main: scenario == .upcoming ? "Clouds" : "Clear", label: "Mostly sunny", place: "Kyoto", rain: 10),
weather: .init(temp: 18, high: 21, low: 11, main: scenario == .upcoming ? "Clouds" : "Clear", label: t("Mostly sunny"), place: "Kyoto", rain: 10),
packing: packing,
todos: todos,
applied: []
)
}
/// The sample's own texts in German and Russian, so the screenshots in those
/// languages show a trip written in them; every other language gets the English.
private static let translations: [String: [String: String]] = [
"Autumn in Kyoto": ["de": "Herbst in Kyoto", "ru": "Осень в Киото"],
"Kyoto, Japan": ["de": "Kyoto, Japan", "ru": "Киото, Япония"],
"Nishiki Market": ["de": "Nishiki-Markt", "ru": "Рынок Нисики"],
"Gion walk": ["de": "Spaziergang durch Gion", "ru": "Прогулка по Гиону"],
"Kaiseki at Kikunoi": ["de": "Kaiseki im Kikunoi", "ru": "Кайсэки в Kikunoi"],
"Arashiyama Bamboo Grove": ["de": "Bambuswald Arashiyama", "ru": "Бамбуковая роща Арасиямы"],
"Nara Park": ["de": "Nara-Park", "ru": "Парк Нара"],
"Philosopher's Path": ["de": "Philosophenweg", "ru": "Тропа философа"],
"Tea ceremony": ["de": "Teezeremonie", "ru": "Чайная церемония"],
"Fushimi sake district": ["de": "Sake-Viertel Fushimi", "ru": "Саке-квартал Фусими"],
"Pontocho alley": ["de": "Gasse Pontocho", "ru": "Улочка Понтотё"],
"Shinkansen to Osaka": ["de": "Shinkansen nach Osaka", "ru": "Синкансэн в Осаку"],
"Go up the Sannenzaka steps for the view.": ["de": "Für den Ausblick die Sannenzaka-Treppe hinauf.", "ru": "Ради вида поднимитесь по лестнице Саннэндзака."],
"Mostly sunny": ["de": "Überwiegend sonnig", "ru": "Преимущественно солнечно"],
"Passport": ["de": "Reisepass", "ru": "Паспорт"],
"JR Pass voucher": ["de": "JR-Pass-Gutschein", "ru": "Ваучер JR Pass"],
"Travel insurance": ["de": "Reiseversicherung", "ru": "Страховка"],
"Rain jacket": ["de": "Regenjacke", "ru": "Дождевик"],
"Walking shoes": ["de": "Wanderschuhe", "ru": "Удобная обувь"],
"Warm sweater": ["de": "Warmer Pullover", "ru": "Тёплый свитер"],
"Socks": ["de": "Socken", "ru": "Носки"],
"Camera": ["de": "Kamera", "ru": "Фотоаппарат"],
"Power bank": ["de": "Powerbank", "ru": "Пауэрбанк"],
"Type A adapter": ["de": "Reiseadapter Typ A", "ru": "Переходник типа A"],
"Toothbrush": ["de": "Zahnbürste", "ru": "Зубная щётка"],
"Sunscreen": ["de": "Sonnencreme", "ru": "Солнцезащитный крем"],
"Documents": ["de": "Dokumente", "ru": "Документы"],
"Clothes": ["de": "Kleidung", "ru": "Одежда"],
"Tech": ["de": "Technik", "ru": "Техника"],
"Toiletries": ["de": "Kulturbeutel", "ru": "Гигиена"],
"Reserve the Ghibli Museum": ["de": "Ghibli-Museum reservieren", "ru": "Забронировать музей Гибли"],
"Buy a Suica card": ["de": "Suica-Karte kaufen", "ru": "Купить карту Suica"],
"Book the tea ceremony": ["de": "Teezeremonie buchen", "ru": "Записаться на чайную церемонию"],
"Print the hotel voucher": ["de": "Hotelgutschein drucken", "ru": "Распечатать ваучер отеля"],
"Order yen": ["de": "Yen bestellen", "ru": "Заказать иены"],
"Renew passport": ["de": "Reisepass verlängern", "ru": "Продлить паспорт"],
]
private static func t(_ english: String) -> String {
let language = Locale.current.language.languageCode?.identifier ?? "en"
return translations[english]?[language] ?? english
}
private static func stop(_ name: String, _ minutes: Int, end: Int? = nil, symbol: String, color: String?, booking: String? = nil,
lat: Double, lng: Double, address: String, note: String? = nil) -> WatchSnapshot.Stop {
WatchSnapshot.Stop(name: name, minutes: minutes, endMinutes: end, symbol: symbol, color: color, address: address,
+14 -4
View File
@@ -51,7 +51,7 @@ enum WatchFormat {
if abs(date.timeIntervalSince(now)) < 60 { return L("time.now") }
// Against `now`, not the system clock, so the screenshot mode's clock counts too.
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
formatter.unitsStyle = .short
formatter.dateTimeStyle = .numeric
return formatter.localizedString(for: date, relativeTo: now)
}
@@ -59,8 +59,8 @@ enum WatchFormat {
/// The time to a moment as a short count: "25 min", "2 h 15 min", "3 d".
static func countdown(_ date: Date, now: Date) -> String {
let seconds = max(date.timeIntervalSince(now), 0)
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .abbreviated
let formatter = WatchFormat.components()
formatter.unitsStyle = .short
formatter.maximumUnitCount = 2
formatter.allowedUnits = seconds >= 86_400 ? [.day, .hour] : seconds >= 3_600 ? [.hour, .minute] : [.minute]
return formatter.string(from: max(seconds, 60)) ?? ""
@@ -70,7 +70,7 @@ enum WatchFormat {
/// "дней", "日"), taken from the system's own phrase so no plural rules
/// have to be kept here: the number is formatted and then cut out.
static func daysUnit(_ count: Int) -> String {
let formatter = DateComponentsFormatter()
let formatter = WatchFormat.components()
formatter.unitsStyle = .full
formatter.allowedUnits = [.day]
let phrase = formatter.string(from: DateComponents(day: count)) ?? ""
@@ -78,6 +78,16 @@ enum WatchFormat {
return String(String.UnicodeScalarView(word)).trimmingCharacters(in: .whitespacesAndNewlines.union(.punctuationCharacters))
}
/// A duration formatter in the watch's language: it takes its words from
/// its calendar's locale, which is not the app's own unless set.
static func components() -> DateComponentsFormatter {
let formatter = DateComponentsFormatter()
var calendar = Calendar.current
calendar.locale = Locale.current
formatter.calendar = calendar
return formatter
}
static func dayOf(_ index: Int, _ total: Int) -> String {
String(format: L("day.of"), index, total)
}
+42 -34
View File
@@ -42,11 +42,15 @@ boot() {
echo "$udid"
}
# A fixed-offset zone in which it is now <hour> o'clock, so the clock the
# watch draws in the corner agrees with the time the screen is about.
zone_for() {
local offset=$(( ($1 - 10#$(date -u +%H) + 36) % 24 - 12 ))
if [ "$offset" -ge 0 ]; then echo "Etc/GMT-$offset"; else echo "Etc/GMT+$(( -offset ))"; fi
# The watch draws the Mac's clock in its corner, so the Mac goes to a zone in
# which it is about one o'clock: the hour the sample trip's screens are made for
# (WatchSample.swift), and the clock and the countdowns agree.
set_clock() {
local offset=$(( (13 - 10#$(date -u +%H) + 36) % 24 - 12 ))
local zone
if [ "$offset" -ge 0 ]; then zone="Etc/GMT-$offset"; else zone="Etc/GMT+$(( -offset ))"; fi
sudo ln -sf "/usr/share/zoneinfo/$zone" /etc/localtime
echo "clock: $zone, $(date +%H:%M)"
}
# Waits for the ready file the app writes once the screen has drawn (DemoMode.signalReady).
@@ -61,19 +65,21 @@ wait_ready() {
}
TOKEN=0
# shot <udid> <file> <screen> <scenario> <hour> <language>: one launch, retried
# shot <udid> <file> <screen> <scenario> <time or -> <language>: one launch, retried
# after 2, 5 and 10 seconds when the simulator refuses it.
shot() {
local udid=$1 file=$2 screen=$3 scenario=$4 hour=$5 lang=$6
local udid=$1 file=$2 screen=$3 scenario=$4 clock=$5 lang=$6
local locale=${lang//-/_} attempt
for attempt in 0 2 5 10; do
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s"; sleep "$attempt"; }
TOKEN=$((TOKEN + 1))
xcrun simctl terminate "$udid" "$BUNDLE" >/dev/null 2>&1 || true
sleep 0.5
SIMCTL_CHILD_TZ=$(zone_for "$hour") xcrun simctl launch "$udid" "$BUNDLE" \
local extra=()
[ "$clock" = - ] || extra=(-TrekNow "$clock")
xcrun simctl launch "$udid" "$BUNDLE" \
-AppleLanguages "($lang)" -AppleLocale "$locale" -TrekDemo "$screen" -TrekScenario "$scenario" \
-TrekReady "$TOKEN" >/dev/null 2>&1 || continue
-TrekReady "$TOKEN" "${extra[@]}" >/dev/null 2>&1 || continue
wait_ready "$udid" "$TOKEN" || continue
# The map's tiles come over the network after the screen is up.
[ "$screen" = place ] && sleep 6
@@ -82,50 +88,51 @@ shot() {
echo "gave up on $(basename "$file")"
}
# name screen scenario hour: every screen, and the states a screen has.
# name screen scenario clock: every screen, and the states a screen has; a
# clock only where the state needs another hour than the real one.
SHOTS="
today today running 13
today-travelday today travelday 10
today-done today running 22
day day running 13
place place running 13
booking booking travelday 10
booking-dinner booking running 17
bookings bookings travelday 10
booking-detail booking-detail travelday 10
trip trip running 13
trip-upcoming trip upcoming 9
days days running 13
countdown countdown upcoming 9
lists lists upcoming 19
packing packing upcoming 19
todos todos upcoming 19
empty empty running 12
nosync nosync running 12
faces faces running 13
today today running -
today-travelday today travelday -
today-done today running 22:30
day day running -
place place running -
booking booking travelday -
booking-dinner booking running -
bookings bookings travelday -
booking-detail booking-detail travelday -
trip trip running -
trip-upcoming trip upcoming -
days days running -
countdown countdown upcoming -
lists lists upcoming -
packing packing upcoming -
todos todos upcoming -
empty empty running -
nosync nosync running -
faces faces running -
"
MAIN="today booking countdown trip lists packing day"
LANGS="ar az ca cs de el en es et fr he hu id it ja ko nl pl pt-BR ru sk sv th tr uk vi zh-Hans zh-Hant"
EXPECTED=()
run() {
local size=$1 udid=$2 lang shotname screen scenario hour
local size=$1 udid=$2 lang shotname screen scenario clock
for lang in en de ru; do
while read -r shotname screen scenario hour; do
while read -r shotname screen scenario clock; do
[ -n "$shotname" ] || continue
EXPECTED+=("$size-$lang-$shotname.png")
shot "$udid" "$OUT/$size-$lang-$shotname.png" "$screen" "$scenario" "$hour" "$lang"
shot "$udid" "$OUT/$size-$lang-$shotname.png" "$screen" "$scenario" "$clock" "$lang"
done <<< "$SHOTS"
done
if [ "$size" = small ]; then
for lang in $LANGS; do
case "$lang" in en|de|ru) continue ;; esac
while read -r shotname screen scenario hour; do
while read -r shotname screen scenario clock; do
[ -n "$shotname" ] || continue
case " $MAIN " in
*" $shotname "*)
EXPECTED+=("$size-$lang-$shotname.png")
shot "$udid" "$OUT/$size-$lang-$shotname.png" "$screen" "$scenario" "$hour" "$lang"
shot "$udid" "$OUT/$size-$lang-$shotname.png" "$screen" "$scenario" "$clock" "$lang"
;;
esac
done <<< "$SHOTS"
@@ -136,6 +143,7 @@ run() {
# One watch at a time: launches on several booted simulators at once is what
# makes SpringBoard refuse them ("Scene update failed").
set_clock
run large "$(boot "$LARGE" | tail -1)"
run medium "$(boot "$MEDIUM" | tail -1)"
run small "$(boot "$SMALL" | tail -1)"