Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3459,6 +3459,8 @@ const en: Record<string, string> = {
'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',
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/src/locales/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/src/locales/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3459,6 +3459,8 @@ const nl: Record<string, string> = {
'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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([]),
Expand Down Expand Up @@ -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),
Expand Down
78 changes: 78 additions & 0 deletions apps/native/Sources/VisionNativeUI/Components/AssetLogo.swift
Original file line number Diff line number Diff line change
@@ -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<Fallback: View>: 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<URL> = []
private var pending: [URL: Task<Data?, Never>] = [:]

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<Data?, Never> {
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -224,12 +225,69 @@ public struct VisionGlassDialog<Content: View>: 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,18 +117,27 @@ struct VisionModalPresenter<Panel: View>: 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ struct VisionPageHeader<Actions: View>: 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)
Expand Down Expand Up @@ -138,8 +137,7 @@ struct VisionEmptyState<Action: View>: 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(
Expand Down
Loading
Loading