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) {