From d259ecb11c4699d4b2da4a0e53da1936cd56839b Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 18:29:56 +0100 Subject: [PATCH] fix(ios): widgets hold at an iPad mini's sizes, the extra large ones balanced From the iPad renders of the widget previews: - An iPad mini's small and medium widgets are smaller than an iPhone's. The trip widget during the trip lets the weather give way in the small one and narrows its stops panel in the medium one, the today widget's weather shrinks to its chip and then steps aside before the trip's name is cut, the atlas puts "countries" under the number in the small one and lets its map shrink in the large one, so the continents and latest countries are no longer cut off at the foot. - The extra large widgets fill their width and height: the trip during the trip sets the next stop at the foot of the left column and the next booking under the day's stops, today sets the next two bookings across the foot, the bookings list spaces its rows beside the card. - The Live Activity's next stop shrinks a little before it is cut. --- client/ios/App/TREKWidgets/AtlasContent.swift | 43 +++++--- .../ios/App/TREKWidgets/BookingsContent.swift | 3 +- client/ios/App/TREKWidgets/TodayContent.swift | 101 ++++++++++-------- client/ios/App/TREKWidgets/TripContent.swift | 90 ++++++++++------ 4 files changed, 142 insertions(+), 95 deletions(-) diff --git a/client/ios/App/TREKWidgets/AtlasContent.swift b/client/ios/App/TREKWidgets/AtlasContent.swift index df9604ef6..9877143ea 100644 --- a/client/ios/App/TREKWidgets/AtlasContent.swift +++ b/client/ios/App/TREKWidgets/AtlasContent.swift @@ -44,9 +44,16 @@ struct AtlasContent: View { Text("\(Image(systemName: "globe.europe.africa.fill")) \(atlas?.count ?? 0) \(labels?.countries ?? "")") case .small: VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink) - Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted) + // The word goes under the number where both do not fit on one line (an iPad mini). + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink) + Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted) + } + VStack(alignment: .leading, spacing: -2) { + Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink) + Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted) + } } Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted) Spacer(minLength: 6) @@ -71,18 +78,15 @@ struct AtlasContent: View { } default: VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .lastTextBaseline, spacing: 6) { - Text("\(atlas?.count ?? 0)").font(Trek.number(40, .heavy)).foregroundStyle(ink) - VStack(alignment: .leading, spacing: 0) { - Text(labels?.countries ?? "").font(.system(size: 14, weight: .heavy)).foregroundStyle(ink) - Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted) - } - Spacer(minLength: 4) - if let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) } - if let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) } + // The trips and days step aside before the share of the world is cut (an iPad mini). + ViewThatFits(in: .horizontal) { + largeHeader(atlas, labels, stats: true) + largeHeader(atlas, labels, stats: false) } + // The map takes what the rows leave, so a smaller large widget (an + // iPad mini's) keeps the continents and the latest countries whole. DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 2.2) - .frame(height: 150) + .frame(minHeight: 60, maxHeight: 150) ContinentBars(continents: atlas?.continents ?? [], scheme: scheme) HStack(spacing: 6) { ForEach(Array((atlas?.recent ?? []).prefix(3).enumerated()), id: \.offset) { index, country in @@ -94,6 +98,19 @@ struct AtlasContent: View { } } + private func largeHeader(_ atlas: WidgetSnapshot.Atlas?, _ labels: WidgetSnapshot.Labels?, stats: Bool) -> some View { + HStack(alignment: .lastTextBaseline, spacing: 6) { + Text("\(atlas?.count ?? 0)").font(Trek.number(40, .heavy)).foregroundStyle(ink) + VStack(alignment: .leading, spacing: 0) { + Text(labels?.countries ?? "").font(.system(size: 14, weight: .heavy)).foregroundStyle(ink) + Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted) + } + Spacer(minLength: 4) + if stats, let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) } + if stats, let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) } + } + } + private var ink: Color { Trek.ink(scheme) } private var muted: Color { Trek.muted(scheme) } private var glow: [Color] { [Color(hex: 0x3DDC97), Color(hex: 0x4A7DDB)] } diff --git a/client/ios/App/TREKWidgets/BookingsContent.swift b/client/ios/App/TREKWidgets/BookingsContent.swift index 2206a4ebb..568c0e1a2 100644 --- a/client/ios/App/TREKWidgets/BookingsContent.swift +++ b/client/ios/App/TREKWidgets/BookingsContent.swift @@ -75,7 +75,8 @@ struct BookingsContent: View { // MARK: Medium and large: a list, the first one featured private func list(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels, featureFirst: Bool) -> some View { - VStack(alignment: .leading, spacing: family.isTall ? 8 : 7) { + // The extra large's list stands beside a card as tall as the widget: its rows space out to match. + VStack(alignment: .leading, spacing: family == .extraLarge ? 14 : family.isTall ? 8 : 7) { HStack { Text(labels.bookings ?? "").trekEyebrow(scheme) Spacer(minLength: 0) diff --git a/client/ios/App/TREKWidgets/TodayContent.swift b/client/ios/App/TREKWidgets/TodayContent.swift index ef605a927..f97ffc0ae 100644 --- a/client/ios/App/TREKWidgets/TodayContent.swift +++ b/client/ios/App/TREKWidgets/TodayContent.swift @@ -18,23 +18,12 @@ struct TodayContent: View { wide(snapshot, trip, today, total: total, done: done, timed: timed.count, nowMinutes: nowMinutes) } else { VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center, spacing: 10) { - DayBadge(index: today.index ?? 1, total: total) - VStack(alignment: .leading, spacing: 1) { - Text(trip.title).trekEyebrow(scheme) - Text(today.dayLabel) - .font(.system(size: family == .large ? 19 : 16, weight: .heavy)) - .foregroundStyle(Trek.ink(scheme)) - .lineLimit(1) - } - Spacer(minLength: 0) - if let weather = snapshot.weather { - if family == .large { - PaperWeather(weather: weather) - } else { - WeatherChip(weather: weather) - } - } + // On a narrow widget (an iPad mini's) the weather shrinks to its + // chip, then steps aside, before the trip's name is cut short. + ViewThatFits(in: .horizontal) { + header(trip, today, total: total, weather: family == .large ? .full : .chip) + header(trip, today, total: total, weather: .chip) + header(trip, today, total: total, weather: .hidden) } if family == .large { DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone) @@ -76,43 +65,61 @@ struct TodayContent: View { } extension TodayContent { - /// The iPad's extra large: the day, its progress, the weather and the next - /// booking in a column, the day's stops as a timeline beside it. + enum HeaderWeather { case full, chip, hidden } + + /// The day's badge, the trip and the day, and the weather as much as fits. + func header(_ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today, total: Int, weather: HeaderWeather) -> some View { + HStack(alignment: .center, spacing: 10) { + DayBadge(index: today.index ?? 1, total: total) + VStack(alignment: .leading, spacing: 1) { + Text(trip.title).trekEyebrow(scheme) + Text(today.dayLabel) + .font(.system(size: family == .medium ? 16 : 19, weight: .heavy)) + .foregroundStyle(Trek.ink(scheme)) + .lineLimit(1) + } + Spacer(minLength: 0) + if let forecast = snapshot?.weather { + switch weather { + case .full: PaperWeather(weather: forecast) + case .chip: WeatherChip(weather: forecast) + case .hidden: EmptyView() + } + } + } + } + + /// The iPad's extra large: the day, its progress and the weather beside + /// the day's timeline, and the next two bookings across the foot. func wide( _ snapshot: WidgetSnapshot, _ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today, total: Int, done: Int, timed: Int, nowMinutes: Int ) -> some View { - HStack(alignment: .top, spacing: 22) { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center, spacing: 10) { - DayBadge(index: today.index ?? 1, total: total) - VStack(alignment: .leading, spacing: 1) { - Text(trip.title).trekEyebrow(scheme) - Text(today.dayLabel) - .font(.system(size: 19, weight: .heavy)) - .foregroundStyle(Trek.ink(scheme)) - .lineLimit(1) - .minimumScaleFactor(0.8) + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 22) { + VStack(alignment: .leading, spacing: 0) { + header(trip, today, total: total, weather: .hidden) + DayProgress(done: done, total: timed, scheme: scheme, label: snapshot.labels.allDone) + .padding(.top, 12) + if let weather = snapshot.weather { + PaperWeather(weather: weather).padding(.top, 16) } - Spacer(minLength: 0) } - DayProgress(done: done, total: timed, scheme: scheme, label: snapshot.labels.allDone) - .padding(.top, 12) - if let weather = snapshot.weather { - PaperWeather(weather: weather).padding(.top, 16) - } - Spacer(minLength: 8) - if let booking = snapshot.bookings?.first { - NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false) + .frame(width: 250) + StopList( + stops: today.stops, nowMinutes: nowMinutes, limit: 6, + nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now + ) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + Spacer(minLength: 10) + if let bookings = snapshot.bookings, !bookings.isEmpty { + HStack(spacing: 12) { + ForEach(Array(bookings.prefix(2).enumerated()), id: \.offset) { _, booking in + NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false) + } } } - .frame(width: 250) - .frame(maxHeight: .infinity, alignment: .top) - StopList( - stops: today.stops, nowMinutes: nowMinutes, limit: 8, - nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now - ) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } } @@ -228,7 +235,7 @@ struct TripDayBanner: View { Text(dayLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)).lineLimit(1) } HStack(alignment: .firstTextBaseline) { - Text(nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1) + Text(nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1).minimumScaleFactor(0.8) Spacer(minLength: 4) if let nextTime { Text(nextTime).font(Trek.number(18, .heavy)).foregroundStyle(.white) diff --git a/client/ios/App/TREKWidgets/TripContent.swift b/client/ios/App/TREKWidgets/TripContent.swift index e0e725978..961f9a761 100644 --- a/client/ios/App/TREKWidgets/TripContent.swift +++ b/client/ios/App/TREKWidgets/TripContent.swift @@ -201,10 +201,17 @@ struct TripContent: View { switch family { case .small: VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .top) { - Text(today.dayLabel).trekEyebrowOnPhoto() - Spacer(minLength: 0) - if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) } + // An iPad mini's small widget has no room for both: the day stays, the weather gives way. + ViewThatFits(in: .horizontal) { + HStack(alignment: .top) { + Text(today.dayLabel).trekEyebrowOnPhoto() + Spacer(minLength: 0) + if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) } + } + HStack(alignment: .top) { + Text(today.dayLabel).trekEyebrowOnPhoto() + Spacer(minLength: 0) + } } DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 7) Spacer(minLength: 0) @@ -222,34 +229,43 @@ struct TripContent: View { Text(trip.title).font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)).lineLimit(1).padding(.top, 2) } case .medium: - HStack(spacing: 14) { - VStack(alignment: .leading, spacing: 0) { - Text(trip.title).trekEyebrowOnPhoto() - Text(today.dayLabel) - .font(.system(size: 21, weight: .heavy)) - .foregroundStyle(.white) - .lineLimit(1) - .minimumScaleFactor(0.8) - .padding(.top, 3) - DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8) - Spacer(minLength: 0) - if let weather = snapshot.weather { - HStack(spacing: 6) { - WeatherChip(weather: weather, onPhoto: true) - if let high = weather.high, let low = weather.low { - Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8)) + // 176 pt for the stops on an iPhone and an 11" or 13" iPad; an iPad + // mini's medium widget is narrower, so the panel gives up some width + // and the weather keeps to its chip, and the day still reads whole. + GeometryReader { geo in + let narrow = geo.size.width < 300 + HStack(spacing: 14) { + VStack(alignment: .leading, spacing: 0) { + Text(trip.title).trekEyebrowOnPhoto() + Text(today.dayLabel) + .font(.system(size: 21, weight: .heavy)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + .padding(.top, 3) + DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8) + Spacer(minLength: 0) + if let weather = snapshot.weather { + HStack(spacing: 6) { + WeatherChip(weather: weather, onPhoto: true, compact: narrow) + if !narrow, let high = weather.high, let low = weather.low { + Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8)) + } } } } + .frame(maxWidth: .infinity, alignment: .leading) + GlassPanel { + StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now) + } + .frame(width: narrow ? 150 : 176) } - .frame(maxWidth: .infinity, alignment: .leading) - GlassPanel { - StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now) - } - .frame(width: 176) + .frame(width: geo.size.width, height: geo.size.height) } case .extraLarge: - // The iPad's wide widget: the day on the left, its stops beside it. + // The iPad's wide widget: the day and the next stop on the left, the + // day's stops and the next booking on the right, each column + // anchored at its top and its foot. HStack(alignment: .top, spacing: 20) { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top) { @@ -268,23 +284,29 @@ struct TripContent: View { Spacer(minLength: 8) if let next { Text(next.time ?? snapshot.labels.today) - .font(Trek.number(30, .heavy)) + .font(Trek.number(34, .heavy)) .foregroundStyle(.white) Text(next.name) - .font(.system(size: 16, weight: .bold)) + .font(.system(size: 17, weight: .bold)) .foregroundStyle(.white) - .lineLimit(1) - .padding(.bottom, 10) + .lineLimit(2) + .minimumScaleFactor(0.85) + } else { + Text(snapshot.labels.nothingPlanned).font(.system(size: 17, weight: .bold)).foregroundStyle(.white).lineLimit(2) } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 10) { + GlassPanel { + StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 6, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now) + } + Spacer(minLength: 0) if let booking = snapshot.bookings?.first { NextBookingStrip(booking: booking, labels: snapshot.labels, now: now) } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - GlassPanel { - StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 7, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now) - } .frame(width: 300) + .frame(maxHeight: .infinity, alignment: .top) } default: VStack(alignment: .leading, spacing: 0) {