From 90077193559817ef82c817078fba6ac967b9d8ae Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 1 Nov 2026 18:57:59 +0100 Subject: [PATCH] fix(ios): the strip reads at the largest text size At the largest text size Now's strip and the plan's lanes overlapped and cut text: the kicker ran under the price now, the scale's 200 and 100 sat on each other, and the pass's figures ended in an ellipsis. - The header keeps the kicker beside the price where both fit, and puts the price on its own line where they do not. Its hidden room for the price now has the price's real width. - The pass's three figures stand one under another where they do not fit side by side. - A grid line keeps its figure only where it has room; the top's figure wins. The level row's 0 % keeps clear of the price's top figure at any size. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/PassFigures.swift | 49 ++++++++++++++--------- appleApp/FTW/UI/PlanStrip.swift | 36 +++++++++++++---- appleApp/FTWCharts/PlanStripDrawing.swift | 18 ++++++--- 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/appleApp/FTW/UI/PassFigures.swift b/appleApp/FTW/UI/PassFigures.swift index 0bbde43..d4e27e5 100644 --- a/appleApp/FTW/UI/PassFigures.swift +++ b/appleApp/FTW/UI/PassFigures.swift @@ -6,7 +6,8 @@ import SwiftUI /// meets. Each a mono title, a figure and a line, with space between them /// and no borders. The first title takes the pass's colour; a plan that is /// old reads in ink3. It swaps with the pass, with no animation, so a tap -/// answers at once. +/// answers at once. Where the three do not fit side by side, as at the +/// largest text sizes, they stand one under another rather than end in "…". struct PassFigures: View, Equatable { let pass: PlanPass var grey = false @@ -15,30 +16,42 @@ struct PassFigures: View, Equatable { nonisolated static func == (a: Self, b: Self) -> Bool { a.pass == b.pass && a.grey == b.grey } var body: some View { - HStack(alignment: .top, spacing: 12) { - ForEach(Array(pass.figures.enumerated()), id: \.offset) { i, f in - VStack(alignment: i == 2 ? .trailing : .leading, spacing: 1) { - Text(f.title) - .font(Theme.mono()) - .tracking(0.5) - .foregroundStyle(grey ? Theme.ink3 : tint(f.tint)) - .dynamicTypeSize(...DynamicTypeSize.accessibility1) - Text(f.value) - .font(Theme.figure(figure)) - .foregroundStyle(grey ? Theme.ink3 : Theme.ink) - .minimumScaleFactor(0.7) - Text(f.sub) - .font(.caption) - .foregroundStyle(grey ? Theme.ink3 : Theme.ink2) + let figures = Array(pass.figures.enumerated()) + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 12) { + ForEach(figures, id: \.offset) { i, f in + column(f, trailing: i == 2) + .frame(maxWidth: .infinity, alignment: i == 2 ? .trailing : .leading) + } + } + VStack(alignment: .leading, spacing: 12) { + ForEach(figures, id: \.offset) { _, f in + column(f, trailing: false) } - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: i == 2 ? .trailing : .leading) } } .accessibilityElement(children: .ignore) .accessibilityLabel(pass.spoken) } + private func column(_ f: PlanPass.Figure, trailing: Bool) -> some View { + VStack(alignment: trailing ? .trailing : .leading, spacing: 1) { + Text(f.title) + .font(Theme.mono()) + .tracking(0.5) + .foregroundStyle(grey ? Theme.ink3 : tint(f.tint)) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + Text(f.value) + .font(Theme.figure(figure)) + .foregroundStyle(grey ? Theme.ink3 : Theme.ink) + .minimumScaleFactor(0.7) + Text(f.sub) + .font(.caption) + .foregroundStyle(grey ? Theme.ink3 : Theme.ink2) + } + .lineLimit(1) + } + private func tint(_ t: PlanPass.Figure.Tint) -> Color { switch t { case .gives: return Theme.gives diff --git a/appleApp/FTW/UI/PlanStrip.swift b/appleApp/FTW/UI/PlanStrip.swift index bf6999d..19b3043 100644 --- a/appleApp/FTW/UI/PlanStrip.swift +++ b/appleApp/FTW/UI/PlanStrip.swift @@ -157,8 +157,25 @@ struct PlanStripView: View, Equatable { } /// The kicker and its chevron, and room for the price, which sits over - /// the strip as a link of its own. + /// the strip as a link of its own. Beside the price where both fit; at + /// the largest sizes the price takes the first line and the kicker the + /// next, so the two never overlap. private var header: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + kickerLine + Spacer(minLength: 8) + priceRoom + } + VStack(alignment: .leading, spacing: 4) { + priceRoom + kickerLine + } + } + .animation(Theme.spring, value: strip.kicker) + } + + private var kickerLine: some View { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(priceLabel.map { strip.kicker + " · " + $0 } ?? strip.kicker) .font(mono) @@ -172,13 +189,18 @@ struct PlanStripView: View, Equatable { .font(.caption2.weight(.semibold)) .foregroundStyle(Theme.ink3) } - Spacer(minLength: 8) - // The price's line, never drawn: it gives the header the price's - // height and baseline, so the kicker sits on the same line. - Text("\(Text(verbatim: "0").font(Theme.figure(figure))) \(Text(verbatim: unit).font(mono).tracking(0.5))") - .hidden() } - .animation(Theme.spring, value: strip.kicker) + } + + /// The price's line, never drawn: it gives the header the price's + /// width, height and baseline, so the kicker sits on the same line and + /// clear of it. + private var priceRoom: some View { + let figures = priceNow.map { AppWords.decimal($0) } ?? "0" + let words = priceNow == nil ? unit : "\(unit) \(PlanGlance.nowWord)" + return Text("\(Text(verbatim: figures).font(Theme.figure(figure))) \(Text(verbatim: words).font(mono).tracking(0.5))") + .lineLimit(1) + .hidden() } private func follow(_ x: CGFloat) { diff --git a/appleApp/FTWCharts/PlanStripDrawing.swift b/appleApp/FTWCharts/PlanStripDrawing.swift index 7f4715d..43c2575 100644 --- a/appleApp/FTWCharts/PlanStripDrawing.swift +++ b/appleApp/FTWCharts/PlanStripDrawing.swift @@ -28,9 +28,10 @@ struct PlanStripLayout: Equatable { var priceTop: CGFloat { Self.lanesBottom + levelHeight } var priceBottom: CGFloat { priceTop + priceHeight } - /// 100 % and 0 % in the level's row, clear of the lanes and the bars. + /// 100 % and 0 % in the level's row, clear of the lanes and the bars, + /// and 0 %'s figure clear of the price's top figure at any text size. var levelTop: CGFloat { Self.lanesBottom + 5 } - var levelBottom: CGFloat { priceTop - 14 } + var levelBottom: CGFloat { priceTop - max(14, (axis * 1.2).rounded(.up)) } func levelY(_ pct: Double) -> CGFloat { levelBottom - CGFloat(min(100, max(0, pct)) / 100) * (levelBottom - levelTop) } var hoursTop: CGFloat { priceBottom + 3 } /// Room on the right for the scale's figures, "600", in the axis's @@ -177,17 +178,22 @@ struct PlanStripDrawing: ChartDrawing { if layout.levelHeight > 0 { drawLevel(in: &g, layout: layout, width: width, px: px, ghost: ghost, chosen: chosen) } // The scale: a hairline at half the top and at the top, each with - // its figure in the gutter, and the zero line when a price falls - // under it. + // its figure in the gutter where there is room for it, and the zero + // line when a price falls under it. In a short row at a large text + // size the top keeps its figure and half the top keeps only its line. let priceScale = strip.scale var lines = Path() for v in priceScale.lines + (priceScale.bottom < 0 ? [0] : []) { lines.addRect(CGRect(x: 0, y: snap(layout.priceY(v, priceScale)), width: width, height: px)) } g.fill(lines, with: .color(Theme.ink.opacity(0.1))) - for v in priceScale.lines { + var lastY: CGFloat? + for v in priceScale.lines.sorted(by: >) { let text = g.resolve(ChartText.label(priceScale.label(v), size: layout.axis * 0.9)) - g.draw(text, at: CGPoint(x: width + 4, y: layout.priceY(v, priceScale)), anchor: .leading) + let y = layout.priceY(v, priceScale) + if let lastY, abs(y - lastY) < text.measure(in: size).height + 2 { continue } + g.draw(text, at: CGPoint(x: width + 4, y: y), anchor: .leading) + lastY = y } // The price: a bar per stretch of equal price and equal ink. Ink by