From 0fb8dfc0e35167b42623d5c92e27e801b39ea69d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 15:20:46 +0000 Subject: [PATCH] fix(native): match the web app's cards, tables, sidebar, chart tooltips and sankey Cards no longer draw an opaque shadow under their glass, the aurora wash and grain follow the web values, the sidebar uses the web row states instead of the system list highlight, tables fill their cards through a shared VisionTable layout, the insights bar chart shows the web floating tooltip, the sankey highlights only the hovered element, the forecast diagnostics follow the web layout and the portfolio ticker follows the web tape. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_018QJtaKt8X8tTpztJkkqNjD --- .../Components/AuroraCanvas.swift | 56 ++-- .../Components/ChartHoverTooltip.swift | 111 ++++++++ .../Components/NativeSidebarRail.swift | 9 +- .../Components/VisionCard.swift | 8 +- .../Components/VisionCardLayers.swift | 33 ++- .../Components/VisionTable.swift | 209 +++++++++++++++ .../DatabaseMaintenanceView.swift | 40 ++- .../VisionNativeUI/EndpointLivenessView.swift | 33 ++- .../VisionNativeUI/ExchangeRatesView.swift | 29 +-- .../HomeForecastDiagnostics.swift | 239 ++++++++++++------ .../Sources/VisionNativeUI/HomeView.swift | 2 +- .../VisionNativeUI/InsightCharts.swift | 52 ++-- .../VisionNativeUI/PlannedPaymentsView.swift | 2 + .../PortfolioPerformanceWidgets.swift | 41 +-- .../VisionNativeUI/PortfolioTickerView.swift | 185 +++++++++----- .../VisionNativeUI/ProviderHealthView.swift | 45 ++-- .../ResearchCompareTables.swift | 4 +- .../Resources/Localizable.xcstrings | 36 +++ .../Screens/Reports/TaxTableViewport.swift | 9 +- .../VisionNativeUI/ShellRootView.swift | 206 +++++++++++---- .../VisionNativeUI/StatisticsFlowView.swift | 111 ++++---- .../VisionNativeUI/StatisticsView.swift | 9 +- .../VisionNativeUI/TaxBreakdownViews.swift | 2 + .../VisionNativeUI/TaxChartsView.swift | 2 + apps/native/i18n-keys.txt | 2 + 25 files changed, 1052 insertions(+), 423 deletions(-) create mode 100644 apps/native/Sources/VisionNativeUI/Components/ChartHoverTooltip.swift create mode 100644 apps/native/Sources/VisionNativeUI/Components/VisionTable.swift diff --git a/apps/native/Sources/VisionNativeUI/Components/AuroraCanvas.swift b/apps/native/Sources/VisionNativeUI/Components/AuroraCanvas.swift index 0d6ddc412..1ce92d4ca 100644 --- a/apps/native/Sources/VisionNativeUI/Components/AuroraCanvas.swift +++ b/apps/native/Sources/VisionNativeUI/Components/AuroraCanvas.swift @@ -100,11 +100,14 @@ struct AuroraCanvas: View { } } - /// `radial-gradient(120rem 60rem at 70% -20%, primary / wash, transparent 60%)`. + /// `radial-gradient(120rem 60rem at 70% -20%, primary / wash, transparent 60%)`: an ellipse of + /// 1920 by 960 pt radii that is clear from 60 % of them. `EllipticalGradient` counts its + /// radius fractions against the frame's width and height (0.5 reaches the edge), so the + /// 3840 by 1920 pt frame ends the fade at 0.3. private func wash(_ size: CGSize) -> some View { EllipticalGradient( - colors: [theme.primary.opacity(alphas.wash), .clear], center: .center, startRadiusFraction: 0, - endRadiusFraction: 0.6 + colors: [theme.primary.opacity(alphas.wash), theme.primary.opacity(0)], center: .center, + startRadiusFraction: 0, endRadiusFraction: 0.3 ) .frame(width: 1920 * 2, height: 960 * 2) .position(x: size.width * 0.7, y: -size.height * 0.2) @@ -253,7 +256,11 @@ private struct AuroraBlobs: NSViewRepresentable { } } -/// `.liquid-canvas-grain`: a tiled 160×160 fractal-noise texture. +/// `.liquid-canvas-grain`: a tiled 160 by 160 film grain. The web tile is an SVG +/// `feTurbulence` (`fractalNoise`, base frequency 0.8, three octaves): at that frequency a noise +/// cell is about one pixel, so the tile reads as fine per-pixel grain with every channel, +/// alpha included, noisy around its middle value. Three octaves of white noise, weighted like +/// the turbulence sum, give the same grain; the pixels are premultiplied. private struct AuroraGrain: View { private static let tile: Image? = { let side = 160 @@ -262,43 +269,22 @@ private struct AuroraGrain: View { state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 return Double(state >> 33) / Double(UInt64(1) << 31) } - var lattices: [[Double]] = [] - for octave in 0..<3 { - let cells = 8 << octave - lattices.append((0..<(cells * cells)).map { _ in next() }) + /// One channel: 0.5 plus the octave sum (amplitudes 1, 1/2, 1/4), halved as `fractalNoise` does. + func channel() -> Double { + let sum = (next() - 0.5) + (next() - 0.5) / 2 + (next() - 0.5) / 4 + return max(0, min(1, 0.5 + sum / 2)) } var pixels = [UInt8](repeating: 0, count: side * side * 4) - for y in 0.. CGPoint { + var x = anchor.x - size.width / 2 + var y = anchor.y - size.height - gap + var side = false + if x + size.width > bounds.width - pad { + side = true + x = anchor.x - size.width - gap + y = anchor.y - size.height / 2 + } else if x < pad { + side = true + x = anchor.x + gap + y = anchor.y - size.height / 2 + } + // No room above the chart: drop below the anchor. + if !side, y < -roomAbove { y = anchor.y + gap } + if x + size.width > bounds.width - pad { x = max(pad, bounds.width - size.width - pad) } + if x < pad { x = pad } + return CGPoint(x: x, y: y) + } + + var body: some View { + let origin = Self.origin(anchor: anchor, size: size, bounds: bounds, roomAbove: 160) + VStack(alignment: .leading, spacing: 6) { + if let title, !title.isEmpty { + Text(title) + .font(.caption2.weight(.medium)) + .tracking(1.3) + .textCase(.uppercase) + .foregroundStyle(Color(token: theme.palette.mutedForeground).opacity(0.8)) + } + VStack(alignment: .leading, spacing: 4) { + ForEach(items) { item in + HStack(spacing: 12) { + HStack(spacing: 8) { + if let color = item.color { + Circle().fill(color).frame(width: 8, height: 8).accessibilityHidden(true) + } + Text(item.label).foregroundStyle(theme.foreground.opacity(0.8)) + } + Spacer(minLength: 0) + Text(item.value).fontWeight(.medium).monospacedDigit().foregroundStyle(theme.foreground) + } + .lineLimit(1) + } + } + } + .font(.footnote) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(minWidth: 140, maxWidth: 260, alignment: .leading) + .fixedSize() + .background(surface) + .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } + .offset(x: origin.x, y: origin.y) + // Unmeasured, it would flash at the wrong place for a frame. + .opacity(size == .zero ? 0 : 1) + .frame(width: bounds.width, height: bounds.height, alignment: .topLeading) + .allowsHitTesting(false) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.updatesFrequently) + } + + @ViewBuilder + private var surface: some View { + let radius = DesignTokens.Radius.card + let shape = RoundedRectangle(cornerRadius: radius, style: .continuous) + if fullEffects { + VisionGlassSurface(material: .thick, cornerRadius: radius) + } else { + // `bg-popover shadow-elevation-3`: `0 4px 8px shadow/0.06` (dark 0.35) and the wide + // ambient layer, whose negative spread SwiftUI cannot draw, at a third of its alpha. + shape.fill(theme.popover) + .overlay(shape.strokeBorder(theme.border.opacity(0.6), lineWidth: 1)) + .shadow( + color: Color(token: theme.palette.glassShadow, opacity: theme.isDark ? 0.35 : 0.06), radius: 4, y: 4) + .shadow( + color: Color(token: theme.palette.glassShadow, opacity: theme.isDark ? 0.25 : 0.1), radius: 14, y: 12) + } + } +} diff --git a/apps/native/Sources/VisionNativeUI/Components/NativeSidebarRail.swift b/apps/native/Sources/VisionNativeUI/Components/NativeSidebarRail.swift index bad0a2a20..cbcf667ff 100644 --- a/apps/native/Sources/VisionNativeUI/Components/NativeSidebarRail.swift +++ b/apps/native/Sources/VisionNativeUI/Components/NativeSidebarRail.swift @@ -161,13 +161,6 @@ struct NativeSidebarRail: View { .font(.system(size: 15)) .foregroundStyle(Color(token: theme.glass.icon)) .frame(width: Self.buttonSize, height: Self.buttonSize) - .background( - RoundedRectangle(cornerRadius: DesignTokens.Radius.chip) - .fill( - isActive - ? Color(token: theme.glass.selection, opacity: theme.glass.selectionOpacity) - : Color.clear) - ) .overlay(alignment: .topTrailing) { countPill(badge) } // The update-ready dot, collapsed: `h-[7px] w-[7px]` primary at `right-1 top-1`. .overlay(alignment: .topTrailing) { @@ -177,7 +170,7 @@ struct NativeSidebarRail: View { } .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(SidebarRowButtonStyle(isActive: isActive)) .help(tooltip) .accessibilityLabel(label) .accessibilityValue(badgeLabel) diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionCard.swift b/apps/native/Sources/VisionNativeUI/Components/VisionCard.swift index 7f70d6b2c..05dbf752a 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionCard.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionCard.swift @@ -90,8 +90,8 @@ struct VisionCardBackground: View { } } - /// `glass-thin`'s surface for `VisionCardLayers`. SwiftUI shades a view's shadow by the view's - /// own alpha, so the shadows are scaled by the surface's alpha at the bottom edge, where they show. + /// `glass-thin`'s surface for `VisionCardLayers`. The layers cut the card's shape out of its + /// shadows, as CSS does for a `box-shadow`, so the shadows keep their token alphas. private func layerStyle(frame: Color, highlightAlpha: Double, lift: ShadowLayer?, liftAlpha: Double) -> VisionCardLayerStyle { @@ -114,11 +114,11 @@ struct VisionCardBackground: View { edgeBottom: NSColor(Color(token: palette.glassShadow, opacity: dark ? 0 : 0.05)).cgColor, frame: NSColor(frame).cgColor, shadow: VisionCardShadow( - color: NSColor(Color(token: palette.glassShadow, opacity: (dark ? 0.2 : 0.04) * bottomAlpha)).cgColor, + color: NSColor(Color(token: palette.glassShadow, opacity: dark ? 0.2 : 0.04)).cgColor, radius: dark ? 8 : 6, x: 0, y: dark ? 5 : 4), lift: interactive ? VisionCardShadow( - color: NSColor(Color(token: palette.glassShadow, opacity: liftAlpha * bottomAlpha)).cgColor, + color: NSColor(Color(token: palette.glassShadow, opacity: liftAlpha)).cgColor, radius: CGFloat((lift?.blur ?? 0) / 2), x: CGFloat(lift?.x ?? 0), y: CGFloat(lift?.y ?? 0)) : nil, transitionDuration: hoverAnimation == nil ? nil : DesignTokens.Duration.normal, diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionCardLayers.swift b/apps/native/Sources/VisionNativeUI/Components/VisionCardLayers.swift index 973e557f0..8f792ab03 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionCardLayers.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionCardLayers.swift @@ -45,6 +45,10 @@ struct VisionCardLayerStyle: Equatable { private let host = CALayer() private let shadowLayer = CALayer() private let liftLayer = CALayer() + /// Holds the two shadows and cuts the card's own shape out of them: a CSS `box-shadow` is + /// not painted under its box, so the translucent surface stays as clear as the web card's. + private let shadowHost = CALayer() + private let shadowMask = CAShapeLayer() private let fillLayer = CAGradientLayer() private let edgeLayer = CAGradientLayer() private let edgeMask = CAShapeLayer() @@ -57,10 +61,15 @@ struct VisionCardLayerStyle: Equatable { layer = host wantsLayer = true host.masksToBounds = false - for shadow in [shadowLayer, liftLayer] { - shadow.shadowOpacity = 1 - host.addSublayer(shadow) - } + shadowMask.fillRule = .evenOdd + shadowMask.fillColor = CGColor(gray: 0, alpha: 1) + shadowHost.mask = shadowMask + host.addSublayer(shadowHost) + shadowLayer.shadowOpacity = 1 + // No colour is not "no shadow" to Core Animation (it falls back to opaque black), so + // the lift stays off until a card has one. + liftLayer.shadowOpacity = 0 + for shadow in [shadowLayer, liftLayer] { shadowHost.addSublayer(shadow) } for gradient in [fillLayer, edgeLayer] { gradient.startPoint = CGPoint(x: 0.5, y: 1) gradient.endPoint = CGPoint(x: 0.5, y: 0) @@ -89,7 +98,7 @@ struct VisionCardLayerStyle: Equatable { override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() let scale = window?.backingScaleFactor ?? 2 - for layer in [host, shadowLayer, liftLayer, fillLayer, edgeLayer, edgeMask, frameLayer] { + for layer in [host, shadowHost, shadowMask, shadowLayer, liftLayer, fillLayer, edgeLayer, edgeMask, frameLayer] { layer.contentsScale = scale } } @@ -114,8 +123,9 @@ struct VisionCardLayerStyle: Equatable { Self.setShadow(shadowLayer, style.shadow) if let lift = style.lift { Self.setShadow(liftLayer, lift) + liftLayer.shadowOpacity = 1 } else { - liftLayer.shadowColor = nil + liftLayer.shadowOpacity = 0 } CATransaction.commit() needsLayout = true @@ -134,6 +144,14 @@ struct VisionCardLayerStyle: Equatable { } shadowLayer.shadowPath = outline liftLayer.shadowPath = outline + // Everything around the card, far enough out for the widest shadow, minus the card. + let reach = bounds.insetBy(dx: -Self.shadowReach, dy: -Self.shadowReach) + let around = CGMutablePath() + around.addRect(reach) + around.addPath(outline) + shadowHost.frame = bounds + shadowMask.frame = bounds + shadowMask.path = around fillLayer.cornerRadius = radius frameLayer.cornerRadius = radius // `shape.inset(by: 1).strokeBorder(…, lineWidth: 1)`: a 1 pt line just inside 1 pt of inset. @@ -142,6 +160,9 @@ struct VisionCardLayerStyle: Equatable { CATransaction.commit() } + /// How far outside the card a shadow may paint (offset plus blur of the largest lift). + private static let shadowReach: CGFloat = 96 + /// SwiftUI's `.shadow(color:radius:x:y:)`: y grows downward there and upward here. private static func setShadow(_ layer: CALayer, _ shadow: VisionCardShadow) { layer.shadowColor = shadow.color diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift b/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift new file mode 100644 index 000000000..3da8f9929 --- /dev/null +++ b/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift @@ -0,0 +1,209 @@ +import SwiftUI +import VisionNativeCore + +/// The web `` (`components/ui/table.tsx`: `w-full`, automatic table layout) as a layout. +/// +/// A SwiftUI `Grid` sizes every column to its content, so a table in a wide card sat packed +/// against the leading edge. This layout fills the width it is offered, as `w-full` does: each +/// column starts at the widest of its cells and the room left over is shared between the +/// columns in proportion to those widths (CSS automatic table layout). When the content is +/// wider than the card the columns shrink in the same proportion. +/// +/// The cells are the layout's direct children in row order, one per column. A child marked +/// with `visionTableSpan()` takes a whole row (a divider, an expanded detail row, an empty +/// state). +/// +/// VisionTable(columns: [.leading, .trailing]) { +/// Text("Currency") +/// Text("Rate") +/// ForEach(rows) { row in +/// Divider().visionTableSpan() +/// Text(row.currency) +/// Text(row.rate) +/// } +/// } +struct VisionTable: View { + /// The horizontal alignment of each column's cells; its count is the column count. + let columns: [HorizontalAlignment] + /// The gap between two columns (the two `px-4` paddings that meet there are 32 pt on the + /// web; the native tables keep the 16 pt they were drawn with unless a table asks). + var columnSpacing: CGFloat = 16 + /// A column that keeps its content width instead of taking a share of the spare room + /// (`w-24`, an actions column). + var fixedColumns: Set = [] + @ViewBuilder var content: () -> Content + + var body: some View { + VisionTableLayout(columns: columns, columnSpacing: columnSpacing, fixedColumns: fixedColumns) { + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +extension View { + /// The view takes a whole row of its `VisionTable`. + func visionTableSpan() -> some View { layoutValue(key: VisionTableSpanKey.self, value: true) } +} + +private struct VisionTableSpanKey: LayoutValueKey { + static let defaultValue = false +} + +struct VisionTableLayout: Layout { + let columns: [HorizontalAlignment] + let columnSpacing: CGFloat + let fixedColumns: Set + + struct Cache { + var width: CGFloat? + var plan: Plan? + } + + /// The measured table: where each row starts, how tall it is and how wide each column is. + struct Plan { + var columnWidths: [CGFloat] + /// One entry per row: the subview indices in it, whether it spans, its top and height. + var rows: [Row] + var size: CGSize + + struct Row { + var indices: [Int] + var spans: Bool + var top: CGFloat + var height: CGFloat + } + } + + func makeCache(subviews: Subviews) -> Cache { Cache() } + + func updateCache(_ cache: inout Cache, subviews: Subviews) { cache = Cache() } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize { + plan(width: proposal.width, subviews: subviews, cache: &cache).size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) { + let plan = plan(width: bounds.width, subviews: subviews, cache: &cache) + for row in plan.rows { + if row.spans { + for index in row.indices { + subviews[index].place( + at: CGPoint(x: bounds.minX, y: bounds.minY + row.top), anchor: .topLeading, + proposal: ProposedViewSize(width: bounds.width, height: row.height)) + } + continue + } + var x = bounds.minX + for (column, index) in row.indices.enumerated() { + let width = plan.columnWidths[column] + let size = subviews[index].sizeThatFits(ProposedViewSize(width: width, height: nil)) + let offset: CGFloat + switch columns[column] { + case .trailing: offset = width - size.width + case .center: offset = (width - size.width) / 2 + default: offset = 0 + } + subviews[index].place( + at: CGPoint(x: x + max(0, offset), y: bounds.minY + row.top + (row.height - size.height) / 2), + anchor: .topLeading, proposal: ProposedViewSize(width: width, height: nil)) + x += width + columnSpacing + } + } + } + + /// Groups the subviews into rows: a spanning child is a row of its own, the others fill + /// rows of `columns.count` cells. + static func rows(spans: [Bool], columnCount: Int) -> [(indices: [Int], spans: Bool)] { + var rows: [(indices: [Int], spans: Bool)] = [] + var current: [Int] = [] + for (index, isSpan) in spans.enumerated() { + if isSpan { + if !current.isEmpty { + rows.append((current, false)) + current = [] + } + rows.append(([index], true)) + } else { + current.append(index) + if current.count == columnCount { + rows.append((current, false)) + current = [] + } + } + } + if !current.isEmpty { rows.append((current, false)) } + return rows + } + + /// CSS automatic table layout for `available` points: the content widths, plus a share of + /// the spare room in proportion to them (fixed columns keep theirs), or scaled down in + /// proportion when the content is wider. + static func columnWidths(ideal: [CGFloat], available: CGFloat?, spacing: CGFloat, fixed: Set) -> [CGFloat] { + guard let available, !ideal.isEmpty else { return ideal } + let room = available - spacing * CGFloat(ideal.count - 1) + let total = ideal.reduce(0, +) + guard room > 0, total > 0 else { return ideal } + if room >= total { + let flexible = ideal.enumerated().filter { !fixed.contains($0.offset) }.map(\.element).reduce(0, +) + // Nothing can grow: every column is fixed, so the last one takes the room. + guard flexible > 0 else { + var widths = ideal + widths[widths.count - 1] += room - total + return widths + } + let spare = room - total + return ideal.enumerated().map { index, width in + fixed.contains(index) ? width : width + spare * width / flexible + } + } + return ideal.map { $0 * room / total } + } + + private func plan(width: CGFloat?, subviews: Subviews, cache: inout Cache) -> Plan { + if let plan = cache.plan, cache.width == width { return plan } + let count = max(1, columns.count) + let grouped = Self.rows(spans: subviews.map { $0[VisionTableSpanKey.self] }, columnCount: count) + var ideal = [CGFloat](repeating: 0, count: count) + for row in grouped where !row.spans { + for (column, index) in row.indices.enumerated() { + ideal[column] = max(ideal[column], subviews[index].sizeThatFits(.unspecified).width) + } + } + let finite = width.flatMap { $0.isFinite ? $0 : nil } + let widths = Self.columnWidths(ideal: ideal, available: finite, spacing: columnSpacing, fixed: fixedColumns) + let tableWidth = finite ?? (widths.reduce(0, +) + columnSpacing * CGFloat(count - 1)) + var rows: [Plan.Row] = [] + var top: CGFloat = 0 + for row in grouped { + var height: CGFloat = 0 + if row.spans { + for index in row.indices { + height = max( + height, subviews[index].sizeThatFits(ProposedViewSize(width: tableWidth, height: nil)).height) + } + } else { + for (column, index) in row.indices.enumerated() { + height = max( + height, + subviews[index].sizeThatFits(ProposedViewSize(width: widths[column], height: nil)).height) + } + } + rows.append(Plan.Row(indices: row.indices, spans: row.spans, top: top, height: height)) + top += height + } + let plan = Plan(columnWidths: widths, rows: rows, size: CGSize(width: tableWidth, height: top)) + cache.width = width + cache.plan = plan + return plan + } +} + +/// A row's bottom border (`border-b border-border/40` of `TableRow`), across the whole table. +struct VisionTableDivider: View { + @Environment(\.visionTheme) private var theme + + var body: some View { + Rectangle().fill(theme.border.opacity(0.4)).frame(height: 1).visionTableSpan() + } +} diff --git a/apps/native/Sources/VisionNativeUI/DatabaseMaintenanceView.swift b/apps/native/Sources/VisionNativeUI/DatabaseMaintenanceView.swift index 27da17b76..35a315d72 100644 --- a/apps/native/Sources/VisionNativeUI/DatabaseMaintenanceView.swift +++ b/apps/native/Sources/VisionNativeUI/DatabaseMaintenanceView.swift @@ -129,33 +129,29 @@ public struct DatabaseMaintenanceView: View { @ViewBuilder private var table: some View { - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 0) { - GridRow { - ForEach(headers) { header in - Text(strings.t(header.key)) - .font(.caption.weight(.medium)) - .foregroundStyle(theme.secondaryLabel) - .padding(.vertical, 8) - .gridColumnAlignment(header.trailing ? .trailing : .leading) - .accessibilityAddTraits(.isHeader) - } - // `sr-only`: read by VoiceOver, not drawn. - Text(strings.t("dbMaintenance.col.actions")) - .font(.caption) - .foregroundStyle(Color.clear) - .gridColumnAlignment(.trailing) + // `
`: full width; the actions column keeps its button's width. + VisionTable(columns: headers.map { $0.trailing ? .trailing : .leading } + [.trailing], fixedColumns: [5]) { + ForEach(headers) { header in + Text(strings.t(header.key)) + .font(.caption.weight(.medium)) + .foregroundStyle(theme.secondaryLabel) + .padding(.vertical, 8) .accessibilityAddTraits(.isHeader) } + // `sr-only`: read by VoiceOver, not drawn. + Text(strings.t("dbMaintenance.col.actions")) + .font(.caption) + .foregroundStyle(Color.clear) + .frame(width: 1) + .accessibilityAddTraits(.isHeader) if model.isLoading { ForEach(0..<5, id: \.self) { _ in - Divider().gridCellUnsizedAxes(.horizontal) - GridRow { - ForEach(0..<7, id: \.self) { _ in SkeletonBar(width: 72, height: 14).padding(.vertical, 10) } - } + VisionTableDivider() + ForEach(0..<6, id: \.self) { _ in SkeletonBar(width: 72, height: 14).padding(.vertical, 10) } } } else { ForEach(model.rows(settings: shell.settings)) { row in - Divider().gridCellUnsizedAxes(.horizontal) + VisionTableDivider() tableRow(row) } } @@ -173,8 +169,8 @@ public struct DatabaseMaintenanceView: View { } } - private func tableRow(_ row: DatabaseTableRow) -> GridRow { - GridRow { + private func tableRow(_ row: DatabaseTableRow) -> some View { + Group { Button { shell.navigate(to: row.editorRoute) } label: { diff --git a/apps/native/Sources/VisionNativeUI/EndpointLivenessView.swift b/apps/native/Sources/VisionNativeUI/EndpointLivenessView.swift index 73321b242..538d24ad9 100644 --- a/apps/native/Sources/VisionNativeUI/EndpointLivenessView.swift +++ b/apps/native/Sources/VisionNativeUI/EndpointLivenessView.swift @@ -56,27 +56,25 @@ public struct EndpointLivenessView: View { @ViewBuilder private var table: some View { let rows = model.filteredRows - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 0) { - GridRow { - ForEach(Array(columns.enumerated()), id: \.offset) { index, column in - Text(column) - .font(.caption.weight(.medium)) - .foregroundStyle(theme.secondaryLabel) - .padding(.vertical, 8) - .gridColumnAlignment(index < 2 ? .leading : .trailing) - .accessibilityAddTraits(.isHeader) - } + // `
`: full width; Method is `w-24`, the rest share the card. + VisionTable( + columns: [.leading, .leading, .trailing, .trailing, .trailing, .trailing, .trailing], fixedColumns: [0] + ) { + ForEach(Array(columns.enumerated()), id: \.offset) { _, column in + Text(column) + .font(.caption.weight(.medium)) + .foregroundStyle(theme.secondaryLabel) + .padding(.vertical, 8) + .accessibilityAddTraits(.isHeader) } if model.isLoading { ForEach(0..<10, id: \.self) { _ in - Divider().gridCellUnsizedAxes(.horizontal) - GridRow { - ForEach(0..<7, id: \.self) { _ in SkeletonBar(width: 56, height: 14).padding(.vertical, 10) } - } + VisionTableDivider() + ForEach(0..<7, id: \.self) { _ in SkeletonBar(width: 56, height: 14).padding(.vertical, 10) } } } else if !rows.isEmpty { ForEach(rows) { row in - Divider().gridCellUnsizedAxes(.horizontal) + VisionTableDivider() endpointRow(row) } } @@ -107,10 +105,11 @@ public struct EndpointLivenessView: View { } } - private func endpointRow(_ row: EndpointRow) -> GridRow { + @ViewBuilder + private func endpointRow(_ row: EndpointRow) -> some View { let tone = row.methodTone let neutral = tone == .neutral - return GridRow { + Group { Text(row.method) .font(.caption.monospaced()) .foregroundStyle(methodColor(tone)) diff --git a/apps/native/Sources/VisionNativeUI/ExchangeRatesView.swift b/apps/native/Sources/VisionNativeUI/ExchangeRatesView.swift index c0cae9e36..7a40edefd 100644 --- a/apps/native/Sources/VisionNativeUI/ExchangeRatesView.swift +++ b/apps/native/Sources/VisionNativeUI/ExchangeRatesView.swift @@ -198,25 +198,20 @@ public struct ExchangeRatesView: View { .foregroundStyle(theme.secondaryLabel) } .padding(VisionPageLayout.padding) - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 0) { - GridRow { - ForEach(Array(columns.enumerated()), id: \.offset) { index, column in - Text(column) - .font(.caption.weight(.medium)) - .foregroundStyle(theme.secondaryLabel) - .padding(.vertical, 8) - .gridColumnAlignment(index == 0 ? .leading : .trailing) - .accessibilityAddTraits(.isHeader) - } + VisionTable(columns: [.leading] + Array(repeating: .trailing, count: max(0, columns.count - 1))) { + ForEach(Array(columns.enumerated()), id: \.offset) { _, column in + Text(column) + .font(.caption.weight(.medium)) + .foregroundStyle(theme.secondaryLabel) + .padding(.vertical, 8) + .accessibilityAddTraits(.isHeader) } ForEach(table.rows) { row in - Divider().gridCellUnsizedAxes(.horizontal) - GridRow { - cell(row.currency.rawValue, font: .body.monospaced().weight(.medium)) - cell(row.rate, font: .body.monospaced()) - cell(row.inverse, font: .body.monospaced()) - cell(row.hundredInEuro, font: .body) - } + VisionTableDivider() + cell(row.currency.rawValue, font: .body.monospaced().weight(.medium)) + cell(row.rate, font: .body.monospaced()) + cell(row.inverse, font: .body.monospaced()) + cell(row.hundredInEuro, font: .body) } } .padding(.horizontal, 16) diff --git a/apps/native/Sources/VisionNativeUI/HomeForecastDiagnostics.swift b/apps/native/Sources/VisionNativeUI/HomeForecastDiagnostics.swift index 1cbe04f32..434c8edb2 100644 --- a/apps/native/Sources/VisionNativeUI/HomeForecastDiagnostics.swift +++ b/apps/native/Sources/VisionNativeUI/HomeForecastDiagnostics.swift @@ -5,8 +5,6 @@ struct HomeForecastDiagnostics: View { let model: HomeModel let settings: NativeAppSettings let strings: any Localizing - @State private var selected: [String: String] = [:] - @State private var range: [String: String] = [:] @Environment(\.visionDismissDialog) private var dismiss @Environment(\.visionTheme) private var theme @@ -20,39 +18,45 @@ struct HomeForecastDiagnostics: View { if model.isForecastLoading { ProgressView() } else if let diagnostics = model.forecast?.diagnostics { + let ranked = diagnostics.backtest.sorted { $0.mae < $1.mae } + let hasPersisted = !(model.forecastAccuracy?.methods.isEmpty ?? true) Text( strings.t("cashflow.diagnostics.desc", ["months": String(diagnostics.history_months)]) ).foregroundStyle(theme.secondaryLabel) - Text(strings.t("cashflow.diagnostics.accuracy")).font(.headline) - accuracy(diagnostics) - Text( - strings.t( - "cashflow.diagnostics.backtestNote", - [ - "n": String(diagnostics.history_months), - "currency": (model.forecast?.currency ?? LedgerCurrency.display(settings)).rawValue, - ]) - ).font(.footnote).foregroundStyle(theme.secondaryLabel) + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Text(strings.t("cashflow.diagnostics.accuracy")).font(.headline) + .accessibilityAddTraits(.isHeader) + Spacer(minLength: 8) + if hasPersisted { + Label(strings.t("cashflow.diagnostics.persistedHistory"), systemImage: "cylinder.split.1x2") + .font(.caption).foregroundStyle(theme.secondaryLabel) + } + } + accuracy(ranked) + Text( + hasPersisted + ? strings.t("cashflow.diagnostics.backtestNoteWithHistory") + : strings.t( + "cashflow.diagnostics.backtestNote", + ["n": String(diagnostics.history_months), "currency": currency.rawValue]) + ).font(.footnote).foregroundStyle(theme.secondaryLabel) + } // The weights the ensemble applied, computed on the server. let weights = diagnostics.ensemble_weights ?? [:] if !weights.isEmpty { - let maxWeight = weights.values.max() ?? 1 - Text(strings.t("cashflow.diagnostics.suggestedWeights")).font(.headline) - Text(strings.t("cashflow.diagnostics.weightsNote")).font(.footnote).foregroundStyle( - theme.secondaryLabel) - ForEach(diagnostics.backtest.sorted { $0.mae < $1.mae }) { method in - if let weight = weights[method.id] { - HStack { - Text(method.label) - Spacer() - Text( - VisionFormat.decimal( - 100 * weight, fractionDigits: 0, locale: settings.numberFormat.locale) - + "%") + let topWeight = max(weights.values.max() ?? 0, 0) + VStack(alignment: .leading, spacing: 8) { + Text(strings.t("cashflow.diagnostics.suggestedWeights")).font(.headline) + .accessibilityAddTraits(.isHeader) + Text(strings.t("cashflow.diagnostics.weightsNote")).font(.footnote).foregroundStyle( + theme.secondaryLabel + ) + .padding(.bottom, 4) + ForEach(ranked) { method in + if let weight = weights[method.id] { + weightRow(method, weight: weight, topWeight: topWeight) } - ProgressView( - value: maxWeight > 0 ? NSDecimalNumber(decimal: weight / maxWeight).doubleValue : 0 - ).tint(theme.primary) } } } @@ -68,68 +72,137 @@ struct HomeForecastDiagnostics: View { .onDisappear { model.forecastOptions.diagnostics = false } } - private func accuracy(_ diagnostics: InsightForecast.Diagnostics) -> some View { - VStack(alignment: .leading, spacing: 12) { - Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 10) { - GridRow { - Text(strings.t("cashflow.diagnostics.rank")) - Text(strings.t("cashflow.diagnostics.method")) - Text("MAE") - Text("RMSE") - Text("MAPE") - Text(strings.t("cashflow.diagnostics.months")) - }.font(.caption.weight(.semibold)).foregroundStyle(theme.secondaryLabel) - ForEach( - Array(diagnostics.backtest.sorted { $0.mae < $1.mae }.enumerated()), id: \.element.id - ) { index, method in - GridRow { - Text("#\(index + 1)").foregroundStyle(index == 0 ? theme.warning : theme.secondaryLabel) - Text(method.label) - amount(method.mae) - amount(method.rmse) - Text( - method.mape.map { - $0 > 9999 - ? "—" - : VisionFormat.decimal( - $0, fractionDigits: 1, locale: settings.numberFormat.locale) + "%" - } ?? "—") - Text(String(method.months)) - }.font(.caption) - } + private var currency: CurrencyCode { model.forecast?.currency ?? LedgerCurrency.display(settings) } + + /// `METHOD_COLORS`: each forecasting method keeps its chart colour; an unknown one takes the eighth. + private func color(_ method: String) -> Color { + let order = [ + "simple_avg", "weighted_avg", "ewma", "holt_winters", "prophet_lite", "monte_carlo_parametric", + "monte_carlo_block_bootstrap", + ] + let colors = theme.chartColors + let index = order.firstIndex(of: method) ?? 7 + return index < colors.count ? colors[index] : theme.primary + } + + /// The accuracy `
` in its bordered frame: method, MAE, RMSE, MAPE, months, rank, MAE trend. + private func accuracy(_ ranked: [InsightForecast.Backtest]) -> some View { + VisionTable( + columns: [.leading, .trailing, .trailing, .trailing, .trailing, .leading, .leading], columnSpacing: 20, + fixedColumns: [6] + ) { + Group { + Text(strings.t("cashflow.diagnostics.method")) + Text(verbatim: "MAE") + Text(verbatim: "RMSE") + Text(verbatim: "MAPE") + Text(strings.t("cashflow.diagnostics.months")) + Text(strings.t("cashflow.diagnostics.rank")) + Text(strings.t("cashflow.diagnostics.maeTrend")) } - .padding(12) - .overlay( - RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous) - .strokeBorder(theme.border.opacity(0.6))) - ForEach(diagnostics.backtest.sorted { $0.mae < $1.mae }) { method in - let persisted = model.forecastAccuracy?.methods.first { $0.id == method.id } - let history = persisted?.history ?? method.per_month - VStack(alignment: .leading, spacing: 5) { - HStack { - Text(method.label).font(.caption.weight(.medium)) - if persisted != nil { - Text(strings.t("cashflow.diagnostics.persistedHistory")).font(.caption) - .foregroundStyle(theme.secondaryLabel) - } + .font(.footnote.weight(.medium)).foregroundStyle(theme.secondaryLabel).lineLimit(1) + .padding(.vertical, 10) + .accessibilityAddTraits(.isHeader) + ForEach(Array(ranked.enumerated()), id: \.element.id) { index, method in + VisionTableDivider() + Group { + HStack(spacing: 8) { + Circle().fill(color(method.id)).frame(width: 10, height: 10).accessibilityHidden(true) + Text(method.label).fontWeight(.medium).lineLimit(2) } - InsightTimelineChart( - series: [ - InsightChartSeries( - id: method.id, label: "MAE", - points: history.map { InsightChartPoint(key: $0.month, value: $0.mae) }) - ], - currency: model.forecast?.currency ?? LedgerCurrency.display(settings), settings: settings, - title: strings.t("cashflow.diagnostics.maeTrend"), height: 70, sparkline: true, - selection: Binding(get: { selected[method.id] }, set: { selected[method.id] = $0 }), - rangeStart: Binding(get: { range[method.id] }, set: { range[method.id] = $0 })) + amount(method.mae) + amount(method.rmse) + Text(mapeText(method.mape)).monospacedDigit() + Text(String(method.months)).monospacedDigit().foregroundStyle(theme.secondaryLabel) + rank(index + 1) + trend(method) + } + .padding(.vertical, 8) + } + } + .padding(.horizontal, 16) + .overlay( + RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous) + .strokeBorder(theme.border.opacity(0.6))) + } + + /// MAPE to one decimal; "N/A" without one or above 9999 %, as on the web. + private func mapeText(_ mape: Decimal?) -> String { + guard let mape, mape <= 9999 else { return "N/A" } + return VisionFormat.percent(mape, digits: 1, locale: settings.numberFormat.locale) + } + + /// `RankBadge`: a warning badge for the best method, muted for the next two, plain text after. + @ViewBuilder + private func rank(_ rank: Int) -> some View { + if rank <= 3 { + VisionBadge(text: "#\(rank)", tone: rank == 1 ? .warning : .muted, small: true) + } else { + Text(verbatim: "#\(rank)").font(.footnote).foregroundStyle(theme.secondaryLabel) + } + } + + /// The MAE sparkline (`w-24`, 24 pt tall): the persisted history when there is one, else + /// the backtest's own months. + @ViewBuilder + private func trend(_ method: InsightForecast.Backtest) -> some View { + let persisted = model.forecastAccuracy?.methods.first { $0.id == method.id }?.history + let history = (persisted?.count ?? 0) > 1 ? persisted ?? [] : method.per_month + let values = history.map { NSDecimalNumber(decimal: $0.mae).doubleValue } + if values.count > 1 { + ForecastSparkline(values: values) + .stroke(color(method.id), style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)) + .frame(width: 96, height: 24) + .accessibilityLabel(strings.t("cashflow.diagnostics.maeTrend")) + } else { + Text(verbatim: "—").font(.footnote).foregroundStyle(theme.secondaryLabel).frame(width: 96, alignment: .leading) + } + } + + /// One ensemble weight: the method's dot and name (`w-44`), its bar against the largest + /// weight and the rounded percentage. + private func weightRow(_ method: InsightForecast.Backtest, weight: Decimal, topWeight: Decimal) -> some View { + let share = topWeight > 0 ? NSDecimalNumber(decimal: weight / topWeight).doubleValue : 0 + let percent = Int((NSDecimalNumber(decimal: weight).doubleValue * 100).rounded()) + return HStack(spacing: 12) { + Circle().fill(color(method.id)).frame(width: 8, height: 8).accessibilityHidden(true) + Text(method.label).font(.footnote).lineLimit(1).truncationMode(.tail) + .frame(width: 176, alignment: .leading) + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(theme.foreground.opacity(0.08)) + Capsule().fill(color(method.id)).frame(width: proxy.size.width * min(1, max(0, share))) } } + .frame(height: 6) + Text(verbatim: "\(percent)%").font(.footnote).monospacedDigit().foregroundStyle(theme.secondaryLabel) + .frame(width: 36, alignment: .trailing) } + .accessibilityElement(children: .combine) } + private func amount(_ value: Decimal) -> some View { MoneyText( - amount: value, currency: model.forecast?.currency ?? LedgerCurrency.display(settings), - settings: settings, signed: false, colored: false) + amount: value, currency: currency, settings: settings, signed: false, colored: false) + } +} + +/// `Sparkline` (components/charts/Sparkline.tsx): the values across the width, the range padded +/// by a tenth and inset 2 pt top and bottom. +private struct ForecastSparkline: Shape { + let values: [Double] + + func path(in rect: CGRect) -> Path { + var path = Path() + guard values.count > 1, let low = values.min(), let high = values.max() else { return path } + let pad = high != low ? (high - low) * 0.1 : 1 + let bottom = low - pad + let span = (high + pad) - bottom + for (index, value) in values.enumerated() { + let x = rect.minX + rect.width * CGFloat(index) / CGFloat(values.count - 1) + let y = rect.maxY - 2 - (rect.height - 4) * CGFloat((value - bottom) / span) + if index == 0 { path.move(to: CGPoint(x: x, y: y)) } else { path.addLine(to: CGPoint(x: x, y: y)) } + } + return path } } diff --git a/apps/native/Sources/VisionNativeUI/HomeView.swift b/apps/native/Sources/VisionNativeUI/HomeView.swift index 3bd0baa74..c63637293 100644 --- a/apps/native/Sources/VisionNativeUI/HomeView.swift +++ b/apps/native/Sources/VisionNativeUI/HomeView.swift @@ -37,7 +37,7 @@ public struct HomeView: View { if let notifications = shell.shellNotifications, notifications.upcoming.shouldShow { ShellUpcomingPaymentsBanner(model: notifications.upcoming, shell: shell) } - VisionPageHeader(title: greeting, subtitle: todayLong, symbol: "house") { + VisionPageHeader(title: greeting, subtitle: todayLong, symbol: "square.grid.2x2") { Menu { Button(strings.t("home.customize")) { customize = true } } label: { diff --git a/apps/native/Sources/VisionNativeUI/InsightCharts.swift b/apps/native/Sources/VisionNativeUI/InsightCharts.swift index 78370d087..8f79deecb 100644 --- a/apps/native/Sources/VisionNativeUI/InsightCharts.swift +++ b/apps/native/Sources/VisionNativeUI/InsightCharts.swift @@ -318,6 +318,16 @@ struct InsightTimelineChart: View { }, onPress: { focused = true }, scrubbable: rangeComparison) + // `ChartTooltip`: the hovered category's values, floating at its tallest point. + if !sparkline, scrub.range == nil, let selection, let position = index[selection], + let frame = proxy.plotFrame.map({ geometry[$0] }), + let x = proxy.position(forX: Double(position)), + let y = proxy.position(forY: tooltipAnchorValue(selection, rects: rects)) + { + ChartHoverTooltip( + title: periodLabel(selection), items: tooltipItems(selection), + anchor: CGPoint(x: frame.minX + x, y: frame.minY + y), bounds: geometry.size) + } } } .frame(height: height) @@ -348,29 +358,31 @@ struct InsightTimelineChart: View { } if placement == .bottomLeading { ChartLegendRow(items: legendItems) } if placement == .bottomCenter { ChartLegendRow(items: legendItems, centered: true) } - if scrub.range == nil, let selection { - VStack(alignment: .leading, spacing: 3) { - Text(periodLabel(selection)).font(.caption.weight(.medium)) - ForEach(series) { line in - if let value = line.points.first(where: { $0.key == selection })?.value { - HStack { - Text(line.label).foregroundStyle(theme.secondaryLabel) - Spacer() - MoneyText( - amount: value, currency: currency, settings: settings, signed: false, - colored: false) - }.font(.caption) - } - } - } - .padding(8).background( - theme.foreground.opacity(0.04), in: RoundedRectangle(cornerRadius: DesignTokens.Radius.md) - ) - .accessibilityElement(children: .combine) - } } } + /// The tooltip's rows: every series with a value at `key`, in series order, with its swatch. + private func tooltipItems(_ key: String) -> [ChartTooltipItem] { + series.indices.compactMap { position in + let line = series[position] + guard let value = line.points.first(where: { $0.key == key })?.value else { return nil } + return ChartTooltipItem( + id: line.id, label: line.label, + value: MoneyText.text(value, currency: currency, settings: settings, signed: false), + color: seriesColor(at: position)) + } + } + + /// Where the tooltip points: the top of the tallest bar of the category (the baseline when + /// every bar is negative), or the highest line value there. + private func tooltipAnchorValue(_ key: String, rects: [BarRect]) -> Double { + let suffix = "|" + key + let tops = rects.filter { $0.id.hasSuffix(suffix) }.map { max($0.y0, $0.y1) } + if let top = tops.max() { return max(top, 0) } + let values = series.compactMap { line in line.points.first { $0.key == key }.map { coordinate($0.value) } } + return values.max() ?? 0 + } + /// The key under an x position of the quantitative axis. private func keyAt(_ position: Double) -> String? { let rounded = Int(position.rounded()) diff --git a/apps/native/Sources/VisionNativeUI/PlannedPaymentsView.swift b/apps/native/Sources/VisionNativeUI/PlannedPaymentsView.swift index f732f783e..76341806c 100644 --- a/apps/native/Sources/VisionNativeUI/PlannedPaymentsView.swift +++ b/apps/native/Sources/VisionNativeUI/PlannedPaymentsView.swift @@ -721,7 +721,9 @@ struct PlannedHistorySheet: View { GridRow { headerText("plannedPage.history.colExecutedOn") headerText("plannedPage.history.colPlanned") + // `
` is `w-full`: this column takes the spare width. headerText("plannedPage.history.colTransaction") + .frame(maxWidth: .infinity, alignment: .leading) headerText("plannedPage.col.amount") .gridColumnAlignment(.trailing) } diff --git a/apps/native/Sources/VisionNativeUI/PortfolioPerformanceWidgets.swift b/apps/native/Sources/VisionNativeUI/PortfolioPerformanceWidgets.swift index 3084bc06e..6c1b4ed7d 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioPerformanceWidgets.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioPerformanceWidgets.swift @@ -198,30 +198,36 @@ struct PortfolioBreakdownView: View { description: shell.strings.t("performance.heatmapDesc"), large: true ) { let monthNames = (1...12).map { monthLabel($0) } + // `
`: the year column is `w-16`, the months and the + // year-to-date column share the card; a narrow card scrolls sideways. ScrollView(.horizontal) { - Grid(horizontalSpacing: 5, verticalSpacing: 5) { - GridRow { - Text(shell.strings.t("performance.year")) + VisionTable( + columns: [.leading] + Array(repeating: .center, count: 13), columnSpacing: 8, + fixedColumns: [0] + ) { + Group { + Text(shell.strings.t("performance.year")).frame(width: 48, alignment: .leading) ForEach(1...12, id: \.self) { month in Text(monthNames[month - 1]).frame(minWidth: 48) } - Text(shell.strings.t("performance.ytd")).frame(minWidth: 58) - }.font(.caption.weight(.medium)).foregroundStyle(theme.secondaryLabel) + Text(shell.strings.t("performance.ytd")).frame(minWidth: 56) + } + .font(.caption.weight(.medium)).foregroundStyle(theme.secondaryLabel).padding(.vertical, 8) ForEach(data.heatmap.years, id: \.self) { year in if let months = data.heatmap.data[String(year)] { - GridRow { - Text(String(year)).font(.caption.weight(.medium)).monospacedDigit() - ForEach(0..<12, id: \.self) { index in - let cell: Decimal? = index < months.count ? months[index] : nil - let cellText = cell.map { signedPercent($0, digits: 2) } - heatCell(cell, text: cellText) - .accessibilityLabel("\(monthNames[index]) \(year)") - .accessibilityValue(cellText ?? shell.strings.t("common.noData2")) - } - let yearly = yearReturn(months) - heatCell(yearly, text: yearly.map { signedPercent($0, digits: 2) }) + Text(String(year)).font(.caption.weight(.medium)).monospacedDigit() + .padding(.vertical, 4) + ForEach(0..<12, id: \.self) { index in + let cell: Decimal? = index < months.count ? months[index] : nil + let cellText = cell.map { signedPercent($0, digits: 2) } + heatCell(cell, text: cellText) + .accessibilityLabel("\(monthNames[index]) \(year)") + .accessibilityValue(cellText ?? shell.strings.t("common.noData2")) } + let yearly = yearReturn(months) + heatCell(yearly, text: yearly.map { signedPercent($0, digits: 2) }) } } } + .containerRelativeFrame(.horizontal, alignment: .leading) { length, _ in max(length, 760) } } HStack(spacing: 4) { Text(shell.strings.t("performance.loss")) @@ -292,8 +298,9 @@ struct PortfolioBreakdownView: View { ? theme.foreground.opacity(0.04) : (amount >= 0 ? theme.gain : theme.loss).opacity(opacity) return Text(text ?? "—") - .font(.caption.monospaced()).frame(minWidth: 48).padding(.vertical, 8) + .font(.caption.monospaced()).frame(minWidth: 48, maxWidth: .infinity).padding(.vertical, 8) .background(color, in: RoundedRectangle(cornerRadius: DesignTokens.Radius.sm)) + .padding(.vertical, 2) .visionTooltip(text ?? shell.strings.t("common.noData2")) } private func yearReturn(_ months: [Decimal?]) -> Decimal? { diff --git a/apps/native/Sources/VisionNativeUI/PortfolioTickerView.swift b/apps/native/Sources/VisionNativeUI/PortfolioTickerView.swift index cab1f4fb2..ab7799e1c 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioTickerView.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioTickerView.swift @@ -7,7 +7,10 @@ struct PortfolioTickerView: View { let shell: NativeShellModel @State private var manager = false @State private var hovered = false - @State private var visible = false + /// On screen until the scroll view says otherwise (the web starts active too). + @State private var visible = true + /// The measured width of one pass of the tape. + @State private var tapeWidth: CGFloat = 0 @State private var elapsed: TimeInterval = 0 @State private var startedAt: Date? @State private var overrides: [InvestmentID: Bool] = [:] @@ -37,49 +40,51 @@ struct PortfolioTickerView: View { } } + /// The bar's text when the tape is empty for a reason worth saying: every holding hidden + /// (the manage menu fixes that) or offline. While quotes load the bar stays quiet. + private var placeholder: String? { + if included.isEmpty { return shell.strings.t("portfolio.ticker.allHidden") } + if !model.isOnline { return shell.strings.t("portfolio.ticker.offline") } + return nil + } + var body: some View { if !quotable.isEmpty { HStack(spacing: 0) { - if quoted.isEmpty { - Text(shell.strings.t(model.isOnline ? "portfolio.ticker.allHidden" : "portfolio.ticker.offline")) - .font(.callout).foregroundStyle(theme.secondaryLabel).padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - } else if reducedMotion { - ScrollView(.horizontal) { tape }.scrollIndicators(.hidden) - } else { - GeometryReader { geometry in - // The tape is built once per pass, not per animation tick: the timeline only moves it. - let count = quoted.count - let width = CGFloat(count) * 250 - let duration = max(24, Double(count) * 4.5) - let strip = HStack(spacing: 0) { - ForEach(0.. 0) - } - } - TimelineView(.animation(minimumInterval: 1 / 30, paused: !moving)) { timeline in - let time = elapsed + (startedAt.map { timeline.date.timeIntervalSince($0) } ?? 0) - strip - .offset(x: -width * CGFloat(time.truncatingRemainder(dividingBy: duration) / duration)) - .frame(width: max(geometry.size.width, width * 2), alignment: .leading) - } - }.frame(height: 54).clipped() + Group { + if quoted.isEmpty { + Text(placeholder ?? "") + .font(.footnote).foregroundStyle(theme.secondaryLabel).padding(.horizontal, 20) + .frame(maxWidth: .infinity, alignment: .leading) + } else if reducedMotion { + ScrollView(.horizontal) { tape }.scrollIndicators(.hidden) + } else { + marquee + } } + // `min-h-[2.625rem]`. + .frame(minHeight: 42) + .accessibilityElement(children: .contain) + .accessibilityLabel(shell.strings.t("portfolio.ticker.aria")) + // `border-l border-border/40 px-1`. + Rectangle().fill(theme.border.opacity(0.4)).frame(width: 1) Button { manager = true } label: { Image(systemName: "slider.horizontal.3") } - .buttonStyle(.vision(.ghost, size: .iconSm)).padding(16) + .buttonStyle(.vision(.ghost, size: .iconSm)).padding(.horizontal, 4) + .foregroundStyle(theme.secondaryLabel) .accessibilityLabel(shell.strings.t("portfolio.ticker.manage")) + .visionTooltip(shell.strings.t("portfolio.ticker.manage")) .popover(isPresented: $manager) { management } } + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) .visionCardBackground() .background { WindowLivenessProbe { windowLive = $0 } } - .accessibilityLabel(shell.strings.t("portfolio.ticker.aria")) .onHover { hovered = $0 } - .onScrollVisibilityChange(threshold: 0.1) { visible = $0 } + .onScrollVisibilityChange(threshold: 0.01) { visible = $0 } .onChange(of: moving, initial: true) { _, moving in if moving { startedAt = Date() @@ -99,49 +104,95 @@ struct PortfolioTickerView: View { } } + /// `.ticker-track`: the tape and enough copies of it to cover the bar, sliding left by one + /// tape width every `max(24, 4.5 × tiles)` seconds, so the speed does not depend on the + /// number of holdings. The tiles keep their natural widths, as on the web; the tape's width + /// is measured once per set of quotes and only the offset changes per frame. + private var marquee: some View { + GeometryReader { geometry in + let duration = max(24, Double(quoted.count) * 4.5) + let copies = tapeWidth > 0 ? max(2, Int((geometry.size.width / tapeWidth).rounded(.up)) + 1) : 2 + let strip = HStack(spacing: 0) { + ForEach(0.. 0) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in + if index == 0, tapeWidth != width { tapeWidth = width } + } + } + } + .fixedSize() + TimelineView(.animation(minimumInterval: 1 / 30, paused: !moving)) { timeline in + let time = elapsed + (startedAt.map { timeline.date.timeIntervalSince($0) } ?? 0) + strip + .offset(x: -tapeWidth * CGFloat(time.truncatingRemainder(dividingBy: duration) / duration)) + .frame(width: geometry.size.width, height: geometry.size.height, alignment: .leading) + } + } + .clipped() + } + + /// One pass of the tape: `border-r border-border/40 px-5 py-2.5` tiles of symbol, price and + /// the day's change. private var tape: some View { HStack(spacing: 0) { ForEach(quoted, id: \.0.id) { investment, quote in HStack(spacing: 8) { PortfolioInlineDisclosure(label: investment.symbol ?? quote.symbol, explanation: investment.name) - .font(.callout.monospaced().weight(.medium)) + .font(.body.monospaced().weight(.medium)) if let price = quote.price { Text( ResearchMoney.text( price, currency: quote.currency, locale: shell.settings.numberFormat.locale, fractionDigits: shell.settings.showDecimalPlaces)) - .font(.callout).monospacedDigit().foregroundStyle(theme.secondaryLabel) + .monospacedDigit().foregroundStyle(theme.secondaryLabel) } - if let change = quote.changePercent { - Label(signedPercent(change), systemImage: change >= 0 ? "arrow.up.right" : "arrow.down.right") - .font(.caption.weight(.medium)).monospacedDigit() - .foregroundStyle(change >= 0 ? theme.gain : theme.loss) + // A quote without a change counts as flat and up, as on the web. + let change = quote.changePercent ?? 0 + HStack(spacing: 2) { + Image(systemName: change >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.system(size: 11, weight: .medium)).accessibilityHidden(true) + Text(signedPercent(change)).fontWeight(.medium).monospacedDigit() } - }.padding(.horizontal, 14).frame(width: 250, height: 54) - .accessibilityElement(children: .combine) + .foregroundStyle(change >= 0 ? theme.gain : theme.loss) + } + .font(.body) + .lineLimit(1) + .fixedSize() + .padding(.horizontal, 20).padding(.vertical, 10) + .overlay(alignment: .trailing) { Rectangle().fill(theme.border.opacity(0.4)).frame(width: 1) } + .accessibilityElement(children: .combine) } } } + /// `TickerManager`: a `w-72` popover with a bordered header and one switch per holding. private var management: some View { - VStack(alignment: .leading, spacing: 14) { - Text(shell.strings.t("portfolio.ticker.manageTitle")).font(.headline) - Text( - shell.strings.t( - "portfolio.ticker.manageCount", ["shown": String(included.count), "total": String(quotable.count)]) - ) - .font(.caption).foregroundStyle(theme.secondaryLabel) + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 2) { + Text(shell.strings.t("portfolio.ticker.manageTitle")).font(.body.weight(.medium)) + Text( + shell.strings.t( + "portfolio.ticker.manageCount", + ["shown": String(included.count), "total": String(quotable.count)]) + ) + .font(.caption).foregroundStyle(theme.secondaryLabel) + } + .padding(.horizontal, 12).padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + Rectangle().fill(theme.border.opacity(0.6)).frame(height: 1) + // `max-h-72 overflow-y-auto`: a long list scrolls inside the popover. + if quotable.count > 7 { + ScrollView { managementRows }.frame(height: 288) + } else { + managementRows + } + } + .frame(width: 288) + } + private var managementRows: some View { + VStack(spacing: 0) { ForEach(quotable) { investment in - Toggle( - isOn: Binding( - get: { overrides[investment.id] ?? (investment.showInTicker != false) }, - set: { value in - overrides[investment.id] = value - Task { - let saved = await editing.setTicker(id: investment.id, visible: value) - if !saved { overrides.removeValue(forKey: investment.id) } - } - }) - ) { + HStack(spacing: 12) { HStack(spacing: 8) { if let symbol = investment.symbol, !symbol.isEmpty { Text(symbol).font(.caption.monospaced().weight(.medium)) @@ -149,13 +200,29 @@ struct PortfolioTickerView: View { Text(investment.name).font(.footnote).foregroundStyle(theme.secondaryLabel) .lineLimit(1).truncationMode(.tail) } + Spacer(minLength: 0) + Toggle( + isOn: Binding( + get: { overrides[investment.id] ?? (investment.showInTicker != false) }, + set: { value in + overrides[investment.id] = value + Task { + let saved = await editing.setTicker(id: investment.id, visible: value) + if !saved { overrides.removeValue(forKey: investment.id) } + } + }) + ) { EmptyView() } + .labelsHidden() + .toggleStyle(.visionSwitch) + .accessibilityLabel(investment.name) + .disabled(editing.isMutating) } - .toggleStyle(.visionSwitch) - .accessibilityLabel(investment.name) - .disabled(editing.isMutating) + .padding(8) } - }.padding(20).frame(width: 340) + } + .padding(6) } + private func symbol(_ investment: PortfolioInvestment) -> String { (investment.priceProviderId ?? investment.symbol ?? "").trimmingCharacters(in: .whitespacesAndNewlines) .uppercased() diff --git a/apps/native/Sources/VisionNativeUI/ProviderHealthView.swift b/apps/native/Sources/VisionNativeUI/ProviderHealthView.swift index 76b0f409e..d6a0342e6 100644 --- a/apps/native/Sources/VisionNativeUI/ProviderHealthView.swift +++ b/apps/native/Sources/VisionNativeUI/ProviderHealthView.swift @@ -55,29 +55,27 @@ public struct ProviderHealthView: View { } private var table: some View { - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 0) { - GridRow { - header(strings.t("admin.providers.colProvider")) - header(strings.t("admin.providers.colKind")) - header(strings.t("admin.providers.colLastSuccess")) - header(strings.t("admin.providers.colFailures")) - header(strings.t("admin.providers.colLastError")) - // `sr-only`: read by VoiceOver, not drawn. - Text(strings.t("dbMaintenance.col.actions")) - .font(.caption) - .foregroundStyle(Color.clear) - .accessibilityAddTraits(.isHeader) - } + // `
`: full width; the Check now column keeps its button's width. + VisionTable(columns: [.leading, .leading, .leading, .leading, .leading, .trailing], fixedColumns: [5]) { + header(strings.t("admin.providers.colProvider")) + header(strings.t("admin.providers.colKind")) + header(strings.t("admin.providers.colLastSuccess")) + header(strings.t("admin.providers.colFailures")) + header(strings.t("admin.providers.colLastError")) + // `sr-only`: read by VoiceOver, not drawn. + Text(strings.t("dbMaintenance.col.actions")) + .font(.caption) + .foregroundStyle(Color.clear) + .frame(width: 1) + .accessibilityAddTraits(.isHeader) if model.providers.isLoading { ForEach(0..<7, id: \.self) { _ in - Divider().gridCellUnsizedAxes(.horizontal) - GridRow { - ForEach(0..<6, id: \.self) { _ in SkeletonBar(width: 72, height: 14).padding(.vertical, 10) } - } + VisionTableDivider() + ForEach(0..<6, id: \.self) { _ in SkeletonBar(width: 72, height: 14).padding(.vertical, 10) } } } else { ForEach(model.rows(settings: shell.settings, strings: strings)) { row in - Divider().gridCellUnsizedAxes(.horizontal) + VisionTableDivider() providerRow(row) if model.isExpanded(row.provider), let error = row.lastError { errorRow(error, at: row.lastErrorAt) @@ -95,8 +93,8 @@ public struct ProviderHealthView: View { .accessibilityAddTraits(.isHeader) } - private func providerRow(_ row: ProviderHealthRow) -> GridRow { - GridRow { + private func providerRow(_ row: ProviderHealthRow) -> some View { + Group { HStack(spacing: 8) { Image(systemName: row.tone.symbol) .foregroundStyle(tone(row.tone)) @@ -144,12 +142,11 @@ public struct ProviderHealthView: View { .buttonStyle(.visionOutline) .controlSize(.small) .disabled(model.isProbing(row.provider)) - .gridColumnAlignment(.trailing) } } - private func errorRow(_ error: String, at timestamp: String?) -> GridRow { - GridRow { + private func errorRow(_ error: String, at timestamp: String?) -> some View { + Group { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(error) .foregroundStyle(theme.destructive) @@ -165,7 +162,7 @@ public struct ProviderHealthView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) .background(theme.destructive.opacity(0.05)) - .gridCellColumns(6) + .visionTableSpan() } } } diff --git a/apps/native/Sources/VisionNativeUI/ResearchCompareTables.swift b/apps/native/Sources/VisionNativeUI/ResearchCompareTables.swift index 340564d9a..561d568c8 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchCompareTables.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchCompareTables.swift @@ -8,10 +8,12 @@ struct ResearchCompareStatisticsView: View { var body: some View { ResearchCard(title: shell.strings.t("research.compare.metrics")) { Text(shell.strings.t("research.compare.historyHint")).font(.callout).foregroundStyle(theme.secondaryLabel) - ScrollView(.horizontal) { + TaxTableViewport(isPrinting: false) { Grid(alignment: .leading, horizontalSpacing: 28, verticalSpacing: 14) { GridRow { + // `
` is `w-full`: the symbol column takes the spare width. Text(shell.strings.t("research.compare.symbol")) + .frame(maxWidth: .infinity, alignment: .leading) Text(shell.strings.t("research.compare.return")).gridColumnAlignment(.trailing) Text(shell.strings.t("research.compare.volatility")).gridColumnAlignment(.trailing) Text(shell.strings.t("research.compare.maxDrawdown")).gridColumnAlignment(.trailing) diff --git a/apps/native/Sources/VisionNativeUI/Resources/Localizable.xcstrings b/apps/native/Sources/VisionNativeUI/Resources/Localizable.xcstrings index 2d40dafa7..3c85a912a 100644 --- a/apps/native/Sources/VisionNativeUI/Resources/Localizable.xcstrings +++ b/apps/native/Sources/VisionNativeUI/Resources/Localizable.xcstrings @@ -35857,6 +35857,24 @@ } } }, + "nav.hideSection": { + "comment": "Generated from i18n/source by scripts/native-strings.ts.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Verberg" + } + } + } + }, "nav.home": { "comment": "Generated from i18n/source by scripts/native-strings.ts.", "extractionState": "manual", @@ -36235,6 +36253,24 @@ } } }, + "nav.showSection": { + "comment": "Generated from i18n/source by scripts/native-strings.ts.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Toon" + } + } + } + }, "nav.statistics": { "comment": "Generated from i18n/source by scripts/native-strings.ts.", "extractionState": "manual", diff --git a/apps/native/Sources/VisionNativeUI/Screens/Reports/TaxTableViewport.swift b/apps/native/Sources/VisionNativeUI/Screens/Reports/TaxTableViewport.swift index 148b467f4..4c70e4602 100644 --- a/apps/native/Sources/VisionNativeUI/Screens/Reports/TaxTableViewport.swift +++ b/apps/native/Sources/VisionNativeUI/Screens/Reports/TaxTableViewport.swift @@ -1,5 +1,9 @@ import SwiftUI +/// A table that fills its card and scrolls sideways only when it is wider than the card +/// (`
`). Inside a horizontal scroll view a +/// table is never offered the card's width, so it sat at its content width against the leading +/// edge; `ViewThatFits` lays the table out at the card's width whenever its content fits. /// Printer pages render the complete table rather than a clipped horizontal viewport. struct TaxTableViewport: View { let isPrinting: Bool @@ -8,7 +12,10 @@ struct TaxTableViewport: View { if isPrinting { content() } else { - ScrollView(.horizontal) { content() } + ViewThatFits(in: .horizontal) { + content().frame(maxWidth: .infinity, alignment: .leading) + ScrollView(.horizontal) { content() } + } } } } diff --git a/apps/native/Sources/VisionNativeUI/ShellRootView.swift b/apps/native/Sources/VisionNativeUI/ShellRootView.swift index e72fd2e66..bb7a2217c 100644 --- a/apps/native/Sources/VisionNativeUI/ShellRootView.swift +++ b/apps/native/Sources/VisionNativeUI/ShellRootView.swift @@ -355,16 +355,30 @@ struct NativeSidebar: View { .padding(.horizontal, 12) .padding(.top, 4) .padding(.bottom, 8) - List(selection: selection) { - ForEach(model.sections) { section in - sectionView(section) + // The web sidebar's own rows instead of a system source list: the list drew the + // macOS accent colour on hover and press, where the web row has a neutral hover + // (`hover:bg-foreground/[0.06]`) and the emerald pill for the active page. + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(model.sections) { section in + sectionView(section) + } + } + .padding(.horizontal, 8) + .padding(.bottom, 8) + } + .scrollIndicators(.automatic) + // Arrow keys move through the pages, as the list's selection did. + .focusable() + .focusEffectDisabled() + .onMoveCommand { direction in + switch direction { + case .up: step(-1) + case .down: step(1) + default: break } } - .listStyle(.sidebar) - .scrollContentBackground(.hidden) - // The pill marks the active row; a clear tint hides the system selection highlight - // while keeping the list's selection and arrow-key movement. - .tint(Color.clear) + .accessibilityElement(children: .contain) .accessibilityLabel(model.strings.t("nav.primary")) footer } @@ -393,67 +407,76 @@ struct NativeSidebar: View { } } - /// Selecting a row (click or arrow keys) navigates; the selection follows the reported - /// route, so a navigation the page has not confirmed yet does not move it. - private var selection: Binding { - Binding( - get: { model.selectedItemURL }, - set: { url in - if let url, url != model.route.path { model.navigate(to: url) } - }) + /// The pages in the order shown, without those of collapsed sections. + private var visibleItems: [NavItem] { + model.sections.flatMap { section in + section.collapsible && section.labelKey != nil && !model.isSectionExpanded(section) ? [] : section.items + } + } + + /// Arrow keys: the page before or after the active one. + private func step(_ offset: Int) { + let items = visibleItems + guard !items.isEmpty else { return } + let current = items.firstIndex { $0.url == model.selectedItemURL } + let next = current.map { $0 + offset } ?? (offset > 0 ? 0 : items.count - 1) + guard items.indices.contains(next), items[next].url != model.route.path else { return } + model.navigate(to: items[next].url) } + /// `SidebarGroup`: the label (`SidebarGroupLabel`, a caption in the muted foreground) with + /// the collapse chevron of a collapsible group, then its rows. @ViewBuilder private func sectionView(_ section: NavSection) -> some View { - if let labelKey = section.labelKey, section.collapsible { - Section(isExpanded: expanded(section)) { - rows(section.items) - } header: { - Text(model.strings.t(labelKey)) - } - } else if let labelKey = section.labelKey { - Section { - rows(section.items) - } header: { - Text(model.strings.t(labelKey)) - } - } else { - Section { - rows(section.items) + let collapsible = section.collapsible && section.labelKey != nil + let open = !collapsible || model.isSectionExpanded(section) + VStack(alignment: .leading, spacing: 1) { + if let labelKey = section.labelKey { + SidebarSectionHeader( + title: model.strings.t(labelKey), collapsible: collapsible, isExpanded: open, + toggleLabel: model.strings.t(open ? "nav.hideSection" : "nav.showSection") + " " + + model.strings.t(labelKey) + ) { + model.setSection(section.id, expanded: !open) + } } + if open { rows(section.items) } } + .padding(.top, section.labelKey == nil ? 0 : 10) } - private func expanded(_ section: NavSection) -> Binding { - Binding( - get: { model.isSectionExpanded(section) }, - set: { model.setSection(section.id, expanded: $0) }) - } - - /// The pill follows the route the page reports. + /// `SidebarMenuButton`: 32 pt rows on a chip radius; the pill follows the route the page reports. private func rows(_ items: [NavItem]) -> some View { ForEach(items) { item in let isActive = model.selectedItemURL == item.url - Label { - HStack(spacing: 6) { - Text(model.strings.t(item.titleKey)) + let title = model.strings.t(item.titleKey) + Button { + if item.url != model.route.path { model.navigate(to: item.url) } + } label: { + HStack(spacing: 10) { + Image(systemName: item.symbol) + .font(.system(size: 14)) + .foregroundStyle(Color(token: theme.glass.icon)) + .frame(width: 17, height: 17) + Text(title) + .font(.callout) .fontWeight(isActive ? .semibold : .regular) .foregroundStyle(Color(token: theme.glass.foreground)) .lineLimit(1) Spacer(minLength: 4) badgeView(item) } - } icon: { - Image(systemName: item.symbol) - .foregroundStyle(Color(token: theme.glass.icon)) + .padding(.horizontal, 8) + .frame(height: 32) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } - .tag(item.url as String?) + .buttonStyle(SidebarRowButtonStyle(isActive: isActive)) // The web row's hover hint and rail tooltip, " · G X". - .help( - item.shortcutKey.map { model.strings.t(item.titleKey) + " · G " + $0.uppercased() } - ?? model.strings.t(item.titleKey)) + .help(item.shortcutKey.map { title + " · G " + $0.uppercased() } ?? title) + .accessibilityLabel(title) .accessibilityValue(badgeLabel(item)) - .listRowBackground(SidebarSelectionPill(isActive: isActive)) + .accessibilityAddTraits(isActive ? .isSelected : []) } } @@ -597,17 +620,90 @@ struct NativeSidebar: View { .padding(.horizontal, 8) .contentShape(Rectangle()) } - .buttonStyle(.plain) - .background( - RoundedRectangle(cornerRadius: DesignTokens.Radius.chip) - .fill( - isActive ? Color(token: theme.glass.selection, opacity: theme.glass.selectionOpacity) : Color.clear) - ) + .buttonStyle(SidebarRowButtonStyle(isActive: isActive)) .accessibilityValue(updateReady ? model.strings.t("layout.updateReady") : "") .accessibilityAddTraits(isActive ? .isSelected : []) } } +/// A sidebar row's surface (`sidebarMenuButtonVariants`, sidebar.tsx): the emerald pill when +/// active (`data-[active=true]:bg-primary/[0.12]`, dark 0.16), otherwise `--foreground` at 0.06 +/// under the pointer and 0.08 while pressed, eased over `Duration.fast` on `--ease-glide`. +struct SidebarRowButtonStyle: ButtonStyle { + let isActive: Bool + var cornerRadius: CGFloat = DesignTokens.Radius.chip + + func makeBody(configuration: Configuration) -> some View { + SidebarRowSurface( + isActive: isActive, isPressed: configuration.isPressed, cornerRadius: cornerRadius, + label: configuration.label) + } +} + +private struct SidebarRowSurface<Label: View>: View { + let isActive: Bool + let isPressed: Bool + let cornerRadius: CGFloat + let label: Label + @State private var hovering = false + @Environment(\.visionTheme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let glass = theme.glass + let fill: Color = + isActive + ? Color(token: glass.selection, opacity: glass.selectionOpacity) + : Color(token: glass.foreground, opacity: isPressed ? 0.08 : hovering ? 0.06 : 0) + label + .background(RoundedRectangle(cornerRadius: cornerRadius).fill(fill)) + .onHover { hovering = $0 } + .animation( + reduceMotion ? nil : .visionCurve(DesignTokens.Ease.glide, duration: DesignTokens.Duration.fast), + value: hovering) + } +} + +/// `SidebarGroupLabel` with the group's collapse button: the chevron shows while the pointer +/// is over the header or the group is collapsed. +private struct SidebarSectionHeader: View { + let title: String + let collapsible: Bool + let isExpanded: Bool + let toggleLabel: String + let toggle: () -> Void + @State private var hovering = false + @Environment(\.visionTheme) private var theme + + var body: some View { + let label = HStack(spacing: 4) { + Text(title) + .font(.caption.weight(.medium)) + .foregroundStyle(theme.secondaryLabel) + .lineLimit(1) + Spacer(minLength: 4) + if collapsible { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(theme.tertiaryLabel) + .opacity(hovering || !isExpanded ? 1 : 0) + } + } + .padding(.horizontal, 8) + .frame(height: 24) + .contentShape(Rectangle()) + if collapsible { + Button(action: toggle) { label } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .accessibilityLabel(toggleLabel) + .accessibilityAddTraits(.isHeader) + } else { + label.accessibilityAddTraits(.isHeader) + } + } +} + /// Hosts the native screen for a route and gives it the page's keyboard shortcuts. struct NativeScreenContainer: View { let screen: NativeScreen diff --git a/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift b/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift index b57bfaea6..09b425711 100644 --- a/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift +++ b/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift @@ -10,6 +10,7 @@ struct StatisticsFlowView: View { private let onHoverChange: (String?, String?) -> Void @State private var hoveredLink: String? @Environment(\.visionTheme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion init( flow: StatisticsFlow, settings: NativeAppSettings, currency: CurrencyCode, strings: any Localizing, @@ -30,78 +31,88 @@ struct StatisticsFlowView: View { // Same layout as the web chart (ADR-245): d3-sankey positions inside 16 pt margins. let layout = StatisticsFlowLayout(flow: flow, width: size.width, height: size.height) let margin = CGFloat(StatisticsFlowLayout.margin) - Canvas { context, _ in - for link in layout.links { + let fade: Animation? = + reduceMotion ? nil : .visionCurve(DesignTokens.Ease.glide, duration: DesignTokens.Duration.fast) + ZStack(alignment: .topLeading) { + // Links as shapes, not a canvas, so the highlight fades (`transition: stroke-opacity`). + ForEach(layout.links) { link in let isHovered = hoveredLink == link.id || hoveredNode == link.link.source || hoveredNode == link.link.target // Stroked with the target node's colour, 25% opaque, 60% when highlighted. let target = layout.nodes.first { $0.id == link.link.target } - context.stroke( - curve(link, margin: margin), - with: .color(target.map { color($0.node) }?.opacity(isHovered ? 0.6 : 0.25) ?? theme.mutedForeground), - style: StrokeStyle(lineWidth: max(1, CGFloat(link.width)))) + curve(link, margin: margin) + .stroke( + target.map { color($0.node) } ?? theme.mutedForeground, + style: StrokeStyle(lineWidth: max(1, CGFloat(link.width))) + ) + .opacity(isHovered ? 0.6 : 0.25) + .animation(fade, value: isHovered) + } + .accessibilityHidden(true) + ForEach(layout.nodes) { node in + let bounds = rect(node, margin: margin) + let isHovered = hoveredNode == node.id + // Labels 6 pt off the node edge at its vertical middle, like the web chart: nodes in + // the right half carry theirs on the left (end-anchored), all others on the right. + let leading = node.labelsLeading + let anchorX = leading ? bounds.minX - 6 : bounds.maxX + 6 + RoundedRectangle(cornerRadius: 3).fill(color(node.node)) + .opacity(isHovered ? 1 : 0.85) + .animation(fade, value: isHovered) + .frame(width: bounds.width, height: bounds.height) + .position(x: bounds.midX, y: bounds.midY) + .accessibilityLabel(label(node.node)) + .accessibilityValue( + MoneyText.text(node.value, currency: currency, settings: settings, signed: false) + ) + // A zero-width frame anchors the text at the node's edge whatever its length; + // the web label is never truncated. + Text(label(node.node)).font(.system(size: 11)) + .foregroundStyle(theme.foreground) + .lineLimit(1).fixedSize() + .frame(width: 0, height: 0, alignment: leading ? .trailing : .leading) + .position(x: anchorX, y: bounds.midY) + .accessibilityHidden(true) + // The value, 14 pt under the label, while the node is hovered. + if isHovered { + MoneyText( + amount: node.value, currency: currency, settings: settings, signed: false, colored: false + ) + .font(.system(size: 10)).foregroundStyle(theme.mutedForeground) + .lineLimit(1).fixedSize() + .frame(width: 0, height: 0, alignment: leading ? .trailing : .leading) + .position(x: anchorX, y: bounds.midY + 14) + .accessibilityHidden(true) + } } } + .frame(width: size.width, height: size.height, alignment: .topLeading) + .contentShape(Rectangle()) + // One hover tracker for the chart. As in the SVG, only a node's bar or a link's band + // reacts (labels take no pointer events), and the element drawn last wins. .onContinuousHover { phase in switch phase { case .ended: hoveredNode = nil hoveredLink = nil case .active(let location): - hoveredNode = - layout.nodes.first { node in - let bounds = rect(node, margin: margin) - let label = CGRect( - x: node.labelsLeading ? bounds.minX - 6 - Self.labelWidth : bounds.maxX, - y: bounds.midY - 8, width: Self.labelWidth + 6, height: 16) - return bounds.union(label).contains(location) - }?.id - hoveredLink = - hoveredNode == nil - ? layout.links.first { link in + let node = layout.nodes.last { rect($0, margin: margin).contains(location) }?.id + let link = + node == nil + ? layout.links.last { link in curve(link, margin: margin) .strokedPath(StrokeStyle(lineWidth: max(1, CGFloat(link.width)))) .contains(location) }?.id : nil + if hoveredNode != node { hoveredNode = node } + if hoveredLink != link { hoveredLink = link } } } - ForEach(layout.nodes) { node in - let bounds = rect(node, margin: margin) - // Labels 6 pt off the node edge at its vertical middle, like the web chart: nodes in - // the right half carry theirs on the left (end-anchored), all others on the right. - let leading = node.labelsLeading - RoundedRectangle(cornerRadius: 3).fill(color(node.node).opacity(hoveredNode == node.id ? 1 : 0.85)) - .frame(width: bounds.width, height: bounds.height) - .position(x: bounds.midX, y: bounds.midY) - .overlay { - VStack(alignment: leading ? .trailing : .leading, spacing: 2) { - Text(label(node.node)).font(.system(size: 11)) - .foregroundStyle(theme.foreground) - .lineLimit(1).truncationMode(.tail) - if hoveredNode == node.id { - MoneyText( - amount: node.value, currency: currency, settings: settings, signed: false, - colored: false - ).font(.system(size: 10)).foregroundStyle(theme.mutedForeground) - } - } - .frame(width: Self.labelWidth, alignment: leading ? .trailing : .leading) - .position( - x: leading - ? bounds.minX - 6 - Self.labelWidth / 2 : bounds.maxX + 6 + Self.labelWidth / 2, - y: bounds.midY + (hoveredNode == node.id ? 7 : 0)) - } - .accessibilityLabel(label(node.node)) - .accessibilityValue( - MoneyText.text(node.value, currency: currency, settings: settings, signed: false) - ) - .allowsHitTesting(false) - } }.frame(minHeight: 380) .onChange(of: hoveredNode) { _, _ in onHoverChange(hoveredNode, hoveredLink) } .onChange(of: hoveredLink) { _, _ in onHoverChange(hoveredNode, hoveredLink) } } - private static let labelWidth: CGFloat = 132 + /// The node's rectangle in view coordinates; at least 1 pt tall, as on the web. private func rect(_ node: StatisticsFlowNodeLayout, margin: CGFloat) -> CGRect { diff --git a/apps/native/Sources/VisionNativeUI/StatisticsView.swift b/apps/native/Sources/VisionNativeUI/StatisticsView.swift index 6bb40490c..e9ccee2ca 100644 --- a/apps/native/Sources/VisionNativeUI/StatisticsView.swift +++ b/apps/native/Sources/VisionNativeUI/StatisticsView.swift @@ -939,8 +939,10 @@ public struct StatisticsView: View { Group { StatisticsFlowView( flow: flow, settings: settings, currency: currency, strings: strings, - initialNode: model.printLocalState.selection["flowNode"], - initialLink: model.printLocalState.selection["flowLink"], + // Read only for a print: on screen the chart owns its hover, and reading + // it here would redraw the whole page on every pointer move. + initialNode: isPrinting ? model.printLocalState.selection["flowNode"] : nil, + initialLink: isPrinting ? model.printLocalState.selection["flowLink"] : nil, onHoverChange: { node, link in if !isPrinting { model.printLocalState.selection["flowNode"] = node @@ -948,7 +950,8 @@ public struct StatisticsView: View { } } ).frame(minWidth: isPrinting ? 0 : 720) - }.frame(height: 440) + // `SankeyChart height = 420`. + }.frame(height: 420) } } else if let error = model.flowError { Text(error).foregroundStyle(theme.destructive) diff --git a/apps/native/Sources/VisionNativeUI/TaxBreakdownViews.swift b/apps/native/Sources/VisionNativeUI/TaxBreakdownViews.swift index 9abe412fe..a23c85fb7 100644 --- a/apps/native/Sources/VisionNativeUI/TaxBreakdownViews.swift +++ b/apps/native/Sources/VisionNativeUI/TaxBreakdownViews.swift @@ -489,7 +489,9 @@ struct TaxHouseholdBreakdownView: View { TaxTableViewport(isPrinting: printLayout) { Grid(alignment: .leading, horizontalSpacing: printLayout ? 10 : 24, verticalSpacing: 10) { GridRow { + // `<Table>` is `w-full`: the component column takes the spare width. Text(strings.t("tax.pit.table.component")).font(.caption.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) ForEach(partners, id: \.partnerId) { partner in Text( TaxYearPresentation.partner( diff --git a/apps/native/Sources/VisionNativeUI/TaxChartsView.swift b/apps/native/Sources/VisionNativeUI/TaxChartsView.swift index a4749bb12..ed124f5e0 100644 --- a/apps/native/Sources/VisionNativeUI/TaxChartsView.swift +++ b/apps/native/Sources/VisionNativeUI/TaxChartsView.swift @@ -270,7 +270,9 @@ struct TaxYearComparisonView: View { TaxTableViewport(isPrinting: printLayout) { Grid(alignment: .leading, horizontalSpacing: printLayout ? 10 : 24, verticalSpacing: 12) { GridRow { + // `<Table>` is `w-full`: the metric column takes the spare width. Text(shell.strings.t("tax.comparison.header.metric")) + .frame(maxWidth: .infinity, alignment: .leading) Text(String(overview.year)).gridColumnAlignment(.trailing) HStack { Text(String(selected.year)) diff --git a/apps/native/i18n-keys.txt b/apps/native/i18n-keys.txt index dc9020b2a..6983c4c41 100644 --- a/apps/native/i18n-keys.txt +++ b/apps/native/i18n-keys.txt @@ -55,6 +55,8 @@ nav.transactions nav.watchlist nav.wealth nav.whoOwesYou +nav.hideSection +nav.showSection # Admin > Exchange rates exchangeRates.col.currency