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
116 changes: 60 additions & 56 deletions apps/native/Sources/VisionNativeUI/AccountDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -336,27 +336,29 @@ struct AccountDetailContent: View {
SkeletonBar(width: nil, height: 40)
.accessibilityLabel(strings.t("common.loading"))
} else {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
GridRow {
Text(strings.t("txPage.col.date"))
Text(strings.t("txPage.field.description"))
.gridColumnAlignment(.leading)
Text(strings.t("txPage.col.amount"))
.gridColumnAlignment(.trailing)
}
.font(.footnote.weight(.medium))
.foregroundStyle(theme.secondaryLabel)
ForEach(model.plannedRows) { row in
Divider()
HorizontalOverflowScroll {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
GridRow {
Text(VisionFormat.appDate(row.date, format: settings.dateFormat))
.monospacedDigit()
Text(row.title ?? strings.t("accounts.detail.unlabelled"))
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
MoneyText(
amount: row.amount, currency: (row.currency ?? account.currency), settings: settings,
signed: true)
Text(strings.t("txPage.col.date"))
Text(strings.t("txPage.field.description"))
.gridColumnAlignment(.leading)
Text(strings.t("txPage.col.amount"))
.gridColumnAlignment(.trailing)
}
.font(.footnote.weight(.medium))
.foregroundStyle(theme.secondaryLabel)
ForEach(model.plannedRows) { row in
Divider()
GridRow {
Text(VisionFormat.appDate(row.date, format: settings.dateFormat))
.monospacedDigit()
Text(row.title ?? strings.t("accounts.detail.unlabelled"))
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
MoneyText(
amount: row.amount, currency: (row.currency ?? account.currency), settings: settings,
signed: true)
}
}
}
}
Expand Down Expand Up @@ -499,46 +501,48 @@ struct AccountLedgerCard: View {
}

private var table: some View {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
GridRow {
Text(strings.t("txPage.col.date"))
Text(strings.t("txPage.field.description"))
Text(strings.t("txPage.col.category"))
Text(strings.t("txPage.col.amount"))
.gridColumnAlignment(.trailing)
Text(strings.t("txPage.field.balance"))
.gridColumnAlignment(.trailing)
}
.font(.footnote.weight(.medium))
.foregroundStyle(theme.secondaryLabel)
ForEach(model.visibleLedger) { row in
Divider()
HorizontalOverflowScroll {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
GridRow {
Text(VisionFormat.appDate(row.date, format: settings.dateFormat))
.monospacedDigit()
VStack(alignment: .leading, spacing: 1) {
Text(row.title ?? strings.t("accounts.detail.unlabelled"))
.lineLimit(1)
if let memo = row.subtitle {
Text(memo)
.font(.footnote)
.foregroundStyle(theme.secondaryLabel)
Text(strings.t("txPage.col.date"))
Text(strings.t("txPage.field.description"))
Text(strings.t("txPage.col.category"))
Text(strings.t("txPage.col.amount"))
.gridColumnAlignment(.trailing)
Text(strings.t("txPage.field.balance"))
.gridColumnAlignment(.trailing)
}
.font(.footnote.weight(.medium))
.foregroundStyle(theme.secondaryLabel)
ForEach(model.visibleLedger) { row in
Divider()
GridRow {
Text(VisionFormat.appDate(row.date, format: settings.dateFormat))
.monospacedDigit()
VStack(alignment: .leading, spacing: 1) {
Text(row.title ?? strings.t("accounts.detail.unlabelled"))
.lineLimit(1)
if let memo = row.subtitle {
Text(memo)
.font(.footnote)
.foregroundStyle(theme.secondaryLabel)
.lineLimit(1)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Text(row.categoryName ?? "—")
.foregroundStyle(theme.secondaryLabel)
.lineLimit(1)
Text(MoneyText.text(row.amount, currency: row.currency, settings: settings, signed: true))
.monospacedDigit()
.foregroundStyle(row.amount < 0 ? theme.loss : (row.amount > 0 ? theme.gain : theme.secondaryLabel))
if let balance = row.runningBalance {
MoneyText(amount: balance, currency: row.currency, settings: settings, signed: false, colored: false)
.foregroundStyle(theme.secondaryLabel)
} else {
Text(verbatim: "—")
.frame(maxWidth: .infinity, alignment: .leading)
Text(row.categoryName ?? "—")
.foregroundStyle(theme.secondaryLabel)
.lineLimit(1)
Text(MoneyText.text(row.amount, currency: row.currency, settings: settings, signed: true))
.monospacedDigit()
.foregroundStyle(row.amount < 0 ? theme.loss : (row.amount > 0 ? theme.gain : theme.secondaryLabel))
if let balance = row.runningBalance {
MoneyText(amount: balance, currency: row.currency, settings: settings, signed: false, colored: false)
.foregroundStyle(theme.secondaryLabel)
} else {
Text(verbatim: "—")
.foregroundStyle(theme.secondaryLabel)
}
}
}
}
Expand Down
19 changes: 12 additions & 7 deletions apps/native/Sources/VisionNativeUI/AccountsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,9 @@ struct AccountHubRow: View {

private var titleLine: some View {
HStack(spacing: 6) {
// ListRow's title: `type-body` (Inter 14, regular), not medium.
Text(label)
.font(.body.weight(.medium))
.font(VisionFonts.font(DesignTokens.Typography.body, relativeTo: .body))
.lineLimit(1)
if !account.isActive {
VisionBadge(text: strings.t("accounts.archived"), tone: .outline, small: true)
Expand All @@ -279,13 +280,16 @@ struct AccountHubRow: View {
}
}

/// ListRow's subtitle line: `type-footnote` (Inter 12).
private static var footnote: Font { VisionFonts.font(DesignTokens.Typography.footnote, relativeTo: .footnote) }

@ViewBuilder
private var subtitle: some View {
if let drift = model.drift(account) {
AccountDriftChip(drift: drift, strings: strings) { model.openReconcile(account) }
}
Text(model.meta(account))
.font(.footnote)
.font(Self.footnote)
.foregroundStyle(theme.secondaryLabel)
.lineLimit(1)
if isPortfolio {
Expand All @@ -297,26 +301,26 @@ struct AccountHubRow: View {
Text(verbatim: "· \(provenance)")
}
}
.font(.footnote)
.font(Self.footnote)
.foregroundStyle(theme.secondaryLabel)
.lineLimit(1)
}
} else if account.computedBalance != nil, let provenance = model.provenance(account) {
Text(provenance)
.font(.footnote)
.font(Self.footnote)
.foregroundStyle(theme.secondaryLabel)
.lineLimit(1)
}
if !isPortfolio && account.balanceIncomplete == true {
Text(strings.t("accounts.balanceIncomplete"))
.font(.footnote)
.font(Self.footnote)
.foregroundStyle(theme.warning)
ForEach(model.excludedParts(account), id: \.currency) { part in
HStack(spacing: 4) {
MoneyText(amount: part.balance, currency: part.currency, settings: settings, signed: false, colored: false)
Text(strings.t("accounts.balanceExcluded"))
}
.font(.footnote)
.font(Self.footnote)
.foregroundStyle(theme.warning)
}
}
Expand Down Expand Up @@ -410,7 +414,8 @@ struct AccountDriftChip: View {

var body: some View {
Button(action: open) {
VisionBadge(text: drift.label, tone: drift.stale ? .warning : .destructive, small: true)
// `badgeVariants({ variant })` without a size: the default `px-2.5`, as on the web.
VisionBadge(text: drift.label, tone: drift.stale ? .warning : .destructive)
}
.buttonStyle(.plain)
.visionTooltip(strings.t(drift.tooltipKey))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ struct AnalysisWorkbenchReportView: View {
VStack(alignment: .leading, spacing: 12) {
errors(raw["errors"]?.arrayValue ?? [])
if let summaries = raw["summaries"]?.objectValue {
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 8) {
ForEach(summaries.keys.sorted(), id: \.self) { id in
GridRow {
Text(model.fieldLabel(id, strings: strings))
Text(verbatim: display(summaries[id])).monospacedDigit().gridColumnAlignment(.trailing)
HorizontalOverflowScroll {
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 8) {
ForEach(summaries.keys.sorted(), id: \.self) { id in
GridRow {
Text(model.fieldLabel(id, strings: strings))
Text(verbatim: display(summaries[id])).monospacedDigit().gridColumnAlignment(.trailing)
}
}
}
}.font(.caption).textSelection(.enabled)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import SwiftUI

/// The web `<Table>` wrapper (`relative w-full overflow-auto`): the table fills the width it is
/// offered and, when its content is wider (a narrow window), keeps its content width and
/// scrolls sideways instead of squeezing its cells into piled-up lines.
///
/// A plain `ScrollView(.horizontal)` proposes no width, so its content would stop filling the
/// card; this layout proposes the larger of the offered width and the content's own width.
struct HorizontalOverflowScroll<Content: View>: View {
@ViewBuilder var content: () -> Content
@State private var availableWidth: CGFloat?

var body: some View {
ScrollView(.horizontal) {
HorizontalOverflowLayout(availableWidth: availableWidth) { content() }
}
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
.frame(maxWidth: .infinity, alignment: .leading)
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in
if availableWidth != width { availableWidth = width }
}
}
}

/// Lays its one child out at `max(offered width, the child's ideal width)`.
struct HorizontalOverflowLayout: Layout {
var availableWidth: CGFloat?

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard let child = subviews.first else { return .zero }
let width = width(proposal: proposal, child: child)
return CGSize(width: width, height: child.sizeThatFits(ProposedViewSize(width: width, height: nil)).height)
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard let child = subviews.first else { return }
child.place(
at: bounds.origin, anchor: .topLeading, proposal: ProposedViewSize(width: bounds.width, height: nil))
}

private func width(proposal: ProposedViewSize, child: LayoutSubview) -> CGFloat {
let ideal = child.sizeThatFits(.unspecified).width
let offered = proposal.width.flatMap { $0.isFinite ? $0 : nil } ?? availableWidth ?? ideal
return max(offered, ideal)
}
}
38 changes: 28 additions & 10 deletions apps/native/Sources/VisionNativeUI/Components/VisionTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import VisionNativeCore
/// against the leading edge. This layout fills the width it is offered, as `w-full` does: each
/// column starts at the widest of its cells and the room left over is shared between the
/// columns in proportion to those widths (CSS automatic table layout). When the content is
/// wider than the card the columns shrink in the same proportion.
/// wider than the card the columns keep their content widths and the table scrolls sideways
/// inside its card, as the web `<Table>`'s `overflow-auto` wrapper does: cells never squeeze
/// into piled-up lines.
///
/// The cells are the layout's direct children in row order, one per column. A child marked
/// with `visionTableSpan()` takes a whole row (a divider, an expanded detail row, an empty
Expand Down Expand Up @@ -35,11 +37,25 @@ struct VisionTable<Content: View>: View {
var rowHover = true
@ViewBuilder var content: () -> Content
@State private var hoverY: CGFloat?
/// The width the table is offered: the scroll view proposes no width to its content.
@State private var availableWidth: CGFloat?
@Environment(\.visionTheme) private var theme

var body: some View {
ScrollView(.horizontal) {
table
}
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
.frame(maxWidth: .infinity, alignment: .leading)
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in
if availableWidth != width { availableWidth = width }
}
}

private var table: some View {
VisionTableLayout(
columns: columns, columnSpacing: columnSpacing, fixedColumns: fixedColumns, hoverY: rowHover ? hoverY : nil
columns: columns, columnSpacing: columnSpacing, fixedColumns: fixedColumns, hoverY: rowHover ? hoverY : nil,
availableWidth: availableWidth
) {
if rowHover {
Rectangle().fill(theme.foreground.opacity(0.03)).allowsHitTesting(false)
Expand All @@ -48,7 +64,6 @@ struct VisionTable<Content: View>: View {
}
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.onContinuousHover { phase in
guard rowHover else { return }
let y: CGFloat?
Expand Down Expand Up @@ -81,6 +96,8 @@ struct VisionTableLayout: Layout {
let fixedColumns: Set<Int>
/// The pointer's y in the table, for the hover band; nil hides it.
var hoverY: CGFloat? = nil
/// The width to fill when the proposal has none (inside the horizontal scroll view).
var availableWidth: CGFloat? = nil

struct Cache {
var width: CGFloat?
Expand Down Expand Up @@ -174,8 +191,8 @@ struct VisionTableLayout: Layout {
}

/// CSS automatic table layout for `available` points: the content widths, plus a share of
/// the spare room in proportion to them (fixed columns keep theirs), or scaled down in
/// proportion when the content is wider.
/// the spare room in proportion to them (fixed columns keep theirs). Content wider than the
/// room keeps its widths: the table overflows and scrolls sideways.
static func columnWidths(ideal: [CGFloat], available: CGFloat?, spacing: CGFloat, fixed: Set<Int>) -> [CGFloat] {
guard let available, !ideal.isEmpty else { return ideal }
let room = available - spacing * CGFloat(ideal.count - 1)
Expand All @@ -194,11 +211,12 @@ struct VisionTableLayout: Layout {
fixed.contains(index) ? width : width + spare * width / flexible
}
}
return ideal.map { $0 * room / total }
return ideal
}

private func plan(width: CGFloat?, subviews: Subviews, cache: inout Cache) -> Plan {
if let plan = cache.plan, cache.width == width { return plan }
let finite = width.flatMap { $0.isFinite ? $0 : nil } ?? availableWidth
if let plan = cache.plan, cache.width == finite { return plan }
let count = max(1, columns.count)
let cells = subviews.indices.filter { !subviews[$0][VisionTableBandKey.self] }
let grouped = Self.rows(spans: cells.map { subviews[$0][VisionTableSpanKey.self] }, columnCount: count)
Expand All @@ -209,9 +227,9 @@ struct VisionTableLayout: Layout {
ideal[column] = max(ideal[column], subviews[index].sizeThatFits(.unspecified).width)
}
}
let finite = width.flatMap { $0.isFinite ? $0 : nil }
let widths = Self.columnWidths(ideal: ideal, available: finite, spacing: columnSpacing, fixed: fixedColumns)
let tableWidth = finite ?? (widths.reduce(0, +) + columnSpacing * CGFloat(count - 1))
let contentWidth = widths.reduce(0, +) + columnSpacing * CGFloat(count - 1)
let tableWidth = max(finite ?? 0, contentWidth)
var rows: [Plan.Row] = []
var top: CGFloat = 0
for row in grouped {
Expand All @@ -232,7 +250,7 @@ struct VisionTableLayout: Layout {
top += height
}
let plan = Plan(columnWidths: widths, rows: rows, size: CGSize(width: tableWidth, height: top))
cache.width = width
cache.width = finite
cache.plan = plan
return plan
}
Expand Down
Loading
Loading