diff --git a/apps/native/Sources/VisionNativeUI/AccountDetailView.swift b/apps/native/Sources/VisionNativeUI/AccountDetailView.swift index 918af9c8a..1b76ae40c 100644 --- a/apps/native/Sources/VisionNativeUI/AccountDetailView.swift +++ b/apps/native/Sources/VisionNativeUI/AccountDetailView.swift @@ -336,27 +336,29 @@ struct AccountDetailContent: View { SkeletonBar(width: nil, height: 40) .accessibilityLabel(strings.t("common.loading")) } else { - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { - GridRow { - Text(strings.t("txPage.col.date")) - Text(strings.t("txPage.field.description")) - .gridColumnAlignment(.leading) - Text(strings.t("txPage.col.amount")) - .gridColumnAlignment(.trailing) - } - .font(.footnote.weight(.medium)) - .foregroundStyle(theme.secondaryLabel) - ForEach(model.plannedRows) { row in - Divider() + HorizontalOverflowScroll { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { - Text(VisionFormat.appDate(row.date, format: settings.dateFormat)) - .monospacedDigit() - Text(row.title ?? strings.t("accounts.detail.unlabelled")) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - MoneyText( - amount: row.amount, currency: (row.currency ?? account.currency), settings: settings, - signed: true) + Text(strings.t("txPage.col.date")) + Text(strings.t("txPage.field.description")) + .gridColumnAlignment(.leading) + Text(strings.t("txPage.col.amount")) + .gridColumnAlignment(.trailing) + } + .font(.footnote.weight(.medium)) + .foregroundStyle(theme.secondaryLabel) + ForEach(model.plannedRows) { row in + Divider() + GridRow { + Text(VisionFormat.appDate(row.date, format: settings.dateFormat)) + .monospacedDigit() + Text(row.title ?? strings.t("accounts.detail.unlabelled")) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + MoneyText( + amount: row.amount, currency: (row.currency ?? account.currency), settings: settings, + signed: true) + } } } } @@ -499,46 +501,48 @@ struct AccountLedgerCard: View { } private var table: some View { - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { - GridRow { - Text(strings.t("txPage.col.date")) - Text(strings.t("txPage.field.description")) - Text(strings.t("txPage.col.category")) - Text(strings.t("txPage.col.amount")) - .gridColumnAlignment(.trailing) - Text(strings.t("txPage.field.balance")) - .gridColumnAlignment(.trailing) - } - .font(.footnote.weight(.medium)) - .foregroundStyle(theme.secondaryLabel) - ForEach(model.visibleLedger) { row in - Divider() + HorizontalOverflowScroll { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { - Text(VisionFormat.appDate(row.date, format: settings.dateFormat)) - .monospacedDigit() - VStack(alignment: .leading, spacing: 1) { - Text(row.title ?? strings.t("accounts.detail.unlabelled")) - .lineLimit(1) - if let memo = row.subtitle { - Text(memo) - .font(.footnote) - .foregroundStyle(theme.secondaryLabel) + Text(strings.t("txPage.col.date")) + Text(strings.t("txPage.field.description")) + Text(strings.t("txPage.col.category")) + Text(strings.t("txPage.col.amount")) + .gridColumnAlignment(.trailing) + Text(strings.t("txPage.field.balance")) + .gridColumnAlignment(.trailing) + } + .font(.footnote.weight(.medium)) + .foregroundStyle(theme.secondaryLabel) + ForEach(model.visibleLedger) { row in + Divider() + GridRow { + Text(VisionFormat.appDate(row.date, format: settings.dateFormat)) + .monospacedDigit() + VStack(alignment: .leading, spacing: 1) { + Text(row.title ?? strings.t("accounts.detail.unlabelled")) .lineLimit(1) + if let memo = row.subtitle { + Text(memo) + .font(.footnote) + .foregroundStyle(theme.secondaryLabel) + .lineLimit(1) + } } - } - .frame(maxWidth: .infinity, alignment: .leading) - Text(row.categoryName ?? "—") - .foregroundStyle(theme.secondaryLabel) - .lineLimit(1) - Text(MoneyText.text(row.amount, currency: row.currency, settings: settings, signed: true)) - .monospacedDigit() - .foregroundStyle(row.amount < 0 ? theme.loss : (row.amount > 0 ? theme.gain : theme.secondaryLabel)) - if let balance = row.runningBalance { - MoneyText(amount: balance, currency: row.currency, settings: settings, signed: false, colored: false) - .foregroundStyle(theme.secondaryLabel) - } else { - Text(verbatim: "—") + .frame(maxWidth: .infinity, alignment: .leading) + Text(row.categoryName ?? "—") .foregroundStyle(theme.secondaryLabel) + .lineLimit(1) + Text(MoneyText.text(row.amount, currency: row.currency, settings: settings, signed: true)) + .monospacedDigit() + .foregroundStyle(row.amount < 0 ? theme.loss : (row.amount > 0 ? theme.gain : theme.secondaryLabel)) + if let balance = row.runningBalance { + MoneyText(amount: balance, currency: row.currency, settings: settings, signed: false, colored: false) + .foregroundStyle(theme.secondaryLabel) + } else { + Text(verbatim: "—") + .foregroundStyle(theme.secondaryLabel) + } } } } diff --git a/apps/native/Sources/VisionNativeUI/AccountsView.swift b/apps/native/Sources/VisionNativeUI/AccountsView.swift index 7f0bae275..a9294e9a3 100644 --- a/apps/native/Sources/VisionNativeUI/AccountsView.swift +++ b/apps/native/Sources/VisionNativeUI/AccountsView.swift @@ -267,8 +267,9 @@ struct AccountHubRow: View { private var titleLine: some View { HStack(spacing: 6) { + // ListRow's title: `type-body` (Inter 14, regular), not medium. Text(label) - .font(.body.weight(.medium)) + .font(VisionFonts.font(DesignTokens.Typography.body, relativeTo: .body)) .lineLimit(1) if !account.isActive { VisionBadge(text: strings.t("accounts.archived"), tone: .outline, small: true) @@ -279,13 +280,16 @@ struct AccountHubRow: View { } } + /// ListRow's subtitle line: `type-footnote` (Inter 12). + private static var footnote: Font { VisionFonts.font(DesignTokens.Typography.footnote, relativeTo: .footnote) } + @ViewBuilder private var subtitle: some View { if let drift = model.drift(account) { AccountDriftChip(drift: drift, strings: strings) { model.openReconcile(account) } } Text(model.meta(account)) - .font(.footnote) + .font(Self.footnote) .foregroundStyle(theme.secondaryLabel) .lineLimit(1) if isPortfolio { @@ -297,26 +301,26 @@ struct AccountHubRow: View { Text(verbatim: "· \(provenance)") } } - .font(.footnote) + .font(Self.footnote) .foregroundStyle(theme.secondaryLabel) .lineLimit(1) } } else if account.computedBalance != nil, let provenance = model.provenance(account) { Text(provenance) - .font(.footnote) + .font(Self.footnote) .foregroundStyle(theme.secondaryLabel) .lineLimit(1) } if !isPortfolio && account.balanceIncomplete == true { Text(strings.t("accounts.balanceIncomplete")) - .font(.footnote) + .font(Self.footnote) .foregroundStyle(theme.warning) ForEach(model.excludedParts(account), id: \.currency) { part in HStack(spacing: 4) { MoneyText(amount: part.balance, currency: part.currency, settings: settings, signed: false, colored: false) Text(strings.t("accounts.balanceExcluded")) } - .font(.footnote) + .font(Self.footnote) .foregroundStyle(theme.warning) } } @@ -410,7 +414,8 @@ struct AccountDriftChip: View { var body: some View { Button(action: open) { - VisionBadge(text: drift.label, tone: drift.stale ? .warning : .destructive, small: true) + // `badgeVariants({ variant })` without a size: the default `px-2.5`, as on the web. + VisionBadge(text: drift.label, tone: drift.stale ? .warning : .destructive) } .buttonStyle(.plain) .visionTooltip(strings.t(drift.tooltipKey)) diff --git a/apps/native/Sources/VisionNativeUI/AnalysisWorkbenchReportView.swift b/apps/native/Sources/VisionNativeUI/AnalysisWorkbenchReportView.swift index 9ab50e02f..d8c74fba7 100644 --- a/apps/native/Sources/VisionNativeUI/AnalysisWorkbenchReportView.swift +++ b/apps/native/Sources/VisionNativeUI/AnalysisWorkbenchReportView.swift @@ -14,11 +14,13 @@ struct AnalysisWorkbenchReportView: View { VStack(alignment: .leading, spacing: 12) { errors(raw["errors"]?.arrayValue ?? []) if let summaries = raw["summaries"]?.objectValue { - Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 8) { - ForEach(summaries.keys.sorted(), id: \.self) { id in - GridRow { - Text(model.fieldLabel(id, strings: strings)) - Text(verbatim: display(summaries[id])).monospacedDigit().gridColumnAlignment(.trailing) + HorizontalOverflowScroll { + Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 8) { + ForEach(summaries.keys.sorted(), id: \.self) { id in + GridRow { + Text(model.fieldLabel(id, strings: strings)) + Text(verbatim: display(summaries[id])).monospacedDigit().gridColumnAlignment(.trailing) + } } } }.font(.caption).textSelection(.enabled) diff --git a/apps/native/Sources/VisionNativeUI/Components/HorizontalOverflowScroll.swift b/apps/native/Sources/VisionNativeUI/Components/HorizontalOverflowScroll.swift new file mode 100644 index 000000000..a5485b293 --- /dev/null +++ b/apps/native/Sources/VisionNativeUI/Components/HorizontalOverflowScroll.swift @@ -0,0 +1,46 @@ +import SwiftUI + +/// The web `` wrapper (`relative w-full overflow-auto`): the table fills the width it is +/// offered and, when its content is wider (a narrow window), keeps its content width and +/// scrolls sideways instead of squeezing its cells into piled-up lines. +/// +/// A plain `ScrollView(.horizontal)` proposes no width, so its content would stop filling the +/// card; this layout proposes the larger of the offered width and the content's own width. +struct HorizontalOverflowScroll: View { + @ViewBuilder var content: () -> Content + @State private var availableWidth: CGFloat? + + var body: some View { + ScrollView(.horizontal) { + HorizontalOverflowLayout(availableWidth: availableWidth) { content() } + } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in + if availableWidth != width { availableWidth = width } + } + } +} + +/// Lays its one child out at `max(offered width, the child's ideal width)`. +struct HorizontalOverflowLayout: Layout { + var availableWidth: CGFloat? + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + guard let child = subviews.first else { return .zero } + let width = width(proposal: proposal, child: child) + return CGSize(width: width, height: child.sizeThatFits(ProposedViewSize(width: width, height: nil)).height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + guard let child = subviews.first else { return } + child.place( + at: bounds.origin, anchor: .topLeading, proposal: ProposedViewSize(width: bounds.width, height: nil)) + } + + private func width(proposal: ProposedViewSize, child: LayoutSubview) -> CGFloat { + let ideal = child.sizeThatFits(.unspecified).width + let offered = proposal.width.flatMap { $0.isFinite ? $0 : nil } ?? availableWidth ?? ideal + return max(offered, ideal) + } +} diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift b/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift index fdd711845..f3cd36047 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift @@ -7,7 +7,9 @@ import VisionNativeCore /// 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. +/// wider than the card the columns keep their content widths and the table scrolls sideways +/// inside its card, as the web `
`'s `overflow-auto` wrapper does: cells never squeeze +/// into piled-up lines. /// /// 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 @@ -35,11 +37,25 @@ struct VisionTable: View { var rowHover = true @ViewBuilder var content: () -> Content @State private var hoverY: CGFloat? + /// The width the table is offered: the scroll view proposes no width to its content. + @State private var availableWidth: CGFloat? @Environment(\.visionTheme) private var theme var body: some View { + ScrollView(.horizontal) { + table + } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in + if availableWidth != width { availableWidth = width } + } + } + + private var table: some View { VisionTableLayout( - columns: columns, columnSpacing: columnSpacing, fixedColumns: fixedColumns, hoverY: rowHover ? hoverY : nil + columns: columns, columnSpacing: columnSpacing, fixedColumns: fixedColumns, hoverY: rowHover ? hoverY : nil, + availableWidth: availableWidth ) { if rowHover { Rectangle().fill(theme.foreground.opacity(0.03)).allowsHitTesting(false) @@ -48,7 +64,6 @@ struct VisionTable: View { } content() } - .frame(maxWidth: .infinity, alignment: .leading) .onContinuousHover { phase in guard rowHover else { return } let y: CGFloat? @@ -81,6 +96,8 @@ struct VisionTableLayout: Layout { let fixedColumns: Set /// The pointer's y in the table, for the hover band; nil hides it. var hoverY: CGFloat? = nil + /// The width to fill when the proposal has none (inside the horizontal scroll view). + var availableWidth: CGFloat? = nil struct Cache { var width: CGFloat? @@ -174,8 +191,8 @@ struct VisionTableLayout: Layout { } /// 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. + /// the spare room in proportion to them (fixed columns keep theirs). Content wider than the + /// room keeps its widths: the table overflows and scrolls sideways. 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) @@ -194,11 +211,12 @@ struct VisionTableLayout: Layout { fixed.contains(index) ? width : width + spare * width / flexible } } - return ideal.map { $0 * room / total } + return ideal } private func plan(width: CGFloat?, subviews: Subviews, cache: inout Cache) -> Plan { - if let plan = cache.plan, cache.width == width { return plan } + let finite = width.flatMap { $0.isFinite ? $0 : nil } ?? availableWidth + if let plan = cache.plan, cache.width == finite { return plan } let count = max(1, columns.count) let cells = subviews.indices.filter { !subviews[$0][VisionTableBandKey.self] } let grouped = Self.rows(spans: cells.map { subviews[$0][VisionTableSpanKey.self] }, columnCount: count) @@ -209,9 +227,9 @@ struct VisionTableLayout: Layout { 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)) + let contentWidth = widths.reduce(0, +) + columnSpacing * CGFloat(count - 1) + let tableWidth = max(finite ?? 0, contentWidth) var rows: [Plan.Row] = [] var top: CGFloat = 0 for row in grouped { @@ -232,7 +250,7 @@ struct VisionTableLayout: Layout { top += height } let plan = Plan(columnWidths: widths, rows: rows, size: CGSize(width: tableWidth, height: top)) - cache.width = width + cache.width = finite cache.plan = plan return plan } diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionTextEditorStyle.swift b/apps/native/Sources/VisionNativeUI/Components/VisionTextEditorStyle.swift index e0865d69e..62a8a78d3 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionTextEditorStyle.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionTextEditorStyle.swift @@ -18,10 +18,12 @@ struct VisionTextEditorStyle: ViewModifier { func body(content: Content) -> some View { VisionFieldChrome( isFocused: isFocused, - // `px-3` of the chrome minus the 5 pt `NSTextView` text container inset, so text lines up - // with a `VisionTextFieldStyle` field. - horizontalPadding: VisionControlMetrics.fieldHorizontalPadding - Self.textContainerInset, - verticalPadding: VisionControlMetrics.textAreaVerticalPadding - Self.textContainerInset, + // `px-3` of the chrome minus the 5 pt line fragment padding `NSTextView` adds on each + // side, so text lines up with a `VisionTextFieldStyle` field. That padding is horizontal + // only: the text view adds nothing above the first line, so `py-2` is taken in full. + // Taking 5 pt off it too put the caret and typed text 5 pt above the placeholder. + horizontalPadding: VisionControlMetrics.fieldHorizontalPadding - Self.lineFragmentPadding, + verticalPadding: VisionControlMetrics.textAreaVerticalPadding, minHeight: minHeight, alignment: .topLeading ) { content @@ -31,8 +33,8 @@ struct VisionTextEditorStyle: ViewModifier { } } - /// The horizontal inset `NSTextView` adds around its text. - private static var textContainerInset: CGFloat { 5 } + /// The horizontal inset `NSTextView` adds on each side of its text (`lineFragmentPadding`). + private static var lineFragmentPadding: CGFloat { 5 } } /// The web `Textarea`'s `placeholder`: shown over an empty editor, in the field's font and inset, diff --git a/apps/native/Sources/VisionNativeUI/NetWorthView.swift b/apps/native/Sources/VisionNativeUI/NetWorthView.swift index 593ce20a0..acf0a413f 100644 --- a/apps/native/Sources/VisionNativeUI/NetWorthView.swift +++ b/apps/native/Sources/VisionNativeUI/NetWorthView.swift @@ -371,19 +371,22 @@ public struct NetWorthView: View { } private var table: some View { VStack(alignment: .leading, spacing: 8) { - LocalDataTable( - title: strings.t("networth.dailyBreakdown"), subtitle: "", columns: columns, rows: model.tableRows, - totalCount: model.tableSnapshots.count, - query: Binding(get: { model.tableQuery }, set: { model.tableQuery = $0 }), - filterValues: model.tableFilterValues, strings: strings, serverTotal: model.tableTotal, - isFetchingMore: model.isFetchingMore, hasMore: model.hasMore - ) { - Text(strings.t(model.tableQuery.isNarrowed ? "table.noFilterResults" : "table.noData")) - }.rowAppeared { id in - if model.tableRows.suffix(5).contains(where: { $0.id == id }) { Task { await model.loadMore() } } + // `VirtualDataTable` is always a `Card` on the web. + AdminCard(padding: 0) { + LocalDataTable( + title: strings.t("networth.dailyBreakdown"), subtitle: "", columns: columns, rows: model.tableRows, + totalCount: model.tableSnapshots.count, + query: Binding(get: { model.tableQuery }, set: { model.tableQuery = $0 }), + filterValues: model.tableFilterValues, strings: strings, serverTotal: model.tableTotal, + isFetchingMore: model.isFetchingMore, hasMore: model.hasMore + ) { + Text(strings.t(model.tableQuery.isNarrowed ? "table.noFilterResults" : "table.noData")) + }.rowAppeared { id in + if model.tableRows.suffix(5).contains(where: { $0.id == id }) { Task { await model.loadMore() } } + } + // `VirtualDataTable maxHeight={520}`: the rows scroll under the fixed header. + .bodyMaxHeight(520) } - // `VirtualDataTable maxHeight={520}`: the rows scroll under the fixed header. - .bodyMaxHeight(520) if let error = model.tableError { Text(error).foregroundStyle(theme.destructive) Button(strings.t("common.retry")) { diff --git a/apps/native/Sources/VisionNativeUI/PortfolioDetailHistoryTable.swift b/apps/native/Sources/VisionNativeUI/PortfolioDetailHistoryTable.swift index cc6b166a2..1e06134b9 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioDetailHistoryTable.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioDetailHistoryTable.swift @@ -33,16 +33,19 @@ struct PortfolioDetailHistoryTable: View { } else { // The web history is a virtualised `max-h-[400px]` scroller: the table's header and // search stay put and only the rows scroll, drawn lazily. - LocalDataTable( - title: "", subtitle: "", columns: columns, rows: rows, totalCount: model.transactions.count, - query: $query, - filterValues: { key in - LocalTableQuery.uniqueValues(model.transactions, key: key, value: rawValue) - }, strings: strings - ) { - Text(strings.t("table.noFilterResults")) + // `VirtualDataTable` is always a `Card` on the web. + AdminCard(padding: 0) { + LocalDataTable( + title: "", subtitle: "", columns: columns, rows: rows, totalCount: model.transactions.count, + query: $query, + filterValues: { key in + LocalTableQuery.uniqueValues(model.transactions, key: key, value: rawValue) + }, strings: strings + ) { + Text(strings.t("table.noFilterResults")) + } + .bodyMaxHeight(400) } - .bodyMaxHeight(400) } } } diff --git a/apps/native/Sources/VisionNativeUI/RebalanceView.swift b/apps/native/Sources/VisionNativeUI/RebalanceView.swift index 439c127aa..92723c24c 100644 --- a/apps/native/Sources/VisionNativeUI/RebalanceView.swift +++ b/apps/native/Sources/VisionNativeUI/RebalanceView.swift @@ -203,25 +203,27 @@ public struct RebalanceView: View { } } card("rebalance.deploymentPlan") { - Grid(horizontalSpacing: 24, verticalSpacing: 12) { - GridRow { - Text(strings.t("rebalance.sleeve")).frame(maxWidth: .infinity, alignment: .leading) - Text(strings.t("rebalance.current")) - Text(strings.t("rebalance.target")) - Text(strings.t("rebalance.deploy")) - }.font(.callout.weight(.medium)).foregroundStyle(theme.secondaryLabel) - Divider().gridCellUnsizedAxes(.horizontal) - ForEach(result.sleeves, id: \.self) { sleeve in + HorizontalOverflowScroll { + Grid(horizontalSpacing: 24, verticalSpacing: 12) { GridRow { - Text(label(sleeve)).frame(maxWidth: .infinity, alignment: .leading) - Text(money(result.actualValues[sleeve] ?? 0)).monospacedDigit().gridColumnAlignment(.trailing) - Text(percent(result.targetWeights[sleeve] ?? 0)).monospacedDigit().gridColumnAlignment( - .trailing) - if let deploy = result.deployment[sleeve], deploy > 0 { - VisionBadge(text: money(deploy, signed: true)).monospacedDigit() - .gridColumnAlignment(.trailing) - } else { - Text("—").foregroundStyle(theme.secondaryLabel).gridColumnAlignment(.trailing) + Text(strings.t("rebalance.sleeve")).frame(maxWidth: .infinity, alignment: .leading) + Text(strings.t("rebalance.current")) + Text(strings.t("rebalance.target")) + Text(strings.t("rebalance.deploy")) + }.font(.callout.weight(.medium)).foregroundStyle(theme.secondaryLabel) + Divider().gridCellUnsizedAxes(.horizontal) + ForEach(result.sleeves, id: \.self) { sleeve in + GridRow { + Text(label(sleeve)).frame(maxWidth: .infinity, alignment: .leading) + Text(money(result.actualValues[sleeve] ?? 0)).monospacedDigit().gridColumnAlignment(.trailing) + Text(percent(result.targetWeights[sleeve] ?? 0)).monospacedDigit().gridColumnAlignment( + .trailing) + if let deploy = result.deployment[sleeve], deploy > 0 { + VisionBadge(text: money(deploy, signed: true)).monospacedDigit() + .gridColumnAlignment(.trailing) + } else { + Text("—").foregroundStyle(theme.secondaryLabel).gridColumnAlignment(.trailing) + } } } } diff --git a/apps/native/Sources/VisionNativeUI/ShellRootView.swift b/apps/native/Sources/VisionNativeUI/ShellRootView.swift index 5e9ba056e..e62f74cf1 100644 --- a/apps/native/Sources/VisionNativeUI/ShellRootView.swift +++ b/apps/native/Sources/VisionNativeUI/ShellRootView.swift @@ -77,6 +77,13 @@ public struct ShellRootView: View { // and under the titlebar strip; the other states stack them above. if !isNativeDetail { shellBanners } ZStack { + // `.liquid-canvas` is one fixed layer under every route, never part of a page: the + // canvas sits beside the page here, so a page switch (a new `.id`, new page sizes) + // never rebuilds it, resizes its Metal drawable or restarts its drift. + if isNativeDetail { + FrozenWidthLayout(width: frozenDetailWidth) { DetailGlassBackground() } + .mask { Rectangle().padding(.vertical, -400) } + } if case .native(let screen) = model.detail { FrozenWidthLayout(width: frozenDetailWidth) { NativePageZoomView(factor: model.pageZoom) { @@ -766,11 +773,11 @@ struct NativeScreenContainer: View { var body: some View { content .environment(\.visionContainerOwnsFocus, focused) - // PageTransition keys on the pathname; the canvas behind it stays put. + // PageTransition keys on the pathname. .modifier(PageEntrance()) .id(model.route.path) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(DetailGlassBackground()) + // The canvas behind the page is `pageColumn`'s, so it outlives the page. .focusable() .focusEffectDisabled() .focused($focused) diff --git a/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift b/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift index 9a902d21a..d792580a8 100644 --- a/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift +++ b/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift @@ -40,25 +40,17 @@ struct StatisticsFlowView: View { 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 } - // The band as its stroked outline, so the view's own shape is the hit area: the - // pointer is on a link only over its band, as an SVG `` stroke is. - curve(link, margin: margin) + // The band as its stroked outline: the same path the hover test below uses. + Self.curve(link, margin: margin) .strokedPath(StrokeStyle(lineWidth: max(1, CGFloat(link.width)))) .fill(target.map { color($0.node) } ?? theme.mutedForeground) .opacity(isHovered ? 0.6 : 0.25) .animation(fade, value: isHovered) - .onHover { inside in - // `onMouseEnter` / `onMouseLeave`; a late leave never clears a newer link. - if inside { - if hoveredLink != link.id { hoveredLink = link.id } - } else if hoveredLink == link.id { - hoveredLink = nil - } - } + .allowsHitTesting(false) } .accessibilityHidden(true) ForEach(layout.nodes) { node in - let bounds = rect(node, margin: margin) + let bounds = Self.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. @@ -68,14 +60,7 @@ struct StatisticsFlowView: View { .opacity(isHovered ? 1 : 0.85) .animation(fade, value: isHovered) .frame(width: bounds.width, height: bounds.height) - .contentShape(Rectangle()) - .onHover { inside in - if inside { - if hoveredNode != node.id { hoveredNode = node.id } - } else if hoveredNode == node.id { - hoveredNode = nil - } - } + .allowsHitTesting(false) .position(x: bounds.midX, y: bounds.midY) .accessibilityLabel(label(node.node)) .accessibilityValue( @@ -105,23 +90,51 @@ struct StatisticsFlowView: View { } } .frame(width: size.width, height: size.height, alignment: .topLeading) - // Each node bar and link band tracks its own hover (SVG `onMouseEnter`/`onMouseLeave`), - // nodes drawn over links. One tracker for the whole chart, testing nodes first by their - // rectangles, lit a node's whole section over the band beside it. + .contentShape(Rectangle()) + // SwiftUI's `onHover` tracks a view's whole frame, not its shape, so per-band trackers + // all covered the chart and the topmost band won everywhere. One tracker hit-tests the + // SVG way instead: the topmost painted shape under the pointer, node bars (drawn over + // the bands) by their exact rectangles, then bands by their stroked outlines, last + // drawn first. Empty space hovers nothing. + .onContinuousHover { phase in + switch phase { + case .active(let point): + let hit = Self.hit(at: point, layout: layout, margin: margin) + if hoveredNode != hit.node { hoveredNode = hit.node } + if hoveredLink != hit.link { hoveredLink = hit.link } + case .ended: + if hoveredNode != nil { hoveredNode = nil } + if hoveredLink != nil { hoveredLink = nil } + } + } }.frame(minHeight: 380) .onChange(of: hoveredNode) { _, _ in onHoverChange(hoveredNode, hoveredLink) } .onChange(of: hoveredLink) { _, _ in onHoverChange(hoveredNode, hoveredLink) } } + /// The node or band painted topmost at `point`, as SVG `onMouseEnter` targets resolve. + static func hit( + at point: CGPoint, layout: StatisticsFlowLayout, margin: CGFloat + ) -> (node: String?, link: String?) { + for node in layout.nodes.reversed() where rect(node, margin: margin).contains(point) { + return (node.id, nil) + } + for link in layout.links.reversed() { + let band = curve(link, margin: margin).strokedPath(StrokeStyle(lineWidth: max(1, CGFloat(link.width)))) + if band.contains(point) { return (nil, link.id) } + } + return (nil, nil) + } + /// The node's rectangle in view coordinates; at least 1 pt tall, as on the web. - private func rect(_ node: StatisticsFlowNodeLayout, margin: CGFloat) -> CGRect { + private static func rect(_ node: StatisticsFlowNodeLayout, margin: CGFloat) -> CGRect { CGRect( x: margin + CGFloat(node.x0), y: margin + CGFloat(node.y0), width: CGFloat(node.x1 - node.x0), height: max(1, CGFloat(node.y1 - node.y0))) } /// The link's centre line: the web `sankeyLinkPath` curve, M sx,y0 C mx,y0 mx,y1 tx,y1. - private func curve(_ link: StatisticsFlowLinkLayout, margin: CGFloat) -> Path { + private static func curve(_ link: StatisticsFlowLinkLayout, margin: CGFloat) -> Path { let sx = margin + CGFloat(link.sourceX) let tx = margin + CGFloat(link.targetX) let mx = (sx + tx) / 2 diff --git a/apps/native/Sources/VisionNativeUI/StatisticsView.swift b/apps/native/Sources/VisionNativeUI/StatisticsView.swift index c560de060..f93818149 100644 --- a/apps/native/Sources/VisionNativeUI/StatisticsView.swift +++ b/apps/native/Sources/VisionNativeUI/StatisticsView.swift @@ -652,29 +652,32 @@ public struct StatisticsView: View { let visible = Array(model.recipientRows.prefix(model.recipientVisibleCount)) let ranks = Dictionary( visible.enumerated().map { index, row in (row.id, index + 1) }, uniquingKeysWith: { first, _ in first }) - return LocalDataTable( - title: strings.t("insights.detailsTitle"), subtitle: strings.t("insights.detailsSubtitle"), - columns: [recipientRankColumn(ranks)] + recipientColumns, - rows: visible, - totalCount: data.topMerchants.count, - query: Binding(get: { model.recipientQuery }, set: { model.recipientQuery = $0 }), - filterValues: model.recipientFilterValues, strings: strings, - serverTotal: model.recipientRows.count, - hasMore: model.recipientVisibleCount < model.recipientRows.count - ) { - Text( - strings.t( - model.recipientQuery.isNarrowed ? "table.noFilterResults" : "insights.detailsEmpty")) - } - .rowAppeared { id in - if model.recipientRows.prefix(model.recipientVisibleCount).suffix(5).contains(where: { - $0.id == id - } + // `VirtualDataTable` is always a `Card` on the web. + return AdminCard(padding: 0) { + LocalDataTable( + title: strings.t("insights.detailsTitle"), subtitle: strings.t("insights.detailsSubtitle"), + columns: [recipientRankColumn(ranks)] + recipientColumns, + rows: visible, + totalCount: data.topMerchants.count, + query: Binding(get: { model.recipientQuery }, set: { model.recipientQuery = $0 }), + filterValues: model.recipientFilterValues, strings: strings, + serverTotal: model.recipientRows.count, + hasMore: model.recipientVisibleCount < model.recipientRows.count ) { - model.loadMoreRecipients(pageSize: settings.defaultPageSize) + Text( + strings.t( + model.recipientQuery.isNarrowed ? "table.noFilterResults" : "insights.detailsEmpty")) + } + .rowAppeared { id in + if model.recipientRows.prefix(model.recipientVisibleCount).suffix(5).contains(where: { + $0.id == id + } + ) { + model.loadMoreRecipients(pageSize: settings.defaultPageSize) + } } + .bodyMaxHeight(700) } - .bodyMaxHeight(700) } /// The "#" column: the row's position in the list, not sortable or filterable. private func recipientRankColumn(_ ranks: [RecipientID: Int]) -> LocalDataColumn { diff --git a/apps/native/Sources/VisionNativeUI/TransactionsTable.swift b/apps/native/Sources/VisionNativeUI/TransactionsTable.swift index dee9cee6d..e953bf411 100644 --- a/apps/native/Sources/VisionNativeUI/TransactionsTable.swift +++ b/apps/native/Sources/VisionNativeUI/TransactionsTable.swift @@ -56,9 +56,19 @@ struct TransactionsTableCard: View { searchBar .zIndex(1) if !model.columnFilters.isEmpty { chips } - headerRow - Divider() - rowsList + // The web table keeps its columns' widths and scrolls sideways inside the card when + // the window is narrower; the rows scroll vertically under the fixed header. + ScrollView(.horizontal) { + VStack(alignment: .leading, spacing: 0) { + headerRow + Divider() + rowsList + } + .containerRelativeFrame(.horizontal, alignment: .leading) { length, _ in + max(length, minimumTableWidth) + } + } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) if model.isFetchingMore { HStack(spacing: 8) { ProgressView().controlSize(.small) @@ -224,6 +234,14 @@ struct TransactionsTableCard: View { ).mapValues { CGFloat($0) } } + /// The width under which the columns stop shrinking and the table scrolls sideways: the + /// chrome plus every column at its fitted width. + private var minimumTableWidth: CGFloat { + let fitted = fittedWidths + return Self.selectWidth + Self.editWidth + 32 + (isCompact ? Self.summaryWidth : 0) + + dataColumns.reduce(0) { $0 + width($1, fitted: fitted) } + } + private func width(_ column: TransactionColumn, fitted: [String: CGFloat]) -> CGFloat { fitted[column.rawValue] ?? CGFloat(column.width) }