diff --git a/apps/frontend/src/locales/en.ts b/apps/frontend/src/locales/en.ts index ba2fa30ed..34ff13dc0 100644 --- a/apps/frontend/src/locales/en.ts +++ b/apps/frontend/src/locales/en.ts @@ -3459,6 +3459,8 @@ const en: Record = { 'settings.app.updatesHintWeb': 'Update this deployment the same way it was installed. Any data upgrades run automatically on restart.', 'settings.app.version': 'Version {version}', 'settings.app.versionAvailable': 'Version {version} is available', + 'settings.appearance.assetLogos': 'Company and coin logos', + 'settings.appearance.assetLogosHint': 'Shows logos on the asset pages. Vision loads them by ticker from financialmodelingprep.com (stocks and ETFs) and cdn.jsdelivr.net (crypto); no amounts or other data are sent. Off by default.', 'settings.appearance.autoAdaptDisplay': 'Reduce effects on large displays', 'settings.appearance.autoAdaptDisplayHint': 'Automatically switches to Reduced while the window is on a high-resolution external display such as a 4K TV, and back when it leaves. The built-in screen never counts as large.', 'settings.appearance.darkFrom': 'Dark from', diff --git a/apps/frontend/src/locales/keys.ts b/apps/frontend/src/locales/keys.ts index d28780b08..05f07ffb9 100644 --- a/apps/frontend/src/locales/keys.ts +++ b/apps/frontend/src/locales/keys.ts @@ -3460,6 +3460,8 @@ export type TranslationKey = | "settings.app.updatesHintWeb" | "settings.app.version" | "settings.app.versionAvailable" + | "settings.appearance.assetLogos" + | "settings.appearance.assetLogosHint" | "settings.appearance.autoAdaptDisplay" | "settings.appearance.autoAdaptDisplayHint" | "settings.appearance.darkFrom" diff --git a/apps/frontend/src/locales/nl.ts b/apps/frontend/src/locales/nl.ts index dc1fa8337..f65f844bc 100644 --- a/apps/frontend/src/locales/nl.ts +++ b/apps/frontend/src/locales/nl.ts @@ -3459,6 +3459,8 @@ const nl: Record = { 'settings.app.updatesHintWeb': 'Werk deze installatie bij op dezelfde manier als ze is geïnstalleerd. Eventuele gegevensupgrades draaien automatisch bij herstart.', 'settings.app.version': 'Versie {version}', 'settings.app.versionAvailable': 'Versie {version} is beschikbaar', + 'settings.appearance.assetLogos': 'Bedrijfs- en muntlogo\'s', + 'settings.appearance.assetLogosHint': 'Toont logo\'s op de activapagina\'s. Vision laadt ze op ticker van financialmodelingprep.com (aandelen en etf\'s) en cdn.jsdelivr.net (crypto); bedragen of andere gegevens worden niet verstuurd. Standaard uit.', 'settings.appearance.autoAdaptDisplay': 'Effecten verminderen op grote schermen', 'settings.appearance.autoAdaptDisplayHint': 'Schakelt automatisch naar Verminderd zolang het venster op een extern scherm met hoge resolutie staat, zoals een 4K-tv, en weer terug zodra het dat verlaat. Het ingebouwde scherm telt nooit als groot.', 'settings.appearance.darkFrom': 'Donker vanaf', diff --git a/apps/native/Sources/VisionNativeCore/Screens/Insights/InsightData.swift b/apps/native/Sources/VisionNativeCore/Screens/Insights/InsightData.swift index a1509046b..3f3bb6638 100644 --- a/apps/native/Sources/VisionNativeCore/Screens/Insights/InsightData.swift +++ b/apps/native/Sources/VisionNativeCore/Screens/Insights/InsightData.swift @@ -230,7 +230,8 @@ public enum InsightsRoutes { public static let statistics = "/statistics" public static func drill( month: InsightMonth? = nil, type: String? = nil, - categoryIds: [CategoryID] = [], uncategorised: Bool = false, label: String? = nil + categoryIds: [CategoryID] = [], uncategorised: Bool = false, recipientId: RecipientID? = nil, + label: String? = nil ) -> NativeRoute { var query: [URLQueryItem] = [] if let month { @@ -248,6 +249,7 @@ public enum InsightsRoutes { name: "category_ids", value: categoryIds.map { String($0.rawValue) }.joined(separator: ","))) } if uncategorised { query.append(URLQueryItem(name: "uncategorised", value: "true")) } + if let recipientId { query.append(URLQueryItem(name: "recipient_id", value: String(recipientId.rawValue))) } if let label { query.append(URLQueryItem(name: "filter_label", value: label)) } return NativeRoute(.transactions, query: query) } diff --git a/apps/native/Sources/VisionNativeCore/Screens/Portfolio/AssetLogoSource.swift b/apps/native/Sources/VisionNativeCore/Screens/Portfolio/AssetLogoSource.swift new file mode 100644 index 000000000..78a67e12e --- /dev/null +++ b/apps/native/Sources/VisionNativeCore/Screens/Portfolio/AssetLogoSource.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Where an asset's logo image lives, if anywhere: a company logo for stocks and ETFs, a coin +/// icon for crypto. The asset pages draw it in place of the asset-class glyph and keep the glyph +/// when there is no address, no network, or no image for the symbol. +/// +/// Only the ticker leaves the Mac, to the same kind of public market-data host the price +/// providers already query; no amounts, names or account data. +public enum AssetLogoSource { + /// `cryptocurrency-icons` 0.18.1 (CC0, spothq) on jsDelivr's npm mirror: 128 px colour PNGs + /// named by lower-case ticker. Pinned so a later release cannot change what is shown. + static let cryptoBase = "https://cdn.jsdelivr.net/npm/cryptocurrency-icons@0.18.1/128/color/" + /// Financial Modeling Prep's public logo images, by upper-case ticker, without an API key. + static let companyBase = "https://financialmodelingprep.com/image-stock/" + + public static func url(symbol: String?, assetClass: OpenAPI.AssetClass) -> URL? { + guard let raw = symbol?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + switch assetClass { + case .crypto: + let coin = cryptoTicker(raw) + guard !coin.isEmpty, coin.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber) }) else { return nil } + return URL(string: cryptoBase + coin.lowercased() + ".png") + case .stock, .etf: + guard raw.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "." || $0 == "-") }) + else { return nil } + return URL(string: companyBase + raw.uppercased() + ".png") + default: + return nil + } + } + + /// The coin in a crypto symbol: `BTC-USD`, `BTC/EUR`, `BTCUSDT` and `btc` are all `BTC`. + static func cryptoTicker(_ symbol: String) -> String { + let upper = symbol.uppercased() + let head = upper.split(whereSeparator: { "-/:".contains($0) }).first.map(String.init) ?? upper + for quote in ["USDT", "USDC", "EUR", "USD"] where head.count > quote.count && head.hasSuffix(quote) { + return String(head.dropLast(quote.count)) + } + return head + } +} diff --git a/apps/native/Sources/VisionNativeCore/Settings/NativeAppSettings.swift b/apps/native/Sources/VisionNativeCore/Settings/NativeAppSettings.swift index 03947e30e..f3b222c2e 100644 --- a/apps/native/Sources/VisionNativeCore/Settings/NativeAppSettings.swift +++ b/apps/native/Sources/VisionNativeCore/Settings/NativeAppSettings.swift @@ -57,6 +57,9 @@ public struct NativeAppSettings: Equatable, Sendable { /// `auto` by default (on with the Standard tier); the design layer reads the resolved switch /// through `NativeSettingsModel.fullEffects`. public var fullEffects: FullEffectsChoice + /// `assetLogos` (Swift app only): load company and coin logos for the asset pages by ticker + /// (`AssetLogoSource`). Off by default, so no logo host is contacted until the user opts in. + public var assetLogos = false public init( defaultCurrency: CurrencyCode = .eur, dateFormat: AppDateFormat = .dayMonthYearSlash, @@ -113,6 +116,7 @@ public struct NativeAppSettings: Equatable, Sendable { } if let value = fields["adminMode"]?.boolValue { settings.adminMode = value } if let value = fields["colorblindGainLoss"]?.boolValue { settings.colorblindGainLoss = value } + if let value = fields["assetLogos"]?.boolValue { settings.assetLogos = value } if let value = fields["defaultPageSize"]?.intValue, value > 0 { settings.defaultPageSize = value } if let value = fields["analysisBenchmark"]?.stringValue { let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() diff --git a/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsDefaults.swift b/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsDefaults.swift index 303a639fa..c00fc2e4e 100644 --- a/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsDefaults.swift +++ b/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsDefaults.swift @@ -8,7 +8,7 @@ public enum NativeSettingsDefaults { "language": .string("en"), "costBasisMethod": .string("weighted_avg"), "adminMode": .bool(false), "visualEffects": .string("standard"), "autoAdaptDisplay": .bool(true), "fullEffects": .string(FullEffectsChoice.auto.rawValue), "startupSection": .string("budgeting"), - "autoClearPlannedOnMatch": .bool(true), "colorblindGainLoss": .bool(false), + "autoClearPlannedOnMatch": .bool(true), "colorblindGainLoss": .bool(false), "assetLogos": .bool(false), ]) public static let dashboard: TaxValue = .object([ "excludedCategoryIds": .array([]), "excludedRecipientIds": .array([]), @@ -66,7 +66,7 @@ public enum NativeSettingsDefaults { case "showDecimalPlaces": return integer(value).map { (0...20).contains($0) } == true ? value : app[key] case "fullEffects": return FullEffectsChoice(stored: value).map { .string($0.rawValue) } ?? app[key] - case "adminMode", "autoAdaptDisplay", "autoClearPlannedOnMatch", "colorblindGainLoss": + case "adminMode", "autoAdaptDisplay", "autoClearPlannedOnMatch", "colorblindGainLoss", "assetLogos": return value.boolValue != nil ? value : app[key] case "analysisBenchmark": guard let raw = value.stringValue, let symbol = ResearchCompareURLState.normalizeSymbol(raw), diff --git a/apps/native/Sources/VisionNativeUI/Components/AssetLogo.swift b/apps/native/Sources/VisionNativeUI/Components/AssetLogo.swift new file mode 100644 index 000000000..43ddc9ec9 --- /dev/null +++ b/apps/native/Sources/VisionNativeUI/Components/AssetLogo.swift @@ -0,0 +1,78 @@ +import AppKit +import SwiftUI +import VisionNativeCore + +/// An asset's logo (`AssetLogoSource`), or `fallback` (the asset-class glyph) until it has +/// loaded and whenever it cannot: no address, offline, an error, or no image for the ticker. +struct AssetLogo: View { + let url: URL? + var size: CGFloat = 32 + /// Company logos are drawn on a light disc, since many are dark marks on transparency; + /// coin icons are already discs. + var plate = true + @ViewBuilder let fallback: () -> Fallback + @State private var loaded: NSImage? + + var body: some View { + // The store's copy first, so a table row scrolled back into view shows the logo at once. + let image = loaded ?? url.flatMap { AssetLogoStore.shared.cached($0) } + Group { + if let image { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .padding(plate ? size * 0.14 : 0) + .frame(width: size, height: size) + .background { if plate { Circle().fill(Color.white) } } + .clipShape(Circle()) + } else { + fallback() + } + } + .task(id: url) { + guard let url else { + loaded = nil + return + } + loaded = await AssetLogoStore.shared.image(for: url) + } + } +} + +/// Logos fetched this session, by address. A failed address is not asked again until relaunch, +/// so an unknown ticker or an offline Mac costs one request per symbol, not one per redraw. +@MainActor +final class AssetLogoStore { + static let shared = AssetLogoStore() + + private var images: [URL: NSImage] = [:] + private var failed: Set = [] + private var pending: [URL: Task] = [:] + + func cached(_ url: URL) -> NSImage? { images[url] } + + func image(for url: URL) async -> NSImage? { + if let image = images[url] { return image } + if failed.contains(url) { return nil } + let task = + pending[url] + ?? Task { + let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 10) + guard let result = try? await URLSession.shared.data(for: request) else { return nil } + let (data, response) = result + guard (response as? HTTPURLResponse)?.statusCode == 200, !data.isEmpty else { return nil } + return data + } + pending[url] = task + let data = await task.value + pending[url] = nil + if let image = images[url] { return image } + if let data, let image = NSImage(data: data), image.isValid { + images[url] = image + return image + } + failed.insert(url) + return nil + } +} diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionGlassDialog.swift b/apps/native/Sources/VisionNativeUI/Components/VisionGlassDialog.swift index 1c4350c83..dc4f6677e 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionGlassDialog.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionGlassDialog.swift @@ -122,7 +122,8 @@ public struct VisionDialogMetrics: Equatable, Sendable { } /// The widest the panel grows; it shrinks to the window less a margin on each side. `nil` lets - /// the content choose its own width (a view with a fixed frame), without scrolling. + /// the content choose its own width and height (its ideal size, see `VisionDialogFit`), + /// without a scroll view of the panel's own. public var maxWidth: CGFloat? public var height: Height public var padding: CGFloat @@ -224,12 +225,69 @@ public struct VisionGlassDialog: View { // The first frame has no measured height yet; show the panel once it has. .opacity(contentHeight > 0 ? 1 : 0) } else { - stack.frame(maxHeight: maxHeight) + // The content's own width and height, never more than the window allows. A bare + // `frame(maxHeight:)` here took the whole allowance whatever the content's height + // (the content floated in the middle of a 90 %-tall card), and content with a + // flexible width spread across the window. + VisionDialogContentFit( + limit: CGSize(width: max(available.width - 2 * margin, 0), height: maxHeight) + ) { stack } } } } } +/// The size of a dialog that sizes to its content (`maxWidth: nil`, `.fitContent`): the content's +/// ideal width and the height it needs at that width, each capped by the window's allowance. +/// The panel then hugs its content the way the web `DialogContent` does (`max-h-[90vh]`, no +/// fixed height), so a card can neither leave a band of empty glass around its content nor +/// stretch past the window. Content that should scroll when the window is short owns a +/// `ScrollView`, or a flexible height (`frame(minHeight: 0, idealHeight:, maxHeight:)`, see +/// `visionDialogFrame(width:height:)`), and gets the capped height proposed. +enum VisionDialogFit { + static func size(ideal: CGSize, heightAtWidth: (CGFloat) -> CGFloat, limit: CGSize) -> CGSize { + let width = min(finite(ideal.width, or: limit.width), limit.width) + let height = min(finite(heightAtWidth(width), or: limit.height), limit.height) + return CGSize(width: max(width, 0), height: max(height, 0)) + } + + private static func finite(_ value: CGFloat, or fallback: CGFloat) -> CGFloat { + value.isFinite ? value : fallback + } +} + +/// Lays one content view out at `VisionDialogFit.size`, centred. +struct VisionDialogContentFit: Layout { + let limit: CGSize + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + guard let content = subviews.first else { return .zero } + return fit(content) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + guard let content = subviews.first else { return } + let size = fit(content) + content.place(at: CGPoint(x: bounds.midX, y: bounds.midY), anchor: .center, proposal: ProposedViewSize(size)) + } + + private func fit(_ content: LayoutSubview) -> CGSize { + VisionDialogFit.size( + ideal: content.sizeThatFits(.unspecified), + heightAtWidth: { content.sizeThatFits(ProposedViewSize(width: $0, height: nil)).height }, + limit: limit) + } +} + +extension View { + /// A dialog body's size for a content-sized dialog (`VisionDialogMetrics(maxWidth: nil)`): a + /// fixed width and a preferred height that gives way when the window is shorter, so the + /// body's own `ScrollView` takes the difference instead of the card clipping it. + func visionDialogFrame(width: CGFloat, height: CGFloat) -> some View { + frame(width: width).frame(minHeight: 0, idealHeight: height, maxHeight: height) + } +} + /// The edge drawer of `SheetContent side="right"` (components/ui/sheet.tsx): `glass-thick`, full /// height, `w-3/4` up to `sm:max-w-sm`, `rounded-l-sheet`, `p-6 gap-4`. The trailing corners are /// drawn past the window edge, so only the leading corners show rounded. diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionModalHost.swift b/apps/native/Sources/VisionNativeUI/Components/VisionModalHost.swift index d201013bb..e21bee383 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionModalHost.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionModalHost.swift @@ -117,18 +117,27 @@ struct VisionModalPresenter: ViewModifier { if mounted { VisionModalRegistry.shared.opened() } else { VisionModalRegistry.shared.closed() } } + /// The scrim covers everything the presenting view reaches, safe areas included. The panel is + /// placed in the presenting view's own width: a page's detail column reaches under the + /// floating sidebar through a leading safe-area inset, and a panel laid out across that inset + /// sat half under the sidebar (clipped by the column, its content off-centre). Vertically it + /// spans the window, as the web's `fixed inset-0` overlay does; the shell hides the topbar + /// while a page panel is up (`PageModalTopbarHiding`). private var layer: some View { - GeometryReader { proxy in - ZStack(alignment: kind == .sideSheet ? (metrics.sideEdge == .leading ? .leading : .trailing) : .center) { - scrim - placed(in: proxy.size) + ZStack { + scrim.ignoresSafeArea() + GeometryReader { proxy in + ZStack(alignment: kind == .sideSheet ? (metrics.sideEdge == .leading ? .leading : .trailing) : .center) { + Color.clear + placed(in: proxy.size) + } } + .ignoresSafeArea(edges: .vertical) } // The panel's system `Material` follows the environment colour scheme, not the app theme. // The presenter sits above the content that sets it, so without this a dark theme under a // light system appearance drew a light material behind dark content. .environment(\.colorScheme, theme.colorScheme) - .ignoresSafeArea() } private var scrim: some View { diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionPageHeader.swift b/apps/native/Sources/VisionNativeUI/Components/VisionPageHeader.swift index 3b6a8a409..81652a48a 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionPageHeader.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionPageHeader.swift @@ -24,8 +24,7 @@ struct VisionPageHeader: View { HStack(alignment: .center, spacing: 12) { // PageHeader's default icon: a bare 20 pt muted glyph in a 36 pt slot (its gradient // tile only shows when a page passes an icon colour). - Image(systemName: symbol) - .font(.system(size: 18, weight: .regular)) + VisionSymbol(symbol, size: 18) .foregroundStyle(Color(token: theme.palette.mutedForeground)) .frame(width: 36, height: 36) .accessibilityHidden(true) @@ -138,8 +137,7 @@ struct VisionEmptyState: View { private var block: some View { VStack(spacing: 0) { - Image(systemName: symbol) - .font(.system(size: 18, weight: .regular)) + VisionSymbol(symbol, size: 18) .foregroundStyle(theme.secondaryLabel) .frame(width: 40, height: 40) .background( diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionSymbol.swift b/apps/native/Sources/VisionNativeUI/Components/VisionSymbol.swift new file mode 100644 index 000000000..3e1dc18d9 --- /dev/null +++ b/apps/native/Sources/VisionNativeUI/Components/VisionSymbol.swift @@ -0,0 +1,69 @@ +import SwiftUI + +/// An SF Symbol by name, or one of Vision's own glyphs where SF Symbols has none. Views that take +/// a symbol name (page headers, empty states, asset tiles) draw it through this, so a custom name +/// works wherever an SF Symbol name does. +struct VisionSymbol: View { + let name: String + var size: CGFloat + var weight: Font.Weight = .regular + + init(_ name: String, size: CGFloat, weight: Font.Weight = .regular) { + self.name = name + self.size = size + self.weight = weight + } + + /// A gold bar (ingot), for precious metals. SF Symbols has no ingot, and the web's lucide + /// `Gem` read as a jewel rather than bullion. + static let goldBar = "vision.goldbar" + + var body: some View { + if name == Self.goldBar { + GoldBarShape() + .stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) + // The ingot is wider than tall, like `rectangle`-family symbols at the same size. + .frame(width: size * 1.3, height: size * 1.05) + } else { + Image(systemName: name).font(.system(size: size, weight: weight)) + } + } + + /// Close to the stroke of an SF Symbol at the same point size and weight. + private var lineWidth: CGFloat { + let scale: CGFloat + switch weight { + case .ultraLight, .thin, .light: scale = 0.065 + case .medium: scale = 0.09 + case .semibold, .bold, .heavy, .black: scale = 0.105 + default: scale = 0.08 + } + return max(1, size * scale) + } +} + +/// A trapezoidal bar seen from the front and a little above: the sloped front face, the top face +/// and the right end. +struct GoldBarShape: Shape { + func path(in rect: CGRect) -> Path { + func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { + CGPoint(x: rect.minX + x * rect.width, y: rect.minY + y * rect.height) + } + var path = Path() + // Outline: front bottom edge, right end, back top edge, front top-left corner. + path.move(to: point(0.04, 0.86)) + path.addLine(to: point(0.78, 0.86)) + path.addLine(to: point(0.96, 0.68)) + path.addLine(to: point(0.84, 0.22)) + path.addLine(to: point(0.34, 0.22)) + path.addLine(to: point(0.18, 0.40)) + path.closeSubpath() + // The front face's top edge and its right edge, which separate the three faces. + path.move(to: point(0.18, 0.40)) + path.addLine(to: point(0.66, 0.40)) + path.addLine(to: point(0.84, 0.22)) + path.move(to: point(0.66, 0.40)) + path.addLine(to: point(0.78, 0.86)) + return path + } +} diff --git a/apps/native/Sources/VisionNativeUI/DatabaseMaintenanceView.swift b/apps/native/Sources/VisionNativeUI/DatabaseMaintenanceView.swift index 7c8691d3e..33a22f654 100644 --- a/apps/native/Sources/VisionNativeUI/DatabaseMaintenanceView.swift +++ b/apps/native/Sources/VisionNativeUI/DatabaseMaintenanceView.swift @@ -138,11 +138,11 @@ public struct DatabaseMaintenanceView: View { .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) + // `sr-only`: read by VoiceOver, not drawn; zero-size, so it cannot wrap into a tall row. + Color.clear + .frame(width: 1, height: 1) + .accessibilityElement() + .accessibilityLabel(strings.t("dbMaintenance.col.actions")) .accessibilityAddTraits(.isHeader) if model.isLoading { ForEach(0..<5, id: \.self) { _ in diff --git a/apps/native/Sources/VisionNativeUI/ImportSidebarViews.swift b/apps/native/Sources/VisionNativeUI/ImportSidebarViews.swift index 86ed12d10..87d68d9c7 100644 --- a/apps/native/Sources/VisionNativeUI/ImportSidebarViews.swift +++ b/apps/native/Sources/VisionNativeUI/ImportSidebarViews.swift @@ -93,20 +93,28 @@ struct ImportExportCard: View { .foregroundStyle(theme.secondaryLabel) .fixedSize(horizontal: false, vertical: true) } - HStack { - Text(strings.t("importPage.exportFilters")) - .font(.headline) - .accessibilityAddTraits(.isHeader) - Spacer() - Button(strings.t(model.showsFilters ? "importPage.hideFilters" : "importPage.showFilters")) { - Task { await model.toggleFilters() } + VStack(alignment: .leading, spacing: 0) { + HStack { + Text(strings.t("importPage.exportFilters")) + .font(.headline) + .accessibilityAddTraits(.isHeader) + Spacer() + Button(strings.t(model.showsFilters ? "importPage.hideFilters" : "importPage.showFilters")) { + Task { await model.toggleFilters() } + } + .buttonStyle(.visionOutline) + .controlSize(.small) } - .buttonStyle(.visionOutline) - .controlSize(.small) - } - if model.showsFilters { - filters - .transition(.opacity.combined(with: .move(edge: .top))) + // The filters slide down from under the heading inside their own clipped + // slot, so the motion never draws them over the heading and description. + VStack(alignment: .leading, spacing: 0) { + if model.showsFilters { + filters + .padding(.top, 16) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .clipped() } HStack(spacing: 8) { ForEach(ExportFormat.allCases) { format in diff --git a/apps/native/Sources/VisionNativeUI/NativeShortcutsHelpView.swift b/apps/native/Sources/VisionNativeUI/NativeShortcutsHelpView.swift index 30b3cbbc6..0e59e27dc 100644 --- a/apps/native/Sources/VisionNativeUI/NativeShortcutsHelpView.swift +++ b/apps/native/Sources/VisionNativeUI/NativeShortcutsHelpView.swift @@ -41,7 +41,7 @@ public struct NativeShortcutsHelpView: View { Text(shell.strings.t("shortcuts.rowMenu")).font(.footnote).foregroundStyle(theme.secondaryLabel) }.padding(.trailing, 8) } - }.visionDialogPadding().frame(width: 448, height: 660).foregroundStyle(theme.foreground) + }.visionDialogPadding().visionDialogFrame(width: 448, height: 660).foregroundStyle(theme.foreground) } private func heading(_ key: String) -> some View { Text(shell.strings.t(key)).font(.footnote.weight(.semibold)).foregroundStyle(theme.secondaryLabel) diff --git a/apps/native/Sources/VisionNativeUI/PortfolioHoldingsView.swift b/apps/native/Sources/VisionNativeUI/PortfolioHoldingsView.swift index 0795db5ef..33e354a86 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioHoldingsView.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioHoldingsView.swift @@ -26,7 +26,7 @@ public struct PortfolioHoldingsView: View { private var symbol: String { switch assetClass { case .crypto: "bitcoinsign.circle" - case .metals: "diamond" + case .metals: VisionSymbol.goldBar case .realEstate: "building.2" case .savings, .bond: "banknote" default: "chart.line.uptrend.xyaxis" @@ -320,13 +320,19 @@ public struct PortfolioHoldingsView: View { cell: { holding in AnyView( HStack(spacing: 10) { - // `rounded-full bg-primary/12 text-primary`, 32 pt, with the page icon. - Image(systemName: symbol) - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(theme.primary) - .frame(width: 32, height: 32) - .background(theme.primary.opacity(0.12), in: Circle()) - .accessibilityHidden(true) + // The company or coin logo when there is one; otherwise `rounded-full + // bg-primary/12 text-primary`, 32 pt, with the page icon. + AssetLogo( + url: shell.settings.assetLogos + ? AssetLogoSource.url(symbol: holding.symbol, assetClass: holding.assetClass) : nil, + plate: holding.assetClass != .crypto + ) { + VisionSymbol(symbol, size: 14, weight: .medium) + .foregroundStyle(theme.primary) + .frame(width: 32, height: 32) + .background(theme.primary.opacity(0.12), in: Circle()) + } + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { if let symbol = holding.symbol { Button(symbol) { diff --git a/apps/native/Sources/VisionNativeUI/PortfolioInvestmentForm.swift b/apps/native/Sources/VisionNativeUI/PortfolioInvestmentForm.swift index f6c15a90d..137f1538a 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioInvestmentForm.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioInvestmentForm.swift @@ -294,8 +294,7 @@ private struct PortfolioAssetTypeTile: View { let shape = RoundedRectangle(cornerRadius: CGFloat(DesignTokens.Radius.card), style: .continuous) Button(action: action) { VStack(alignment: .leading, spacing: 8) { - Image(systemName: symbol) - .font(.system(size: 20)).foregroundStyle(theme.primary).accessibilityHidden(true) + VisionSymbol(symbol, size: 20).foregroundStyle(theme.primary).accessibilityHidden(true) .frame(width: 40, height: 40) .background( RoundedRectangle(cornerRadius: VisionControlMetrics.radius, style: .continuous) diff --git a/apps/native/Sources/VisionNativeUI/PortfolioOverviewView.swift b/apps/native/Sources/VisionNativeUI/PortfolioOverviewView.swift index 7c9bc7c91..b4aafa294 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioOverviewView.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioOverviewView.swift @@ -400,9 +400,14 @@ public struct PortfolioOverviewView: View { Task { await model.openDetail(id: holding.id) } } label: { HStack(spacing: 14) { - Image(systemName: PortfolioOverviewAsset.symbol(holding.assetClass)).foregroundStyle( - theme.primary - ) + AssetLogo( + url: shell.settings.assetLogos + ? AssetLogoSource.url(symbol: holding.symbol, assetClass: holding.assetClass) : nil, size: 26, + plate: holding.assetClass != .crypto + ) { + VisionSymbol(PortfolioOverviewAsset.symbol(holding.assetClass), size: 13) + .foregroundStyle(theme.primary) + } .frame(width: 32).accessibilityHidden(true) VStack(alignment: .leading, spacing: 5) { HStack { @@ -629,7 +634,7 @@ enum PortfolioOverviewAsset { static func symbol(_ asset: OpenAPI.AssetClass) -> String { switch asset { case .crypto: "bitcoinsign.circle" - case .metals: "diamond" + case .metals: VisionSymbol.goldBar case .realEstate: "building.2" case .savings, .bond: "banknote" default: "chart.line.uptrend.xyaxis" diff --git a/apps/native/Sources/VisionNativeUI/ProviderHealthView.swift b/apps/native/Sources/VisionNativeUI/ProviderHealthView.swift index d6a0342e6..c7e6d31e4 100644 --- a/apps/native/Sources/VisionNativeUI/ProviderHealthView.swift +++ b/apps/native/Sources/VisionNativeUI/ProviderHealthView.swift @@ -62,11 +62,12 @@ public struct ProviderHealthView: View { 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) + // `sr-only`: read by VoiceOver, not drawn. A zero-size element: a hidden text in a + // 1 pt column wrapped one letter per line and made the header row several lines tall. + Color.clear + .frame(width: 1, height: 1) + .accessibilityElement() + .accessibilityLabel(strings.t("dbMaintenance.col.actions")) .accessibilityAddTraits(.isHeader) if model.providers.isLoading { ForEach(0..<7, id: \.self) { _ in @@ -89,7 +90,8 @@ public struct ProviderHealthView: View { Text(title) .font(.caption.weight(.medium)) .foregroundStyle(theme.secondaryLabel) - .padding(.vertical, 8) + // `TableHead` `h-10`. + .padding(.vertical, 12) .accessibilityAddTraits(.isHeader) } @@ -101,7 +103,8 @@ public struct ProviderHealthView: View { .accessibilityHidden(true) Text(row.label).fontWeight(.medium) } - .padding(.vertical, 6) + // `TableCell` `py-3`: the row's height comes from its tallest cell. + .padding(.vertical, 12) VisionBadge(text: row.kind, tone: .muted, small: true) Text(row.lastSuccess) .foregroundStyle(theme.secondaryLabel) diff --git a/apps/native/Sources/VisionNativeUI/ResearchWatchlistAddSheet.swift b/apps/native/Sources/VisionNativeUI/ResearchWatchlistAddSheet.swift index 4450d9c16..dc5aea409 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchWatchlistAddSheet.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchWatchlistAddSheet.swift @@ -132,7 +132,7 @@ public struct ResearchWatchlistAddSheet: View { Spacer(minLength: 200) } } - }.visionDialogPadding().frame(width: 448, height: model.selected == nil ? 540 : 560) + }.visionDialogPadding().visionDialogFrame(width: 448, height: model.selected == nil ? 540 : 560) .visionInteractiveDismissDisabled(model.isSubmitting) .onChange(of: model.validationRevision) { _, _ in targetFocused = true } } diff --git a/apps/native/Sources/VisionNativeUI/ResearchWatchlistChartSheet.swift b/apps/native/Sources/VisionNativeUI/ResearchWatchlistChartSheet.swift index bf155b662..5dc92f31a 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchWatchlistChartSheet.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchWatchlistChartSheet.swift @@ -61,7 +61,7 @@ struct ResearchWatchlistChartSheet: View { } } } - }.padding(24).frame(width: 760, height: 640) + }.padding(24).visionDialogFrame(width: 760, height: 640) .onChange(of: model.isEditingTarget) { _, editing in if editing { targetFocused = true } } .onChange(of: model.validationRevision) { _, _ in targetFocused = true } } diff --git a/apps/native/Sources/VisionNativeUI/ResearchWatchlistView.swift b/apps/native/Sources/VisionNativeUI/ResearchWatchlistView.swift index 1bbbe24f6..1f161d835 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchWatchlistView.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchWatchlistView.swift @@ -113,9 +113,10 @@ private struct ResearchWatchlistRow: View { private var metric: ResearchWatchlistMetrics? { model.metrics[item.id.rawValue] } var body: some View { HStack(alignment: .top, spacing: 14) { - Image( - systemName: item.assetClass == "crypto" - ? "bitcoinsign.circle" : item.assetClass == "metals" ? "diamond" : "chart.line.uptrend.xyaxis" + VisionSymbol( + item.assetClass == "crypto" + ? "bitcoinsign.circle" : item.assetClass == "metals" ? VisionSymbol.goldBar : "chart.line.uptrend.xyaxis", + size: 13 ) .foregroundStyle(theme.secondaryLabel).frame(width: 24).padding(.top, 4).accessibilityHidden(true) ViewThatFits(in: .horizontal) { diff --git a/apps/native/Sources/VisionNativeUI/Resources/Localizable.xcstrings b/apps/native/Sources/VisionNativeUI/Resources/Localizable.xcstrings index 3c85a912a..363a78b9e 100644 --- a/apps/native/Sources/VisionNativeUI/Resources/Localizable.xcstrings +++ b/apps/native/Sources/VisionNativeUI/Resources/Localizable.xcstrings @@ -61741,6 +61741,42 @@ } } }, + "settings.appearance.assetLogos": { + "comment": "Generated from i18n/source by scripts/native-strings.ts.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Company and coin logos" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Bedrijfs- en muntlogo's" + } + } + } + }, + "settings.appearance.assetLogosHint": { + "comment": "Generated from i18n/source by scripts/native-strings.ts.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows logos on the asset pages. Vision loads them by ticker from financialmodelingprep.com (stocks and ETFs) and cdn.jsdelivr.net (crypto); no amounts or other data are sent. Off by default." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Toont logo's op de activapagina's. Vision laadt ze op ticker van financialmodelingprep.com (aandelen en etf's) en cdn.jsdelivr.net (crypto); bedragen of andere gegevens worden niet verstuurd. Standaard uit." + } + } + } + }, "settings.appearance.autoAdaptDisplay": { "comment": "Generated from i18n/source by scripts/native-strings.ts.", "extractionState": "manual", diff --git a/apps/native/Sources/VisionNativeUI/SavedChartsView.swift b/apps/native/Sources/VisionNativeUI/SavedChartsView.swift index 0988cb276..29bdf4a86 100644 --- a/apps/native/Sources/VisionNativeUI/SavedChartsView.swift +++ b/apps/native/Sources/VisionNativeUI/SavedChartsView.swift @@ -310,7 +310,7 @@ struct CustomChartBuilderSheet: View { .buttonStyle(.visionPrimary).keyboardShortcut(.defaultAction).disabled( !draft.canSave || model.isSaving) } - }.visionDialogPadding().frame(width: 896, height: 680).visionInteractiveDismissDisabled(model.isSaving) + }.visionDialogPadding().visionDialogFrame(width: 896, height: 740).visionInteractiveDismissDisabled(model.isSaving) .task(id: model.draft) { await model.refreshPreview() } } /// The web `Label`: `type-body font-medium text-label-primary` above its control. diff --git a/apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift b/apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift index 147867a6a..1341c46dc 100644 --- a/apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift +++ b/apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift @@ -128,6 +128,20 @@ struct NativeSettingsAppearanceView: View { ).frame(width: 200) } } + NativeSettingsGroup(title: strings.t("nav.portfolio")) { + NativeSettingsRow( + title: strings.t("settings.appearance.assetLogos"), + description: strings.t("settings.appearance.assetLogosHint") + ) { + Toggle( + strings.t("settings.appearance.assetLogos"), + isOn: Binding( + get: { model.appBool("assetLogos", fallback: false) }, + set: { model.updateApp(["assetLogos": .bool($0)]) }) + ) + .labelsHidden().toggleStyle(.visionSwitchLabelless) + } + } } } private var gainLossOptions: [VisionSelectOption] { diff --git a/apps/native/Sources/VisionNativeUI/ShellRootView.swift b/apps/native/Sources/VisionNativeUI/ShellRootView.swift index d308c82be..6881a5502 100644 --- a/apps/native/Sources/VisionNativeUI/ShellRootView.swift +++ b/apps/native/Sources/VisionNativeUI/ShellRootView.swift @@ -60,6 +60,7 @@ public struct ShellRootView: View { if !model.isSidebarVisible { // The rail's first button starts below the lowered traffic lights (bottom edge 34 pt). NativeSidebarRail(model: model, topInset: max(0, 48 - titlebarSafeInset)) + .modifier(PageModalSidebarScrim(active: pageModalOpen)) .environment(\.colorScheme, theme.colorScheme) } pageColumn(theme) @@ -80,9 +81,13 @@ public struct ShellRootView: View { // `.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. + // + // It is neither frozen nor clipped to the column: the sidebar floats over the detail + // column, which reaches under it through a leading safe-area inset. Clipped to the + // page's frame, the strip under the sidebar was empty window background, which + // showed as a grey box while the sidebar slid in over it. if isNativeDetail { - FrozenWidthLayout(width: frozenDetailWidth) { DetailGlassBackground() } - .clipShape(ColumnSidewaysClip()) + DetailGlassBackground() } if case .native(let screen) = model.detail { FrozenWidthLayout(width: frozenDetailWidth) { @@ -145,6 +150,9 @@ public struct ShellRootView: View { model: model, height: NativeTopbar.barHeight, veiled: pageVeiled, scrolled: pageScrolled, pageTitle: pageTitle) .ignoresSafeArea(.container, edges: .top) + // A page's dialog or sheet runs up to the window's top edge, as the web overlay + // covers the topbar; the bar sits above the page, so it steps aside meanwhile. + .modifier(PageModalTopbarHiding(active: pageModalOpen)) } } .onChange(of: model.route.path) { @@ -686,6 +694,23 @@ private struct PageModalSidebarScrim: ViewModifier { } } +/// Hides the topbar while a page shows a dialog or sheet: the panel and its scrim run up to the +/// window's top edge underneath it. +private struct PageModalTopbarHiding: ViewModifier { + let active: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + content + .opacity(active ? 0 : 1) + .allowsHitTesting(!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`. diff --git a/apps/native/Sources/VisionNativeUI/StatisticsView.swift b/apps/native/Sources/VisionNativeUI/StatisticsView.swift index f93818149..4c6a5805c 100644 --- a/apps/native/Sources/VisionNativeUI/StatisticsView.swift +++ b/apps/native/Sources/VisionNativeUI/StatisticsView.swift @@ -677,6 +677,10 @@ public struct StatisticsView: View { } } .bodyMaxHeight(700) + // A double click opens the payee's transactions, as the pivot cells drill into theirs. + .rowDoubleClicked { row in + shell.navigate(to: InsightsRoutes.drill(recipientId: row.recipientId, label: row.name).string) + } } } /// The "#" column: the row's position in the list, not sortable or filterable. diff --git a/apps/native/Sources/VisionNativeUI/TransactionsView.swift b/apps/native/Sources/VisionNativeUI/TransactionsView.swift index 8589b2c31..8ba31388a 100644 --- a/apps/native/Sources/VisionNativeUI/TransactionsView.swift +++ b/apps/native/Sources/VisionNativeUI/TransactionsView.swift @@ -674,12 +674,15 @@ struct TransactionQuickLookSheet: View { .frame(maxWidth: .infinity) } .visionDialogPadding() - .frame(minWidth: 384, idealWidth: 384) - .focusable() - .focusEffectDisabled() - .onKeyPress(.space) { - model.closeQuickLook() - return .handled + .frame(width: 384) + // Space closes from wherever focus is: the dialog panel takes focus when it opens, so a + // key handler on this view alone never saw the key. + .background { + Button(strings.t("common.close")) { model.closeQuickLook() } + .keyboardShortcut(.space, modifiers: []) + .opacity(0) + .frame(width: 0, height: 0) + .accessibilityHidden(true) } } } diff --git a/apps/native/Tests/VisionNativeCoreTests/AssetLogoSourceTests.swift b/apps/native/Tests/VisionNativeCoreTests/AssetLogoSourceTests.swift new file mode 100644 index 000000000..53cac94bd --- /dev/null +++ b/apps/native/Tests/VisionNativeCoreTests/AssetLogoSourceTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing + +@testable import VisionNativeCore + +struct AssetLogoSourceTests { + @Test func stocksAndETFsUseTheCompanyLogoByTicker() { + #expect( + AssetLogoSource.url(symbol: " aapl ", assetClass: .stock)?.absoluteString + == "https://financialmodelingprep.com/image-stock/AAPL.png") + #expect( + AssetLogoSource.url(symbol: "VWCE.DE", assetClass: .etf)?.absoluteString + == "https://financialmodelingprep.com/image-stock/VWCE.DE.png") + } + + @Test func cryptoUsesTheCoinIconWhateverTheQuote() { + for symbol in ["BTC", "btc", "BTC-USD", "BTC/EUR", "BTCUSDT"] { + #expect( + AssetLogoSource.url(symbol: symbol, assetClass: .crypto)?.absoluteString + == "https://cdn.jsdelivr.net/npm/cryptocurrency-icons@0.18.1/128/color/btc.png") + } + // A stablecoin on its own is the coin, not an empty ticker. + #expect(AssetLogoSource.url(symbol: "USDT", assetClass: .crypto)?.lastPathComponent == "usdt.png") + } + + @Test func noLogoWithoutAUsableTickerOrForOtherClasses() { + #expect(AssetLogoSource.url(symbol: nil, assetClass: .stock) == nil) + #expect(AssetLogoSource.url(symbol: " ", assetClass: .stock) == nil) + #expect(AssetLogoSource.url(symbol: "A/B?x", assetClass: .stock) == nil) + #expect(AssetLogoSource.url(symbol: "XAU", assetClass: .metals) == nil) + #expect(AssetLogoSource.url(symbol: "KBC", assetClass: .savings) == nil) + } +} diff --git a/apps/native/Tests/VisionNativeCoreTests/InsightsTests.swift b/apps/native/Tests/VisionNativeCoreTests/InsightsTests.swift index 6cf467781..d703b843d 100644 --- a/apps/native/Tests/VisionNativeCoreTests/InsightsTests.swift +++ b/apps/native/Tests/VisionNativeCoreTests/InsightsTests.swift @@ -110,6 +110,14 @@ struct InsightsTests { #expect(NativeRoute(route.string)?.value("filter_label") == "Food & cafés") } + @Test func payeeDrillOpensTransactionsFilteredToThePayee() throws { + let route = try #require(NativeRoute(InsightsRoutes.drill(recipientId: 42, label: "Delhaize").string)) + #expect(route.path == "/transactions") + #expect(route.value("recipient_id") == "42") + #expect(route.value("filter_label") == "Delhaize") + #expect(TransactionFilters(route: route).recipientId == 42) + } + @Test func homeIncomeAndSpendingDrillsCarryTheirCardLabel() throws { let month = try insightFixture(InsightMonthlySummary.self, insightMonthsJSON).months[0] for (type, label) in [("income", "Latest month's income"), ("expense", "Latest month's spending")] { diff --git a/apps/native/Tests/VisionNativeCoreTests/NativeFoundationsTests.swift b/apps/native/Tests/VisionNativeCoreTests/NativeFoundationsTests.swift index 339273429..9aa99a73a 100644 --- a/apps/native/Tests/VisionNativeCoreTests/NativeFoundationsTests.swift +++ b/apps/native/Tests/VisionNativeCoreTests/NativeFoundationsTests.swift @@ -21,6 +21,13 @@ import Testing #expect(NativeAppSettings.migrate(nil) == .defaults) } + @Test func assetLogosStayOffUntilTheUserTurnsThemOn() { + #expect(NativeAppSettings.defaults.assetLogos == false) + #expect(NativeSettingsDefaults.app["assetLogos"] == .bool(false)) + #expect(NativeAppSettings.migrate(.object(["assetLogos": .bool(true)])).assetLogos) + #expect(NativeAppSettings.migrate(.object(["assetLogos": .string("yes")])).assetLogos == false) + } + @Test func numberFormatsUseTheWebLocales() { #expect(NumberFormat.eu.locale.identifier == "de_DE") #expect(NumberFormat.us.locale.identifier == "en_US") diff --git a/apps/native/Tests/VisionNativeUITests/NativeOverlayMetricsTests.swift b/apps/native/Tests/VisionNativeUITests/NativeOverlayMetricsTests.swift index ad478962a..22c292bff 100644 --- a/apps/native/Tests/VisionNativeUITests/NativeOverlayMetricsTests.swift +++ b/apps/native/Tests/VisionNativeUITests/NativeOverlayMetricsTests.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import Testing import VisionNativeCore @@ -27,6 +28,38 @@ struct NativeOverlayMetricsTests { #expect(palette.height == .fitContent) #expect(palette.padding == 0) } + @Test func contentSizedDialogsTakeTheContentSizeUpToTheWindowAllowance() { + let limit = CGSize(width: 1052, height: 684) + let fits = VisionDialogFit.size(ideal: CGSize(width: 448, height: 300), heightAtWidth: { _ in 300 }, limit: limit) + #expect(fits == CGSize(width: 448, height: 300)) + let tall = VisionDialogFit.size(ideal: CGSize(width: 896, height: 900), heightAtWidth: { _ in 900 }, limit: limit) + #expect(tall == CGSize(width: 896, height: 684)) + // Unbounded ideal sizes (a flexible frame without an ideal) stop at the allowance. + let flexible = VisionDialogFit.size( + ideal: CGSize(width: CGFloat.infinity, height: 200), heightAtWidth: { _ in .infinity }, limit: limit) + #expect(flexible == limit) + } + + /// The panel hugs its content: no band of empty glass around a short body (the chart + /// builder, Customize and Mark as filed dialogs were 90 % of the window tall), and a long + /// body stops at the window allowance with its preferred height given up. + @MainActor @Test func contentSizedPanelsHugTheirContent() { + let available = CGSize(width: 1100, height: 760) + let metrics = VisionDialogMetrics(maxWidth: nil, padding: 0, spacing: 0) + func panelSize(_ content: Content) -> CGSize { + let host = NSHostingView(rootView: VisionGlassDialog(metrics: metrics, available: available) { content }) + host.frame = NSRect(origin: .zero, size: available) + host.layoutSubtreeIfNeeded() + return host.fittingSize + } + let short = panelSize(Color.clear.frame(width: 448, height: 300)) + #expect(abs(short.width - 448) <= 1 && abs(short.height - 300) <= 1) + let allowance = (available.height * CGFloat(DesignTokens.Overlay.dialogMaxHeightFraction)).rounded() + let tall = panelSize(Color.clear.visionDialogFrame(width: 760, height: 1200)) + #expect(abs(tall.width - 760) <= 1) + #expect(abs(tall.height - allowance) <= 1) + } + @Test func settingsRailRowsAre42PointsTall() { #expect(NativeSettingsWindow.Layout.railRowHeight == 42) } diff --git a/apps/native/i18n-keys.txt b/apps/native/i18n-keys.txt index 6983c4c41..4ac191bdb 100644 --- a/apps/native/i18n-keys.txt +++ b/apps/native/i18n-keys.txt @@ -4267,6 +4267,8 @@ settings.appearance.autoAdaptDisplay settings.appearance.autoAdaptDisplayHint settings.appearance.fullEffects settings.appearance.fullEffectsHint +settings.appearance.assetLogos +settings.appearance.assetLogosHint settings.appearance.darkFrom settings.appearance.gainLossColors settings.appearance.gainLossColors.classic diff --git a/docs/architecture/native-app.md b/docs/architecture/native-app.md index c0ae38ac6..18d816e5a 100644 --- a/docs/architecture/native-app.md +++ b/docs/architecture/native-app.md @@ -2,8 +2,8 @@ title: Vision Native App Architecture type: architecture-doc status: active -date: 2026-11-14 -updated: 2026-11-07 +date: 2026-11-15 +updated: 2026-11-15 tags: [ architecture, @@ -818,7 +818,15 @@ the app's appearance; native menus follow the system. dialogs and side sheets are centred glass panels inside the window (`.visionDialog`, `.visionAlert`, `.visionConfirmationDialog`, `.visionSideSheet`), not system sheets; the scrim is frosted, and the content under it blurred, only under Full effects or the Enhanced tier. Inside -them `\.dismiss` does nothing, so callers pass `onClose` or read `visionDismissDialog`. Controls +them `\.dismiss` does nothing, so callers pass `onClose` or read `visionDismissDialog`. A page's +panel is placed in the page's own width, never across the leading safe-area inset under the +floating sidebar, and runs up to the window's top edge while the topbar steps aside +(`PageModalTopbarHiding`); the sidebar and the collapsed rail take the scrim +(`PageModalSidebarScrim`). A dialog without a `maxWidth` takes its content's ideal size, capped at +the window less margins and `Overlay.dialogMaxHeightFraction` (`VisionDialogFit`, +`VisionDialogContentFit`), so the card hugs its content; bodies with a preferred height use +`visionDialogFrame(width:height:)` so a short window shrinks their scroll area instead of +clipping them. Controls take their sizes from `VisionControlMetrics`, and Inter and Fraunces are bundled and registered by `VisionFonts`. @@ -1255,6 +1263,15 @@ present above the detail without losing its selection; closing a successful arch an active detail. Dirty drafts register the native leave/quit guard. Explicit Cancel resets the form; Escape or the sheet close keeps applicable edit/trade drafts, matching the web. +With Settings › Appearance › Company and coin logos on (`assetLogos` in `app_settings`, off by +default; Swift app only), the asset pages and the overview's holdings list show a company or coin +logo where one exists (`AssetLogoSource`, `AssetLogo`): Financial Modeling Prep's public `image-stock` +images by ticker for stocks and ETFs, and the pinned `cryptocurrency-icons` 0.18.1 PNGs on jsDelivr +for crypto. Only the ticker is sent. Each address is fetched once per session; a failure, an +unknown ticker or no network keeps the asset-class glyph. Metals use Vision's own gold-bar glyph +(`VisionSymbol.goldBar`) in place of the web's gem on the asset pages and tiles; the sidebar keeps +the generated navigation symbol. + `PortfolioModel` consumes typed Decimal responses for canonical active summaries, performance, broker partitions, exposure and prices. Currency/period revisions reject late reads. The eleven widget choices preserve other pages and forward the complete original CAS baseline. Portfolio @@ -1875,7 +1892,9 @@ The sidebar header shows the Vision logo button (`NativeSidebar.brandButton` in `ShellRootView` in the transparent titlebar strip over native detail screens: the sidebar trigger and search button while the sidebar is hidden, and the page title once the page scrolls past 96 pt (`PageScrollObserver`, reset on route change). It takes no clicks outside its buttons, so the strip -still drags the window. `DeltaPill` (`Components/DeltaPill.swift`) is the shared change chip (tinted +still drags the window. The page canvas (`DetailGlassBackground`) is not clipped to the page: it +runs under the floating sidebar, so no empty strip shows while the sidebar slides in or out. +`DeltaPill` (`Components/DeltaPill.swift`) is the shared change chip (tinted capsule with a direction arrow, `invert` for spending); the Portfolio holdings, Research and Statistics/Tax chart views use it in place of ad hoc chips. The Sankey layout of the Statistics flow is `SankeyLayout`, a port of the in-repo web layout that replaced d3-sankey diff --git a/docs/i18n/translations.md b/docs/i18n/translations.md index 8bd72376c..c5e4a8a88 100644 --- a/docs/i18n/translations.md +++ b/docs/i18n/translations.md @@ -2,8 +2,8 @@ title: Translations & i18n type: i18n status: active -date: 2026-10-28 -updated: 2026-10-28 +date: 2026-11-15 +updated: 2026-11-15 tags: [i18n, translations, localization, internationalization, phase-6, phase-8, phase-f, phase-9, phase-c, phase-d, phase-2, splits, settlement, admin, observability, cash-flow-forecast, pdf-export, portfolio, tax, backup, encrypt, passphrase-modal, accessibility, aria-label, bug-hunt-2026-05-06, chart-aria, screen-reader, plural, tc, intl-plural-rules, planned-page, toast, electron-native, menu, system-accent, splash, upcoming-count, electron-error-page, backend-watchdog, visual-effects-tiers, auto-adapt-display, colorblind, gain-loss, june-2026, combobox-tags, tag-filter-combobox, validate-locales, source-key-usage, placeholder-bug-fix, url-state, destructive-confirm] description: Internationalization system including supported languages, translation workflow, and usage patterns. Phase 6 adds 32 export keys for PDF report localization. Phase 8 adds 11 additional export.section.* keys for portfolio (6) and tax (7) report sections. Phase C adds 15 cash flow forecast keys. Phase F adds 60 admin observability keys. 2026-05-29 adds 16 chart.aria.* keys (localized chart screen-reader summaries) and 21 aria.* keys (localized icon-button aria-labels). June 2026 adds tc() plural mechanism and plannedPage error-toast keys. June 2026 (ADR-070) adds 5 commandPalette.* keys (en + nl) for the new ⌘K command palette. June 2026 Premium v3 (ADR-071) adds 8 keys: settings.general.enhancedEffects/Hint, shortcuts.title/showHelp/closeDialog/chartScrub, commandPalette.recent/searchTransactions. June 2026 Premium v3 V5-V7 adds 14 keys: contextMenu.* (8 keys), quickLook.* (2 keys), shortcuts table-interaction additions (4 keys). June 2026 V12 (ADR-072) adds 11 keys: menu.edit/file/go/importCsv/keyboardShortcuts/newTransaction/settings/toggleSidebar/view, settings.appearance.systemAccent/systemAccentHint. June 2026 V11 adds 4 keys: dashboard.suggestions, dashboard.widgetDescriptions.suggestions, suggestions.kicker, suggestions.review. June 2026 (startup/UI fixes) adds 5 splash.* keys (en + nl, Electron boot splash narration) + tc()-plural upcoming.count.one/.other; removes upcoming.countSingle/countPlural. 2026-06-11 adds 5 app.* keys (Electron error page + backend-lost watchdog, en + nl). 2026-06-12 (ADR-075) adds 7 settings.appearance.visualEffects*/autoAdaptDisplay* keys; removes settings.general.enhancedEffects + settings.general.enhancedEffectsHint. ADR-075 addendum (same day) adds 2 more contextual-note keys (visualEffectsAutoNote + visualEffectsOverrideNote). 2026-06-24 adds 5 Accessibility group keys (settings.group.accessibility, settings.appearance.gainLossColors, settings.appearance.gainLossColorsHint, settings.appearance.gainLossColors.colorblind, settings.appearance.gainLossColors.classic). 2026-06-26 adds 3 combobox.tags.* keys (combobox.tags.empty, combobox.tags.nSelected, combobox.tags.search) for TagFilterCombobox i18n (bulk-tag and filter-toolbar combobox). 2026-06-26 — validate-locales gains source key-usage checks (key-existence, dropped-vars, value-shape); closes 10 missing keys and fixes placeholder mismatches. 2026-08-10 (PR #156) adds 11 keys: txPage.loadMoreFailed/loadMoreFailedDesc/deleteAttachmentError (3), watchlist.removeTitle/removeDesc/removeConfirm (3), research.mapping.removeDesc, importReview.recipientPickerLabel, dbEditor.discardNewRow/nextPage/prevPage (3). 2026-10-05 (trust fixes) adds 30 keys — settings.app.resetAllConfirm.* (3), plannedPage.toast.* (6), categories.deleted, plannedPage.loanTerm.one/.other, cashflow.methodError, aria.filterColumn/removeTag/deleteAttachment/openReleaseNotes (4) and tags.color.* (13) — removes form.addTransaction.amountPlaceholder; changes txPage.delete.desc, settings.app.resetAllHint and the English Uncategorized spelling. The same day's unused-key cleanup removes 21 keys. 2026-10-06 (ADR-182 copy pass) rewrites 829 English and 507 Dutch values (sentence case, payee, excluded, income tax, plain words); adds categories.createButton, recipients.createButton, filter.recipientUnknown, txPage.searchPlaceholder, txPage.view.showExcluded and the tc() pairs bankWidget.acrossAccounts.one/.other, tax.dividendConventionIncomplete.one/.other, merge.mergeCount.one/.other and splitDialog.alreadySplit.one/.other; removes exclusion.noExclusions, exclusion.tooltipNone, the unused common.create and the four replaced singular keys; tax.page.subtitle gains {year}. 2026-10-08 (bulk split and the 0/100 preset) adds 18 keys per locale: splitDialog.othersPayAll, splitDialog.othersPayAllSummary.one/.other, splitDialog.owes, splits.bulkCreated.one/.other, splits.bulkCreateFailed, splits.bulkNothingSplit, splits.bulkSkippedAlreadySplit.one/.other, splits.bulkSkippedOther.one/.other and txPage.bulk.split/splitConfirm/splitDesc/splitEqualHint/splitFullHint/splitTitle; txPage.bulk.tagSelectionHint now also covers split. Total key count last verified 2026-10-08 (4694 per locale); run `bun run validate-locales` (see [[docs/reference/scripts|Scripts Reference]]) for a current count. aliases: [i18n, translations, localization, language, nl, en, dutch, english] @@ -501,7 +501,7 @@ Code links: [[packaging/electron/main.ts]], [[i18n/source/en.json]], [[i18n/sour | `suggestions.review` | "Review" | > [!note] 2026-08-27 — SuggestionCard residue removed -> `SuggestionCard` was removed as part of the upcoming-payments banner unification. Its four orphaned keys were removed in the fixed-point unused-key cleanup. `bun run validate-locales` now fails when an exact source key has no static or dynamic-prefix consumer. +> `SuggestionCard` was removed as part of the upcoming-payments banner unification. Its four orphaned keys were removed in the fixed-point unused-key cleanup. `bun run validate-locales` now fails when an exact source key has no static or dynamic-prefix consumer. Swift sources under `apps/native/` count as consumers (2026-11-15), so a key only the Swift app uses, such as `settings.appearance.assetLogos`, passes. > [!note] No new keys for V8, V9, V10 > V8 (icon bounce) and V9 (sparkline scrub) are purely CSS/JS with no user-visible new strings. V10 (genie dialog exit) is also CSS/JS-only. diff --git a/i18n/source/en.json b/i18n/source/en.json index af43e49c3..131d7c9c4 100644 --- a/i18n/source/en.json +++ b/i18n/source/en.json @@ -3457,6 +3457,8 @@ "settings.app.updatesHintWeb": "Update this deployment the same way it was installed. Any data upgrades run automatically on restart.", "settings.app.version": "Version {version}", "settings.app.versionAvailable": "Version {version} is available", + "settings.appearance.assetLogos": "Company and coin logos", + "settings.appearance.assetLogosHint": "Shows logos on the asset pages. Vision loads them by ticker from financialmodelingprep.com (stocks and ETFs) and cdn.jsdelivr.net (crypto); no amounts or other data are sent. Off by default.", "settings.appearance.autoAdaptDisplay": "Reduce effects on large displays", "settings.appearance.autoAdaptDisplayHint": "Automatically switches to Reduced while the window is on a high-resolution external display such as a 4K TV, and back when it leaves. The built-in screen never counts as large.", "settings.appearance.darkFrom": "Dark from", diff --git a/i18n/source/nl.json b/i18n/source/nl.json index 3126c0704..815adf0e2 100644 --- a/i18n/source/nl.json +++ b/i18n/source/nl.json @@ -3457,6 +3457,8 @@ "settings.app.updatesHintWeb": "Werk deze installatie bij op dezelfde manier als ze is geïnstalleerd. Eventuele gegevensupgrades draaien automatisch bij herstart.", "settings.app.version": "Versie {version}", "settings.app.versionAvailable": "Versie {version} is beschikbaar", + "settings.appearance.assetLogos": "Bedrijfs- en muntlogo's", + "settings.appearance.assetLogosHint": "Toont logo's op de activapagina's. Vision laadt ze op ticker van financialmodelingprep.com (aandelen en etf's) en cdn.jsdelivr.net (crypto); bedragen of andere gegevens worden niet verstuurd. Standaard uit.", "settings.appearance.autoAdaptDisplay": "Effecten verminderen op grote schermen", "settings.appearance.autoAdaptDisplayHint": "Schakelt automatisch naar Verminderd zolang het venster op een extern scherm met hoge resolutie staat, zoals een 4K-tv, en weer terug zodra het dat verlaat. Het ingebouwde scherm telt nooit als groot.", "settings.appearance.darkFrom": "Donker vanaf", diff --git a/scripts/validate-locales.ts b/scripts/validate-locales.ts index 88ee90c07..604bba1d8 100644 --- a/scripts/validate-locales.ts +++ b/scripts/validate-locales.ts @@ -410,7 +410,9 @@ if (fs.existsSync(frontendSrc)) { // keeping ambiguous keys; the validator never deletes locale data itself. // --------------------------------------------------------------------------- const USAGE_SCAN_DIRS = ["apps", "packaging", "scripts", "packages", "config"]; -const USAGE_EXTS = /\.(ts|tsx|js|jsx|cjs|mjs|json|html|sh|mako|yml|yaml)$/; +// `swift`: the Swift app (apps/native) reads the same keys through its String Catalog. +const USAGE_EXTS = + /\.(ts|tsx|js|jsx|cjs|mjs|json|html|sh|mako|yml|yaml|swift)$/; // Prefixes for keys consumed dynamically outside t()/tc() (e.g. the electron shell). const DYNAMIC_KEY_ALLOWLIST = ["app."]; // A dotted-key prefix ending at a dot boundary (e.g. "accounts.type.").