diff --git a/apps/native/Sources/VisionNativeUI/AccountDetailView.swift b/apps/native/Sources/VisionNativeUI/AccountDetailView.swift index aeb832894..a2fc403db 100644 --- a/apps/native/Sources/VisionNativeUI/AccountDetailView.swift +++ b/apps/native/Sources/VisionNativeUI/AccountDetailView.swift @@ -109,7 +109,7 @@ struct AccountDetailContent: View { // MARK: Header menu private var actionsMenu: some View { - Menu { + VisionMenu { ForEach(Array(model.menuItems(account).enumerated()), id: \.offset) { _, item in menuRow(item) } @@ -179,8 +179,13 @@ struct AccountDetailContent: View { case .deleteDisabled: Button { } label: { - Text(strings.t("common.delete")) - Text(strings.t("accounts.delete.hasTransactions")) + Label { + Text(strings.t("common.delete")) + Text(strings.t("accounts.delete.hasTransactions")) + .font(.footnote).foregroundStyle(theme.secondaryLabel) + } icon: { + Image(systemName: "trash") + } } .disabled(true) } diff --git a/apps/native/Sources/VisionNativeUI/AccountsView.swift b/apps/native/Sources/VisionNativeUI/AccountsView.swift index e794c0597..7f0bae275 100644 --- a/apps/native/Sources/VisionNativeUI/AccountsView.swift +++ b/apps/native/Sources/VisionNativeUI/AccountsView.swift @@ -368,7 +368,7 @@ struct AccountHubRow: View { } private var rowMenu: some View { - Menu { + VisionMenu { Button { model.openDetail(account) } label: { diff --git a/apps/native/Sources/VisionNativeUI/CategoriesView.swift b/apps/native/Sources/VisionNativeUI/CategoriesView.swift index b11bde8a4..68b84ae35 100644 --- a/apps/native/Sources/VisionNativeUI/CategoriesView.swift +++ b/apps/native/Sources/VisionNativeUI/CategoriesView.swift @@ -201,7 +201,7 @@ struct CategoryTreeRowView: View { /// The ••• menu: Edit, Merge category, Mark inactive / active, then Delete. private var rowMenu: some View { - Menu { + VisionMenu { Button { model.openEdit(node) } label: { @@ -228,7 +228,9 @@ struct CategoryTreeRowView: View { Label { Text(strings.t("common.delete")) if row.isBranch { + // The web helper line: `type-footnote text-label-secondary`. Text(strings.t("categoriesPage.deleteChildrenFirst")) + .font(.footnote).foregroundStyle(theme.secondaryLabel) } } icon: { Image(systemName: "trash") diff --git a/apps/native/Sources/VisionNativeUI/Components/ChartHoverTooltip.swift b/apps/native/Sources/VisionNativeUI/Components/ChartHoverTooltip.swift new file mode 100644 index 000000000..03dd52804 --- /dev/null +++ b/apps/native/Sources/VisionNativeUI/Components/ChartHoverTooltip.swift @@ -0,0 +1,111 @@ +import SwiftUI +import VisionNativeCore + +/// One line of a chart tooltip: the series swatch, its label and its formatted value. +struct ChartTooltipItem: Identifiable, Equatable { + let id: String + let label: String + let value: String + var color: Color? +} + +/// `ChartTooltip` (components/charts/ChartTooltip.tsx): the floating readout of the hovered +/// category. It sits centred above its anchor with a 12 pt gap, moves beside the anchor when it +/// would leave the chart sideways and below it when there is no room above. `--popover` with the +/// elevation shadow; `glass-thick` under Full effects. +/// +/// Place it in a chart overlay that spans `bounds`, with `anchor` in the overlay's coordinates. +struct ChartHoverTooltip: View { + let title: String? + let items: [ChartTooltipItem] + let anchor: CGPoint + /// The chart box the tooltip stays within sideways. + let bounds: CGSize + @State private var size: CGSize = .zero + @Environment(\.visionTheme) private var theme + @Environment(\.visionFullEffects) private var fullEffects + + private static let gap: CGFloat = 12 + private static let pad: CGFloat = 8 + + /// `computePosition`: where the tooltip's top-left corner goes for its measured size. + static func origin(anchor: CGPoint, size: CGSize, bounds: CGSize, roomAbove: CGFloat) -> 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/LocalDataTable.swift b/apps/native/Sources/VisionNativeUI/Components/LocalDataTable.swift index 2c2528f57..5f7a756d3 100644 --- a/apps/native/Sources/VisionNativeUI/Components/LocalDataTable.swift +++ b/apps/native/Sources/VisionNativeUI/Components/LocalDataTable.swift @@ -409,6 +409,13 @@ struct LocalDataTable: View { // MARK: Rows private func rowView(_ row: Row) -> some View { + // `TableRow`'s `hover:bg-foreground/[0.03]`; the hover state stays in the row. + HoverTracking { hovered in + rowContent(row).background(theme.foreground.opacity(hovered ? 0.03 : 0)) + } + } + + private func rowContent(_ row: Row) -> some View { HStack(spacing: 0) { ForEach(columns) { column in column.cell(row) 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/VisionMenu.swift b/apps/native/Sources/VisionNativeUI/Components/VisionMenu.swift new file mode 100644 index 000000000..ce015136b --- /dev/null +++ b/apps/native/Sources/VisionNativeUI/Components/VisionMenu.swift @@ -0,0 +1,136 @@ +import SwiftUI +import VisionNativeCore + +/// The web `DropdownMenu` (components/ui/dropdown-menu.tsx) in place of an AppKit menu: an in-page +/// `glass-thick rounded-card p-1.5` popover whose items show their icon (`mr-2 h-4 w-4 +/// text-label-secondary`), a `bg-primary/10` hover row, and destructive items in the destructive +/// colour (`variant="destructive"`, icon included). A `Label` whose title holds a second `Text` +/// shows it as the item's helper line (`type-footnote text-label-secondary`). +/// +/// Use it like `Menu` for menus of `Button`s and `Divider`s; a menu that holds `Toggle`s or +/// `Picker`s stays a `Menu`. Buttons keep their role and `.disabled`; choosing an item closes the +/// popover first, then runs its action. Escape or a click outside closes it. +/// +/// VisionMenu { +/// Button { edit() } label: { Label("Edit", systemImage: "pencil") } +/// Divider() +/// Button(role: .destructive) { delete() } label: { Label("Delete", systemImage: "trash") } +/// } label: { +/// Image(systemName: "ellipsis") +/// } +struct VisionMenu: View { + private let content: () -> Content + private let label: () -> MenuLabel + @State private var isOpen = false + + init(@ViewBuilder content: @escaping () -> Content, @ViewBuilder label: @escaping () -> MenuLabel) { + self.content = content + self.label = label + } + + var body: some View { + Button { + isOpen.toggle() + } label: { + label() + } + .popover(isPresented: $isOpen, arrowEdge: .bottom) { + VisionMenuPanel(dismiss: { isOpen = false }, content: content) + } + } +} + +/// `DropdownMenuContent`: `min-w-[8rem] p-1.5` on `glass-thick`. +private struct VisionMenuPanel: View { + let dismiss: () -> Void + let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + content() + } + .buttonStyle(VisionMenuItemStyle(dismiss: dismiss)) + .labelStyle(VisionMenuItemLabelStyle()) + .padding(6) + .frame(minWidth: 128, alignment: .leading) + .fixedSize() + .background(VisionGlassSurface(material: .thick, cornerRadius: CGFloat(DesignTokens.Radius.card))) + .presentationBackground(.clear) + } +} + +/// `DropdownMenuItem`: `rounded-[card-6px] px-2 py-1.5 type-body`, `focus:bg-primary/10` +/// (destructive: `text-destructive focus:bg-destructive/10`), `data-[disabled]:opacity-50`. +private struct VisionMenuItemStyle: PrimitiveButtonStyle { + let dismiss: () -> Void + + func makeBody(configuration: Configuration) -> some View { + VisionMenuItem(configuration: configuration, dismiss: dismiss) + } +} + +private struct VisionMenuItem: View { + let configuration: PrimitiveButtonStyleConfiguration + let dismiss: () -> Void + @State private var hovering = false + @Environment(\.isEnabled) private var isEnabled + @Environment(\.visionTheme) private var theme + + var body: some View { + let destructive = configuration.role == .destructive + let shape = RoundedRectangle(cornerRadius: CGFloat(DesignTokens.Radius.card) - 6, style: .continuous) + let highlight = destructive ? theme.destructive : theme.primary + Button { + dismiss() + configuration.trigger() + } label: { + VStack(alignment: .leading, spacing: 1) { configuration.label } + .font(VisionControlMetrics.fieldFont) + .foregroundStyle(destructive ? theme.destructive : theme.foreground) + .environment(\.visionMenuItemDestructive, destructive) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(shape.fill(highlight.opacity(hovering && isEnabled ? 0.1 : 0))) + .contentShape(shape) + } + .buttonStyle(.plain) + .opacity(isEnabled ? 1 : VisionControlMetrics.disabledOpacity) + .onHover { inside in + if hovering != inside { hovering = inside } + } + } +} + +/// The item's icon (`mr-2 h-4 w-4`, secondary unless the item is destructive), then its title; +/// a second `Text` in the title is the helper line. +private struct VisionMenuItemLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + VisionMenuItemLabel(configuration: configuration) + } +} + +private struct VisionMenuItemLabel: View { + let configuration: LabelStyleConfiguration + @Environment(\.visionMenuItemDestructive) private var destructive + @Environment(\.visionTheme) private var theme + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + configuration.icon + .font(.system(size: 13)) + .foregroundStyle(destructive ? theme.destructive : theme.secondaryLabel) + .frame(width: 16) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + configuration.title + } + .lineLimit(1) + } + } +} + +extension EnvironmentValues { + /// Set by a `VisionMenu` item for its label: the icon takes the destructive colour. + @Entry var visionMenuItemDestructive = false +} diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift b/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift index 3da8f9929..fdd711845 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift @@ -31,13 +31,33 @@ struct VisionTable: View { /// A column that keeps its content width instead of taking a share of the spare room /// (`w-24`, an actions column). var fixedColumns: Set = [] + /// `TableRow`'s `hover:bg-foreground/[0.03]` band under the pointer's row. + var rowHover = true @ViewBuilder var content: () -> Content + @State private var hoverY: CGFloat? + @Environment(\.visionTheme) private var theme var body: some View { - VisionTableLayout(columns: columns, columnSpacing: columnSpacing, fixedColumns: fixedColumns) { + VisionTableLayout( + columns: columns, columnSpacing: columnSpacing, fixedColumns: fixedColumns, hoverY: rowHover ? hoverY : nil + ) { + if rowHover { + Rectangle().fill(theme.foreground.opacity(0.03)).allowsHitTesting(false) + .accessibilityHidden(true) + .layoutValue(key: VisionTableBandKey.self, value: true) + } content() } .frame(maxWidth: .infinity, alignment: .leading) + .onContinuousHover { phase in + guard rowHover else { return } + let y: CGFloat? + switch phase { + case .active(let point): y = point.y + case .ended: y = nil + } + if hoverY != y { hoverY = y } + } } } @@ -50,10 +70,17 @@ private struct VisionTableSpanKey: LayoutValueKey { static let defaultValue = false } +/// The hover band: drawn behind the cells over the row under `hoverY`, outside the row grouping. +private struct VisionTableBandKey: LayoutValueKey { + static let defaultValue = false +} + struct VisionTableLayout: Layout { let columns: [HorizontalAlignment] let columnSpacing: CGFloat let fixedColumns: Set + /// The pointer's y in the table, for the hover band; nil hides it. + var hoverY: CGFloat? = nil struct Cache { var width: CGFloat? @@ -85,6 +112,16 @@ struct VisionTableLayout: Layout { func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) { let plan = plan(width: bounds.width, subviews: subviews, cache: &cache) + for index in subviews.indices where subviews[index][VisionTableBandKey.self] { + // Over the cell row under the pointer (not dividers or full-width rows), else empty. + if let hoverY, let row = plan.rows.first(where: { !$0.spans && hoverY >= $0.top && hoverY < $0.top + $0.height }) { + subviews[index].place( + at: CGPoint(x: bounds.minX, y: bounds.minY + row.top), anchor: .topLeading, + proposal: ProposedViewSize(width: bounds.width, height: row.height)) + } else { + subviews[index].place(at: bounds.origin, anchor: .topLeading, proposal: .zero) + } + } for row in plan.rows { if row.spans { for index in row.indices { @@ -163,7 +200,9 @@ struct VisionTableLayout: Layout { 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) + let cells = subviews.indices.filter { !subviews[$0][VisionTableBandKey.self] } + let grouped = Self.rows(spans: cells.map { subviews[$0][VisionTableSpanKey.self] }, columnCount: count) + .map { row in (indices: row.indices.map { cells[$0] }, spans: row.spans) } var ideal = [CGFloat](repeating: 0, count: count) for row in grouped where !row.spans { for (column, index) in row.indices.enumerated() { diff --git a/apps/native/Sources/VisionNativeUI/DatabaseMaintenanceView.swift b/apps/native/Sources/VisionNativeUI/DatabaseMaintenanceView.swift index 27da17b76..7c8691d3e 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: { @@ -185,7 +181,8 @@ public struct DatabaseMaintenanceView: View { .buttonStyle(.plain) .foregroundStyle(theme.primary) .accessibilityAddTraits(.isLink) - .padding(.vertical, 8) + // `TableCell` `py-3`: the row height of the web table. + .padding(.vertical, 12) Text(row.liveRows).monospacedDigit() Text(row.lastVacuum) .font(.footnote) @@ -210,7 +207,7 @@ public struct DatabaseMaintenanceView: View { /// `dbMaintenance.rowMenu`: Browse rows and Run vacuum (disabled while any vacuum runs). private func rowMenu(_ row: DatabaseTableRow) -> some View { - Menu { + VisionMenu { 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/HomeView.swift b/apps/native/Sources/VisionNativeUI/HomeView.swift index 3bd0baa74..02ca71153 100644 --- a/apps/native/Sources/VisionNativeUI/HomeView.swift +++ b/apps/native/Sources/VisionNativeUI/HomeView.swift @@ -37,9 +37,9 @@ 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") { - Menu { - Button(strings.t("home.customize")) { customize = true } + VisionPageHeader(title: greeting, subtitle: todayLong, symbol: "square.grid.2x2") { + VisionMenu { + Button(strings.t("home.customize"), systemImage: "square.grid.2x2") { customize = true } } label: { Image(systemName: "ellipsis") } 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 bd7ae182b..fa5c47030 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/PortfolioHoldingsCards.swift b/apps/native/Sources/VisionNativeUI/PortfolioHoldingsCards.swift index 10916b475..6fc6f01ab 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioHoldingsCards.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioHoldingsCards.swift @@ -9,7 +9,7 @@ struct PortfolioHoldingActionsMenu: View { var page = PortfolioHoldingsPage.stocks private var strings: any Localizing { shell.strings } var body: some View { - Menu { + VisionMenu { Button(strings.t("invDetail.trigger"), systemImage: "info.circle") { Task { await model.openDetail(id: holding.id) } } diff --git a/apps/native/Sources/VisionNativeUI/PortfolioHoldingsView.swift b/apps/native/Sources/VisionNativeUI/PortfolioHoldingsView.swift index 74d7b511d..08af21e54 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioHoldingsView.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioHoldingsView.swift @@ -48,11 +48,15 @@ public struct PortfolioHoldingsView: View { editing.beginAddInvestment(allowedAssetClasses: classes) } .buttonStyle(.visionPrimary) - Menu { + VisionMenu { if assetClass != .crypto { - Button(strings.t("portfolio.menu.exportPdf")) { shell.openPortfolioReport() } + Button(strings.t("portfolio.menu.exportPdf"), systemImage: "arrow.down.doc") { + shell.openPortfolioReport() + } + } + Button(strings.t("nav.portfolioImport"), systemImage: "square.and.arrow.down") { + shell.navigate(to: "/portfolio/import") } - Button(strings.t("nav.portfolioImport")) { shell.navigate(to: "/portfolio/import") } } label: { Image(systemName: "ellipsis").visionChipChrome() } diff --git a/apps/native/Sources/VisionNativeUI/PortfolioOverviewView.swift b/apps/native/Sources/VisionNativeUI/PortfolioOverviewView.swift index 47a5e9cbf..7c9bc7c91 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioOverviewView.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioOverviewView.swift @@ -51,11 +51,15 @@ public struct PortfolioOverviewView: View { strings.t(model.isOnline ? "portfolio.refreshPrices" : "portfolio.refreshPricesOffline") ) .disabled(model.isRefreshingPrices || !model.isOnline) - Menu { - Button(strings.t("portfolio.menu.exportPdf")) { shell.openPortfolioReport() } - Button(strings.t("portfolio.menu.customize")) { customize = true } + VisionMenu { + Button(strings.t("portfolio.menu.exportPdf"), systemImage: "arrow.down.doc") { + shell.openPortfolioReport() + } + Button(strings.t("portfolio.menu.customize"), systemImage: "square.grid.2x2") { customize = true } Divider() - Button(strings.t("nav.portfolioImport")) { shell.navigate(to: "/portfolio/import") } + Button(strings.t("nav.portfolioImport"), systemImage: "square.and.arrow.down") { + shell.navigate(to: "/portfolio/import") + } } label: { Image(systemName: "ellipsis").visionChipChrome() } 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/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/RecipientsView.swift b/apps/native/Sources/VisionNativeUI/RecipientsView.swift index cd406d12d..2408291c0 100644 --- a/apps/native/Sources/VisionNativeUI/RecipientsView.swift +++ b/apps/native/Sources/VisionNativeUI/RecipientsView.swift @@ -93,7 +93,7 @@ public struct RecipientsView: View { /// More actions: Merge payees. private var moreMenu: some View { - Menu { + VisionMenu { Button { Task { await model.openMerge() } } label: { @@ -373,7 +373,7 @@ public struct RecipientsView: View { /// The ••• menu: Edit, Match rules, Unmerge (aliases), Mark inactive / active, Delete. private func rowMenu(_ payee: OpenAPI.Recipient) -> some View { - Menu { + VisionMenu { Button { model.startEditing(payee) } label: { @@ -861,7 +861,7 @@ struct MatchRulesSheet: View { .labelsHidden() .disabled(state.isSaving) .accessibilityLabel(strings.t("recipientPatterns.activeFor", ["pattern": rule.pattern])) - Menu { + VisionMenu { Button { model.editRule(rule) } label: { 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/ResearchForecastChart.swift b/apps/native/Sources/VisionNativeUI/ResearchForecastChart.swift index 8e77f4a85..a6f675166 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchForecastChart.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchForecastChart.swift @@ -91,6 +91,17 @@ struct ResearchForecastChart: View { { RuleMark(x: .value("date", date)).foregroundStyle(theme.secondaryLabel.opacity(0.5)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])).accessibilityHidden(true) + // The web LineChart's active dots: a ring in each line's colour at the hovered month. + ForEach(series) { line in + PointMark( + x: .value("date", date), y: .value(label(line), coordinate(points[index][keyPath: line.value]))) + .symbol { + Circle().strokeBorder(line.color, lineWidth: 2) + .background(Circle().fill(theme.background)) + .frame(width: 9, height: 9) + } + .accessibilityHidden(true) + } } } .chartLegend(.hidden) @@ -136,6 +147,23 @@ struct ResearchForecastChart: View { if selected != index { selected = index } }, onPress: { focused = true }) + // `ChartTooltip`: the hovered month's percentiles and capital in, floating + // above the median, so the layout below the chart never moves. + if scrub.range == nil, let index = selected, points.indices.contains(index), + let day = date(points[index].date), + let frame = proxy.plotFrame.map({ geometry[$0] }), + let x = proxy.position(forX: day), + let y = proxy.position(forY: coordinate(points[index].p90)) + { + ChartHoverTooltip( + title: fullDate(points[index].date), + items: lines.map { line in + ChartTooltipItem( + id: line.id, label: label(line), value: money(points[index][keyPath: line.value]), + color: line.color) + }, + anchor: CGPoint(x: frame.minX + x, y: frame.minY + y), bounds: geometry.size) + } } } .frame(height: 360).focusable().focused($focused) @@ -148,19 +176,6 @@ struct ResearchForecastChart: View { ChartAccessibility.series( strings, kind: .line, categoryCount: points.count, seriesLabels: series.map(label)) } - if scrub.range == nil, let index = selected, points.indices.contains(index) { - VStack(alignment: .leading, spacing: 6) { - Text(fullDate(points[index].date)).font(.caption.weight(.semibold)) - ForEach(series) { line in - HStack { - Text(label(line)).foregroundStyle(theme.secondaryLabel) - Spacer() - Text(money(points[index][keyPath: line.value])).monospacedDigit() - }.font(.caption) - } - }.padding(12).background(theme.muted, in: RoundedRectangle(cornerRadius: DesignTokens.Radius.md)) - .accessibilityElement(children: .combine) - } } } .onChange(of: points) { _, _ in selected = nil } 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/Screens/Settings/NativeSettingsWindowComponents.swift b/apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsWindowComponents.swift index 48ecf761c..125c55f73 100644 --- a/apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsWindowComponents.swift +++ b/apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsWindowComponents.swift @@ -72,9 +72,12 @@ struct NativeSettingsRow: View { content() } } else { + // `SettingRow`: title and footnote on the left, the control on the right; stacked + // only when even that does not fit. The label's ideal width is pinned: left at its + // one-line text width, any row with a description failed the fit and stacked. ViewThatFits(in: .horizontal) { HStack(alignment: .center, spacing: 20) { - label.frame(minWidth: 145) + label.frame(minWidth: 145, idealWidth: 145) content().frame(minWidth: 100, maxWidth: 230, alignment: .trailing) } VStack(alignment: .leading, spacing: 10) { diff --git a/apps/native/Sources/VisionNativeUI/ShellGlassViews.swift b/apps/native/Sources/VisionNativeUI/ShellGlassViews.swift index 8247a5c71..da07b31b6 100644 --- a/apps/native/Sources/VisionNativeUI/ShellGlassViews.swift +++ b/apps/native/Sources/VisionNativeUI/ShellGlassViews.swift @@ -103,19 +103,6 @@ struct DetailGlassBackground: View { } } -/// The emerald pill of the active row (`data-[active=true]:bg-primary/[0.12]`). -struct SidebarSelectionPill: View { - let isActive: Bool - @Environment(\.visionTheme) var theme - - var body: some View { - let glass = theme.glass - RoundedRectangle(cornerRadius: DesignTokens.Radius.chip) - .fill(isActive ? Color(token: glass.selection, opacity: glass.selectionOpacity) : Color.clear) - .padding(.horizontal, 8) - } -} - /// Gives the hosting window the theme's appearance (the web follows its own theme, not macOS), /// so system materials, scrollers and menus in the window match the app theme. Without it a /// dialog's `Material` follows the macOS appearance: light glass over a dark theme. diff --git a/apps/native/Sources/VisionNativeUI/ShellRootView.swift b/apps/native/Sources/VisionNativeUI/ShellRootView.swift index 866b099f5..e370ae8b4 100644 --- a/apps/native/Sources/VisionNativeUI/ShellRootView.swift +++ b/apps/native/Sources/VisionNativeUI/ShellRootView.swift @@ -169,10 +169,18 @@ public struct ShellRootView: View { .environment(\.colorScheme, theme.colorScheme) } + /// A page presented an in-window dialog, alert or sheet (shell overlays excluded). + private var pageModalOpen: Bool { + VisionModalRegistry.shared.isOpen && !model.isPresentingNativeOverlay + } + public var body: some View { let theme = VisionTheme(selection: model.theme) NavigationSplitView(columnVisibility: $columnVisibility) { NativeSidebar(model: model, skipToContent: isNativeDetail ? { contentFocused = true } : nil) + // A page's dialog or sheet dims and blurs the sidebar too, as the web overlay + // covers the whole window; shell overlays already cover it themselves. + .modifier(PageModalSidebarScrim(active: pageModalOpen)) .navigationSplitViewColumnWidth( min: SidebarWidthGuard.minimumWidth, ideal: SidebarWidthGuard.idealWidth, max: SidebarWidthGuard.maximumWidth) @@ -199,7 +207,8 @@ public struct ShellRootView: View { .environment(\.locale, Locale(identifier: model.strings.language.rawValue)) .environment(\.nativeNoticeCenter, model.notices) .overlay(alignment: .bottomTrailing) { - if model.settings.adminMode, let inspector = model.apiInspector { + // Hidden under a page dialog or sheet: the pill would sit over its footer buttons. + if model.settings.adminMode, !pageModalOpen, let inspector = model.apiInspector { NativeAPIInspectorOverlay(model: inspector).environment(\.visionTheme, theme) } } @@ -357,16 +366,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 } @@ -395,67 +418,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)) - } - .tag(item.url as String?) - // 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)) + .padding(.horizontal, 8) + .frame(height: 32) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(SidebarRowButtonStyle(isActive: isActive)) + // No hover tooltip here: the web shows "<title> · G X" only on the collapsed rail + // (`TooltipContent hidden={state !== "collapsed"}`), see `NativeSidebarRail`. + .accessibilityLabel(title) .accessibilityValue(badgeLabel(item)) - .listRowBackground(SidebarSelectionPill(isActive: isActive)) + .accessibilityAddTraits(isActive ? .isSelected : []) } } @@ -541,7 +573,7 @@ struct NativeSidebar: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .help(model.strings.t("aria.toggleSidebar")) + // No tooltip: the web button carries only its aria-label. .accessibilityLabel(model.strings.t("aria.toggleSidebar")) } @@ -556,7 +588,7 @@ struct NativeSidebar: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .help(model.strings.t("aria.collapseSidebar")) + // No tooltip: the web button carries only its aria-label. .accessibilityLabel(model.strings.t("aria.collapseSidebar")) } @@ -599,17 +631,122 @@ 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 : []) } } +/// The modal overlay's share over the sidebar column while a page shows a dialog or sheet: the +/// same scrim and blur `VisionModalPresenter` puts over the page (`.modal-overlay`, frosted with +/// Full effects or the enhanced tier), and the sidebar takes no clicks meanwhile. +private struct PageModalSidebarScrim: ViewModifier { + let active: Bool + @Environment(\.visionFullEffects) private var fullEffects + @Environment(\.visionVisualEffects) private var visualEffects + @Environment(\.visionTheme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + let frosted = fullEffects || visualEffects == .enhanced + content + .blur(radius: frosted && active ? CGFloat(DesignTokens.Overlay.blur) : 0) + .overlay { + Color( + token: theme.palette.background, + opacity: frosted ? DesignTokens.Overlay.scrimFrosted : DesignTokens.Overlay.scrim + ) + .opacity(active ? 1 : 0) + .allowsHitTesting(active) + .ignoresSafeArea() + .accessibilityHidden(true) + } + .disabled(active) + .accessibilityHidden(active) + .animation( + reduceMotion ? nil : .visionCurve(DesignTokens.Ease.glide, duration: DesignTokens.Duration.normal), + value: active) + } +} + +/// 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/StatisticsCharts.swift b/apps/native/Sources/VisionNativeUI/StatisticsCharts.swift index 493e1d8e4..e2b2d3148 100644 --- a/apps/native/Sources/VisionNativeUI/StatisticsCharts.swift +++ b/apps/native/Sources/VisionNativeUI/StatisticsCharts.swift @@ -142,38 +142,37 @@ struct StatisticsCategoryDonut: View { .chartForegroundStyleScale(domain: rows.map(\.name), range: rows.map { color($0.path.first ?? $0.name) }) .chartLegend(.hidden) .chartBackground { _ in + // `DonutChart`'s centre morph: the hovered slice's swatch and name over its amount. if let row = rows.first(where: { $0.id == selected }) { - VStack(spacing: 4) { - Text(row.name).font(.caption).multilineTextAlignment(.center) + VStack(spacing: 2) { + HStack(spacing: 6) { + Circle().fill(color(row.path.first ?? row.name)).frame(width: 8, height: 8) + .accessibilityHidden(true) + Text(row.name).lineLimit(1).truncationMode(.tail) + } + .font(.footnote).foregroundStyle(theme.secondaryLabel) MoneyText( amount: row.total, currency: currency, settings: settings, signed: false, colored: false ) - .font(.caption.weight(.medium)) - }.frame(maxWidth: 135) + .font(.title3.monospacedDigit()).foregroundStyle(theme.foreground) + } + .frame(maxWidth: 170) + .transition(.opacity) } } + .animation(.easeOut(duration: DesignTokens.Duration.fast), value: selected) .chartOverlay { proxy in GeometryReader { geometry in + // The hovered slice is worked out from the pointer here and written straight to + // `selected` (only on change): going through the angle binding missed a slice the + // pointer re-entered, because the same angle fired no change. Rectangle().fill(.clear).contentShape(Rectangle()).onContinuousHover { phase in + let hovered: String? switch phase { - case .ended: selected = nil - case .active(let point): - guard let plot = proxy.plotFrame else { return } - let bounds = geometry[plot] - let dx = point.x - bounds.midX - let dy = point.y - bounds.midY - let radius = min(bounds.width, bounds.height) / 2 - let distance = hypot(dx, dy) - if distance < radius * 0.6 || distance > radius { - selected = nil - return - } - var value = atan2(dx, -dy) - if value < 0 { value += 2 * .pi } - angle = - Double(value / (2 * .pi)) - * NSDecimalNumber(decimal: rows.reduce(0) { $0 + $1.total }).doubleValue + case .ended: hovered = nil + case .active(let point): hovered = row(at: point, proxy: proxy, geometry: geometry)?.id } + if selected != hovered { selected = hovered } } } } @@ -212,6 +211,25 @@ struct StatisticsCategoryDonut: View { ChartLegendRow(items: rows.map(legendItem)) } } + /// The slice under `point`, inside the ring only. + private func row(at point: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> StatisticsCategory? { + guard let plot = proxy.plotFrame else { return nil } + let bounds = geometry[plot] + let dx = point.x - bounds.midX + let dy = point.y - bounds.midY + let radius = min(bounds.width, bounds.height) / 2 + let distance = hypot(dx, dy) + guard distance >= radius * 0.6, distance <= radius else { return nil } + var value = atan2(dx, -dy) + if value < 0 { value += 2 * .pi } + let total = NSDecimalNumber(decimal: rows.reduce(0) { $0 + $1.total }).doubleValue + let position = Double(value / (2 * .pi)) * total + var end = 0.0 + return rows.first { row in + end += NSDecimalNumber(decimal: row.total).doubleValue + return position < end + } + } private func legendItem(_ row: StatisticsCategory) -> ChartLegendItem { let amount = MoneyText.text(row.total, currency: currency, settings: settings, signed: false) return ChartLegendItem(id: row.id, label: row.name + " · " + amount, color: color(row.path.first ?? row.name)) 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 f8e4f7618..76d3e6bfd 100644 --- a/apps/native/Sources/VisionNativeUI/StatisticsView.swift +++ b/apps/native/Sources/VisionNativeUI/StatisticsView.swift @@ -940,8 +940,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 @@ -949,7 +951,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