From d5ba4439f27287429917e24a2e56df677ea75925 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 13:02:54 +0100 Subject: [PATCH] feat(native): a calmer watch map, whole street names, a today page that ends clean The place map is flat and muted without points of interest, with the stop's own pin; the day timeline gives a street two lines; the today page puts what follows into the card instead of a list cut off at the fold, and its header opens the day. The screenshot mode takes the real clock, moved to the hour a screen needs through the app's time zone, so the clock in the corner agrees; the capture covers three watch sizes, three languages and every state. --- client/ios/App/TREKWatch/DemoMode.swift | 18 +++-- client/ios/App/TREKWatch/Details.swift | 20 ++++- client/ios/App/TREKWatch/Pages.swift | 62 +++++++++----- client/ios/App/TREKWatch/TREKWatchApp.swift | 2 + client/ios/App/TREKWatchKit/WatchStyle.swift | 7 +- client/ios/scripts/watch-screens.sh | 85 ++++++++++++++------ 6 files changed, 139 insertions(+), 55 deletions(-) diff --git a/client/ios/App/TREKWatch/DemoMode.swift b/client/ios/App/TREKWatch/DemoMode.swift index 7dc58928a..52a9723a3 100644 --- a/client/ios/App/TREKWatch/DemoMode.swift +++ b/client/ios/App/TREKWatch/DemoMode.swift @@ -5,13 +5,18 @@ import Foundation /// every screen in every size and language from a simulator /// (.github/workflows/native.yml, watch-screens). Nothing is sent or stored. /// -/// -TrekDemo today, day, place, booking, bookings, trip, days, -/// packing, todos, lists, countdown, empty, nosync, faces +/// -TrekDemo today, day, place, booking, booking-detail, bookings, +/// trip, days, packing, todos, lists, countdown, empty, +/// nosync, faces /// -TrekScenario running, travelday or upcoming (default by screen) -/// -TrekNow the time the screens think it is +/// -TrekNow the time the screens think it is; without it the +/// real time, which the capture script moves to the +/// hour it wants with the app's time zone (TZ), so the +/// clock in the corner agrees with the screen enum DemoMode { enum Screen: String { case today, day, place, booking, bookings, trip, days, packing, todos, lists, countdown, empty, nosync, faces + case bookingDetail = "booking-detail" } static var screen: Screen? { @@ -35,16 +40,15 @@ enum DemoMode { private static func defaultScenario(_ screen: Screen) -> WatchSample.Scenario { switch screen { - case .booking, .bookings: return .travelday + case .booking, .bookings, .bookingDetail: return .travelday case .countdown, .packing, .lists: return .upcoming default: return .running } } private static func clock() -> Date { - let parts = (value(after: "-TrekNow") ?? "13:05").split(separator: ":").compactMap { Int($0) } - let minutes = parts.count == 2 ? parts[0] * 60 + parts[1] : 13 * 60 + 5 - return TrekDate.at(minutes, on: Date()) + guard let parts = value(after: "-TrekNow")?.split(separator: ":").compactMap({ Int($0) }), parts.count == 2 else { return Date() } + return TrekDate.at(parts[0] * 60 + parts[1], on: Date()) } private static func value(after flag: String) -> String? { diff --git a/client/ios/App/TREKWatch/Details.swift b/client/ios/App/TREKWatch/Details.swift index 6daafe601..a35f7d663 100644 --- a/client/ios/App/TREKWatch/Details.swift +++ b/client/ios/App/TREKWatch/Details.swift @@ -139,11 +139,13 @@ struct TimelineRow: View { .font(.system(.footnote, design: .rounded).weight(state == .now ? .bold : .semibold)) .foregroundStyle(state == .past ? WatchLook.muted : WatchLook.ink) .lineLimit(2) + // The street, whole: the first part of the address, on up to two lines. if let detail = stop.booking.map(WatchLook.kindName) ?? stop.address?.split(separator: ",").first.map(String.init) { Text(detail) .font(.system(.caption2, design: .rounded)) .foregroundStyle(WatchLook.faint) - .lineLimit(1) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) } } .padding(.top, 3) @@ -217,10 +219,20 @@ struct MiniMap: View { var body: some View { let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lng) - Map(initialPosition: .region(MKCoordinateRegion(center: coordinate, latitudinalMeters: 900, longitudinalMeters: 900)), interactionModes: []) { - Marker(name, coordinate: coordinate).tint(color) + // 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. + Map(initialPosition: .region(MKCoordinateRegion(center: coordinate, latitudinalMeters: 700, longitudinalMeters: 700)), interactionModes: []) { + Annotation(name, coordinate: coordinate, anchor: .center) { + Circle() + .fill(color.gradient) + .frame(width: 22, height: 22) + .overlay(Circle().stroke(.white, lineWidth: 2.5)) + .shadow(color: .black.opacity(0.4), radius: 3, y: 1) + } + .annotationTitles(.hidden) } - .frame(height: 92) + .mapStyle(.standard(elevation: .flat, emphasis: .muted, pointsOfInterest: .excludingAll, showsTraffic: false)) + .frame(height: 104) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .accessibilityHidden(true) } diff --git a/client/ios/App/TREKWatch/Pages.swift b/client/ios/App/TREKWatch/Pages.swift index 674d4e04c..837aee7b7 100644 --- a/client/ios/App/TREKWatch/Pages.swift +++ b/client/ios/App/TREKWatch/Pages.swift @@ -25,21 +25,16 @@ struct TodayPage: View { header if let focus, let todayIndex { NavigationLink(value: Route.stop(day: todayIndex, stop: focus)) { - FocusCard(stop: stops[focus], isNow: focus == currentIndex, hour12: snapshot.hour12) + FocusCard( + stop: stops[focus], + isNow: focus == currentIndex, + hour12: snapshot.hour12, + then: reduced ? nil : stops.dropFirst(focus + 1).first + ) } .buttonStyle(.plain) DayProgressBar(stops: stops, nowMinutes: nowMinutes) .padding(.horizontal, 2) - let later = Array(stops.enumerated().filter { $0.offset > focus }.prefix(3)) - if !later.isEmpty && !reduced { - Text(L("today.later")).watchEyebrow().padding(.top, 2) - ForEach(later, id: \.offset) { entry in - NavigationLink(value: Route.stop(day: todayIndex, stop: entry.offset)) { - CompactStopRow(stop: entry.element, hour12: snapshot.hour12) - } - .buttonStyle(.plain) - } - } } else if stops.isEmpty { DoneCard(title: L("today.free"), detail: L("today.freeBody"), symbol: "sun.max.fill") } else { @@ -66,17 +61,29 @@ struct TodayPage: View { } } + /// The day and the trip; a tap opens the whole day's timeline. @ViewBuilder private var header: some View { - if case .running(let index, let total) = snapshot.phase(at: now) { - VStack(alignment: .leading, spacing: 1) { - Text(WatchFormat.dayOf(index, total)).watchEyebrow(.white.opacity(0.85)) - Text(snapshot.trip?.title ?? "") - .font(.system(.footnote, design: .rounded).weight(.semibold)) - .foregroundStyle(WatchLook.muted) - .lineLimit(1) + 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) { + Text(WatchFormat.dayOf(index, total)).watchEyebrow(.white.opacity(0.85)) + Text(snapshot.trip?.title ?? "") + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundStyle(WatchLook.muted) + .lineLimit(1) + } + Spacer(minLength: 0) + Image(systemName: "list.bullet") + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundStyle(WatchLook.muted) + } + .contentShape(Rectangle()) } + .buttonStyle(.plain) .accessibilityElement(children: .combine) + .accessibilityHint(L("today.plan")) } } } @@ -86,6 +93,8 @@ struct FocusCard: View { let stop: WatchSnapshot.Stop let isNow: Bool let hour12: Bool + /// The stop after it, in a line at the foot. + var then: WatchSnapshot.Stop? @Environment(\.trekNow) private var now var body: some View { @@ -113,6 +122,23 @@ struct FocusCard: View { .font(.system(.footnote, design: .rounded).weight(.medium).monospacedDigit()) .foregroundStyle(WatchLook.muted) } + if let then { + Divider().overlay(Color.white.opacity(0.12)).padding(.vertical, 2) + HStack(spacing: 5) { + Image(systemName: "arrow.turn.down.right") + .foregroundStyle(WatchLook.faint) + if let minutes = then.minutes { + Text(WatchFormat.time(minutes: minutes, hour12: hour12)).monospacedDigit() + .foregroundStyle(WatchLook.muted) + } + Text(then.name) + .foregroundStyle(WatchLook.ink.opacity(0.85)) + .lineLimit(1) + } + .font(.system(.caption, design: .rounded).weight(.semibold)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(L("today.later")): \(then.name)") + } } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/client/ios/App/TREKWatch/TREKWatchApp.swift b/client/ios/App/TREKWatch/TREKWatchApp.swift index 3c7be0e28..62594633b 100644 --- a/client/ios/App/TREKWatch/TREKWatchApp.swift +++ b/client/ios/App/TREKWatch/TREKWatchApp.swift @@ -142,6 +142,8 @@ struct RootView: View { let next = stops.firstIndex { ($0.minutes ?? 0) >= TrekDate.minutesOfDay(now) } ?? 0 path = [.stop(day: today, stop: next)] case .bookings: path = [.bookings] + case .bookingDetail: + if let booking = snapshot.nextBooking(at: now) { path = [.booking(booking.id)] } case .packing: path = [.packing] case .todos: path = [.todos] default: break diff --git a/client/ios/App/TREKWatchKit/WatchStyle.swift b/client/ios/App/TREKWatchKit/WatchStyle.swift index 10625e815..a3cf917c9 100644 --- a/client/ios/App/TREKWatchKit/WatchStyle.swift +++ b/client/ios/App/TREKWatchKit/WatchStyle.swift @@ -49,8 +49,11 @@ enum WatchFormat { /// "in 25 min", "in 2 hr", "5 min ago": named and plural-correct in every language. static func relative(_ date: Date, now: Date) -> String { if abs(date.timeIntervalSince(now)) < 60 { return L("time.now") } - let style = Date.RelativeFormatStyle(presentation: .numeric, unitsStyle: .abbreviated) - return date.formatted(style.locale(.current)) + // Against `now`, not the system clock, so the screenshot mode's clock counts too. + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + formatter.dateTimeStyle = .numeric + return formatter.localizedString(for: date, relativeTo: now) } /// The time to a moment as a short count: "25 min", "2 h 15 min", "3 d". diff --git a/client/ios/scripts/watch-screens.sh b/client/ios/scripts/watch-screens.sh index ad46a126a..b43df516c 100644 --- a/client/ios/scripts/watch-screens.sh +++ b/client/ios/scripts/watch-screens.sh @@ -1,9 +1,10 @@ #!/bin/bash # Captures the watch app's screens from watchOS simulators, in screenshot mode # (client/ios/App/TREKWatch/DemoMode.swift), for review and the App Store: -# every screen in English and German on the largest and the smallest watch, -# and the main screens in every language on the smallest, where text is -# tightest. .github/workflows/native.yml runs it after building the app. +# every screen and its states in English, German and Russian on the newest +# Ultra, the largest other watch and the smallest one, and the main screens in +# every language on the smallest, where text is tightest. +# .github/workflows/native.yml runs it after building the app. # # watch-screens.sh set -euo pipefail @@ -13,8 +14,9 @@ OUT="$2" BUNDLE=com.liketrek.trek.watchkitapp mkdir -p "$OUT" -# The newest watchOS runtime, its newest Ultra, and the smallest watch it still runs on. -read -r RUNTIME LARGE SMALL < <(xcrun simctl list runtimes -j | python3 -c ' +# The newest watchOS runtime, its newest Ultra, the largest other watch and the +# smallest one it still runs on. +read -r RUNTIME LARGE MEDIUM SMALL < <(xcrun simctl list runtimes -j | python3 -c ' import json, re, sys runtimes = [r for r in json.load(sys.stdin)["runtimes"] if r["platform"] == "watchOS" and r["isAvailable"]] runtime = sorted(runtimes, key=lambda r: [int(x) for x in r["version"].split(".")])[-1] @@ -24,48 +26,81 @@ def mm(n): def gen(n): m = re.search(r"(?:Series|Ultra|SE) ?(\d+)?", n); return int(m.group(1) or 1) if m else 0 ultra = sorted([n for n in names if "Ultra" in n], key=gen)[-1] -small = sorted([n for n in names if "Ultra" not in n], key=lambda n: (mm(n), -gen(n)))[0] -print(runtime["identifier"], ultra.replace(" ", "_"), small.replace(" ", "_"))') -echo "runtime $RUNTIME" +rest = sorted([n for n in names if "Ultra" not in n], key=lambda n: (mm(n), gen(n))) +print(runtime["identifier"], *[n.replace(" ", "_") for n in (ultra, rest[-1], rest[0])])') LARGE=${LARGE//_/ } +MEDIUM=${MEDIUM//_/ } SMALL=${SMALL//_/ } -echo "large: $LARGE, small: $SMALL" +echo "runtime $RUNTIME; large: $LARGE, medium: $MEDIUM, small: $SMALL" boot() { local udid udid=$(xcrun simctl create "$1" "$1" "$RUNTIME") xcrun simctl boot "$udid" xcrun simctl bootstatus "$udid" -b >/dev/null - xcrun simctl status_bar "$udid" override --time "13:05" >/dev/null 2>&1 || true xcrun simctl install "$udid" "$APP" echo "$udid" } -# shot [extra launch arguments] +# 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 +} + +# shot shot() { - local udid=$1 file=$2 screen=$3 lang=$4 - shift 4 + local udid=$1 file=$2 screen=$3 scenario=$4 hour=$5 lang=$6 local locale=${lang//-/_} - xcrun simctl launch --terminate-running-process "$udid" "$BUNDLE" \ - -AppleLanguages "($lang)" -AppleLocale "$locale" -TrekDemo "$screen" "$@" >/dev/null || { echo "could not launch $file"; return 0; } - case "$screen" in place) sleep 9 ;; *) sleep 4 ;; esac + SIMCTL_CHILD_TZ=$(zone_for "$hour") xcrun simctl launch --terminate-running-process "$udid" "$BUNDLE" \ + -AppleLanguages "($lang)" -AppleLocale "$locale" -TrekDemo "$screen" -TrekScenario "$scenario" >/dev/null \ + || { echo "could not launch $file"; return 0; } + case "$screen" in place) sleep 10 ;; *) sleep 4 ;; esac xcrun simctl io "$udid" screenshot --type=png "$file" >/dev/null 2>&1 || echo "could not capture $file" } -ALL="today day place booking bookings trip days packing todos lists countdown empty nosync faces" -MAIN="today booking countdown trip lists packing" +# name screen scenario hour: every screen, and the states a screen has. +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 +" +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" run() { local size=$1 name=$2 udid udid=$(boot "$name" | tail -1) - for lang in en de; do - for screen in $ALL; do shot "$udid" "$OUT/$size-$lang-$screen.png" "$screen" "$lang"; done + for lang in en de ru; do + while read -r shotname screen scenario hour; do + [ -n "$shotname" ] || continue + shot "$udid" "$OUT/$size-$lang-$shotname.png" "$screen" "$scenario" "$hour" "$lang" + done <<< "$SHOTS" done if [ "$size" = small ]; then for lang in $LANGS; do - [ "$lang" = en ] || [ "$lang" = de ] && continue - for screen in $MAIN; do shot "$udid" "$OUT/$size-$lang-$screen.png" "$screen" "$lang"; done + case "$lang" in en|de|ru) continue ;; esac + while read -r shotname screen scenario hour; do + [ -n "$shotname" ] || continue + case " $MAIN " in *" $shotname "*) shot "$udid" "$OUT/$size-$lang-$shotname.png" "$screen" "$scenario" "$hour" "$lang" ;; esac + done <<< "$SHOTS" done fi xcrun simctl shutdown "$udid" || true @@ -73,10 +108,12 @@ run() { run large "$LARGE" & large=$! +run medium "$MEDIUM" & +medium=$! run small "$SMALL" & small=$! wait "$large" +wait "$medium" wait "$small" -echo "$LARGE" > "$OUT/devices.txt" -echo "$SMALL" >> "$OUT/devices.txt" +printf '%s\n' "large: $LARGE" "medium: $MEDIUM" "small: $SMALL" > "$OUT/devices.txt" ls "$OUT" | wc -l