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.
This commit is contained in:
Maurice
2026-11-07 13:02:54 +01:00
parent 78a28e2747
commit d5ba4439f2
6 changed files with 139 additions and 55 deletions
+11 -7
View File
@@ -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 <screen> today, day, place, booking, bookings, trip, days,
/// packing, todos, lists, countdown, empty, nosync, faces
/// -TrekDemo <screen> today, day, place, booking, booking-detail, bookings,
/// trip, days, packing, todos, lists, countdown, empty,
/// nosync, faces
/// -TrekScenario <name> running, travelday or upcoming (default by screen)
/// -TrekNow <HH:mm> the time the screens think it is
/// -TrekNow <HH:mm> 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? {
+16 -4
View File
@@ -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)
}
+44 -18
View File
@@ -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)
@@ -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
+5 -2
View File
@@ -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".
+61 -24
View File
@@ -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 <TREKWatch.app> <output directory>
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 <udid> <file> <screen> <language> [extra launch arguments]
# 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
}
# shot <udid> <file> <screen> <scenario> <hour> <language>
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