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