diff --git a/apps/native/Sources/VisionNativeCore/Screens/Insights/StatisticsModel.swift b/apps/native/Sources/VisionNativeCore/Screens/Insights/StatisticsModel.swift index 96de4fc38..f8d43ec5b 100644 --- a/apps/native/Sources/VisionNativeCore/Screens/Insights/StatisticsModel.swift +++ b/apps/native/Sources/VisionNativeCore/Screens/Insights/StatisticsModel.swift @@ -102,6 +102,12 @@ public final class StatisticsModel { public func isVisible(_ id: String) -> Bool { InsightWidget.statistics.first { $0.id == id }.map { widgets.isVisible($0, page: "statistics") } ?? false } + /// A tab click: the tab shows at once, before the route change reaches `appear`, which + /// then reads the same tab back from the URL. + public func selectTab(_ next: StatisticsTab) { + tab = next + } + public func appear(settings: NativeAppSettings, route: NativeRoute, force: Bool = false) async { let nextWindow = StatisticsWindow(routeValue: route.value("window")) tab = StatisticsTab(rawValue: route.value("tab") ?? "") ?? .overview diff --git a/apps/native/Sources/VisionNativeUI/AnalysisWorkspaceView.swift b/apps/native/Sources/VisionNativeUI/AnalysisWorkspaceView.swift index b796303ec..e33d468b8 100644 --- a/apps/native/Sources/VisionNativeUI/AnalysisWorkspaceView.swift +++ b/apps/native/Sources/VisionNativeUI/AnalysisWorkspaceView.swift @@ -42,15 +42,15 @@ public struct AnalysisWorkspaceView: View { if model.displayedResult != nil { AnalysisResultView(model: model, shell: shell, focusRequest: resultFocusRequest) } - if model.displayedResult != nil { - AnalysisPanel { - DisclosureGroup(strings.t("analysis.refineTitle"), isExpanded: $toolsOpen) { - VStack(alignment: .leading, spacing: 12) { - Text(strings.t("analysis.refineHelp")).font(.callout).foregroundStyle( - theme.secondaryLabel) - AnalysisWorkbenchView(model: workbench, shell: shell) - }.padding(.top, 12) - } + // The web renders the Refine card with or without a result, so the + // Prepare and Calculate links always have a section to open and scroll to. + AnalysisPanel { + DisclosureGroup(strings.t("analysis.refineTitle"), isExpanded: $toolsOpen) { + VStack(alignment: .leading, spacing: 12) { + Text(strings.t("analysis.refineHelp")).font(.callout).foregroundStyle( + theme.secondaryLabel) + AnalysisWorkbenchView(model: workbench, shell: shell) + }.padding(.top, 12) } } savedFeedback @@ -104,7 +104,8 @@ public struct AnalysisWorkspaceView: View { scroll(proxy, to: "analysis-" + section) } }.buttonStyle(.visionOutline) - .disabled(section == "present" && model.displayedResult == nil) + // ``: always enabled; without a result there is no + // section to scroll to, as on the web. } }.accessibilityElement(children: .contain).accessibilityLabel(strings.t("analysis.workflow.title")) } diff --git a/apps/native/Sources/VisionNativeUI/Components/LocalDataTable.swift b/apps/native/Sources/VisionNativeUI/Components/LocalDataTable.swift index 875a6d59b..2c2528f57 100644 --- a/apps/native/Sources/VisionNativeUI/Components/LocalDataTable.swift +++ b/apps/native/Sources/VisionNativeUI/Components/LocalDataTable.swift @@ -160,6 +160,16 @@ struct LocalDataTable: View { Divider() } } + // The end of the loaded rows. A row's `onAppear` fires once, so a page that was not + // asked for then (the rows appeared together, or a reload dropped the request) was + // never asked for again and the table stopped short of the total. This marker gets a + // new identity with every page, so reaching the end asks again until nothing is left. + if hasMore, let last = rows.last, let onRowAppear { + Color.clear.frame(height: 1) + .id(rows.count) + .onAppear { onRowAppear(last.id) } + .accessibilityHidden(true) + } if isFetchingMore { HStack(spacing: 8) { ProgressView().controlSize(.small) diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift b/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift new file mode 100644 index 000000000..3da8f9929 --- /dev/null +++ b/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift @@ -0,0 +1,209 @@ +import SwiftUI +import VisionNativeCore + +/// The web `` (`components/ui/table.tsx`: `w-full`, automatic table layout) as a layout. +/// +/// A SwiftUI `Grid` sizes every column to its content, so a table in a wide card sat packed +/// against the leading edge. This layout fills the width it is offered, as `w-full` does: each +/// column starts at the widest of its cells and the room left over is shared between the +/// columns in proportion to those widths (CSS automatic table layout). When the content is +/// wider than the card the columns shrink in the same proportion. +/// +/// The cells are the layout's direct children in row order, one per column. A child marked +/// with `visionTableSpan()` takes a whole row (a divider, an expanded detail row, an empty +/// state). +/// +/// VisionTable(columns: [.leading, .trailing]) { +/// Text("Currency") +/// Text("Rate") +/// ForEach(rows) { row in +/// Divider().visionTableSpan() +/// Text(row.currency) +/// Text(row.rate) +/// } +/// } +struct VisionTable: View { + /// The horizontal alignment of each column's cells; its count is the column count. + let columns: [HorizontalAlignment] + /// The gap between two columns (the two `px-4` paddings that meet there are 32 pt on the + /// web; the native tables keep the 16 pt they were drawn with unless a table asks). + var columnSpacing: CGFloat = 16 + /// A column that keeps its content width instead of taking a share of the spare room + /// (`w-24`, an actions column). + var fixedColumns: Set = [] + @ViewBuilder var content: () -> Content + + var body: some View { + VisionTableLayout(columns: columns, columnSpacing: columnSpacing, fixedColumns: fixedColumns) { + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +extension View { + /// The view takes a whole row of its `VisionTable`. + func visionTableSpan() -> some View { layoutValue(key: VisionTableSpanKey.self, value: true) } +} + +private struct VisionTableSpanKey: LayoutValueKey { + static let defaultValue = false +} + +struct VisionTableLayout: Layout { + let columns: [HorizontalAlignment] + let columnSpacing: CGFloat + let fixedColumns: Set + + struct Cache { + var width: CGFloat? + var plan: Plan? + } + + /// The measured table: where each row starts, how tall it is and how wide each column is. + struct Plan { + var columnWidths: [CGFloat] + /// One entry per row: the subview indices in it, whether it spans, its top and height. + var rows: [Row] + var size: CGSize + + struct Row { + var indices: [Int] + var spans: Bool + var top: CGFloat + var height: CGFloat + } + } + + func makeCache(subviews: Subviews) -> Cache { Cache() } + + func updateCache(_ cache: inout Cache, subviews: Subviews) { cache = Cache() } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize { + plan(width: proposal.width, subviews: subviews, cache: &cache).size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) { + let plan = plan(width: bounds.width, subviews: subviews, cache: &cache) + for row in plan.rows { + if row.spans { + for index in row.indices { + subviews[index].place( + at: CGPoint(x: bounds.minX, y: bounds.minY + row.top), anchor: .topLeading, + proposal: ProposedViewSize(width: bounds.width, height: row.height)) + } + continue + } + var x = bounds.minX + for (column, index) in row.indices.enumerated() { + let width = plan.columnWidths[column] + let size = subviews[index].sizeThatFits(ProposedViewSize(width: width, height: nil)) + let offset: CGFloat + switch columns[column] { + case .trailing: offset = width - size.width + case .center: offset = (width - size.width) / 2 + default: offset = 0 + } + subviews[index].place( + at: CGPoint(x: x + max(0, offset), y: bounds.minY + row.top + (row.height - size.height) / 2), + anchor: .topLeading, proposal: ProposedViewSize(width: width, height: nil)) + x += width + columnSpacing + } + } + } + + /// Groups the subviews into rows: a spanning child is a row of its own, the others fill + /// rows of `columns.count` cells. + static func rows(spans: [Bool], columnCount: Int) -> [(indices: [Int], spans: Bool)] { + var rows: [(indices: [Int], spans: Bool)] = [] + var current: [Int] = [] + for (index, isSpan) in spans.enumerated() { + if isSpan { + if !current.isEmpty { + rows.append((current, false)) + current = [] + } + rows.append(([index], true)) + } else { + current.append(index) + if current.count == columnCount { + rows.append((current, false)) + current = [] + } + } + } + if !current.isEmpty { rows.append((current, false)) } + return rows + } + + /// CSS automatic table layout for `available` points: the content widths, plus a share of + /// the spare room in proportion to them (fixed columns keep theirs), or scaled down in + /// proportion when the content is wider. + static func columnWidths(ideal: [CGFloat], available: CGFloat?, spacing: CGFloat, fixed: Set) -> [CGFloat] { + guard let available, !ideal.isEmpty else { return ideal } + let room = available - spacing * CGFloat(ideal.count - 1) + let total = ideal.reduce(0, +) + guard room > 0, total > 0 else { return ideal } + if room >= total { + let flexible = ideal.enumerated().filter { !fixed.contains($0.offset) }.map(\.element).reduce(0, +) + // Nothing can grow: every column is fixed, so the last one takes the room. + guard flexible > 0 else { + var widths = ideal + widths[widths.count - 1] += room - total + return widths + } + let spare = room - total + return ideal.enumerated().map { index, width in + fixed.contains(index) ? width : width + spare * width / flexible + } + } + return ideal.map { $0 * room / total } + } + + private func plan(width: CGFloat?, subviews: Subviews, cache: inout Cache) -> Plan { + if let plan = cache.plan, cache.width == width { return plan } + let count = max(1, columns.count) + let grouped = Self.rows(spans: subviews.map { $0[VisionTableSpanKey.self] }, columnCount: count) + var ideal = [CGFloat](repeating: 0, count: count) + for row in grouped where !row.spans { + for (column, index) in row.indices.enumerated() { + ideal[column] = max(ideal[column], subviews[index].sizeThatFits(.unspecified).width) + } + } + let finite = width.flatMap { $0.isFinite ? $0 : nil } + let widths = Self.columnWidths(ideal: ideal, available: finite, spacing: columnSpacing, fixed: fixedColumns) + let tableWidth = finite ?? (widths.reduce(0, +) + columnSpacing * CGFloat(count - 1)) + var rows: [Plan.Row] = [] + var top: CGFloat = 0 + for row in grouped { + var height: CGFloat = 0 + if row.spans { + for index in row.indices { + height = max( + height, subviews[index].sizeThatFits(ProposedViewSize(width: tableWidth, height: nil)).height) + } + } else { + for (column, index) in row.indices.enumerated() { + height = max( + height, + subviews[index].sizeThatFits(ProposedViewSize(width: widths[column], height: nil)).height) + } + } + rows.append(Plan.Row(indices: row.indices, spans: row.spans, top: top, height: height)) + top += height + } + let plan = Plan(columnWidths: widths, rows: rows, size: CGSize(width: tableWidth, height: top)) + cache.width = width + cache.plan = plan + return plan + } +} + +/// A row's bottom border (`border-b border-border/40` of `TableRow`), across the whole table. +struct VisionTableDivider: View { + @Environment(\.visionTheme) private var theme + + var body: some View { + Rectangle().fill(theme.border.opacity(0.4)).frame(height: 1).visionTableSpan() + } +} diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionTooltip.swift b/apps/native/Sources/VisionNativeUI/Components/VisionTooltip.swift index 0294e0fc0..8a4d5ab09 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionTooltip.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionTooltip.swift @@ -190,11 +190,18 @@ private final class VisionTooltipAnchorBox { weak var view: NSView? } +/// Only measures where the tooltip goes. It is transparent to the mouse, so it can never take a +/// mouse-down meant for the menu or button it is attached to. +private final class VisionTooltipAnchorView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { false } +} + private struct VisionTooltipAnchor: NSViewRepresentable { let box: VisionTooltipAnchorBox func makeNSView(context: Context) -> NSView { - let view = NSView() + let view = VisionTooltipAnchorView() box.view = view return view } diff --git a/apps/native/Sources/VisionNativeUI/HomeForecastDiagnostics.swift b/apps/native/Sources/VisionNativeUI/HomeForecastDiagnostics.swift index 1cbe04f32..434c8edb2 100644 --- a/apps/native/Sources/VisionNativeUI/HomeForecastDiagnostics.swift +++ b/apps/native/Sources/VisionNativeUI/HomeForecastDiagnostics.swift @@ -5,8 +5,6 @@ struct HomeForecastDiagnostics: View { let model: HomeModel let settings: NativeAppSettings let strings: any Localizing - @State private var selected: [String: String] = [:] - @State private var range: [String: String] = [:] @Environment(\.visionDismissDialog) private var dismiss @Environment(\.visionTheme) private var theme @@ -20,39 +18,45 @@ struct HomeForecastDiagnostics: View { if model.isForecastLoading { ProgressView() } else if let diagnostics = model.forecast?.diagnostics { + let ranked = diagnostics.backtest.sorted { $0.mae < $1.mae } + let hasPersisted = !(model.forecastAccuracy?.methods.isEmpty ?? true) Text( strings.t("cashflow.diagnostics.desc", ["months": String(diagnostics.history_months)]) ).foregroundStyle(theme.secondaryLabel) - Text(strings.t("cashflow.diagnostics.accuracy")).font(.headline) - accuracy(diagnostics) - Text( - strings.t( - "cashflow.diagnostics.backtestNote", - [ - "n": String(diagnostics.history_months), - "currency": (model.forecast?.currency ?? LedgerCurrency.display(settings)).rawValue, - ]) - ).font(.footnote).foregroundStyle(theme.secondaryLabel) + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Text(strings.t("cashflow.diagnostics.accuracy")).font(.headline) + .accessibilityAddTraits(.isHeader) + Spacer(minLength: 8) + if hasPersisted { + Label(strings.t("cashflow.diagnostics.persistedHistory"), systemImage: "cylinder.split.1x2") + .font(.caption).foregroundStyle(theme.secondaryLabel) + } + } + accuracy(ranked) + Text( + hasPersisted + ? strings.t("cashflow.diagnostics.backtestNoteWithHistory") + : strings.t( + "cashflow.diagnostics.backtestNote", + ["n": String(diagnostics.history_months), "currency": currency.rawValue]) + ).font(.footnote).foregroundStyle(theme.secondaryLabel) + } // The weights the ensemble applied, computed on the server. let weights = diagnostics.ensemble_weights ?? [:] if !weights.isEmpty { - let maxWeight = weights.values.max() ?? 1 - Text(strings.t("cashflow.diagnostics.suggestedWeights")).font(.headline) - Text(strings.t("cashflow.diagnostics.weightsNote")).font(.footnote).foregroundStyle( - theme.secondaryLabel) - ForEach(diagnostics.backtest.sorted { $0.mae < $1.mae }) { method in - if let weight = weights[method.id] { - HStack { - Text(method.label) - Spacer() - Text( - VisionFormat.decimal( - 100 * weight, fractionDigits: 0, locale: settings.numberFormat.locale) - + "%") + let topWeight = max(weights.values.max() ?? 0, 0) + VStack(alignment: .leading, spacing: 8) { + Text(strings.t("cashflow.diagnostics.suggestedWeights")).font(.headline) + .accessibilityAddTraits(.isHeader) + Text(strings.t("cashflow.diagnostics.weightsNote")).font(.footnote).foregroundStyle( + theme.secondaryLabel + ) + .padding(.bottom, 4) + ForEach(ranked) { method in + if let weight = weights[method.id] { + weightRow(method, weight: weight, topWeight: topWeight) } - ProgressView( - value: maxWeight > 0 ? NSDecimalNumber(decimal: weight / maxWeight).doubleValue : 0 - ).tint(theme.primary) } } } @@ -68,68 +72,137 @@ struct HomeForecastDiagnostics: View { .onDisappear { model.forecastOptions.diagnostics = false } } - private func accuracy(_ diagnostics: InsightForecast.Diagnostics) -> some View { - VStack(alignment: .leading, spacing: 12) { - Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 10) { - GridRow { - Text(strings.t("cashflow.diagnostics.rank")) - Text(strings.t("cashflow.diagnostics.method")) - Text("MAE") - Text("RMSE") - Text("MAPE") - Text(strings.t("cashflow.diagnostics.months")) - }.font(.caption.weight(.semibold)).foregroundStyle(theme.secondaryLabel) - ForEach( - Array(diagnostics.backtest.sorted { $0.mae < $1.mae }.enumerated()), id: \.element.id - ) { index, method in - GridRow { - Text("#\(index + 1)").foregroundStyle(index == 0 ? theme.warning : theme.secondaryLabel) - Text(method.label) - amount(method.mae) - amount(method.rmse) - Text( - method.mape.map { - $0 > 9999 - ? "—" - : VisionFormat.decimal( - $0, fractionDigits: 1, locale: settings.numberFormat.locale) + "%" - } ?? "—") - Text(String(method.months)) - }.font(.caption) - } + private var currency: CurrencyCode { model.forecast?.currency ?? LedgerCurrency.display(settings) } + + /// `METHOD_COLORS`: each forecasting method keeps its chart colour; an unknown one takes the eighth. + private func color(_ method: String) -> Color { + let order = [ + "simple_avg", "weighted_avg", "ewma", "holt_winters", "prophet_lite", "monte_carlo_parametric", + "monte_carlo_block_bootstrap", + ] + let colors = theme.chartColors + let index = order.firstIndex(of: method) ?? 7 + return index < colors.count ? colors[index] : theme.primary + } + + /// The accuracy `
` in its bordered frame: method, MAE, RMSE, MAPE, months, rank, MAE trend. + private func accuracy(_ ranked: [InsightForecast.Backtest]) -> some View { + VisionTable( + columns: [.leading, .trailing, .trailing, .trailing, .trailing, .leading, .leading], columnSpacing: 20, + fixedColumns: [6] + ) { + Group { + Text(strings.t("cashflow.diagnostics.method")) + Text(verbatim: "MAE") + Text(verbatim: "RMSE") + Text(verbatim: "MAPE") + Text(strings.t("cashflow.diagnostics.months")) + Text(strings.t("cashflow.diagnostics.rank")) + Text(strings.t("cashflow.diagnostics.maeTrend")) } - .padding(12) - .overlay( - RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous) - .strokeBorder(theme.border.opacity(0.6))) - ForEach(diagnostics.backtest.sorted { $0.mae < $1.mae }) { method in - let persisted = model.forecastAccuracy?.methods.first { $0.id == method.id } - let history = persisted?.history ?? method.per_month - VStack(alignment: .leading, spacing: 5) { - HStack { - Text(method.label).font(.caption.weight(.medium)) - if persisted != nil { - Text(strings.t("cashflow.diagnostics.persistedHistory")).font(.caption) - .foregroundStyle(theme.secondaryLabel) - } + .font(.footnote.weight(.medium)).foregroundStyle(theme.secondaryLabel).lineLimit(1) + .padding(.vertical, 10) + .accessibilityAddTraits(.isHeader) + ForEach(Array(ranked.enumerated()), id: \.element.id) { index, method in + VisionTableDivider() + Group { + HStack(spacing: 8) { + Circle().fill(color(method.id)).frame(width: 10, height: 10).accessibilityHidden(true) + Text(method.label).fontWeight(.medium).lineLimit(2) } - InsightTimelineChart( - series: [ - InsightChartSeries( - id: method.id, label: "MAE", - points: history.map { InsightChartPoint(key: $0.month, value: $0.mae) }) - ], - currency: model.forecast?.currency ?? LedgerCurrency.display(settings), settings: settings, - title: strings.t("cashflow.diagnostics.maeTrend"), height: 70, sparkline: true, - selection: Binding(get: { selected[method.id] }, set: { selected[method.id] = $0 }), - rangeStart: Binding(get: { range[method.id] }, set: { range[method.id] = $0 })) + amount(method.mae) + amount(method.rmse) + Text(mapeText(method.mape)).monospacedDigit() + Text(String(method.months)).monospacedDigit().foregroundStyle(theme.secondaryLabel) + rank(index + 1) + trend(method) + } + .padding(.vertical, 8) + } + } + .padding(.horizontal, 16) + .overlay( + RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous) + .strokeBorder(theme.border.opacity(0.6))) + } + + /// MAPE to one decimal; "N/A" without one or above 9999 %, as on the web. + private func mapeText(_ mape: Decimal?) -> String { + guard let mape, mape <= 9999 else { return "N/A" } + return VisionFormat.percent(mape, digits: 1, locale: settings.numberFormat.locale) + } + + /// `RankBadge`: a warning badge for the best method, muted for the next two, plain text after. + @ViewBuilder + private func rank(_ rank: Int) -> some View { + if rank <= 3 { + VisionBadge(text: "#\(rank)", tone: rank == 1 ? .warning : .muted, small: true) + } else { + Text(verbatim: "#\(rank)").font(.footnote).foregroundStyle(theme.secondaryLabel) + } + } + + /// The MAE sparkline (`w-24`, 24 pt tall): the persisted history when there is one, else + /// the backtest's own months. + @ViewBuilder + private func trend(_ method: InsightForecast.Backtest) -> some View { + let persisted = model.forecastAccuracy?.methods.first { $0.id == method.id }?.history + let history = (persisted?.count ?? 0) > 1 ? persisted ?? [] : method.per_month + let values = history.map { NSDecimalNumber(decimal: $0.mae).doubleValue } + if values.count > 1 { + ForecastSparkline(values: values) + .stroke(color(method.id), style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)) + .frame(width: 96, height: 24) + .accessibilityLabel(strings.t("cashflow.diagnostics.maeTrend")) + } else { + Text(verbatim: "—").font(.footnote).foregroundStyle(theme.secondaryLabel).frame(width: 96, alignment: .leading) + } + } + + /// One ensemble weight: the method's dot and name (`w-44`), its bar against the largest + /// weight and the rounded percentage. + private func weightRow(_ method: InsightForecast.Backtest, weight: Decimal, topWeight: Decimal) -> some View { + let share = topWeight > 0 ? NSDecimalNumber(decimal: weight / topWeight).doubleValue : 0 + let percent = Int((NSDecimalNumber(decimal: weight).doubleValue * 100).rounded()) + return HStack(spacing: 12) { + Circle().fill(color(method.id)).frame(width: 8, height: 8).accessibilityHidden(true) + Text(method.label).font(.footnote).lineLimit(1).truncationMode(.tail) + .frame(width: 176, alignment: .leading) + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(theme.foreground.opacity(0.08)) + Capsule().fill(color(method.id)).frame(width: proxy.size.width * min(1, max(0, share))) } } + .frame(height: 6) + Text(verbatim: "\(percent)%").font(.footnote).monospacedDigit().foregroundStyle(theme.secondaryLabel) + .frame(width: 36, alignment: .trailing) } + .accessibilityElement(children: .combine) } + private func amount(_ value: Decimal) -> some View { MoneyText( - amount: value, currency: model.forecast?.currency ?? LedgerCurrency.display(settings), - settings: settings, signed: false, colored: false) + amount: value, currency: currency, settings: settings, signed: false, colored: false) + } +} + +/// `Sparkline` (components/charts/Sparkline.tsx): the values across the width, the range padded +/// by a tenth and inset 2 pt top and bottom. +private struct ForecastSparkline: Shape { + let values: [Double] + + func path(in rect: CGRect) -> Path { + var path = Path() + guard values.count > 1, let low = values.min(), let high = values.max() else { return path } + let pad = high != low ? (high - low) * 0.1 : 1 + let bottom = low - pad + let span = (high + pad) - bottom + for (index, value) in values.enumerated() { + let x = rect.minX + rect.width * CGFloat(index) / CGFloat(values.count - 1) + let y = rect.maxY - 2 - (rect.height - 4) * CGFloat((value - bottom) / span) + if index == 0 { path.move(to: CGPoint(x: x, y: y)) } else { path.addLine(to: CGPoint(x: x, y: y)) } + } + return path } } diff --git a/apps/native/Sources/VisionNativeUI/PlannedPaymentsView.swift b/apps/native/Sources/VisionNativeUI/PlannedPaymentsView.swift index f732f783e..bd7ae182b 100644 --- a/apps/native/Sources/VisionNativeUI/PlannedPaymentsView.swift +++ b/apps/native/Sources/VisionNativeUI/PlannedPaymentsView.swift @@ -165,8 +165,8 @@ public struct PlannedPaymentsView: View { Image(systemName: "ellipsis") } .visionMoreActionsMenu() - .accessibilityLabel(strings.t("plannedPage.menu")) .visionTooltip(strings.t("plannedPage.menu")) + .accessibilityLabel(strings.t("plannedPage.menu")) } private var addButton: some View { diff --git a/apps/native/Sources/VisionNativeUI/ResearchCompareView.swift b/apps/native/Sources/VisionNativeUI/ResearchCompareView.swift index b42ebbbf3..25a408a1f 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchCompareView.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchCompareView.swift @@ -29,7 +29,9 @@ public struct ResearchCompareView: View { isUnavailable: model.search.meta?.source == "unavailable", isError: model.search.error != nil, provider: model.search.meta?.provider, - placeholder: strings.t("research.compare.addPlaceholder"), shell: shell, + placeholder: strings.t("research.compare.addPlaceholder"), + // `leadingIcon={}` on each result. + leadingSymbol: "plus", shell: shell, onSelect: { item in if let symbol = item.symbol { replace(model.addSymbol(symbol)) } } @@ -38,6 +40,10 @@ public struct ResearchCompareView: View { .frame(maxWidth: 672, alignment: .leading) } } + // The results popup hangs below the search box as an overlay. `zIndex` orders only + // siblings, so this whole block must sit above the cards that follow it; otherwise + // the empty-state card draws over the popup and takes its clicks. + .zIndex(model.search.isOpen ? 1 : 0) if model.state.symbols.isEmpty { ResearchCard { VisionEmptyState( diff --git a/apps/native/Sources/VisionNativeUI/ResearchUIComponents.swift b/apps/native/Sources/VisionNativeUI/ResearchUIComponents.swift index 403e249d8..26ddcc280 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchUIComponents.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchUIComponents.swift @@ -62,6 +62,8 @@ struct ResearchSymbolSearchView: View { var provider: String? = nil let placeholder: String var autoFocus = false + /// `SymbolSearchResultItem leadingIcon`: an SF Symbol before each result's ticker. + var leadingSymbol: String? = nil let onEscape: (() -> Void)? let shell: NativeShellModel let onSelect: (ResearchSearchItem) -> Void @@ -71,7 +73,8 @@ struct ResearchSymbolSearchView: View { init( text: Binding, items: [ResearchSearchItem], isOpen: Bool, isFetching: Bool = false, showsLoading: Bool = true, isUnavailable: Bool = false, isError: Bool = false, provider: String? = nil, - placeholder: String, autoFocus: Bool = false, onEscape: (() -> Void)? = nil, shell: NativeShellModel, + placeholder: String, autoFocus: Bool = false, leadingSymbol: String? = nil, onEscape: (() -> Void)? = nil, + shell: NativeShellModel, onSelect: @escaping (ResearchSearchItem) -> Void ) { self._text = text @@ -84,6 +87,7 @@ struct ResearchSymbolSearchView: View { self.provider = provider self.placeholder = placeholder self.autoFocus = autoFocus + self.leadingSymbol = leadingSymbol self.onEscape = onEscape self.shell = shell self.onSelect = onSelect @@ -172,6 +176,11 @@ struct ResearchSymbolSearchView: View { } private func row(_ item: ResearchSearchItem, selected: Bool) -> some View { HStack(spacing: 12) { + if let leadingSymbol { + // `h-3.5 w-3.5 shrink-0 text-label-secondary`. + Image(systemName: leadingSymbol).font(.system(size: 12, weight: .medium)) + .foregroundStyle(theme.secondaryLabel).frame(width: 14).accessibilityHidden(true) + } Text(item.symbol ?? "—").font(.callout.monospaced().weight(.medium)).frame( minWidth: 64, alignment: .leading) Text(item.name ?? "").font(.callout).foregroundStyle(theme.secondaryLabel).lineLimit(1) diff --git a/apps/native/Sources/VisionNativeUI/ShellRootView.swift b/apps/native/Sources/VisionNativeUI/ShellRootView.swift index e72fd2e66..866b099f5 100644 --- a/apps/native/Sources/VisionNativeUI/ShellRootView.swift +++ b/apps/native/Sources/VisionNativeUI/ShellRootView.swift @@ -303,6 +303,8 @@ public struct ShellRootView: View { .environment(\.visionVisualEffects, model.nativeSettings?.effectiveVisualEffects ?? .standard) .environment(\.visionFullEffects, model.nativeSettings?.fullEffects ?? false) .environment(\.colorScheme, theme.colorScheme) + // `
`: a click anywhere on a disclosure's title row toggles it, not only the chevron. + .disclosureGroupStyle(.vision) } private var shellBanners: some View { VStack(spacing: 0) { shellBannerStack } diff --git a/apps/native/Sources/VisionNativeUI/StatisticsView.swift b/apps/native/Sources/VisionNativeUI/StatisticsView.swift index 6bb40490c..f8e4f7618 100644 --- a/apps/native/Sources/VisionNativeUI/StatisticsView.swift +++ b/apps/native/Sources/VisionNativeUI/StatisticsView.swift @@ -121,6 +121,7 @@ public struct StatisticsView: View { selection: Binding( get: { model.tab }, set: { value in + model.selectTab(value) shell.replaceQuery("tab", with: value.rawValue) }), values: StatisticsTab.allCases, title: { strings.t($0.labelKey) } diff --git a/apps/native/Sources/VisionNativeUI/VisionDisclosure.swift b/apps/native/Sources/VisionNativeUI/VisionDisclosure.swift index 18854dc6e..82250961c 100644 --- a/apps/native/Sources/VisionNativeUI/VisionDisclosure.swift +++ b/apps/native/Sources/VisionNativeUI/VisionDisclosure.swift @@ -24,3 +24,48 @@ struct VisionDisclosure: View { } } } + +/// The web `
`/`` (components/ui/disclosure.tsx): the whole summary row, not +/// only the marker, opens and closes the section (`cursor-pointer select-none`). The marker is a +/// chevron in `text-label-tertiary` before the label that turns down when open. Set once at the +/// shell root, so every `DisclosureGroup` behaves this way. +struct VisionDisclosureGroupStyle: DisclosureGroupStyle { + func makeBody(configuration: Configuration) -> some View { + VisionDisclosureGroupBody(configuration: configuration) + } +} + +private struct VisionDisclosureGroupBody: View { + let configuration: DisclosureGroupStyleConfiguration + @Environment(\.visionTheme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.15)) { + configuration.isExpanded.toggle() + } + } label: { + HStack(spacing: 6) { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(theme.tertiaryLabel) + .rotationEffect(.degrees(configuration.isExpanded ? 90 : 0)) + .accessibilityHidden(true) + configuration.label + .frame(maxWidth: .infinity, alignment: .leading) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + if configuration.isExpanded { + configuration.content + } + } + } +} + +extension DisclosureGroupStyle where Self == VisionDisclosureGroupStyle { + static var vision: VisionDisclosureGroupStyle { VisionDisclosureGroupStyle() } +}