mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +00:00
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:
@@ -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? {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user