From 6b5b2f614d83ebaba730fdcd1bde4061392ed526 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 13:44:18 +0100 Subject: [PATCH] fix(native): watch screens fit every size and language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/ios/App/TREKWatch/Details.swift | 18 ++- client/ios/App/TREKWatch/FacesView.swift | 68 ++++++---- client/ios/App/TREKWatch/Pages.swift | 117 +++++++++++------- .../App/TREKWatchKit/ComplicationViews.swift | 2 +- client/ios/App/TREKWatchKit/WatchSample.swift | 113 ++++++++++++----- client/ios/App/TREKWatchKit/WatchStyle.swift | 18 ++- client/ios/scripts/watch-screens.sh | 76 +++++++----- 7 files changed, 269 insertions(+), 143 deletions(-) diff --git a/client/ios/App/TREKWatch/Details.swift b/client/ios/App/TREKWatch/Details.swift index a35f7d663..eb5051fe3 100644 --- a/client/ios/App/TREKWatch/Details.swift +++ b/client/ios/App/TREKWatch/Details.swift @@ -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 { diff --git a/client/ios/App/TREKWatch/FacesView.swift b/client/ios/App/TREKWatch/FacesView.swift index 21a83a36f..a1eba6e16 100644 --- a/client/ios/App/TREKWatch/FacesView.swift +++ b/client/ios/App/TREKWatch/FacesView.swift @@ -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(_ 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(@ViewBuilder _ content: () -> Content) -> some View { + content() + .frame(width: dial, height: dial) + .clipShape(Circle()) + } + + private func wide(_ 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)) } } diff --git a/client/ios/App/TREKWatch/Pages.swift b/client/ios/App/TREKWatch/Pages.swift index 837aee7b7..25ac0f3cd 100644 --- a/client/ios/App/TREKWatch/Pages.swift +++ b/client/ios/App/TREKWatch/Pages.swift @@ -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")) + } } diff --git a/client/ios/App/TREKWatchKit/ComplicationViews.swift b/client/ios/App/TREKWatchKit/ComplicationViews.swift index 2a6ca5b9e..cc9a1f125 100644 --- a/client/ios/App/TREKWatchKit/ComplicationViews.swift +++ b/client/ios/App/TREKWatchKit/ComplicationViews.swift @@ -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) diff --git a/client/ios/App/TREKWatchKit/WatchSample.swift b/client/ios/App/TREKWatchKit/WatchSample.swift index ba220b6ea..04906de96 100644 --- a/client/ios/App/TREKWatchKit/WatchSample.swift +++ b/client/ios/App/TREKWatchKit/WatchSample.swift @@ -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, diff --git a/client/ios/App/TREKWatchKit/WatchStyle.swift b/client/ios/App/TREKWatchKit/WatchStyle.swift index a3cf917c9..c7a2c5340 100644 --- a/client/ios/App/TREKWatchKit/WatchStyle.swift +++ b/client/ios/App/TREKWatchKit/WatchStyle.swift @@ -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) } diff --git a/client/ios/scripts/watch-screens.sh b/client/ios/scripts/watch-screens.sh index c34a9e44c..dc526f699 100644 --- a/client/ios/scripts/watch-screens.sh +++ b/client/ios/scripts/watch-screens.sh @@ -42,11 +42,15 @@ boot() { echo "$udid" } -# A fixed-offset zone in which it is now 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 : one launch, retried +# shot