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